@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.
@@ -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`. A call has no error |
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 context 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 |
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
- if (open) status = 'working'
199
- else if (state.failed) status = 'error'
200
- else status = at - lastActivityAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
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 { DETAIL_LIMITS, RECENT_MS, number, planSummary, text, titleFromPrompt } from '../lib/telemetry-fields.mjs'
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 >= cutoff && !turnEnding(state).handedBack) totals.running++
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: text(meta?.description) || titleFromPrompt(state.firstPrompt) || 'Subagent',
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
- return unread || now - lastAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
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 unread ? 'done' : 'idle'
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 ? { name: latest.name, startedAt: latest.startedAt } : null
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 status = statusOf({ running, state, subagents, awaiting, lastAt: lastActivityAt, mark, unread, now })
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