ai-obs-cli 0.0.0-stage → 0.5.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.
- package/README.md +666 -2
- package/bin/ai-obs.js +8 -0
- package/dist/adapters/claude-code/analyze.js +189 -0
- package/dist/adapters/claude-code/analyze.js.map +1 -0
- package/dist/adapters/claude-code/detect.js +60 -0
- package/dist/adapters/claude-code/detect.js.map +1 -0
- package/dist/adapters/claude-code/handlers.js +183 -0
- package/dist/adapters/claude-code/handlers.js.map +1 -0
- package/dist/adapters/claude-code/index.js +55 -0
- package/dist/adapters/claude-code/index.js.map +1 -0
- package/dist/adapters/claude-code/run-links.js +53 -0
- package/dist/adapters/claude-code/run-links.js.map +1 -0
- package/dist/adapters/claude-code/session.js +427 -0
- package/dist/adapters/claude-code/session.js.map +1 -0
- package/dist/adapters/claude-code/settings.js +161 -0
- package/dist/adapters/claude-code/settings.js.map +1 -0
- package/dist/adapters/claude-code/state.js +66 -0
- package/dist/adapters/claude-code/state.js.map +1 -0
- package/dist/adapters/claude-code/subagents.js +246 -0
- package/dist/adapters/claude-code/subagents.js.map +1 -0
- package/dist/adapters/claude-code/transcript.js +309 -0
- package/dist/adapters/claude-code/transcript.js.map +1 -0
- package/dist/adapters/codex/accumulator.js +135 -0
- package/dist/adapters/codex/accumulator.js.map +1 -0
- package/dist/adapters/codex/detect.js +93 -0
- package/dist/adapters/codex/detect.js.map +1 -0
- package/dist/adapters/codex/hooks-config.js +158 -0
- package/dist/adapters/codex/hooks-config.js.map +1 -0
- package/dist/adapters/codex/index.js +270 -0
- package/dist/adapters/codex/index.js.map +1 -0
- package/dist/adapters/codex/locate.js +72 -0
- package/dist/adapters/codex/locate.js.map +1 -0
- package/dist/adapters/codex/records.js +194 -0
- package/dist/adapters/codex/records.js.map +1 -0
- package/dist/adapters/codex/rollout.js +72 -0
- package/dist/adapters/codex/rollout.js.map +1 -0
- package/dist/adapters/codex/session.js +212 -0
- package/dist/adapters/codex/session.js.map +1 -0
- package/dist/adapters/codex/subagents.js +139 -0
- package/dist/adapters/codex/subagents.js.map +1 -0
- package/dist/adapters/codex/summarize.js +84 -0
- package/dist/adapters/codex/summarize.js.map +1 -0
- package/dist/adapters/codex/tools.js +145 -0
- package/dist/adapters/codex/tools.js.map +1 -0
- package/dist/adapters/hook-command.js +94 -0
- package/dist/adapters/hook-command.js.map +1 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/analysis/corrections.js +79 -0
- package/dist/analysis/corrections.js.map +1 -0
- package/dist/analysis/digest.js +16 -0
- package/dist/analysis/digest.js.map +1 -0
- package/dist/analysis/outcome.js +76 -0
- package/dist/analysis/outcome.js.map +1 -0
- package/dist/analysis/phases.js +203 -0
- package/dist/analysis/phases.js.map +1 -0
- package/dist/analysis/summaries.js +350 -0
- package/dist/analysis/summaries.js.map +1 -0
- package/dist/analysis/tokens.js +67 -0
- package/dist/analysis/tokens.js.map +1 -0
- package/dist/analysis/usage.js +229 -0
- package/dist/analysis/usage.js.map +1 -0
- package/dist/cc-cache.js +81 -0
- package/dist/cc-cache.js.map +1 -0
- package/dist/cli.js +128 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/common.js +235 -0
- package/dist/commands/common.js.map +1 -0
- package/dist/commands/doctor.js +193 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/flush.js +131 -0
- package/dist/commands/flush.js.map +1 -0
- package/dist/commands/init.js +119 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/link.js +171 -0
- package/dist/commands/link.js.map +1 -0
- package/dist/commands/live.js +130 -0
- package/dist/commands/live.js.map +1 -0
- package/dist/commands/login.js +397 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/status.js +155 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/token.js +92 -0
- package/dist/commands/token.js.map +1 -0
- package/dist/commands/uninstall.js +56 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/config.js +305 -0
- package/dist/config.js.map +1 -0
- package/dist/context.js +109 -0
- package/dist/context.js.map +1 -0
- package/dist/delivery.js +151 -0
- package/dist/delivery.js.map +1 -0
- package/dist/dotfile.js +187 -0
- package/dist/dotfile.js.map +1 -0
- package/dist/events.js +259 -0
- package/dist/events.js.map +1 -0
- package/dist/fsutil.js +50 -0
- package/dist/fsutil.js.map +1 -0
- package/dist/git.js +139 -0
- package/dist/git.js.map +1 -0
- package/dist/hook.js +147 -0
- package/dist/hook.js.map +1 -0
- package/dist/http.js +35 -0
- package/dist/http.js.map +1 -0
- package/dist/jobs.js +53 -0
- package/dist/jobs.js.map +1 -0
- package/dist/live/claude-code.js +226 -0
- package/dist/live/claude-code.js.map +1 -0
- package/dist/live/codex.js +91 -0
- package/dist/live/codex.js.map +1 -0
- package/dist/live/monitor.js +229 -0
- package/dist/live/monitor.js.map +1 -0
- package/dist/live/render.js +92 -0
- package/dist/live/render.js.map +1 -0
- package/dist/lock.js +93 -0
- package/dist/lock.js.map +1 -0
- package/dist/log.js +36 -0
- package/dist/log.js.map +1 -0
- package/dist/migrate.js +109 -0
- package/dist/migrate.js.map +1 -0
- package/dist/nudge.js +231 -0
- package/dist/nudge.js.map +1 -0
- package/dist/paths.js +73 -0
- package/dist/paths.js.map +1 -0
- package/dist/pk-cache.js +110 -0
- package/dist/pk-cache.js.map +1 -0
- package/dist/prices.js +45 -0
- package/dist/prices.js.map +1 -0
- package/dist/project-key.js +144 -0
- package/dist/project-key.js.map +1 -0
- package/dist/protocol-types.js +30 -0
- package/dist/protocol-types.js.map +1 -0
- package/dist/queue.js +649 -0
- package/dist/queue.js.map +1 -0
- package/dist/routing.js +122 -0
- package/dist/routing.js.map +1 -0
- package/dist/runtime.js +54 -0
- package/dist/runtime.js.map +1 -0
- package/dist/sender.js +559 -0
- package/dist/sender.js.map +1 -0
- package/dist/state.js +59 -0
- package/dist/state.js.map +1 -0
- package/dist/ulid.js +73 -0
- package/dist/ulid.js.map +1 -0
- package/dist/version.js +26 -0
- package/dist/version.js.map +1 -0
- package/dist/warnings.js +71 -0
- package/dist/warnings.js.map +1 -0
- package/dist/worker-main.js +14 -0
- package/dist/worker-main.js.map +1 -0
- package/dist/worker.js +295 -0
- package/dist/worker.js.map +1 -0
- package/package.json +25 -4
package/README.md
CHANGED
|
@@ -1,3 +1,667 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ai-obs — collector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zero-dependency Node CLI that records how AI coding agents are used: tokens, models, effort levels,
|
|
4
|
+
phases, sub-agent runs, outcomes and one-line summaries. It reads exact numbers from harness hooks and
|
|
5
|
+
local transcripts and sends batched, idempotent events to an ai-obs server.
|
|
6
|
+
Design: [BRIEF §5](../../BRIEF.md), ADR-0001, ADR-0004, ADR-0005, ADR-0008, ADR-0011, ADR-0028 – ADR-0030 (one
|
|
7
|
+
deployment per company, per-user tokens with device login, the project dot file; brief:
|
|
8
|
+
[`docs/briefs/single-deployment-per-user-tokens.md`](../../docs/briefs/single-deployment-per-user-tokens.md)).
|
|
9
|
+
|
|
10
|
+
- Node ≥ 20, **no runtime dependencies** (only `node:` built-ins).
|
|
11
|
+
- Harnesses: Claude Code and Codex. Each one is an adapter in `src/adapters/<name>/`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx ai-obs-cli login --server https://obs.example.com # approve the code in the browser
|
|
17
|
+
npx ai-obs-cli init # user-level hooks (--project: this repository only)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
One ai-obs server is one company (a "deployment"). A token belongs to you (or to a service user an admin made) and
|
|
21
|
+
identifies you on that server; which **team** a repository reports to comes from a dot file in the repository (see
|
|
22
|
+
"The dot file"). A machine can be logged in to several servers, one token per server (see "Several servers").
|
|
23
|
+
|
|
24
|
+
### `ai-obs login`
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
ai-obs login --server https://obs.example.com # device login (browser)
|
|
28
|
+
ai-obs login --server https://obs.example.com --no-browser # print the URL only (SSH, containers)
|
|
29
|
+
ai-obs login --server https://obs.example.com --token - # paste a token made under "My tokens" (read from stdin)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
1. **Device login** (the default): `POST <server>/api/v1/device/code` with this machine's name (`--label` overrides
|
|
33
|
+
it) and the collector version. The CLI prints `Open <server>/device and enter the code XXXX-XXXX` and opens the
|
|
34
|
+
browser (not over SSH, not on Linux without a display, not with `--no-browser`). The URL is always built from
|
|
35
|
+
`--server`, never taken from the server's answer. On the page you see the machine name and the requester IP, and
|
|
36
|
+
Approve or Deny. Meanwhile the CLI polls `/api/v1/device/token` every `interval` seconds (5 by default, 5 s more
|
|
37
|
+
after each `slow_down`), keeps polling through network errors, and stops on `access_denied`, `expired_token`,
|
|
38
|
+
`invalid_grant` or when the 10-minute code expires. A 429 while polling waits at least the server's `Retry-After`
|
|
39
|
+
(at most 60 s between polls). A 429 on the code request ends the login with the server's reason (too many logins
|
|
40
|
+
waiting for approval server-wide, or too many from this address) and its `Retry-After`. A server without device
|
|
41
|
+
login (HTTP 401, 404 or 405 on `/api/v1/device/code`; a 0.3 / 0.4 server answers 401) gets the hint to paste a
|
|
42
|
+
token from "My tokens" with `--token -`. A device-login token is valid for **90 days**. If the approval answer is
|
|
43
|
+
lost (network error at that moment), the code is spent and the CLI says so: the token the server made then is
|
|
44
|
+
never used, so revoke it under "My tokens".
|
|
45
|
+
2. **Pasted token** (`--token <t>` or `--token -` for stdin): a token from "My tokens" in the dashboard, with the
|
|
46
|
+
lifetime chosen there (30 / 90 / 365 days or none). Only `aiobs_u_…` tokens exist; the retired company
|
|
47
|
+
(`aiobs_c_`) and team (`aiobs_t_`) tokens are refused.
|
|
48
|
+
3. Either way the token is checked with `GET /api/v1/collector-config`, which returns the deployment, your e-mail,
|
|
49
|
+
your teams, the token's expiry, label and source, and the project naming mode. A 401 / 403 changes nothing; a
|
|
50
|
+
429 is retried after `Retry-After` (at most 3 tries, 60 s). If the server cannot be reached for a pasted token,
|
|
51
|
+
the token is stored unchecked and nothing is sent until the server answers.
|
|
52
|
+
4. The token is stored in `~/.ai-obs/config.json` (mode 0600) as the entry of that server, under the worker lock.
|
|
53
|
+
**Logging in to the same server again replaces its token**: events queued for the old one are sent with the new
|
|
54
|
+
one (streams are keyed by server, not by token). The old token stays valid on the server until it expires or is
|
|
55
|
+
revoked under "My tokens". **When the new token belongs to another account** (another e-mail, or a person
|
|
56
|
+
replaced by a service user, as collector-config says), the previous account's queued events are not sent as
|
|
57
|
+
the new one: they are moved to `~/.ai-obs/queue/set-aside/<ulid>/` (with an `about.json` naming both accounts)
|
|
58
|
+
and kept there, unsent. `--keep-queue` sends them with the new token instead; to send a set-aside batch later,
|
|
59
|
+
move the directories in its `p/` and `u/` back into `~/.ai-obs/queue/p/` and `queue/u/`. A token that could not
|
|
60
|
+
be checked at login, or an entry that was never checked, cannot be compared (its queue is sent with the new
|
|
61
|
+
token, as before), nor can two service users. The hook runtime in `~/.ai-obs/app` is refreshed first.
|
|
62
|
+
Until collector-config answers, the entry shows as "(not checked yet)".
|
|
63
|
+
|
|
64
|
+
`--server` defaults to `AI_OBS_ENDPOINT`, else the only registered server; with several it is required.
|
|
65
|
+
`ai-obs init --token <t> --endpoint <url> [--label <name>]` is the non-interactive form (login with a pasted token,
|
|
66
|
+
then the hooks), for scripts and CI.
|
|
67
|
+
|
|
68
|
+
### `ai-obs init`
|
|
69
|
+
|
|
70
|
+
Installs the hooks of every detected harness; run it after the first login and after every upgrade.
|
|
71
|
+
|
|
72
|
+
1. Migrates an older home (see "Upgrading to 0.5") and copies the collector (`bin/`, `dist/`, `package.json`) to
|
|
73
|
+
`~/.ai-obs/app`, so hooks keep working after the npx cache is cleared.
|
|
74
|
+
2. Installs hooks in every detected harness. Each hook runs `<node> ~/.ai-obs/app/bin/ai-obs.js hook <harness> <hook>`.
|
|
75
|
+
With `--project`, the hooks go into the repository's harness config; no token is ever written there.
|
|
76
|
+
3. Prints the next steps only you can do. For Codex: **open Codex once and run `/hooks` to trust the new hooks**.
|
|
77
|
+
Codex does not run untrusted hooks, so nothing is recorded until you do.
|
|
78
|
+
|
|
79
|
+
Without a login `init` still installs the hooks, and says that nothing is recorded until `ai-obs login`.
|
|
80
|
+
|
|
81
|
+
### The dot file (`.ai-obs.json`)
|
|
82
|
+
|
|
83
|
+
A repository says where its sessions report in a file at its git top level:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "server": "https://obs.example.com", "team": "platform" }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Key | Meaning |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `server` | The ai-obs server (http/https URL) this repository's sessions go to. Optional with one registered server |
|
|
92
|
+
| `team` | The team slug (1–63 of `a-z`, `0-9`, `-`) the sessions are attributed to. Optional |
|
|
93
|
+
|
|
94
|
+
- **`.ai-obs.json` is committed**, so everyone working in the repository reports to the same server and team.
|
|
95
|
+
**`.ai-obs.local.json`** (git-ignored) has the same keys and overrides the committed file per key, for a personal
|
|
96
|
+
choice. Unknown keys are ignored. Tokens never go in either file (`link` refuses and strips them; `status` and
|
|
97
|
+
`doctor` report a file that holds one with ✗: remove it and revoke the token). `server` is at most 512 characters.
|
|
98
|
+
- **Lookup**: from the session's working directory up to the directory that holds `.git` (no git call); the first
|
|
99
|
+
directory with either file wins (so a package of a monorepo may have its own). Nothing outside a git repository is
|
|
100
|
+
read. The result is cached in `state/dot-files.json` and checked with a few `stat` calls, so the hook stays inside
|
|
101
|
+
its budget. A **malformed file counts as absent** and is reported by `status` and `doctor`.
|
|
102
|
+
- **Write it with `ai-obs link --team <slug> [--server <url>] [--local]`**. The server defaults to the one the dot
|
|
103
|
+
file already names, else the only registered server. When this machine is logged in to that server, the team is
|
|
104
|
+
checked against your teams in its collector-config (a team you are not in is refused); otherwise `link` warns and
|
|
105
|
+
writes anyway. `--local` writes `.ai-obs.local.json` and adds it to `.gitignore`. The file goes to the git top level,
|
|
106
|
+
also when `--project-dir` names a subdirectory. Events of the repository that were recorded while no server was
|
|
107
|
+
chosen move to the linked server.
|
|
108
|
+
- **The server decides the team** (ADR-0030): an admin's project → team mapping wins; else the dot-file team if you
|
|
109
|
+
are a member of it; else your primary team; else none. A team you are not in, or an unknown slug, is ignored and
|
|
110
|
+
the server answers a warning (`team_not_member`, `team_unknown`) that `status`, `doctor` and the next session's
|
|
111
|
+
start text show.
|
|
112
|
+
- **Pinned per session**: a session keeps the server and team in force at its first hook, also when the dot file
|
|
113
|
+
changes while it runs; the next session takes the new values. A session that started while no server was chosen
|
|
114
|
+
joins the repository's server from its next event once there is one.
|
|
115
|
+
|
|
116
|
+
### Several servers
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
ai-obs login --server https://obs.acme.example
|
|
120
|
+
ai-obs login --server https://obs.globex.example
|
|
121
|
+
cd ~/work/billing && ai-obs link --server https://obs.globex.example --team web
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- **With one server nothing more is needed**: every repository without a dot-file server reports to it.
|
|
125
|
+
- **With two or more**, a repository reports to the server its dot file names. A repository without one keeps its
|
|
126
|
+
events on this machine (nothing is sent anywhere) and the session start asks your agent to run `ai-obs link`.
|
|
127
|
+
- **A token is only ever sent to its own server.** A dot file naming a server this machine is not logged in to
|
|
128
|
+
(a committed file from a teammate, say) never makes the collector send anything there: its events wait, and the
|
|
129
|
+
session start asks you to run `ai-obs login --server <url>`.
|
|
130
|
+
- `ai-obs token list` shows each server with its account, deployment, label, expiry, state and the repositories
|
|
131
|
+
delivered to it; `ai-obs token remove <server>` forgets a token (its queued events stay on the machine; revoke the
|
|
132
|
+
token in the dashboard if it may have leaked).
|
|
133
|
+
|
|
134
|
+
### Token expiry
|
|
135
|
+
|
|
136
|
+
Device-login tokens expire after 90 days, pasted tokens when their chosen lifetime ends. The collector keeps the
|
|
137
|
+
expiry from collector-config in `config.json` and:
|
|
138
|
+
|
|
139
|
+
- **14 days before** it, `status` and `doctor` warn and the session start of a repository on that server says so
|
|
140
|
+
once per session;
|
|
141
|
+
- **from the expiry on**, it sends nothing with that token (checked locally, also on `ai-obs flush`) and asks you to
|
|
142
|
+
run `ai-obs login --server <url>`;
|
|
143
|
+
- a token the server refuses (401: expired or revoked) blocks that server with "token expired or revoked: run
|
|
144
|
+
ai-obs login --server <url>"; the other servers keep sending. The next login (a new token) unblocks it.
|
|
145
|
+
|
|
146
|
+
While a server is refused or its token expired, its queued events are kept and **that time does not count** against
|
|
147
|
+
the 30-day queue age cap.
|
|
148
|
+
|
|
149
|
+
### The SessionStart text
|
|
150
|
+
|
|
151
|
+
The SessionStart hook puts one short text into the agent's context only when:
|
|
152
|
+
|
|
153
|
+
1. the repository has no dot-file server and two or more servers are registered: ask the user, then
|
|
154
|
+
`ai-obs link --server <url> --team <team> --project-dir <repository root>`;
|
|
155
|
+
2. the repository's server has no token, or an expired or refused one: tell the user to run
|
|
156
|
+
`ai-obs login --server <url>` (never ask for a token);
|
|
157
|
+
3. that server's token expires within 14 days;
|
|
158
|
+
4. queued events were dropped by the caps (only the ones dropped since the text last mentioned drops);
|
|
159
|
+
5. the server ignored this repository's dot-file team (latest warning): your primary team is used, or no team if you
|
|
160
|
+
have none.
|
|
161
|
+
|
|
162
|
+
It needs no network, carries only identifiers (hosts, server URLs, team slugs, the sanitized directory), never a
|
|
163
|
+
token or an e-mail, is at most 1000 characters (the repeated command is written once, then plainly as `ai-obs`, then
|
|
164
|
+
`--project-dir` and the list of hosts are shortened, then a server URL is named by its host only) and appears at most
|
|
165
|
+
once per session per text. Claude Code gets
|
|
166
|
+
it as JSON `additionalContext`, Codex as plain text (see `src/adapters/codex/FORMAT.md`).
|
|
167
|
+
|
|
168
|
+
### Upgrading to 0.5 (from 0.3 or 0.4)
|
|
169
|
+
|
|
170
|
+
**0.4.0 was never published**: 0.5.0 is the next release after 0.3.0 on npm and also carries the live session work
|
|
171
|
+
(`turn_started`, `ai-obs live`, summaries after the flush; ADR-0027). Upgrade the server first (migration 0018 and the
|
|
172
|
+
web / ingest release); a 0.5 collector needs the device-login routes and the new collector-config.
|
|
173
|
+
|
|
174
|
+
**The package is renamed**: 0.3.0 went out as `@soukupl/ai-obs`; 0.5.0 and every later release are `ai-obs-cli`
|
|
175
|
+
([ADR-0031](../../docs/adr/0031-npm-package-name-ai-obs-cli.md)). The command is still `ai-obs`, the hooks and `~/.ai-obs` are
|
|
176
|
+
unchanged (the hooks run the runtime copied to `~/.ai-obs/app`, never the npm name), and `@soukupl/ai-obs` is deprecated on
|
|
177
|
+
npm with a pointer here.
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
npx ai-obs-cli@0.5.0 init # refreshes the runtime and the hooks, and migrates ~/.ai-obs
|
|
181
|
+
npx ai-obs-cli status # then check server, token expiry and this repository's team
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`ai-obs` is not on PATH after `npx`, so use `npx` for the follow-up commands too.
|
|
185
|
+
|
|
186
|
+
The first 0.5 command that finds the worker lock free (or the background worker) migrates the home, once, and says
|
|
187
|
+
what it changed. It first refreshes an older hook runtime in `~/.ai-obs/app` (an older runtime would read the new
|
|
188
|
+
config as "no token" and drop every hook job); if the runtime cannot be refreshed, nothing is migrated and the
|
|
189
|
+
command says to run `ai-obs init`. Then:
|
|
190
|
+
|
|
191
|
+
- `config.json` version 2 → **version 3**: every user token (`aiobs_u_`) becomes the entry of its server (endpoint
|
|
192
|
+
kept; deployment, e-mail and expiry are learned at the next collector-config fetch). **Company tokens (`aiobs_c_`)
|
|
193
|
+
are dropped** with a message: run `ai-obs login` for that server. A server keeps one token: of two user tokens
|
|
194
|
+
for one server (several companies on one dev server), the one more repositories in `projects.json` used is kept
|
|
195
|
+
(on a tie, the later one). The message names both accounts (e-mail, company, repositories) and which one was
|
|
196
|
+
dropped; to report as the dropped account on this machine, run `ai-obs login --server <url>` and approve with it.
|
|
197
|
+
Routing epochs, `legacy`, malformed entries and rotation fingerprints are discarded. The old file is kept as
|
|
198
|
+
**`config.json.v2`** (mode 0600; it holds the old tokens, so delete it when you no longer need them).
|
|
199
|
+
- `projects.json` and `ai-obs use` are gone: the file is renamed to `projects.json.v2` and kept for inspection. Write
|
|
200
|
+
`.ai-obs.json` with `ai-obs link` instead.
|
|
201
|
+
- Queued streams are converted: data bound to a token goes to that token's server (no team); data recorded while a
|
|
202
|
+
repository had no choice goes to the only server, or, with several, waits until you `ai-obs link` the repository.
|
|
203
|
+
`queue/dead/pre-upgrade/` is left as it is.
|
|
204
|
+
- The kept user token stays valid after the cutover (its expiry is set by the server); there is no need to log in
|
|
205
|
+
again until `status` says so, unless it is the account you did not want (see above). A token the server no longer
|
|
206
|
+
accepts shows as REJECTED: run `ai-obs login --server <url>`.
|
|
207
|
+
|
|
208
|
+
Sessions running during the upgrade keep their stream. `MIN_COLLECTOR_VERSION` is raised to 0.5.0 by the operator
|
|
209
|
+
after the rollout. The collector has a devDependency on `@ai-obs/db` for one test (it keeps the local price table
|
|
210
|
+
equal to the dashboard's); the runtime still has no dependencies.
|
|
211
|
+
|
|
212
|
+
### Upgrading from 0.1 / 0.2
|
|
213
|
+
|
|
214
|
+
A 0.1.x `config.json` (a single team token) loads with no server: run `ai-obs login`. Batches 0.1.x left in
|
|
215
|
+
`queue/*.ndjson` are never sent; 0.2 – 0.4 moved them to `queue/dead/pre-upgrade/`, which 0.5 leaves as it is (delete
|
|
216
|
+
it when you no longer need it). Old `jobs/<id>.json` hook jobs and 0.1.x session state are still processed; a
|
|
217
|
+
session state without a working directory has its events dropped (logged).
|
|
218
|
+
|
|
219
|
+
**Node path caveat (nvm, fnm, Volta, asdf, Homebrew).** Hooks run the absolute path of the Node that
|
|
220
|
+
executed `init` (`process.execPath`), so they do not depend on `PATH` at hook time. With a
|
|
221
|
+
version-managed Node that path contains the version (`~/.nvm/versions/node/v22.1.0/bin/node`). When
|
|
222
|
+
you remove or upgrade that version, the hooks silently stop working. `init` prints a warning when it
|
|
223
|
+
detects such a path, and `ai-obs doctor` reports a missing or non-executable node binary (or a missing
|
|
224
|
+
`~/.ai-obs/app` script) with the fix: run `ai-obs init` again, ideally under a Node that stays.
|
|
225
|
+
|
|
226
|
+
**Project-scope hooks (`--project`).** The hook command is the same absolute command as in user
|
|
227
|
+
scope, so the repo's `.claude/settings.json` or `.codex/hooks.json` holds paths of this machine. They
|
|
228
|
+
do not work for teammates or on CI, and `init --project` warns about it. Keep the file out of git
|
|
229
|
+
(for example in `.git/info/exclude`), or let every teammate run `ai-obs init --project` themselves. We
|
|
230
|
+
do not write a relative command such as `node ./node_modules/.bin/ai-obs …`: harnesses run hooks in
|
|
231
|
+
the session's working directory, which is not always the repo root, and a hook that cannot find its
|
|
232
|
+
script fails silently.
|
|
233
|
+
|
|
234
|
+
Options:
|
|
235
|
+
|
|
236
|
+
| Option | Purpose |
|
|
237
|
+
|---|---|
|
|
238
|
+
| `--server <url>` | `login`, `link`: the ai-obs server. Default for `login`: `AI_OBS_ENDPOINT`, else the only registered server |
|
|
239
|
+
| `--token <t>` / `--token -` | `login`, `init`: a pasted token (`-` reads it from stdin) instead of the device login |
|
|
240
|
+
| `--no-browser` | `login`: print the device URL without opening a browser |
|
|
241
|
+
| `--label <name>` | `login`, `init --token`: the client name shown on the approval page and in "My tokens" (default: this machine's hostname) |
|
|
242
|
+
| `--endpoint <url>` | `init --token`: the server of that token |
|
|
243
|
+
| `--keep-queue` | `login`: when the new token belongs to another account, send the previous account's queued events with it instead of setting them aside |
|
|
244
|
+
| `--team <slug>` | `link`: the team the repository reports to |
|
|
245
|
+
| `--local` | `link`: write `.ai-obs.local.json` (git-ignored) instead of `.ai-obs.json` |
|
|
246
|
+
| `--harness claude-code,codex` | `init`: limit setup to these harnesses |
|
|
247
|
+
| `--project-dir <dir>` | `init --project`, `link`, `uninstall`: the repository root (for `link`, a subdirectory is resolved to its git top level). Default: the git top level of the current directory |
|
|
248
|
+
| `--home <dir>` | Collector home. Works with every command |
|
|
249
|
+
| `--yes` | With `uninstall --purge`: skip the confirmation |
|
|
250
|
+
|
|
251
|
+
## Commands
|
|
252
|
+
|
|
253
|
+
| Command | Does |
|
|
254
|
+
|---|---|
|
|
255
|
+
| `login [--server <url>] [--token <t>\|-] [--no-browser] [--label <name>] [--keep-queue]` | Log this machine in to a server (device login or a pasted token), as described above |
|
|
256
|
+
| `init [--project] [--project-dir <dir>] [--harness …] [--token <t> --endpoint <url> [--label <name>]]` | Install the hooks (and, with `--token`, log in first) |
|
|
257
|
+
| `link --team <slug> [--server <url>] [--local] [--project-dir <dir>]` | Write the repository's dot file (server and team), checking the team on the server |
|
|
258
|
+
| `token list` | Servers: endpoint, e-mail, deployment, label, expiry, repositories delivered, queued events, state |
|
|
259
|
+
| `token remove <server>` | Forget a server's token (URL or host). Its queued events stay on this machine |
|
|
260
|
+
| `doctor` | Checks the config, each server (reachable, token valid or expired / revoked, expiry, your teams, minimum version, naming), this repository (dot-file validity, its server and token, its team, the server's warning), each harness (installed, version supported, hooks installed), the node binary and script baked into the hooks (✗ with "run `ai-obs init` again" when they are gone), Codex hooks waiting to be trusted (`!`), the queue and the last send per server. Prints ✓, ! or ✗ per check and exits 1 if any check shows ✗ |
|
|
261
|
+
| `status` | Local state without the network: servers with account, deployment, expiry and state; this repository's dot file, server, token state, team and latest warning; queue size and what is kept (no server chosen, not logged in, refused / expired, unresolved, pre-upgrade); dropped events; pending hook jobs; summaries (`<n> waiting for retry, <m> due`); last send and last error |
|
|
262
|
+
| `flush [--wait <s>]` | First runs every adapter's idle-session sweep (Codex ends sessions idle for 1 h, Claude Code ones silent for 24 h) and writes the staged and pending summaries (`<Harness>: summaries X of Y done, Z still waiting`; while the background worker holds `summaries.lock` it says so and skips this), then sends the queue now, per server, ignoring the retry backoff and retrying a refused server once (an expired token is never used). Kept data stays kept (exit 0); exit 1 when a server failed or refused |
|
|
263
|
+
| `live [--cwd] [--once]` | Watch the Claude Code and Codex sessions running on this machine: a table redrawn about once per second (alternate screen, in place; `q`, `Q` or Ctrl-C quits and restores the terminal). Local only: no network, nothing written, hook state files and offsets untouched, works without a login. `--cwd` narrows to the current git repository (or the directory itself outside one). `--once`, or a stdin or stdout that is not a terminal, prints one frame without escape codes and exits 0; on a non-terminal it is cut at 200 columns, with `--once` on a terminal at the terminal width. An unknown flag exits 2 with the usage text. Details below |
|
|
264
|
+
| `uninstall [--purge [--yes]] [--project-dir <dir>]` | Removes our hooks from user scope and from the current repo. `--purge` also deletes `~/.ai-obs`: tokens and queue (with all local state); it needs `--yes` while unsent events wait. Dot files in repositories are not touched |
|
|
265
|
+
| `hook <harness> <hook>` | Internal. Harness hooks call it. It always exits 0 |
|
|
266
|
+
|
|
267
|
+
## Files (`~/.ai-obs`, or `$AI_OBS_HOME`)
|
|
268
|
+
|
|
269
|
+
| Path | Content |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `config.json` | Version 3, mode 0600: `{version: 3, machine_id, created_at, servers[]}`. A server entry: `endpoint`, `token`, `email` (null for a service user or until checked), `deployment {name, slug}`, `label`, `expires_at` (null = no expiry or not known yet), `source` (`device` \| `manual`; null until collector-config answered for the token), `added_at`. One entry per endpoint; its token is only sent there |
|
|
272
|
+
| `config.json.v2` | The 0.2 – 0.4 config.json (mode 0600, with its tokens), kept by the migration and no longer read; delete it when you no longer need it |
|
|
273
|
+
| `projects.json.v2` | The 0.2 – 0.4 per-repository choices, renamed by the migration and no longer read (the migration reads them once, to choose between two tokens of one server) |
|
|
274
|
+
| `project-secret` | Random 32-byte hex secret (mode 0600, created on first use, never sent) that keys the hash of a repo with no remote |
|
|
275
|
+
| `queue/p/s-<id>/` | One stream per (project key, server endpoint, team slug), each with `stream.json` (`{key, endpoint, team}`; `endpoint` "" = no server chosen yet, `team` "" = no dot-file team) and `*.ndjson` segments named by ULID |
|
|
276
|
+
| `queue/u/s-<id>/` | Streams whose project key could not be resolved when recorded (git timed out), with their server and team. A later flush adopts them into their project stream |
|
|
277
|
+
| `queue/p/o-…`, `queue/p/t-…` | 0.2 – 0.4 streams, converted to `s-` streams by the migration |
|
|
278
|
+
| `queue/*.ndjson` | The pre-upgrade stream written by 0.1.x: frozen, never sent |
|
|
279
|
+
| `queue/dead/` | Batches the server refused as a whole (HTTP 400), kept for inspection (max 5 MB), and `pre-upgrade/` (left as it is) |
|
|
280
|
+
| `queue/set-aside/<ulid>/` | Queued streams of a previous account, moved by a `login` that replaced its token with another account's (`p/`, `u/`, `about.json`). Never sent, not counted in the queue cap; move them back into `queue/p/` / `queue/u/` to send them with the current token, or delete them |
|
|
281
|
+
| | Queue caps: ~50 MB for the whole queue (oldest segment first) and 30 days for streams that can be sent now, counted from when the stream became sendable (`sendable_since` in its `stream.json`), so data that waited (no server chosen, not logged in, refused, expired) gets its full 30 days once it can be sent; waiting time does not count. Every drop is counted in `state/dropped.json` and shown in `status`, `doctor` and `flush`, and once in the next SessionStart text (`state/dropped-shown.json` keeps the counters it last mentioned, so the text reports only new drops) |
|
|
282
|
+
| `jobs/*.v2.json` | Hook invocations waiting for the worker (0.1.x wrote `<id>.json`, still read). They hold the raw hook payload and stay on this machine |
|
|
283
|
+
| `state/<harness>/<session>.json` | Per-session adapter state, such as transcript cursors, provisional outcomes and the session's pinned stream |
|
|
284
|
+
| `state/summaries/<harness>.json` | Turn digests whose summary failed or was not tried yet, with their stream key. They are retried for up to 5 attempts or 7 days (staged digests expire after 7 days too) and stay on this machine |
|
|
285
|
+
| `state/summaries/staged/<harness>/<ulid>.json` | Digests due for a summary (mode 0600, local only). The hook job writes them before it clears them from the session state; the summary phase deletes them after the summaries are queued |
|
|
286
|
+
| `state/collector-config/<host>.json` | Cached collector-config per server (`:` in the host written as `_`). TTL 6 h; after a failed fetch, the next try waits 10 min |
|
|
287
|
+
| `state/delivery.json` | Per server host: last attempt, last success, last error, backoff, a `blocked` flag (401 / 403) and the repositories delivered (`token list`) |
|
|
288
|
+
| `state/warnings.json` | Latest ingest warning per project key (`team_not_member` / `team_unknown`, team, server, session) |
|
|
289
|
+
| `state/dropped.json` | Counters of events dropped by the caps, split into waiting and sendable |
|
|
290
|
+
| `state/project-keys.json` | Working directory → project key cache (500 entries) that the hook reads without running git |
|
|
291
|
+
| `state/dot-files.json` | Working directory → dot-file lookup cache (500 entries), checked with `stat` calls |
|
|
292
|
+
| `state/nudged/<session>` | Marker that the SessionStart text was shown in a session (deleted after 7 days) |
|
|
293
|
+
| `log/collector.log` | Errors and warnings from hooks and the worker. Rotated at 1 MB. Prompts and paths are never logged |
|
|
294
|
+
| `app/` | The collector copy that hooks run |
|
|
295
|
+
| `worker.lock` | Held by the single worker, a `flush`, and the write phase of `login`, `init`, `link` and `token remove` |
|
|
296
|
+
| `summaries.lock` | Held by the worker's summary phase (or `flush`) while it runs the model call. Separate from `worker.lock`, so hooks and sends are not blocked by it |
|
|
297
|
+
|
|
298
|
+
## How hooks work
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
harness hook ─▶ ai-obs hook <harness> <hook> (always exit 0)
|
|
302
|
+
├─ read stdin JSON (50 ms idle timeout)
|
|
303
|
+
├─ write jobs/<ulid>.v2.json (payload, cwd, harness env vars minus secrets)
|
|
304
|
+
├─ spawn detached worker ─▶ dist/worker-main.js
|
|
305
|
+
└─ SessionStart only: print the link / login / expiry text when there is one (local files only)
|
|
306
|
+
|
|
307
|
+
worker: take worker.lock (or exit: the holder picks the job up)
|
|
308
|
+
├─ for each job, oldest first: adapter.handleHook(job, ctx)
|
|
309
|
+
│ pins each session once, at its first hook: project key + the dot file's server and team
|
|
310
|
+
│ (else the only server) → one queue stream per (project, server, team)
|
|
311
|
+
│ (+ adapter.sweepCrashedSessions after session-start hooks)
|
|
312
|
+
├─ (first run after an upgrade: migrate config.json v2, projects.json, o- / t- streams)
|
|
313
|
+
├─ flush: send every stream to its server's entry (no entry → kept); per server
|
|
314
|
+
│ ≤ 500 events / 1 MB per batch, gzip, that server's Bearer token and naming
|
|
315
|
+
└─ release lock, re-scan jobs/ (and run again when config.json changed)
|
|
316
|
+
|
|
317
|
+
summary phase (after the flush, under ~/.ai-obs/summaries.lock, at most 3 passes):
|
|
318
|
+
├─ summarise the staged + pending digests (one headless call per chunk of 25; worker.lock is NOT held;
|
|
319
|
+
│ no chunk starts after 5 min, the rest waits as pending)
|
|
320
|
+
├─ take worker.lock (waits up to 60 s), queue the turn_summary events, send them in a second flush
|
|
321
|
+
└─ jobs that arrived meanwhile are processed in the same run
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- **SessionStart text.** The time budget counts in-process from hook entry (Node start-up is not counted). The hook
|
|
325
|
+
reads config.json, the dot file (a bounded `stat` walk, cached in `state/dot-files.json`) and the delivery state;
|
|
326
|
+
it needs the project key only when a server warning exists, from `state/project-keys.json` (a cached key is used
|
|
327
|
+
only while the directory's git config is unchanged). On a cache miss it runs git for at most 40 ms, and only when
|
|
328
|
+
less than 40 ms have passed, then kills it; when git is slower, this session shows no warning and the next one
|
|
329
|
+
does. It does no network call and prints at most once per session and text. The Claude Code `SessionStart` entry
|
|
330
|
+
has `timeout: 5`. Every other hook prints nothing.
|
|
331
|
+
- **Summaries** (ADR-0027). When digests are due (session ended, 10 waiting, or the oldest has waited 10 minutes) the hook
|
|
332
|
+
job only stages them to `state/summaries/staged/<harness>/<ulid>.json` and clears them from the session state after
|
|
333
|
+
that. The worker's summary phase writes the summaries in chunks of 25; a chunk that has not finished after 130 s counts
|
|
334
|
+
as a failed attempt, and after 5 minutes no further chunk starts: what was written is sent and the rest waits in
|
|
335
|
+
`state/summaries/<harness>.json`. If `worker.lock` stays busy (60 s), nothing changes and the next run retries. A summary keeps its event id across retries (its time is the digest's first
|
|
336
|
+
staging or failure time).
|
|
337
|
+
- **Recursion guard.** `bin/ai-obs.js` exits immediately when `AI_OBS_SUMMARIZING` is set. The
|
|
338
|
+
summarizer sets that variable on the headless model call it starts.
|
|
339
|
+
- **Delivery.** A batch is acked (removed from the queue) only after a 2xx response. Server
|
|
340
|
+
duplicates and per-event rejections count as delivered. State is per server (keyed by host): on network errors,
|
|
341
|
+
5xx or 429 (which honours `Retry-After`) that server's events stay queued with exponential backoff (30 s up to
|
|
342
|
+
1 h), and the other servers keep sending. A 401 or 403 keeps that server's batches, stops sending with its token
|
|
343
|
+
("token expired or revoked: run ai-obs login --server <url>"), and shows in `status`, `doctor` and the SessionStart
|
|
344
|
+
text; automatic flushes skip it, `ai-obs flush` tries it once, a new login clears it. A token past its
|
|
345
|
+
`expires_at` is never used. `maxBatches` per flush counts per server. Hook-triggered flushes respect the backoff;
|
|
346
|
+
`ai-obs flush` ignores it. On 413 the batch size is halved; on 400 the batch goes to `queue/dead/`. Nothing is
|
|
347
|
+
sent with a token whose collector-config was never fetched; a fresh collector-config (fetched by a send, `doctor` or
|
|
348
|
+
`link`) also updates the server entry (e-mail, deployment, label, expiry). `session_start.project` is rebuilt at send time from the stream key
|
|
349
|
+
with the receiving deployment's naming mode and salt, plus the stream's dot-file `team`; skill / MCP names are
|
|
350
|
+
hashed the same way. An ingest response's `warnings` (`team_not_member`, `team_unknown`) are kept per project in
|
|
351
|
+
`state/warnings.json`; a later batch whose sessions start without one clears it (not a batch with server-side
|
|
352
|
+
duplicates: a replayed `session_start` gets no team resolution, so no warning).
|
|
353
|
+
- **Idempotency.** Event builders derive `event_id` from a seed plus the event time (`ulidFrom`), so
|
|
354
|
+
re-processing a transcript after a crash produces the same IDs, and the server de-duplicates them.
|
|
355
|
+
- **Crashed jobs.** A job is renamed to `*.processing` before it is handled. If the worker dies, the
|
|
356
|
+
next worker drops that job instead of retrying it forever. Adapters re-read transcripts from their
|
|
357
|
+
cursor, so no data is lost.
|
|
358
|
+
|
|
359
|
+
## Writing an adapter
|
|
360
|
+
|
|
361
|
+
Create `src/adapters/<name>/index.ts` that exports `const adapter: HarnessAdapter`, then register it
|
|
362
|
+
in `src/adapters/index.ts`. The contract is in [`src/adapters/types.ts`](src/adapters/types.ts):
|
|
363
|
+
|
|
364
|
+
| Member | Contract |
|
|
365
|
+
|---|---|
|
|
366
|
+
| `name`, `displayName`, `sessionStartHooks` | Identity. After any hook listed in `sessionStartHooks`, the worker calls `sweepCrashedSessions` |
|
|
367
|
+
| `detect({projectDir?})` | Reports installed, version, versionSupported and hooksInstalled `{user, project}`. Never throws |
|
|
368
|
+
| `nextSteps?` | Lines `init` prints after installing this harness's hooks (Codex: trust them with `/hooks`) |
|
|
369
|
+
| `installedHookCommands({projectDir?})` | Optional. The shell commands of our installed hooks; `doctor` checks that their node binary and script still exist |
|
|
370
|
+
| `installHooks({scope, projectDir?, hookCommand})` | Writes `hookCommand(hookName).shell` (or `.argv`) for each hook. Idempotent, and keeps unrelated config |
|
|
371
|
+
| `uninstallHooks({scope, projectDir?})` | Removes only our entries; use `isAiObsHookCommand()` to recognize them |
|
|
372
|
+
| `handleHook(invocation, ctx)` | Runs in the worker. Reads the transcript from the cursor in `ctx.state`, builds events with `src/events.ts` and enqueues them into the session's pinned stream (`ctx.streamFor(holder, capturedAt)`) |
|
|
373
|
+
| `sweepCrashedSessions(ctx)` | Finishes sessions that never got an end hook and emits `session_end` with reason `crashed` |
|
|
374
|
+
| `summarize(digests, ctx)` | One headless call to the cheapest model. Implement it with `summarizeWithCommand()`. Never call it directly; use `ctx.scheduleSummaries(digests, key)`, which stages the digests for the post-flush summary phase (synchronous; `false` = the staged file could not be written, keep the digests and retry at the next due hook) |
|
|
375
|
+
|
|
376
|
+
Shared, harness-neutral helpers:
|
|
377
|
+
|
|
378
|
+
| Module | Use |
|
|
379
|
+
|---|---|
|
|
380
|
+
| `src/events.ts` | `sessionStartEvent`, `turnEvent`, `runEvent`, `turnOutcomeEvent`, `turnSummaryEvent`, `sessionEndEvent`, `countTools`, `toolKey` |
|
|
381
|
+
| `src/analysis/phases.ts` | `classify(tool, {command, filePaths})`, `classifyCommand`, `checkKind`, `attributeTime(timeline, endAt)`, `toPhasesMs`, `PHASE_RULES_VERSION` |
|
|
382
|
+
| `src/analysis/tokens.ts` | `sumUsage(records)` de-duplicates by response id and sums per model × effort. Also `mergeUsage`, `primaryModel` |
|
|
383
|
+
| `src/analysis/outcome.ts` | `provisionalOutcome(signals)`, `finalOutcome(provisional, followUp)`, `isRevertCommand`, `editFingerprint` / `detectRevert` |
|
|
384
|
+
| `src/analysis/corrections.ts` | `looksLikeCorrection(nextPrompt)`: EN and CZ heuristics |
|
|
385
|
+
| `src/analysis/digest.ts` | `buildDigest()`. Truncates the prompt and the last message to 300 chars. Digests are used locally only |
|
|
386
|
+
| `src/analysis/summaries.ts` | `summarizeWithCommand`, `runSummaryCommand` (sets `AI_OBS_SUMMARIZING=1`, 120 s timeout), `buildSummaryPrompt`, `parseSummaryResponse`, `summariesDue` (session end / `SUMMARY_BATCH` = 10 turns / `SUMMARY_MAX_WAIT_MS` = 10 min, ADR-0023) |
|
|
387
|
+
| `src/project-key.ts` `resolveProjectKey(cwd)` | The plain project key: normalized git remote, else git top level, else the directory. Hashing happens at send time |
|
|
388
|
+
| `src/analysis/usage.ts` | Skill / MCP usage (ADR-0016): `parseMcpTool`, `addSkill`, `addMcp`, `mcpFromTools`, `mergeSkills` / `mergeMcp`, wire normalisation, `skillReadBy` (Codex SKILL.md reads) and `applyUsageNaming` / `hashUsageName`. Adapters pass **plain** names to `turnEvent` / `runEvent` (`skills`, `mcp`); the sender hashes them in `hashed` mode when it sends |
|
|
389
|
+
|
|
390
|
+
Token convention (matches `packages/db`): `input` excludes cache reads and writes; `reasoning` is part
|
|
391
|
+
of `output`; a turn's totals are the sum of its `usage` rows.
|
|
392
|
+
|
|
393
|
+
Outcome flow:
|
|
394
|
+
|
|
395
|
+
1. At Stop, the `turn` event carries `provisionalOutcome(…)`.
|
|
396
|
+
2. When the next prompt arrives, the adapter sends `turnOutcomeEvent({outcome: finalOutcome(prev, {correction: looksLikeCorrection(prompt), revert}), at: promptTime})`
|
|
397
|
+
for every turn still waiting for it (Claude Code: the last human turn and the task-notification
|
|
398
|
+
turns after it).
|
|
399
|
+
3. At session end, every turn still waiting gets `finalOutcome(prev, {sessionEnded: true})`.
|
|
400
|
+
|
|
401
|
+
### Claude Code adapter (`src/adapters/claude-code/`)
|
|
402
|
+
|
|
403
|
+
The real hook and transcript format (field names, version 2.1.289) is documented in
|
|
404
|
+
[`src/adapters/claude-code/FORMAT.md`](src/adapters/claude-code/FORMAT.md).
|
|
405
|
+
|
|
406
|
+
**SessionStart output.** The SessionStart text is printed as JSON, `{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "…"}}`, one line.
|
|
407
|
+
|
|
408
|
+
**Hooks.** The adapter writes `SessionStart`, `UserPromptSubmit`, `Stop`, `StopFailure`,
|
|
409
|
+
`SubagentStop` and `SessionEnd` to `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR`), or to
|
|
410
|
+
`<repo>/.claude/settings.json` with `--project`.
|
|
411
|
+
|
|
412
|
+
- It adds one group per event, `{hooks:[{type:"command", command:"<node> <bin> hook claude-code <Event>"}]}`.
|
|
413
|
+
- Re-running `init` replaces our entry, and `uninstall` removes only ours.
|
|
414
|
+
- The `SessionStart` entry has `timeout: 5` (seconds). Re-running `init` rewrites it, so existing installs get it.
|
|
415
|
+
- Before each change, the previous file is copied to `settings.json.ai-obs.bak`.
|
|
416
|
+
- A file that is not valid JSON is never overwritten.
|
|
417
|
+
|
|
418
|
+
| Module | Role |
|
|
419
|
+
|---|---|
|
|
420
|
+
| `index.ts` | The adapter (`createClaudeCodeAdapter()` for tests) and the summary command |
|
|
421
|
+
| `handlers.ts` | Hook dispatch, `session_start` / `turn_started` / `session_end`, crash sweep, staging digests for summaries (at session end, every 10 turns, or after 10 minutes) |
|
|
422
|
+
| `session.ts` | Turn segmentation from the cursor, continuations, outcome patches, sub-agent runs |
|
|
423
|
+
| `analyze.ts` | One stretch of activity: tokens, phases, tools, edits, checks, interrupts |
|
|
424
|
+
| `transcript.ts` | Byte-cursor JSONL reader; slims each line to ids, numbers and phase decisions |
|
|
425
|
+
| `subagents.ts` | `subagents/agent-<id>.jsonl` + `.meta.json`, and `subagents/workflows/<wf_id>/agent-*.jsonl` + `journal.jsonl` → `run` events |
|
|
426
|
+
| `run-links.ts` | Agent flow links between the runs of one turn (`item` / `sequence`, ADR-0020) |
|
|
427
|
+
| `settings.ts` / `detect.ts` | `settings.json` merge, version (`claude --version`, cached 6 h) |
|
|
428
|
+
|
|
429
|
+
How the adapter works:
|
|
430
|
+
|
|
431
|
+
- **Turns.** A prompt starts a turn. Prompts are typed text, slash commands and task notifications;
|
|
432
|
+
tool results, meta entries, local-command output and interrupt markers do not count. The turn ends
|
|
433
|
+
at the next prompt or at the `Stop` / `StopFailure` that follows. `turn_id` is the prompt entry's
|
|
434
|
+
`uuid`.
|
|
435
|
+
- **`turn_started`.** `UserPromptSubmit` (main agent only, task-notification prompts included) also sends a
|
|
436
|
+
`turn_started` event with the hook time. Its `turn_id` is the uuid of the transcript prompt matched to the hook
|
|
437
|
+
(`prompt_id` against the entry's `promptId`; without them, a prompt from 10 s before to 1 s after the hook time),
|
|
438
|
+
null when the prompt is not in the transcript yet. A `UserPromptSubmit` never ends its own prompt's turn, even when
|
|
439
|
+
a late worker already sees the replies; that turn is emitted at its `Stop`. Earlier interrupted turns still end at
|
|
440
|
+
`UserPromptSubmit`.
|
|
441
|
+
- **Late reads:** a prompt written after the hook fired stays open, so a worker that runs late
|
|
442
|
+
never cuts the next turn short.
|
|
443
|
+
- **Interrupted turns** get no `Stop`. They end at the next `UserPromptSubmit`, `Stop` or session
|
|
444
|
+
end.
|
|
445
|
+
- **Injected notifications:** a task notification that arrives inside a running turn is part of
|
|
446
|
+
that turn.
|
|
447
|
+
- **Continuations.** Activity after a `Stop` with no new prompt (a blocking Stop hook, queued meta
|
|
448
|
+
input) is added to the previous turn. The turn is then re-sent under a new event id, and the server
|
|
449
|
+
replaces its usage rows.
|
|
450
|
+
- **Tokens.**
|
|
451
|
+
- **Source:** each assistant entry's `message.usage`, de-duplicated per `message.id` (Claude Code
|
|
452
|
+
writes one entry per content block, each repeating the usage). `reasoning` comes from
|
|
453
|
+
`output_tokens_details.thinking_tokens`, and effort from the entry's `effort` field.
|
|
454
|
+
- **What the turn reports:** the main agent plus every sub-agent run counted into it, as the
|
|
455
|
+
ingest API expects.
|
|
456
|
+
- **Model:** the turn's `model` is the main agent's primary model.
|
|
457
|
+
- **Sub-agents.** Each `subagents/agent-<id>.jsonl` becomes a `run` event, linked to the turn
|
|
458
|
+
through `meta.toolUseId` → the `Agent` tool_use.
|
|
459
|
+
- **Fields:** `parent_run_id` is the parent agent's id for nested agents and null for agents the
|
|
460
|
+
main agent spawned. `depth` follows the nesting, and `agent_type` comes from the meta file (or
|
|
461
|
+
the tool's `subagent_type`). `agent_group` is the same agent type (omitted when it is unknown).
|
|
462
|
+
- **No `main` run:** the main agent is not sent as a run, because the protocol defines
|
|
463
|
+
`parent_run_id: null` / `depth: 1` as "spawned by the main agent".
|
|
464
|
+
- **Delegating time:** the parent's time waiting for a sub-agent is `delegating`, which is not
|
|
465
|
+
reported for the parent.
|
|
466
|
+
- **Background sub-agents** that finish after `Stop` are sent at their `SubagentStop`, together
|
|
467
|
+
with a re-sent turn.
|
|
468
|
+
- **Flow:** each run carries `links` to the sibling it followed (`sequence`: same parent, the
|
|
469
|
+
sibling with the latest end not after its start; parallel siblings are not chained).
|
|
470
|
+
- **Workflow sub-agents.** The Workflow tool runs its agents in the background, under
|
|
471
|
+
`<session>/subagents/workflows/<wf_id>/agent-*.jsonl`. Without these, their tokens would be missing.
|
|
472
|
+
- **Linking:** the Workflow tool result (`taskType: local_workflow`, `runId: wf_…`) ties the run to
|
|
473
|
+
the turn that launched it. Each agent becomes a `run` with `parent_run_id: null` and `depth: 1`
|
|
474
|
+
(spawned by the main agent), and its usage is added to that turn, which is re-sent.
|
|
475
|
+
- **Fields:** `agent_type` is `workflow:<label>` (a slug of the meta `description`, ≤ 48 chars;
|
|
476
|
+
`workflow-subagent` when there is no label). `agent_group` is `workflow:<phase slug>` when the
|
|
477
|
+
workflow phase is known, else `workflow:<label verb>` (first token of the label's slug, i.e. of
|
|
478
|
+
the stored `agent_type`, split on `[-:._/ ]`, lower-cased, trailing digits stripped; plain
|
|
479
|
+
`workflow` when empty; the same rule the dashboard applies to rows without a group), and
|
|
480
|
+
`orchestration_id` is the workflow run id (`wf_…`). `ext` has `workflow_id`, `workflow_phase` and
|
|
481
|
+
`workflow_label`. The script, the journal's results and the workflow `.json` are never read
|
|
482
|
+
beyond the agent ids, the phases of `started` records and the final `status`.
|
|
483
|
+
- **Flow (ADR-0020):** `stage_index` is the order in which the agent's phase first started in the
|
|
484
|
+
journal (0-based); `flow_key` is the `<item>` of a `<stage>:<item>` label (`impl:data` → `data`,
|
|
485
|
+
slugged, ≤ 64); `links` chains the agents of one workflow with the same `flow_key` in start order
|
|
486
|
+
(`item`: `impl:data → verify:data → fix:data`). Links are computed over every run of the turn sent
|
|
487
|
+
so far, so agents finishing in a later pass link back to earlier ones; an agent already sent whose
|
|
488
|
+
incoming links change (a same-item agent that started earlier finished later) is re-sent with them
|
|
489
|
+
under a new event id. Details in
|
|
490
|
+
[FORMAT.md](src/adapters/claude-code/FORMAT.md#agent-flow-fields-stage_index-flow_key-links-adr-0020).
|
|
491
|
+
- **When:** an agent is sent when the workflow journal (`journal.jsonl`) says it returned or
|
|
492
|
+
failed, when its `SubagentStop` arrives, when the workflow is over (`<session>/workflows/<wf_id>.json`
|
|
493
|
+
has a `status` other than `running`) or at `SessionEnd`. This is checked after every hook, so
|
|
494
|
+
agents that finish between turns arrive with the next hook. An agent the journal lists as `failed`
|
|
495
|
+
is a failed run.
|
|
496
|
+
- **Outcome.**
|
|
497
|
+
- **At `Stop`:** the turn carries a provisional outcome: `StopFailure` → failed, an interrupt
|
|
498
|
+
marker → abandoned, otherwise the last test or validate `Bash` exit code (`Exit code N` /
|
|
499
|
+
`is_error`).
|
|
500
|
+
- **Next human prompt** (`UserPromptSubmit`, or the next prompt in the transcript): runs the
|
|
501
|
+
correction heuristics and sends a `turn_outcome` patch for every turn still waiting: the most
|
|
502
|
+
recent human turn and the task-notification turns that followed it (background agents or a
|
|
503
|
+
Workflow reporting back continue that turn's work). A correction fails all of them; otherwise
|
|
504
|
+
they settle as usual (`unknown` → success, `no_failure_signal`).
|
|
505
|
+
- **Reverts:** the next human turn reverting edits (by line hashes or a `git restore` /
|
|
506
|
+
`checkout --` / `reset --hard`) fails the turns of that group that made edits, even after they
|
|
507
|
+
were settled.
|
|
508
|
+
- **Task notifications** do not settle anything (they only pass on a revert).
|
|
509
|
+
- **Session end** (and the crash sweep): every turn still waiting becomes final (`unknown` stays
|
|
510
|
+
`unknown`). Turns about to be dropped from the session state (more than 50 unsettled) are settled
|
|
511
|
+
the same way first, so no turn stays provisional forever.
|
|
512
|
+
- **Hooks inside a sub-agent** (payload with `agent_id`): `Stop` and `UserPromptSubmit` are
|
|
513
|
+
ignored, and a `StopFailure` fails only that sub-agent's run (`failed`, `stop_failure`); the main
|
|
514
|
+
turn keeps running.
|
|
515
|
+
- **Session state** is stored in `state/claude-code/<session>.json`. It holds the byte cursor of the
|
|
516
|
+
first unprocessed prompt, the last few turns plus every unsettled turn (for continuations,
|
|
517
|
+
follow-ups and late sub-agents; each with the ids of the sub-agent runs already sent for it, so a
|
|
518
|
+
Workflow with hundreds of agents never re-sends one) and the pending digests. Paths, code and prompt text are not stored there, apart from the
|
|
519
|
+
transcript path, the cwd and the digests' truncated text.
|
|
520
|
+
- **Resumed sessions:** a `SessionStart` with `source: resume|compact|fork` for a session never seen
|
|
521
|
+
starts at the end of the file, so its history is not back-filled (a fork's transcript starts with a
|
|
522
|
+
copy of the parent conversation, already counted under the parent). Any other first contact reads
|
|
523
|
+
the transcript from the start.
|
|
524
|
+
- **Crash sweep** (after every `SessionStart`): a session silent for more than 24 h without
|
|
525
|
+
`SessionEnd` is finished.
|
|
526
|
+
- **What it sends:** the remaining turns, the outcome patches and `session_end` with reason
|
|
527
|
+
`crashed`, followed by the summaries.
|
|
528
|
+
- **Clean-up:** the state of ended sessions is deleted after 30 days.
|
|
529
|
+
- **Session title** (ADR-0026): the transcript's `custom-title` (`/rename`) and `ai-title` entries are tracked
|
|
530
|
+
while it is read; after every hook the effective title (the `/rename` title wins over the generated one, the latest
|
|
531
|
+
of each kind wins) is sent as a `session_title` patch when it differs from the one last sent (`title.sent` in the
|
|
532
|
+
session state). A resumed session first seen mid-way starts at the end of its file, so a title written before that
|
|
533
|
+
is sent only once Claude Code writes it again. Codex sends none.
|
|
534
|
+
- **Summaries** run at `SessionEnd`, and earlier once 10 turns are waiting or the first waiting turn
|
|
535
|
+
has waited 10 minutes (`summariesDue`, ADR-0023; the wait starts at the hook that first saw the turn
|
|
536
|
+
waiting, `digestsSince` in the session state): `claude -p --model haiku --output-format json
|
|
537
|
+
--no-session-persistence --tools ""`, with the batched prompt on stdin and `AI_OBS_SUMMARIZING=1`.
|
|
538
|
+
The command runs in the OS temp dir, and the reply's `result` field is parsed.
|
|
539
|
+
|
|
540
|
+
### Codex adapter (`src/adapters/codex/`)
|
|
541
|
+
|
|
542
|
+
The real hook and rollout format is documented in
|
|
543
|
+
[`src/adapters/codex/FORMAT.md`](src/adapters/codex/FORMAT.md): the fields, the versions seen, and how
|
|
544
|
+
sub-agents and session end show up.
|
|
545
|
+
|
|
546
|
+
**Hooks.** The adapter writes `SessionStart`, `UserPromptSubmit`, `Stop`, `SubagentStop` and `SessionEnd`
|
|
547
|
+
to `~/.codex/hooks.json`, or to `<repo>/.codex/hooks.json` with `--project`. It appends its own
|
|
548
|
+
matcher groups and leaves other hooks alone.
|
|
549
|
+
|
|
550
|
+
**SessionStart output.** Codex gets the question as plain text on stdout. codex-cli 0.154.0 rejected the JSON form, so the switch `SESSION_START_OUTPUT` in `src/nudge.ts` is `text` for Codex (see [FORMAT.md](src/adapters/codex/FORMAT.md), "Hook output"). The Codex hook entries are not changed by this release, so they need no new trust.
|
|
551
|
+
|
|
552
|
+
**After `init`, open Codex once and run `/hooks` to trust the new hooks.** Codex does not run untrusted
|
|
553
|
+
hooks. `ai-obs doctor` lists the ai-obs hooks that are still untrusted.
|
|
554
|
+
|
|
555
|
+
| Module | Role |
|
|
556
|
+
|---|---|
|
|
557
|
+
| `index.ts` | The adapter: hook dispatch, cursor sync, `SubagentStop` re-resolution, idle sweep |
|
|
558
|
+
| `session.ts` | Main-thread state machine: turns, outcome patches, digests, session end |
|
|
559
|
+
| `accumulator.ts` | Per-run accumulation: token deltas, phase timeline, tools, checks, patch facts |
|
|
560
|
+
| `records.ts` / `rollout.ts` | Rollout line parsing and incremental, byte-cursor reading |
|
|
561
|
+
| `tools.ts` | Classifying Codex tools, parsing `apply_patch` and exit codes |
|
|
562
|
+
| `subagents.ts` | Turning sub-agent threads (their own rollouts) into `run` events |
|
|
563
|
+
| `hooks-config.ts` / `detect.ts` / `locate.ts` | Merging `hooks.json`, trust and feature checks, version, finding rollouts |
|
|
564
|
+
| `summarize.ts` | `codex exec --ephemeral … -c features.hooks=false -m <smallest listed model> -` |
|
|
565
|
+
|
|
566
|
+
How the adapter works:
|
|
567
|
+
|
|
568
|
+
- **`turn_started`.** The `userpromptsubmit` hook (main thread only) sends `turn_started` with the hook time; `turn_id` is the hook payload's `turn_id`, or null.
|
|
569
|
+
- **Tokens** come from the differences between cumulative `total_token_usage` snapshots. Repeated
|
|
570
|
+
snapshots are skipped, and cached input is subtracted from input.
|
|
571
|
+
- **Session state** is stored in `state/codex/<session>.json`. It holds the byte cursor, the open turn,
|
|
572
|
+
the previous turn's outcome and the pending digests. Digests are staged for summaries at `SessionEnd`, once 10 turns are
|
|
573
|
+
waiting, or once the first waiting turn has waited 10 minutes (same `summariesDue` rule as Claude Code,
|
|
574
|
+
ADR-0023); the worker writes the summaries after its flush.
|
|
575
|
+
- **Idle sessions:** a session whose rollout has been idle for more than 1 h without a `SessionEnd`
|
|
576
|
+
(crash or kill) is ended by the sweep with reason `crashed`.
|
|
577
|
+
- **Resumed sessions:** a session that started before `init` and is resumed afterwards is back-filled
|
|
578
|
+
from the start of its rollout.
|
|
579
|
+
|
|
580
|
+
## `ai-obs live`
|
|
581
|
+
|
|
582
|
+
Lists the Claude Code and Codex sessions running on this machine (ADR-0027). **Sessions:** hook state files in
|
|
583
|
+
`~/.ai-obs/state/<harness>` younger than 2 h and not ended, plus Claude Code transcripts under
|
|
584
|
+
`$CLAUDE_CONFIG_DIR` (default `~/.claude`)`/projects/*/<uuid>.jsonl` that have no state file and were written in the last
|
|
585
|
+
30 minutes. **Columns:**
|
|
586
|
+
|
|
587
|
+
| Column | Meaning |
|
|
588
|
+
|---|---|
|
|
589
|
+
| STATUS | `working` or `idle`, with the time since the turn started / ended |
|
|
590
|
+
| HARNESS, PROJECT | Harness and the last path segment of the working directory |
|
|
591
|
+
| TITLE | The `/rename` title, else the generated one (Codex has none) |
|
|
592
|
+
| TOKENS | Input + output + cache read + cache write of the current turn while working, of the last turn while idle, main agent and sub-agents |
|
|
593
|
+
| ≈COST | At default list prices from a local copy of the dashboard's table (`+` = some model has no price, `n/a` = none has). Prices an operator edited are not visible locally |
|
|
594
|
+
| TOOLS, AGENTS, LAST TOOL | Tool calls, sub-agents launched in the turn, the tool called last (`-` when only a stored turn's tool list is known and it used several tools) |
|
|
595
|
+
|
|
596
|
+
On a narrow terminal the least important columns are dropped (LAST TOOL, then AGENTS, then TOOLS) so the title keeps room;
|
|
597
|
+
on a short one the sessions that do not fit are left out and counted in the footer (`N more not shown`). Control
|
|
598
|
+
characters in names and titles are shown as spaces.
|
|
599
|
+
**Order:** working (longest running first), then idle (most recent first). Stale rows are hidden and counted in the
|
|
600
|
+
footer. **Status rules** match the dashboard's: working until an end-of-turn record follows the last activity (a user
|
|
601
|
+
interrupt, an interrupted tool result or an API error also ends it); a working turn is stale after 2 h, an idle session
|
|
602
|
+
after 30 minutes without a signal. **How it reads:** new sessions are found every 5 s; known ones are read on every redraw
|
|
603
|
+
from an in-memory byte cursor (Claude Code starts at the state offset, the start of the first unfinished turn; Codex
|
|
604
|
+
replays the rollout into a copy of the state with the adapter's own reducer, rebuilt whenever the hook writes newer
|
|
605
|
+
state, which also carries the sub-agents' usage; scan-only transcripts are read from the
|
|
606
|
+
last 8 MB). It never writes, and never touches the hook state files or their offsets.
|
|
607
|
+
|
|
608
|
+
## Environment
|
|
609
|
+
|
|
610
|
+
| Variable | Effect |
|
|
611
|
+
|---|---|
|
|
612
|
+
| `AI_OBS_HOME` | Collector home (default `~/.ai-obs`). It is passed on to the worker |
|
|
613
|
+
| `AI_OBS_ENDPOINT` | Default `--server` for `login` and `init --token` only. Each token keeps its own server, so it has no effect at runtime |
|
|
614
|
+
| `AI_OBS_SUMMARIZING` | Set by the collector on its own summarizer process. When it is set, every `ai-obs` invocation exits 0 immediately |
|
|
615
|
+
| `AI_OBS_CODEX_SUMMARY_MODEL` | Model for Codex turn summaries. By default it is the smallest listed model in `$CODEX_HOME/models_cache.json` |
|
|
616
|
+
| `CODEX_HOME` | Codex home (default `~/.codex`). It is read from the hook environment |
|
|
617
|
+
| `CLAUDE_CONFIG_DIR` | Claude Code config dir (default `~/.claude`). It holds the user `settings.json` the hooks are written to, and the transcript location used when a hook payload has no `transcript_path` |
|
|
618
|
+
|
|
619
|
+
## Privacy guarantees
|
|
620
|
+
|
|
621
|
+
- **Never sent:** prompts, responses, code, diffs or file paths (ADR-0008). The only conversation-derived text
|
|
622
|
+
that leaves the machine is the ≤ 140-char turn summary and the ≤ 140-char session title (Claude Code's `/rename` or
|
|
623
|
+
generated title, ADR-0026). Before either is sent, it is whitespace-collapsed and any path in it is reduced to its
|
|
624
|
+
last segment.
|
|
625
|
+
- **Project identity:** the normalized git remote, with credentials, `user@` and port stripped. The queue keeps the plain key and plain skill / MCP names (local files, mode 0600); they are hashed or named **at send time** with the receiving deployment's salt and naming mode, so each server sees only its own form. A plain path key never leaves the machine. The dot-file team slug is sent with `session_start`; the server decides whether to use it.
|
|
626
|
+
- **`hashed` mode:** `sha256:` + a hash of that remote, with no name. When collector-config sends a
|
|
627
|
+
per-deployment `project_salt` (this server does, since migration 0004), the hash is HMAC-SHA256 keyed by it (the same on every machine, so
|
|
628
|
+
keys still agree). Without one (an older server) it is plain sha256, which anyone with the key (the server, DB
|
|
629
|
+
access) can reverse with a dictionary of org/repo names, so treat `hashed` as protection against
|
|
630
|
+
casual reading, not against the operator. A salt the server stores is still known to the server;
|
|
631
|
+
it stops cross-deployment and precomputed dictionaries, not a determined operator.
|
|
632
|
+
- **No remote (both modes):** `sha256:` + HMAC-SHA256 of the absolute path keyed by the local
|
|
633
|
+
`~/.ai-obs/project-secret`, never the path itself. The server cannot reverse it to a
|
|
634
|
+
home-directory path (or the OS user name in it). The key is per machine; if the secret cannot be
|
|
635
|
+
created, the collector falls back to plain sha256 of the path. Keys made before this change
|
|
636
|
+
(plain sha256) differ, so such a project shows up once more as a new project.
|
|
637
|
+
- A token whose collector-config was never fetched sends nothing (its events wait), so no unsalted hash of a project is created.
|
|
638
|
+
- **Skill and MCP names** (ADR-0016) follow the same mode: plain skill, MCP server and tool names in `plain`, `sha256:` +
|
|
639
|
+
HMAC-SHA256 under the deployment salt (plain sha256 without one) of `skill:<name>`, `mcp:<server>`, `mcp:<server>/<tool>` in
|
|
640
|
+
`hashed`, applied by the sender when it sends the batch. Skill arguments and MCP inputs / outputs are never read.
|
|
641
|
+
MCP reliability (`mcp_stats`: errors, refused calls, latency histogram) comes from pairing each call with its result;
|
|
642
|
+
error texts are only matched locally (to tell a refusal from an error) and never sent.
|
|
643
|
+
- **Hook job files** keep only harness-related env vars (`CLAUDE_*`, `CODEX_*`, `AI_OBS_*`) and drop
|
|
644
|
+
any whose name suggests a secret. Job files are deleted after processing.
|
|
645
|
+
- **`ai-obs live`** reads local state files and transcripts only: it sends nothing and writes nothing, and works without a login.
|
|
646
|
+
- **Local only:** prompt digests for summaries (including the staged files), edit fingerprints for revert detection (hashes only)
|
|
647
|
+
and next-prompt correction checks.
|
|
648
|
+
- No git e-mail is sent: the token identifies the user (the batch `identity` is empty).
|
|
649
|
+
- The SessionStart text carries only hosts, server URLs, team slugs and the sanitized directory, never a token or an e-mail.
|
|
650
|
+
- Tokens live only in `~/.ai-obs/config.json` (0600; after an upgrade from 0.4 also in its copy `config.json.v2`, 0600), never in a repository: the dot file holds a server URL and a team slug, and a token is only ever sent to the server it was issued for.
|
|
651
|
+
|
|
652
|
+
## Development
|
|
653
|
+
|
|
654
|
+
```sh
|
|
655
|
+
pnpm --filter ai-obs-cli typecheck
|
|
656
|
+
pnpm --filter ai-obs-cli test # = vitest run --project collector (no network: local http servers, temp homes)
|
|
657
|
+
pnpm --filter ai-obs-cli build # dist/; the real-process hook tests (test/hook-process.test.ts) run only when dist exists
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Test helpers (`test/helpers.ts`): `writeTestConfig` / `writeTestServers` (registered servers with their caches), `writeDotFile`, `ccValue` (a collector-config body), `tokenResponder` (per-token fake server), `okResponder`, `gitRepo`, and `queueStore` / `enqueueFor` (`test/queue-helpers.ts`). The definition-of-done test is `test/servers.integration.test.ts`: local servers with device login, the real CLI (`login`, `link`), hook and worker; `test/login.test.ts` drives the device flow against a `node:http` server.
|
|
661
|
+
|
|
662
|
+
The Claude Code fixture turn ids are prefixed `cc-fixture-`, so demos that send them do not collide with
|
|
663
|
+
`packages/protocol/fixtures/valid-batch.json` (`turn-1`, …); a test guards this.
|
|
664
|
+
|
|
665
|
+
`src/protocol-types.ts` is a dependency-free copy of the `@ai-obs/protocol` types.
|
|
666
|
+
`test/protocol-conformance.test.ts` type-checks it against the package and validates the builders'
|
|
667
|
+
output with `validateBatch()`.
|