@threadcrumbs/common 0.1.4 → 0.1.6
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/NOTICES.md +96 -0
- package/README.md +14 -2
- package/lib/claude-transcript.mjs +37 -5
- package/lib/context-breakdown.mjs +25 -17
- package/lib/jsonl-follower.mjs +15 -1
- package/lib/opencode-store.mjs +60 -21
- package/lib/telemetry-fields.mjs +4 -0
- package/package.json +26 -3
- package/telemetry/README.md +20 -4
- package/telemetry/antigravity.mjs +2 -1
- package/telemetry/claude-code.mjs +17 -10
- package/telemetry/codex.mjs +34 -24
- package/telemetry/cursor.mjs +2 -1
- package/telemetry/hermes.mjs +2 -1
- package/ui/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- package/ui/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/ui/fonts/inter-tight-latin-ext-wght-normal.woff2 +0 -0
- package/ui/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/ui/fonts.css +48 -0
- package/ui/format.mjs +60 -0
- package/ui/gauge.css +75 -0
- package/ui/gauge.mjs +14 -0
- package/ui/html.mjs +12 -0
- package/ui/icons.mjs +105 -0
- package/ui/pictures.css +100 -0
- package/ui/pictures.mjs +158 -0
- package/ui/rows.css +403 -0
- package/ui/rows.mjs +172 -0
- package/ui/status.css +75 -0
- package/ui/status.mjs +36 -0
- package/ui/theme.css +104 -0
- package/ui/totals.css +96 -0
- package/ui/totals.mjs +94 -0
- package/ui/where.css +43 -0
- package/ui/where.mjs +24 -0
package/telemetry/README.md
CHANGED
|
@@ -98,8 +98,10 @@ adapter in `../harnesses/`, so a session has the same id in both apps. Add the h
|
|
|
98
98
|
| `error` | `turn-failed` | The last turn ended in an error | All but Hermes |
|
|
99
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
100
|
| | `recent` | No record of reading, and the turn ended in the last 30 minutes | All |
|
|
101
|
+
| | `process-idle` | The live process says it's idle, with no record of reading, and the turn ended in the last 30 minutes | Claude Code |
|
|
101
102
|
| `idle` | `read` | The harness records that you've read the reply | Claude Code (the app's record), Codex, Hermes, OpenCode 2.x |
|
|
102
103
|
| | `quiet` | No record of reading, and nothing for 30 minutes | All |
|
|
104
|
+
| | `process-idle` | The live process says it's idle, with no record of reading, and nothing for 30 minutes | Claude Code |
|
|
103
105
|
|
|
104
106
|
### What a thread needs
|
|
105
107
|
|
|
@@ -158,6 +160,15 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
|
|
|
158
160
|
(`startCall`, `endCall`, `toolTarget`, `keepNewest`, `fileList`, and the limits) are in
|
|
159
161
|
`../lib/telemetry-fields.mjs`.
|
|
160
162
|
|
|
163
|
+
`sessionDetail(id, { filesOnly: true })` returns only `{ id, sessionId, files }`, with each file
|
|
164
|
+
as `{ path }`, from what the session scan already holds. It's for finding other threads that
|
|
165
|
+
changed the same files, so it never opens the readers that serve an open thread.
|
|
166
|
+
|
|
167
|
+
The readers that serve an open thread (thoughts and replies for Claude Code, Codex, Cursor, and
|
|
168
|
+
Antigravity) are kept for the last three sessions opened. Claude Code's and Codex's read the
|
|
169
|
+
transcript from its end first, in growing steps, until they have the newest 50 replies, and then
|
|
170
|
+
follow it forwards.
|
|
171
|
+
|
|
161
172
|
### Context breakdown
|
|
162
173
|
|
|
163
174
|
`contextBreakdown` splits the context into parts, so an app can show what fills it.
|
|
@@ -170,8 +181,8 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
|
|
|
170
181
|
them aren't read, so an app should call it the default. Free space is `limit - total -
|
|
171
182
|
reserved`.
|
|
172
183
|
- **`parts`** are `[{ id, tokens, items }]` in this order: `messages`, `system`,
|
|
173
|
-
`builtInTools`, `mcpTools`, `skills`, `memory`. A part
|
|
174
|
-
out, and its tokens stay in `system`.
|
|
184
|
+
`builtInTools`, `mcpTools`, `skills`, `memory`. A part the harness records is sent even at 0
|
|
185
|
+
tokens; one it doesn't record is left out, and its tokens stay in `system`.
|
|
175
186
|
- `messages` items: `{ id: 'prompts' }`, `{ id: 'replies' }`, `{ id: 'summary' }` (from the
|
|
176
187
|
last compaction), and `{ id: 'tool', name }` for each tool's calls and results.
|
|
177
188
|
- `mcpTools` items: `{ name, tools }` per server, `tools` a count or `null`.
|
|
@@ -183,6 +194,11 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
|
|
|
183
194
|
- That gives a characters-per-token ratio for the text of each fixed part (system prompt,
|
|
184
195
|
tool definitions, MCP servers, skills, memory files), so each part is its text length at
|
|
185
196
|
that ratio. A part added later counts at the same ratio.
|
|
197
|
+
- The ratio is used only when the system prompt and tool definitions were recorded, as in
|
|
198
|
+
Claude Code's `api-request-shape`. Otherwise it would spread their unrecorded text over the
|
|
199
|
+
parts that were, so each recorded part is its text at 4 characters a token, and `system` is
|
|
200
|
+
the rest of the fixed count. That's always the case for Codex, whose built-in tool
|
|
201
|
+
definitions aren't written.
|
|
186
202
|
- `messages` is the rest of the total. Each request's growth over the one before is split
|
|
187
203
|
among the prompts, replies, and tool calls and results since then, by text length. After a
|
|
188
204
|
compaction, the next request's growth over the fixed parts is the summary.
|
|
@@ -200,8 +216,8 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
|
|
|
200
216
|
- Codex: the rollout's base instructions and developer messages, its AGENTS.md messages, and
|
|
201
217
|
its `additional_tools` items. Its built-in tool definitions aren't written, so they're in
|
|
202
218
|
`system`.
|
|
203
|
-
- **
|
|
204
|
-
|
|
219
|
+
- **Its sizes are kept by the session scan,** which already reads every record, as numbers and
|
|
220
|
+
names only, never the text, so opening a thread doesn't read its transcript again.
|
|
205
221
|
|
|
206
222
|
### Tool kinds
|
|
207
223
|
|
|
@@ -252,9 +252,10 @@ export function antigravity({
|
|
|
252
252
|
return sessions
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
async function sessionDetail(key) {
|
|
255
|
+
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
256
256
|
const id = String(key).replace(/^antigravity:/, '')
|
|
257
257
|
if (!known.has(id)) return null
|
|
258
|
+
if (filesOnly) return { id: `antigravity:${id}`, sessionId: id, files: null }
|
|
258
259
|
const sessionDir = path.join(brainDir, id)
|
|
259
260
|
const found = await transcriptOf(sessionDir)
|
|
260
261
|
const state = (found && (await transcripts.read(found.file, found.stat).catch(() => null))) || emptyTranscript()
|
|
@@ -6,7 +6,8 @@ import { awaitedPeers, readLiveSessions, transcriptDetail, transcriptReader, tur
|
|
|
6
6
|
import { exists, listDirs, listFiles, num } from '../lib/fsutil.mjs'
|
|
7
7
|
import { contextBreakdown } from '../lib/context-breakdown.mjs'
|
|
8
8
|
import { recentReaders } from '../lib/jsonl-follower.mjs'
|
|
9
|
-
import {
|
|
9
|
+
import { nameKind } from '../lib/tool-kinds.mjs'
|
|
10
|
+
import { DETAIL_LIMITS, RECENT_MS, askOf, filePaths, number, planSummary, sampleList, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
|
|
10
11
|
|
|
11
12
|
export { nextPlan } from '../lib/telemetry-fields.mjs'
|
|
12
13
|
|
|
@@ -27,10 +28,10 @@ export function claudeCode({
|
|
|
27
28
|
const projectsDir = path.join(home, 'projects')
|
|
28
29
|
const liveDir = path.join(home, 'sessions')
|
|
29
30
|
const catalogDir = path.join(home, 'cache', 'model-catalog')
|
|
30
|
-
const transcripts = transcriptReader({ detail: true })
|
|
31
|
+
const transcripts = transcriptReader({ detail: true, sizes: true })
|
|
31
32
|
const subagentFiles = transcriptReader()
|
|
32
33
|
const subagentDetails = transcriptReader({ detail: true, words: true })
|
|
33
|
-
const openedFor = recentReaders(() => transcriptReader({ words: true,
|
|
34
|
+
const openedFor = recentReaders(() => transcriptReader({ words: true, tail: true }), OPENED_KEPT)
|
|
34
35
|
const desktopRecords = desktopRecordReader()
|
|
35
36
|
const olderReaders = new Map()
|
|
36
37
|
const metas = new Map()
|
|
@@ -219,11 +220,11 @@ export function claudeCode({
|
|
|
219
220
|
const read = await openedFor(file).read(file, stat).catch(() => null)
|
|
220
221
|
const limit = contextLimit(limits, state.model)
|
|
221
222
|
const reserved = limit ? Math.min(RESERVED_TOKENS, limit) : RESERVED_TOKENS
|
|
222
|
-
return { words: read?.words ?? null, contextBreakdown: contextBreakdown(
|
|
223
|
+
return { words: read?.words ?? null, contextBreakdown: contextBreakdown(state.sizes, { total: state.context, limit, reserved }) }
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
function olderReader(file) {
|
|
226
|
-
const reader = olderReaders.get(file) || transcriptReader({ detail: true })
|
|
227
|
+
const reader = olderReaders.get(file) || transcriptReader({ detail: true, sizes: true })
|
|
227
228
|
olderReaders.delete(file)
|
|
228
229
|
olderReaders.set(file, reader)
|
|
229
230
|
while (olderReaders.size > OLDER_KEPT) olderReaders.delete(olderReaders.keys().next().value)
|
|
@@ -252,13 +253,14 @@ export function claudeCode({
|
|
|
252
253
|
}
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
async function sessionDetail(sessionKey, { subagent = null } = {}) {
|
|
256
|
+
async function sessionDetail(sessionKey, { subagent = null, filesOnly = false } = {}) {
|
|
256
257
|
const id = String(sessionKey).replace(/^claude-code:/, '')
|
|
257
258
|
const file = known.get(id)
|
|
258
259
|
const stat = file ? await fsp.stat(file).catch(() => null) : null
|
|
259
260
|
if (!stat) return null
|
|
260
261
|
if (subagent != null) return subagentDetail(file, id, String(subagent))
|
|
261
262
|
const state = await transcripts.read(file, stat)
|
|
263
|
+
if (filesOnly) return { id: `claude-code:${id}`, sessionId: id, files: filePaths(state.detail.files, state.cwd) }
|
|
262
264
|
const said = await opened(file, stat, state, await contextLimits())
|
|
263
265
|
return {
|
|
264
266
|
id: `claude-code:${id}`,
|
|
@@ -332,15 +334,19 @@ function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, un
|
|
|
332
334
|
const { failed, onAgents: onlyAgents } = turnEnding(state)
|
|
333
335
|
const blocked = mark?.outcome === 'blocked'
|
|
334
336
|
const failedTurn = failed || Boolean(mark?.failed)
|
|
335
|
-
const
|
|
337
|
+
const settled = running?.status === 'idle'
|
|
338
|
+
const quiet = () => (unread ? ['done', 'unread'] : ['idle', mark ? 'read' : settled ? 'process-idle' : 'quiet'])
|
|
336
339
|
if (running) {
|
|
337
|
-
if (running.status === 'waiting')
|
|
340
|
+
if (running.status === 'waiting') {
|
|
341
|
+
const asked = calls.filter((c) => (c.kind ?? nameKind(c.name)) !== 'agent')
|
|
342
|
+
return ['asking', asked.some((c) => c.questions) ? 'question' : asked.length ? 'approval' : 'waiting']
|
|
343
|
+
}
|
|
338
344
|
const busy = running.status ? running.status === 'busy' : midTurn
|
|
339
345
|
if (busy) return onlyAgents ? ['delegating', 'subagents'] : ['working', running.status ? 'process-busy' : 'turn-open']
|
|
340
346
|
if (subagents || awaiting.length) return ['delegating', subagents ? 'subagents' : 'awaiting']
|
|
341
347
|
if (blocked) return ['asking', 'marked-blocked']
|
|
342
348
|
if (failedTurn) return ['error', 'turn-failed']
|
|
343
|
-
if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', 'recent']
|
|
349
|
+
if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', settled ? 'process-idle' : 'recent']
|
|
344
350
|
return quiet()
|
|
345
351
|
}
|
|
346
352
|
if (midTurn && now - lastAt < STALE_TURN_MS) return onlyAgents ? ['delegating', 'subagents'] : ['working', 'turn-open']
|
|
@@ -364,7 +370,8 @@ function toSession({ id, state, stat, running, subagents, workingSubagents, awai
|
|
|
364
370
|
const unread = Boolean(mark) && (state.handedBackAt || mark.activeAt) > mark.focusedAt
|
|
365
371
|
const calls = [...state.open.values()].sort((a, b) => a.startedAt - b.startedAt)
|
|
366
372
|
const [status, statusReason] = statusOf({ running, state, calls, subagents, awaiting, lastAt: lastActivityAt, mark, unread, now })
|
|
367
|
-
const
|
|
373
|
+
const markFailed = Boolean(mark?.failed) && !turnEnding(state).failed
|
|
374
|
+
const errorText = (markFailed && mark.errorText) || state.detail?.errors.at(-1)?.message || mark?.errorText || ''
|
|
368
375
|
const midTurn = Boolean(state.ending && !state.ending.handedBack)
|
|
369
376
|
const turnOpen = midTurn && (status === 'working' || status === 'delegating' || status === 'asking')
|
|
370
377
|
const { projectPath, project, worktree } = projectOf(mark?.cwd || state.firstCwd || state.cwd, mark?.originCwd)
|
package/telemetry/codex.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
emptySample,
|
|
20
20
|
endCall,
|
|
21
21
|
fileList,
|
|
22
|
+
filePaths,
|
|
22
23
|
inputKind,
|
|
23
24
|
joinedText,
|
|
24
25
|
keepNewest,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
startCall,
|
|
35
36
|
text,
|
|
36
37
|
textList,
|
|
38
|
+
wordsFull,
|
|
37
39
|
titleFromPrompt,
|
|
38
40
|
toolInput,
|
|
39
41
|
toolNow,
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
const ACTIVE_WINDOW_MS = 30 * 60 * 1000
|
|
44
46
|
const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
|
|
45
47
|
const OPENED_KEPT = 3
|
|
48
|
+
const TAIL_BYTES = 512 * 1024
|
|
46
49
|
const COMPACT_SHARE = 0.9
|
|
47
50
|
const REPORTED_SHARE = 0.95
|
|
48
51
|
const ROLLOUT_FILE = /^rollout-.{19}-([0-9a-f-]{36})(?:_[0-9a-f-]{36})?\.jsonl$/i
|
|
@@ -143,6 +146,7 @@ function emptyRollout() {
|
|
|
143
146
|
fallbackPrompts: 0,
|
|
144
147
|
open: false,
|
|
145
148
|
turnStartedAt: 0,
|
|
149
|
+
turnId: '',
|
|
146
150
|
usageLimits: null,
|
|
147
151
|
failed: false,
|
|
148
152
|
calls: new Map(),
|
|
@@ -151,6 +155,8 @@ function emptyRollout() {
|
|
|
151
155
|
tokens: null,
|
|
152
156
|
context: null,
|
|
153
157
|
contextSample: emptySample(),
|
|
158
|
+
sizes: emptySizes(),
|
|
159
|
+
sent: emptySent(),
|
|
154
160
|
plan: null,
|
|
155
161
|
history: [],
|
|
156
162
|
contextParts: null,
|
|
@@ -166,13 +172,19 @@ function emptyRollout() {
|
|
|
166
172
|
|
|
167
173
|
const AGENTS_FILE = /^# AGENTS\.md instructions for ([^\n]+)/
|
|
168
174
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
const emptyWords = () => ({ lastAt: 0, thoughts: [], replies: [] })
|
|
176
|
+
|
|
177
|
+
function applyWords(state, record) {
|
|
178
|
+
const at = Date.parse(record.timestamp) || state.lastAt
|
|
179
|
+
state.lastAt = at
|
|
180
|
+
const item = record.payload
|
|
181
|
+
if (record.type !== 'response_item' || !item || typeof item !== 'object') return
|
|
182
|
+
const id = text(item.id) || null
|
|
183
|
+
if (item.type === 'reasoning') keepText(state.thoughts, 'thoughts', { id, at, text: joinedText(item.summary, 'summary_text', 'text') })
|
|
184
|
+
else if (item.type === 'message' && item.role === 'assistant') keepText(state.replies, 'replies', { id, at, text: joinedText(item.content, 'output_text', 'text') })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const emptySent = () => ({ base: 0, developer: [], other: 0, memory: new Map(), tools: new Map() })
|
|
176
188
|
|
|
177
189
|
function callName(item) {
|
|
178
190
|
if (item.type === 'local_shell_call') return 'shell'
|
|
@@ -186,14 +198,11 @@ function codexFixed(sent) {
|
|
|
186
198
|
fixed.system = developer + (sent.developer.includes(sent.base) ? 0 : sent.base) + sent.other
|
|
187
199
|
fixed.mcp = new Map(sent.tools)
|
|
188
200
|
fixed.memory = new Map(sent.memory)
|
|
201
|
+
fixed.recorded = new Set(['system', 'mcpTools', 'memory'])
|
|
189
202
|
return fixed
|
|
190
203
|
}
|
|
191
204
|
|
|
192
|
-
function
|
|
193
|
-
const at = Date.parse(record.timestamp) || state.lastAt
|
|
194
|
-
state.lastAt = at
|
|
195
|
-
const item = record.payload
|
|
196
|
-
if (!item || typeof item !== 'object') return
|
|
205
|
+
function applySizes(state, record, item, at) {
|
|
197
206
|
const { sizes, sent } = state
|
|
198
207
|
const before = sizes.first == null
|
|
199
208
|
if (record.type === 'session_meta') {
|
|
@@ -207,14 +216,10 @@ function applyOpened(state, record) {
|
|
|
207
216
|
} else if (record.type !== 'response_item') {
|
|
208
217
|
return
|
|
209
218
|
} else if (item.type === 'reasoning') {
|
|
210
|
-
|
|
211
|
-
keepText(state.thoughts, 'thoughts', { id: text(item.id) || null, at, text: said })
|
|
212
|
-
sizeReply(sizes, said.length)
|
|
219
|
+
sizeReply(sizes, joinedText(item.summary, 'summary_text', 'text').length)
|
|
213
220
|
return
|
|
214
221
|
} else if (item.type === 'message' && item.role === 'assistant') {
|
|
215
|
-
|
|
216
|
-
keepText(state.replies, 'replies', { id: text(item.id) || null, at, text: said })
|
|
217
|
-
sizeReply(sizes, said.length)
|
|
222
|
+
sizeReply(sizes, joinedText(item.content, 'output_text', 'text').length)
|
|
218
223
|
return
|
|
219
224
|
} else if (CALLS.has(item.type)) {
|
|
220
225
|
sizeCall(sizes, text(item.call_id), callName(item), (text(item.arguments) || text(item.input) || JSON.stringify(item.action ?? '')).length)
|
|
@@ -230,7 +235,7 @@ function applyOpened(state, record) {
|
|
|
230
235
|
} else if (item.type === 'message') {
|
|
231
236
|
const raw = contentText(item.content)
|
|
232
237
|
const agents = item.role === 'user' ? AGENTS_FILE.exec(raw) : null
|
|
233
|
-
if (agents) sent.memory.set(`${agents[1].trim().replace(/[
|
|
238
|
+
if (agents) sent.memory.set(`${agents[1].trim().replace(/[\\/]+$/, '')}/AGENTS.md`, raw.length)
|
|
234
239
|
else if (item.role === 'user' && promptText(raw)) {
|
|
235
240
|
sizePrompt(sizes, raw.length)
|
|
236
241
|
return
|
|
@@ -253,6 +258,7 @@ function applyRecord(state, record) {
|
|
|
253
258
|
}
|
|
254
259
|
const payload = record.payload
|
|
255
260
|
if (!payload || typeof payload !== 'object') return
|
|
261
|
+
applySizes(state, record, payload, at || state.lastAt)
|
|
256
262
|
if (record.type === 'session_meta') applyMeta(state, payload)
|
|
257
263
|
else if (record.type === 'turn_context') applySettings(state, payload.model, payload.effort, payload.cwd)
|
|
258
264
|
else if (record.type === 'response_item') applyItem(state, payload, at || state.lastAt)
|
|
@@ -367,13 +373,16 @@ function addLines(state, patch) {
|
|
|
367
373
|
function applyEvent(state, event, at) {
|
|
368
374
|
switch (event.type) {
|
|
369
375
|
case 'task_started':
|
|
370
|
-
case 'turn_started':
|
|
371
|
-
|
|
376
|
+
case 'turn_started': {
|
|
377
|
+
const turn = text(event.turn_id)
|
|
378
|
+
if (!state.open || (turn && turn !== state.turnId)) state.turnStartedAt = at
|
|
379
|
+
if (turn) state.turnId = turn
|
|
372
380
|
state.open = true
|
|
373
381
|
state.failed = false
|
|
374
382
|
state.calls.clear()
|
|
375
383
|
settleCalls(state.activity)
|
|
376
384
|
break
|
|
385
|
+
}
|
|
377
386
|
case 'task_complete':
|
|
378
387
|
case 'turn_complete':
|
|
379
388
|
state.open = false
|
|
@@ -468,7 +477,7 @@ export function codex({
|
|
|
468
477
|
const locksDir = path.join(home, 'thread-writer-locks')
|
|
469
478
|
const indexFile = path.join(home, 'session_index.jsonl')
|
|
470
479
|
const rollouts = jsonlFollower({ empty: emptyRollout, apply: applyRecord })
|
|
471
|
-
const openedFor = recentReaders(() => jsonlFollower({ empty:
|
|
480
|
+
const openedFor = recentReaders(() => jsonlFollower({ empty: emptyWords, apply: applyWords, tail: { bytes: TAIL_BYTES, enough: wordsFull } }), OPENED_KEPT)
|
|
472
481
|
let index = { mtime: -1, names: new Map() }
|
|
473
482
|
let known = new Map()
|
|
474
483
|
let childrenOf = new Map()
|
|
@@ -576,11 +585,12 @@ export function codex({
|
|
|
576
585
|
return state ? { file, stat, state } : null
|
|
577
586
|
}
|
|
578
587
|
|
|
579
|
-
async function sessionDetail(key) {
|
|
588
|
+
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
580
589
|
const id = String(key).replace(/^codex:/, '').toLowerCase()
|
|
581
590
|
const found = await readKnown(id)
|
|
582
591
|
if (!found || found.state.subagent || found.state.internal) return null
|
|
583
592
|
const { file, stat, state } = found
|
|
593
|
+
if (filesOnly) return { id: `codex:${id}`, sessionId: id, files: filePaths(state.files, state.cwd) }
|
|
584
594
|
const said = await openedFor(file).read(file, stat).catch(() => null)
|
|
585
595
|
const limit = state.context?.limit || null
|
|
586
596
|
const subagents = []
|
|
@@ -617,7 +627,7 @@ export function codex({
|
|
|
617
627
|
prompts: (state.typed ? state.promptList : state.fallbackPromptList).map((p) => ({ ...p })),
|
|
618
628
|
thoughts: textList(said?.thoughts),
|
|
619
629
|
replies: textList(said?.replies),
|
|
620
|
-
contextBreakdown: contextBreakdown(
|
|
630
|
+
contextBreakdown: contextBreakdown(state.sizes, {
|
|
621
631
|
total: state.context?.used,
|
|
622
632
|
limit,
|
|
623
633
|
reserved: limit ? Math.round(limit * (1 - COMPACT_SHARE / REPORTED_SHARE)) : null,
|
package/telemetry/cursor.mjs
CHANGED
|
@@ -198,9 +198,10 @@ export function cursor({
|
|
|
198
198
|
return list.sort((a, b) => a.startedAt - b.startedAt).slice(-DETAIL_LIMITS.subagents)
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
async function sessionDetail(key) {
|
|
201
|
+
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
202
202
|
const id = String(key).replace(/^cursor:/, '')
|
|
203
203
|
const entry = known.get(id)
|
|
204
|
+
if (filesOnly) return entry ? { id: `cursor:${id}`, sessionId: id, files: null } : null
|
|
204
205
|
const stat = entry ? await fsp.stat(entry.file).catch(() => null) : null
|
|
205
206
|
if (!stat) return null
|
|
206
207
|
const state = await follow(entry.file, stat)
|
package/telemetry/hermes.mjs
CHANGED
|
@@ -122,10 +122,11 @@ export function hermes({ home = resolveHermesHome(), now = Date.now } = {}) {
|
|
|
122
122
|
return sessions
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
async function sessionDetail(key) {
|
|
125
|
+
async function sessionDetail(key, { filesOnly = false } = {}) {
|
|
126
126
|
const match = /^hermes:([^:]+):(.+)$/.exec(String(key))
|
|
127
127
|
if (!match) return null
|
|
128
128
|
const [, profile, rootId] = match
|
|
129
|
+
if (filesOnly) return { id: `hermes:${profile}:${rootId}`, sessionId: rootId, files: null }
|
|
129
130
|
const store = (await storeFiles()).find((s) => s.profile === profile)
|
|
130
131
|
if (!store) return null
|
|
131
132
|
const detail = await withReadOnly(store.file, (db) => detailFrom(db, rootId, now()), null)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ui/fonts.css
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
@font-face {
|
|
2
|
+
font-family: 'Inter Variable';
|
|
3
|
+
font-style: normal;
|
|
4
|
+
font-display: swap;
|
|
5
|
+
font-weight: 100 900;
|
|
6
|
+
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
|
7
|
+
src: url('./fonts/inter-latin-ext-wght-normal.woff2') format('woff2-variations');
|
|
8
|
+
}
|
|
9
|
+
@font-face {
|
|
10
|
+
font-family: 'Inter Variable';
|
|
11
|
+
font-style: normal;
|
|
12
|
+
font-display: swap;
|
|
13
|
+
font-weight: 100 900;
|
|
14
|
+
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
|
15
|
+
src: url('./fonts/inter-latin-wght-normal.woff2') format('woff2-variations');
|
|
16
|
+
}
|
|
17
|
+
@font-face {
|
|
18
|
+
font-family: 'Inter Tight Variable';
|
|
19
|
+
font-style: normal;
|
|
20
|
+
font-display: swap;
|
|
21
|
+
font-weight: 100 900;
|
|
22
|
+
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
|
23
|
+
src: url('./fonts/inter-tight-latin-ext-wght-normal.woff2') format('woff2-variations');
|
|
24
|
+
}
|
|
25
|
+
@font-face {
|
|
26
|
+
font-family: 'Inter Tight Variable';
|
|
27
|
+
font-style: normal;
|
|
28
|
+
font-display: swap;
|
|
29
|
+
font-weight: 100 900;
|
|
30
|
+
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
|
31
|
+
src: url('./fonts/inter-tight-latin-wght-normal.woff2') format('woff2-variations');
|
|
32
|
+
}
|
|
33
|
+
@font-face {
|
|
34
|
+
font-family: 'JetBrains Mono Variable';
|
|
35
|
+
font-style: normal;
|
|
36
|
+
font-display: swap;
|
|
37
|
+
font-weight: 100 800;
|
|
38
|
+
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
|
39
|
+
src: url('./fonts/jetbrains-mono-latin-ext-wght-normal.woff2') format('woff2-variations');
|
|
40
|
+
}
|
|
41
|
+
@font-face {
|
|
42
|
+
font-family: 'JetBrains Mono Variable';
|
|
43
|
+
font-style: normal;
|
|
44
|
+
font-display: swap;
|
|
45
|
+
font-weight: 100 800;
|
|
46
|
+
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
|
47
|
+
src: url('./fonts/jetbrains-mono-latin-wght-normal.woff2') format('woff2-variations');
|
|
48
|
+
}
|
package/ui/format.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const UNITS = [
|
|
2
|
+
['B', 1e9],
|
|
3
|
+
['M', 1e6],
|
|
4
|
+
['K', 1e3],
|
|
5
|
+
]
|
|
6
|
+
|
|
7
|
+
export function compactNumber(n) {
|
|
8
|
+
if (!Number.isFinite(n)) return '—'
|
|
9
|
+
const abs = Math.abs(n)
|
|
10
|
+
for (const [suffix, size] of UNITS) {
|
|
11
|
+
if (abs < size * 0.9995) continue
|
|
12
|
+
const value = n / size
|
|
13
|
+
const shown = Math.abs(value) >= 99.95 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '')
|
|
14
|
+
return `${shown}${suffix}`
|
|
15
|
+
}
|
|
16
|
+
return String(Math.round(n))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function duration(ms) {
|
|
20
|
+
const seconds = Math.max(0, Math.floor(ms / 1000))
|
|
21
|
+
if (seconds < 60) return `${seconds}s`
|
|
22
|
+
const minutes = Math.floor(seconds / 60)
|
|
23
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
|
24
|
+
const hours = Math.floor(minutes / 60)
|
|
25
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`
|
|
26
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function callDuration(ms) {
|
|
30
|
+
if (!Number.isFinite(ms) || ms < 0) return '—'
|
|
31
|
+
const tenths = Math.round(ms / 100)
|
|
32
|
+
if (tenths < 10) return `${(tenths / 10).toFixed(1)}s`
|
|
33
|
+
return duration(Math.max(ms, 1000))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ago(ms) {
|
|
37
|
+
if (ms < 45 * 1000) return 'just now'
|
|
38
|
+
const minutes = Math.round(ms / 60000)
|
|
39
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
40
|
+
const hours = Math.round(minutes / 60)
|
|
41
|
+
if (hours < 24) return `${hours}h ago`
|
|
42
|
+
return `${Math.round(hours / 24)}d ago`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function percent(used, limit) {
|
|
46
|
+
if (!limit) return null
|
|
47
|
+
return Math.min(100, Math.max(0, (used / limit) * 100))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function modelName(id) {
|
|
51
|
+
const match = /^(?:[\w.-]+\/)?claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[.*\])?$/.exec(id || '')
|
|
52
|
+
if (!match) return id || ''
|
|
53
|
+
const [, family, major, minor] = match
|
|
54
|
+
return `${family[0].toUpperCase()}${family.slice(1)} ${minor ? `${major}.${minor}` : major}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toolLabel(name) {
|
|
58
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(name || '')
|
|
59
|
+
return mcp ? `${mcp[2]} (${mcp[1]})` : name || ''
|
|
60
|
+
}
|
package/ui/gauge.css
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
.inst {
|
|
2
|
+
display: block;
|
|
3
|
+
flex: none;
|
|
4
|
+
overflow: visible;
|
|
5
|
+
}
|
|
6
|
+
.i-dial {
|
|
7
|
+
fill: var(--dial);
|
|
8
|
+
stroke: var(--line);
|
|
9
|
+
stroke-width: 1;
|
|
10
|
+
}
|
|
11
|
+
.i-mini .i-dial {
|
|
12
|
+
stroke-width: 2.4;
|
|
13
|
+
}
|
|
14
|
+
.i-hub {
|
|
15
|
+
fill: var(--hub);
|
|
16
|
+
}
|
|
17
|
+
.i-track,
|
|
18
|
+
.i-fill,
|
|
19
|
+
.i-red {
|
|
20
|
+
fill: none;
|
|
21
|
+
stroke-width: 6;
|
|
22
|
+
stroke-linecap: round;
|
|
23
|
+
}
|
|
24
|
+
.i-track {
|
|
25
|
+
stroke: var(--dial-track);
|
|
26
|
+
}
|
|
27
|
+
.i-fill {
|
|
28
|
+
stroke: var(--inst);
|
|
29
|
+
}
|
|
30
|
+
.i-red {
|
|
31
|
+
stroke: var(--err);
|
|
32
|
+
opacity: 0.8;
|
|
33
|
+
}
|
|
34
|
+
.i-mini .i-track,
|
|
35
|
+
.i-mini .i-fill,
|
|
36
|
+
.i-mini .i-red {
|
|
37
|
+
stroke-width: 10;
|
|
38
|
+
}
|
|
39
|
+
.inst[data-level='high'] .i-fill {
|
|
40
|
+
stroke: var(--warn);
|
|
41
|
+
}
|
|
42
|
+
.inst[data-level='full'] .i-fill {
|
|
43
|
+
stroke: var(--err);
|
|
44
|
+
}
|
|
45
|
+
.i-needle {
|
|
46
|
+
stroke: var(--needle);
|
|
47
|
+
stroke-width: 3.2;
|
|
48
|
+
stroke-linecap: round;
|
|
49
|
+
filter: drop-shadow(0 0 3px var(--needle-glow));
|
|
50
|
+
}
|
|
51
|
+
.i-mini .i-needle {
|
|
52
|
+
stroke-width: 6;
|
|
53
|
+
filter: none;
|
|
54
|
+
}
|
|
55
|
+
.i-label {
|
|
56
|
+
fill: var(--text);
|
|
57
|
+
font: 600 12px var(--cond);
|
|
58
|
+
}
|
|
59
|
+
.i-wrap {
|
|
60
|
+
position: relative;
|
|
61
|
+
}
|
|
62
|
+
.i-wrap::after {
|
|
63
|
+
content: '';
|
|
64
|
+
position: absolute;
|
|
65
|
+
top: calc(-1 * var(--hit-t, 4px));
|
|
66
|
+
right: calc(-1 * var(--hit-r, 4px));
|
|
67
|
+
bottom: calc(-1 * var(--hit-b, 4px));
|
|
68
|
+
left: calc(-1 * var(--hit-l, 4px));
|
|
69
|
+
}
|
|
70
|
+
.i-wrap:has(+ .i-wrap) {
|
|
71
|
+
--hit-r: 2px;
|
|
72
|
+
}
|
|
73
|
+
.i-wrap + .i-wrap {
|
|
74
|
+
--hit-l: 2px;
|
|
75
|
+
}
|
package/ui/gauge.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const ARC = 'M-19.8,19.8 A28,28 0 1 1 19.8,19.8'
|
|
2
|
+
const fx = (n) => n.toFixed(2)
|
|
3
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
|
|
4
|
+
|
|
5
|
+
export const levelOf = (pct) => (pct == null ? 'unknown' : pct >= 90 ? 'full' : pct >= 70 ? 'high' : 'ok')
|
|
6
|
+
export const levelClass = (spec) => (spec.level === 'full' ? 'x' : spec.level === 'high' ? 'h' : 'f')
|
|
7
|
+
|
|
8
|
+
export function contextGauge(spec, size = 18) {
|
|
9
|
+
const mini = size <= 30
|
|
10
|
+
const p = clamp(spec.pct ?? 0, 0, 100)
|
|
11
|
+
const level = spec.level || levelOf(spec.pct ?? null)
|
|
12
|
+
const body = `<path d="${ARC}" class="i-track" pathLength="100"/><path d="${ARC}" class="i-red" pathLength="100" stroke-dasharray="10 100" stroke-dashoffset="-90"/><path d="${ARC}" class="i-fill" pathLength="100" stroke-dasharray="${fx(p)} 100"/><line x1="0" y1="0" x2="${mini ? 19 : 21}" y2="0" class="i-needle" transform="rotate(${fx(135 + (270 * p) / 100)})"/>${mini ? '' : `<text x="0" y="28" class="i-label" text-anchor="middle">${Math.round(p)}%</text>`}`
|
|
13
|
+
return `<svg class="inst${mini ? ' i-mini' : ''}" data-k="gauge" data-level="${level}" width="${size}" height="${size}" viewBox="-36 -36 72 72" aria-hidden="true"><circle r="35" class="i-dial"/>${body}<circle r="${mini ? 6 : 4.5}" class="i-hub"/></svg>`
|
|
14
|
+
}
|
package/ui/html.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
2
|
+
|
|
3
|
+
export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
|
|
4
|
+
|
|
5
|
+
export function joinNames(names) {
|
|
6
|
+
if (names.length <= 1) return names[0] || ''
|
|
7
|
+
return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const termButton = (key, label, attrs = '') => `<button type="button" class="term" data-term="${esc(key)}" aria-expanded="false"${attrs ? ` ${attrs}` : ''}>${esc(label)}</button>`
|
|
11
|
+
|
|
12
|
+
export const instAttrs = (spec, val = '', sub = '') => `data-inst="${esc(JSON.stringify(spec))}" data-val="${esc(val)}" data-sub="${esc(sub)}"`
|