@threadcrumbs/common 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadcrumbs/common",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Code the Threadcrumbs apps share, starting with the readers for each coding-agent harness.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -25,7 +25,16 @@
25
25
  "./license": "./license/index.mjs",
26
26
  "./license/store": "./license/store.mjs",
27
27
  "./orgs": "./orgs/index.mjs",
28
- "./lib/*": "./lib/*.mjs"
28
+ "./lib/*": "./lib/*.mjs",
29
+ "./ui/status": "./ui/status.mjs",
30
+ "./ui/status.css": "./ui/status.css",
31
+ "./ui/icons": "./ui/icons.mjs",
32
+ "./ui/where": "./ui/where.mjs",
33
+ "./ui/where.css": "./ui/where.css",
34
+ "./ui/gauge": "./ui/gauge.mjs",
35
+ "./ui/gauge.css": "./ui/gauge.css",
36
+ "./ui/theme.css": "./ui/theme.css",
37
+ "./ui/fonts.css": "./ui/fonts.css"
29
38
  },
30
39
  "files": [
31
40
  "NOTICES.md",
@@ -34,9 +43,15 @@
34
43
  "license/",
35
44
  "lib/",
36
45
  "orgs/",
37
- "telemetry/"
46
+ "telemetry/",
47
+ "ui/"
38
48
  ],
39
49
  "scripts": {
40
50
  "test": "node --test \"test/**/*.test.mjs\""
51
+ },
52
+ "devDependencies": {
53
+ "@fontsource-variable/inter": "^5.3.0",
54
+ "@fontsource-variable/inter-tight": "^5.3.0",
55
+ "@fontsource-variable/jetbrains-mono": "^5.3.0"
41
56
  }
42
57
  }
@@ -59,6 +59,7 @@ adapter in `../harnesses/`, so a session has the same id in both apps. Add the h
59
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) |
60
60
  | `tokens` | `{ input, output, cacheRead, cacheWrite }` or `null` | Totals for the session's own thread. `input` leaves out cache reads and writes |
61
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 |
62
63
  | `costUsd` | number or `null` | Spend as the harness records it; `null` when it doesn't |
63
64
  | `lines` | `{ added, removed }` or `null` | Lines changed, as the harness records it |
64
65
  | `plan` | `{ total, done, current }` or `null` | Plan progress, if the agent keeps one |
@@ -157,6 +158,15 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
157
158
  (`startCall`, `endCall`, `toolTarget`, `keepNewest`, `fileList`, and the limits) are in
158
159
  `../lib/telemetry-fields.mjs`.
159
160
 
161
+ `sessionDetail(id, { filesOnly: true })` returns only `{ id, sessionId, files }`, with each file
162
+ as `{ path }`, from what the session scan already holds. It's for finding other threads that
163
+ changed the same files, so it never opens the readers that serve an open thread.
164
+
165
+ The readers that serve an open thread (thoughts and replies for Claude Code, Codex, Cursor, and
166
+ Antigravity) are kept for the last three sessions opened. Claude Code's and Codex's read the
167
+ transcript from its end first, in growing steps, until they have the newest 50 replies, and then
168
+ follow it forwards.
169
+
160
170
  ### Context breakdown
161
171
 
162
172
  `contextBreakdown` splits the context into parts, so an app can show what fills it.
@@ -169,8 +179,8 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
169
179
  them aren't read, so an app should call it the default. Free space is `limit - total -
170
180
  reserved`.
171
181
  - **`parts`** are `[{ id, tokens, items }]` in this order: `messages`, `system`,
172
- `builtInTools`, `mcpTools`, `skills`, `memory`. A part a harness doesn't let us size is left
173
- out, and its tokens stay in `system`.
182
+ `builtInTools`, `mcpTools`, `skills`, `memory`. A part the harness records is sent even at 0
183
+ tokens; one it doesn't record is left out, and its tokens stay in `system`.
174
184
  - `messages` items: `{ id: 'prompts' }`, `{ id: 'replies' }`, `{ id: 'summary' }` (from the
175
185
  last compaction), and `{ id: 'tool', name }` for each tool's calls and results.
176
186
  - `mcpTools` items: `{ name, tools }` per server, `tools` a count or `null`.
@@ -182,6 +192,11 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
182
192
  - That gives a characters-per-token ratio for the text of each fixed part (system prompt,
183
193
  tool definitions, MCP servers, skills, memory files), so each part is its text length at
184
194
  that ratio. A part added later counts at the same ratio.
195
+ - The ratio is used only when the system prompt and tool definitions were recorded, as in
196
+ Claude Code's `api-request-shape`. Otherwise it would spread their unrecorded text over the
197
+ parts that were, so each recorded part is its text at 4 characters a token, and `system` is
198
+ the rest of the fixed count. That's always the case for Codex, whose built-in tool
199
+ definitions aren't written.
185
200
  - `messages` is the rest of the total. Each request's growth over the one before is split
186
201
  among the prompts, replies, and tool calls and results since then, by text length. After a
187
202
  compaction, the next request's growth over the fixed parts is the summary.
@@ -199,8 +214,8 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
199
214
  - Codex: the rollout's base instructions and developer messages, its AGENTS.md messages, and
200
215
  its `additional_tools` items. Its built-in tool definitions aren't written, so they're in
201
216
  `system`.
202
- - **Read only for an open thread,** by the same second reader as thoughts and replies. It keeps
203
- numbers and names, never the text.
217
+ - **Its sizes are kept by the session scan,** which already reads every record, as numbers and
218
+ names only, never the text, so opening a thread doesn't read its transcript again.
204
219
 
205
220
  ### Tool kinds
206
221
 
@@ -233,6 +233,7 @@ export function antigravity({
233
233
  tool: null,
234
234
  tokens: null,
235
235
  context: null,
236
+ contextHistory: null,
236
237
  costUsd: null,
237
238
  lines: null,
238
239
  plan: planSummary(plan),
@@ -251,9 +252,10 @@ export function antigravity({
251
252
  return sessions
252
253
  }
253
254
 
254
- async function sessionDetail(key) {
255
+ async function sessionDetail(key, { filesOnly = false } = {}) {
255
256
  const id = String(key).replace(/^antigravity:/, '')
256
257
  if (!known.has(id)) return null
258
+ if (filesOnly) return { id: `antigravity:${id}`, sessionId: id, files: null }
257
259
  const sessionDir = path.join(brainDir, id)
258
260
  const found = await transcriptOf(sessionDir)
259
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 { DETAIL_LIMITS, RECENT_MS, askOf, number, planSummary, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
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, sizes: true }), OPENED_KEPT)
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(read?.sizes, { total: state.context, limit, reserved }) }
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}`,
@@ -334,7 +336,10 @@ function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, un
334
336
  const failedTurn = failed || Boolean(mark?.failed)
335
337
  const quiet = () => (unread ? ['done', 'unread'] : ['idle', mark ? 'read' : 'quiet'])
336
338
  if (running) {
337
- if (running.status === 'waiting') return ['asking', calls.some((c) => c.questions) ? 'question' : calls.length ? 'approval' : 'waiting']
339
+ if (running.status === 'waiting') {
340
+ const asked = calls.filter((c) => (c.kind ?? nameKind(c.name)) !== 'agent')
341
+ return ['asking', asked.some((c) => c.questions) ? 'question' : asked.length ? 'approval' : 'waiting']
342
+ }
338
343
  const busy = running.status ? running.status === 'busy' : midTurn
339
344
  if (busy) return onlyAgents ? ['delegating', 'subagents'] : ['working', running.status ? 'process-busy' : 'turn-open']
340
345
  if (subagents || awaiting.length) return ['delegating', subagents ? 'subagents' : 'awaiting']
@@ -364,7 +369,8 @@ function toSession({ id, state, stat, running, subagents, workingSubagents, awai
364
369
  const unread = Boolean(mark) && (state.handedBackAt || mark.activeAt) > mark.focusedAt
365
370
  const calls = [...state.open.values()].sort((a, b) => a.startedAt - b.startedAt)
366
371
  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 || ''
372
+ const markFailed = Boolean(mark?.failed) && !turnEnding(state).failed
373
+ const errorText = (markFailed && mark.errorText) || state.detail?.errors.at(-1)?.message || mark?.errorText || ''
368
374
  const midTurn = Boolean(state.ending && !state.ending.handedBack)
369
375
  const turnOpen = midTurn && (status === 'working' || status === 'delegating' || status === 'asking')
370
376
  const { projectPath, project, worktree } = projectOf(mark?.cwd || state.firstCwd || state.cwd, mark?.originCwd)
@@ -388,6 +394,7 @@ function toSession({ id, state, stat, running, subagents, workingSubagents, awai
388
394
  tool: status === 'working' || status === 'delegating' ? currentTool(state.open) : null,
389
395
  tokens: { ...state.tokens },
390
396
  context: state.context ? { used: state.context, limit } : null,
397
+ contextHistory: sampleList(state.contextSample, state.context),
391
398
  costUsd: state.unpriced || totals.unpriced ? (state.cost ? state.cost.usd : null) : state.spend + totals.spend,
392
399
  lines: { added: state.lines.added + totals.added, removed: state.lines.removed + totals.removed },
393
400
  plan: planSummary(state.plan),
@@ -16,8 +16,10 @@ import {
16
16
  clipBlock,
17
17
  diffHunk,
18
18
  emptyActivity,
19
+ emptySample,
19
20
  endCall,
20
21
  fileList,
22
+ filePaths,
21
23
  inputKind,
22
24
  joinedText,
23
25
  keepNewest,
@@ -27,10 +29,13 @@ import {
27
29
  planItems,
28
30
  planSummary,
29
31
  relativePath,
32
+ sampleList,
33
+ samplePoint,
30
34
  settleCalls,
31
35
  startCall,
32
36
  text,
33
37
  textList,
38
+ wordsFull,
34
39
  titleFromPrompt,
35
40
  toolInput,
36
41
  toolNow,
@@ -40,6 +45,7 @@ import {
40
45
  const ACTIVE_WINDOW_MS = 30 * 60 * 1000
41
46
  const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
42
47
  const OPENED_KEPT = 3
48
+ const TAIL_BYTES = 512 * 1024
43
49
  const COMPACT_SHARE = 0.9
44
50
  const REPORTED_SHARE = 0.95
45
51
  const ROLLOUT_FILE = /^rollout-.{19}-([0-9a-f-]{36})(?:_[0-9a-f-]{36})?\.jsonl$/i
@@ -140,6 +146,7 @@ function emptyRollout() {
140
146
  fallbackPrompts: 0,
141
147
  open: false,
142
148
  turnStartedAt: 0,
149
+ turnId: '',
143
150
  usageLimits: null,
144
151
  failed: false,
145
152
  calls: new Map(),
@@ -147,6 +154,9 @@ function emptyRollout() {
147
154
  lines: { added: 0, removed: 0 },
148
155
  tokens: null,
149
156
  context: null,
157
+ contextSample: emptySample(),
158
+ sizes: emptySizes(),
159
+ sent: emptySent(),
150
160
  plan: null,
151
161
  history: [],
152
162
  contextParts: null,
@@ -162,13 +172,19 @@ function emptyRollout() {
162
172
 
163
173
  const AGENTS_FILE = /^# AGENTS\.md instructions for ([^\n]+)/
164
174
 
165
- const emptyOpened = () => ({
166
- lastAt: 0,
167
- thoughts: [],
168
- replies: [],
169
- sizes: emptySizes(),
170
- sent: { base: 0, developer: [], other: 0, memory: new Map(), tools: new Map() },
171
- })
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() })
172
188
 
173
189
  function callName(item) {
174
190
  if (item.type === 'local_shell_call') return 'shell'
@@ -182,14 +198,11 @@ function codexFixed(sent) {
182
198
  fixed.system = developer + (sent.developer.includes(sent.base) ? 0 : sent.base) + sent.other
183
199
  fixed.mcp = new Map(sent.tools)
184
200
  fixed.memory = new Map(sent.memory)
201
+ fixed.recorded = new Set(['system', 'mcpTools', 'memory'])
185
202
  return fixed
186
203
  }
187
204
 
188
- function applyOpened(state, record) {
189
- const at = Date.parse(record.timestamp) || state.lastAt
190
- state.lastAt = at
191
- const item = record.payload
192
- if (!item || typeof item !== 'object') return
205
+ function applySizes(state, record, item, at) {
193
206
  const { sizes, sent } = state
194
207
  const before = sizes.first == null
195
208
  if (record.type === 'session_meta') {
@@ -203,14 +216,10 @@ function applyOpened(state, record) {
203
216
  } else if (record.type !== 'response_item') {
204
217
  return
205
218
  } else if (item.type === 'reasoning') {
206
- const said = joinedText(item.summary, 'summary_text', 'text')
207
- keepText(state.thoughts, 'thoughts', { id: text(item.id) || null, at, text: said })
208
- sizeReply(sizes, said.length)
219
+ sizeReply(sizes, joinedText(item.summary, 'summary_text', 'text').length)
209
220
  return
210
221
  } else if (item.type === 'message' && item.role === 'assistant') {
211
- const said = joinedText(item.content, 'output_text', 'text')
212
- keepText(state.replies, 'replies', { id: text(item.id) || null, at, text: said })
213
- sizeReply(sizes, said.length)
222
+ sizeReply(sizes, joinedText(item.content, 'output_text', 'text').length)
214
223
  return
215
224
  } else if (CALLS.has(item.type)) {
216
225
  sizeCall(sizes, text(item.call_id), callName(item), (text(item.arguments) || text(item.input) || JSON.stringify(item.action ?? '')).length)
@@ -226,7 +235,7 @@ function applyOpened(state, record) {
226
235
  } else if (item.type === 'message') {
227
236
  const raw = contentText(item.content)
228
237
  const agents = item.role === 'user' ? AGENTS_FILE.exec(raw) : null
229
- if (agents) sent.memory.set(`${agents[1].trim().replace(/[\/]+$/, '')}/AGENTS.md`, raw.length)
238
+ if (agents) sent.memory.set(`${agents[1].trim().replace(/[\\/]+$/, '')}/AGENTS.md`, raw.length)
230
239
  else if (item.role === 'user' && promptText(raw)) {
231
240
  sizePrompt(sizes, raw.length)
232
241
  return
@@ -249,6 +258,7 @@ function applyRecord(state, record) {
249
258
  }
250
259
  const payload = record.payload
251
260
  if (!payload || typeof payload !== 'object') return
261
+ applySizes(state, record, payload, at || state.lastAt)
252
262
  if (record.type === 'session_meta') applyMeta(state, payload)
253
263
  else if (record.type === 'turn_context') applySettings(state, payload.model, payload.effort, payload.cwd)
254
264
  else if (record.type === 'response_item') applyItem(state, payload, at || state.lastAt)
@@ -363,13 +373,16 @@ function addLines(state, patch) {
363
373
  function applyEvent(state, event, at) {
364
374
  switch (event.type) {
365
375
  case 'task_started':
366
- case 'turn_started':
367
- if (!state.open) state.turnStartedAt = at
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
368
380
  state.open = true
369
381
  state.failed = false
370
382
  state.calls.clear()
371
383
  settleCalls(state.activity)
372
384
  break
385
+ }
373
386
  case 'task_complete':
374
387
  case 'turn_complete':
375
388
  state.open = false
@@ -451,6 +464,7 @@ function applyTokens(state, info, at) {
451
464
  const sum = number(total?.total_tokens)
452
465
  if (sum === state.lastTotal) return
453
466
  state.lastTotal = sum
467
+ samplePoint(state.contextSample, last.total_tokens)
454
468
  keepNewest(state.history, { at, context: number(last.total_tokens), output: number(last.output_tokens) }, DETAIL_LIMITS.history)
455
469
  }
456
470
 
@@ -463,7 +477,7 @@ export function codex({
463
477
  const locksDir = path.join(home, 'thread-writer-locks')
464
478
  const indexFile = path.join(home, 'session_index.jsonl')
465
479
  const rollouts = jsonlFollower({ empty: emptyRollout, apply: applyRecord })
466
- const openedFor = recentReaders(() => jsonlFollower({ empty: emptyOpened, apply: applyOpened }), OPENED_KEPT)
480
+ const openedFor = recentReaders(() => jsonlFollower({ empty: emptyWords, apply: applyWords, tail: { bytes: TAIL_BYTES, enough: wordsFull } }), OPENED_KEPT)
467
481
  let index = { mtime: -1, names: new Map() }
468
482
  let known = new Map()
469
483
  let childrenOf = new Map()
@@ -571,11 +585,12 @@ export function codex({
571
585
  return state ? { file, stat, state } : null
572
586
  }
573
587
 
574
- async function sessionDetail(key) {
588
+ async function sessionDetail(key, { filesOnly = false } = {}) {
575
589
  const id = String(key).replace(/^codex:/, '').toLowerCase()
576
590
  const found = await readKnown(id)
577
591
  if (!found || found.state.subagent || found.state.internal) return null
578
592
  const { file, stat, state } = found
593
+ if (filesOnly) return { id: `codex:${id}`, sessionId: id, files: filePaths(state.files, state.cwd) }
579
594
  const said = await openedFor(file).read(file, stat).catch(() => null)
580
595
  const limit = state.context?.limit || null
581
596
  const subagents = []
@@ -612,7 +627,7 @@ export function codex({
612
627
  prompts: (state.typed ? state.promptList : state.fallbackPromptList).map((p) => ({ ...p })),
613
628
  thoughts: textList(said?.thoughts),
614
629
  replies: textList(said?.replies),
615
- contextBreakdown: contextBreakdown(said?.sizes, {
630
+ contextBreakdown: contextBreakdown(state.sizes, {
616
631
  total: state.context?.used,
617
632
  limit,
618
633
  reserved: limit ? Math.round(limit * (1 - COMPACT_SHARE / REPORTED_SHARE)) : null,
@@ -668,6 +683,7 @@ function toSession({ id, state, names, live, turnLive, fresh, locking, unread, s
668
683
  tool,
669
684
  tokens: state.tokens ? { ...state.tokens } : null,
670
685
  context: state.context && state.context.used ? { ...state.context } : null,
686
+ contextHistory: sampleList(state.contextSample, state.context?.used),
671
687
  costUsd: null,
672
688
  lines: { ...state.lines },
673
689
  plan: planSummary(state.plan),
@@ -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)
@@ -273,6 +274,7 @@ export function cursor({
273
274
  tool: open && state.tool ? { ...toolNow(state.tool), target: clip(relativePath(state.tool.target, projectPath)) } : null,
274
275
  tokens: null,
275
276
  context: null,
277
+ contextHistory: null,
276
278
  costUsd: null,
277
279
  lines: null,
278
280
  plan: planSummary(state.plan),
@@ -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)
@@ -423,6 +424,7 @@ function toSession({ db, profile, tip, root, chain, ids, open, errands, workingS
423
424
  ? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 }
424
425
  : null,
425
426
  context: used ? { used, limit: contextLimit(lengths, text(tip.model), tip.billing_base_url) } : null,
427
+ contextHistory: null,
426
428
  costUsd: costs.some((c) => c != null) ? costs.reduce((total, c) => total + number(c), 0) : null,
427
429
  lines: null,
428
430
  plan: planSummary(latestPlan(db, ids, messages)),
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/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 @@
1
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])