@threadcrumbs/common 0.1.2 → 0.1.4
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 +45 -12
- package/lib/claude-transcript.mjs +108 -5
- package/lib/context-breakdown.mjs +149 -0
- package/lib/jsonl-follower.mjs +11 -0
- package/lib/opencode-store.mjs +130 -37
- package/lib/telemetry-fields.mjs +109 -2
- package/lib/tool-kinds.mjs +256 -0
- package/package.json +1 -1
- package/telemetry/HARNESS-NOTES.md +246 -0
- package/telemetry/README.md +137 -11
- package/telemetry/antigravity.mjs +29 -4
- package/telemetry/claude-code.mjs +59 -23
- package/telemetry/codex.mjs +162 -28
- package/telemetry/cursor.mjs +45 -13
- package/telemetry/hermes.mjs +54 -18
package/telemetry/README.md
CHANGED
|
@@ -51,20 +51,71 @@ adapter in `../harnesses/`, so a session has the same id in both apps. Add the h
|
|
|
51
51
|
| `cwd`, `gitBranch` | string | Where it's working |
|
|
52
52
|
| `model`, `effort` | string | The harness's own ids, as recorded |
|
|
53
53
|
| `status` | string | `asking`, `working`, `delegating`, `done`, `error`, or `idle`. See [Done, needing you, and archived](#done-needing-you-and-archived) |
|
|
54
|
+
| `statusReason` | string | What the reader saw that decided `status`, one code from a fixed list. See [Why this status](#why-this-status) |
|
|
55
|
+
| `ask` | object or `null` | What the thread needs from you, while it's `asking` or `error`; `null` otherwise. See [What a thread needs](#what-a-thread-needs) |
|
|
56
|
+
| `usageLimits` | `{ at, subscription, windows: [{ id, usedPercent, windowMinutes, resetsAt }] }` or `null` | The plan's usage limits as the harness last recorded them in this session, at `at`. They cover the whole account, so an app shows the newest across a harness's sessions. `subscription` is the plan's name, such as `pro`, or `null`. `resetsAt` is epoch ms. Codex only, from `token_count`'s `rate_limits`, whose `primary` and `secondary` become the window ids; `null` elsewhere |
|
|
54
57
|
| `live` | boolean | A process for it is running now. A harness that keeps no record of its processes reports `true` while a turn is open |
|
|
55
58
|
| `startedAt`, `lastActivityAt` | number | Epoch ms |
|
|
56
|
-
| `tool` | `{ name, startedAt }` or `null` | The tool running right now, only while working or delegating |
|
|
59
|
+
| `tool` | `{ name, startedAt, target, kind }` or `null` | The tool running right now, only while working or delegating. `target` is its short file path, command, or search, as in `Detail.activity`, or `''` when the harness doesn't record one. `kind` is one of the [tool kinds](#tool-kinds) |
|
|
57
60
|
| `tokens` | `{ input, output, cacheRead, cacheWrite }` or `null` | Totals for the session's own thread. `input` leaves out cache reads and writes |
|
|
58
61
|
| `context` | `{ used, limit }` or `null` | Tokens in the window after the last reply; `limit` is `0` when unknown |
|
|
62
|
+
| `contextHistory` | number[] or `null` | The context after each reply, oldest first, for a sparkline: at most 24 whole numbers, evenly spaced across the session and always ending at `context.used`. A reply written twice counts once. Each reader keeps a running sample as it reads, never the whole history; OpenCode 2.x reads only the replies newer than the last one it saw, once the session changes, leaves the newest for the next read while it has no `time.completed`, and starts over if replies were removed. `null` for Cursor, Antigravity, and Hermes, whose session scan sees no per-reply counts, and before the first reply |
|
|
59
63
|
| `costUsd` | number or `null` | Spend as the harness records it; `null` when it doesn't |
|
|
60
64
|
| `lines` | `{ added, removed }` or `null` | Lines changed, as the harness records it |
|
|
61
65
|
| `plan` | `{ total, done, current }` or `null` | Plan progress, if the agent keeps one |
|
|
62
66
|
| `subagents` | number | Subagents still working for it |
|
|
67
|
+
| `workingSubagents` | `[{ id, name }]` or `null` | Those subagents, oldest first, named as `Detail.subagents` names them. `null` for Antigravity, which records none |
|
|
63
68
|
| `awaiting` | `[{ id, title }]` | Claude Code only: other sessions it asked to hear from that are still busy, by their own `id`. Either this or `subagents` makes an idle session `delegating`. Other harnesses leave it out |
|
|
64
69
|
| `prompts` | number or `null` | Prompts typed in this session |
|
|
70
|
+
| `turnStartedAt` | number or `null` | Epoch ms when the open turn began, for a turn timer. `null` when no turn is open, which includes a session waiting only on its subagents after handing its turn back. See [When a turn began](#when-a-turn-began) |
|
|
65
71
|
|
|
66
72
|
`null` means "this harness doesn't record it". Threadgauge shows "—" instead of guessing.
|
|
67
73
|
|
|
74
|
+
### When a turn began
|
|
75
|
+
|
|
76
|
+
| Harness | `turnStartedAt` comes from |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Claude Code | The first record after a reply that handed the turn back: a prompt, a message from another session, or anything else that reopens it |
|
|
79
|
+
| Codex | `task_started` or `turn_started`, whichever opened the turn |
|
|
80
|
+
| Cursor | The `<timestamp>` on the prompt that opened the turn, which is to the minute |
|
|
81
|
+
| Antigravity CLI | The newest prompt in `history.jsonl`, the one that shows the turn underway |
|
|
82
|
+
| Hermes | The newest user message across the compression chain |
|
|
83
|
+
| OpenCode, Kilo Code | 1.x: the newest user message. 2.x: `time_busy`, or the newest user message without it |
|
|
84
|
+
|
|
85
|
+
### Why this status
|
|
86
|
+
|
|
87
|
+
| `status` | `statusReason` | Meaning | Harnesses |
|
|
88
|
+
| --- | --- | --- | --- |
|
|
89
|
+
| `asking` | `question` | A question tool is open | Claude Code, Codex, Hermes (clarify), OpenCode, Kilo Code |
|
|
90
|
+
| | `approval` | A tool waits for your permission | Claude Code, Codex, Hermes |
|
|
91
|
+
| | `waiting` | The live process says it waits on you, with no open tool to say why | Claude Code |
|
|
92
|
+
| | `marked-blocked` | The Claude app marked the turn as waiting on you | Claude Code |
|
|
93
|
+
| `working` | `turn-open` | The transcript or database shows a turn underway | All but Antigravity |
|
|
94
|
+
| | `process-busy` | The live process says it's busy | Claude Code |
|
|
95
|
+
| | `new-prompt` | A prompt is newer than the transcript | Antigravity |
|
|
96
|
+
| `delegating` | `subagents` | Its subagents are working, or its only open calls start them | All but Antigravity |
|
|
97
|
+
| | `awaiting` | It asked to hear from other sessions that are still busy (`awaiting`) | Claude Code |
|
|
98
|
+
| `error` | `turn-failed` | The last turn ended in an error | All but Hermes |
|
|
99
|
+
| `done` | `unread` | The harness records that you haven't read the reply | Claude Code (the app's record), Codex, Hermes, OpenCode 2.x |
|
|
100
|
+
| | `recent` | No record of reading, and the turn ended in the last 30 minutes | All |
|
|
101
|
+
| `idle` | `read` | The harness records that you've read the reply | Claude Code (the app's record), Codex, Hermes, OpenCode 2.x |
|
|
102
|
+
| | `quiet` | No record of reading, and nothing for 30 minutes | All |
|
|
103
|
+
|
|
104
|
+
### What a thread needs
|
|
105
|
+
|
|
106
|
+
`ask` is one of these. Free text is cut like an activity target: headers and labels to 160
|
|
107
|
+
characters, questions, descriptions, and error text to 500, with "…" at the cut. At most four
|
|
108
|
+
questions with eight options each are kept.
|
|
109
|
+
- `{ type: 'question', questions: [{ header, text, options: [{ label, description }], multiple }] }`
|
|
110
|
+
comes from the open question tool's input: Claude Code's AskUserQuestion, Codex's
|
|
111
|
+
`request_user_input`, and OpenCode's and Kilo Code's `question`.
|
|
112
|
+
- `{ type: 'approval', tool, target, kind }` is the open tool waiting for permission: Claude
|
|
113
|
+
Code's newest open call while its process waits on you, and Codex's `request_permissions`.
|
|
114
|
+
- `{ type: 'error', text }` is the newest error's text while the status is `error`. It can be
|
|
115
|
+
`''` when the harness records the failure but not its text.
|
|
116
|
+
- `{ type: 'unknown' }` means the thread needs you but the harness doesn't say why: the Claude
|
|
117
|
+
app's mark, or Hermes, whose activity label is all it records.
|
|
118
|
+
|
|
68
119
|
### Done, needing you, and archived
|
|
69
120
|
|
|
70
121
|
These follow the session readers in `../harnesses/`, so Threadgauge and Threadnauts count a
|
|
@@ -92,29 +143,104 @@ thread the same way.
|
|
|
92
143
|
| `contextParts` | `{ input, cacheRead, cacheWrite, output }` | What filled the context in the latest request: new input, cache reads, cache writes, and its output. They add up to the session's `context.used`. `null` for Cursor, Antigravity, and Hermes |
|
|
93
144
|
| `history` | `[{ at, context, output }]` | Context and output after each reply, the newest 600 |
|
|
94
145
|
| `compactions` | `[{ at, before, after }]` | When the context was compacted, and its size before and after |
|
|
95
|
-
| `activity` | `[{ id, name, target, input, path, line, startedAt, endedAt, running, error, errorText }]` | The newest 100 tool calls. `target` is a short file path, command, or search, never the output. `input` is the full command, or the call's input as JSON without file contents, cut to 2,000 characters. `path` and `line` are the file a call names, relative to the session's folder, and its line where known. `errorText` is the start of a failed call's own error output, cut to 500 characters. Any of these is `null` when the harness doesn't record it |
|
|
96
|
-
| `tools` | `[{ name, calls, errors, ms }]` | Every call in the session, per tool |
|
|
146
|
+
| `activity` | `[{ id, name, kind, server, target, input, path, line, startedAt, endedAt, running, error, errorText }]` | The newest 100 tool calls. `kind` and `server` are described under [Tool kinds](#tool-kinds). `target` is a short file path, command, or search, never the output. `input` is the full command, or the call's input as JSON without file contents, cut to 2,000 characters. `path` and `line` are the file a call names, relative to the session's folder, and its line where known. `errorText` is the start of a failed call's own error output, cut to 500 characters. Any of these is `null` when the harness doesn't record it |
|
|
147
|
+
| `tools` | `[{ name, kind, server, calls, errors, ms }]` | Every call in the session, per tool. A tool's `kind` comes from its name alone, so a shell tool is `execute` here even when some of its calls are `search` |
|
|
97
148
|
| `plan` | `[{ content, status }]` | Every plan step; `[]` when the agent kept none |
|
|
98
|
-
| `subagents` | `[{ id, name, type, status, model, tokens, tool, startedAt, lastActivityAt }]` | Every subagent it started, finished or not |
|
|
149
|
+
| `subagents` | `[{ id, name, type, status, model, tokens, tool, startedAt, lastActivityAt }]` | Every subagent it started, finished or not. `tool` has the Session's `tool` shape |
|
|
99
150
|
| `files` | `[{ path, added, removed, edits, line, hunks }]` | Lines changed per file, with paths relative to the session's folder. `line` is where its latest change starts. `hunks` (`[{ line, oldLine, lines, cut }]`) are its newest 5 changes as diff lines, each with at most 60 lines of 240 characters, and `cut` counts the lines left out. Only the 25 most recently edited files keep hunks; older ones have `[]`. `line` and `hunks` are `null` when the harness doesn't record them, and a hunk's `line` is `null` when the patch has no line numbers |
|
|
100
151
|
| `prompts` | `[{ at, text }]` | The newest 50 prompts, each cut to 2,000 characters, with line breaks kept |
|
|
152
|
+
| `thoughts` | `[{ at, text }]` | The agent's reasoning, where the harness writes it as text: the newest 20 messages that had some, each cut to 2,000 characters. Encrypted, redacted, and signature-only reasoning is skipped |
|
|
153
|
+
| `replies` | `[{ at, text }]` | The agent's text to you, one entry per message with its text blocks joined: the newest 50, each cut to 2,000 characters. Tool calls and API errors are left out |
|
|
101
154
|
| `errors` | `[{ at, message }]` | The newest 20 API errors and failed turns, each cut to 500 characters |
|
|
155
|
+
| `contextBreakdown` | `{ at, total, limit, reserved, parts }` | What fills the context, like Claude Code's `/context`. Only `total` is measured; every part is an estimate. See [Context breakdown](#context-breakdown). `null` for Cursor, Antigravity, Hermes, OpenCode, and Kilo Code |
|
|
102
156
|
|
|
103
157
|
Again, a `null` field means the harness doesn't record it. The shared helpers for building one
|
|
104
158
|
(`startCall`, `endCall`, `toolTarget`, `keepNewest`, `fileList`, and the limits) are in
|
|
105
159
|
`../lib/telemetry-fields.mjs`.
|
|
106
160
|
|
|
161
|
+
### Context breakdown
|
|
162
|
+
|
|
163
|
+
`contextBreakdown` splits the context into parts, so an app can show what fills it.
|
|
164
|
+
- **Only `total` is measured.** It's the harness's own count of the latest request, the same
|
|
165
|
+
number as the Session's `context.used`, and `at` is that request's time. Every part is an
|
|
166
|
+
estimate scaled to it, and the parts always add up to it. An app should say so.
|
|
167
|
+
- **`limit`** is the context limit, and **`reserved`** is what the harness keeps free for
|
|
168
|
+
compacting by default: 33,000 tokens for Claude Code, and for Codex the gap between the
|
|
169
|
+
window it reports and the 90% of its full window where it compacts. The settings that change
|
|
170
|
+
them aren't read, so an app should call it the default. Free space is `limit - total -
|
|
171
|
+
reserved`.
|
|
172
|
+
- **`parts`** are `[{ id, tokens, items }]` in this order: `messages`, `system`,
|
|
173
|
+
`builtInTools`, `mcpTools`, `skills`, `memory`. A part a harness doesn't let us size is left
|
|
174
|
+
out, and its tokens stay in `system`.
|
|
175
|
+
- `messages` items: `{ id: 'prompts' }`, `{ id: 'replies' }`, `{ id: 'summary' }` (from the
|
|
176
|
+
last compaction), and `{ id: 'tool', name }` for each tool's calls and results.
|
|
177
|
+
- `mcpTools` items: `{ name, tools }` per server, `tools` a count or `null`.
|
|
178
|
+
- `skills` items: `{ name }`. `memory` items: `{ path }`, the file's full path.
|
|
179
|
+
- Every item also has `tokens`.
|
|
180
|
+
- **How it's worked out:**
|
|
181
|
+
- What was there before the first message is the first request's input count minus the
|
|
182
|
+
first prompt (at 4 characters a token).
|
|
183
|
+
- That gives a characters-per-token ratio for the text of each fixed part (system prompt,
|
|
184
|
+
tool definitions, MCP servers, skills, memory files), so each part is its text length at
|
|
185
|
+
that ratio. A part added later counts at the same ratio.
|
|
186
|
+
- `messages` is the rest of the total. Each request's growth over the one before is split
|
|
187
|
+
among the prompts, replies, and tool calls and results since then, by text length. After a
|
|
188
|
+
compaction, the next request's growth over the fixed parts is the summary.
|
|
189
|
+
- **When it falls back:** if the transcript began mid-session (no first prompt before the first
|
|
190
|
+
request, or a compaction summary first), or the fixed count comes out at or below zero or
|
|
191
|
+
above the total, there are only two parts: `system` is the fixed text at 4 characters a
|
|
192
|
+
token, and `messages` the rest. With no fixed text known either, it's `null`. A transcript
|
|
193
|
+
with no record of what was sent has `messages` and `system` only, `system` being the whole
|
|
194
|
+
fixed count.
|
|
195
|
+
- **Where the fixed text comes from:** only the files the readers already open; no config file
|
|
196
|
+
is read.
|
|
197
|
+
- Claude Code: its transcript's own records of each request (`api-request-shape` and the
|
|
198
|
+
`instructions`, `skill_listing`, `deferred_tools_delta`, `mcp_instructions_delta`, and
|
|
199
|
+
`agent_listing_delta` attachments; see [HARNESS-NOTES.md](HARNESS-NOTES.md)).
|
|
200
|
+
- Codex: the rollout's base instructions and developer messages, its AGENTS.md messages, and
|
|
201
|
+
its `additional_tools` items. Its built-in tool definitions aren't written, so they're in
|
|
202
|
+
`system`.
|
|
203
|
+
- **Read only for an open thread,** by the same second reader as thoughts and replies. It keeps
|
|
204
|
+
numbers and names, never the text.
|
|
205
|
+
|
|
206
|
+
### Tool kinds
|
|
207
|
+
|
|
208
|
+
Every call and every tool total has a `kind`, so an app can draw one icon per kind instead of
|
|
209
|
+
one per tool. The kinds are ACP's tool kinds plus Gemini CLI's `agent`, `plan`, and
|
|
210
|
+
`communicate`: `read`, `search`, `edit`, `delete`, `move`, `execute`, `think`, `fetch`, `agent`,
|
|
211
|
+
`plan`, `communicate`, `switch_mode`, and `other`.
|
|
212
|
+
- **By name:** `../lib/tool-kinds.mjs` keeps one table of every harness's tool names, compared
|
|
213
|
+
without case, spaces, or dashes. A name it doesn't know is `other`.
|
|
214
|
+
- **MCP tools** (`mcp__<server>__<tool>`) are `other`, with `server` set to the server's name.
|
|
215
|
+
`server` is `null` for every other tool.
|
|
216
|
+
- **Shell calls:** a call whose tool is `execute` takes `read`, `search`, `delete`, or `move`
|
|
217
|
+
when every step of its command does only that. Anything else stays `execute`: a step the
|
|
218
|
+
table doesn't know, mixed kinds, a redirect into a file, command substitution, a background
|
|
219
|
+
`&`, or a pipe into anything but a filter such as `head` or `sort`.
|
|
220
|
+
- A known command with an option that writes a file or runs one stays `execute` too:
|
|
221
|
+
`find -exec`, `-delete`, or `-fprint`; `fd -x`; `rg --pre`; `tree -o`; `less -o`;
|
|
222
|
+
`sort -o`; and `uniq` with an output file.
|
|
223
|
+
- `sed` is `read` only when its script just prints, such as `10,20p`.
|
|
224
|
+
- `git` skips `-C <dir>` and `--no-pager` before its subcommand.
|
|
225
|
+
- A command cut short to fit `input` is `execute`, because the part that was cut could
|
|
226
|
+
write. An open call reads its command whole.
|
|
227
|
+
- `startCall` sets both fields, so a reader gets them by passing the call's name and input.
|
|
228
|
+
|
|
107
229
|
| Harness | Where its details come from | `null` |
|
|
108
230
|
| --- | --- | --- |
|
|
109
|
-
| Claude Code | The transcript the scan already reads, collected as it goes (`transcriptReader({ detail: true })`), plus every subagent transcript and its `.meta.json`. Hunks come from each edit's `structuredPatch`, and a failed call's error text from its tool result | — |
|
|
110
|
-
| Codex | The rollout state: calls and their exit codes, per-file lines and hunks from patches, and each `token_count`. Subagents are the child rollouts. A failed command's error text is its output | `compactions`. Hunks have no line numbers |
|
|
111
|
-
| Cursor | The transcript state. A call's start is when it was first read | `history`, `models`, `files`, `compactions`. A call has no end time or error |
|
|
112
|
-
| Antigravity CLI | Transcript steps, each ended by the next, with prompts from `history.jsonl` | `history`, `models`, `files`, `subagents`, `compactions`. A call has no input or file |
|
|
113
|
-
| Hermes | One query for the compression chain: tool calls joined to their results, user messages, and child sessions | `history`, `files`, `compactions
|
|
114
|
-
| OpenCode, Kilo Code | One query for the session's messages and tool parts. Files are the 1.x per-turn diffs; a failed call's error text is its `state.error` | `compactions`; `files` on 2.x and when Kilo kept no diffs; `hunks` always |
|
|
231
|
+
| Claude Code | The transcript the scan already reads, collected as it goes (`transcriptReader({ detail: true })`), plus every subagent transcript and its `.meta.json`. Hunks come from each edit's `structuredPatch`, and a failed call's error text from its tool result. Thoughts and replies come from a second reader of the transcript, kept for the last three sessions opened | — |
|
|
232
|
+
| Codex | The rollout state: calls and their exit codes, per-file lines and hunks from patches, and each `token_count`. Subagents are the child rollouts. A failed command's error text is its output. Thoughts are reasoning summaries and replies are assistant messages, from a second reader kept for the last three sessions opened | `compactions`. Hunks have no line numbers |
|
|
233
|
+
| Cursor | The transcript state. A call's start is when it was first read. Replies come from a second reader kept for the last three sessions opened; Cursor writes no time on them, so each takes its prompt's time, and it writes the agent's thinking into the same text | `thoughts`, `history`, `models`, `files`, `compactions`. A call has no end time or error |
|
|
234
|
+
| Antigravity CLI | Transcript steps, each ended by the next, with prompts from `history.jsonl`. Thoughts and replies are planner steps' `thinking` and `content`, from a second reader kept for the last three sessions opened | `history`, `models`, `files`, `subagents`, `compactions`. A call has no input or file |
|
|
235
|
+
| Hermes | One query for the compression chain: tool calls joined to their results, user and assistant messages, and child sessions. Thoughts are the `reasoning` column, or `reasoning_content` | `history`, `files`, `compactions`; `thoughts` when the database has neither column. A call has no error |
|
|
236
|
+
| OpenCode, Kilo Code | One query for the session's messages and tool parts. Files are the 1.x per-turn diffs; a failed call's error text is its `state.error`. Thoughts and replies are the reasoning and text parts of each reply. On 2.x a reply's parts (calls, plan, thoughts, and text) are its part rows when OpenCode writes them (`experimental.part_rows`, off by default), and otherwise the message's own content | `compactions`; `files` on 2.x and when Kilo kept no diffs; `hunks` always |
|
|
115
237
|
|
|
116
238
|
## What each harness records
|
|
117
239
|
|
|
240
|
+
[HARNESS-NOTES.md](HARNESS-NOTES.md) has what each harness saves of its reasoning and replies,
|
|
241
|
+
the hooks it offers, and how much of its context it keeps free for compacting, with the version
|
|
242
|
+
and date each was checked.
|
|
243
|
+
|
|
118
244
|
Only Claude Code was checked against a real install. The others were written from each
|
|
119
245
|
harness's source code, or, for the closed ones, from what other open-source tools parse, and
|
|
120
246
|
tested against fake installs. Recheck them when a harness changes its format.
|
|
@@ -122,7 +248,7 @@ tested against fake installs. Recheck them when a harness changes its format.
|
|
|
122
248
|
| Harness | What the reader takes, and from where | Not on disk, so `null` |
|
|
123
249
|
| --- | --- | --- |
|
|
124
250
|
| Claude Code | Transcripts, the live session records, the model catalog cache, and the Claude app's own record of each thread, for whether you've read it, whether its turn is waiting on you, and whether it's archived (`../lib/claude-desktop.mjs`). Spend is every reply's usage priced at Anthropic's list prices (`../lib/anthropic-prices.mjs`), the way Claude Code's own `/usage` total is computed, subagents included. Lines are counted from Edit and Write results and the files shell commands changed. The `cost-state` records Claude Code writes are a fallback only, for a model with no list price: they're written now and then, and they cover a whole process, which can span several sessions | — |
|
|
125
|
-
| Codex | Rollouts, plus the writer lock each loaded thread holds. Tokens and
|
|
251
|
+
| Codex | Rollouts, plus the writer lock each loaded thread holds. Tokens, context, and usage limits come from `token_count`. Lines are counted from patches that applied. Renames come from `session_index.jsonl` and the state database, which also says what's archived and what you've read | Spend. An open approval prompt can't be told apart from a running command; only a question to you (`request_user_input`) shows as needing you |
|
|
126
252
|
| Cursor | Agent transcripts: status from `turn_ended`, the tool last called, the todo list, and subagents | Tokens, context, model, spend, lines, and tool start times (the start is when the record appeared) |
|
|
127
253
|
| Antigravity CLI | The transcript, written after each turn ends; the prompt history, whose newer prompt shows a turn underway; and the `task.md` checklist | Tokens, context, model, spend, lines, the current tool, and subagents. They're only in the protobuf conversation database, which isn't read |
|
|
128
254
|
| Hermes | `state.db`: token and spend columns added up across compaction segments, turn leases, the newest todo list, and the tool calls with no result yet. Context limits come from `context_length_cache.yaml` | Lines. Needing you shows only through the activity label, which Hermes updates at most once a minute |
|
|
@@ -2,19 +2,22 @@ import fsp from 'node:fs/promises'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { exists, jsonLines, listDirs } from '../lib/fsutil.mjs'
|
|
5
|
-
import { jsonlFollower } from '../lib/jsonl-follower.mjs'
|
|
5
|
+
import { jsonlFollower, recentReaders } from '../lib/jsonl-follower.mjs'
|
|
6
6
|
import {
|
|
7
7
|
DETAIL_LIMITS,
|
|
8
8
|
RECENT_MS,
|
|
9
9
|
activityDetail,
|
|
10
|
+
askOf,
|
|
10
11
|
clip,
|
|
11
12
|
clipBlock,
|
|
12
13
|
emptyActivity,
|
|
13
14
|
keepNewest,
|
|
15
|
+
keepText,
|
|
14
16
|
planItems,
|
|
15
17
|
planSummary,
|
|
16
18
|
startCall,
|
|
17
19
|
text,
|
|
20
|
+
textList,
|
|
18
21
|
titleFromPrompt,
|
|
19
22
|
} from '../lib/telemetry-fields.mjs'
|
|
20
23
|
|
|
@@ -25,6 +28,7 @@ const TRANSCRIPTS = [
|
|
|
25
28
|
['.system_generated', 'logs', 'transcript.jsonl'],
|
|
26
29
|
['transcript.jsonl'],
|
|
27
30
|
]
|
|
31
|
+
const WORDS_KEPT = 3
|
|
28
32
|
const CHECKLIST = /^\s*[-*]\s+\[([ xX/])\]\s+(.+?)\s*$/
|
|
29
33
|
const CHECK_STATUS = { ' ': 'pending', '/': 'in_progress', x: 'completed', X: 'completed' }
|
|
30
34
|
|
|
@@ -105,6 +109,15 @@ function applyRecord(state, record) {
|
|
|
105
109
|
if (record.status) state.failed = record.status === 'ERROR' || record.type === 'ERROR_MESSAGE'
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
const emptyWords = () => ({ thoughts: [], replies: [] })
|
|
113
|
+
|
|
114
|
+
function applyWords(state, record) {
|
|
115
|
+
if (record?.type !== 'PLANNER_RESPONSE') return
|
|
116
|
+
const at = epochMs(record.created_at)
|
|
117
|
+
keepText(state.thoughts, 'thoughts', { at, text: text(record.thinking) })
|
|
118
|
+
keepText(state.replies, 'replies', { at, text: contentText(record.content) })
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
export function parseChecklist(markdown) {
|
|
109
122
|
const steps = []
|
|
110
123
|
for (const line of text(markdown).split(/\r?\n/)) {
|
|
@@ -121,6 +134,7 @@ export function antigravity({
|
|
|
121
134
|
const brainDir = path.join(home, 'brain')
|
|
122
135
|
const historyFile = path.join(home, 'history.jsonl')
|
|
123
136
|
const transcripts = jsonlFollower({ empty: emptyTranscript, apply: applyRecord })
|
|
137
|
+
const wordsFor = recentReaders(() => jsonlFollower({ empty: emptyWords, apply: applyWords }), WORDS_KEPT)
|
|
124
138
|
const plans = new Map()
|
|
125
139
|
let history = { mtime: -1, byId: new Map() }
|
|
126
140
|
let known = new Set()
|
|
@@ -195,9 +209,10 @@ export function antigravity({
|
|
|
195
209
|
const plan = await readPlan(sessionDir)
|
|
196
210
|
const open = Boolean(typed) && typed.lastAt > transcriptAt && at - typed.lastAt < ACTIVE_WINDOW_MS
|
|
197
211
|
let status
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
else
|
|
212
|
+
let statusReason
|
|
213
|
+
if (open) [status, statusReason] = ['working', 'new-prompt']
|
|
214
|
+
else if (state.failed) [status, statusReason] = ['error', 'turn-failed']
|
|
215
|
+
else [status, statusReason] = at - lastActivityAt < ACTIVE_WINDOW_MS ? ['done', 'recent'] : ['idle', 'quiet']
|
|
201
216
|
const cwd = state.cwd || typed?.workspace || ''
|
|
202
217
|
const prompt = state.firstPrompt || typed?.first || ''
|
|
203
218
|
sessions.push({
|
|
@@ -218,11 +233,17 @@ export function antigravity({
|
|
|
218
233
|
tool: null,
|
|
219
234
|
tokens: null,
|
|
220
235
|
context: null,
|
|
236
|
+
contextHistory: null,
|
|
221
237
|
costUsd: null,
|
|
222
238
|
lines: null,
|
|
223
239
|
plan: planSummary(plan),
|
|
224
240
|
subagents: 0,
|
|
241
|
+
workingSubagents: null,
|
|
225
242
|
prompts: Math.max(state.prompts, typed?.prompts || 0),
|
|
243
|
+
turnStartedAt: open ? typed.lastAt : null,
|
|
244
|
+
statusReason,
|
|
245
|
+
ask: askOf(status, { errorText: state.errors.at(-1)?.message }),
|
|
246
|
+
usageLimits: null,
|
|
226
247
|
})
|
|
227
248
|
}
|
|
228
249
|
transcripts.sweep()
|
|
@@ -237,6 +258,7 @@ export function antigravity({
|
|
|
237
258
|
const sessionDir = path.join(brainDir, id)
|
|
238
259
|
const found = await transcriptOf(sessionDir)
|
|
239
260
|
const state = (found && (await transcripts.read(found.file, found.stat).catch(() => null))) || emptyTranscript()
|
|
261
|
+
const said = found ? await wordsFor(found.file).read(found.file, found.stat).catch(() => null) : null
|
|
240
262
|
const typed = (await readHistory()).get(id)
|
|
241
263
|
return {
|
|
242
264
|
id: `antigravity:${id}`,
|
|
@@ -251,6 +273,9 @@ export function antigravity({
|
|
|
251
273
|
subagents: null,
|
|
252
274
|
files: null,
|
|
253
275
|
prompts: (typed?.list.length ? typed.list : state.promptList).map((p) => ({ ...p })),
|
|
276
|
+
thoughts: found ? textList(said?.thoughts) : [],
|
|
277
|
+
replies: found ? textList(said?.replies) : [],
|
|
278
|
+
contextBreakdown: null,
|
|
254
279
|
errors: state.errors.map((e) => ({ ...e })),
|
|
255
280
|
}
|
|
256
281
|
}
|
|
@@ -4,7 +4,9 @@ import path from 'node:path'
|
|
|
4
4
|
import { claudeDesktopStores, desktopRecordReader, projectOf } from '../lib/claude-desktop.mjs'
|
|
5
5
|
import { awaitedPeers, readLiveSessions, transcriptDetail, transcriptReader, turnEnding } from '../lib/claude-transcript.mjs'
|
|
6
6
|
import { exists, listDirs, listFiles, num } from '../lib/fsutil.mjs'
|
|
7
|
-
import {
|
|
7
|
+
import { contextBreakdown } from '../lib/context-breakdown.mjs'
|
|
8
|
+
import { recentReaders } from '../lib/jsonl-follower.mjs'
|
|
9
|
+
import { DETAIL_LIMITS, RECENT_MS, askOf, number, planSummary, sampleList, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
|
|
8
10
|
|
|
9
11
|
export { nextPlan } from '../lib/telemetry-fields.mjs'
|
|
10
12
|
|
|
@@ -12,6 +14,8 @@ const STALE_TURN_MS = 2 * 60 * 1000
|
|
|
12
14
|
const ACTIVE_WINDOW_MS = 30 * 60 * 1000
|
|
13
15
|
const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
|
|
14
16
|
const OLDER_KEPT = 3
|
|
17
|
+
const OPENED_KEPT = 3
|
|
18
|
+
const RESERVED_TOKENS = 33_000
|
|
15
19
|
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
16
20
|
|
|
17
21
|
export function claudeCode({
|
|
@@ -25,7 +29,8 @@ export function claudeCode({
|
|
|
25
29
|
const catalogDir = path.join(home, 'cache', 'model-catalog')
|
|
26
30
|
const transcripts = transcriptReader({ detail: true })
|
|
27
31
|
const subagentFiles = transcriptReader()
|
|
28
|
-
const subagentDetails = transcriptReader({ detail: true })
|
|
32
|
+
const subagentDetails = transcriptReader({ detail: true, words: true })
|
|
33
|
+
const openedFor = recentReaders(() => transcriptReader({ words: true, sizes: true }), OPENED_KEPT)
|
|
29
34
|
const desktopRecords = desktopRecordReader()
|
|
30
35
|
const olderReaders = new Map()
|
|
31
36
|
const metas = new Map()
|
|
@@ -77,7 +82,7 @@ export function claudeCode({
|
|
|
77
82
|
|
|
78
83
|
async function subagentTotals(file, sessionId) {
|
|
79
84
|
const cutoff = now() - SUBAGENT_WINDOW_MS
|
|
80
|
-
const totals = { running: 0, spend: 0, unpriced: false, added: 0, removed: 0 }
|
|
85
|
+
const totals = { running: 0, working: [], spend: 0, unpriced: false, added: 0, removed: 0 }
|
|
81
86
|
for (const sub of await listFiles(subagentDir(file, sessionId), (n) => n.endsWith('.jsonl'))) {
|
|
82
87
|
const stat = await fsp.stat(sub).catch(() => null)
|
|
83
88
|
if (!stat) continue
|
|
@@ -87,8 +92,12 @@ export function claudeCode({
|
|
|
87
92
|
totals.unpriced ||= state.unpriced
|
|
88
93
|
totals.added += state.lines.added
|
|
89
94
|
totals.removed += state.lines.removed
|
|
90
|
-
if (stat.mtimeMs
|
|
95
|
+
if (stat.mtimeMs < cutoff || turnEnding(state).handedBack) continue
|
|
96
|
+
totals.running++
|
|
97
|
+
const meta = await readMeta(sub.replace(/\.jsonl$/, '.meta.json'))
|
|
98
|
+
totals.working.push({ id: path.basename(sub, '.jsonl').replace(/^agent-/, ''), name: subagentName(meta, state), startedAt: state.startedAt })
|
|
91
99
|
}
|
|
100
|
+
totals.working = totals.working.sort((a, b) => a.startedAt - b.startedAt).map(({ id, name }) => ({ id, name }))
|
|
92
101
|
return totals
|
|
93
102
|
}
|
|
94
103
|
|
|
@@ -131,7 +140,7 @@ export function claudeCode({
|
|
|
131
140
|
const status = ending.failed ? 'error' : !open ? 'done' : now() - lastActivityAt < SUBAGENT_WINDOW_MS ? 'working' : 'idle'
|
|
132
141
|
list.push({
|
|
133
142
|
id: path.basename(sub, '.jsonl').replace(/^agent-/, ''),
|
|
134
|
-
name:
|
|
143
|
+
name: subagentName(meta, state),
|
|
135
144
|
type: text(meta?.agentType),
|
|
136
145
|
status,
|
|
137
146
|
model: state.model,
|
|
@@ -168,7 +177,7 @@ export function claudeCode({
|
|
|
168
177
|
if (!running && now() - (state.lastAt || stat.mtimeMs) > RECENT_MS) continue
|
|
169
178
|
const totals = await subagentTotals(file, id)
|
|
170
179
|
const awaiting = running ? awaitedPeers(state, live, handedBackAt) : []
|
|
171
|
-
sessions.push(toSession({ id, state, stat, running, subagents: running ? totals.running : 0, awaiting, totals, limits, mark: marks.get(id), now: now() }))
|
|
180
|
+
sessions.push(toSession({ id, state, stat, running, subagents: running ? totals.running : 0, workingSubagents: running ? totals.working : [], awaiting, totals, limits, mark: marks.get(id), now: now() }))
|
|
172
181
|
}
|
|
173
182
|
known = files
|
|
174
183
|
listed = new Map(sessions.map((s) => [s.id, s]))
|
|
@@ -189,6 +198,7 @@ export function claudeCode({
|
|
|
189
198
|
agentId,
|
|
190
199
|
source: sub,
|
|
191
200
|
...transcriptDetail(state),
|
|
201
|
+
contextBreakdown: null,
|
|
192
202
|
subagents: null,
|
|
193
203
|
}
|
|
194
204
|
}
|
|
@@ -205,6 +215,13 @@ export function claudeCode({
|
|
|
205
215
|
return newest?.file || null
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
async function opened(file, stat, state, limits) {
|
|
219
|
+
const read = await openedFor(file).read(file, stat).catch(() => null)
|
|
220
|
+
const limit = contextLimit(limits, state.model)
|
|
221
|
+
const reserved = limit ? Math.min(RESERVED_TOKENS, limit) : RESERVED_TOKENS
|
|
222
|
+
return { words: read?.words ?? null, contextBreakdown: contextBreakdown(read?.sizes, { total: state.context, limit, reserved }) }
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
function olderReader(file) {
|
|
209
226
|
const reader = olderReaders.get(file) || transcriptReader({ detail: true })
|
|
210
227
|
olderReaders.delete(file)
|
|
@@ -220,17 +237,18 @@ export function claudeCode({
|
|
|
220
237
|
if (!stat) return null
|
|
221
238
|
const state = await (known.get(id) === file ? transcripts : olderReader(file)).read(file, stat)
|
|
222
239
|
const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
|
|
240
|
+
const said = await opened(file, stat, state, limits)
|
|
223
241
|
if (marks.get(id)?.archived) return null
|
|
224
242
|
const running = live.get(id)
|
|
225
243
|
const totals = await subagentTotals(file, id)
|
|
226
244
|
const awaiting = running ? awaitedPeers(state, live) : []
|
|
227
245
|
const session =
|
|
228
246
|
listed.get(`claude-code:${id}`) ||
|
|
229
|
-
toSession({ id, state, stat, running, subagents: running ? totals.running : 0, awaiting, totals, limits, mark: marks.get(id), now: now() })
|
|
247
|
+
toSession({ id, state, stat, running, subagents: running ? totals.running : 0, workingSubagents: running ? totals.working : [], awaiting, totals, limits, mark: marks.get(id), now: now() })
|
|
230
248
|
if (subagent != null) return { session, detail: await subagentDetail(file, id, String(subagent)) }
|
|
231
249
|
return {
|
|
232
250
|
session,
|
|
233
|
-
detail: { id: `claude-code:${id}`, sessionId: id, source: file, ...transcriptDetail(state), subagents: await subagentList(file, id) },
|
|
251
|
+
detail: { id: `claude-code:${id}`, sessionId: id, source: file, ...transcriptDetail(state, said.words), contextBreakdown: said.contextBreakdown, subagents: await subagentList(file, id) },
|
|
234
252
|
}
|
|
235
253
|
}
|
|
236
254
|
|
|
@@ -241,11 +259,13 @@ export function claudeCode({
|
|
|
241
259
|
if (!stat) return null
|
|
242
260
|
if (subagent != null) return subagentDetail(file, id, String(subagent))
|
|
243
261
|
const state = await transcripts.read(file, stat)
|
|
262
|
+
const said = await opened(file, stat, state, await contextLimits())
|
|
244
263
|
return {
|
|
245
264
|
id: `claude-code:${id}`,
|
|
246
265
|
sessionId: id,
|
|
247
266
|
source: file,
|
|
248
|
-
...transcriptDetail(state),
|
|
267
|
+
...transcriptDetail(state, said.words),
|
|
268
|
+
contextBreakdown: said.contextBreakdown,
|
|
249
269
|
subagents: await subagentList(file, id),
|
|
250
270
|
}
|
|
251
271
|
}
|
|
@@ -284,6 +304,7 @@ function desktopMarks({ records, deleted }) {
|
|
|
284
304
|
activeAt: num(r.lastActivityAt) || num(r.lastFocusedAt) || num(r.createdAt),
|
|
285
305
|
outcome: r.turnOutcome || '',
|
|
286
306
|
failed: Boolean(r.error) || r.turnOutcome === 'failed',
|
|
307
|
+
errorText: typeof r.error === 'string' ? r.error : text(r.error?.message),
|
|
287
308
|
}
|
|
288
309
|
const seen = marks.get(r.cliSessionId)
|
|
289
310
|
marks.set(
|
|
@@ -297,6 +318,7 @@ function desktopMarks({ records, deleted }) {
|
|
|
297
318
|
activeAt: Math.max(seen.activeAt, next.activeAt),
|
|
298
319
|
outcome: seen.outcome || next.outcome,
|
|
299
320
|
failed: seen.failed || next.failed,
|
|
321
|
+
errorText: seen.errorText || next.errorText,
|
|
300
322
|
}
|
|
301
323
|
: next
|
|
302
324
|
)
|
|
@@ -305,38 +327,46 @@ function desktopMarks({ records, deleted }) {
|
|
|
305
327
|
return marks
|
|
306
328
|
}
|
|
307
329
|
|
|
308
|
-
function statusOf({ running, state, subagents, awaiting, lastAt, mark, unread, now }) {
|
|
330
|
+
function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, unread, now }) {
|
|
309
331
|
const midTurn = Boolean(state.ending && !state.ending.handedBack)
|
|
310
332
|
const { failed, onAgents: onlyAgents } = turnEnding(state)
|
|
311
333
|
const blocked = mark?.outcome === 'blocked'
|
|
312
334
|
const failedTurn = failed || Boolean(mark?.failed)
|
|
335
|
+
const quiet = () => (unread ? ['done', 'unread'] : ['idle', mark ? 'read' : 'quiet'])
|
|
313
336
|
if (running) {
|
|
314
|
-
if (running.status === 'waiting') return 'asking'
|
|
337
|
+
if (running.status === 'waiting') return ['asking', calls.some((c) => c.questions) ? 'question' : calls.length ? 'approval' : 'waiting']
|
|
315
338
|
const busy = running.status ? running.status === 'busy' : midTurn
|
|
316
|
-
if (busy) return onlyAgents ? 'delegating' : 'working'
|
|
317
|
-
if (subagents || awaiting.length) return 'delegating'
|
|
318
|
-
if (blocked) return 'asking'
|
|
319
|
-
if (failedTurn) return 'error'
|
|
320
|
-
|
|
339
|
+
if (busy) return onlyAgents ? ['delegating', 'subagents'] : ['working', running.status ? 'process-busy' : 'turn-open']
|
|
340
|
+
if (subagents || awaiting.length) return ['delegating', subagents ? 'subagents' : 'awaiting']
|
|
341
|
+
if (blocked) return ['asking', 'marked-blocked']
|
|
342
|
+
if (failedTurn) return ['error', 'turn-failed']
|
|
343
|
+
if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', 'recent']
|
|
344
|
+
return quiet()
|
|
321
345
|
}
|
|
322
|
-
if (midTurn && now - lastAt < STALE_TURN_MS) return onlyAgents ? 'delegating' : 'working'
|
|
323
|
-
if (blocked) return 'asking'
|
|
324
|
-
if (failedTurn) return 'error'
|
|
325
|
-
return
|
|
346
|
+
if (midTurn && now - lastAt < STALE_TURN_MS) return onlyAgents ? ['delegating', 'subagents'] : ['working', 'turn-open']
|
|
347
|
+
if (blocked) return ['asking', 'marked-blocked']
|
|
348
|
+
if (failedTurn) return ['error', 'turn-failed']
|
|
349
|
+
return quiet()
|
|
326
350
|
}
|
|
327
351
|
|
|
352
|
+
const subagentName = (meta, state) => text(meta?.description) || titleFromPrompt(state.firstPrompt) || 'Subagent'
|
|
353
|
+
|
|
328
354
|
function currentTool(open) {
|
|
329
355
|
let latest = null
|
|
330
356
|
for (const call of open.values()) {
|
|
331
357
|
if (!latest || call.startedAt >= latest.startedAt) latest = call
|
|
332
358
|
}
|
|
333
|
-
return latest
|
|
359
|
+
return toolNow(latest)
|
|
334
360
|
}
|
|
335
361
|
|
|
336
|
-
function toSession({ id, state, stat, running, subagents, awaiting, totals, limits, mark, now }) {
|
|
362
|
+
function toSession({ id, state, stat, running, subagents, workingSubagents, awaiting, totals, limits, mark, now }) {
|
|
337
363
|
const lastActivityAt = state.lastAt || Math.floor(stat.mtimeMs)
|
|
338
364
|
const unread = Boolean(mark) && (state.handedBackAt || mark.activeAt) > mark.focusedAt
|
|
339
|
-
const
|
|
365
|
+
const calls = [...state.open.values()].sort((a, b) => a.startedAt - b.startedAt)
|
|
366
|
+
const [status, statusReason] = statusOf({ running, state, calls, subagents, awaiting, lastAt: lastActivityAt, mark, unread, now })
|
|
367
|
+
const errorText = state.detail?.errors.at(-1)?.message || mark?.errorText || ''
|
|
368
|
+
const midTurn = Boolean(state.ending && !state.ending.handedBack)
|
|
369
|
+
const turnOpen = midTurn && (status === 'working' || status === 'delegating' || status === 'asking')
|
|
340
370
|
const { projectPath, project, worktree } = projectOf(mark?.cwd || state.firstCwd || state.cwd, mark?.originCwd)
|
|
341
371
|
const limit = contextLimit(limits, state.model)
|
|
342
372
|
const promptTitle = state.firstPrompt ? titleFromPrompt(state.firstPrompt) : ''
|
|
@@ -358,12 +388,18 @@ function toSession({ id, state, stat, running, subagents, awaiting, totals, limi
|
|
|
358
388
|
tool: status === 'working' || status === 'delegating' ? currentTool(state.open) : null,
|
|
359
389
|
tokens: { ...state.tokens },
|
|
360
390
|
context: state.context ? { used: state.context, limit } : null,
|
|
391
|
+
contextHistory: sampleList(state.contextSample, state.context),
|
|
361
392
|
costUsd: state.unpriced || totals.unpriced ? (state.cost ? state.cost.usd : null) : state.spend + totals.spend,
|
|
362
393
|
lines: { added: state.lines.added + totals.added, removed: state.lines.removed + totals.removed },
|
|
363
394
|
plan: planSummary(state.plan),
|
|
364
395
|
subagents,
|
|
396
|
+
workingSubagents,
|
|
365
397
|
awaiting: awaiting.map((p) => ({ id: `claude-code:${p.sessionId}`, title: p.name })),
|
|
366
398
|
prompts: state.prompts,
|
|
399
|
+
turnStartedAt: turnOpen ? state.turnStartedAt || null : null,
|
|
400
|
+
statusReason,
|
|
401
|
+
ask: askOf(status, { calls, errorText }),
|
|
402
|
+
usageLimits: null,
|
|
367
403
|
}
|
|
368
404
|
}
|
|
369
405
|
|