@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.
- package/LICENSE.md +34 -0
- package/NOTICES.md +33 -0
- package/README.md +109 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +102 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +40 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- package/telemetry/opencode.mjs +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadcrumbs/common",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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()
|