SLOPSHOPPER

vault-eta

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…

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-22MichaelGold11/vault-eta
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vault-eta
│ ┃ ETA ✕ › fix the failing auth test and add an audit log call │ ┃ freeform │ ┃ 0:00 in · no estimate yet ⏺ Read(src/auth.ts) │ ┃ learning (0 of 5) ⎿ Read 6 lines │ ┃ 0 past runs like this · 5 more to go ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ NOW -- ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ WHO time tokens cost │ ┃ main other 0:00 0 -- ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ agent 0:00 · tools 0:00 · waiting on you 0 ✻ Worked for 42s · done 4:20 PM │ ┃ speed -- │ ┃ context 97.4k · cache -- › /eta │ ┃ $0.00 so far · at API prices │ ⟨Claude Code's own drawing⟩ learning (0 of 5) · 0:00 in · $0.00 · freeform ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ learning (0 of 5) · 0:00 in · $0.00 · freeform
Pane · ETA
freeform 0:00 in · no estimate yet learning (0 of 5) 0 past runs like this · 5 more to go NOW -- WHO time tokens cost main other 0:00 0 -- agent 0:00 · tools 0:00 · waiting on you 0 speed -- context 97.4k · cache -- $0.00 so far · at API prices
README

vault-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.

What you see

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

Install

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.

Requirements

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.

Commands

CommandWhat it does
/etaOpens 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 closeOpen 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 endStops tagging and shows the job's total.
/eta reportWrites 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.

Importing past history

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.

How it learns

Plain statistics on local logs. No model is called.

  1. Every run is logged: its task type, project, model, how long the agent worked, the cost and the steps taken.
  2. A new run is matched with past runs of the same task type, project and model (falling back to fewer of those when there are too few). Under five matches it says learning.
  3. At submit it shows the median ("usual") and the 80th percentile ("slow") of the last 50 matches.
  4. As the run goes it re-estimates: of past runs like this one that were still going at this point, how much longer did they take? It also uses steps done, the agent's task list when it writes one, and the model's speed today.
  5. It scores itself by replaying every past run against only the runs that ended before it started (each against the last 500 runs of its task type), and corrects its slow figure toward the 80% target. The score is made when the session starts and again while Claude is idle, never while a run works.

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.

What it stores and where

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.

  • One file per session: <date>-<session>.jsonl, named by the day it started.
  • Past 512 KiB a session goes on in <date>-<session>.part2.jsonl and so on.
  • A session reloaded or resumed on a later day starts a file named by that day.
  • Two sessions open in the same vault write separate files.
  • 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:

  • times: start and end, agent time, model time, tool time, time waiting on you, machine sleep;
  • each model request: when it started, time to the first token, tokens per second, token counts, cost, stop reason, characters of thinking;
  • each tool call: its name, when it started and how long it took, whether it was asked or failed, and the safe facts below;
  • the model, effort, agent type, subagent ids, token counts and cost of the turn;
  • the task labels: the task type (a skill's or slash command's name), the project (a folder name), the job tag;
  • the prompt's length in characters and where it came from (typed, SDK, a background result), never its text;
  • conditions: the permission mode, the hour, weekday and time zone offset, the Claude Code version, the name of the folder the logs live in, the context size, whether the cache was warm, compactions;
  • the estimate the band showed, so the report can score it.

It never stores prompt text, file contents, tool output, error messages or subagent prompts. Tool calls keep only these facts:

  • a shell command: the command's name, plus up to two more words from a fixed list of known subcommands (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;
  • a file tool: the folder inside your vault, up to four levels, never the file's name. A path outside the vault is stored as (outside);
  • an edit: how many lines it added and removed;
  • a skill or subagent call: the skill's name or the agent type, never its arguments or prompt;
  • MCP and other tools: the tool's name only.

Cost

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.

Overhead

  • Model tokens: zero. The mod never calls a model and adds nothing to your prompts.
  • Network: none. It makes no network calls. If your vault sits in a synced folder (iCloud, Dropbox, OneDrive), the sync service uploads .vault-eta like any other folder.
  • Time: two waits remain. 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.
  • It runs no processes. It reads only your folder, the names of the folders above it (to find where .vault-eta belongs), and, for the optional backfill script, Claude Code's own ~/.claude/projects/.

Options

Set them with claude plugin configure vault-eta or in the plugin's settings.

OptionDefaultWhat it does
cacheWriteTtl1hPrices cache writes at the 1 hour or 5 minute rate. Use 5m if your sessions use short caches.
ignoreSkillsemptyComma-separated skills that never name a task. Add one here if a router skill still names runs it should not.
projectsFolderemptyThe 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>.
reportPathvault-eta-report.mdWhere /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).

Uninstall

/plugin uninstall vault-eta

or claude plugin uninstall vault-eta. What stays behind, for you to delete if you want it gone:

  • the .vault-eta folder in your vault (or project folder), with the logs;
  • any report note /eta report wrote (vault-eta-report.md by default);
  • the plugin's store entry in Claude Code's configuration folder, which holds the machine id and the job tag.

Developing

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.

License

MIT. See LICENSE.

Source 21 files
hooks/register.tsx 620 lines
1import { 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}
620
lib/base.mjs 102 lines
1// 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}
102
lib/classify.mjs 444 lines
1// @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}
444
hooks/band.tsx 48 lines
1import { 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}
48
hooks/commands.ts 139 lines
1import 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}
139
hooks/guard.ts 37 lines
1import 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}
37
hooks/log.ts 340 lines
1import 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}
340
hooks/pane.tsx 102 lines
1import { 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}
102
hooks/predict.ts 361 lines
1import { 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}
361
hooks/record.ts 1281 lines
1import {
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 lines
1import { 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}
92
hooks/layout.ts 223 lines
1import 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