@threadcrumbs/common 0.0.0-stage → 0.1.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 (42) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +33 -0
  3. package/README.md +109 -3
  4. package/harnesses/README.md +213 -0
  5. package/harnesses/antigravity.mjs +269 -0
  6. package/harnesses/claude-code.mjs +578 -0
  7. package/harnesses/codex.mjs +419 -0
  8. package/harnesses/cursor.mjs +199 -0
  9. package/harnesses/hermes.mjs +211 -0
  10. package/harnesses/index.mjs +36 -0
  11. package/harnesses/kilocode.mjs +361 -0
  12. package/harnesses/opencode.mjs +449 -0
  13. package/launch/index.mjs +163 -0
  14. package/launch/macos.mjs +21 -0
  15. package/launch/registry.mjs +52 -0
  16. package/launch/terminal.mjs +213 -0
  17. package/launch/win-terminal.mjs +87 -0
  18. package/launch/windows.mjs +96 -0
  19. package/launch/xdg.mjs +44 -0
  20. package/lib/anthropic-prices.mjs +70 -0
  21. package/lib/claude-desktop.mjs +208 -0
  22. package/lib/claude-transcript.mjs +462 -0
  23. package/lib/cursor-folder.mjs +107 -0
  24. package/lib/fsutil.mjs +191 -0
  25. package/lib/hermes-home.mjs +30 -0
  26. package/lib/jsonl-follower.mjs +45 -0
  27. package/lib/opencode-store.mjs +602 -0
  28. package/lib/sqlite.mjs +29 -0
  29. package/lib/telemetry-fields.mjs +245 -0
  30. package/license/index.mjs +102 -0
  31. package/license/store.mjs +155 -0
  32. package/orgs/index.mjs +157 -0
  33. package/package.json +40 -4
  34. package/telemetry/README.md +148 -0
  35. package/telemetry/antigravity.mjs +268 -0
  36. package/telemetry/claude-code.mjs +370 -0
  37. package/telemetry/codex.mjs +556 -0
  38. package/telemetry/cursor.mjs +276 -0
  39. package/telemetry/hermes.mjs +404 -0
  40. package/telemetry/index.mjs +9 -0
  41. package/telemetry/kilocode.mjs +31 -0
  42. package/telemetry/opencode.mjs +36 -0
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@threadcrumbs/common",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Code the Threadcrumbs apps share, starting with the readers for each coding-agent harness.",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "author": "Threadcrumbs LLC",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/threadcrumbs/common.git"
11
+ },
12
+ "engines": {
13
+ "node": ">=22.13"
14
+ },
15
+ "packageManager": "pnpm@11.9.0",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "exports": {
20
+ "./harnesses": "./harnesses/index.mjs",
21
+ "./harnesses/*": "./harnesses/*.mjs",
22
+ "./telemetry": "./telemetry/index.mjs",
23
+ "./telemetry/*": "./telemetry/*.mjs",
24
+ "./launch": "./launch/index.mjs",
25
+ "./license": "./license/index.mjs",
26
+ "./license/store": "./license/store.mjs",
27
+ "./orgs": "./orgs/index.mjs",
28
+ "./lib/*": "./lib/*.mjs"
29
+ },
30
+ "files": [
31
+ "NOTICES.md",
32
+ "harnesses/",
33
+ "launch/",
34
+ "license/",
35
+ "lib/",
36
+ "orgs/",
37
+ "telemetry/"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --test \"test/**/*.test.mjs\""
41
+ }
42
+ }
@@ -0,0 +1,148 @@
1
+ # Harness telemetry readers
2
+
3
+ A **harness** is whatever runs the agent sessions: Claude Code, Codex, OpenCode, and so on. Each
4
+ file here reads one harness's own session files and reports live telemetry for each session:
5
+ what it's doing now, its context, tokens, spend, and plan. Threadgauge shows them.
6
+
7
+ These are the telemetry half of each harness. The other half, finding every thread and opening
8
+ it, is in [`../harnesses/`](../harnesses/README.md), which Threadnauts uses. They're imported
9
+ separately (`@threadcrumbs/common/telemetry` and `@threadcrumbs/common/harnesses`), so an app
10
+ that only needs one half never loads the other.
11
+
12
+ ## The shape of it
13
+
14
+ ```js
15
+ export default {
16
+ id: 'my-harness',
17
+ name: 'My Harness',
18
+ detect,
19
+ watchRoots,
20
+ scanSessions,
21
+ sessionDetail,
22
+ }
23
+ ```
24
+
25
+ | Member | What it does |
26
+ | --- | --- |
27
+ | `id` | Stable and kebab-case. It's part of every session id, so it must never change |
28
+ | `name` | What a person sees |
29
+ | `detect()` | `Promise<boolean>`: is this harness on this machine at all? It runs on every scan, so keep it cheap |
30
+ | `watchRoots()` | The folders to watch recursively. A change anywhere under them triggers a scan |
31
+ | `scanSessions()` | `Promise<Session[]>`: the sessions worth showing now. Cache against file size and mtime, because it runs often |
32
+ | `sessionDetail(id, options)` | Optional. `Promise<Detail \| null>`: one session's details, for Threadgauge's details panel. `null` for an id the last scan didn't list. It runs about once a second while that panel is open, so build it from state the scan already keeps, or one read-only query. Claude Code also takes `{ subagent }`, a subagent's id from `subagents`, and returns that subagent's own Detail, with `agentId` set and `subagents: null` |
33
+ | `readSession(id, options)` | Optional, Claude Code only so far. `Promise<{ session, detail } \| null>`: one session read on demand, even one older than the last day that `scanSessions` leaves out, so Threadgauge Pro can open an older thread. Takes `{ subagent }` like `sessionDetail`. It keeps the last few files it read, so asking again reads only what was added |
34
+
35
+ Throwing from `scanSessions` is survivable. Threadgauge's monitor reports the error under the
36
+ harness's name and carries on with the other harnesses.
37
+
38
+ Register a reader by adding it to `TELEMETRY` in `index.mjs`. Use the same `id` as that harness's
39
+ adapter in `../harnesses/`, so a session has the same id in both apps. Add the harness to
40
+ `test/shared-ids.test.mjs` too, which fails until both readers are compared on one fake install.
41
+
42
+ ## The `Session`
43
+
44
+ | Field | Type | What it means |
45
+ | --- | --- | --- |
46
+ | `id` | string | `<harness id>:<session id>`, unique across every harness |
47
+ | `harness` | string | The reader's `id` |
48
+ | `title` | string | The harness's own title, or the first line of the first prompt |
49
+ | `project` / `projectPath` | string | The repo folder's name and absolute path |
50
+ | `worktree` | string | Worktree name, or `''` |
51
+ | `cwd`, `gitBranch` | string | Where it's working |
52
+ | `model`, `effort` | string | The harness's own ids, as recorded |
53
+ | `status` | string | `asking`, `working`, `delegating`, `done`, `error`, or `idle`. See [Done, needing you, and archived](#done-needing-you-and-archived) |
54
+ | `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
+ | `startedAt`, `lastActivityAt` | number | Epoch ms |
56
+ | `tool` | `{ name, startedAt }` or `null` | The tool running right now, only while working or delegating |
57
+ | `tokens` | `{ input, output, cacheRead, cacheWrite }` or `null` | Totals for the session's own thread. `input` leaves out cache reads and writes |
58
+ | `context` | `{ used, limit }` or `null` | Tokens in the window after the last reply; `limit` is `0` when unknown |
59
+ | `costUsd` | number or `null` | Spend as the harness records it; `null` when it doesn't |
60
+ | `lines` | `{ added, removed }` or `null` | Lines changed, as the harness records it |
61
+ | `plan` | `{ total, done, current }` or `null` | Plan progress, if the agent keeps one |
62
+ | `subagents` | number | Subagents still working for it |
63
+ | `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
+ | `prompts` | number or `null` | Prompts typed in this session |
65
+
66
+ `null` means "this harness doesn't record it". Threadgauge shows "—" instead of guessing.
67
+
68
+ ### Done, needing you, and archived
69
+
70
+ These follow the session readers in `../harnesses/`, so Threadgauge and Threadnauts count a
71
+ thread the same way.
72
+ - **Done** means the turn ended and you haven't read the reply. After that it's `idle`.
73
+ - **Where the harness records reading, that decides:** Codex's read receipts, Hermes's
74
+ `last_read_at`, and OpenCode 2.x's `time_viewed`.
75
+ - **Claude Code** counts a reply as unread when the Claude app's record of the thread changed
76
+ after you last focused it. A live session that handed its turn back in the last 30 minutes
77
+ is done either way, as the session reader's is.
78
+ - **Everywhere else,** a terminal-only Claude Code session included, a turn is done for 30
79
+ minutes after it ends. Not knowing isn't the same as read.
80
+ - **Needing you** also covers a Claude Code turn the Claude app marked `blocked`, unless
81
+ subagents or other sessions are still out. `delegating` comes first.
82
+ - **Archived** sessions are left out, and so are Claude Code sessions deleted in the app. Cursor
83
+ and Antigravity keep no archive record, so theirs always show.
84
+
85
+ ## The `Detail`
86
+
87
+ | Field | Type | What it means |
88
+ | --- | --- | --- |
89
+ | `id`, `sessionId` | string | The session's id, and the harness's own id for it |
90
+ | `source` | string | The transcript or database it was read from |
91
+ | `models` | `[{ model, tokens, costUsd }]` | Tokens, and spend where recorded, per model |
92
+ | `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
+ | `history` | `[{ at, context, output }]` | Context and output after each reply, the newest 600 |
94
+ | `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 |
97
+ | `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 |
99
+ | `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
+ | `prompts` | `[{ at, text }]` | The newest 50 prompts, each cut to 2,000 characters, with line breaks kept |
101
+ | `errors` | `[{ at, message }]` | The newest 20 API errors and failed turns, each cut to 500 characters |
102
+
103
+ Again, a `null` field means the harness doesn't record it. The shared helpers for building one
104
+ (`startCall`, `endCall`, `toolTarget`, `keepNewest`, `fileList`, and the limits) are in
105
+ `../lib/telemetry-fields.mjs`.
106
+
107
+ | Harness | Where its details come from | `null` |
108
+ | --- | --- | --- |
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 |
115
+
116
+ ## What each harness records
117
+
118
+ Only Claude Code was checked against a real install. The others were written from each
119
+ harness's source code, or, for the closed ones, from what other open-source tools parse, and
120
+ tested against fake installs. Recheck them when a harness changes its format.
121
+
122
+ | Harness | What the reader takes, and from where | Not on disk, so `null` |
123
+ | --- | --- | --- |
124
+ | 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 |
126
+ | 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
+ | 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
+ | 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 |
129
+ | OpenCode | `opencode.db`, both the 1.x tables and 2.x's `session_v2`; a session in both keeps the newer copy. Totals come from the session row's usage columns, context from the last reply that produced output, and limits from `models.json` (1.x) or the `kv` catalog (2.x). Lines are the per-turn diff summaries (1.x). Needing you is a running `question` tool | Lines on 2.x. A pending permission prompt looks like any running tool |
130
+ | Kilo Code | `kilo.db`, the same 1.x tables as OpenCode. Its `summary_*` columns are live, so they give lines | As OpenCode 1.x |
131
+
132
+ ## Ground rules
133
+
134
+ - **Read-only.** A reader opens files for reading and nothing else. `test/read-only.test.mjs`
135
+ checks this folder along with `harnesses/` and `lib/`.
136
+ - **Only the user's own home directory.** Never read from or run anything inside another app's
137
+ bundle.
138
+ - **Read incrementally.** Transcripts grow to tens of megabytes, so keep a byte offset per file
139
+ and parse only what was added. `jsonlFollower` in `../lib/jsonl-follower.mjs` does that for any
140
+ JSONL transcript, and Claude Code's go through `../lib/claude-transcript.mjs`. A transcript that
141
+ shrank was rewritten: start it over.
142
+ - **Open databases read-only, and check columns first.** `withReadOnly` in `../lib/sqlite.mjs`
143
+ opens one and closes it again. Check a column with `columnsOf` before a query names it, so a
144
+ column a harness renames costs one field instead of the whole harness.
145
+ - **Expect half-written lines.** A session being written right now is normal. Hold a partial
146
+ last line until its newline arrives, and skip a record that won't parse.
147
+ - **Never read secrets.** Claude Code keeps `*.key` files beside its session records. Read only
148
+ the `.json` files.
@@ -0,0 +1,268 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { exists, jsonLines, listDirs } from '../lib/fsutil.mjs'
5
+ import { jsonlFollower } from '../lib/jsonl-follower.mjs'
6
+ import {
7
+ DETAIL_LIMITS,
8
+ RECENT_MS,
9
+ activityDetail,
10
+ clip,
11
+ clipBlock,
12
+ emptyActivity,
13
+ keepNewest,
14
+ planItems,
15
+ planSummary,
16
+ startCall,
17
+ text,
18
+ titleFromPrompt,
19
+ } from '../lib/telemetry-fields.mjs'
20
+
21
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
23
+ const TRANSCRIPTS = [
24
+ ['.system_generated', 'logs', 'transcript_full.jsonl'],
25
+ ['.system_generated', 'logs', 'transcript.jsonl'],
26
+ ['transcript.jsonl'],
27
+ ]
28
+ const CHECKLIST = /^\s*[-*]\s+\[([ xX/])\]\s+(.+?)\s*$/
29
+ const CHECK_STATUS = { ' ': 'pending', '/': 'in_progress', x: 'completed', X: 'completed' }
30
+
31
+ function contentText(content) {
32
+ if (typeof content === 'string') return content
33
+ if (!Array.isArray(content)) return ''
34
+ return content.map((p) => (typeof p === 'string' ? p : text(p?.text) || text(p?.input_text))).filter(Boolean).join('\n')
35
+ }
36
+
37
+ function userText(record) {
38
+ const raw = contentText(record?.content || record?.message?.content || '')
39
+ const query = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/i.exec(raw)
40
+ return (query ? query[1] : raw.replace(/<[a-z_]+>[\s\S]*?<\/[a-z_]+>/gi, ' ')).trim()
41
+ }
42
+
43
+ function workspaceOf(record) {
44
+ const m = /active workspaces, each defined by a URI[\s\S]*?\[(.*?)\]\s*->/i.exec(contentText(record?.content))
45
+ return text(record?.cwd) || (m && path.isAbsolute(m[1]) ? m[1] : '')
46
+ }
47
+
48
+ function epochMs(value) {
49
+ if (typeof value === 'number') return value < 1e11 ? value * 1000 : value
50
+ const t = Date.parse(text(value))
51
+ return Number.isNaN(t) ? 0 : t
52
+ }
53
+
54
+ const isPrompt = (record) => record?.type === 'USER_INPUT' || record?.source === 'USER_EXPLICIT' || record?.role === 'user'
55
+
56
+ const REPLY_STEPS = new Set(['PLANNER_RESPONSE', 'ERROR_MESSAGE'])
57
+
58
+ function stepName(type) {
59
+ const words = text(type).toLowerCase().replace(/_+/g, ' ').trim()
60
+ return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Step'
61
+ }
62
+
63
+ function emptyTranscript() {
64
+ return { firstPrompt: '', cwd: '', startedAt: 0, prompts: 0, failed: false, activity: emptyActivity(), lastStep: '', promptList: [], errors: [] }
65
+ }
66
+
67
+ function closeStep(state, at) {
68
+ const call = state.lastStep ? state.activity.calls.findLast((c) => c.id === state.lastStep) : null
69
+ state.lastStep = ''
70
+ if (!call || !at || call.endedAt) return
71
+ call.endedAt = Math.max(at, call.startedAt)
72
+ const tool = state.activity.tools.get(call.name)
73
+ if (tool) tool.ms += call.endedAt - call.startedAt
74
+ }
75
+
76
+ function applyRecord(state, record) {
77
+ const at = epochMs(record.created_at)
78
+ if (at && !state.startedAt) state.startedAt = at
79
+ if (isPrompt(record)) {
80
+ closeStep(state, 0)
81
+ const prompt = userText(record)
82
+ if (prompt) {
83
+ state.prompts++
84
+ state.firstPrompt ||= prompt
85
+ keepNewest(state.promptList, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
86
+ }
87
+ state.cwd ||= workspaceOf(record)
88
+ } else if (record.type) {
89
+ closeStep(state, at)
90
+ const failed = record.status === 'ERROR' || record.type === 'ERROR_MESSAGE'
91
+ if (!REPLY_STEPS.has(record.type)) {
92
+ const id = String(record.step_index ?? `${at}:${state.activity.calls.length}`)
93
+ const call = startCall(state.activity, { id, name: stepName(record.type), startedAt: at, running: false })
94
+ if (call) {
95
+ call.error = failed
96
+ if (failed) state.activity.tools.get(call.name).errors++
97
+ state.lastStep = id
98
+ }
99
+ }
100
+ if (failed) {
101
+ const message = userText(record) || `${stepName(record.type)} failed`
102
+ keepNewest(state.errors, { at, message: clip(message, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
103
+ }
104
+ }
105
+ if (record.status) state.failed = record.status === 'ERROR' || record.type === 'ERROR_MESSAGE'
106
+ }
107
+
108
+ export function parseChecklist(markdown) {
109
+ const steps = []
110
+ for (const line of text(markdown).split(/\r?\n/)) {
111
+ const m = CHECKLIST.exec(line)
112
+ if (m) steps.push({ content: m[2], status: CHECK_STATUS[m[1]] })
113
+ }
114
+ return steps
115
+ }
116
+
117
+ export function antigravity({
118
+ home = process.env.THREADCRUMBS_ANTIGRAVITY_HOME || process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
119
+ now = Date.now,
120
+ } = {}) {
121
+ const brainDir = path.join(home, 'brain')
122
+ const historyFile = path.join(home, 'history.jsonl')
123
+ const transcripts = jsonlFollower({ empty: emptyTranscript, apply: applyRecord })
124
+ const plans = new Map()
125
+ let history = { mtime: -1, byId: new Map() }
126
+ let known = new Set()
127
+
128
+ async function readHistory() {
129
+ const stat = await fsp.stat(historyFile).catch(() => null)
130
+ if (!stat) return new Map()
131
+ if (stat.mtimeMs === history.mtime) return history.byId
132
+ const byId = new Map()
133
+ for (const row of jsonLines(await fsp.readFile(historyFile, 'utf8').catch(() => ''))) {
134
+ const id = text(row?.conversationId)
135
+ if (!id) continue
136
+ const entry = byId.get(id) || { prompts: 0, firstAt: 0, lastAt: 0, first: '', workspace: '', list: [] }
137
+ const at = epochMs(row.timestamp)
138
+ entry.prompts++
139
+ entry.first ||= text(row.display)
140
+ if (text(row.display).trim()) keepNewest(entry.list, { at, text: clipBlock(row.display, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
141
+ entry.firstAt ||= at
142
+ entry.lastAt = Math.max(entry.lastAt, at)
143
+ if (!entry.workspace && path.isAbsolute(text(row.workspace))) entry.workspace = row.workspace
144
+ byId.set(id, entry)
145
+ }
146
+ history = { mtime: stat.mtimeMs, byId }
147
+ return byId
148
+ }
149
+
150
+ async function readPlan(sessionDir) {
151
+ const file = path.join(sessionDir, 'task.md')
152
+ const stat = await fsp.stat(file).catch(() => null)
153
+ if (!stat) return null
154
+ const hit = plans.get(file)
155
+ if (hit && hit.mtime === stat.mtimeMs) return hit.plan
156
+ const plan = parseChecklist(await fsp.readFile(file, 'utf8').catch(() => ''))
157
+ plans.set(file, { mtime: stat.mtimeMs, plan })
158
+ return plan
159
+ }
160
+
161
+ async function transcriptOf(sessionDir) {
162
+ for (const parts of TRANSCRIPTS) {
163
+ const file = path.join(sessionDir, ...parts)
164
+ const stat = await fsp.stat(file).catch(() => null)
165
+ if (stat?.size) return { file, stat }
166
+ }
167
+ return null
168
+ }
169
+
170
+ async function scanSessions() {
171
+ const at = now()
172
+ const prompts = await readHistory()
173
+ const ids = new Set((await listDirs(brainDir)).map((d) => path.basename(d)).filter((id) => UUID.test(id)))
174
+ for (const [id, entry] of prompts) if (UUID.test(id) && at - entry.lastAt < ACTIVE_WINDOW_MS) ids.add(id)
175
+ const sessions = []
176
+ const seenPlans = new Set()
177
+ const listed = new Set()
178
+ for (const id of ids) {
179
+ const sessionDir = path.join(brainDir, id)
180
+ const found = await transcriptOf(sessionDir)
181
+ const typed = prompts.get(id)
182
+ const transcriptAt = found ? Math.floor(found.stat.mtimeMs) : 0
183
+ const lastActivityAt = Math.max(transcriptAt, typed?.lastAt || 0)
184
+ if (at - lastActivityAt > RECENT_MS) continue
185
+ let state = emptyTranscript()
186
+ if (found) {
187
+ try {
188
+ state = await transcripts.read(found.file, found.stat)
189
+ } catch {
190
+ continue
191
+ }
192
+ }
193
+ seenPlans.add(path.join(sessionDir, 'task.md'))
194
+ listed.add(id)
195
+ const plan = await readPlan(sessionDir)
196
+ const open = Boolean(typed) && typed.lastAt > transcriptAt && at - typed.lastAt < ACTIVE_WINDOW_MS
197
+ let status
198
+ if (open) status = 'working'
199
+ else if (state.failed) status = 'error'
200
+ else status = at - lastActivityAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
201
+ const cwd = state.cwd || typed?.workspace || ''
202
+ const prompt = state.firstPrompt || typed?.first || ''
203
+ sessions.push({
204
+ id: `antigravity:${id}`,
205
+ harness: 'antigravity',
206
+ title: (prompt ? titleFromPrompt(prompt) : '') || 'Untitled session',
207
+ project: cwd ? path.basename(cwd.replace(/\\/g, '/')) : 'unknown',
208
+ projectPath: cwd,
209
+ worktree: '',
210
+ cwd,
211
+ gitBranch: '',
212
+ model: '',
213
+ effort: '',
214
+ status,
215
+ live: open,
216
+ startedAt: state.startedAt || typed?.firstAt || transcriptAt,
217
+ lastActivityAt,
218
+ tool: null,
219
+ tokens: null,
220
+ context: null,
221
+ costUsd: null,
222
+ lines: null,
223
+ plan: planSummary(plan),
224
+ subagents: 0,
225
+ prompts: Math.max(state.prompts, typed?.prompts || 0),
226
+ })
227
+ }
228
+ transcripts.sweep()
229
+ for (const file of plans.keys()) if (!seenPlans.has(file)) plans.delete(file)
230
+ known = listed
231
+ return sessions
232
+ }
233
+
234
+ async function sessionDetail(key) {
235
+ const id = String(key).replace(/^antigravity:/, '')
236
+ if (!known.has(id)) return null
237
+ const sessionDir = path.join(brainDir, id)
238
+ const found = await transcriptOf(sessionDir)
239
+ const state = (found && (await transcripts.read(found.file, found.stat).catch(() => null))) || emptyTranscript()
240
+ const typed = (await readHistory()).get(id)
241
+ return {
242
+ id: `antigravity:${id}`,
243
+ sessionId: id,
244
+ source: found?.file || historyFile,
245
+ models: null,
246
+ history: null,
247
+ compactions: null,
248
+ contextParts: null,
249
+ ...activityDetail(state.activity),
250
+ plan: planItems(await readPlan(sessionDir)),
251
+ subagents: null,
252
+ files: null,
253
+ prompts: (typed?.list.length ? typed.list : state.promptList).map((p) => ({ ...p })),
254
+ errors: state.errors.map((e) => ({ ...e })),
255
+ }
256
+ }
257
+
258
+ return {
259
+ id: 'antigravity',
260
+ name: 'Antigravity CLI',
261
+ detect: async () => (await exists(brainDir)) || (await exists(historyFile)),
262
+ watchRoots: () => [home],
263
+ scanSessions,
264
+ sessionDetail,
265
+ }
266
+ }
267
+
268
+ export default antigravity()