@agentstrack/collector 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +209 -1
  2. package/README.md +114 -38
  3. package/dist/adapters/claude.d.ts +18 -0
  4. package/dist/adapters/claude.js +153 -45
  5. package/dist/adapters/claude.js.map +1 -1
  6. package/dist/adapters/codex.d.ts +15 -1
  7. package/dist/adapters/codex.js +87 -34
  8. package/dist/adapters/codex.js.map +1 -1
  9. package/dist/adapters/opencode.d.ts +17 -6
  10. package/dist/adapters/opencode.js +72 -26
  11. package/dist/adapters/opencode.js.map +1 -1
  12. package/dist/adapters/types.d.ts +16 -0
  13. package/dist/adapters/types.js +61 -0
  14. package/dist/adapters/types.js.map +1 -1
  15. package/dist/cli.js +164 -33
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/service.js +40 -10
  18. package/dist/commands/service.js.map +1 -1
  19. package/dist/config.d.ts +1 -1
  20. package/dist/config.js +18 -5
  21. package/dist/config.js.map +1 -1
  22. package/dist/daemon.d.ts +73 -21
  23. package/dist/daemon.js +350 -119
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/git/commits.d.ts +7 -1
  26. package/dist/git/commits.js +36 -17
  27. package/dist/git/commits.js.map +1 -1
  28. package/dist/git/repo.d.ts +13 -4
  29. package/dist/git/repo.js +34 -20
  30. package/dist/git/repo.js.map +1 -1
  31. package/dist/machine.d.ts +27 -0
  32. package/dist/machine.js +46 -0
  33. package/dist/machine.js.map +1 -0
  34. package/dist/privacy/pipeline.d.ts +6 -0
  35. package/dist/privacy/pipeline.js +41 -7
  36. package/dist/privacy/pipeline.js.map +1 -1
  37. package/dist/privacy/redact.d.ts +15 -2
  38. package/dist/privacy/redact.js +45 -6
  39. package/dist/privacy/redact.js.map +1 -1
  40. package/dist/queue/event-id.d.ts +9 -0
  41. package/dist/queue/event-id.js +15 -0
  42. package/dist/queue/event-id.js.map +1 -0
  43. package/dist/queue/spool.d.ts +24 -5
  44. package/dist/queue/spool.js +89 -33
  45. package/dist/queue/spool.js.map +1 -1
  46. package/dist/queue/tailer.d.ts +27 -4
  47. package/dist/queue/tailer.js +89 -28
  48. package/dist/queue/tailer.js.map +1 -1
  49. package/dist/sessions/title.d.ts +14 -2
  50. package/dist/sessions/title.js +18 -6
  51. package/dist/sessions/title.js.map +1 -1
  52. package/dist/transport/client.d.ts +37 -13
  53. package/dist/transport/client.js +50 -3
  54. package/dist/transport/client.js.map +1 -1
  55. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -10,6 +10,204 @@ released as a major version, with a migration note in this file.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.1] — 2026-09-05
14
+
15
+ ### Fixed
16
+
17
+ - **A session title could ship a piece of a secret (security).** `derived_title` is the first
18
+ meaningful line of your prompt, cut to 120 characters. Until now that cut was made on the **raw**
19
+ prompt and the secret scan ran afterwards, on the already-shortened title. If a key happened to
20
+ straddle the 120-character boundary, the cut split it in two, the leftover head no longer looked
21
+ like a key to any pattern, and it was uploaded as ordinary title text. A prompt of a hundred
22
+ characters followed by an `sk-ant-…` key produced a title ending in `sk-ant-api03-AAAAA…` while
23
+ the event dutifully reported `secrets_redacted: [{ anthropic_key, 1 }]` — the tally was right and
24
+ the title still carried a fragment.
25
+
26
+ The prompt is now redacted **before** the title is taken from it, in all three adapters (Claude
27
+ Code, Codex, OpenCode), so a truncation can only ever cut through a `[REDACTED:…]` marker. The
28
+ privacy pipeline still redacts `derived_title` afterwards; that pass is now a no-op and stays in
29
+ place as defence in depth.
30
+
31
+ The same cut-then-scan mistake applied to a Codex `error` event's `message`, truncated to 1000
32
+ characters; it is redacted before truncation now too.
33
+
34
+ **What to do:** only a partial value could escape, never a whole one, and only when a secret sat
35
+ across the cut. But a fragment is enough to identify which key was pasted, and the rest of it may
36
+ be guessable from context. If you have used `analytics` or `full` mode, look through your existing
37
+ session titles for key-shaped fragments, and rotate anything you find. Titles produced from 0.4.1
38
+ on are safe.
39
+
40
+ Fragments already uploaded stay in the product until you delete those sessions — upgrading the
41
+ collector does not rewrite history.
42
+
43
+ ### Changed
44
+
45
+ - Documented the ordering (redact, then truncate) in the README and `docs/EVENT_SCHEMA.md`, and
46
+ corrected two stale lines there that still said `privacy.prompts: never` leaves `derived_title`
47
+ in place under `mode: full`. It has not since 0.4.0 — `never` drops it in every mode.
48
+
49
+ ## [0.4.0] — 2026-09-05
50
+
51
+ ### Added
52
+ - **Secret exposure is reported as metadata.** When local redaction fires, the event now carries
53
+ `secrets_redacted: [{ kind, count }]` — which pattern matched and how many times, sorted by kind
54
+ and omitted entirely when nothing fired. The matched value never travels: not the text, not a
55
+ prefix of it, not a hash, not the surrounding context. The tally is computed **before** the mode
56
+ strip, so it survives `metadata` mode — the mode where a team most wants to know a credential was
57
+ typed into an agent and least wants the credential itself. An org-supplied rule reports as the
58
+ single generic kind `org_rule`, because a rule name can itself describe the shape of that
59
+ organization's secrets.
60
+
61
+ ## [0.3.0] — 2026-09-05
62
+
63
+ ### Added
64
+ - **Skills, sub-agents and workflows are named.** A Claude Code `tool_use` named `Skill` carries
65
+ `skill`; `Agent` carries `subagent_type` and `description`; `Workflow` carries `workflow_name`
66
+ (the `name` from the script's `meta` header, when it is that simple). The `Agent` call's prompt and the
67
+ script body are never copied out of the call; a sub-agent transcript's own opening prompt is a
68
+ prompt like any other and follows `privacy.prompts`. The extras ride on `tool.started` and on the matching
69
+ `tool.completed` / `tool.failed`.
70
+ - **Sub-agent transcripts are attributed.** Claude Code writes each sub-agent to
71
+ `<session-uuid>/subagents/[workflows/<wf>/]agent-<id>.jsonl` with the parent's session id; the
72
+ tailer already walked them, but nothing said which lines were the sub-agent's. Every event from
73
+ such a file (or any line with `isSidechain: true`) now carries `sidechain: true`, `agent_id`,
74
+ `agent_kind` (`subagent` | `workflow`) and, from the sibling `agent-<id>.meta.json`, `agent_type`
75
+ — so the sub-agent's own `model.response` usage can be attributed to it server-side.
76
+ - **`user.prompted.ultracode`**, `true` when the prompt contains the whole word `ultracode`
77
+ (case-insensitive). Computed locally before redaction, so it survives `metadata` mode; the prompt
78
+ does not travel to be inspected.
79
+ - **Machine info on register and health.** Alongside `hostname`, `os` and `arch` the collector now
80
+ sends `os_release` and `machine_kind` — `ci` (a CI env var), `container`
81
+ (`/.dockerenv` or a docker/containerd/kubepods cgroup), `workstation` (macOS, Windows, or Linux
82
+ with a display), `server` (headless Linux), else `unknown`. Health repeats it, so a device that
83
+ changes shape is updated. See the README privacy section for what the server does with it.
84
+
85
+ ### Changed
86
+ - `description` (the Agent tool's one-line label) is treated like a title: secret-redacted, and
87
+ dropped in `metadata` mode.
88
+
89
+ ### Security
90
+ - **`api_url` must be `https`.** The bearer API key rides on every request, so `http` is now rejected
91
+ for any host except `localhost`/`127.0.0.1`/`[::1]`. `login` prints the URL it is about to use.
92
+ - **The API key can stay off the command line.** `agentstrack login` now takes the key as an optional
93
+ argument and otherwise reads `AGENTSTRACK_API_KEY`, an echo-off terminal prompt, or stdin
94
+ (`agentstrack login < key.txt`), keeping it out of shell history and `ps`.
95
+ - **`config.yaml` is written atomically at mode 600** — a temp file created 0600 then renamed over the
96
+ target, so a crash can no longer leave a truncated config or a brief world-readable window.
97
+ - **Org redaction rules are bounded.** A server-supplied pattern that is malformed, over 256
98
+ characters, or using a backreference is skipped (as a malformed rule already was), the common
99
+ catastrophic nested-quantifier shapes (`(a+)+`, `(a|aa)+`, `((a+)b)+`) are rejected, and org rules
100
+ match only the first 64 KB of a value — a pathological pattern is much less likely to hang the
101
+ single-threaded daemon.
102
+
103
+ ### Fixed
104
+ - **Claude Code tokens and cost were inflated ~1.8x.** Claude Code writes one `assistant` line per
105
+ content block of a single response, each repeating the same `message.id` and usage; every line
106
+ became a `model.response`. Usage is now emitted once per `message.id`.
107
+ - **Claude Code tool outcomes were all named `unknown`.** `tool_result` blocks carry only a
108
+ `tool_use_id`; the name is now resolved from the `tool_use` that started the call.
109
+ - **Claude Code prompts pasted with an image (or as text blocks) were never counted**, and slash
110
+ command echoes (`<command-name>`, `<command-message>`, `<local-command-stdout>`,
111
+ `<task-notification>`) were counted as human prompts. Both fixed.
112
+ - **Codex sessions went dark after a collector restart.** The session id lived only in memory from
113
+ `session_meta`; it is now seeded from the rollout file name, which carries the same uuid.
114
+ - **Codex cached tokens were billed twice.** `cached_input_tokens` is a subset of `input_tokens`
115
+ in Codex; the collector now subtracts it so the two are exclusive, as the schema documents.
116
+ - **Codex shell commands landed as `shell` with `duration_ms: 0` and never failed.**
117
+ `exec_command_end` carries an argv list, a `{secs,nanos}` duration and a numeric exit code;
118
+ `function_call_output.output` is a string. Both are read as they really are, and `tool.failed`
119
+ is emitted on a non-zero exit.
120
+ - **OpenCode `session.ended` was rejected by the server on every emit** (missing
121
+ `external_session_id`, `reason` outside the enum). It now sends `reason: normal` with the
122
+ archived/compacted distinction in `end_kind`.
123
+ - **OpenCode resumed sessions never got a `session.started`.** Sessions are fetched by
124
+ `time_updated` but starts were gated on a `time_created` cursor; a per-session marker in the
125
+ spool's meta store replaces it.
126
+ - **`logout` now tears the service down first.** It previously left the launchd/systemd unit
127
+ installed, so the supervisor kept restarting an unauthenticated collector. Both units now restart
128
+ only on a crash (launchd `KeepAlive`/`SuccessfulExit`, systemd `Restart=on-failure` with a
129
+ 5-in-5-minutes start limit), and the unauthenticated foreground path exits cleanly so the
130
+ supervisor idles.
131
+ - **Service units handle paths with spaces and non-ASCII characters.** The CLI path is resolved with
132
+ `fileURLToPath` instead of a percent-encoded `URL.pathname`, plist strings are XML-escaped, and
133
+ systemd `ExecStart` arguments are quoted. `AGENTSTRACK_HOME` is written into the unit when set.
134
+ - **A second foreground `start` refuses to run** when one is already collecting (checked via the pid
135
+ file, created with `wx`), preventing two collectors from racing on one spool.
136
+ - **`stop` verifies the pid still belongs to a collector** (via `ps`) before signalling it, so a
137
+ recycled pid in a stale pid file is not killed.
138
+ - **`doctor` reports the real scan window.** It now prints `modified in the last N day(s)` using
139
+ `tracking.max_age_days` instead of a hard-coded "7 days".
140
+ - **`git.commit` polling stops re-diffing history every tick.** Each repo's `git log --since` now
141
+ starts from its last poll, SHAs are listed before any diffstat so `git show --numstat` runs only for
142
+ commits not yet emitted, and the emitted-SHA guard is pruned by age instead of cleared wholesale, so
143
+ a commit inside the lookback window is never re-emitted.
144
+
145
+ ### Changed
146
+ - `detect()` reads only the first 16 KB of the newest transcript (by mtime) for the agent version,
147
+ cached on mtime, instead of the whole file every 5 s. OpenCode keeps one read-only database
148
+ handle with prepared statements rather than opening and closing one per query, and caches the
149
+ version for 60 s.
150
+ - The contract test now runs every adapter fixture through the server's own payload schemas when
151
+ the server checkout is present, and fails (rather than skips) when `AGENTSTRACK_SERVER_REPO` is
152
+ set but missing.
153
+ - **Node floor is `>=22`** (was `>=24`), matching `.nvmrc` and the runtime the code actually needs;
154
+ CI now tests Node 22 and 24.
155
+ - `status` shows an upload-paused reason when one is present.
156
+
157
+ ### Fixed — daemon, queue and transport
158
+ - **The upload failure policy ran once per concurrent batch, not once per wave.** With
159
+ `upload.concurrency: 4` a dead API escalated the backoff counter by four per wave (5-minute waits
160
+ after two waves), slept inside the wave so tailing froze for the duration, and a sibling's success
161
+ reset the counter or undid a `413` halving. `sendBatch` now returns a pure outcome and `flush()`
162
+ applies the policy once on the wave: halve once, one backoff step, strikes only for poison
163
+ batches, counter reset only when the whole wave succeeded. Backoff sets a next-upload time instead
164
+ of sleeping, honours `Retry-After` / `Retry-After-ingest` as the minimum, and a daemon tick sends
165
+ at most five waves before scanning again. Covered by `src/queue/flush.test.ts`.
166
+ - **`401`/`403` no longer count strikes against telemetry.** They pause uploads (`status` shows the
167
+ reason), as does an over-quota `200` — previously acked and dropped locally with the `quota` block
168
+ ignored — and a `200` that rejects every event as malformed, which now logs a version-mismatch
169
+ error instead of deleting the batch.
170
+ - **Checkpoints were written before the events were spooled.** A throw between the two lost those
171
+ lines for good. The tailer now streams in 4 MB chunks and commits each chunk's checkpoint in the
172
+ same transaction as its events; a line over 8 MB is skipped to the next newline and counted, at
173
+ most 64 MB per file is read per scan, short reads are looped, and one unreadable file no longer
174
+ aborts the scan for every file after it.
175
+ - **Re-reading a transcript double-counted on the server.** `event_id` was a fresh `randomUUID` per
176
+ spool write; it is now derived from the adapter, file, byte offset and line content (a UUID v8
177
+ shape), so a rotated inode, a purged spool or a second collector on the same files dedupes.
178
+ - **`VERSION` was hard-coded `0.1.0`.** It is read from `package.json`; the CLI, register and health
179
+ report the published version, and every request carries `User-Agent: agentstrack-collector/<v>`.
180
+ - **A Claude Code response spanning a restart was billed twice.** `model.response` is now keyed on
181
+ `message.id` (the daemon's deterministic id, seeded by the adapter), not on the line, so the
182
+ server's dedupe absorbs the second line's copy after a restart.
183
+ - **One rejected event paused every upload as a "schema mismatch".** A single-event batch the
184
+ server rejects is now struck like any other poison event; the pause is reserved for a whole
185
+ batch rejected without a quota reason.
186
+ - **A failed commit left the checkpoint cache ahead of disk.** `setCheckpoint()` updated the
187
+ in-memory Map before COMMIT; the cache is now reloaded from the table when a transaction throws.
188
+ - **OpenCode cursors and started-markers were written before the events they covered.** They are
189
+ now buffered and committed in the same transaction as the enqueue, so a full disk cannot mark a
190
+ session started that was never spooled.
191
+ - **systemd `WorkingDirectory=` was quoted.** Path-typed settings are not unquoted by systemd; the
192
+ unit now writes the bare path (`ExecStart=` keeps its quoting).
193
+ - **Claude Code and Codex sessions never ended.** The daemon emits `session.ended`
194
+ (`reason: timeout`) for a session quiet longer than `tracking.idle_timeout_seconds`, stamped at
195
+ the moment the timeout elapsed, and `reason: unknown` for anything still open on shutdown.
196
+ - The batch response is validated with zod at the trust boundary; an unreadable body is retried,
197
+ never acked. Rejected `allSettled` outcomes are logged instead of swallowed. Local `batch_size` is
198
+ clamped to the server's `max_batch_events`.
199
+
200
+ ### Changed — daemon, queue and transport
201
+ - The spool is `VACUUM`ed at open when the freelist is both over 2048 pages and more than half the
202
+ file (a 206 MB spool holding 78 events was observed), the WAL is capped at 8 MB
203
+ (`journal_size_limit`), statements are prepared once, checkpoints are cached in memory so an
204
+ unchanged file costs no SQL, and WAL/SHM are chmod 600 after they exist.
205
+ - `describeRepo` is cached per scan pass; `flush()`, `reportHealth()` and `depth()` are inside the
206
+ loop's try/catch; the log rotates once at 5 MB to `collector.log.1` and a scan pass writes one
207
+ `Queued N events across M files` line instead of one per file.
208
+ - Org redaction rules are compiled once per server-config refresh and handed to the privacy
209
+ pipeline pre-compiled, instead of being recompiled for every event.
210
+
13
211
  ## [0.2.1] — 2026-08-31
14
212
 
15
213
  ### Fixed
@@ -203,5 +401,15 @@ As released. Several of these have since been fixed — see `## Unreleased` abov
203
401
  - The service installer supports launchd and systemd only. `agentstrack start --foreground` works
204
402
  anywhere Node 20+ does.
205
403
 
206
- [Unreleased]: https://github.com/agentstrack/collector/compare/v0.1.0...HEAD
404
+ <!-- 0.2.0 and 0.4.0 have no tag: this repository's history was squashed before
405
+ it was made public, and no commit in it carries either version. The releases
406
+ were real (0.2.0 is on npm) and their notes stay above; the compare links
407
+ simply skip to the neighbouring tag that does exist. -->
408
+
409
+ [Unreleased]: https://github.com/agentstrack/collector/compare/v0.4.1...HEAD
410
+ [0.4.1]: https://github.com/agentstrack/collector/compare/v0.3.0...v0.4.1
411
+ [0.4.0]: https://github.com/agentstrack/collector/compare/v0.3.0...v0.4.1
412
+ [0.3.0]: https://github.com/agentstrack/collector/compare/v0.2.1...v0.3.0
413
+ [0.2.1]: https://github.com/agentstrack/collector/compare/v0.1.0...v0.2.1
414
+ [0.2.0]: https://github.com/agentstrack/collector/compare/v0.1.0...v0.2.1
207
415
  [0.1.0]: https://github.com/agentstrack/collector/releases/tag/v0.1.0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@agentstrack/collector.svg)](https://www.npmjs.com/package/@agentstrack/collector)
6
6
  [![CI](https://github.com/agentstrack/collector/actions/workflows/ci.yml/badge.svg)](https://github.com/agentstrack/collector/actions/workflows/ci.yml)
7
7
  [![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
8
- [![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen.svg)](https://nodejs.org)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
9
9
 
10
10
  `@agentstrack/collector` turns the records Claude Code, Codex and OpenCode already keep on your
11
11
  machine into a normalized event stream:
@@ -30,7 +30,7 @@ before anything is: [Verify it yourself](#verify-it-yourself).
30
30
  ## Quick start
31
31
 
32
32
  ```bash
33
- npm install -g @agentstrack/collector # requires Node >= 24
33
+ npm install -g @agentstrack/collector # requires Node >= 22
34
34
 
35
35
  agentstrack login at_live_xxxxxxxx_xxxxxxxx # key from Settings → API keys
36
36
  agentstrack start # installs a login service and starts collecting
@@ -86,8 +86,10 @@ Nothing showing up? Run `agentstrack doctor`.
86
86
  | File paths, **relative to the project root by default** | Send absolute paths — which leak your username and your clients' names — unless you opt in |
87
87
  | Lines added/removed per edit, computed locally from the tool input | Send the lines themselves |
88
88
  | Git branch, commit SHA, additions/deletions/files changed | Send your git remote URL (only a SHA-256 of it) or commit messages and diffs |
89
- | Locally generated session titles (`analytics` mode and above) | Send the prompt those titles were derived from |
90
- | Your hostname, OS, arch and each detected agent's version — **once, at registration** | Store the raw hostname server-side (it is kept only as a SHA-256) |
89
+ | A session title — the first line of the prompt, secret-redacted and then capped at 120 chars (`analytics` mode and above) | Send the rest of the prompt those titles were taken from |
90
+ | Your hostname, OS and release, arch, Node version, a coarse machine kind (`workstation` / `server` / `container` / `ci`) and each detected agent's version — at registration and with each health report | Show your hostname or IP address in the product see [Machine info](#machine-info) |
91
+ | That local redaction fired: which pattern matched and how many times (`secrets_redacted`), in every privacy mode | Send the secret it matched — not the text, not a prefix of it, not a hash of it, not the characters around it |
92
+ | Which skill, sub-agent type or workflow a Claude Code session invoked, and which events came from a sub-agent | Copy the `Agent` call's prompt or a workflow script's body out of the call (a sub-agent transcript's own opening prompt follows `privacy.prompts` like any other prompt) |
91
93
  | | Install hooks or modify `~/.claude/settings.json` / `~/.codex/hooks.json` |
92
94
  | | Send `organization_id` or `user_id` — they are not in the wire format at all |
93
95
  | | Watch your keyboard, your screen, or any process on your machine |
@@ -177,7 +179,7 @@ agentstrack doctor --json # structured diagnostics, safe to paste
177
179
 
178
180
  ┌────────────────────────┐
179
181
  │ privacy pipeline │ mode-based content strip
180
- │ │ → 15 built-in secret rules + org rules
182
+ │ │ → 16 built-in secret rules + org rules
181
183
  │ │ → path normalization
182
184
  │ │ → raw content DISCARDED HERE
183
185
  └───────────┬────────────┘
@@ -198,8 +200,20 @@ collector keeps parsing and keeps spooling; when the network returns it drains o
198
200
  over 1 KB are gzipped.
199
201
 
200
202
  **Restart is safe.** File read offsets live in the same SQLite database as the queue, keyed by
201
- `(path, inode)`. A restart resumes mid-file. If a file is replaced (new inode) or truncated (offset
202
- past the end), it is re-read from the start rather than silently skipped.
203
+ `(path, inode)`, and a chunk's offset is committed in the **same transaction** as the events parsed
204
+ from it a crash or a full disk between "read" and "queued" re-reads those lines rather than losing
205
+ them. A restart resumes mid-file. If a file is replaced (new inode) or truncated (offset past the
206
+ end), it is re-read from the start rather than silently skipped. Re-reading never double-counts:
207
+ every event's `event_id` is derived from the file, the line's byte offset and the line's content, so
208
+ the server's dedupe absorbs a replay. A Claude Code `model.response` is keyed on its `message.id`
209
+ instead, since one response spans several lines; an idle `session.ended` on the agent, session and
210
+ last-activity time. Only events with no source line (`git.commit`, database-backed agents) get a
211
+ random id.
212
+
213
+ **Big files are streamed, not slurped.** Transcripts are read in 4 MB chunks with the partial line
214
+ carried across the boundary, at most 64 MB per file per scan so a first import keeps yielding to the
215
+ upload loop. A single line over 8 MB is skipped to the next newline and counted in the log — never
216
+ buffered, never logged. One unreadable file is logged and skipped; it cannot stall the other files.
203
217
 
204
218
  **A line the agent is still writing is never consumed.** The tailer advances its checkpoint only as
205
219
  far as the **last complete newline**; a partial trailing line is left unread and picked up whole on
@@ -209,9 +223,18 @@ so both halves would fail to parse and that event would be lost. Byte offsets ar
209
223
  raw buffer, not from decoded text, so a multi-byte character cannot desynchronise the position
210
224
  either.
211
225
 
212
- **Backpressure is handled.** A `413` halves the batch size and the collector recovers it on the next
213
- success. A `5xx`, a timeout, a `408` or a `429` is retried with jittered exponential backoff (1s
214
- base, capped at 5 minutes). A `4xx` that is none of those means the server will never accept the
226
+ **Backpressure is handled, once per wave.** Batches go out `upload.concurrency` at a time, and the
227
+ failure policy runs on the wave's collected outcomes rather than inside each request so four
228
+ failing siblings cost one backoff step, not four, and a sibling's success cannot undo a `413`
229
+ shrink. A `413` halves the batch size once (never above the server's `max_batch_events`) and it
230
+ creeps back up on success. A `5xx`, a timeout, a `408` or a `429` sets the next upload time with
231
+ jittered exponential backoff (1s base, capped at 5 minutes, or the server's `Retry-After` if longer)
232
+ — the daemon never sleeps on it, so tailing continues meanwhile. A daemon tick sends at most five
233
+ waves before scanning again. Three responses **pause** uploads instead: a `401`/`403` (the key, not
234
+ the events, is the problem), a `200` whose `quota.exceeded` says the org is over its monthly cap,
235
+ and a `200` that rejects every event as malformed (the collector is probably older than the server).
236
+ Nothing is acked or dropped while paused; `agentstrack status` shows the reason, and the pause lifts
237
+ by itself once a wave succeeds. A `4xx` that is none of those means the server will never accept the
215
238
  batch: **the attempt counter is incremented for the events in that batch only, and one of them is
216
239
  deleted once it reaches `upload.max_retries` (default 8)**. It is not parked and it does not come
217
240
  back — a poison event must not be able to block the queue forever.
@@ -243,6 +266,9 @@ Two properties of that deletion are worth stating explicitly, because both are e
243
266
 
244
267
  `analytics` is the honest middle: the title is computed **on your machine** from the prompt, and then
245
268
  the prompt is deleted. The server receives `"fix flaky auth test"`, never the 900 words you typed.
269
+ The prompt is redacted **before** the title is cut out of it, so the cut can only ever land inside a
270
+ `[REDACTED:…]` marker and never through the middle of a key. Before 0.4.1 it was cut first, which
271
+ could ship half of a secret — see the CHANGELOG.
246
272
 
247
273
  If even the title is too much, `privacy.prompts: never` drops that too: in `analytics` it strips
248
274
  `derived_title`, so nothing derived from a prompt leaves the machine, without giving up token, tool
@@ -266,8 +292,8 @@ daemon cannot drift.
266
292
 
267
293
  ### Built-in secret redaction
268
294
 
269
- Every free-text field that survives the mode strip (`prompt_text`, `derived_title`, `message`,
270
- `command`) passes through these 15 rules, most-specific first, on your machine:
295
+ Every free-text field (`prompt_text`, `derived_title`, `message`, `command`, `description`) passes
296
+ through these 16 rules, most-specific first, on your machine:
271
297
 
272
298
  | Rule | Catches |
273
299
  |---|---|
@@ -284,19 +310,43 @@ Every free-text field that survives the mode strip (`prompt_text`, `derived_titl
284
310
  | `jwt` | three-segment `eyJ…` tokens |
285
311
  | `bearer_header` | `Bearer <token>` → `Bearer [REDACTED]` |
286
312
  | `basic_auth_url` | `https://user:pw@host` → `https://[REDACTED]@host` |
313
+ | `inline_password_flag` | `-pSECRET`, `--password=SECRET`, `--password "SECRET"` |
287
314
  | `env_assignment` | `*SECRET*=`, `*TOKEN*=`, `*PASSWORD*=`, `*PASSWD*=`, `*APIKEY*=`, `*API_KEY*=`, `*ACCESS_KEY*=`, `*PRIVATE_KEY*=` |
288
315
  | `generic_hex_secret` | bare hex strings of 40+ characters |
289
316
 
290
- A match is replaced in place, and most rules substitute `[REDACTED:rule_name]`. Four do not:
317
+ A match is replaced in place, and most rules substitute `[REDACTED:rule_name]`. Five do not:
291
318
  `bearer_header` → `Bearer [REDACTED]` and `basic_auth_url` → `scheme://[REDACTED]@host` keep the
292
- surrounding syntax so the shape of the command survives, `env_assignment` → `NAME=[REDACTED]` keeps
293
- the variable name, and `generic_hex_secret` substitutes the shorter `[REDACTED:hex]`. Your
294
- organization can add patterns server-side; a malformed org pattern is skipped rather than breaking
295
- the collector.
319
+ surrounding syntax so the shape of the command survives, `env_assignment` → `NAME=[REDACTED]` and
320
+ `inline_password_flag` `--password=[REDACTED]` keep the variable or flag name, and
321
+ `generic_hex_secret` substitutes the shorter `[REDACTED:hex]`. Your
322
+ organization can add patterns server-side; an org pattern that is malformed, longer than 256
323
+ characters, or using a backreference is skipped, the common catastrophic nested-quantifier shapes
324
+ (`(a+)+`, `(a|aa)+`, `((a+)b)+`) are rejected — a heuristic, not a proof — and org patterns are
325
+ matched against at most the first 64 KB of any value.
296
326
 
297
327
  Redaction is defence in depth, not the primary control. The primary control is that in `metadata`
298
328
  and `analytics` modes the content is **deleted locally** and never enters the pipeline at all.
299
329
 
330
+ ### What a redaction reports
331
+
332
+ When a rule fires, the event carries `secrets_redacted` — a list of `{ kind, count }`, sorted by
333
+ kind, absent when nothing fired:
334
+
335
+ ```json
336
+ "secrets_redacted": [{ "kind": "aws_access_key", "count": 1 }]
337
+ ```
338
+
339
+ Plainly: **we report that a secret-shaped string was found and which pattern matched it. We never
340
+ send the value.** Not the matched text, not a prefix of it, not a hash of it, not the surrounding
341
+ context — there is nothing in the payload to reverse. A rule your organization added reports as the
342
+ single generic kind `org_rule`, because a rule name (`acme_prod_db_password`) can itself describe
343
+ the shape of your secrets.
344
+
345
+ This travels in **every** mode, `metadata` included: the tally is computed before the mode strip
346
+ deletes the text it was computed from. A count is metadata; the prompt it came from is not. And
347
+ `metadata` is exactly the mode where a team most wants to know that a live credential was pasted
348
+ into an agent — the point being to go rotate it, which needs no copy of it.
349
+
300
350
  ### How file paths are handled
301
351
 
302
352
  With `file_paths: relative` (the default):
@@ -311,6 +361,22 @@ The project root itself (`repo.project_path`) is **dropped entirely** in `never`
311
361
  modes — it is only transmitted if you opt into `file_paths: absolute`. Repositories are correlated
312
362
  by `remote_hash`, a SHA-256 of the normalized remote URL, not by path.
313
363
 
364
+ ### Machine info
365
+
366
+ `login` (registration) and the daemon's health report, once a minute, send: `hostname`, `os`
367
+ (`darwin` / `linux` / `win32`), `os_release` (`os.release()`), `arch`, and
368
+ `machine_kind` — `ci` when `CI`, `GITHUB_ACTIONS` or `GITLAB_CI` is set; `container` when
369
+ `/.dockerenv` exists or `/proc/1/cgroup` mentions docker, containerd or kubepods; `workstation` on
370
+ macOS and Windows, or Linux with `DISPLAY` / `WAYLAND_DISPLAY` / a graphical `XDG_SESSION_TYPE`;
371
+ `server` for headless Linux; `unknown` otherwise. It is derived from those probes only.
372
+
373
+ The server keeps the hostname (raw, plus a SHA-256 that keys registration) and the IP address it
374
+ saw the register, health and upload requests come from, **for operations and abuse prevention
375
+ only** — telling one machine's collector from another, and shutting off a key that is being abused.
376
+ Neither is returned by any user-facing endpoint or shown anywhere in the product; the dashboard
377
+ identifies a device by its label and machine kind. Everything else in the list (OS, arch, kind,
378
+ versions) is what the device page shows.
379
+
314
380
  ### Excluding a project entirely
315
381
 
316
382
  ```yaml
@@ -345,14 +411,18 @@ events at all** — not even counts. Edit the YAML and restart the collector.
345
411
  agentstrack login <api-key> [--api-url <url>] [--label <name>]
346
412
  ```
347
413
 
348
- The key is a **positional argument** there is no interactive prompt and no environment variable.
349
- `--api-url` points at a self-hosted instance; `--label` names this machine in the dashboard.
414
+ The key argument is **optional**. Passing it on the command line leaves it in your shell history and
415
+ in `ps`, so `login` also reads `AGENTSTRACK_API_KEY`, or prompts on the terminal (echo off), or takes
416
+ the key on stdin — `agentstrack login < key.txt`. `--api-url` points at a self-hosted instance and
417
+ **must be `https`** (plain `http` is accepted only for `localhost`); `login` prints the URL it is
418
+ about to use. `--label` names this machine in the dashboard.
350
419
  Registration is idempotent on (user, hostname hash), so re-running `login` on the same machine reuses
351
420
  the existing collector instead of fragmenting its history. The config file is written mode `600`, in
352
421
  a directory created mode `700`.
353
422
 
354
- The registration payload is `hostname`, `label`, `os`, `arch`, the collector's own version, and one
355
- entry per configured agent: `{ agent, version }`. The **agent version is the real one**, read out of
423
+ The registration payload is `hostname`, `label`, `os`, `os_release`, `arch`, `machine_kind`,
424
+ (see [Machine info](#machine-info)), the collector's own version, and one entry per
425
+ configured agent: `{ agent, version }`. The **agent version is the real one**, read out of
356
426
  a transcript the agent already wrote (`2.1.247`, say, from Claude Code's `version` field). Where an
357
427
  adapter cannot cheaply establish a version at detection time the field is simply **omitted** rather
358
428
  than filled with a placeholder, so a missing version in the dashboard means "not reported", never
@@ -372,8 +442,11 @@ If your local privacy mode is stricter than the org's, login says so and keeps y
372
442
 
373
443
  `agentstrack start` writes a **launchd** agent on macOS (`~/Library/LaunchAgents/ai.agentstrack.collector.plist`)
374
444
  or a **systemd user unit** on Linux (`~/.config/systemd/user/agentstrack.service`), loads it, and
375
- returns. Neither needs root. `-f` / `--foreground` runs in the terminal instead best for a first
376
- run, and the only mode where `status` reports `Running: yes`.
445
+ returns. Neither needs root. The unit restarts the collector only on a **crash**, not after a clean
446
+ exit — after `logout` the collector exits cleanly and the supervisor leaves it stopped instead of
447
+ respawning it every few seconds (launchd `KeepAlive`/`SuccessfulExit`, systemd `Restart=on-failure`
448
+ with a 5-in-5-minutes start limit). `-f` / `--foreground` runs in the terminal instead — best for a
449
+ first run, and the only mode where `status` reports `Running: yes`.
377
450
 
378
451
  `agentstrack stop` removes the service unit *and* signals a foreground collector. There is no
379
452
  "stop but keep the unit"; use `agentstrack service install` to put it back.
@@ -389,9 +462,9 @@ Configuration
389
462
 
390
463
  Agents
391
464
  ✓ claude_code transcripts found
392
- 225 file(s) modified in the last 7 days
465
+ 225 file(s) modified in the last 7 days # window is tracking.max_age_days (default 7)
393
466
  ✓ codex transcripts found
394
- 3 file(s) modified in the last 7 days
467
+ 3 file(s) modified in the last 7 days # window is tracking.max_age_days (default 7)
395
468
 
396
469
  Connectivity
397
470
  ✗ API reachable at https://api.agentstrack.ai
@@ -507,10 +580,10 @@ privacy:
507
580
 
508
581
  # never | local_summary_only (default) | full
509
582
  # `full` is what keeps `mode: full` from uploading prompt text unless you also
510
- # ask for it here. `never` suppresses prompt text in every mode, and in
511
- # `analytics` it additionally drops the locally derived `derived_title`, so
512
- # nothing derived from a prompt leaves the machine at all. Under `mode: full`
513
- # it does NOT drop `derived_title` — see the privacy section.
583
+ # ask for it here. `never` suppresses prompt text in every mode, and it
584
+ # additionally drops the locally derived `derived_title` in every mode too —
585
+ # `analytics` and `full` alike — so nothing derived from a prompt leaves the
586
+ # machine at all. See the privacy section.
514
587
  prompts: local_summary_only
515
588
 
516
589
  # never (default) | full
@@ -569,7 +642,7 @@ database filename or an absolute path — the same override OpenCode itself hono
569
642
 
570
643
  | Agent | Status | Reads |
571
644
  |---|---|---|
572
- | **Claude Code** | ✅ Stable | `~/.claude/projects/<slug>/<session-uuid>.jsonl` |
645
+ | **Claude Code** | ✅ Stable | `~/.claude/projects/<slug>/<session-uuid>.jsonl`, plus `<session-uuid>/subagents/**/agent-<id>.jsonl` |
573
646
  | **Codex** | ✅ Stable | `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl` |
574
647
  | **OpenCode** | ✅ Stable | `~/.local/share/opencode/opencode.db` — SQLite, opened **read-only** |
575
648
  | Gemini CLI · Cursor · Cline · Copilot CLI | 🗓 Planned | ids reserved in the schema, no adapter yet |
@@ -593,6 +666,7 @@ identical things:
593
666
  | Commits | ✅ (from `git log`, not the transcript) | ✅ (same) | ✅ (same) |
594
667
  | Plan / subscription type | ➖ | ✅ `plan_type` | ➖ |
595
668
  | Account attribution | ✅ from `~/.claude.json` | ➖ no account file | ✅ from `account.json`, per provider |
669
+ | Skills / sub-agents / workflows | ✅ `Skill`, `Agent`, `Workflow` tool calls named; sub-agent transcripts stamped `sidechain` | ➖ Codex's `spawn_agent` collaboration tools are defined in its prompt but no rollout on hand shows one invoked, so nothing is parsed yet | ➖ `parent_session_id` only |
596
670
 
597
671
  Every adapter is read-only. Adapter formats drift between agent releases: an unparseable line is
598
672
  skipped, never fatal to the file.
@@ -678,8 +752,10 @@ gzipped (`content-encoding: gzip`).
678
752
 
679
753
  | Symptom | Cause | Fix |
680
754
  |---|---|---|
681
- | `401` in the log | Key revoked or wrong | `agentstrack login <new-key>` |
682
- | `403` | Key lacks ingest permission | Issue a new key |
755
+ | `Uploads paused (auth)` `401` | Key revoked or wrong | `agentstrack login <new-key>`; nothing was dropped |
756
+ | `Uploads paused (auth)` — `403` | Key lacks ingest permission | Issue a new key; nothing was dropped |
757
+ | `Uploads paused (quota)` | Org over its monthly event cap | Events stay spooled and resume when the cap resets or the plan changes |
758
+ | `Uploads paused (schema)` | Server rejects every event — collector older than the API | Upgrade the collector; nothing was dropped |
683
759
  | `fetch failed`, `ETIMEDOUT` | Network, VPN or proxy | Set `HTTPS_PROXY`; events keep spooling meanwhile |
684
760
  | `Server rejected the batch as too large` | Batch above the server's limit | Automatic — batch size halves and recovers |
685
761
  | `Batch permanently rejected: … (dropped N)` | Non-retryable `4xx` | N events **in that batch** hit `max_retries` and were deleted. Nothing outside the batch is touched. Check the API version matches the collector's schema. |
@@ -720,7 +796,8 @@ grep -i "error\|failed\|rejected" ~/.agentstrack/collector.log | tail -20
720
796
  ```
721
797
 
722
798
  The log records counts, queue depths and `event_id`s — never payloads, prompts, code or keys. That is
723
- what makes it safe to attach to an issue. Please attach `agentstrack doctor --json` too.
799
+ what makes it safe to attach to an issue. It rotates once at 5 MB to `collector.log.1`; a scan pass
800
+ writes one `Queued N events across M files` line, not one per file. Please attach `agentstrack doctor --json` too.
724
801
 
725
802
  ### Complete reset
726
803
 
@@ -739,15 +816,14 @@ Honest list of things that are **not** in 0.1.0, so you do not go looking for th
739
816
  [ROADMAP.md](./ROADMAP.md) has the same list with the design constraints and what "help wanted"
740
817
  means for each.
741
818
 
742
- - **Backfill window control** (`sync --since 30d`). Today the daemon reads whatever was modified in the last 7 days, and that window is not configurable.
819
+ - **Backfill window control** (`sync --since 30d`). Today the window is `tracking.max_age_days` (default 7) for every run; there is no per-invocation override.
743
820
  - **`config get` / `config set` / `config edit`** — edit the YAML by hand for now.
744
821
  - **`--verbose` logging** and per-run agent selection (`start --agent codex`); use `tracking.agents`.
745
- - **Local time accounting.** Human-active / agent-active / idle windows are derived server-side from the event stream; `tracking.idle_timeout_seconds` is parsed by the collector but not used by it.
822
+ - **Local time accounting.** Human-active / agent-active / idle windows are derived server-side from the event stream; the collector uses `tracking.idle_timeout_seconds` only to decide when a quiet session has ended.
746
823
  - **Process metrics.** `tracking.process_metrics` is accepted and ignored.
747
- - **`session.ended`, `heartbeat`, `model.request` and `git.branch_changed`** are in the schema but no adapter emits them yet.
824
+ - **`heartbeat`, `model.request` and `git.branch_changed`** are in the schema but no adapter emits them yet. `session.ended` is not read from any Claude Code or Codex transcript either — the daemon emits it after `tracking.idle_timeout_seconds` of quiet (`reason: timeout`) or on shutdown (`reason: unknown`).
748
825
  - **Local task classification** (`task_category`) — the field exists in the schema; the collector only derives a title.
749
- - **Windows.** The service installer covers launchd and systemd only; `--foreground` works anywhere Node 24+ does.
750
- - **Content-derived `event_id`.** Ids are random per enqueue, so retrying a batch is safe but re-reading a truncated transcript would create duplicates.
826
+ - **Windows.** The service installer covers launchd and systemd only; `--foreground` works anywhere Node 22+ does.
751
827
  - **`MultiEdit`.** The Claude Code adapter derives file changes from `Edit`, `Write`, `NotebookEdit` and `Read`; a `MultiEdit` call is still recorded as `tool.started`/`tool.completed`, but produces no `file.changed` events and no line counts.
752
828
 
753
829
  ---
@@ -14,10 +14,22 @@ import { type TokenUsage } from '../schema.js';
14
14
  * "output_tokens_details":{"thinking_tokens":257}}},"timestamp":"…"}
15
15
  *
16
16
  * Tool calls appear as tool_use / tool_result blocks inside message.content.
17
+ * One API response is written as SEVERAL assistant lines — one per content
18
+ * block (thinking, text, tool_use) — each repeating the same message.id and
19
+ * the same usage. Usage is therefore billed once per message.id, not per line.
20
+ *
21
+ * Sub-agents (the Agent tool, and Workflow scripts) write their own transcript
22
+ * under <session-uuid>/subagents/[workflows/<wf>/]agent-<id>.jsonl, with the
23
+ * parent's sessionId on every line, isSidechain: true and an agentId. A sibling
24
+ * agent-<id>.meta.json carries {"agentType","description","toolUseId",…}. Every
25
+ * event from such a line is stamped sidechain/agent_id/agent_kind/agent_type
26
+ * so the server can attribute the sub-agent's own usage to it.
17
27
  */
18
28
  export declare const CLAUDE_DIR: string;
19
29
  export declare class ClaudeCodeAdapter implements AgentAdapter {
20
30
  readonly id = "claude_code";
31
+ /** Cross-line facts within one transcript: a message's lines are contiguous. */
32
+ private readonly fileState;
21
33
  detect(): Promise<DetectionResult>;
22
34
  /**
23
35
  * The account Claude Code is signed in as right now.
@@ -34,6 +46,12 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
34
46
  private normalizeUser;
35
47
  private normalizeAssistant;
36
48
  }
49
+ /**
50
+ * Maps Claude Code's usage block onto the normalized shape.
51
+ *
52
+ * thinking_tokens sits under output_tokens_details and is a SUBSET of
53
+ * output_tokens — it must not be added on top, or thinking gets billed twice.
54
+ */
37
55
  export declare function readUsage(raw: unknown): TokenUsage;
38
56
  /**
39
57
  * Line deltas for one edit, derived from the tool input.