Times every step of every Claude Code run, with its agent, model, tokens and cost at API prices, logs one row per turn to .vault-eta/runs/, and shows an ETA…

A Claude Code mod that tells you how long a run will take and what it costs, learned from your own past runs.
It is built for people who run Claude Code inside a vault or second brain (Obsidian and similar), where the same kinds of tasks come back again and again. It works in any project folder too.
The band. One row above the prompt, always on. While Claude works:
~4m left · 2:10 in · $0.81 · website-work
When idle it shows the last run against its estimate and today's total:
last wrap 3:40 (est 3-5m, on time) · today $38
The countdown turns yellow when the run passes the usual time and red when it passes the slow time. It pauses and says waiting on you while Claude waits for your answer or a permission. In the first runs it says learning (3 of 5) instead of guessing. The band is drawn under any band another plugin draws, never in its place.
The band in its states (working, waiting on you, learning, past the usual estimate, idle):
~4m left · 2:10 in · $0.81 · website-work
waiting on you · 2:10 in · $0.81 · website-work
learning (3 of 5) · 2:10 in · $0.81 · website-work
over usual · 6:40 in · $0.81 · website-work
last wrap 3:40 (est 3-5m, on time) · today $38
A GIF of the band and pane in a live session is coming.
The /eta pane. A side pane with the header (task, project, job), a progress bar, time left with the usual and slow figures, how many past runs it matched, what is running right now, a table of the main agent and each subagent with time, tokens and cost, where the time went (agent, tools, waiting on you), how fast the model is running today, context size, and the cost so far.
The pane while a run works, at 60 columns:
website-work · website · job onboarding
███████░░░░░░░░░░░░░ 2:10 of ~6:00
~4m left usual 6m · slow 9m
34 past runs · 82% finished on time
NOW Bash bun run build 0:41
WHO time tokens cost
main opus 1:20 48.2k $0.61
sonnet x1 0:52 21.0k $0.18
agent 1:31 · tools 0:39 · waiting on you 0
speed 62 tok/s (usual 70) · eta +8%
context 96.0k · cache warm
$0.81 so far · at API prices
At a terminal prompt inside Claude Code:
/plugin install vault-eta --marketplace MichaelGold11/vault-eta
From a shell:
claude plugin install vault-eta --marketplace MichaelGold11/vault-eta
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with MichaelGold11/vault-eta, then Add plugin.
A Claude Code version that supports mods, which are plugins with a hooks module (code that draws the band and the pane and listens to events). The docs do not state a minimum version. It was built and tested on Claude Code 2.1.302. If your version does not load it, update Claude Code. Node 18 or newer is only needed for the optional scripts below.
| Command | What it does |
|---|---|
/eta | Opens the pane, or closes it if it is open. It also opens by itself on a wide terminal when a run starts, unless you closed it. |
/eta open and /eta close | Open or close the pane explicitly. |
/eta job <name> | Tags every run after it with a job name (letters, digits and hyphens), so a build that spans many sessions adds up. /eta job alone shows the job's totals. |
/eta job end | Stops tagging and shows the job's total. |
/eta report | Writes a Markdown report (by task type, model, project and job, where the time went, accuracy) to the report file. Asked while Claude works, it is written when the run ends. |
None of these print a reply, so nothing they show reaches the model. Answers appear as a short toast. Claude Code lists /eta in the typeahead and /help; in 2.1.302 it is not in the command list the model reads. Claude Code gives a plugin no switch to keep a command out of that list, so if a later version adds plugin commands to it, the one-line description of /eta is what the model would see.
A job tag is kept in the plugin's own store, not in your vault: it applies to every session and every folder on this computer until /eta job end.
The report goes to reportPath (default vault-eta-report.md at the top of the folder the logs live under). It holds task types, project and job names, the first words of commands, machine ids and costs. In a code repository that is the repository's root, and the file is not ignored by git: set reportPath to a folder you ignore, or add the file to your .gitignore. The report never overwrites a file it did not write (one without its own frontmatter), never writes through a symbolic link, and never writes outside the folder.
On day one the mod has no history, so the first estimates say learning. You can import what Claude Code already kept in its own transcripts. Clone this repository, then from its folder:
node scripts/backfill.mjs --vault ~/notes --machine laptop
--vault is your vault (or project) folder. The transcripts are found under ~/.claude/projects/ from the vault path. --machine is a name for this computer, and is required. Add --dry-run to see the totals without writing anything. Other options: --projects-dir <dir> if your transcripts are somewhere else (for example when you set CLAUDE_CONFIG_DIR), --ignore "skill-a, skill-b" and --projects-folder Projects, which work like the options below. bun scripts/backfill.mjs works the same way.
Windows paths such as C:\Users\you\notes are handled by the same rules (every character that is not a letter or digit becomes a dash in the transcripts folder name). That path handling was written from how Claude Code names those folders and covered by tests, but I could only run it on macOS.
Run it again any time: it replaces its own backfill-YYYY-MM.jsonl and .partN files (nothing else in the folder) and skips runs the live mod already logged. It stores the same fields as the live log (below). It writes and deletes nothing when .vault-eta, runs, the machine folder, the ignore file or one of its files is a symbolic link, or when a path would land outside the vault.
Plain statistics on local logs. No model is called.
learning.A task type is the first skill that names the run, else the slash command, else freeform. A skill that only hands over to another skill (it loads, then another skill loads before any other tool call) is treated as a router and does not name the run. Helper skills such as browser or charting skills do not name a run either.
Honest accuracy: it is rough when you hit enter and gets tighter as the run goes. As an example, replaying about 1,000 runs of one person's vault over five weeks, the typical miss of the usual figure was a factor of 2.4 at submit, 1.7 after five requests, and the slow figure held (the run finished under it) about three runs in four, against a target of four in five. Repeated tasks do better than one-off work. Your numbers will differ, and /eta report prints yours. To replay your own history from a shell: node scripts/label-eval.mjs --vault ~/notes.
Logs go to .vault-eta/runs/<machine>/ in your vault, or in the project folder: it looks upward from where the session started for a folder that already has .vault-eta, else one with .git, else it uses the start folder, and it never climbs into your home folder from below. <machine> is a random id made once per computer.
<date>-<session>.jsonl, named by the day it started.<date>-<session>.part2.jsonl and so on.backfill-YYYY-MM.jsonl files come from the optional import below..vault-eta has its own .gitignore containing *, so the logs are never committed by accident. It is written whenever it is missing, checked before every write, so deleting the whole folder is safe: the next write puts it back first. To commit your logs on purpose, empty that file rather than delete it.
If .vault-eta, runs, the machine folder or a log file is a symbolic link, or a path would land outside the folder, the mod writes no log for that session (and reads no history through it), and says so once.
Each row (one per turn of the main agent or of a subagent) holds:
It never stores prompt text, file contents, tool output, error messages or subagent prompts. Tool calls keep only these facts:
git push, bun run build, gh pr create), or a script's file name after an interpreter (node backfill.mjs). Never its arguments, flags, paths, hosts or values;(outside);Dollars are "at API prices": what the same tokens would cost on the Anthropic API at list prices, not what a subscription bills you. The price table is in lib/price.mjs. A model that is not in the table is never guessed: a cost with nothing priced shows --, and a total that leaves some unpriced work out shows + ($0.81+), in the band, the pane, the job toast and the report. Cache writes are priced at the TTL you choose in the options, since live usage does not say which one was used.
.vault-eta like any other folder.session.end waits for the last log write, within the engine's exit limit, and turn.complete waits for a pending run open. Every event passes through unchanged, and the band's refresh and the other log writes run after it. At session start it reads the last 90 days of logs (about 9 MiB and 100 ms for a heavy user) and scores them (about 20 ms); the score is made again while Claude is idle..vault-eta belongs), and, for the optional backfill script, Claude Code's own ~/.claude/projects/.Set them with claude plugin configure vault-eta or in the plugin's settings.
| Option | Default | What it does |
|---|---|---|
cacheWriteTtl | 1h | Prices cache writes at the 1 hour or 5 minute rate. Use 5m if your sessions use short caches. |
ignoreSkills | empty | Comma-separated skills that never name a task. Add one here if a router skill still names runs it should not. |
projectsFolder | empty | The folder that holds one folder per project, relative to your vault (Projects, Work/Clients). Empty detects a top-level folder called projects, any case, with an optional number prefix, so Projects, 01-projects and 1 Projects all work. A run's project is the folder it edited or read most. A skill called <name>-work also sets the project to <name>. |
reportPath | vault-eta-report.md | Where /eta report writes, relative to the folder the logs live under. It must be a relative .md path that stays inside that folder (no .., no drive, not in .git or .vault-eta). |
/plugin uninstall vault-eta
or claude plugin uninstall vault-eta. What stays behind, for you to delete if you want it gone:
.vault-eta folder in your vault (or project folder), with the logs;/eta report wrote (vault-eta-report.md by default);Run the tests with claude plugin test . from a clone. Type-checking needs the engine's type declarations: Claude Code writes them to .claude-plugin/types/ (ignored by git) when it loads the mod from a folder it hot-reloads, for example claude --plugin-dir <your clone>. After that, npx -p typescript tsc -p . --noEmit checks everything; leave out --noEmit and tsc writes .js files next to the sources.
MIT. See LICENSE.
hooks/register.tsx 620 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, TurnStepResult } from 'claude-code'
3
4import { resolveBase } from '../lib/base.mjs'
5import { isPersonPrompt, parseList } from '../lib/classify.mjs'
6import type { EtaView } from '../types'
7import { registerBand } from './band'
8import { JOB_KEY, jobCommand, jobName, reportCommand } from './commands'
9import { refusalOf } from './guard'
10import { LOG_ROOT, SessionLog, WriteRefused, ensureGitignore, flushLog, joinPath, loadRecent, localDate, reloadLog } from './log'
11import { autoOpen, paneCommand, registerCommand, registerPane } from './pane'
12import { Profile, estimateRun, storedEta } from './predict'
13import { Recorder } from './record'
14import type { LiveStep } from './record'
15import { idleView, workingView } from './view'
16
17// vault-eta: times every model request and tool call of every run, tags each
18// with its agent and model, prices it at API prices, and writes one row per
19// turn to `.vault-eta/runs/` in the session's folder. From the last 90 days of
20// those rows (backfill included) it estimates each run as it goes and draws
21// the estimate in a band above the prompt and a side pane, /eta toggling the
22// pane. The mod never calls a model and never adds text to the prompt.
23//
24// Every hook passes its event on unchanged and returns what is beneath it.
25// The view refresh and the log writes never hold a hook: they run after it,
26// a refresh at a time and the writes one after another on a chain.
27
28type Engine = EngineInterface
29
30export const TICK_MS = 1000
31/** A tick this late means the machine slept (or the host froze): that time is not agent time. */
32export const SLEEP_GAP_MS = 5000
33const MACHINE_KEY = 'machineId'
34/** The machine id's form (`m` and eight hex digits): it names a folder, so anything else is replaced. */
35export const MACHINE_ID = /^m[0-9a-f]{8}$/
36
37/** What the band and the pane draw; this file writes it, they only read it. */
38const viewAtom = atom({ plugin: 'vault-eta', key: 'view' } as const, { kind: 'none' } as EtaView)
39
40/** Chunks that carry the model's own output; `engine` chunks (the envelope, block starts) do not. */
41const CONTENT_CHUNKS = new Set(['text', 'thinking', 'tool', 'input'])
42
43// Module state: a reload runs register again, so these start over with it
44// (the session's log is read back from disk at the next session.start).
45let rec = new Recorder({ cacheWriteTtl: '1h', ignoreSkills: [], projectsFolder: '', tickMs: TICK_MS, sleepGapMs: SLEEP_GAP_MS })
46let log: SessionLog | undefined
47let cwd = ''
48let machineId = ''
49let version: string | null = null
50let reportPath = 'vault-eta-report.md'
51let timer: Timer | undefined
52let isTicking = false
53let profile = new Profile()
54/** The terminal as the band last measured it: the pane opens by itself only where it lands as a sidebar. */
55let viewport: { columns: number; isFullscreen: boolean | undefined } | null = null
56let lastView = ''
57let refreshing: Promise<void> | null = null
58let refreshAgain = false
59let refreshSince = 0
60const REFRESH_STALE_MS = 5000
61/** Per run, the main request count its last step-time checkpoint was made at. */
62const checkpointed = new Map<string, number>()
63/** Bumped at every register: work started under an older one (a reload) drops its result. */
64let generation = 0
65/** The log writes, one after another. */
66let flushChain: Promise<void> = Promise.resolve()
67/** `/eta report` asked while the main loop worked: written when it next goes idle, so it never stalls a turn. */
68let isReportWaiting = false
69/** Why this session writes no log (a symbolic link in `.vault-eta`, or a path that leads outside the folder); null while it may. */
70let logBlocked: string | null = null
71/** The latest run's opening (its context, its job, a new log after /clear): turn.start does not wait for it, its turn's end does. */
72let pendingOpen: Promise<void> = Promise.resolve()
73
74function newMachineId(): string {
75 const uuid = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.()
76 const hex = uuid ? uuid.replace(/-/g, '') : Math.random().toString(16).slice(2) + Date.now().toString(16)
77 return `m${hex.slice(0, 8)}`
78}
79
80function stopTicker() {
81 timer?.cancel()
82 timer = undefined
83 rec.stopTicking()
84}
85
86/** The view the band and the pane draw: the running run's estimate, else the idle summary. */
87async function refreshOnce($: Engine, gen: number) {
88 const now = await $.clock.now()
89 if (gen !== generation) return
90 const live = rec.liveView(now)
91 let view: EtaView
92 if (live && live.isMainOpen) {
93 const est = estimateRun(profile, live)
94 if (live.mainSteps <= 2) rec.setEstimate(live.runId, storedEta(est, live.mainSteps))
95 if (est.leftMs !== null && est.leftSlowMs !== null && checkpointed.get(live.runId) !== live.mainSteps) {
96 checkpointed.set(live.runId, live.mainSteps)
97 rec.checkpoint(live.runId, { atMs: live.agentMs, usualMs: live.agentMs + est.leftMs, slowMs: live.agentMs + est.leftSlowMs })
98 }
99 const usage = await $.session.usage().catch(() => undefined)
100 view = workingView(live, est, { contextTokens: usage?.context.tokens ?? null })
101 } else {
102 // Nothing runs on the main loop: the self-score catches up with the rows that came in.
103 profile.refreshScores()
104 const job = jobName(await $.store.get(JOB_KEY).catch(() => undefined))
105 view = idleView(profile, now, job)
106 }
107 if (gen !== generation) return
108 const text = JSON.stringify(view)
109 if (text === lastView) return
110 await update($, viewAtom, () => view)
111 lastView = text
112}
113
114/**
115 * Rebuilds the view; a call while one runs makes it run once more. One stuck
116 * past five seconds (a dispatch abandoned under it) is left behind, so the
117 * band never freezes on it. Hooks call it without waiting (`void`): it never
118 * rejects, and nothing a hook answers waits on it.
119 */
120function refreshView($: Engine): Promise<void> {
121 if (refreshing && Date.now() - refreshSince < REFRESH_STALE_MS) {
122 refreshAgain = true
123 return refreshing
124 }
125 refreshSince = Date.now()
126 const gen = generation
127 const mine = (async () => {
128 try {
129 do {
130 refreshAgain = false
131 await refreshOnce($, gen)
132 } while (refreshAgain && gen === generation)
133 } catch {
134 // the view stays as last drawn; the next tick or event rebuilds it
135 }
136 })()
137 refreshing = mine
138 void mine.then(() => {
139 if (refreshing === mine) refreshing = null
140 })
141 return mine
142}
143
144/** The last 90 days of rows, backfill included, as the profile estimates learn from; scored here, before the first run. */
145async function loadProfile($: Engine) {
146 const next = new Profile()
147 // a log folder that leads elsewhere is not read either
148 if (logBlocked !== null) {
149 profile = next
150 return
151 }
152 try {
153 next.load(await loadRecent($, cwd, await $.clock.now()), rec.session?.sessionId ?? '')
154 next.score()
155 } catch {
156 // an empty profile: every run shows learning until its kind has five runs
157 }
158 profile = next
159}
160
161async function onTick($: Engine, gen: number) {
162 if (isTicking || gen !== generation) return
163 isTicking = true
164 try {
165 rec.tick(await $.clock.now())
166 await refreshView($)
167 if (rec.isIdle()) stopTicker()
168 } catch {
169 stopTicker()
170 } finally {
171 isTicking = false
172 }
173}
174
175/** One tick a second while any loop works; a late tick is machine sleep. */
176function startTicker($: Engine, now: number) {
177 if (timer) return
178 rec.tick(now)
179 const gen = generation
180 timer = $.clock.every(TICK_MS, () => void onTick($, gen))
181}
182
183/** The log for this session id; after a /clear the id changes and a new file starts. */
184async function logFor($: Engine, sessionId: string, now: number): Promise<SessionLog> {
185 if (log && log.sessionId === sessionId) return log
186 const next = new SessionLog(joinPath(cwd, LOG_ROOT, machineId), localDate(now), sessionId)
187 if (logBlocked === null) await reloadLog($, next)
188 next.markBaseline((await $.session.usage().catch(() => undefined))?.cost?.usd ?? null)
189 log = next
190 rec.setSessionId(sessionId)
191 return next
192}
193
194/** Stops this session's logging, with one toast saying why. */
195function blockLog($: Engine, reason: string) {
196 if (logBlocked !== null) return
197 logBlocked = reason
198 $.ui.toast(`vault-eta is not logging this session: ${reason}`, { timeoutMs: 8000 })
199}
200
201/**
202 * Writes a log's new rows, and the ignore file first if it has gone missing.
203 * Nothing new, nothing written. Every path goes through the shared write
204 * rule: a symbolic link at `.vault-eta`, `runs`, the machine folder or the
205 * file, or a path that resolves outside the folder, stops the session's log.
206 */
207async function flush($: Engine, target: SessionLog | undefined) {
208 if (logBlocked !== null || !target || !target.isDirty()) return
209 const refused = await ensureGitignore($, cwd)
210 if (refused !== null) return blockLog($, refused)
211 const usage = await $.session.usage().catch(() => undefined)
212 try {
213 await flushLog(
214 $,
215 target,
216 runId => rec.taskFor(runId),
217 { machineId, now: await $.clock.now(), engineUsd: usage?.cost?.usd ?? null, version },
218 path => refusalOf($, cwd, path),
219 )
220 } catch (error) {
221 if (error instanceof WriteRefused) blockLog($, error.message)
222 else throw error
223 }
224}
225
226/** Queues a flush of the log as it is now on the write chain; the promise never rejects. */
227function queueFlush($: Engine, target: SessionLog | undefined = log): Promise<void> {
228 const gen = generation
229 flushChain = flushChain.then(() => (gen === generation ? flush($, target) : undefined)).catch(() => undefined)
230 return flushChain
231}
232
233/**
234 * The folder `.vault-eta/` sits in. The session's project root (a shell `cd`
235 * does not move it) leads; the event's cwd is the fallback. Resolved again on
236 * every session.start, so a reload from a subfolder gives the same answer.
237 */
238async function baseFolder($: Engine, eventCwd: string): Promise<string> {
239 const root = await $.session.root().catch(() => '')
240 const start = root !== '' ? root : eventCwd
241 const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
242 // Windows has USERPROFILE where macOS and Linux have HOME
243 const home = env?.HOME ?? env?.USERPROFILE
244 return resolveBase(start, {
245 home,
246 namesIn: async dir => {
247 try {
248 return (await $.fs.list(dir)).map(e => e.name)
249 } catch {
250 return null
251 }
252 },
253 }).catch(() => start)
254}
255
256async function startSession($: Engine, sessionCwd: string) {
257 cwd = await baseFolder($, sessionCwd)
258 const now = await $.clock.now()
259 const stored = await $.store.get(MACHINE_KEY)
260 machineId = typeof stored === 'string' && MACHINE_ID.test(stored) ? stored : newMachineId()
261 if (stored !== machineId) await $.store.set(MACHINE_KEY, machineId)
262 version = await $.session
263 .version()
264 .then(v => v.version)
265 .catch(() => null)
266 const sessionId = await $.session.id()
267 const refused = await ensureGitignore($, cwd)
268 if (refused !== null) blockLog($, refused)
269 rec.startSession({ machineId, sessionId, cwd, version, now })
270 await logFor($, sessionId, now)
271}
272
273/** A run opened: the context it starts with, the job in force, and a new file after a /clear. Runs after turn.start answered. */
274async function openRun($: Engine, runId: string, now: number) {
275 if (!rec.session) return
276 const sessionId = await $.session.id()
277 if (sessionId !== rec.session.sessionId) {
278 // the old log is queued with its rows as they are; the new one takes over at once
279 void queueFlush($, log)
280 await logFor($, sessionId, now)
281 }
282 const usage = await $.session.usage().catch(() => undefined)
283 const job = jobName(await $.store.get(JOB_KEY).catch(() => undefined))
284 rec.setRunContext(runId, { contextStart: usage?.context.tokens ?? null, job })
285}
286
287/** Writes the report now, or once the main loop is idle when a turn is running (so it never stalls one). */
288async function report($: Engine) {
289 if (rec.loops.has('main')) {
290 isReportWaiting = true
291 $.ui.toast('/eta report: written when this run ends', { timeoutMs: 6000 })
292 return
293 }
294 isReportWaiting = false
295 // a log folder that leads elsewhere is not read for the report either
296 if (logBlocked !== null) {
297 $.ui.toast(`/eta report not written: ${logBlocked}`, { timeoutMs: 6000 })
298 return
299 }
300 await reportCommand($, cwd, profile, reportPath)
301}
302
303/** Reads the clock, or the fallback when the dispatch was abandoned. */
304async function nowOr($: Engine, fallback: number): Promise<number> {
305 try {
306 return await $.clock.now()
307 } catch {
308 return fallback
309 }
310}
311
312export const register: Register = (on, options) => {
313 generation += 1
314 flushChain = Promise.resolve()
315 isReportWaiting = false
316 pendingOpen = Promise.resolve()
317 logBlocked = null
318 rec = new Recorder({
319 cacheWriteTtl: options.cacheWriteTtl === '5m' ? '5m' : '1h',
320 ignoreSkills: parseList(typeof options.ignoreSkills === 'string' ? options.ignoreSkills : ''),
321 projectsFolder: typeof options.projectsFolder === 'string' ? options.projectsFolder.trim() : '',
322 tickMs: TICK_MS,
323 sleepGapMs: SLEEP_GAP_MS,
324 })
325 log = undefined
326 timer = undefined
327 isTicking = false
328 profile = new Profile()
329 viewport = null
330 lastView = ''
331 refreshing = null
332 refreshAgain = false
333 refreshSince = 0
334 checkpointed.clear()
335
336 registerBand(on, {
337 onViewport: (columns, isFullscreen) => {
338 viewport = { columns, isFullscreen }
339 },
340 })
341 reportPath = typeof options.reportPath === 'string' && options.reportPath.trim() !== '' ? options.reportPath.trim() : 'vault-eta-report.md'
342 registerPane(on)
343
344 // /eta prints nothing: the pane, the report and the job tag each answer in a toast.
345 on('command.run', { command: 'eta' }, async ($, e) => {
346 try {
347 const words = (e.args ?? '').trim().split(/\s+/).filter(Boolean)
348 const verb = words[0]?.toLowerCase() ?? ''
349 if (verb === 'report') await report($)
350 else if (verb === 'job') {
351 if (await jobCommand($, profile, words.slice(1).join(' '))) void refreshView($)
352 } else await paneCommand($, verb)
353 } catch {
354 // the command answers nothing either way
355 }
356 return {}
357 })
358
359 on('session.start', async ($, e, next) => {
360 const result = await next(e)
361 await registerCommand($).catch(() => undefined)
362 await startSession($, e.cwd).catch(() => undefined)
363 await loadProfile($)
364 void refreshView($)
365 return result
366 })
367
368 // The engine's end step first; then, inside the same short bound, any rows not on disk yet.
369 on('session.end', async ($, e, next) => {
370 const result = await next(e)
371 try {
372 if (e.reason === 'clear') stopTicker()
373 if (log?.isDirty()) void queueFlush($)
374 await flushChain
375 } catch {
376 // the rows not written are lost; the session is not
377 }
378 return result
379 })
380
381 on('prompt.submit', async ($, e, next) => {
382 try {
383 rec.promptSubmitted({ origin: e.origin.kind, isMidTurn: e.turnId !== undefined, text: e.text, command: e.command?.name ?? null })
384 } catch {
385 // recorded nothing
386 }
387 return next(e)
388 })
389
390 on('classic.UserPromptSubmit', async ($, e, next) => {
391 try {
392 rec.classicPrompt({
393 ...(e.prompt_id ? { promptId: e.prompt_id } : {}),
394 ...(e.permission_mode ? { permissionMode: e.permission_mode } : {}),
395 })
396 } catch {
397 // recorded nothing
398 }
399 return next(e)
400 })
401
402 on('turn.start', async ($, e, next) => {
403 try {
404 const now = await $.clock.now()
405 if (e.agentId === undefined) {
406 const opened = rec.mainTurnStarted({ turnId: e.turnId, text: e.text, now })
407 if (opened) {
408 pendingOpen = openRun($, opened.id, now).catch(() => undefined)
409 if (isPersonPrompt({ kind: opened.origin })) void autoOpen($, viewport).catch(() => undefined)
410 }
411 } else rec.agentTurnStarted({ agentId: e.agentId, turnId: e.turnId, now })
412 startTicker($, now)
413 void refreshView($)
414 } catch {
415 // recorded nothing
416 }
417 return next(e)
418 })
419
420 // A streaming hook: each chunk passes through as it arrives, so the first
421 // token and the stop chunk are timed where they happen.
422 on('turn.step', async function* ($, e, next) {
423 const startedAt = await nowOr($, Date.now())
424 let step: LiveStep | undefined
425 try {
426 step = rec.beginStep({
427 ...(e.agentId ? { agentId: e.agentId } : {}),
428 turnId: e.turnId,
429 index: e.index,
430 model: e.model,
431 ...(e.effort !== undefined ? { effort: e.effort } : {}),
432 now: startedAt,
433 })
434 } catch {
435 step = undefined
436 }
437 let result: TurnStepResult | undefined
438 let stopUsage: TurnStepResult['usage'] = null
439 let stopReason: string | null = null
440 try {
441 const stream = next(e)
442 for await (const chunk of stream) {
443 if (step) {
444 try {
445 if (step.firstAt === null && CONTENT_CHUNKS.has(chunk.kind)) step.firstAt = await $.clock.now()
446 if (chunk.kind === 'thinking') step.thinking += chunk.text.length
447 if (chunk.kind === 'stop') {
448 step.stopAt = await $.clock.now()
449 stopUsage = chunk.usage
450 stopReason = chunk.stopReason
451 }
452 } catch {
453 // this chunk's timing is lost; the chunk is not
454 }
455 }
456 yield chunk
457 }
458 result = await stream.result
459 return result
460 } finally {
461 if (step) {
462 try {
463 const end = await nowOr($, step.stopAt ?? step.firstAt ?? step.startedAt)
464 rec.endStep(step, { now: end, usage: result?.usage ?? stopUsage, stop: result?.stopReason ?? stopReason })
465 } catch {
466 // the step is left unfinished in the record
467 }
468 // Re-estimate after every step, without holding the response.
469 void refreshView($)
470 }
471 }
472 })
473
474 on('tool.check', async ($, e, next) => {
475 const verdict = await next(e)
476 try {
477 if (e.tool_use_id) rec.checked(e.tool_use_id, verdict.decision)
478 if (verdict.decision === 'ask') void refreshView($)
479 } catch {
480 // recorded nothing
481 }
482 return verdict
483 })
484
485 // A permission dialog put a call to the person (in auto mode, the classifier could not settle it).
486 on('classic.PermissionRequest', async ($, e, next) => {
487 try {
488 rec.dialogShown({ tool: e.tool_name, ...(e.agent_id ? { agentId: e.agent_id } : {}) })
489 void refreshView($)
490 } catch {
491 // recorded nothing
492 }
493 return next(e)
494 })
495
496 // A running call's progress row: the call runs, so its dialog has been answered.
497 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
498 try {
499 rec.toolRunning(e.props.tool_use_id)
500 } catch {
501 // recorded nothing
502 }
503 return next(e)
504 })
505
506 on('tool.call', async ($, e, next) => {
507 const tool = String(e.tool)
508 const startedAt = await nowOr($, Date.now())
509 const id = e.tool_use_id ?? `${tool}:${startedAt}`
510 try {
511 rec.toolStarted({ id, ...(e.agentId ? { agentId: e.agentId } : {}), tool, input: e, now: startedAt })
512 void refreshView($)
513 } catch {
514 // recorded nothing
515 }
516 let failed = true
517 let backgroundTaskId: string | undefined
518 try {
519 const result = await next(e)
520 failed = result.deny !== undefined || result.isError === true
521 const record = result.result as { backgroundTaskId?: unknown } | undefined
522 if (typeof record === 'object' && record !== null && typeof record.backgroundTaskId === 'string') backgroundTaskId = record.backgroundTaskId
523 return result
524 } finally {
525 try {
526 const end = await nowOr($, startedAt)
527 rec.toolEnded({ id, now: end, failed, ...(backgroundTaskId ? { backgroundTaskId } : {}) })
528 } catch {
529 // the call stays open in the record until its turn ends
530 }
531 void refreshView($)
532 }
533 })
534
535 on('classic.PostToolUse', async ($, e, next) => {
536 try {
537 rec.toolRan(e.tool_use_id, e.duration_ms)
538 rec.noteMode(e.permission_mode)
539 } catch {
540 // recorded nothing
541 }
542 return next(e)
543 })
544
545 on('classic.PostToolUseFailure', async ($, e, next) => {
546 try {
547 rec.toolRan(e.tool_use_id, e.duration_ms)
548 } catch {
549 // recorded nothing
550 }
551 return next(e)
552 })
553
554 on('agent.spawn', async ($, e, next) => {
555 const result = await next(e)
556 try {
557 if (result.agentId) {
558 rec.spawned({
559 agentId: result.agentId,
560 type: e.subagentType,
561 model: result.model,
562 ...(e.parentAgentId ? { parentAgentId: e.parentAgentId } : {}),
563 background: e.background,
564 ...(e.permissionMode ? { permissionMode: e.permissionMode } : {}),
565 })
566 }
567 } catch {
568 // recorded nothing
569 }
570 return result
571 })
572
573 on('session.compact', async ($, e, next) => {
574 const result = await next(e)
575 try {
576 if (result.skip === undefined) {
577 rec.compacted({
578 ...(e.agentId ? { agentId: e.agentId } : {}),
579 trigger: e.trigger,
580 ...(result.tokensBefore !== undefined ? { before: result.tokensBefore } : {}),
581 ...(result.tokensAfter !== undefined ? { after: result.tokensAfter } : {}),
582 now: await $.clock.now(),
583 })
584 }
585 } catch {
586 // recorded nothing
587 }
588 return result
589 })
590
591 on('turn.complete', async ($, e, next) => {
592 const result = await next(e)
593 try {
594 // long done by now in practice (a model request came between); a row must not land in the log a /clear left behind
595 await pendingOpen
596 const row = rec.turnCompleted({
597 ...(e.agentId ? { agentId: e.agentId } : {}),
598 turnId: e.turnId,
599 durationMs: e.durationMs,
600 reason: e.reason,
601 ...(e.usage ? { usage: e.usage } : {}),
602 now: await $.clock.now(),
603 })
604 if (log) {
605 log.add(row, runId => rec.taskFor(runId))
606 // A run ends with its main turn; a late agent's row is saved when the main loop is idle.
607 if (e.agentId === undefined || !rec.loops.has('main')) void queueFlush($)
608 }
609 profile.addRow({ ...row, task: rec.taskFor(row.runId) ?? row.task })
610 for (const id of checkpointed.keys()) if (!rec.runs.has(id)) checkpointed.delete(id)
611 void refreshView($)
612 if (rec.isIdle()) stopTicker()
613 if (isReportWaiting && !rec.loops.has('main')) void report($).catch(() => undefined)
614 } catch {
615 // the row is lost, the turn is not
616 }
617 return result
618 })
619}
620lib/base.mjs 102 lines1// Where the mod's `.vault-eta/` folder lives: one base folder per session,
2// the same answer on every reload whatever folder the shell has wandered into.
3// Paths are compared with forward slashes, so a Windows path (`C:\Users\me\notes`)
4// walks up the same way as a POSIX one; the answer comes back with forward
5// slashes, which the file system takes on Windows too.
6
7const MARK = '.vault-eta'
8
9/**
10 * A path with forward slashes, no doubled or trailing one; a drive root keeps
11 * its slash (`C:/`), a network path its leading two (`//server/share`).
12 * @param {string} path
13 * @returns {string}
14 */
15export function slashPath(path) {
16 let s = String(path).replace(/\\/g, '/')
17 const isUnc = s.startsWith('//')
18 s = s.replace(/\/{2,}/g, '/')
19 if (isUnc) s = `/${s}`
20 if (/^[A-Za-z]:$/.test(s)) return `${s}/`
21 if (s.length > 1 && s.endsWith('/') && !/^[A-Za-z]:\/$/.test(s)) s = s.slice(0, -1)
22 return s
23}
24
25/**
26 * True at the top of a file system: `/`, a drive root (`C:/`), or a network share (`//server/share`).
27 * @param {string} path a slashPath
28 * @returns {boolean}
29 */
30function isRoot(path) {
31 return path === '/' || /^[A-Za-z]:\/$/.test(path) || /^\/\/[^/]+\/[^/]+$/.test(path)
32}
33
34/**
35 * The same folder, ignoring case for a Windows path (its file system does).
36 * @param {string} a
37 * @param {string} b
38 * @returns {boolean}
39 */
40function isSame(a, b) {
41 return /^[A-Za-z]:/.test(a) || a.startsWith('//') ? a.toLowerCase() === b.toLowerCase() : a === b
42}
43
44/**
45 * The path with any `.vault-eta` segment (and what follows it) cut off.
46 * @param {string} path
47 * @returns {string}
48 */
49export function cutMark(path) {
50 const s = slashPath(path)
51 const parts = s.split('/')
52 const at = parts.indexOf(MARK)
53 if (at < 0) return s
54 const kept = parts.slice(0, at).join('/')
55 return kept === '' ? '/' : slashPath(kept)
56}
57
58/**
59 * The parent folder, or null at the top of a file system.
60 * @param {string} path
61 * @returns {string | null}
62 */
63export function parentOf(path) {
64 const s = slashPath(path)
65 if (isRoot(s)) return null
66 const i = s.lastIndexOf('/')
67 if (i < 0) return null
68 return i === 0 ? '/' : slashPath(s.slice(0, i))
69}
70
71/**
72 * The base folder for a start path. Cuts a `.vault-eta` segment off, then walks
73 * up to the nearest folder that already has `.vault-eta/`, else the nearest
74 * with `.git`, else the start itself. The walk stops at the top of the file
75 * system and at home (home itself is only the answer when it is the start).
76 * `namesIn(dir)` lists a folder's entry names, or null when it cannot be read.
77 *
78 * @param {string} start
79 * @param {{ namesIn: (dir: string) => Promise<string[] | null>, home?: string }} io
80 * @returns {Promise<string>}
81 */
82export async function resolveBase(start, io) {
83 const origin = cutMark(start)
84 const home = io.home ? slashPath(io.home) : ''
85 /** @type {string[]} */
86 const chain = []
87 /** @type {string | null} */
88 let dir = origin
89 for (; dir !== null; dir = parentOf(dir)) {
90 if (dir !== origin && ((home !== '' && isSame(dir, home)) || isRoot(dir))) break
91 chain.push(dir)
92 }
93 /** @type {Array<string[] | null>} */
94 const listings = []
95 for (const dir of chain) listings.push(await io.namesIn(dir))
96 const eta = chain.findIndex((_, i) => listings[i]?.includes(MARK))
97 if (eta >= 0) return chain[eta] ?? origin
98 const git = chain.findIndex((_, i) => listings[i]?.includes('.git'))
99 if (git >= 0) return chain[git] ?? origin
100 return origin
101}
102lib/classify.mjs 444 lines1// @ts-check
2// What kind of work a run is, and the few safe facts kept about each tool
3// call. Nothing here keeps text: a Bash command keeps its first words, a file
4// tool its folder inside the vault (`(outside)` for anything else), an Edit
5// its line counts.
6
7/**
8 * Forward slashes, no doubled or trailing separator, a drive letter in lower
9 * case: `C:\Users\m\vault\` and `c:/Users/m/vault` read the same.
10 * @param {unknown} p
11 * @returns {string}
12 */
13export function normalizePath(p) {
14 let s = String(p ?? '').trim().replace(/\\/g, '/')
15 const unc = s.startsWith('//')
16 s = s.replace(/\/{2,}/g, '/')
17 if (unc) s = `/${s}`
18 if (/^[A-Za-z]:/.test(s)) s = s[0]?.toLowerCase() + s.slice(1)
19 if (s.length > 1 && s.endsWith('/') && !/^[a-z]:\/$/.test(s)) s = s.slice(0, -1)
20 return s
21}
22
23/**
24 * The path under `cwd`, or null when it lies outside. Compared without case,
25 * as macOS and Windows file systems do by default.
26 * @param {unknown} path
27 * @param {unknown} cwd
28 * @returns {string | null}
29 */
30export function relativePath(path, cwd) {
31 const p = normalizePath(path)
32 const root = normalizePath(cwd)
33 if (root === '' || p === '') return null
34 if (p.toLowerCase() === root.toLowerCase()) return '.'
35 const prefix = root.endsWith('/') ? root : `${root}/`
36 if (p.toLowerCase().startsWith(prefix.toLowerCase())) return p.slice(prefix.length)
37 if (!/^([a-z]:)?\//.test(p)) return p.replace(/^\.\//, '')
38 return null
39}
40
41const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'NotebookRead'])
42const DIR_TOOLS = new Set(['Grep', 'Glob', 'LS'])
43export const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
44
45/**
46 * The path a file tool works on: the file for Read, Write and Edit, the
47 * folder searched for Grep and Glob.
48 * @param {string} tool
49 * @param {any} input
50 * @returns {{ path: string, isDir: boolean } | null}
51 */
52export function toolPath(tool, input) {
53 if (!input || typeof input !== 'object') return null
54 if (FILE_TOOLS.has(tool)) {
55 const path = input.file_path ?? input.notebook_path
56 return typeof path === 'string' && path !== '' ? { path, isDir: false } : null
57 }
58 if (DIR_TOOLS.has(tool)) {
59 const path = input.path
60 return typeof path === 'string' && path !== '' ? { path, isDir: true } : { path: '.', isDir: true }
61 }
62 return null
63}
64
65/** What a path outside the vault (or the project folder) is kept as: no part of it. */
66export const OUTSIDE = '(outside)'
67
68/** A last segment that names a file (`plan.md`, `.env.local`), not a folder: a dot with something before it, then an extension. */
69const FILE_NAME = /.\.[A-Za-z0-9]{1,10}$/
70
71/**
72 * The folder of a path inside `cwd`, relative to it and cut to `depth`
73 * segments: `01-projects/website/plan` for a note in that folder. A file's
74 * name is never kept: for a search tool's path (`isDir`) that names a file,
75 * its folder. A path outside `cwd` (absolute elsewhere, `~`, or climbing out
76 * with `..`) is kept as `(outside)`, so no user name, client folder or file
77 * name from elsewhere on the machine reaches the log.
78 * @param {string} path
79 * @param {boolean} isDir
80 * @param {unknown} cwd
81 * @param {number} [depth]
82 * @returns {string}
83 */
84export function folderOf(path, isDir, cwd, depth = 4) {
85 const rel = relativePath(path, cwd)
86 if (rel === null) return OUTSIDE
87 const parts = rel.split('/').filter(s => s !== '' && s !== '.')
88 if (parts.includes('..') || (parts[0] ?? '').startsWith('~')) return OUTSIDE
89 const last = parts[parts.length - 1]
90 if (last !== undefined && (!isDir || FILE_NAME.test(last))) parts.pop()
91 return parts.length === 0 ? '.' : parts.slice(0, depth).join('/')
92}
93
94/**
95 * @param {unknown} s
96 * @returns {number}
97 */
98export function lineCount(s) {
99 const t = String(s ?? '')
100 if (t === '') return 0
101 const breaks = (t.match(/\n/g) ?? []).length
102 return t.endsWith('\n') ? breaks : breaks + 1
103}
104
105/**
106 * Lines written and lines replaced by an edit, from its arguments' shape
107 * alone: `[added, removed]`, or undefined for a tool that edits nothing.
108 * @param {string} tool
109 * @param {any} input
110 * @returns {[number, number] | undefined}
111 */
112export function linesChanged(tool, input) {
113 if (!input || typeof input !== 'object') return undefined
114 if (tool === 'Edit') return [lineCount(input.new_string), lineCount(input.old_string)]
115 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
116 let added = 0
117 let removed = 0
118 for (const edit of input.edits) {
119 added += lineCount(edit?.new_string)
120 removed += lineCount(edit?.old_string)
121 }
122 return [added, removed]
123 }
124 if (tool === 'Write') return [lineCount(input.content), 0]
125 if (tool === 'NotebookEdit') return input.edit_mode === 'delete' ? [0, 0] : [lineCount(input.new_source), 0]
126 return undefined
127}
128
129const WRAPPERS = new Set(['sudo', 'time', 'env', 'nohup', 'exec', 'command'])
130/** A wrapper's flags that take a value (`sudo -u postgres`, `env -u NAME`): the value is skipped, never taken for the command. */
131const WRAPPER_VALUE_FLAGS = {
132 sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--host', '--prompt', '--chdir', '--role', '--type', '--other-user', '--close-from', '--command-timeout']),
133 env: new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']),
134 time: new Set(['-f', '-o', '--format', '--output']),
135 exec: new Set(['-a']),
136}
137/** The command's own name: letters, digits and `._+-` (no `:` or `@`, which a host, a URL or a credential has). */
138const PROGRAM = /^[a-z0-9][a-z0-9._+-]{0,39}$/i
139const SCRIPT = /\.(mjs|cjs|js|ts|tsx|sh|ps1|py|rb)$/i
140const SCRIPT_NAME = /^[a-z][a-z0-9:_-]{0,29}$/
141const INTERPRETERS = new Set(['node', 'bun', 'deno', 'tsx', 'python', 'python3', 'bash', 'sh', 'zsh', 'pwsh', 'powershell', 'ruby'])
142const PACKAGE_RUNNERS = new Set(['bun', 'npm', 'pnpm', 'yarn'])
143/** The only words kept after a tool's name; anything else could be a branch, host or file. */
144const SUBCOMMANDS = {
145 git: 'add bisect blame branch checkout cherry-pick clean clone commit diff fetch grep init log ls-files merge mv pull push rebase remote reset restore rev-parse revert rm show stash status switch tag worktree',
146 bun: 'add build create dev i install link outdated pm publish remove run test update upgrade x',
147 npm: 'add ci create exec i install link outdated publish remove run start test uninstall update',
148 pnpm: 'add create dev dlx exec i install link outdated publish remove run start test update',
149 yarn: 'add create dev install link outdated publish remove run start test upgrade',
150 gh: 'api auth issue pr release repo run search workflow',
151 claude: 'mcp plugin',
152 docker: 'build compose exec ps pull push run',
153 vercel: 'deploy env link login logs whoami',
154 supabase: 'db functions gen link migration start status stop',
155}
156/** A third word is kept only from this list (`gh pr create`, `claude plugin test`, `supabase db push`). */
157const VERBS = new Set('add apply build checkout close comment create deploy diff disable down edit enable list logs merge new pull push remove reset start status stop test up update validate view watch'.split(' '))
158/** @type {Record<string, Set<string>>} */
159const KNOWN = Object.fromEntries(Object.entries(SUBCOMMANDS).map(([tool, list]) => [tool, new Set(list.split(' '))]))
160
161/**
162 * The first words of a shell command, never its arguments: `bun run build`,
163 * `git push`, `node find.mjs`. The tool's name is kept; a second or
164 * third word only from a known list (or a script's file name after an
165 * interpreter, or a package script after `run`). A leading `cd ... &&`,
166 * variable assignments, flags, paths and quotes are skipped or end it.
167 * @param {unknown} command
168 * @returns {string}
169 */
170export function commandHead(command) {
171 const first = String(command ?? '').split('\n')[0] ?? ''
172 const segments = first.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean)
173 const segment = segments.find(s => !/^(cd|pushd|set-location|sl)\b/i.test(s)) ?? segments[0] ?? ''
174 const tokens = segment.split(/\s+/).filter(Boolean)
175 /** @type {string[]} */
176 const out = []
177 let skipNext = false
178 let isDone = false
179 /** The wrapper in front of the command, while its own flags are read. */
180 let wrapper = ''
181 for (const raw of tokens) {
182 if (isDone || out.length >= 3) break
183 if (skipNext) {
184 skipNext = false
185 continue
186 }
187 if (out.length === 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(raw)) continue
188 if (out.length === 0 && WRAPPERS.has(raw)) {
189 wrapper = raw
190 continue
191 }
192 if (raw.startsWith('-')) {
193 if (out.length === 0) {
194 const takesValue = /** @type {Record<string, Set<string>>} */ (WRAPPER_VALUE_FLAGS)[wrapper]?.has(raw.split('=')[0] ?? raw)
195 if (takesValue && !raw.includes('=')) skipNext = true
196 } else if (/^-[A-Za-z]$/.test(raw)) skipNext = /^-[Cc]$/.test(raw)
197 continue
198 }
199 if (/["'`$<>(){}]/.test(raw) || raw.includes('=')) break
200 const isPath = raw.includes('/') || raw.includes('\\')
201 const word = isPath ? raw.split(/[\\/]/).pop() ?? '' : raw
202 if (out.length === 0) {
203 if (!PROGRAM.test(word)) break
204 out.push(word.toLowerCase().replace(/\.exe$/, ''))
205 continue
206 }
207 const tool = out[0] ?? ''
208 if (out.length === 1 && INTERPRETERS.has(tool) && SCRIPT.test(word) && PROGRAM.test(word)) {
209 out.push(word)
210 isDone = true
211 } else if (isPath) {
212 break
213 } else if (out.length === 1 && KNOWN[tool]?.has(raw)) {
214 out.push(raw)
215 } else if (out.length === 2 && PACKAGE_RUNNERS.has(tool) && out[1] === 'run' && SCRIPT_NAME.test(raw)) {
216 out.push(raw)
217 } else if (out.length === 2 && KNOWN[tool] && VERBS.has(raw)) {
218 out.push(raw)
219 } else {
220 break
221 }
222 }
223 return out.join(' ')
224}
225
226/**
227 * `website` from `website-work`, the naming for a project's skill.
228 * @param {unknown} name
229 * @returns {string | null}
230 */
231export function projectFromSkill(name) {
232 const m = /^(.+)-work$/i.exec(String(name ?? ''))
233 return m && m[1] ? m[1].toLowerCase() : null
234}
235
236/**
237 * True for a top-level folder name that holds projects: `Projects`,
238 * `01-projects`, `1 Projects`, `10 projects`, `project`, any case. A number
239 * prefix (digits, then a dash, dot, underscore or space) is ignored.
240 * @param {unknown} name
241 * @returns {boolean}
242 */
243export function isProjectsFolder(name) {
244 return /^(\d+[-_. ]+)?projects?$/i.test(String(name ?? '').trim())
245}
246
247/**
248 * The project a path is in: the folder under the vault's projects folder, or
249 * null. `projectsFolder` (the userConfig option) names that folder, as a
250 * vault-relative path such as `Work/Projects`; empty means auto-detect: the
251 * first folder of the path, if its name passes `isProjectsFolder`. A path
252 * that is not vault-relative (absolute, outside the vault) has no project.
253 * @param {unknown} path
254 * @param {string} [projectsFolder]
255 * @returns {string | null}
256 */
257export function projectFromPath(path, projectsFolder = '') {
258 const parts = normalizePath(path).split('/').filter(s => s !== '' && s !== '.')
259 if (/^([a-z]:)?\//.test(normalizePath(path))) return null
260 const base = normalizePath(projectsFolder).split('/').filter(s => s !== '' && s !== '.')
261 let at = 0
262 if (base.length > 0) {
263 if (parts.length <= base.length || !base.every((seg, i) => seg.toLowerCase() === (parts[i] ?? '').toLowerCase())) return null
264 at = base.length
265 } else if (parts.length < 2 || !isProjectsFolder(parts[0])) {
266 return null
267 } else {
268 at = 1
269 }
270 const name = parts[at] ?? ''
271 const isFile = at === parts.length - 1 && /\.[a-z0-9]+$/i.test(name)
272 return name !== '' && !isFile ? name.toLowerCase() : null
273}
274
275/**
276 * @param {readonly string[]} paths
277 * @param {string} [projectsFolder]
278 * @returns {string | null}
279 */
280function mostCommonProject(paths, projectsFolder = '') {
281 /** @type {Map<string, number>} */
282 const counts = new Map()
283 for (const p of paths) {
284 const project = projectFromPath(p, projectsFolder)
285 if (project) counts.set(project, (counts.get(project) ?? 0) + 1)
286 }
287 let best = null
288 let most = 0
289 for (const [project, n] of counts) {
290 if (n > most) {
291 best = project
292 most = n
293 }
294 }
295 return best
296}
297
298/**
299 * A comma or space separated list from a setting: `"kickoff, triage"`.
300 * @param {unknown} value
301 * @returns {string[]}
302 */
303export function parseList(value) {
304 return String(value ?? '')
305 .split(/[\s,]+/)
306 .map(s => s.trim().toLowerCase())
307 .filter(Boolean)
308}
309
310/**
311 * Skills that help with a task rather than name it, in any setup: they load
312 * partway through whatever the work is (browser, design, charts, authoring,
313 * setup). Skipped like the user's `ignoreSkills` when naming a run. A name
314 * with a `plugin:` prefix is compared by its last part.
315 */
316export const GENERIC_HELPERS = [
317 'artifact-capabilities',
318 'artifact-design',
319 'artifact-diagramming',
320 'claude-in-chrome',
321 'built-in-browser',
322 'chrome-browser',
323 'computer-use',
324 'dataviz',
325 'plugin-authoring',
326 'find-skills',
327 'update-config',
328 'keybindings-help',
329 'fewer-permission-prompts',
330]
331
332/**
333 * @param {string} name
334 * @returns {string}
335 */
336function bareName(name) {
337 const at = name.lastIndexOf(':')
338 return (at < 0 ? name : name.slice(at + 1)).toLowerCase()
339}
340
341/**
342 * A skill followed by another naming skill with at most this many other tool
343 * calls between them is a router (it only hands over), and does not name the run.
344 */
345export const ROUTER_MAX_WORK = 0
346
347/**
348 * @typedef {{ skills?: readonly string[], helpers?: readonly string[], command?: string | null, editedPaths?: readonly string[], readPaths?: readonly string[], ignore?: readonly string[], skillWork?: readonly number[], projectsFolder?: string }} RunFacts
349 * @typedef {{ taskType: string, skill: string | null, command: string | null, project: string | null }} RunClass
350 */
351
352/**
353 * The task type is the first skill that names the run, else its slash
354 * command, else `freeform`. A skill is skipped when it is on the user's
355 * ignore list (routing skills like `kickoff`) or is a generic helper
356 * (`helpers`, GENERIC_HELPERS unless the caller passes its own list; `[]`
357 * turns it off): a helper loaded mid-task is not what the task was. The
358 * project comes from the naming skill's name (`website-work`), else from
359 * the folders the run edited, else from the folders it read.
360 * @param {RunFacts} facts
361 * @returns {RunClass}
362 */
363export function classifyRun(facts) {
364 const ignore = new Set((facts.ignore ?? []).map(s => s.toLowerCase()))
365 const helpers = new Set((facts.helpers ?? GENERIC_HELPERS).map(s => s.toLowerCase()))
366 const skills = facts.skills ?? []
367 const names = skills.map(s => s !== '' && !ignore.has(s.toLowerCase()) && !helpers.has(s.toLowerCase()) && !helpers.has(bareName(s)))
368 const work = facts.skillWork
369 const isRouter = (/** @type {number} */ i) => {
370 const here = work?.[i]
371 if (here === undefined) return false
372 for (let j = i + 1; j < skills.length; j++) {
373 const there = work?.[j]
374 if (names[j] && there !== undefined && there - here <= ROUTER_MAX_WORK) return true
375 }
376 return false
377 }
378 const at = skills.findIndex((_, i) => names[i] && !isRouter(i))
379 const skill = at < 0 ? null : (skills[at] ?? null)
380 const rawCommand = facts.command ? String(facts.command).replace(/^\//, '') : null
381 const command = rawCommand && !ignore.has(rawCommand.toLowerCase()) ? rawCommand : null
382 const taskType = skill ?? command ?? 'freeform'
383 const folder = facts.projectsFolder ?? ''
384 const project =
385 projectFromSkill(skill) ??
386 projectFromSkill(command) ??
387 mostCommonProject(facts.editedPaths ?? [], folder) ??
388 mostCommonProject(facts.readPaths ?? [], folder)
389 return { taskType, skill, command, project }
390}
391
392const AGENT_NAMES = new Set(['Agent', 'Task'])
393const SHELL_NAMES = new Set(['Bash', 'PowerShell'])
394/** A run's shape is read from its first this-many model requests, and is known once the next one begins. */
395export const SHAPE_STEPS = 2
396/** How much a run did, least to most: nothing but an answer, looked at things, ran commands, changed files, handed work to agents. */
397export const SHAPES = /** @type {const} */ (['answer', 'read', 'shell', 'edit', 'agents'])
398
399/**
400 * The shape of the work from the names of the tools called so far (never
401 * their arguments): the most involved kind wins.
402 * @param {Iterable<string>} names
403 * @returns {'answer' | 'read' | 'shell' | 'edit' | 'agents'}
404 */
405export function shapeOf(names) {
406 let rank = 0
407 for (const name of names) {
408 const r = AGENT_NAMES.has(name) ? 4 : EDIT_TOOLS.has(name) ? 3 : SHELL_NAMES.has(name) ? 2 : 1
409 if (r > rank) rank = r
410 }
411 return SHAPES[rank] ?? 'answer'
412}
413
414/**
415 * True for a prompt the person sent (typed, from the phone bridge, or an SDK
416 * host's own turn).
417 * @param {{ kind?: unknown } | null | undefined} origin
418 * @returns {boolean}
419 */
420export function isPersonPrompt(origin) {
421 const kind = origin?.kind
422 return kind === 'composer' || kind === 'bridge' || kind === 'sdk'
423}
424
425/**
426 * A turn the engine starts to hand the model a background task's result.
427 * @param {unknown} text
428 * @returns {boolean}
429 */
430export function isNoticeText(text) {
431 return /^\s*<(task-notification|agent-message|background-task|system-reminder)\b/.test(String(text ?? ''))
432}
433
434/**
435 * The background task a notification is about (`<task-id>...</task-id>`): the
436 * id alone, never the text around it.
437 * @param {unknown} text
438 * @returns {string | null}
439 */
440export function taskIdOf(text) {
441 const m = /<task-id>\s*([^<\s]{1,200})\s*<\/task-id>/.exec(String(text ?? '').slice(0, 4000))
442 return m && m[1] ? m[1] : null
443}
444hooks/band.tsx 48 lines1import { atom, read } from 'claude-code'
2import type { On, RenderElement } from 'claude-code'
3
4import type { EtaView } from '../types'
5import { LOOK, SEP, bandSegments, fitBand } from './layout'
6import type { Part } from './layout'
7
8// The band: one row above the prompt, below any other band (its segments
9// are in layout.ts). It always calls next(e) and draws what the plugins
10// beneath drew above its own row, so no other mod's band is hidden and the
11// row sits under the strip whichever of the two runs first.
12
13const viewAtom = atom({ plugin: 'vault-eta', key: 'view' } as const, { kind: 'none' } as EtaView)
14
15/** What the plugins beneath drew, or undefined when they drew nothing (an empty Box). */
16function drawnBelow(tree: RenderElement | null | undefined): RenderElement | undefined {
17 if (tree === null || tree === undefined || typeof tree !== 'object') return undefined
18 const el = tree as { type?: unknown; props?: { children?: unknown }; children?: unknown }
19 const kids = el.children ?? el.props?.children
20 const isEmpty = el.type === 'Box' && (kids === undefined || kids === null || (Array.isArray(kids) && kids.length === 0))
21 return isEmpty ? undefined : tree
22}
23
24export type BandDeps = {
25 /** The terminal's width and layout, as the band last measured them (the pane's auto-open reads them). */
26 onViewport: (columns: number, isFullscreen: boolean | undefined) => void
27}
28
29export function registerBand(on: On, deps: BandDeps) {
30 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
31 const result = await next(e)
32 if (e.viewport) deps.onViewport(e.viewport.columns, e.viewport.isFullscreen)
33 if (e.props.hasSurvey) return result
34 const fitted = fitBand(bandSegments(await read($, viewAtom)), Math.max(4, e.props.bodyColumns))
35 if (fitted.length === 0) return result
36 const { Box, Text } = $.ui.resolve(e)
37 const draw = (part: Part) => (
38 <Text {...LOOK[part.look]} {...(part.bold ? { bold: true } : {})} wrap="truncate">
39 {part.text}
40 </Text>
41 )
42 const children = fitted.flatMap((segment, i) => [...(i === 0 ? [] : [<Text dimColor wrap="truncate">{SEP}</Text>]), ...segment.parts.map(draw)])
43 const row = <Box flexDirection="row">{children}</Box>
44 const below = drawnBelow(result)
45 return below === undefined ? row : <Box flexDirection="column">{below}{row}</Box>
46 })
47}
48hooks/commands.ts 139 lines1import type { EngineInterface } from 'claude-code'
2
3import { fmtCost, fmtDur } from '../lib/format.mjs'
4import { buildReport } from '../lib/report.mjs'
5import { refusalOf } from './guard'
6import { joinPath, loadAll } from './log'
7import type { Profile } from './predict'
8
9// What /eta report and /eta job do. Like the rest of /eta they print no
10// reply: nothing reaches the model, and the answer is a toast. register.tsx
11// routes the command here and rebuilds the view after a job changes.
12
13type Engine = EngineInterface
14
15/** The `$.store` key `/eta job <name>` sets; read at each run's start. */
16export const JOB_KEY = 'job'
17const JOB_MAX = 40
18const TOAST_MS = 6000
19
20/** A job name as stored: lowercase, a-z 0-9 and hyphens, at most 40 characters; empty when nothing usable is left. */
21export function sanitizeJob(raw: string): string {
22 return raw
23 .toLowerCase()
24 .replace(/[^a-z0-9]+/g, '-')
25 .replace(/^-+/, '')
26 .slice(0, JOB_MAX)
27 .replace(/-+$/, '')
28}
29
30/** The job in a stored value; null when none is set. */
31export function jobName(value: unknown): string | null {
32 if (typeof value === 'string' && value.trim() !== '') return value.trim()
33 if (typeof value === 'object' && value !== null && typeof (value as { name?: unknown }).name === 'string') {
34 const name = (value as { name: string }).name.trim()
35 return name === '' ? null : name
36 }
37 return null
38}
39
40/** A job's totals from the profile, as the toasts read them. */
41function jobTotals(profile: Profile, name: string): string {
42 const t = profile.totals(r => r.job === name)
43 return `${t.runs} run${t.runs === 1 ? '' : 's'}, ${fmtDur(t.ms)}, ${fmtCost(t.cost.usd, t.cost.priced, t.cost.unpriced)} at API prices`
44}
45
46/**
47 * `/eta job`, `/eta job <name>` and `/eta job end`. Returns whether the job
48 * changed, so the caller rebuilds the view (the pane header shows the job at once).
49 */
50export async function jobCommand($: Engine, profile: Profile, rest: string): Promise<boolean> {
51 const current = jobName(await $.store.get(JOB_KEY).catch(() => undefined))
52 const arg = rest.trim()
53 if (arg === '') {
54 $.ui.toast(current ? `job ${current}: ${jobTotals(profile, current)}` : 'no job set. /eta job <name> tags the runs after it.', { timeoutMs: TOAST_MS })
55 return false
56 }
57 if (arg.toLowerCase() === 'end') {
58 if (!current) {
59 $.ui.toast('no job set.', { timeoutMs: TOAST_MS })
60 return false
61 }
62 const totals = jobTotals(profile, current)
63 await $.store.delete(JOB_KEY)
64 $.ui.toast(`job ${current} ended: ${totals}`, { timeoutMs: TOAST_MS })
65 return true
66 }
67 const name = sanitizeJob(arg)
68 if (name === '') {
69 $.ui.toast('a job name needs letters or numbers: /eta job my-build', { timeoutMs: TOAST_MS })
70 return false
71 }
72 await $.store.set(JOB_KEY, name)
73 $.ui.toast(`job ${name}: runs are tagged until /eta job end`, { timeoutMs: TOAST_MS })
74 return true
75}
76
77/**
78 * Where the report may be written: a relative `.md` path that stays inside
79 * the folder the logs live under (no `..`, no drive or root, nothing under a
80 * `.git` or `.vault-eta` folder). Null when the setting asks for anything else,
81 * so a setting can never point the report at another file on the machine.
82 */
83export function safeReportPath(cwd: string, reportPath: string): string | null {
84 const text = reportPath.trim()
85 if (/^([a-zA-Z]:)?[\\/]/.test(text) || text.startsWith('~')) return null
86 const parts = text.split(/[\\/]+/).filter(p => p !== '' && p !== '.')
87 if (parts.length === 0 || !/\.md$/i.test(parts[parts.length - 1] ?? '')) return null
88 if (parts.some(p => p === '..' || p.toLowerCase() === '.git' || p.toLowerCase() === '.vault-eta' || /[:\0]/.test(p))) return null
89 return joinPath(cwd, ...parts)
90}
91
92/** True for a note this mod wrote: its frontmatter carries the report's tags and `generated: true`. */
93export function isReportFile(text: string): boolean {
94 if (!text.startsWith('---\n')) return false
95 const end = text.indexOf('\n---', 3)
96 if (end < 0) return false
97 const head = text.slice(4, end).split('\n').map(line => line.trim())
98 return head.includes('tags: [vault-eta, report]') && head.includes('generated: true')
99}
100
101/**
102 * Why the report may not be written at `path` (inside `base` by its
103 * spelling), or null when it may. The shared write rule first (no symbolic
104 * link at the file, nothing that resolves outside the base; a linked folder
105 * inside the vault is fine while it stays inside); then a file already there
106 * must be a report this mod wrote, so a note of the person's is never
107 * overwritten.
108 */
109export async function reportRefusal($: Engine, base: string, path: string): Promise<string | null> {
110 const refusal = await refusalOf($, base, path, { linkedFolders: true })
111 if (refusal !== null) return refusal
112 const own = await $.fs.stat(path).catch(() => undefined)
113 if (own) {
114 const name = path.slice(path.lastIndexOf('/') + 1)
115 const text = await $.fs.read(path).catch(() => null)
116 if (text === null || !isReportFile(text)) return `${name} exists and was not written by vault-eta (move it, or set reportPath)`
117 }
118 return null
119}
120
121/** Writes the report of every run ever logged (and this session's unflushed rows) to `reportPath`, then toasts where, or why not. */
122export async function reportCommand($: Engine, cwd: string, profile: Profile, reportPath: string) {
123 try {
124 const path = safeReportPath(cwd, reportPath)
125 if (path === null) throw new Error('the reportPath option must be a relative .md path inside the folder (no .. or drive)')
126 const refusal = await reportRefusal($, cwd, path)
127 if (refusal !== null) {
128 $.ui.toast(`/eta report not written: ${refusal}`, { timeoutMs: TOAST_MS })
129 return
130 }
131 const rows = [...(await loadAll($, cwd)), ...profile.sessionRows]
132 const text = buildReport(rows, { now: await $.clock.now() })
133 await $.fs.write(path, text)
134 $.ui.toast(`report written: ${path}`, { timeoutMs: TOAST_MS })
135 } catch (error) {
136 $.ui.toast(`/eta report failed: ${error instanceof Error ? error.message : String(error)}`, { timeoutMs: TOAST_MS })
137 }
138}
139hooks/guard.ts 37 lines1import type { EngineInterface } from 'claude-code'
2
3import { writeRefusal } from '../lib/safepath.mjs'
4
5// The mod's one rule for where it writes (lib/safepath.mjs) over $.fs: the
6// report, the log files and the .gitignore all go through it.
7
8type Engine = EngineInterface
9
10/** What a path is without following it: a link (one leading nowhere included), a file or a folder; null when nothing is there. */
11async function lstatOf($: Engine, path: string): Promise<{ isLink: boolean; kind: 'file' | 'dir' | 'other' } | null> {
12 const st = await $.fs.stat(path).catch(() => undefined)
13 if (st) return { isLink: st.isLink, kind: st.kind }
14 // a link that leads nowhere may not stat: its folder's listing still names it
15 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
16 if (cut <= 0) return null
17 try {
18 const entry = (await $.fs.list(path.slice(0, cut))).find(e => e.name === path.slice(cut + 1))
19 return entry?.isLink ? { isLink: true, kind: 'other' } : null
20 } catch {
21 return null
22 }
23}
24
25/** Why the mod may not write `path` under `base` (see writeRefusal), or null when it may. */
26export function refusalOf($: Engine, base: string, path: string, options: { linkedFolders?: boolean } = {}): Promise<string | null> {
27 return writeRefusal(
28 {
29 lstat: p => lstatOf($, p),
30 realPath: async p => (await $.fs.stat(p, { resolve: true }).catch(() => undefined))?.realPath ?? null,
31 },
32 base,
33 path,
34 options,
35 )
36}
37hooks/log.ts 340 lines1import type { EngineInterface } from 'claude-code'
2
3import { refusalOf } from './guard'
4import type { LogRow, RunTask, SessionRow, TurnRow } from './record'
5
6// The session's log file: `.vault-eta/runs/<machineId>/<YYYY-MM-DD>-<sessionId>.jsonl`
7// under the session's folder. $.fs has no append, so rows stay in memory and
8// the current part is rewritten at each run's end; one writer per file, so
9// Mac and Windows never touch the same one. Past ROLL_BYTES (512 KiB) the
10// part is sealed and the next rows go to `...part2.jsonl`, which keeps each
11// rewrite small (a synced folder uploads less, an exit writes less). A flush
12// with nothing new since the last one writes nothing.
13
14type Engine = EngineInterface
15
16export const LOG_ROOT = '.vault-eta/runs'
17/** The `.vault-eta` folder's own ignore file: ignore everything in it, itself included. */
18export const GITIGNORE_TEXT = '*\n'
19
20export const ROLL_BYTES = 512 * 1024
21export const HISTORY_DAYS = 90
22
23const encoder = new TextEncoder()
24const bytesOf = (s: string) => encoder.encode(s).length
25
26export function joinPath(...parts: string[]): string {
27 return parts
28 .filter(p => p !== '')
29 .map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p.replace(/^[\\/]+|[\\/]+$/g, '')))
30 .join('/')
31}
32
33/** A log write the shared write rule refused (a symbolic link on its way, or a path that resolves outside the base). */
34export class WriteRefused extends Error {}
35
36/**
37 * Makes the log folder ignore itself in git: writes `.vault-eta/.gitignore`
38 * containing `*` whenever that file is missing (a new folder, or one deleted
39 * and made again by a later write), so nobody commits their logs by accident.
40 * An ignore file that exists is left as its owner has it: emptying it is how
41 * the logs are committed on purpose. Returns why the folder may not be
42 * written at all (a symbolic link at `.vault-eta` or the ignore file, or a
43 * path that resolves outside the base), else null.
44 */
45export async function ensureGitignore($: Engine, base: string): Promise<string | null> {
46 const file = joinPath(base, '.vault-eta', '.gitignore')
47 const refusal = await refusalOf($, base, file)
48 if (refusal !== null) return refusal
49 try {
50 if (await $.fs.exists(file)) return null
51 await $.fs.write(file, GITIGNORE_TEXT)
52 } catch {
53 // no ignore file is not worth failing a session over
54 }
55 return null
56}
57
58/** The local calendar date of a time, `YYYY-MM-DD`. */
59export function localDate(ms: number): string {
60 const d = new Date(ms)
61 const pad = (n: number) => String(n).padStart(2, '0')
62 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
63}
64
65/** A session id as it appears in a file name: letters, digits, dash and underscore only, so an id can never carry a path. */
66export function safeId(sessionId: string): string {
67 return sessionId.replace(/[^A-Za-z0-9_-]/g, '-')
68}
69
70export function fileName(date: string, sessionId: string, part: number): string {
71 const id = safeId(sessionId)
72 return part <= 1 ? `${date}-${id}.jsonl` : `${date}-${id}.part${part}.jsonl`
73}
74
75export function parseFileName(name: string): { date: string; sessionId: string; part: number } | null {
76 const m = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:\.part(\d+))?\.jsonl$/.exec(name)
77 if (!m || !m[1] || !m[2]) return null
78 return { date: m[1], sessionId: m[2], part: m[3] ? Number(m[3]) : 1 }
79}
80
81/**
82 * A backfill file's month and part, as scripts/backfill.mjs names them:
83 * `backfill-2026-10.jsonl`, `backfill-2026-10.part2.jsonl`.
84 */
85export function parseBackfillName(name: string): { month: string; part: number } | null {
86 const m = /^backfill-(\d{4}-\d{2})(?:\.part(\d+))?\.jsonl$/.exec(name)
87 if (!m || !m[1]) return null
88 return { month: m[1], part: m[2] ? Number(m[2]) : 1 }
89}
90
91export function toJsonl(rows: readonly LogRow[]): string {
92 return rows.map(r => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '')
93}
94
95/** Rows of a JSONL file; a line that does not parse (a write cut short) is skipped. */
96export function parseJsonl(text: string): LogRow[] {
97 const rows: LogRow[] = []
98 for (const line of text.split('\n')) {
99 if (line.trim() === '') continue
100 try {
101 const row = JSON.parse(line) as LogRow
102 if (row && typeof row === 'object' && (row.kind === 'turn' || row.kind === 'session')) rows.push(row)
103 } catch {
104 // skipped
105 }
106 }
107 return rows
108}
109
110export type SessionTotals = { turns: number; usd: number; unpriced: number; start: number | null }
111
112export class SessionLog {
113 readonly dir: string
114 readonly date: string
115 readonly sessionId: string
116 part = 1
117 rows: TurnRow[] = []
118 bytes = 0
119 /** Every part's turns, for the session row. */
120 readonly totals: SessionTotals = { turns: 0, usd: 0, unpriced: 0, start: null }
121 /** The engine's total cost and this log's logged cost when this instance took the log on. */
122 private engineBase: number | null = null
123 private loggedBase = 0
124 /** Parts sealed by a rollover and not yet written. */
125 private sealed: Array<{ path: string; text: string }> = []
126 /** Rows added in all, and how many of them the last good flush had: unequal means a flush has work. */
127 private added = 0
128 private flushed = 0
129
130 constructor(dir: string, date: string, sessionId: string) {
131 this.dir = dir
132 this.date = date
133 this.sessionId = sessionId
134 }
135
136 /** Call once the log is read back from disk: later ratios compare only what happened after this point. */
137 markBaseline(engineUsd: number | null) {
138 this.engineBase = engineUsd
139 this.loggedBase = this.totals.usd
140 }
141
142 path(part = this.part): string {
143 return joinPath(this.dir, fileName(this.date, this.sessionId, part))
144 }
145
146 private count(row: TurnRow) {
147 this.totals.turns += 1
148 if (typeof row.usd === 'number') this.totals.usd += row.usd
149 else this.totals.unpriced += 1
150 this.totals.start = this.totals.start === null ? row.start : Math.min(this.totals.start, row.start)
151 }
152
153 /** Keeps a row; seals the current part first when the row would carry it past ROLL_BYTES. */
154 add(row: TurnRow, taskFor?: (runId: string) => RunTask | undefined) {
155 const size = bytesOf(JSON.stringify(row)) + 1
156 if (this.rows.length > 0 && this.bytes + size > ROLL_BYTES) {
157 this.sealed.push({ path: this.path(), text: toJsonl(this.refreshed(taskFor)) })
158 this.part += 1
159 this.rows = []
160 this.bytes = 0
161 }
162 this.rows.push(row)
163 this.bytes += size
164 this.count(row)
165 this.added += 1
166 }
167
168 /** Rows (or a sealed part) not on disk yet. */
169 isDirty(): boolean {
170 return this.added !== this.flushed || this.sealed.length > 0
171 }
172
173 /** How many rows a flush starting now covers; `markFlushed` with it once the flush succeeded. */
174 mark(): number {
175 return this.added
176 }
177
178 markFlushed(mark: number) {
179 this.flushed = mark
180 }
181
182 /** The rows with each run's task as it stands now (a skill loaded late in a run names the earlier rows too). */
183 private refreshed(taskFor?: (runId: string) => RunTask | undefined): TurnRow[] {
184 if (!taskFor) return this.rows
185 return this.rows.map(r => {
186 const task = taskFor(r.runId)
187 return task ? { ...r, task } : r
188 })
189 }
190
191 sessionRow(input: { machineId: string; now: number; engineUsd: number | null; version: string | null }): SessionRow {
192 const logged = Math.round(this.totals.usd * 1e6) / 1e6
193 const base = this.engineBase
194 const hasBase = base !== null && input.engineUsd !== null
195 const engineDelta = hasBase && input.engineUsd !== null ? input.engineUsd - base : input.engineUsd
196 const loggedDelta = hasBase ? this.totals.usd - this.loggedBase : this.totals.usd
197 return {
198 v: 1,
199 kind: 'session',
200 src: 'live',
201 machineId: input.machineId,
202 sessionId: this.sessionId,
203 start: this.totals.start ?? input.now,
204 end: input.now,
205 turns: this.totals.turns,
206 loggedUsd: logged,
207 unpricedTurns: this.totals.unpriced,
208 engineUsd: input.engineUsd,
209 ...(hasBase ? { engineBaseUsd: Math.round(base * 1e6) / 1e6, loggedBaseUsd: Math.round(this.loggedBase * 1e6) / 1e6 } : {}),
210 ratio: engineDelta !== null && engineDelta > 0 ? Math.round((loggedDelta / engineDelta) * 1000) / 1000 : null,
211 version: input.version,
212 }
213 }
214
215 /** What a flush writes: any sealed part, then the current part whole, the session row last. */
216 writes(
217 taskFor: ((runId: string) => RunTask | undefined) | undefined,
218 session: { machineId: string; now: number; engineUsd: number | null; version: string | null },
219 ): Array<{ path: string; text: string; isSealed: boolean }> {
220 const out = this.sealed.map(p => ({ ...p, isSealed: true }))
221 if (this.rows.length > 0 || this.totals.turns > 0) {
222 out.push({ path: this.path(), text: toJsonl([...this.refreshed(taskFor), this.sessionRow(session)]), isSealed: false })
223 }
224 return out
225 }
226
227 /** A sealed part reached the disk. */
228 wrote(path: string) {
229 this.sealed = this.sealed.filter(p => p.path !== path)
230 }
231
232 /** Takes in a part of this session's log read back from disk; the newest part's rows are kept to be rewritten. */
233 takeIn(part: number, rows: readonly LogRow[], isNewest: boolean) {
234 const turns = rows.filter((r): r is TurnRow => r.kind === 'turn')
235 for (const t of turns) this.count(t)
236 if (isNewest) {
237 this.part = part
238 this.rows = turns
239 this.bytes = turns.reduce((s, r) => s + bytesOf(JSON.stringify(r)) + 1, 0)
240 }
241 }
242}
243
244/**
245 * Writes a log's sealed parts and its current part. `guard` says why a path
246 * may not be written (refusalOf); a refusal throws WriteRefused before
247 * anything goes through it.
248 */
249export async function flushLog(
250 $: Engine,
251 log: SessionLog,
252 taskFor: ((runId: string) => RunTask | undefined) | undefined,
253 session: { machineId: string; now: number; engineUsd: number | null; version: string | null },
254 guard?: (path: string) => Promise<string | null>,
255) {
256 const mark = log.mark()
257 for (const w of log.writes(taskFor, session)) {
258 const refusal = guard ? await guard(w.path) : null
259 if (refusal !== null) throw new WriteRefused(refusal)
260 await $.fs.write(w.path, w.text)
261 if (w.isSealed) log.wrote(w.path)
262 }
263 log.markFlushed(mark)
264}
265
266/**
267 * Picks up a log this session already has on disk (a hot reload, or a
268 * resume on the same machine and day): its newest part's rows are kept to be
269 * rewritten, every part's turns are counted.
270 */
271export async function reloadLog($: Engine, log: SessionLog) {
272 let names: string[]
273 try {
274 names = (await $.fs.list(log.dir)).filter(e => e.kind === 'file').map(e => e.name)
275 } catch {
276 return
277 }
278 const parts = names
279 .map(name => ({ name, parsed: parseFileName(name) }))
280 .filter(p => p.parsed !== null && p.parsed.sessionId === safeId(log.sessionId) && p.parsed.date === log.date)
281 .sort((a, b) => (a.parsed?.part ?? 0) - (b.parsed?.part ?? 0))
282 const newest = parts[parts.length - 1]
283 for (const p of parts) {
284 try {
285 log.takeIn(p.parsed?.part ?? 1, parseJsonl(await $.fs.read(joinPath(log.dir, p.name))), p === newest)
286 } catch {
287 // an unreadable part is left out
288 }
289 }
290}
291
292/**
293 * Every machine's turn rows from the last `days` days under the folder: the
294 * history the profile is built from. Session files go by the date in their
295 * name; backfill files (one a month) by their month, then each row by its
296 * start. A backfilled run the live recorder also logged is dropped later,
297 * when rows become runs (dedupeRuns: a live row wins).
298 */
299export async function loadRecent($: Engine, cwd: string, now: number, days = HISTORY_DAYS): Promise<TurnRow[]> {
300 const root = joinPath(cwd, LOG_ROOT)
301 const isAll = !Number.isFinite(days)
302 const cutoffMs = isAll ? -Infinity : now - days * 86_400_000
303 const cutoff = isAll ? '' : localDate(cutoffMs)
304 const rows: TurnRow[] = []
305 let machines: string[]
306 try {
307 machines = (await $.fs.list(root)).filter(e => e.kind === 'dir').map(e => e.name)
308 } catch {
309 return rows
310 }
311 for (const machine of machines) {
312 let files: string[]
313 try {
314 files = (await $.fs.list(joinPath(root, machine))).filter(e => e.kind === 'file').map(e => e.name)
315 } catch {
316 continue
317 }
318 for (const name of files) {
319 const parsed = parseFileName(name)
320 const backfill = parsed ? null : parseBackfillName(name)
321 if (parsed ? parsed.date < cutoff : !backfill || backfill.month < cutoff.slice(0, 7)) continue
322 try {
323 for (const r of parseJsonl(await $.fs.read(joinPath(root, machine, name)))) {
324 if (r.kind !== 'turn') continue
325 if (backfill && !(typeof r.start === 'number' && r.start >= cutoffMs)) continue
326 rows.push(r)
327 }
328 } catch {
329 // an unreadable file is left out
330 }
331 }
332 }
333 return rows
334}
335
336/** Every row ever logged under the folder, whatever its age (the report reads this). */
337export function loadAll($: Engine, cwd: string): Promise<TurnRow[]> {
338 return loadRecent($, cwd, 0, Infinity)
339}
340hooks/pane.tsx 102 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { EtaView } from '../types'
5import { LOOK, lineWidth, paneLines } from './layout'
6import type { Part } from './layout'
7
8// The side pane: `$.ui.open`, toggled by /eta at any width, opening by itself
9// as a sidebar when a run starts on a wide fullscreen terminal (the person
10// closing it stops that for the session). It draws the same view the band
11// does, redrawn once a second while a run works, in this layout:
12// header, progress, time left, confidence, NOW, WHO, the time
13// split, speed, context, cost.
14
15type Engine = EngineInterface
16
17export const PANE = 'vault-eta'
18export const TITLE = 'ETA'
19/** The dock's width the pane asks for: 45 columns of content inside its border. */
20export const PANE_COLUMNS = 48
21/** The rows it asks for when seated inline above the prompt. */
22export const PANE_ROWS = 18
23/** Below this width an unasked pane would crowd the transcript: it is not opened by itself. */
24export const AUTO_OPEN_COLUMNS = 144
25
26const viewAtom = atom({ plugin: 'vault-eta', key: 'view' } as const, { kind: 'none' } as EtaView)
27const dismissedAtom = atom({ plugin: 'vault-eta', key: 'isPaneDismissed' } as const, false)
28
29/** The person asked for the pane (/eta, or an open of theirs): seated at any width. */
30async function openPane($: Engine) {
31 await update($, dismissedAtom, () => false)
32 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: PANE_ROWS })
33}
34
35/**
36 * A run started: open the pane by itself where it lands as a sidebar, a
37 * fullscreen terminal at least AUTO_OPEN_COLUMNS wide, unless the person
38 * closed it this session or it is open already.
39 */
40export async function autoOpen($: Engine, viewport: { columns: number; isFullscreen: boolean | undefined } | null) {
41 if (!viewport || viewport.isFullscreen !== true || viewport.columns < AUTO_OPEN_COLUMNS) return
42 if (await read($, dismissedAtom)) return
43 const panes = await $.ui.panes()
44 if (panes.some(pane => pane.id === PANE)) return
45 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: PANE_ROWS })
46}
47
48export async function registerCommand($: Engine) {
49 await $.command.register({
50 name: 'eta',
51 description: 'vault-eta: the ETA pane (open or close it), the history report (report), and job tags that add up a build across sessions (job <name>, job end)',
52 argumentHint: '[open|close|report|job <name>|job end]',
53 immediate: true,
54 })
55}
56
57export function registerPane(on: On) {
58 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59 const { Box, Text } = $.ui.resolve(e)
60 const columns = Math.max(20, e.props.bodyColumns)
61 const lines = paneLines(await read($, viewAtom), columns)
62 const draw = (part: Part) => (
63 <Text {...LOOK[part.look]} {...(part.bold ? { bold: true } : {})} wrap="truncate">
64 {part.text}
65 </Text>
66 )
67 return (
68 <Box flexDirection="column">
69 {lines.map(line => (line.length === 0 || lineWidth(line) === 0 ? <Text> </Text> : <Box flexDirection="row">{line.map(draw)}</Box>))}
70 </Box>
71 )
72 })
73
74 // Closed by the person (its mark, a key) or by /eta: it stops opening by itself this session.
75 on('ui.close', async ($, e, next) => {
76 const result = await next(e)
77 if (e.id === PANE && e.origin.kind !== 'unload') await update($, dismissedAtom, () => true).catch(() => undefined)
78 return result
79 })
80}
81
82/**
83 * /eta, /eta open, /eta close (routed here by register.tsx). Prints nothing,
84 * so nothing it shows reaches the model; a word it does not know says what
85 * the command takes in a toast.
86 */
87export async function paneCommand($: Engine, verb: string) {
88 if (verb !== '' && verb !== 'open' && verb !== 'close') {
89 $.ui.toast('/eta: open, close, report, job <name>, job end.', { timeoutMs: 6000 })
90 return
91 }
92 const pane = (await $.ui.panes()).find(one => one.id === PANE)
93 // A pane opened by itself may wait for room: /eta seats it rather than closing it.
94 const isSeated = pane !== undefined && pane.isPlaced
95 if (verb === 'close' || (verb === '' && isSeated)) {
96 if (pane) await $.ui.close({ id: PANE })
97 await update($, dismissedAtom, () => true)
98 return
99 }
100 await openPane($)
101}
102hooks/predict.ts 361 lines1import { summarizeRuns } from '../lib/aggregate.mjs'
2import { modelKey } from '../lib/price.mjs'
3import {
4 combineRemaining,
5 estimateAtSubmit,
6 matchRuns,
7 median,
8 remainingAfter,
9 scoreRuns,
10 speedFactor,
11 stepFraction,
12 taskFraction,
13} from '../lib/stats.mjs'
14import type { Match, PastRun, RunQuery, TypeScore } from '../lib/stats.mjs'
15import type { EtaCost, EtaState, EtaTotals } from '../types'
16import type { LiveView, MidEta, StoredEta, TurnRow } from './record'
17
18// The profile and the estimate. At session start the last 90 days of rows
19// (live and backfilled) become one summary per run; this session's rows join
20// as they are written. An estimate matches past runs like this one (lib/stats),
21// then refines the time left from how long the run has gone, its steps and
22// task list, today's writing speed and the self-score. Plain statistics, no
23// model calls.
24
25export const MIN_MATCHES = 5
26export const MATCH_LIMIT = 50
27/** Writing speeds kept per model, newest first. */
28const TPS_KEEP = 300
29/** Writing speeds of the session's latest requests that count as "today's speed". */
30const RECENT_TPS = 10
31
32export type ProfileRun = PastRun & {
33 runId: string
34 sessionId: string
35 /** Its rows, and how many of them had no price (a model missing from the price table). */
36 turns: number
37 unpricedTurns: number
38 job: string | null
39 startedAt: number
40 outcome: string
41 eta: StoredEta | null
42 etaMid: MidEta | null
43}
44
45export type Estimate = {
46 query: RunQuery
47 learning: { have: number; need: number } | null
48 level: string | null
49 n: number
50 submit: { usualMs: number; slowMs: number; usualUsd: number | null; slowUsd: number | null } | null
51 factor: number
52 coverage: number | null
53 scored: number
54 state: EtaState
55 leftMs: number | null
56 leftSlowMs: number | null
57 totalMs: number | null
58 speed: { tps: number | null; usualTps: number | null; etaPct: number | null }
59}
60
61export { modelKey }
62
63function toRun(s: ReturnType<typeof summarizeRuns>[number]): ProfileRun {
64 return {
65 runId: s.runId,
66 sessionId: s.sessionId,
67 turns: s.turns,
68 unpricedTurns: s.unpricedTurns,
69 taskType: s.taskType,
70 project: s.project,
71 model: modelKey(s.model),
72 effort: s.effort,
73 promptChars: s.promptChars,
74 shape: s.shape,
75 agentMs: s.agentMs,
76 usd: s.usd,
77 endedAt: s.endedAt,
78 startedAt: s.startedAt,
79 mainSteps: s.mainSteps,
80 job: s.job,
81 outcome: s.outcome,
82 eta: s.eta,
83 etaMid: s.etaMid,
84 }
85}
86
87/** One summary per run, with the estimate its main rows carry (lib/aggregate). */
88function runsOf(rows: readonly TurnRow[]): ProfileRun[] {
89 return summarizeRuns(rows as never).map(toRun)
90}
91
92/** Runs an estimate can learn from: finished, with time on the clock. */
93function isUsable(r: ProfileRun): boolean {
94 return r.outcome === 'finished' && r.agentMs > 0
95}
96
97export class Profile {
98 /** Runs of other sessions (and every backfilled run), from the rows loaded at session start. */
99 history: ProfileRun[] = []
100 /** This session's runs, rebuilt from its rows as they are written. */
101 session: ProfileRun[] = []
102 sessionRows: TurnRow[] = []
103 rowsLoaded = 0
104 backfillRows = 0
105 /** Median share of a run's agent time spent in model requests. */
106 modelShare = 0.6
107 /** Bumped at every change; the match cache keys on it. */
108 version = 0
109 private tps = new Map<string, number[]>()
110 private matches = new Map<string, Match>()
111 private scores: Record<string, TypeScore> | null = null
112 /** Rows arrived since the scores were made: refreshScores makes them again while nothing runs, never on every row. */
113 private isScoreStale = false
114
115 load(rows: readonly TurnRow[], sessionId: string) {
116 const isMine = (r: TurnRow) => r.sessionId === sessionId && (r.src as string) !== 'backfill'
117 this.rowsLoaded = rows.length
118 this.backfillRows = rows.filter(r => (r.src as string) === 'backfill').length
119 this.sessionRows = rows.filter(isMine)
120 const mineRuns = new Set(this.sessionRows.map(r => r.runId))
121 this.history = runsOf(rows.filter(r => !isMine(r))).filter(r => !mineRuns.has(r.runId))
122 this.tps = new Map()
123 for (const r of [...rows].sort((a, b) => b.end - a.end)) this.learnSpeed(r, false)
124 const shares = rows
125 .filter(r => !r.agentId && r.agentMs > 1000 && r.modelMs > 0)
126 .map(r => Math.min(1, r.modelMs / r.agentMs))
127 this.modelShare = Math.max(0.05, Math.min(1, median(shares) ?? 0.6))
128 this.rebuild()
129 this.scores = null
130 this.isScoreStale = false
131 }
132
133 /** A row this session just wrote. */
134 addRow(row: TurnRow) {
135 this.sessionRows.push(row)
136 this.learnSpeed(row, true)
137 this.rebuild()
138 }
139
140 private rebuild() {
141 this.session = runsOf(this.sessionRows)
142 const mine = new Set(this.session.map(r => r.runId))
143 if (this.history.some(r => mine.has(r.runId))) this.history = this.history.filter(r => !mine.has(r.runId))
144 this.version += 1
145 this.matches.clear()
146 if (this.scores) this.isScoreStale = true
147 }
148
149 /** Writing speeds per model; a backfilled step has none (null), and is skipped. */
150 private learnSpeed(row: TurnRow, isNewest: boolean) {
151 for (const s of row.steps ?? []) {
152 const tps = s.tokensPerSec
153 if (typeof tps !== 'number' || !(tps > 0)) continue
154 const key = modelKey(s.model ?? row.model)
155 if (key === null) continue
156 const list = this.tps.get(key) ?? []
157 if (isNewest) list.unshift(tps)
158 else if (list.length < TPS_KEEP) list.push(tps)
159 if (list.length > TPS_KEEP) list.length = TPS_KEEP
160 this.tps.set(key, list)
161 }
162 }
163
164 usualTps(model: string | null | undefined): number | null {
165 const key = modelKey(model)
166 return key === null ? null : median(this.tps.get(key) ?? [])
167 }
168
169 /** The session's latest writing speeds on the model, newest last. */
170 recentTps(model: string | null | undefined): number[] {
171 const key = modelKey(model)
172 if (key === null) return []
173 const out: number[] = []
174 for (let i = this.sessionRows.length - 1; i >= 0 && out.length < RECENT_TPS; i--) {
175 const row = this.sessionRows[i]
176 if (!row || row.agentId) continue
177 const steps = row.steps ?? []
178 for (let j = steps.length - 1; j >= 0 && out.length < RECENT_TPS; j--) {
179 const s = steps[j]
180 if (s && typeof s.tokensPerSec === 'number' && s.tokensPerSec > 0 && modelKey(s.model ?? row.model) === key) out.unshift(s.tokensPerSec)
181 }
182 }
183 return out
184 }
185
186 /** Every run known, this session's last. */
187 runs(): ProfileRun[] {
188 return [...this.history, ...this.session]
189 }
190
191 match(query: RunQuery, excludeRunId: string | null): Match {
192 const key = `${this.version}|${excludeRunId ?? ''}|${JSON.stringify(query)}`
193 const held = this.matches.get(key)
194 if (held) return held
195 const pool = this.runs().filter(r => r.runId !== excludeRunId && isUsable(r))
196 const found = matchRuns(pool, query, { minMatches: MIN_MATCHES, limit: MATCH_LIMIT })
197 if (this.matches.size > 50) this.matches.clear()
198 this.matches.set(key, found)
199 return found
200 }
201
202 /**
203 * Per task type: how often the slow figure held, its correction, and which
204 * estimate missed by less. Scored by replaying every run against only the
205 * runs that ended before it (uncorrected), so there is data from day one;
206 * what live runs actually showed is scored apart (`shown`). Computed at
207 * load, then again by refreshScores while idle.
208 */
209 score(): Record<string, TypeScore> {
210 if (this.scores) return this.scores
211 this.scores = scoreRuns(this.runs().filter(isUsable), { minMatches: MIN_MATCHES, limit: MATCH_LIMIT })
212 this.isScoreStale = false
213 return this.scores
214 }
215
216 /**
217 * Makes the scores again when rows have arrived since they were made. The
218 * caller runs it while no run works (the idle view), so a run's own steps
219 * never wait on a replay. Returns whether it ran.
220 */
221 refreshScores(): boolean {
222 if (!this.isScoreStale) return false
223 this.scores = null
224 this.score()
225 return true
226 }
227
228 /** The newest finished run: this session's first, else the last one logged before it. */
229 lastRun(excludeRunId: string | null): ProfileRun | null {
230 const pick = (list: ProfileRun[]) => {
231 let best: ProfileRun | null = null
232 for (const r of list) if (r.runId !== excludeRunId && (!best || r.endedAt > best.endedAt)) best = r
233 return best
234 }
235 return pick(this.session) ?? pick(this.history)
236 }
237
238 totals(filter: (r: ProfileRun) => boolean): EtaTotals {
239 const out: EtaTotals = { runs: 0, ms: 0, cost: { usd: 0, priced: 0, unpriced: 0 } }
240 for (const r of this.runs()) {
241 if (!filter(r)) continue
242 const c = costOfRun(r)
243 out.runs += 1
244 out.ms += r.agentMs
245 out.cost.usd += c.usd
246 out.cost.priced += c.priced
247 out.cost.unpriced += c.unpriced
248 }
249 return out
250 }
251}
252
253/** A logged run's cost: its turns with a price, and those without. */
254export function costOfRun(run: ProfileRun): EtaCost {
255 const unpriced = run.unpricedTurns ?? 0
256 return { usd: run.usd ?? 0, priced: Math.max(0, (run.turns ?? 1) - unpriced), unpriced }
257}
258
259/** Local midnight of the day `now` falls on. */
260export function startOfDay(now: number): number {
261 const d = new Date(now)
262 d.setHours(0, 0, 0, 0)
263 return d.getTime()
264}
265
266/** The query a running run is matched by. */
267export function queryOf(live: LiveView): RunQuery {
268 return {
269 taskType: live.task?.taskType ?? 'freeform',
270 project: live.task?.project ?? null,
271 model: modelKey(live.model),
272 effort: live.effort,
273 promptChars: live.promptChars,
274 shape: live.shape,
275 }
276}
277
278/**
279 * The estimate for the running run: at submit from its matches, then the
280 * time left given how long it has gone (agent time, waits on the person
281 * paused out), refined by steps, the task list and today's speed.
282 */
283export function estimateRun(profile: Profile, live: LiveView): Estimate {
284 const query = queryOf(live)
285 const match = profile.match(query, live.runId)
286 const score = profile.score()[query.taskType]
287 const factor = score?.factor ?? 1
288 const sub = estimateAtSubmit(match, { slowFactor: factor })
289
290 // Today's speed against the usual: the session's latest requests on this model, this run's last.
291 const recent = [...profile.recentTps(live.model), ...live.recentTps].slice(-RECENT_TPS)
292 const usualTps = profile.usualTps(live.model)
293 const recentMedian = median(recent)
294 const speed = speedFactor(recent, usualTps)
295 const share = profile.modelShare
296 const etaPct = recentMedian !== null && usualTps !== null ? Math.round((share * speed + (1 - share) - 1) * 100) : null
297 const speedOut = { tps: recentMedian === null ? null : Math.round(recentMedian), usualTps: usualTps === null ? null : Math.round(usualTps), etaPct }
298
299 const base = { query, level: match.level, n: match.runs.length, factor, coverage: score && score.n >= MIN_MATCHES ? score.coverage : null, scored: score?.n ?? 0, speed: speedOut }
300 if (sub.learning || sub.usualMs === null || sub.slowMs === null) {
301 return { ...base, learning: sub.learning ?? { have: match.runs.length, need: MIN_MATCHES }, submit: null, state: 'learning', leftMs: null, leftSlowMs: null, totalMs: null }
302 }
303
304 const usual = sub.usualMs
305 const slow = sub.slowMs
306 const elapsed = live.agentMs
307 const survivors = remainingAfter(match.runs, elapsed)
308 const usualSteps = median(match.runs.map(r => r.mainSteps ?? 0).filter(n => n > 0))
309 const combined = combineRemaining({
310 elapsedMs: elapsed,
311 survivors,
312 taskFraction: taskFraction(live.tasks.done, live.tasks.total),
313 stepFraction: stepFraction(live.mainSteps, usualSteps),
314 speed,
315 modelShare: share,
316 })
317 let leftMs = combined.usualMs
318 let leftSlowMs = combined.slowMs
319 // Where the estimate at submit has missed by less for this task type, it leads while it still can.
320 if (score && score.best === 'submit' && score.n >= MIN_MATCHES && usual > elapsed) {
321 leftMs = usual - elapsed
322 leftSlowMs = Math.max(leftMs, slow - elapsed)
323 }
324 if (leftMs === null && usual > elapsed) {
325 leftMs = usual - elapsed
326 leftSlowMs = Math.max(leftMs, slow - elapsed)
327 }
328 if (leftMs !== null && !(leftMs > 0)) {
329 leftMs = null
330 leftSlowMs = null
331 }
332 const state: EtaState = elapsed > slow ? 'past-slow' : elapsed > usual ? 'past-usual' : 'on-track'
333 return {
334 ...base,
335 learning: null,
336 submit: { usualMs: usual, slowMs: slow, usualUsd: sub.usualUsd, slowUsd: sub.slowUsd },
337 state,
338 leftMs,
339 leftSlowMs,
340 totalMs: leftMs !== null ? elapsed + leftMs : usual,
341 }
342}
343
344/** What a run's rows keep of the estimate it began with. */
345export function storedEta(est: Estimate, steps: number): StoredEta {
346 return {
347 taskType: est.query.taskType,
348 project: est.query.project ?? null,
349 model: est.query.model ?? null,
350 level: est.level,
351 n: est.n,
352 learning: est.learning,
353 usualMs: est.submit ? Math.round(est.submit.usualMs) : null,
354 slowMs: est.submit ? Math.round(est.submit.slowMs) : null,
355 usualUsd: est.submit?.usualUsd ?? null,
356 slowUsd: est.submit?.slowUsd ?? null,
357 factor: Math.round(est.factor * 1000) / 1000,
358 steps,
359 }
360}
361hooks/record.ts 1281 lines1import {
2 EDIT_TOOLS,
3 classifyRun,
4 commandHead,
5 folderOf,
6 isNoticeText,
7 isPersonPrompt,
8 SHAPE_STEPS,
9 shapeOf,
10 linesChanged,
11 taskIdOf,
12 toolPath,
13} from '../lib/classify.mjs'
14import { costOf, familyOf, tokensOf } from '../lib/price.mjs'
15import { clip, lengthOf, overlapMs, subtract } from '../lib/spans.mjs'
16import type { Span } from '../lib/spans.mjs'
17
18// The recorder: what happened in each run, kept in memory while it runs and
19// turned into one log row per turn.complete (the main loop's and every
20// subagent's). A run is one person's prompt; its subagents, background ones
21// that finish later included, and the notification turns that hand their
22// results back all belong to it. State lives in a mutable module object
23// rather than $.state; the band and pane draw from a derived view.
24//
25// Never kept: prompt text, file contents, tool output, full Bash commands.
26
27export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
28export type Effort = string | number
29
30/** One model request: time to its first token and its writing speed separate model speed from the rest. */
31export type StepRow = {
32 /** Milliseconds from the turn's start. */
33 at: number
34 /** Left out when it is the row's own model (or effort): the row says it once. */
35 model?: string
36 effort?: Effort
37 ms: number
38 /** From the request leaving this hook to the first streamed token (text, thinking or a tool call). */
39 firstTokenMs: number | null
40 /** Output tokens over the time from the first token to the stop chunk. */
41 tokensPerSec: number | null
42 tokens: Tokens | null
43 usd: number | null
44 /** Characters of thinking streamed (0 when the model's thinking display is omitted). */
45 thinkingChars?: number
46 stop: string | null
47}
48
49export type ToolRow = {
50 at: number
51 name: string
52 /** Wall time of the call: a permission prompt, if one was shown, and the tool. */
53 ms: number
54 /** The tool's own time, as the engine reports it (PostToolUse duration_ms). */
55 runMs?: number
56 /** Time held waiting on the person (or the auto-mode decider). */
57 waitMs?: number
58 /** tool.check answered ask. */
59 asked?: true
60 error?: true
61 /** Still running when its turn ended (an interrupt). */
62 cut?: true
63 /** Bash: the first words only. */
64 cmd?: string
65 /** File tools: the folder, never the file's content. */
66 dir?: string
67 /** Edit and Write: lines added, lines removed. */
68 lines?: [number, number]
69 skill?: string
70 agentType?: string
71 /** TodoWrite: items done, items in the list. */
72 tasks?: [number, number]
73 background?: true
74}
75
76export type RunTask = {
77 taskType: string
78 skill: string | null
79 command: string | null
80 project: string | null
81 /** The `/eta job` tag in force when the run began. */
82 job: string | null
83 /** Length of the prompt in characters; never its text. */
84 promptChars: number
85 /** The prompt's origin kind: composer, sdk, bridge, scheduled-trigger, ... */
86 origin: string
87 runStart: number
88}
89
90export type Compaction = { trigger: string; before?: number; after?: number }
91
92export type Conditions = {
93 /** Context tokens when the run began ($.session.usage()). */
94 contextStart: number | null
95 /** Input tokens of this loop's first request in the turn. */
96 contextTurn: number | null
97 /** The first request read most of its prompt from the cache (warm) or wrote it (cold). */
98 cache: 'warm' | 'cold' | null
99 hour: number
100 weekday: number
101 tzOffsetMin: number
102 permissionMode: string | null
103 version: string | null
104 cwdName: string
105 compactions?: Compaction[]
106}
107
108export type Outcome = 'finished' | 'aborted' | 'error' | 'refusal'
109
110/**
111 * The estimate made as the run began (refreshed while its first two model
112 * requests settle which task it is), kept on its main rows so the report
113 * can score it against what the run took.
114 */
115export type StoredEta = {
116 taskType: string
117 project: string | null
118 model: string | null
119 /** The match level used (`type+project+model`, ...), null while learning. */
120 level: string | null
121 /** Past runs matched. */
122 n: number
123 learning: { have: number; need: number } | null
124 usualMs: number | null
125 slowMs: number | null
126 usualUsd: number | null
127 slowUsd: number | null
128 /** The self-score's correction applied to the slow figure. */
129 factor: number
130 /** Main model requests done when it was made. */
131 steps: number
132}
133
134/** A step-time estimate of the whole run, made `atMs` of agent time in: the one nearest half the run is kept. */
135export type MidEta = { atMs: number; usualMs: number; slowMs: number }
136
137/** Dollars at API prices: the priced parts' sum, and how many parts had a price and how many had none (a model missing from the price table). */
138export type Cost = { usd: number; priced: number; unpriced: number }
139
140/** One loop's share of a run: who worked, on what model, for how long and at what cost. */
141export type WhoPart = {
142 key: string
143 agentId: string | null
144 type: string | null
145 model: string
146 /** Its own working time: waits on the person, sleep and time in Agent calls left out. */
147 ms: number
148 /** New tokens: input, cache writes and output. */
149 tokens: number
150 cost: Cost
151}
152
153/** The running run as the band and the pane draw it (liveView). */
154export type LiveView = {
155 runId: string
156 task: RunTask | undefined
157 isWorking: boolean
158 isMainOpen: boolean
159 startedAt: number
160 /** Wall time less machine sleep. */
161 elapsedMs: number
162 /** Time held by the person (a question, a plan, a permission dialog), sleep left out. */
163 waitMs: number
164 /** elapsedMs less waitMs: what the estimate compares with past runs. */
165 agentMs: number
166 /** The main loop's tool time, Agent calls and waits left out. */
167 toolMs: number
168 /** The run's cost so far, from each model request as it ends. */
169 cost: Cost
170 mainSteps: number
171 /** What the run did in its first requests (answer, read, shell, edit, agents); null until its third request begins. */
172 shape: string | null
173 model: string | null
174 effort: Effort | null
175 promptChars: number
176 tasks: { done: number; total: number }
177 isWaitingOnYou: boolean
178 now: { kind: 'tool' | 'model'; name: string; detail: string; sinceMs: number } | null
179 agents: Array<{ agentId: string; type: string; model: string; isOpen: boolean; background: boolean }>
180 who: Array<WhoPart & { isOpen: boolean }>
181 /** Writing speed of this run's main requests, newest last. */
182 recentTps: number[]
183 /** Whether the main loop's latest request read its prompt from the cache. */
184 cache: 'warm' | 'cold' | null
185}
186
187export type TurnRow = {
188 v: 1
189 kind: 'turn'
190 src: 'live'
191 machineId: string
192 sessionId: string
193 runId: string
194 turnId: string
195 /** The subagent's id; null on the main loop. */
196 agentId: string | null
197 parentAgentId: string | null
198 agentType: string | null
199 background: boolean | null
200 model: string
201 effort: Effort | null
202 start: number
203 end: number
204 /** The engine's durationMs: the turn's wall time. */
205 ms: number
206 /** ms less waiting on the person and machine sleep: what the ETA learns. */
207 agentMs: number
208 modelMs: number
209 toolMs: number
210 waitMs: number
211 sleepMs: number
212 outcome: Outcome
213 tokens: Tokens
214 /** At API prices; null when the model is not in the price table. */
215 usd: number | null
216 unpriced?: true
217 steps: StepRow[]
218 tools: ToolRow[]
219 /** Machine sleep inside the turn, as [at, ms]. */
220 sleeps?: Array<[number, number]>
221 errors: { tools: number; steps: number }
222 task: RunTask
223 cond: Conditions
224 /** Main rows only: the run's estimate at submit. */
225 eta?: StoredEta
226 /** Main rows only: the step-time estimate made nearest half the run. */
227 etaMid?: MidEta
228}
229
230export type SessionRow = {
231 v: 1
232 kind: 'session'
233 src: 'live'
234 machineId: string
235 sessionId: string
236 start: number
237 end: number
238 turns: number
239 /** Sum of the logged turns' usd, all parts of the session's log. */
240 loggedUsd: number
241 unpricedTurns: number
242 /** The engine's own session total, as /cost has it. */
243 engineUsd: number | null
244 /** The engine's total when this recorder took the log on: cost it never saw (the session ran before the mod, or before a reload). */
245 engineBaseUsd?: number | null
246 /** The log's loggedUsd at that moment (rows read back from disk). */
247 loggedBaseUsd?: number
248 /** Cost logged since the baseline over the engine's cost since it (like with like; near 1 means the log priced what the engine counted). Without a baseline, loggedUsd over engineUsd. */
249 ratio: number | null
250 version: string | null
251}
252
253export type LogRow = TurnRow | SessionRow
254
255export type RecorderConfig = {
256 cacheWriteTtl: '1h' | '5m'
257 ignoreSkills: string[]
258 /** The vault-relative folder that holds projects; empty detects it. */
259 projectsFolder?: string
260 tickMs: number
261 sleepGapMs: number
262}
263
264type StopReason = string | null
265type Usage = {
266 input_tokens: number
267 output_tokens: number
268 cache_read_input_tokens: number
269 cache_creation_input_tokens: number
270 model?: string
271}
272
273/** A model request in flight, timed by the turn.step stream. */
274export type LiveStep = {
275 loop: string
276 agentId: string | null
277 index: number
278 startedAt: number
279 endedAt: number | null
280 firstAt: number | null
281 stopAt: number | null
282 model: string
283 effort?: Effort
284 usage: Usage | null
285 thinking: number
286 stop: StopReason
287}
288
289type LiveTool = {
290 id: string
291 loop: string
292 name: string
293 startedAt: number
294 endedAt: number | null
295 failed: boolean
296 asked: boolean
297 runMs?: number
298 /** classic.PermissionRequest raised for it: a dialog put it to the person. */
299 dialog: boolean
300 /** When it was seen running (its progress row drew): the person had answered by then. */
301 runningAt: number | null
302 info: Omit<ToolRow, 'at' | 'name' | 'ms'>
303}
304
305type Loop = {
306 key: string
307 agentId: string | null
308 turnId: string
309 startedAt: number
310 steps: LiveStep[]
311 tools: LiveTool[]
312 compactions: Compaction[]
313}
314
315export type Run = {
316 id: string
317 seq: number
318 startedAt: number
319 endedAt: number | null
320 isMainOpen: boolean
321 origin: string
322 promptChars: number
323 command: string | null
324 skills: string[]
325 /** Per skill, the main-loop tool calls made before it that were not skill loads. */
326 skillWork: number[]
327 workCalls: number
328 editedDirs: string[]
329 readDirs: string[]
330 job: string | null
331 contextStart: number | null
332 permissionMode: string | null
333 /** From each request's usage as it ends: the live total a band shows, and how many requests had a price and how many had none. */
334 liveUsd: number
335 pricedSteps: number
336 unpricedSteps: number
337 mainSteps: number
338 /** Main model requests begun, and the names of the tools called in the first few (for the shape). */
339 stepsBegun: number
340 earlyTools: string[]
341 tasks: { done: number; total: number }
342 notifications: number
343 /** The main loop's model and effort, from its first request. */
344 model: string | null
345 effort: Effort | null
346 /** Waits on the person in the run's finished turns, clock time. */
347 waitSpans: Span[]
348 /** The run's finished turns, one part each. */
349 done: WhoPart[]
350 /** Tool time of the main loop's finished turns, Agent calls and waits left out. */
351 closedToolMs: number
352 eta: StoredEta | null
353 checkpoints: MidEta[]
354}
355
356type Agent = {
357 agentId: string
358 runId: string | null
359 parentAgentId: string | null
360 type: string
361 model: string
362 background: boolean
363 permissionMode: string | null
364 isOpen: boolean
365}
366
367type Pending = {
368 origin: string
369 isNotice: boolean
370 chars: number
371 command: string | null
372 taskId: string | null
373 promptId: string | null
374}
375
376export type SessionInfo = {
377 machineId: string
378 sessionId: string
379 cwd: string
380 cwdName: string
381 version: string | null
382 startedAt: number
383}
384
385/** The tools whose whole time is the person's: a question, a plan to approve. */
386export const WAIT_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
387/** The tools that hand work to a subagent: their time is the subagent's, not the caller's. */
388export const AGENT_TOOLS = new Set(['Agent', 'Task'])
389const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
390const MAX_WAIT_SPANS = 500
391const MAX_CHECKPOINTS = 200
392const MAX_DONE = 500
393const MAX_DIRS = 200
394const MAX_STEPS = 1000
395const MAX_TOOLS = 2000
396const MAX_SLEEPS = 500
397/** Finished runs kept in memory (for late background results and the band); older ones are dropped. */
398export const KEEP_RUNS = 50
399
400const round = (n: number) => Math.round(n)
401/** Dollars to the nano-dollar, so a row reads $0.00722027 rather than float noise. */
402const roundUsd = (n: number | null) => (n === null ? null : Math.round(n * 1e9) / 1e9)
403const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
404
405function nameOfFolder(cwd: string): string {
406 const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean)
407 return parts[parts.length - 1] ?? ''
408}
409
410function outcomeOf(reason: string): Outcome {
411 if (reason === 'aborted') return 'aborted'
412 if (reason === 'error') return 'error'
413 if (reason === 'refusal') return 'refusal'
414 return 'finished'
415}
416
417function pushCapped(list: string[], value: string) {
418 if (list.length < MAX_DIRS) list.push(value)
419}
420
421export class Recorder {
422 readonly cfg: RecorderConfig
423 session: SessionInfo | null = null
424 readonly runs = new Map<string, Run>()
425 currentRunId: string | null = null
426 readonly agents = new Map<string, Agent>()
427 /** Background task ids (an agent's, a backgrounded command's) to the run that started them. */
428 readonly tasks = new Map<string, string>()
429 readonly loops = new Map<string, Loop>()
430 readonly liveTools = new Map<string, LiveTool>()
431 readonly checks = new Map<string, string>()
432 sleeps: Span[] = []
433 lastTick: number | null = null
434 pending: Pending | null = null
435 /** Compactions while no main turn was open (a /compact while idle): they join the next main turn. */
436 pendingCompactions: Compaction[] = []
437 permissionMode: string | null = null
438 private seq = 0
439
440 constructor(cfg: RecorderConfig) {
441 this.cfg = cfg
442 }
443
444 startSession(info: { machineId: string; sessionId: string; cwd: string; version: string | null; now: number }) {
445 this.session = {
446 machineId: info.machineId,
447 sessionId: info.sessionId,
448 cwd: info.cwd,
449 cwdName: nameOfFolder(info.cwd),
450 version: info.version,
451 startedAt: info.now,
452 }
453 }
454
455 setSessionId(sessionId: string) {
456 if (this.session) this.session.sessionId = sessionId
457 }
458
459 currentRun(): Run | undefined {
460 return this.currentRunId === null ? undefined : this.runs.get(this.currentRunId)
461 }
462
463 // ---------------------------------------------------------------- prompts and runs
464
465 /**
466 * A prompt entered. One submitted while idle starts a turn of its own: a
467 * person's opens a run there, a background task's notification rejoins the
468 * run that owns the task. One delivered into a running turn is part of it.
469 */
470 promptSubmitted(input: { origin: string; isMidTurn: boolean; text: string; command?: string | null }) {
471 if (input.isMidTurn) return
472 const isNotice = input.origin === 'task-notification' || (!isPersonPrompt({ kind: input.origin }) && isNoticeText(input.text))
473 this.pending = {
474 origin: input.origin,
475 isNotice,
476 chars: input.text.length,
477 command: input.command ?? null,
478 taskId: isNotice ? taskIdOf(input.text) : null,
479 promptId: null,
480 }
481 }
482
483 /** The classic UserPromptSubmit hook's fields: the prompt's stable id (the transcript's promptId) and the mode. */
484 classicPrompt(input: { promptId?: string; permissionMode?: string }) {
485 if (input.permissionMode) this.permissionMode = input.permissionMode
486 if (input.promptId && this.pending && !this.pending.isNotice) this.pending.promptId = input.promptId
487 }
488
489 noteMode(mode: unknown) {
490 if (typeof mode === 'string' && mode !== '') this.permissionMode = mode
491 }
492
493 private openRun(p: Pending | null, now: number): Run {
494 this.seq += 1
495 const sessionId = this.session?.sessionId ?? 'session'
496 let id = p?.promptId ?? `${sessionId}:${now}`
497 if (this.runs.has(id)) id = `${id}:${this.seq}`
498 const run: Run = {
499 id,
500 seq: this.seq,
501 startedAt: now,
502 endedAt: null,
503 isMainOpen: true,
504 origin: p?.origin ?? 'unknown',
505 promptChars: p?.chars ?? 0,
506 command: p?.command ?? null,
507 skills: [],
508 skillWork: [],
509 workCalls: 0,
510 editedDirs: [],
511 readDirs: [],
512 job: null,
513 contextStart: null,
514 permissionMode: this.permissionMode,
515 liveUsd: 0,
516 pricedSteps: 0,
517 unpricedSteps: 0,
518 mainSteps: 0,
519 stepsBegun: 0,
520 earlyTools: [],
521 tasks: { done: 0, total: 0 },
522 notifications: 0,
523 model: null,
524 effort: null,
525 waitSpans: [],
526 done: [],
527 closedToolMs: 0,
528 eta: null,
529 checkpoints: [],
530 }
531 this.runs.set(id, run)
532 this.currentRunId = id
533 return run
534 }
535
536 /** The run a background task's notification belongs to. */
537 private ownerOf(taskId: string | null): Run | undefined {
538 if (taskId !== null) {
539 const byTask = this.tasks.get(taskId)
540 if (byTask !== undefined) return this.runs.get(byTask)
541 if (this.agents.has(taskId)) {
542 const runId = this.runOf(taskId)
543 if (runId !== null) return this.runs.get(runId)
544 }
545 }
546 // Unknown task: the newest run with a background agent still working, else with any, else the current one.
547 const newestFirst = [...this.runs.values()].reverse()
548 for (const isOpenOnly of [true, false]) {
549 for (const run of newestFirst) {
550 for (const a of this.agents.values()) if (a.background && a.runId === run.id && (a.isOpen || !isOpenOnly)) return run
551 }
552 }
553 return this.currentRun() ?? newestFirst[0]
554 }
555
556 /** The main loop's turn began. Returns the run when this turn opened a new one. */
557 mainTurnStarted(input: { turnId: string; text: string; now: number }): Run | null {
558 const p = this.pending
559 this.pending = null
560 const isNotice = p ? p.isNotice : isNoticeText(input.text)
561 let opened: Run | null = null
562 if (isNotice) {
563 const owner = this.ownerOf(p?.taskId ?? taskIdOf(input.text))
564 if (owner) {
565 this.currentRunId = owner.id
566 owner.isMainOpen = true
567 owner.notifications += 1
568 } else opened = this.openRun(p, input.now)
569 } else if (p !== null || this.currentRun() === undefined) {
570 opened = this.openRun(p, input.now)
571 } else {
572 const run = this.currentRun()
573 if (run) run.isMainOpen = true
574 }
575 this.loops.set('main', { key: 'main', agentId: null, turnId: input.turnId, startedAt: input.now, steps: [], tools: [], compactions: this.pendingCompactions.splice(0) })
576 return opened
577 }
578
579 setRunContext(runId: string, ctx: { contextStart?: number | null; job?: string | null }) {
580 const run = this.runs.get(runId)
581 if (!run) return
582 if (ctx.contextStart !== undefined) run.contextStart = ctx.contextStart
583 if (ctx.job !== undefined) run.job = ctx.job
584 }
585
586 // ---------------------------------------------------------------- agents
587
588 /** The run an agent belongs to: its own, else its parent's, walking up to the root. */
589 runOf(agentId: string | null | undefined, depth = 0): string | null {
590 if (agentId === null || agentId === undefined || agentId === '') return this.currentRunId
591 const a = this.agents.get(agentId)
592 if (!a) return this.currentRunId
593 if (a.runId !== null) return a.runId
594 if (a.parentAgentId !== null && depth < 32) return this.runOf(a.parentAgentId, depth + 1)
595 return this.currentRunId
596 }
597
598 /** agent.spawn resolved: the agent joins its spawner's run (main's current run, or its parent agent's). */
599 spawned(input: { agentId: string; type: string; model: string; parentAgentId?: string; background: boolean; permissionMode?: string }) {
600 const parent = input.parentAgentId ?? null
601 const runId = parent !== null ? this.runOf(parent) : this.currentRunId
602 const was = this.agents.get(input.agentId)
603 this.agents.set(input.agentId, {
604 agentId: input.agentId,
605 runId,
606 parentAgentId: parent,
607 type: input.type || 'agent',
608 model: input.model || was?.model || '',
609 background: input.background,
610 permissionMode: input.permissionMode ?? null,
611 isOpen: was?.isOpen ?? false,
612 })
613 if (runId !== null) this.tasks.set(input.agentId, runId)
614 }
615
616 agentTurnStarted(input: { agentId: string; turnId: string; now: number }) {
617 const a = this.agents.get(input.agentId)
618 if (a) a.isOpen = true
619 else {
620 // Its spawn has not resolved yet: it most likely started in the current run; the spawn corrects it.
621 this.agents.set(input.agentId, {
622 agentId: input.agentId,
623 runId: this.currentRunId,
624 parentAgentId: null,
625 type: 'agent',
626 model: '',
627 background: false,
628 permissionMode: null,
629 isOpen: true,
630 })
631 }
632 this.loops.set(input.agentId, { key: input.agentId, agentId: input.agentId, turnId: input.turnId, startedAt: input.now, steps: [], tools: [], compactions: [] })
633 }
634
635 private loopFor(agentId: string | null | undefined, now: number, turnId = ''): Loop {
636 const key = agentId ?? 'main'
637 let loop = this.loops.get(key)
638 if (!loop) {
639 loop = { key, agentId: agentId ?? null, turnId, startedAt: now, steps: [], tools: [], compactions: [] }
640 this.loops.set(key, loop)
641 if (agentId) {
642 const a = this.agents.get(agentId)
643 if (a) a.isOpen = true
644 }
645 }
646 return loop
647 }
648
649 // ---------------------------------------------------------------- model requests
650
651 beginStep(input: { agentId?: string; turnId: string; index: number; model: string; effort?: Effort; now: number }): LiveStep {
652 const loop = this.loopFor(input.agentId, input.now, input.turnId)
653 const step: LiveStep = {
654 loop: loop.key,
655 agentId: input.agentId ?? null,
656 index: input.index,
657 startedAt: input.now,
658 endedAt: null,
659 firstAt: null,
660 stopAt: null,
661 model: input.model,
662 ...(input.effort !== undefined ? { effort: input.effort } : {}),
663 usage: null,
664 thinking: 0,
665 stop: null,
666 }
667 if (loop.steps.length < MAX_STEPS) loop.steps.push(step)
668 if (step.agentId === null) {
669 const run = this.currentRun()
670 if (run) run.stepsBegun += 1
671 if (run && run.model === null) {
672 run.model = input.model
673 run.effort = input.effort ?? null
674 }
675 }
676 return step
677 }
678
679 /** The stream ended (or was cut): the step's usage feeds the run's live total. */
680 endStep(step: LiveStep, input: { now: number; usage: Usage | null; stop: StopReason }) {
681 step.endedAt = input.now
682 if (input.usage) step.usage = input.usage
683 if (input.stop !== null) step.stop = input.stop
684 const runId = step.agentId === null ? this.currentRunId : this.runOf(step.agentId)
685 const run = runId === null ? undefined : this.runs.get(runId)
686 if (!run) return
687 if (step.agentId === null) run.mainSteps += 1
688 if (step.usage) {
689 const { usd } = costOf(step.usage.model || step.model, step.usage, { cacheWriteTtl: this.cfg.cacheWriteTtl })
690 if (usd !== null) {
691 run.liveUsd += usd
692 run.pricedSteps += 1
693 } else run.unpricedSteps += 1
694 }
695 }
696
697 // ---------------------------------------------------------------- tools
698
699 checked(toolUseId: string, decision: string) {
700 this.checks.set(toolUseId, decision)
701 }
702
703 toolStarted(input: { id: string; agentId?: string; tool: string; input: unknown; now: number }) {
704 // A main-loop call with no main turn open (a `!` command, another plugin's call) is no run's work, and opening a loop for it would keep the clock ticking while idle.
705 if (!input.agentId && !this.loops.has('main') && !this.currentRun()?.isMainOpen) return
706 const loop = this.loopFor(input.agentId, input.now)
707 const runId = input.agentId ? this.runOf(input.agentId) : this.currentRunId
708 const run = runId === null ? undefined : this.runs.get(runId)
709 const isMain = !input.agentId
710 const args = isObject(input.input) ? input.input : {}
711 const info: LiveTool['info'] = {}
712 const cwd = this.session?.cwd ?? ''
713 const where = toolPath(input.tool, args)
714 if (where) {
715 const dir = folderOf(where.path, where.isDir, cwd)
716 info.dir = dir
717 if (run) pushCapped(EDIT_TOOLS.has(input.tool) ? run.editedDirs : run.readDirs, dir)
718 }
719 const lines = linesChanged(input.tool, args)
720 if (lines) info.lines = lines
721 if (SHELL_TOOLS.has(input.tool)) {
722 const head = commandHead(args.command)
723 if (head !== '') info.cmd = head
724 if (args.run_in_background === true) info.background = true
725 }
726 if (AGENT_TOOLS.has(input.tool) && args.run_in_background === true) info.background = true
727 if (input.tool === 'Skill' && typeof args.skill === 'string') {
728 info.skill = args.skill
729 if (isMain && run) {
730 run.skills.push(args.skill)
731 run.skillWork.push(run.workCalls)
732 }
733 } else if (isMain && run) {
734 run.workCalls++
735 }
736 if (isMain && run && run.stepsBegun <= SHAPE_STEPS) run.earlyTools.push(input.tool)
737 if ((input.tool === 'Agent' || input.tool === 'Task') && typeof args.subagent_type === 'string') info.agentType = args.subagent_type
738 if (input.tool === 'TodoWrite' && Array.isArray(args.todos)) {
739 const total = args.todos.length
740 const done = args.todos.filter(t => isObject(t) && t.status === 'completed').length
741 info.tasks = [done, total]
742 if (run) run.tasks = { done, total }
743 }
744 if (run && input.tool === 'TaskCreate') run.tasks.total += 1
745 if (run && input.tool === 'TaskUpdate') {
746 if (args.status === 'completed') run.tasks.done += 1
747 if (args.status === 'deleted') run.tasks.total = Math.max(0, run.tasks.total - 1)
748 }
749 const tool: LiveTool = {
750 id: input.id,
751 loop: loop.key,
752 name: input.tool,
753 startedAt: input.now,
754 endedAt: null,
755 failed: false,
756 asked: false,
757 dialog: false,
758 runningAt: null,
759 info,
760 }
761 this.liveTools.set(input.id, tool)
762 if (loop.tools.length < MAX_TOOLS) loop.tools.push(tool)
763 }
764
765 toolEnded(input: { id: string; now: number; failed: boolean; backgroundTaskId?: string }) {
766 const tool = this.liveTools.get(input.id)
767 if (!tool) return
768 this.liveTools.delete(input.id)
769 tool.endedAt = input.now
770 tool.failed = input.failed
771 if (this.checks.get(input.id) === 'ask') tool.asked = true
772 this.checks.delete(input.id)
773 if (input.backgroundTaskId) {
774 const loop = this.loops.get(tool.loop)
775 const runId = loop?.agentId ? this.runOf(loop.agentId) : this.currentRunId
776 if (runId !== null) this.tasks.set(input.backgroundTaskId, runId)
777 }
778 }
779
780 /**
781 * classic.PermissionRequest: a dialog put a call to the person. The hook
782 * carries no tool_use_id, so the newest open call of that tool in that
783 * loop is the one.
784 */
785 dialogShown(input: { tool: string; agentId?: string }) {
786 const key = input.agentId ?? 'main'
787 let found: LiveTool | undefined
788 for (const t of this.liveTools.values()) if (t.endedAt === null && t.loop === key && t.name === input.tool && !t.dialog) found = t
789 if (found) found.dialog = true
790 }
791
792 /** The call's progress row drew, so it runs: whoever was asked has answered. */
793 toolRunning(toolUseId: string) {
794 const t = this.liveTools.get(toolUseId)
795 if (t && t.runningAt === null) t.runningAt = Math.max(t.startedAt, this.lastTick ?? t.startedAt)
796 }
797
798 /** The permission mode the loop runs under: the session's latest for the main loop. */
799 private modeOf(loopKey: string): string | null {
800 if (loopKey !== 'main') {
801 const a = this.agents.get(loopKey)
802 if (a?.permissionMode) return a.permissionMode
803 }
804 return this.permissionMode ?? this.currentRun()?.permissionMode ?? null
805 }
806
807 /**
808 * Open and held by the person: a question or a plan to approve, or a call
809 * put to a permission dialog. In auto mode an ask goes to the classifier
810 * first, so it counts only once the dialog is shown.
811 */
812 isHeld(t: LiveTool): boolean {
813 if (t.endedAt !== null) return false
814 if (WAIT_TOOLS.has(t.name)) return true
815 if (t.runningAt !== null || this.checks.get(t.id) !== 'ask') return false
816 return t.dialog || this.modeOf(t.loop) !== 'auto'
817 }
818
819 /** The part of a call the person (or the auto-mode decider) held, as turnCompleted counts it. */
820 private heldSpan(t: LiveTool, now: number): Span | null {
821 if (WAIT_TOOLS.has(t.name)) return [t.startedAt, t.endedAt ?? now]
822 if (t.endedAt !== null) {
823 if (!t.asked) return null
824 const ms = t.endedAt - t.startedAt
825 const wait = t.runMs !== undefined ? Math.max(0, ms - t.runMs) : ms
826 return wait > 0 ? [t.startedAt, t.startedAt + wait] : null
827 }
828 if (this.isHeld(t)) return [t.startedAt, now]
829 if (t.runningAt !== null && this.checks.get(t.id) === 'ask') return [t.startedAt, t.runningAt]
830 return null
831 }
832
833 /** The run's estimate at submit; replaced while its first two main requests settle the task. */
834 setEstimate(runId: string, eta: StoredEta) {
835 const run = this.runs.get(runId)
836 if (run && (run.eta === null || run.mainSteps <= 2)) run.eta = eta
837 }
838
839 /** A step-time estimate of the whole run, made `atMs` of agent time in. */
840 checkpoint(runId: string, mid: MidEta) {
841 const run = this.runs.get(runId)
842 if (!run || run.checkpoints.length >= MAX_CHECKPOINTS) return
843 run.checkpoints.push(mid)
844 }
845
846 /** PostToolUse's duration_ms: the tool's own time, the permission prompt left out. */
847 toolRan(toolUseId: string, durationMs: unknown) {
848 if (typeof durationMs !== 'number' || !Number.isFinite(durationMs)) return
849 for (const loop of this.loops.values()) {
850 const tool = loop.tools.find(t => t.id === toolUseId)
851 if (tool) {
852 tool.runMs = Math.max(0, durationMs)
853 return
854 }
855 }
856 }
857
858 compacted(input: { agentId?: string; trigger: string; before?: number; after?: number; now: number }) {
859 if (input.trigger === 'precompute') return
860 const entry: Compaction = {
861 trigger: input.trigger,
862 ...(input.before !== undefined ? { before: input.before } : {}),
863 ...(input.after !== undefined ? { after: input.after } : {}),
864 }
865 const loop = this.loops.get(input.agentId ?? 'main')
866 // Outside a turn no loop is opened (it would keep the clock ticking while idle): a main one waits for the next main turn.
867 if (loop) loop.compactions.push(entry)
868 else if (!input.agentId) this.pendingCompactions.push(entry)
869 }
870
871 // ---------------------------------------------------------------- the clock
872
873 /** A one-second tick: a gap well past the period means the machine slept. */
874 tick(now: number) {
875 if (this.lastTick !== null && now - this.lastTick > this.cfg.sleepGapMs) {
876 this.sleeps.push([this.lastTick + this.cfg.tickMs, now])
877 if (this.sleeps.length > MAX_SLEEPS) this.sleeps = this.sleeps.slice(-MAX_SLEEPS)
878 }
879 this.lastTick = now
880 }
881
882 stopTicking() {
883 this.lastTick = null
884 }
885
886 isIdle(): boolean {
887 return this.loops.size === 0
888 }
889
890 // ---------------------------------------------------------------- the row
891
892 taskFor(runId: string): RunTask | undefined {
893 const run = this.runs.get(runId)
894 if (!run) return undefined
895 const c = classifyRun({
896 skills: run.skills,
897 skillWork: run.skillWork,
898 projectsFolder: this.cfg.projectsFolder,
899 command: run.command,
900 editedPaths: run.editedDirs,
901 readPaths: run.readDirs,
902 ignore: this.cfg.ignoreSkills,
903 })
904 return { ...c, job: run.job, promptChars: run.promptChars, origin: run.origin, runStart: run.startedAt }
905 }
906
907 /** turn.complete: the loop's turn becomes one row. */
908 turnCompleted(input: { agentId?: string; turnId: string; durationMs: number; reason: string; usage?: Usage; now: number }): TurnRow {
909 const key = input.agentId ?? 'main'
910 const loop = this.loops.get(key)
911 this.loops.delete(key)
912 const end = input.now
913 const duration = Number.isFinite(input.durationMs) && input.durationMs > 0 ? input.durationMs : end - (loop?.startedAt ?? end)
914 const start = end - duration
915 const window: Span = [start, end]
916 const agent = input.agentId ? this.agents.get(input.agentId) : undefined
917 if (agent) agent.isOpen = false
918 let runId = input.agentId ? this.runOf(input.agentId) : this.currentRunId
919 if (runId === null) runId = this.openRun(null, start).id
920 const run = this.runs.get(runId)
921 if (!input.agentId && run) {
922 run.isMainOpen = false
923 run.endedAt = end
924 }
925 const ttl = { cacheWriteTtl: this.cfg.cacheWriteTtl }
926 const at = (t: number) => Math.max(0, round(t - start))
927
928 // Model requests.
929 const steps: StepRow[] = (loop?.steps ?? []).map(s => {
930 const stepEnd = s.endedAt ?? end
931 const tokens = s.usage ? tokensOf(s.usage) : null
932 const model = s.usage?.model || s.model
933 const writing = s.firstAt !== null && s.stopAt !== null ? s.stopAt - s.firstAt : 0
934 const row: StepRow = {
935 at: at(s.startedAt),
936 model,
937 ...(s.effort !== undefined ? { effort: s.effort } : {}),
938 ms: round(stepEnd - s.startedAt),
939 firstTokenMs: s.firstAt === null ? null : round(s.firstAt - s.startedAt),
940 tokensPerSec: tokens && writing >= 100 ? Math.round((tokens.output / (writing / 1000)) * 10) / 10 : null,
941 tokens,
942 usd: s.usage ? roundUsd(costOf(model, s.usage, ttl).usd) : null,
943 ...(s.thinking > 0 ? { thinkingChars: s.thinking } : {}),
944 stop: s.stop,
945 }
946 return row
947 })
948
949 // Tool calls, with the time the person held each.
950 const waitSpans: Span[] = []
951 const runSpans: Span[] = []
952 const tools: ToolRow[] = (loop?.tools ?? []).map(t => {
953 this.liveTools.delete(t.id)
954 const toolEnd = t.endedAt ?? end
955 const ms = Math.max(0, toolEnd - t.startedAt)
956 const isWaitTool = WAIT_TOOLS.has(t.name)
957 const asked = t.asked || isWaitTool
958 const waitMs = isWaitTool ? ms : asked ? (t.runMs !== undefined ? Math.max(0, ms - t.runMs) : ms) : 0
959 if (waitMs > 0) waitSpans.push([t.startedAt, t.startedAt + waitMs])
960 if (ms - waitMs > 0) runSpans.push([t.startedAt + waitMs, toolEnd])
961 const row: ToolRow = {
962 at: at(t.startedAt),
963 name: t.name,
964 ms: round(ms),
965 ...(t.runMs !== undefined ? { runMs: round(t.runMs) } : {}),
966 ...(waitMs > 0 ? { waitMs: round(waitMs) } : {}),
967 ...(asked ? { asked: true as const } : {}),
968 ...(t.failed ? { error: true as const } : {}),
969 ...(t.endedAt === null ? { cut: true as const } : {}),
970 ...t.info,
971 }
972 return row
973 })
974
975 // Where the time went.
976 const sleepIn = clip(this.sleeps, window)
977 const sleepMs = lengthOf(sleepIn)
978 const waitMs = lengthOf(subtract(clip(waitSpans, window), this.sleeps))
979 const stepSpans: Span[] = (loop?.steps ?? []).map(s => [s.startedAt, s.endedAt ?? end])
980 const modelMs = lengthOf(subtract(clip(stepSpans, window), this.sleeps))
981 const toolMs = lengthOf(subtract(clip(runSpans, window), this.sleeps))
982 const agentMs = Math.max(0, duration - waitMs - sleepMs)
983
984 // What it cost: the engine's turn usage, else the requests' own (an interrupted turn has none).
985 const model = input.usage?.model || steps[steps.length - 1]?.model || agent?.model || ''
986 let tokens: Tokens
987 let usd: number | null
988 let isUnpriced = false
989 if (input.usage) {
990 tokens = tokensOf(input.usage)
991 const cost = costOf(model, input.usage, ttl)
992 usd = cost.usd
993 isUnpriced = cost.unpriced
994 } else {
995 tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
996 usd = 0
997 for (const s of steps) {
998 if (!s.tokens) continue
999 tokens.input += s.tokens.input
1000 tokens.output += s.tokens.output
1001 tokens.cacheRead += s.tokens.cacheRead
1002 tokens.cacheWrite += s.tokens.cacheWrite
1003 if (s.usd === null) isUnpriced = true
1004 else if (usd !== null) usd += s.usd
1005 }
1006 if (isUnpriced) usd = null
1007 }
1008
1009 // Conditions.
1010 const first = steps[0]?.tokens ?? null
1011 const firstInput = first ? first.input + first.cacheRead + first.cacheWrite : 0
1012 const startDate = new Date(start)
1013 const compactions = loop?.compactions ?? []
1014 const cond: Conditions = {
1015 contextStart: run?.contextStart ?? null,
1016 contextTurn: first ? firstInput : null,
1017 cache: first === null || firstInput === 0 ? null : first.cacheRead / firstInput >= 0.5 ? 'warm' : 'cold',
1018 hour: startDate.getHours(),
1019 weekday: startDate.getDay(),
1020 tzOffsetMin: startDate.getTimezoneOffset(),
1021 permissionMode: agent?.permissionMode ?? run?.permissionMode ?? this.permissionMode,
1022 version: this.session?.version ?? null,
1023 cwdName: this.session?.cwdName ?? '',
1024 ...(compactions.length > 0 ? { compactions } : {}),
1025 }
1026
1027 const effort = loop?.steps.find(s => s.effort !== undefined)?.effort ?? null
1028 const task = this.taskFor(runId) ?? { taskType: 'freeform', skill: null, command: null, project: null, job: null, promptChars: 0, origin: 'unknown', runStart: start }
1029 const row: TurnRow = {
1030 v: 1,
1031 kind: 'turn',
1032 src: 'live',
1033 machineId: this.session?.machineId ?? '',
1034 sessionId: this.session?.sessionId ?? '',
1035 runId,
1036 turnId: input.turnId,
1037 agentId: input.agentId ?? null,
1038 parentAgentId: agent?.parentAgentId ?? null,
1039 agentType: input.agentId ? (agent?.type ?? 'agent') : null,
1040 background: input.agentId ? (agent?.background ?? false) : null,
1041 model,
1042 effort,
1043 start: round(start),
1044 end: round(end),
1045 ms: round(duration),
1046 agentMs: round(agentMs),
1047 modelMs: round(modelMs),
1048 toolMs: round(toolMs),
1049 waitMs: round(waitMs),
1050 sleepMs: round(sleepMs),
1051 outcome: outcomeOf(input.reason),
1052 tokens,
1053 usd: roundUsd(usd),
1054 ...(isUnpriced ? { unpriced: true as const } : {}),
1055 steps: steps.map(s => {
1056 const { model: m, effort: x, ...rest } = s
1057 return { ...rest, ...(m !== model ? { model: m } : {}), ...(x !== undefined && x !== effort ? { effort: x } : {}) }
1058 }),
1059 tools,
1060 ...(sleepIn.length > 0 ? { sleeps: sleepIn.map(([a, b]) => [at(a), round(b - a)] as [number, number]) } : {}),
1061 errors: {
1062 tools: tools.filter(t => t.error).length,
1063 steps: steps.filter(s => s.tokens === null && s.stop === null).length,
1064 },
1065 task,
1066 cond,
1067 }
1068
1069 // The run's running totals: its waits, who did what, and the estimate it began with.
1070 if (run) {
1071 run.waitSpans.push(...clip(waitSpans, window))
1072 if (run.waitSpans.length > MAX_WAIT_SPANS) run.waitSpans = run.waitSpans.slice(-MAX_WAIT_SPANS)
1073 const inAgentsMs = tools.filter(t => AGENT_TOOLS.has(t.name) && !t.background).reduce((s, t) => s + Math.max(0, t.ms - (t.waitMs ?? 0)), 0)
1074 if (run.done.length < MAX_DONE) {
1075 run.done.push({
1076 key,
1077 agentId: input.agentId ?? null,
1078 type: row.agentType,
1079 model,
1080 ms: round(Math.max(0, agentMs - inAgentsMs)),
1081 tokens: tokens.input + tokens.cacheWrite + tokens.output,
1082 cost: row.usd === null ? { usd: 0, priced: 0, unpriced: 1 } : { usd: row.usd, priced: 1, unpriced: 0 },
1083 })
1084 }
1085 if (!input.agentId) {
1086 run.closedToolMs += tools.filter(t => !AGENT_TOOLS.has(t.name)).reduce((s, t) => s + Math.max(0, t.ms - (t.waitMs ?? 0)), 0)
1087 if (run.eta) row.eta = run.eta
1088 const half = this.runTimes(run, end).agentMs / 2
1089 let mid: MidEta | undefined
1090 for (const c of run.checkpoints) if (!mid || Math.abs(c.atMs - half) < Math.abs(mid.atMs - half)) mid = c
1091 if (mid) row.etaMid = { atMs: round(mid.atMs), usualMs: round(mid.usualMs), slowMs: round(mid.slowMs) }
1092 }
1093 }
1094 this.pruneSleeps()
1095 if (!input.agentId) this.pruneRuns()
1096 return row
1097 }
1098
1099 /**
1100 * Drops finished runs past the newest KEEP_RUNS, with their closed agents
1101 * and task ids, so a session of thousands of runs holds a bounded amount.
1102 * A run with a loop still working or an agent still open is kept.
1103 */
1104 pruneRuns() {
1105 const finished = [...this.runs.values()].filter(r => !r.isMainOpen && r.id !== this.currentRunId)
1106 if (finished.length <= KEEP_RUNS) return
1107 const busy = new Set<string>()
1108 for (const a of this.agents.values()) if (a.isOpen && a.runId !== null) busy.add(a.runId)
1109 for (const loop of this.loops.values()) {
1110 const id = loop.agentId === null ? this.currentRunId : this.runOf(loop.agentId)
1111 if (id !== null) busy.add(id)
1112 }
1113 const gone = new Set<string>()
1114 for (const run of finished.slice(0, finished.length - KEEP_RUNS)) {
1115 if (busy.has(run.id)) continue
1116 this.runs.delete(run.id)
1117 gone.add(run.id)
1118 }
1119 if (gone.size === 0) return
1120 for (const [id, a] of this.agents) if (!a.isOpen && a.runId !== null && gone.has(a.runId)) this.agents.delete(id)
1121 for (const [task, runId] of this.tasks) if (gone.has(runId)) this.tasks.delete(task)
1122 for (const id of this.checks.keys()) if (!this.liveTools.has(id)) this.checks.delete(id)
1123 }
1124
1125 private pruneSleeps() {
1126 let oldest = Infinity
1127 for (const loop of this.loops.values()) oldest = Math.min(oldest, loop.startedAt)
1128 if (oldest === Infinity) this.sleeps = []
1129 else this.sleeps = this.sleeps.filter(s => s[1] >= oldest)
1130 }
1131
1132 // ---------------------------------------------------------------- what the band and the pane draw
1133
1134 /** The loops working for the run now. */
1135 private loopsOf(run: Run): Loop[] {
1136 const out: Loop[] = []
1137 for (const loop of this.loops.values()) {
1138 const id = loop.agentId === null ? this.currentRunId : this.runOf(loop.agentId)
1139 if (id === run.id) out.push(loop)
1140 }
1141 return out
1142 }
1143
1144 /** Where the run's time went so far: wall less sleep, the person's holds, and the rest. */
1145 private runTimes(run: Run, now: number): { end: number; elapsedMs: number; waitMs: number; agentMs: number } {
1146 const end = run.isMainOpen ? now : (run.endedAt ?? now)
1147 const window: Span = [run.startedAt, end]
1148 const sleepMs = overlapMs(this.sleeps, window)
1149 const held: Span[] = [...run.waitSpans]
1150 for (const loop of this.loopsOf(run)) {
1151 for (const t of loop.tools) {
1152 const span = this.heldSpan(t, end)
1153 if (span) held.push(span)
1154 }
1155 }
1156 const waitMs = lengthOf(subtract(clip(held, window), this.sleeps))
1157 const elapsedMs = Math.max(0, end - run.startedAt - sleepMs)
1158 return { end, elapsedMs, waitMs, agentMs: Math.max(0, elapsedMs - waitMs) }
1159 }
1160
1161 /** An open loop's share so far, as turnCompleted will count it. */
1162 private openPart(run: Run, loop: Loop, now: number): WhoPart & { isOpen: boolean } {
1163 const agent = loop.agentId ? this.agents.get(loop.agentId) : undefined
1164 const out: Span[] = [...this.sleeps]
1165 for (const t of loop.tools) {
1166 const span = this.heldSpan(t, now)
1167 if (span) out.push(span)
1168 if (AGENT_TOOLS.has(t.name) && !t.info.background) out.push([t.startedAt, t.endedAt ?? now])
1169 }
1170 let tokens = 0
1171 const cost: Cost = { usd: 0, priced: 0, unpriced: 0 }
1172 let model = ''
1173 for (const s of loop.steps) {
1174 model = s.usage?.model || s.model || model
1175 if (!s.usage) continue
1176 const t = tokensOf(s.usage)
1177 tokens += t.input + t.cacheWrite + t.output
1178 const usd = costOf(s.usage.model || s.model, s.usage, { cacheWriteTtl: this.cfg.cacheWriteTtl }).usd
1179 if (usd !== null) {
1180 cost.usd += usd
1181 cost.priced += 1
1182 } else cost.unpriced += 1
1183 }
1184 if (loop.agentId === null) model = run.model ?? model
1185 else model = agent?.model || model
1186 return {
1187 key: loop.key,
1188 agentId: loop.agentId,
1189 type: loop.agentId ? (agent?.type ?? 'agent') : null,
1190 model,
1191 ms: round(lengthOf(subtract([[loop.startedAt, now]], out))),
1192 tokens,
1193 cost,
1194 isOpen: true,
1195 }
1196 }
1197
1198 /** The running run as the band and the pane need it: elapsed, holds, cost so far, what runs now, who is working. */
1199 liveView(now: number): LiveView | null {
1200 const run = this.currentRun()hooks/view.ts 92 lines1import { familyOf } from '../lib/price.mjs'
2import type { EtaCost, EtaIdle, EtaLastRun, EtaWho, EtaWorking } from '../types'
3import { costOfRun, startOfDay } from './predict'
4import type { Estimate, Profile, ProfileRun } from './predict'
5import type { LiveView } from './record'
6
7// The one value the band and the pane draw from, built from the recorder's
8// live view, the estimate and the profile. Plain data: the render hooks only
9// read it, so a redraw is cheap and never writes.
10
11const addCost = (to: EtaCost, c: EtaCost) => {
12 to.usd += c.usd
13 to.priced += c.priced
14 to.unpriced += c.unpriced
15}
16
17/** The pane's WHO rows: the main loop, then the subagents grouped by model, busiest first. */
18export function whoRows(live: LiveView): EtaWho[] {
19 const main: EtaWho = { isMain: true, label: 'main', model: familyOf(live.model ?? ''), count: 1, ms: 0, tokens: 0, cost: { usd: 0, priced: 0, unpriced: 0 }, isOpen: live.isMainOpen }
20 const groups = new Map<string, EtaWho>()
21 for (const p of live.who) {
22 if (p.agentId === null) {
23 main.ms += p.ms
24 main.tokens += p.tokens
25 addCost(main.cost, p.cost)
26 if (main.model === 'other' && p.model) main.model = familyOf(p.model)
27 continue
28 }
29 const family = familyOf(p.model)
30 const row = groups.get(family) ?? { isMain: false, label: family, model: family, count: 0, ms: 0, tokens: 0, cost: { usd: 0, priced: 0, unpriced: 0 }, isOpen: false }
31 row.count += 1
32 row.ms += p.ms
33 row.tokens += p.tokens
34 addCost(row.cost, p.cost)
35 row.isOpen = row.isOpen || p.isOpen
36 groups.set(family, row)
37 }
38 return [main, ...[...groups.values()].sort((a, b) => b.ms - a.ms)]
39}
40
41export function workingView(live: LiveView, est: Estimate, ctx: { contextTokens: number | null }): EtaWorking {
42 return {
43 kind: 'working',
44 runId: live.runId,
45 taskType: live.task?.taskType ?? 'freeform',
46 project: live.task?.project ?? null,
47 job: live.task?.job ?? null,
48 state: est.state,
49 isWaiting: live.isWaitingOnYou,
50 learning: est.learning,
51 elapsedMs: Math.round(live.agentMs),
52 leftMs: est.leftMs === null ? null : Math.round(est.leftMs),
53 leftSlowMs: est.leftSlowMs === null ? null : Math.round(est.leftSlowMs),
54 totalMs: est.totalMs === null ? null : Math.round(est.totalMs),
55 submit: est.submit ? { usualMs: Math.round(est.submit.usualMs), slowMs: Math.round(est.submit.slowMs) } : null,
56 n: est.n,
57 onTimePct: est.coverage === null ? null : Math.round(est.coverage * 100),
58 scored: est.scored,
59 now: live.now ? { name: live.now.name, detail: live.now.detail, sinceMs: Math.round(live.now.sinceMs) } : null,
60 who: whoRows(live),
61 split: { agentMs: Math.max(0, Math.round(live.agentMs - live.toolMs)), toolMs: Math.round(live.toolMs), waitMs: Math.round(live.waitMs) },
62 speed: est.speed,
63 context: { tokens: ctx.contextTokens, cache: live.cache },
64 cost: { ...live.cost },
65 }
66}
67
68export function lastRunOf(run: ProfileRun): EtaLastRun {
69 const eta = run.eta
70 const est = eta && eta.usualMs !== null && eta.slowMs !== null ? { usualMs: eta.usualMs, slowMs: eta.slowMs } : null
71 return {
72 taskType: run.taskType,
73 agentMs: Math.round(run.agentMs),
74 cost: costOfRun(run),
75 est,
76 verdict: est ? (run.agentMs <= est.slowMs ? 'on time' : 'late') : null,
77 learning: eta?.learning ?? null,
78 }
79}
80
81export function idleView(profile: Profile, now: number, job: string | null): EtaIdle {
82 const last = profile.lastRun(null)
83 const midnight = startOfDay(now)
84 return {
85 kind: 'idle',
86 last: last ? lastRunOf(last) : null,
87 today: profile.totals(r => r.startedAt >= midnight),
88 job: job ? { name: job, ...profile.totals(r => r.job === job) } : null,
89 learnedRuns: profile.history.length + profile.session.length,
90 }
91}
92hooks/layout.ts 223 lines1import type { EtaLook, EtaView, EtaWho, EtaWorking } from '../types'
2import { clock, cost, left, range, rough, tokens } from './fmt'
3
4// The band's segments and the pane's lines, as plain data: no engine calls,
5// so they are tested whole and the layout check prints them at any width.
6// The band: `~4m left · 2:10 in · $0.81 · website-work` while a run works,
7// `last wrap 3:40 (est 3-5m, on time) · today $38` when idle, segments
8// dropped by priority on a narrow terminal. The pane: header, progress, time
9// left, confidence, NOW, WHO, the time split, speed, context, cost.
10
11const BAR_CELLS = 20
12
13export type Part = { text: string; look: EtaLook; bold?: boolean }
14export type Segment = { id: string; priority: number; full: Part[]; short: Part[] }
15export type Fitted = { id: string; parts: Part[] }
16
17export const SEP = ' · '
18
19// Theme keys, not raw colors, so the row follows /theme.
20export const LOOK: Record<EtaLook, { color?: string; dimColor?: boolean }> = {
21 normal: {},
22 dim: { dimColor: true },
23 good: { color: 'success' },
24 warn: { color: 'warning' },
25 bad: { color: 'error' },
26}
27
28const p = (text: string, look: EtaLook = 'normal', bold = false): Part => (bold ? { text, look, bold } : { text, look })
29const seg = (id: string, priority: number, full: Part[], short: Part[] = full): Segment => ({ id, priority, full, short })
30const cut = (text: string, n: number) => ([...text].length <= n ? text : `${[...text].slice(0, n - 1).join('')}~`)
31
32/** The band's segments for a view, steady ones first; none before the profile has loaded. */
33export function bandSegments(view: EtaView): Segment[] {
34 if (view.kind === 'none') return []
35 if (view.kind === 'working') {
36 let countdown: Segment
37 if (view.isWaiting) countdown = seg('eta', 100, [p('waiting on you', 'warn', true)], [p('waiting', 'warn', true)])
38 else if (view.state === 'learning') {
39 const l = view.learning ?? { have: 0, need: 5 }
40 countdown = seg('eta', 100, [p(`learning (${l.have} of ${l.need})`, 'dim')], [p(`learning ${l.have}/${l.need}`, 'dim')])
41 } else if (view.state === 'past-slow' || view.leftMs === null) {
42 const look: EtaLook = view.state === 'past-slow' ? 'bad' : view.state === 'past-usual' ? 'warn' : 'good'
43 const text = view.state === 'on-track' ? 'on track' : 'over usual'
44 countdown = seg('eta', 100, [p(text, look, look === 'bad')], [p(view.state === 'on-track' ? 'on track' : 'over', look, look === 'bad')])
45 } else {
46 const look: EtaLook = view.state === 'past-usual' ? 'warn' : 'good'
47 countdown = seg('eta', 100, [p(`${left(view.leftMs)} left`, look)], [p(left(view.leftMs), look)])
48 }
49 return [
50 countdown,
51 seg('in', 80, [p(clock(view.elapsedMs)), p(' in', 'dim')], [p(clock(view.elapsedMs))]),
52 seg('cost', 60, [p(cost(view.cost))]),
53 seg('task', 40, [p(view.taskType)], [p(cut(view.taskType, 14))]),
54 ]
55 }
56 const out: Segment[] = []
57 const last = view.last
58 if (last) {
59 const head = [p('last ', 'dim'), p(`${last.taskType} `), p(clock(last.agentMs))]
60 const tail: Part[] = []
61 if (last.est && last.verdict) {
62 tail.push(p(` (est ${range(last.est.usualMs, last.est.slowMs)}, `, 'dim'), p(last.verdict, last.verdict === 'on time' ? 'good' : 'warn'), p(')', 'dim'))
63 } else if (last.learning) tail.push(p(` (learning ${last.learning.have} of ${last.learning.need})`, 'dim'))
64 out.push(seg('last', 90, [...head, ...tail], [p('last ', 'dim'), p(`${cut(last.taskType, 14)} `), p(clock(last.agentMs))]))
65 } else out.push(seg('last', 90, [p('vault-eta: no runs logged yet', 'dim')], [p('no runs yet', 'dim')]))
66 out.push(seg('today', 50, [p('today ', 'dim'), p(cost(view.today.cost, true))]))
67 return out
68}
69
70export const partsWidth = (parts: readonly Part[]) => parts.reduce((n, part) => n + [...part.text].length, 0)
71
72function widthOf(list: { parts: Part[] }[]): number {
73 if (list.length === 0) return 0
74 return list.reduce((n, s) => n + partsWidth(s.parts), 0) + SEP.length * (list.length - 1)
75}
76
77/**
78 * Fits the segments into `columns`: every one
79 * full if they fit; else the least important shortened first, then dropped,
80 * so the countdown is the last to go.
81 */
82export function fitBand(segments: readonly Segment[], columns: number): Fitted[] {
83 const chosen = segments.map(s => ({ id: s.id, priority: s.priority, parts: s.full, short: s.short }))
84 const byPriority = [...chosen].sort((a, b) => a.priority - b.priority)
85 for (const s of byPriority) {
86 if (widthOf(chosen) <= columns) break
87 s.parts = s.short
88 }
89 for (const s of byPriority) {
90 if (widthOf(chosen) <= columns || chosen.length <= 1) break
91 chosen.splice(chosen.indexOf(s), 1)
92 }
93 return chosen.map(({ id, parts }) => ({ id, parts }))
94}
95
96/** The band as one plain line, for tests and the layout check. */
97export function bandText(fitted: readonly Fitted[]): string {
98 return fitted.map(s => s.parts.map(part => part.text).join('')).join(SEP)
99}
100
101export type Line = Part[]
102
103export const lineWidth = (line: Line) => line.reduce((n, part) => n + [...part.text].length, 0)
104const thousands = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
105
106const STATE_LOOK = { learning: 'dim', 'on-track': 'good', 'past-usual': 'warn', 'past-slow': 'bad' } as const
107
108/** The countdown as the band says it, with its look. */
109function countdown(v: EtaWorking): Part {
110 if (v.isWaiting) return p('waiting on you', 'warn', true)
111 if (v.state === 'learning') return p(`learning (${v.learning?.have ?? 0} of ${v.learning?.need ?? 5})`, 'dim')
112 if (v.state === 'past-slow') return p('over usual', 'bad', true)
113 if (v.leftMs === null) return v.state === 'on-track' ? p('on track', 'good') : p('over usual', 'warn')
114 return p(`${left(v.leftMs)} left`, STATE_LOOK[v.state])
115}
116
117function whoLine(w: EtaWho): Line {
118 const name = (w.isMain ? `${'main'.padEnd(7)}${w.model}` : `${w.label.padEnd(7)}x${w.count}`).padEnd(14)
119 const usd = w.cost.priced + w.cost.unpriced === 0 ? '--' : cost(w.cost)
120 return [p(name), p(` ${clock(w.ms).padStart(4)}`), p(tokens(w.tokens).padStart(9), 'dim'), p(usd.padStart(9))]
121}
122
123/** The NOW line, its time right-aligned at `room`. */
124function nowLine(v: EtaWorking, room: number): Line {
125 if (!v.now) return [p('NOW', 'normal', true), p(' --', 'dim')]
126 const time = clock(v.now.sinceMs)
127 const head = `NOW ${v.now.name}`
128 let detail = v.now.detail
129 const free = room - head.length - time.length - 4
130 if (detail !== '' && [...detail].length > free) detail = free > 3 ? `${[...detail].slice(0, free - 1).join('')}~` : ''
131 const body = detail === '' ? head : `${head} ${detail}`
132 const pad = Math.max(2, room - body.length - time.length)
133 return [p('NOW', 'normal', true), p(` ${v.now.name}`), ...(detail === '' ? [] : [p(` ${detail}`, 'dim')]), p(' '.repeat(pad)), p(time)]
134}
135
136function workingLines(v: EtaWorking, columns: number): Line[] {
137 const lines: Line[] = []
138 const header: Line = [p(v.taskType, 'normal', true)]
139 if (v.project) header.push(p(' · ', 'dim'), p(v.project))
140 if (v.job) header.push(p(' · ', 'dim'), p(`job ${v.job}`))
141 lines.push(header)
142
143 if (v.state === 'learning' || !v.submit) {
144 lines.push([p(`${clock(v.elapsedMs)} in`), p(' · no estimate yet', 'dim')])
145 lines.push([countdown(v)])
146 const l = v.learning ?? { have: 0, need: 5 }
147 lines.push([p(`${l.have} past runs like this · ${Math.max(0, l.need - l.have)} more to go`, 'dim')])
148 } else {
149 const total = v.totalMs ?? v.submit.usualMs
150 const share = total > 0 ? Math.min(1, v.elapsedMs / total) : 1
151 const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(share * BAR_CELLS)))
152 const look: EtaLook = v.isWaiting ? 'warn' : STATE_LOOK[v.state]
153 lines.push([p('█'.repeat(filled), look), p('░'.repeat(BAR_CELLS - filled), 'dim'), p(` ${clock(v.elapsedMs)} of ~${clock(total)}`)])
154 lines.push([countdown(v), p(` usual ${rough(v.submit.usualMs)} · slow ${rough(v.submit.slowMs)}`, 'dim')])
155 const onTime = v.onTimePct === null ? 'not scored yet' : `${v.onTimePct}% finished on time`
156 lines.push([p(`${v.n} past runs · ${onTime}`, 'dim')])
157 }
158
159 lines.push([])
160 lines.push(nowLine(v, Math.min(columns, 40)))
161 lines.push([])
162 lines.push([p(`${'WHO'.padEnd(14)} time tokens cost`, 'dim')])
163 for (const w of v.who) lines.push(whoLine(w))
164 lines.push([])
165
166 const wait = v.split.waitMs > 0 ? clock(v.split.waitMs) : '0'
167 lines.push([p('agent ', 'dim'), p(clock(v.split.agentMs)), p(' · tools ', 'dim'), p(clock(v.split.toolMs)), p(' · waiting on you ', 'dim'), p(wait, v.isWaiting ? 'warn' : 'normal')])
168 const speed: Line = [p('speed ', 'dim')]
169 if (v.speed.tps === null) speed.push(p('--', 'dim'))
170 else {
171 speed.push(p(`${v.speed.tps} tok/s`))
172 if (v.speed.usualTps !== null) speed.push(p(` (usual ${v.speed.usualTps})`, 'dim'))
173 if (v.speed.etaPct !== null) speed.push(p(' · eta ', 'dim'), p(`${v.speed.etaPct >= 0 ? '+' : ''}${v.speed.etaPct}%`, v.speed.etaPct > 10 ? 'warn' : 'normal'))
174 }
175 lines.push(speed)
176 lines.push([
177 p('context ', 'dim'),
178 p(v.context.tokens === null ? '--' : tokens(v.context.tokens)),
179 p(' · cache ', 'dim'),
180 p(v.context.cache ?? '--', v.context.cache === 'cold' ? 'warn' : 'normal'),
181 ])
182 lines.push([p(cost(v.cost), 'normal', true), p(' so far · at API prices', 'dim')])
183 return lines
184}
185
186/** The pane's lines for a view, each to be cut at the pane's width. */
187export function paneLines(view: EtaView, columns: number): Line[] {
188 if (view.kind === 'working') return workingLines(view, columns)
189 if (view.kind === 'none') return [[p('vault-eta', 'normal', true)], [p('loading the last 90 days...', 'dim')]]
190 const lines: Line[] = [[p('vault-eta', 'normal', true), p(' · idle', 'dim')], []]
191 const last = view.last
192 if (last) {
193 const line: Line = [p('last run ', 'dim'), p(`${last.taskType} `), p(clock(last.agentMs))]
194 if (last.est && last.verdict) {
195 line.push(p(' · est ', 'dim'), p(range(last.est.usualMs, last.est.slowMs)), p(' · ', 'dim'), p(last.verdict, last.verdict === 'on time' ? 'good' : 'warn'))
196 } else if (last.learning) line.push(p(` · learning (${last.learning.have} of ${last.learning.need})`, 'dim'))
197 else line.push(p(' · no estimate', 'dim'))
198 lines.push(line)
199 if (last.cost.priced > 0) lines.push([p(' '), p(cost(last.cost)), p(' at API prices', 'dim')])
200 } else lines.push([p('no runs logged yet', 'dim')])
201 lines.push([])
202 const t = view.today
203 lines.push([p('today ', 'dim'), p(`${t.runs} run${t.runs === 1 ? '' : 's'}`), p(' · ', 'dim'), p(clock(t.ms)), p(' · ', 'dim'), p(cost(t.cost, true))])
204 if (view.job) {
205 const j = view.job
206 lines.push([p('job ', 'dim'), p(`${j.name} `), p(`${j.runs} run${j.runs === 1 ? '' : 's'}`), p(' · ', 'dim'), p(clock(j.ms)), p(' · ', 'dim'), p(cost(j.cost, true))])
207 }
208 lines.push([])
209 lines.push([p(`learned from ${thousands(view.learnedRuns)} past runs (90 days)`, 'dim')])
210 lines.push([p('/eta report', 'normal'), p(' history and accuracy', 'dim')])
211 lines.push([p('all dollars at API prices', 'dim')])
212 return lines
213}
214
215/** The pane as plain lines, for tests and the layout check. */
216export function paneText(view: EtaView, columns: number): string[] {
217 return paneLines(view, columns).map(line => {
218 const text = line.map(part => part.text).join('')
219 return [...text].length > columns ? [...text].slice(0, columns).join('') : text
220 })
221}
222
223