Paces your Claude usage limits: today's budget, session and week limits, and where each session stands, in a line above the prompt

A Claude Code mod that paces your usage limits and shows, at a glance, where every session stands, in a line above the prompt.
Colors follow your terminal's palette, with fixed colors for the project name and for red. See every state for what each one means.
paceline (dev) · Opus 5.5 · high · Today: ▕██████░░░░░░░░│ 8/14 · w 43/100 · 4d left · 59m
Today: is today's budget as a bar that drains as you use it: full cells are what's left, and the │ marker is an even day's share of the week. 8/14 is the weekly points you've spent of today's 14. w 43/100 is the week so far, and 4d left is the days until it resets. If your current pace would use up the week before then, 4d left turns into a warning, out ~Tue: the week is forecast to run out on Tuesday. On a normal day, you never see it. See Run-out warnings.
Your weekly limit resets on a fixed schedule, but Claude Code only tells you how much is left, not whether that's a lot or a little for the days remaining. paceline divides what's left by the days until the reset and gives you today's budget, then counts up how much of it you've used as you work. A light week gives you a big budget; a heavy one, a small budget.
This is the mod version of paceline, the Go status line. Its segments come from the same math, checked against the same test fixtures, but it installs as a plugin, draws in the band above the prompt, puts the project first, and says when a usage limit is about to run out.
In a Claude Code session in your terminal, add this repo as a marketplace, then install the plugin from it:
/plugin marketplace add rogadev/paceline-mod
/plugin install paceline@paceline-mod
Pick a scope when asked (user scope puts it in every session). The line appears above the prompt right away.
Requirements:
claude --version to check.claude -p don't draw mods.To hide the line for a while, click the [-] at the right end of the band, or press Ctrl+X then Ctrl+A. To remove it, uninstall the plugin from the Installed tab in /plugin.
| Segment | Example | Shows |
|---|---|---|
| Project | paceline (dev) | The folder, in a color unique to the project, and the git branch. Always first. |
| Model | Opus 5.5 | The model. |
| Effort | high | The effort level, only when it's above low or medium. Verbose: high effort. |
| Fast mode | fast mode | Only while fast mode is on. |
| Session | s 4% | Off by default; turn it on with segments.session. How much of the five-hour limit you've used, with its reset time once less than 30% is left, or out ~3:45pm when it's forecast to run out first. |
| Today | Today: ▕██████░░░░░░░░│ 8/14 | Today's budget as a bar, one cell per weekly point, then the points spent of the budget. See Today's bar. |
| Week | w 43/100 · 4d left | How much of the weekly limit you've used, then the days until it resets, or last day. When the week is forecast to run out first, out ~Tue replaces the days left. From three days out, the figure turns bold. Verbose: week 43/100. |
| Context | ctx 72% | Only when the context window is 70% or more full. |
| Progress | loop ▰▰▰▰▱ #44 3/5 | Only while an agent reports a long job's progress. See Progress bar. |
| Loop | loop ▰▰▰▱▱ #44 2/5 | Only during an /orc-loop run that doesn't report through the progress tools. See Loop and batch. |
| Batch | 7 issues · 2 reviews · ✓ ready | Where the current dev -> main batch stands in deep-review, in repos that keep a review ledger. See Loop and batch. |
| Duration | 16m | Session wall-clock time. Always last. |
Today's budget is the weekly percentage left at the start of the day, divided by the days from midnight to the reset. It stays fixed all day. Whatever you don't spend spreads over the remaining days. Today and the week are both in weekly percentage points, so 8/14 and w 43/100 add up. The numbers' color is your pace: green while you have room, yellow once you've used more than 70% of the budget, and red past 85%. On the final day before the reset, the budget is everything left in the week.
The bar has one cell per weekly point, against a │ marker at the even daily share of 100 / 7, about 14 points.
░) are what you've spent.+6 saved tag. You spend that surplus first, so the bar shortens and the tag shrinks.▒) before the marker.→ 11 tmrw shows what tomorrow's budget drops to. On the last day, out ~4pm shows when the week runs out at today's pace instead.Project colors come from a hash of the project folder name, mapped to one of 12 evenly spaced hues in the OKLCH color space, so every color is equally bright. A project keeps its color everywhere, including its subfolders, and it's the same color the Go paceline gives it.
The image above shows the line as it draws, in color, for the states you'll see most. Each one in turn:
| State | What you see | Why |
|---|---|---|
| Fresh day | A full bar up to the │ marker, 0/14 | Nothing spent yet, and the week is on pace, so the day's budget is the even share of about 14 points. |
| Working through the day | The bar drains, 5/14 in green | Spent points drain the bar. Green while you have room. |
| Session time | 2h12m at the end of the line | How long this session has been running. It counts up as you work, from 2m to 5h22m and beyond. |
| Most of the budget used | 11/14 in yellow, the bar mostly drained | Yellow past 70% of the budget, red past 85%. |
| Over budget | A red bar and 17/14 → 13 tmrw | The overage is counted, and tomorrow's budget shrinks to match. |
| Over budget by a whole day | A full red bar and 28/14 → 7 tmrw | Twice the day's budget is spent, so tomorrow gets about half a day. |
| Week forecast to run out | w 67/100 · out ~Fri | At your recent pace the week ends before it resets. The warning replaces the days left. |
| Session running low | s 72% · out ~10:15am in red | The session segment is off by default; once it's on, a session forecast to run out says when. |
| The last day | 6/40 +20 saved · last day | The budget is everything left in the week. With plenty left, this is the day to spend it if you need to. |
| Agent job | loop ▰▰▰▱▱ #43 40%, and ctx 74% | A long job's progress, from a regular loop or any agent that reports it, and the context warning from 70% full. See Progress bar. |
An /orc-loop run and its review batch | loop ▰▰▰▱▱ #44 2/5 and 7 issues · 2 reviews · ✓ ready | orc-pack's /orc-loop shows one cell per issue, and the batch shows where the dev -> main batch stands in review. See Loop and batch. |
| Verbose, counting down, fast mode | max effort · fast mode, 8/14 left, week 36/100 left | verbose and countDirection in paceline.json; fast mode comes from your settings. |
| Both limits | s 22% next to the week, no out ~ | "limits": "both" skips the forecast and shows the line as the Go paceline does. |
The underlines and arrows in the image point at the part of the line each state is about; the arrows show the bar draining as you spend and filling with red past the budget. The image is generated from the mod's own renderer with fixed figures by node docs/gallery/render.mts (Node 23.6 or later), so it matches what the code draws; run it again after changing what a segment shows. In a terminal with colors turned off, the same text prints without color.
Your session and weekly limits are separate, and usually neither is close to stopping you. paceline forecasts both and warns you only when one is on track to run out:
4d left becomes out ~Tue, in yellow, while that's more than a day away, then the time, out ~9pm, in red, within a day. The session's reset time becomes out ~3:45pm, always in red. The ~ marks an estimate, the same as today's out ~4pm on the last day. If both are forecast to run out, only the sooner one shows.The forecast learns when you work. Each refresh notes which hours of the day your usage goes up, over the last 14 days, so a 9-to-5 user's nights count for little and an agent running around the clock counts every hour. The weekly burn rate is today's average per hour of work, falling back to the week's average early in the day; the session uses its own five-hour window. Until a few days are recorded, every hour counts as an even chance of working.
If you use an API key instead of a subscription, Claude Code reports no rate limits, so neither the bar, the week, nor a warning shows. To skip the forecast, set "limits": "both" in paceline.json: no warning shows, as in the Go line.
high, instead of high effort, and says when a limit runs out early. Otherwise, today and the week are the Go line's segments, byte for byte.cache cold segment never shows.effortLevel for the session's model under modelSettings in your settings, then to the global effortLevel.fastMode key in your settings, which may not match every way fast mode can be turned on.paceline reads the same ~/.claude/paceline.json (or $CLAUDE_CONFIG_DIR/paceline.json) as the Go paceline, so one file configures both. Every key is optional.
{
"segments": { "duration": false },
"thresholds": { "headroomGreen": 30, "headroomYellow": 15, "contextWarn": 70, "contextCritical": 85 },
"countDirection": { "session": "up", "week": "up", "today": "up" },
"quietEfforts": ["low", "medium"],
"projectSlots": { "website": 4 },
"verbose": false,
"limits": "auto"
}
segments: turn any segment on or off: model, effort, fastMode, project, branch, session, week, today, context, cache, duration, progress, loop, batch. Every segment is on by default except session. The Go paceline ignores loop and batch.thresholds: percentages where colors change from green to yellow to red, and where the context warning appears. headroomGreen and headroomYellow count what's left of a limit.countDirection: "up" or "down" for session, week, and today, each on its own. "down" counts what's left: today reads 6/14 left, and past the budget, how far over (3 over/14); the week reads w 57/100 left. The bar is the same either way.quietEfforts: effort levels that don't need a label.projectSlots: pin a project to a color slot from 0 to 11.verbose: spell out the labels (week 43/100, high effort) instead of one letter or a bare level (w 43/100, high).limits: "auto", the default, warns of a limit forecast to run out before it resets; "both" skips the forecast, so the days left and the session's reset time never change to out ~. The Go paceline ignores this key.The mod reads the file on every refresh, so a change shows within 15 seconds. A key with the wrong type or range is ignored, and a file that isn't valid JSON gives the defaults.
The budget is anchored at the first reading of each day. The mod keeps that anchor in its own plugin store, which every session on your machine shares. If the Go paceline has already anchored the same day and week in paceline-day.json, the mod uses that anchor instead, so both lines show the same budget. The mod never writes paceline-day.json.
When an agent works through a long job on its own, such as a regular loop over a list of tasks, paceline shows how far along it is:
loop ▰▰▰▰▱ #44 3/5 one cell per step: done, active, skipped, blocked, pending
orc ▰▰▱▱ #42 review 55% a planned run: weighted percentage, moving within a step
loop ▰▰▰ ✓ done finished; hidden 30 minutes later
Any agent can report a run, under any name. orc-pack uses it too: /orc reports the tasks of each run, and /orc-loop reports its batch of issues (orc-pack 1.14.0 and later), so both show here as they work.
The mod reads the same progress files as the Go paceline: .git/paceline/progress-<session id>.json for this session's run, or .git/paceline/progress.json for a writer outside Claude Code. Agents write them through paceline-mcp, the MCP server that ships with paceline. The file format is documented there.
Two segments the Go status line never had sit after the others, before the duration. Turn either off with segments.loop or segments.batch.
Loop draws a run of orc-pack's /orc-loop from <git dir>/orc-loop/state.json: one cell per issue (green done, red blocked, dim skipped, cyan in flight, dim empty for the rest), then the phase.
loop ▰▰▰▱▱ #44 2/5 building issue 44, two of five settled
loop ▰▰▰▰▰ review 2/3 the second review round
loop ▰▰▰▰▰ ✓ done finished; hidden 30 minutes later
halted shows in red, planning while it plans, and (paused) once a build, review, or plan has gone six hours without writing the file. A run that reports through the progress tools (orc-pack 1.14.0 and later) draws in the progress bar instead, so the loop segment hides.
Batch shows where the current dev -> main batch stands in code review: every commit from git merge-base origin/main HEAD to HEAD, and the reviews deep-review recorded for it in tmp/reviews/ledger.jsonl. It shows only when HEAD is on a branch other than main, the repo has .claude/skills/deep-review/scripts/ledger.mjs, origin/main exists, and the batch has a commit.
| State | Shows |
|---|---|
| No reviews | 7 issues · no review yet (yellow) |
| Commits since the last review | 7 issues · review stale (+2 commits) (yellow), or review stale when the reviewed commit was rebased away |
| Last review incomplete | 7 issues · 2 reviews · review incomplete (yellow) |
| Blockers | 7 issues · 2 reviews · 1 blocker (red) |
| Warnings | 7 issues · 2 reviews · 3 warnings (yellow) |
| Ready | 7 issues · 2 reviews · ✓ ready (green) |
| Anything else | 7 issues · 2 reviews · not ready (yellow) |
The lead counts the distinct issues the batch's commits reference with refs, closes, fixes, or resolves #N, or the commits when none do. Working this out takes git, so the mod runs it only in such repos, and only when HEAD, origin/main, or the ledger changes. It runs in the background, so the segment can appear a moment after the rest of the line.
A mod runs inside Claude Code with your permissions, so here is everything this one does. claude plugin validate on this folder lists the same calls.
paceline.json and paceline-day.json in your Claude config folder, and, in the repository you're working in, .git/HEAD, its branch and origin/main refs (and packed-refs), .git/paceline/progress*.json, and .git/orc-loop/state.json. In a repo that keeps a deep-review ledger, it also checks that .claude/skills/deep-review/scripts/ledger.mjs exists and reads tmp/reviews/ledger.jsonl.CLAUDE_CONFIG_DIR, USERPROFILE, and HOME, only to find your Claude config folder, and GIT_DIR and GIT_WORK_TREE, only to skip the batch segment when either is set.git merge-base, git log, and git rev-list only for the batch segment: only in repos that keep a deep-review ledger, off main, with a 1.5-second timeout each, repository hooks off, and only when HEAD, origin/main, or the ledger changed. A call that times out or won't start is tried again a minute later. With segments.batch off, it starts no processes at all. The reasoning is in docs/notes.md.Text from outside paceline (folder names, branch names, model names, and progress labels) is stripped of control characters, zero-width characters, and bidi overrides before it's drawn.
The mod is TypeScript that Claude Code loads directly, with no build step.
hooks/register.ts the hooks module: events, and every call on the mods API
src/ the line itself: pace math, rendering, config, git, progress
test/ tests, and the Go paceline's fixtures copied as TypeScript
Run a session with your working copy loaded:
claude --plugin-dir ./paceline-mod
If you also have paceline installed from the marketplace, that session loads the mod twice and can draw two bands. Disable the installed copy in /plugin while you test, then turn it back on.
Check it:
claude plugin validate . # what the module hooks and calls, and anything the engine would refuse
claude plugin test . # every *.test.ts file
tsc -p . # type check, once a session has loaded the mod and laid its types
docs/notes.md records what the mods platform can and can't do, where each figure comes from, and ideas not built yet. .claude/CLAUDE.md holds the working rules for agents.
test/fixtures/ holds the Go paceline's parity.json and progress.json as TypeScript modules. The parity tests prove the mod renders exactly the bytes the Go paceline prints for every case. When the Go fixtures change, copy them again rather than editing the cases by hand.
MIT
hooks/register.ts 372 lines1import type { EngineInterface, FsStat, Register } from 'claude-code'
2
3import { bandTree, type BandElements } from '../src/band.ts'
4import {
5 GIT_ENV,
6 MAX_LEDGER_BYTES,
7 ORIGIN_MAIN_REF,
8 acceptResult,
9 batchAllowed,
10 batchKey,
11 computeBatch,
12 ledgerPath,
13 ledgerScriptPath,
14 planBatch,
15 settleBatch,
16 type BatchFlow,
17 type BatchRefs,
18 type BatchState,
19 type GitOutput,
20} from '../src/batch.ts'
21import { defaultConfig, parseConfig } from '../src/config.ts'
22import { isActivity, migrateActivity, recordActivity } from '../src/forecast.ts'
23import { branchIn, findRepo, readHead, resolveRef, type GitFs, type Repo } from '../src/gitinfo.ts'
24import {
25 ACTIVITY_KEY,
26 LEGACY_ACTIVITY_KEY,
27 SNAPSHOT_KEY,
28 chooseSnapshot,
29 configDirFrom,
30 debugReport,
31 renderLive,
32 toPayload,
33 turnEffort,
34} from '../src/live.ts'
35import { MAX_STATE_BYTES, decodeLoopState, loopStatePath, type LoopFile } from '../src/loop.ts'
36import { MAX_FILE_BYTES, isValidSession, parseRun, progressPath, type Run } from '../src/progress.ts'
37import type { LimitPick, Line } from '../src/render.ts'
38import { dateKey } from '../src/timefmt.ts'
39
40// How often the line refreshes with nothing else to prompt it: the duration
41// ticks, and a progress file another process writes can change.
42const REFRESH_MS = 15_000
43const MAX_CONFIG_BYTES = 64 * 1024
44const MAX_SNAPSHOT_BYTES = 4096
45// HEAD, a .git file, commondir, and a loose ref are a line each; packed-refs
46// can be large, up to the 4 MiB `$.fs.read` accepts.
47const MAX_GIT_FILE_BYTES = 4096
48const MAX_PACKED_REFS_BYTES = 4 * 1024 * 1024
49const DEBUG_TOOL = 'mcp__paceline__get_forecast_debug'
50const DEBUG_COMMAND = 'paceline-debug'
51// Each batch git call gets this long before it is killed.
52const GIT_TIMEOUT_MS = 1500
53
54// Module state starts over on a reload; the first refresh refills it.
55let line: Line = []
56let effort: string | undefined
57let lastPick: LimitPick['show'] | undefined
58let lastDebug: unknown
59let running: Promise<void> | null = null
60let again = false
61// The batch's cached git results and the keys its background update runs by;
62// `planBatch` and `acceptResult` in src/batch.ts decide every change. A refresh
63// never waits on git.
64let batchFlow: BatchFlow = { cache: null, pending: null, wanted: null }
65
66// Every call on `$` stays in this file: the engine follows `$` only into
67// functions declared here, never across an import.
68
69/** Reads a small regular file's text and last write time, or null when it is missing, too big, or unreadable. */
70async function readStamped(
71 $: EngineInterface,
72 path: string,
73 maxBytes: number,
74): Promise<{ text: string; mtimeMs: number } | null> {
75 try {
76 const stat = await $.fs.stat(path)
77 if (stat.kind !== 'file' || stat.size > maxBytes) return null
78 return { text: await $.fs.read(path), mtimeMs: stat.mtimeMs }
79 } catch {
80 return null
81 }
82}
83
84/** Reads a small regular file's text, or null when it is missing, too big, or unreadable. */
85async function readSmall($: EngineInterface, path: string, maxBytes: number): Promise<string | null> {
86 return (await readStamped($, path, maxBytes))?.text ?? null
87}
88
89/** Reads a file under a git directory for gitinfo; anything but a small regular file reads as missing. */
90async function readGitFile($: EngineInterface, path: string): Promise<string | null> {
91 return readSmall($, path, path.endsWith('/packed-refs') ? MAX_PACKED_REFS_BYTES : MAX_GIT_FILE_BYTES)
92}
93
94async function statOf($: EngineInterface, path: string): Promise<FsStat | null> {
95 try {
96 return await $.fs.stat(path)
97 } catch {
98 return null
99 }
100}
101
102async function kindOf($: EngineInterface, path: string): Promise<'file' | 'dir' | 'other' | null> {
103 return (await statOf($, path))?.kind ?? null
104}
105
106/** The repository's progress run for this session, or the shared run. */
107async function loadProgress($: EngineInterface, gitDir: string, session: string): Promise<Run | null> {
108 if (gitDir === '') return null
109 for (const id of isValidSession(session) ? [session, ''] : ['']) {
110 const text = await readSmall($, progressPath(gitDir, id), MAX_FILE_BYTES)
111 const run = text === null ? null : parseRun(text)
112 if (run) return run
113 }
114 return null
115}
116
117/** The repository's `/orc-loop` state file, decoded, or null when it is missing or unreadable. */
118async function loadLoop($: EngineInterface, gitDir: string): Promise<LoopFile | null> {
119 if (gitDir === '') return null
120 const file = await readStamped($, loopStatePath(gitDir), MAX_STATE_BYTES)
121 if (file === null) return null
122 const state = decodeLoopState(file.text)
123 return state === null ? null : { state, mtimeMs: file.mtimeMs }
124}
125
126/**
127 * Reads the batch gate's inputs: HEAD, origin/main, and the ledger's stat, or
128 * null when the work tree has no deep-review `ledger.mjs`, so a repository
129 * without deep-review costs one stat.
130 */
131async function readBatchRefs($: EngineInterface, fs: GitFs, repo: Repo): Promise<BatchRefs | null> {
132 if ((await kindOf($, ledgerScriptPath(repo.workTree))) !== 'file') return null
133 const [head, mainSha, ledger] = await Promise.all([
134 readHead(fs, repo.gitDir),
135 resolveRef(fs, repo.gitDir, ORIGIN_MAIN_REF),
136 statOf($, ledgerPath(repo.workTree)),
137 ])
138 return { workTree: repo.workTree, head, mainSha, ledger: ledger && { mtimeMs: ledger.mtimeMs, size: ledger.size } }
139}
140
141/**
142 * Runs one batch git call and resolves its output, or null when git exits
143 * non-zero. Rejects when git times out or cannot start.
144 *
145 * `cwd` is a folder the repository doesn't control, so a `git` executable
146 * committed to the repository can't be found ahead of the real one; the
147 * arguments name the work tree with `-C`.
148 */
149async function runGit($: EngineInterface, cwd: string, argv: readonly string[]): Promise<GitOutput | null> {
150 const result = await $.process.run(['git', ...argv], { cwd, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS })
151 return result.exitCode === 0 ? result : null
152}
153
154/**
155 * Computes the batch for `found.key` in the background, keeps the result when
156 * that key is still the one the latest refresh asked for, and redraws. Never
157 * rejects: a failure is logged and kept as no batch until its retry time.
158 */
159async function updateBatch(
160 $: EngineInterface,
161 workTree: string,
162 found: { key: string; headSha: string; mainSha: string },
163 hasLedger: boolean,
164 nowMs: number,
165 gitCwd: string,
166): Promise<void> {
167 const readLedger = hasLedger ? () => readSmall($, ledgerPath(workTree), MAX_LEDGER_BYTES) : null
168 const { cache, error } = await settleBatch(found.key, nowMs, () =>
169 computeBatch(argv => runGit($, gitCwd, argv), readLedger, workTree, found),
170 )
171 if (error !== undefined) $.ui.log(`paceline: batch git failed: ${String(error)}`, { to: 'debug' })
172 const accepted = acceptResult(batchFlow, cache)
173 batchFlow = accepted.next
174 if (accepted.redraw) void refresh($)
175}
176
177/**
178 * The batch to draw now, from the cache, starting a background update when
179 * `planBatch` says to. With `refs` null (the batch path was skipped), it
180 * draws nothing and forgets the wanted key.
181 */
182function currentBatch($: EngineInterface, refs: BatchRefs | null, nowMs: number, gitCwd: string): BatchState | null {
183 const found = refs === null ? null : batchKey(refs)
184 const plan = planBatch(batchFlow, found?.key ?? null, nowMs)
185 batchFlow = plan.next
186 if (plan.start && refs !== null && found !== null) {
187 void updateBatch($, refs.workTree, found, refs.ledger !== null, nowMs, gitCwd)
188 }
189 return plan.draw
190}
191
192/** Asks the session for its figures and renders the line. */
193async function liveLine($: EngineInterface): Promise<Line> {
194 const [usage, modelId, cwd, root, sessionId, nowMs, settings] = await Promise.all([
195 $.session.usage(),
196 $.session.model(),
197 $.session.cwd(),
198 $.session.root(),
199 $.session.id(),
200 $.clock.now(),
201 $.settings.read(),
202 ])
203 const userProfile = await $.env.get('USERPROFILE')
204 const dir = configDirFrom({
205 claudeConfigDir: await $.env.get('CLAUDE_CONFIG_DIR'),
206 userProfile,
207 home: await $.env.get('HOME'),
208 })
209 const payload = toPayload({ usage, modelId, cwd, root, sessionId, nowMs, settings, effort })
210
211 const fs: GitFs = { kind: path => kindOf($, path), read: path => readGitFile($, path) }
212 const [configText, goSnapshot, stored, storedActivity, legacyActivity, repo, hostGitDir, hostWorkTree] =
213 await Promise.all([
214 readSmall($, `${dir}/paceline.json`, MAX_CONFIG_BYTES),
215 readSmall($, `${dir}/paceline-day.json`, MAX_SNAPSHOT_BYTES),
216 $.store.get(SNAPSHOT_KEY),
217 $.store.get(ACTIVITY_KEY),
218 $.store.get(LEGACY_ACTIVITY_KEY),
219 findRepo(fs, cwd),
220 $.env.get('GIT_DIR'),
221 $.env.get('GIT_WORK_TREE'),
222 ])
223 // Parsed before the repository reads, so the loop or batch segment turned off costs no reads, and the batch
224 // turned off starts no git.
225 const config = configText === null ? defaultConfig() : parseConfig(configText)
226 const gitDir = repo?.gitDir ?? ''
227 const allowed = batchAllowed(config.segments.batch, { gitDir: hostGitDir, workTree: hostWorkTree, userProfile }, dir)
228 const [branch, run, loop, batchRefs] = await Promise.all([
229 branchIn(fs, gitDir),
230 loadProgress($, gitDir, sessionId),
231 config.segments.loop ? loadLoop($, gitDir) : null,
232 allowed && repo !== null ? readBatchRefs($, fs, repo) : null,
233 ])
234 const batch = currentBatch($, batchRefs, nowMs, dir)
235
236 const snapshot = chooseSnapshot(goSnapshot, stored, dateKey(nowMs, 'local'), payload.rateLimits?.sevenDay)
237 const recorded = recordActivity(
238 isActivity(storedActivity) ? storedActivity : migrateActivity(legacyActivity),
239 nowMs,
240 'local',
241 payload.rateLimits?.fiveHour,
242 payload.rateLimits?.sevenDay,
243 )
244 if (recorded.changed) await $.store.set(ACTIVITY_KEY, recorded.activity)
245
246 const state = {
247 snapshot,
248 branch,
249 run,
250 loop,
251 batch,
252 activity: recorded.activity,
253 nowMs,
254 previous: lastPick,
255 }
256 const result = renderLive(payload, config, state)
257 lastDebug = debugReport({
258 nowMs,
259 payload,
260 config,
261 state,
262 snapshotSource: snapshot === null ? 'none' : snapshot === stored ? 'store' : 'go-file',
263 result,
264 })
265 lastPick = result.pick?.show
266 if (result.save) await $.store.set(SNAPSHOT_KEY, result.save)
267 return result.line
268}
269
270/**
271 * Re-reads the session's figures and redraws the band. One refresh runs at a
272 * time; a request that arrives during one runs once more after it, so the
273 * line always ends on the latest figures.
274 */
275function refresh($: EngineInterface): Promise<void> {
276 if (running) {
277 again = true
278 return running
279 }
280 running = refreshLoop($)
281 return running
282}
283
284async function refreshLoop($: EngineInterface): Promise<void> {
285 do {
286 again = false
287 try {
288 line = await liveLine($)
289 $.ui.invalidate('ui.render')
290 } catch (err) {
291 // Keep the last good line: a failed read must never blank it.
292 $.ui.log(`paceline: refresh failed: ${String(err)}`, { to: 'debug' })
293 }
294 } while (again)
295 running = null
296}
297
298function noteEffort(event: Parameters<typeof turnEffort>[1]): void {
299 effort = turnEffort(effort, event)
300}
301
302// The classic and tool.call hooks below only watch, so each `.catch` lets the
303// event go on if the hook fails: `next(e)` runs what is beneath it, or replays
304// the result when the hook had already called `next`.
305export const register: Register = on => {
306 on('session.start', async ($, e, next) => {
307 await refresh($)
308 $.clock.every(REFRESH_MS, () => refresh($))
309 await $.tool.register({
310 name: 'get_forecast_debug',
311 description:
312 "Returns, as JSON, every figure behind paceline's forecast and its 'out ~time' warning: the usage readings, the day snapshot, the stored activity history, the burn rates and where they came from, and each limit's projection. Call it when asked why the line shows what it shows. Read-only.",
313 })
314 await $.command.register({
315 name: DEBUG_COMMAND,
316 description: "Prints the figures behind paceline's forecast.",
317 })
318 return next(e)
319 })
320
321 // The debug tool and command answer from the latest refresh, redone first so
322 // the figures are current.
323 on('tool.call', { tool: DEBUG_TOOL }, async ($, e, next) => {
324 await refresh($)
325 return { result: JSON.stringify(lastDebug ?? { note: 'No reading yet.' }, null, 2) }
326 })
327 on('command.run', { command: DEBUG_COMMAND }, async ($, e, next) => {
328 await refresh($)
329 return { text: JSON.stringify(lastDebug ?? { note: 'No reading yet.' }, null, 2) }
330 })
331
332 // Usage moved: a turn ended, or a rate-limit window moved a whole point.
333 on('session.measure', ($, e, next) => {
334 void refresh($)
335 return next(e)
336 })
337
338 // /clear, /resume, and /branch start the session's figures over.
339 on('classic.SessionStart', ($, e, next) => {
340 void refresh($)
341 return next(e)
342 }).catch(($, e, next) => next(e))
343
344 // The mods API has no effort reading; PostToolUse and Stop carry the turn's.
345 // A subagent's events carry its own effort, so `noteEffort` ignores them.
346 on('classic.PostToolUse', ($, e, next) => {
347 noteEffort(e)
348 return next(e)
349 }).catch(($, e, next) => next(e))
350 on('classic.Stop', ($, e, next) => {
351 noteEffort(e)
352 void refresh($)
353 return next(e)
354 }).catch(($, e, next) => next(e))
355
356 // An agent reported progress: redraw the bar now rather than on the timer.
357 on('tool.call', async ($, e, next) => {
358 const result = await next(e)
359 if (String(e.tool).startsWith('mcp__paceline__progress_')) void refresh($)
360 return result
361 }).catch(($, e, next) => next(e))
362
363 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
364 if (e.props.hasSurvey || line.length === 0) return next(e)
365 const el = $.ui.resolve(e) as unknown as BandElements
366 const ours = bandTree(line, el, e.surface)
367 // Keep whatever the mods after this one draw in the band, under the line.
368 const theirs = await next(e)
369 return el.Box({ flexDirection: 'column', children: [ours, theirs] })
370 })
371}
372src/band.ts 61 lines1/** Draws the line as elements in the band above the prompt. */
2
3import type { RenderElement, RenderSurface } from 'claude-code'
4
5import type { RGB } from './color.ts'
6import { MUTED_RED, type Line, type Span, type Tone } from './render.ts'
7
8/** The element constructors the band needs from `$.ui.resolve(e)`. */
9export type BandElements = {
10 Box: (props: Record<string, unknown>) => RenderElement
11 Text: (props: Record<string, unknown>) => RenderElement
12}
13
14const hex = (c: RGB) => '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
15
16// The terminal draws named tones in its own palette, as the Go paceline's
17// ANSI codes do. Other surfaces get fixed colors that read on light and dark.
18const SURFACE_TONES: Record<string, string> = { green: '#2e9e44', yellow: '#c99a1a', cyan: '#2f9fbf' }
19
20// In the band, `dimColor` and the theme's `inactive` key both draw as plain
21// text (seen on Claude Code 2.1.296), far brighter than the ANSI dim the Go
22// line uses. A fixed mid gray, Windows Terminal's "bright black", matches that
23// look and stays readable on light backgrounds too.
24const DIM = { color: '#767676' }
25
26// Claude Code indents its own rows under the prompt (the status line, the
27// mode line) two columns from the edge; the band lines up with them.
28const TERMINAL_INDENT = 2
29
30function textProps(tone: Tone, surface: RenderSurface): Record<string, unknown> {
31 if (tone === 'plain') return {}
32 if (tone === 'dim') return DIM
33 if (tone === 'red') return { color: hex(MUTED_RED) }
34 if (typeof tone !== 'string') return { color: hex(tone) }
35 return { color: surface === 'terminal' ? tone : SURFACE_TONES[tone] }
36}
37
38/**
39 * Builds the band's tree: one row that wraps between segments, never inside
40 * one, so a narrow window moves whole segments to the next row.
41 */
42export function bandTree(line: Line, el: BandElements, surface: RenderSurface): RenderElement {
43 const text = (s: Span) =>
44 el.Text({ ...textProps(s.tone, surface), ...(s.bold ? { bold: true } : {}), children: [s.text] })
45 return el.Box({
46 flexDirection: 'row',
47 flexWrap: 'wrap',
48 ...(surface === 'terminal' ? { paddingLeft: TERMINAL_INDENT } : {}),
49 children: line.map((segment, i) =>
50 el.Box({
51 flexDirection: 'row',
52 flexShrink: 0,
53 children: [
54 ...(i === 0 ? [] : [el.Text({ ...DIM, children: [' \u00b7 '] })]),
55 ...segment.map(text),
56 ],
57 }),
58 ),
59 })
60}
61src/batch.ts 470 lines1/**
2 * Parses where the current dev -> main batch stands in code review and draws it
3 * as the batch segment: the batch's size, then the review's state.
4 *
5 * The batch is every commit between `git merge-base origin/main HEAD` and
6 * HEAD, and its reviews are the lines of deep-review's
7 * `tmp/reviews/ledger.jsonl` recorded against that merge base. Everything from
8 * the ledger is untrusted: the parser caps its size, keeps a review's head only
9 * when it is a commit id, reduces the verdict to the values the segment tells
10 * apart, and clamps the counts, so no string from the file reaches the line.
11 *
12 * The module also decides when the segment applies, builds the batch's git
13 * argument lists and cache key, turns git's results into the segment's state,
14 * and decides when the cached state is drawn or recomputed. hooks/register.ts
15 * runs the git calls and holds the cache; docs/notes.md records why git runs
16 * at all.
17 */
18
19import { FULL_SHA, type Head } from './gitinfo.ts'
20import type { Segment, Span, Tone } from './render.ts'
21
22/** The largest ledger the parser accepts, in bytes. */
23export const MAX_LEDGER_BYTES = 1024 * 1024
24const MAX_COUNT = 999
25/** The most commits the batch's `git log` reads, the same as the count clamp. */
26export const MAX_BATCH_COMMITS = MAX_COUNT
27
28// The same glyphs as src/render.ts, copied rather than imported so that
29// render.ts can import this module without the two loading each other.
30// Importing only types from render.ts adds no such cycle.
31const CHECK_MARK = '\u2713'
32const SEPARATOR = ' \u00b7 '
33
34const VERDICTS = ['ready', 'incomplete'] as const
35
36// Hex only, so a head passed to `git rev-list` can never be read as an option
37// or a revision expression. A short id can still name a ref as well as a
38// commit; `sinceArgv` peels it with `^{commit}`, so one naming anything but a
39// commit fails the count instead of counting from it.
40const COMMIT_ID = /^[0-9a-f]{4,64}$/i
41const MAIN_REF = 'refs/heads/main'
42// Global options for every batch git call, after `-C <work tree>`.
43// `--no-lazy-fetch` keeps a partial clone from fetching missing objects, and a
44// git too old to know it (before 2.45) exits 129, so the segment fails closed.
45// The fsmonitor pin is defense in depth: none of these commands reads the index.
46const GIT_PIN = ['--no-lazy-fetch', '-c', 'core.fsmonitor=false']
47const ISSUE_REF = /\b(?:refs|closes|fixes|resolves)\s+#(\d+)/gi
48
49/** A review's verdict, or `other` for any the segment does not draw on its own. */
50export type Verdict = (typeof VERDICTS)[number] | 'other'
51
52/** One review from the ledger, as the segment draws it. */
53export type LedgerEntry = {
54 /** The commit the review saw, or null when the ledger's value is not a commit id. */
55 head: string | null
56 verdict: Verdict
57 /** The review's blocker count, from 0 to 999. */
58 blockers: number
59 /** The review's warning count, from 0 to 999. */
60 warnings: number
61}
62
63/** What the batch segment draws. */
64export type BatchState = {
65 /** The commits in the batch. */
66 commits: number
67 /** The distinct issues the batch's commit messages reference. */
68 issues: number
69 /** The reviews of this batch, oldest first. */
70 reviews: readonly LedgerEntry[]
71 /**
72 * The commits made since the last review's head: 0 when the last review saw
73 * HEAD or there are no reviews, and `gone` when its head is null or git could
74 * not count from it because the commit was rebased away.
75 */
76 sinceLastReview: number | 'gone'
77}
78
79function isRecord(v: unknown): v is Record<string, unknown> {
80 return typeof v === 'object' && v !== null && !Array.isArray(v)
81}
82
83function count(v: unknown): number {
84 return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? Math.min(v, MAX_COUNT) : 0
85}
86
87function parseEntry(lineText: string, base: string): LedgerEntry | null {
88 let doc: unknown
89 try {
90 doc = JSON.parse(lineText)
91 } catch {
92 // A torn or hand-edited line costs one review, not the whole segment.
93 return null
94 }
95 if (!isRecord(doc) || doc.base !== base || typeof doc.head !== 'string') return null
96 return {
97 head: COMMIT_ID.test(doc.head) ? doc.head : null,
98 verdict: VERDICTS.find(known => known === doc.verdict) ?? 'other',
99 blockers: count(doc.blockers),
100 warnings: count(doc.warnings),
101 }
102}
103
104/**
105 * Parses a ledger's text into the reviews of one batch, in file order.
106 *
107 * A line counts when it is a JSON object whose `base` equals `base` and whose
108 * `head` is a string. Blank, torn, and unparsable lines are skipped.
109 *
110 * @param ledgerText - The text of `tmp/reviews/ledger.jsonl`.
111 * @param base - The batch's merge base, as a full sha.
112 * @returns The batch's reviews, or null when the text is over {@link MAX_LEDGER_BYTES}.
113 */
114export function parseLedger(ledgerText: string, base: string): LedgerEntry[] | null {
115 if (new TextEncoder().encode(ledgerText).length > MAX_LEDGER_BYTES) return null
116 const entries: LedgerEntry[] = []
117 for (const lineText of ledgerText.split('\n')) {
118 if (lineText.trim() === '') continue
119 const entry = parseEntry(lineText, base)
120 if (entry !== null) entries.push(entry)
121 }
122 return entries
123}
124
125/**
126 * Splits the output of `git log --format=%x00%B` into one message per commit.
127 * Each message starts after a NUL; blank pieces are dropped.
128 */
129export function splitCommitMessages(logText: string): string[] {
130 return logText.split('\0').filter(message => message.trim() !== '')
131}
132
133/**
134 * Counts the distinct issue numbers that commit messages reference with
135 * `refs`, `closes`, `fixes`, or `resolves` followed by `#<number>`, in any case.
136 */
137export function countIssueRefs(messages: readonly string[]): number {
138 const issues = new Set<number>()
139 for (const message of messages) {
140 for (const [, n] of message.matchAll(ISSUE_REF)) issues.add(Number(n))
141 }
142 return issues.size
143}
144
145// The same helper as in src/render.ts, copied for the reason given above CHECK_MARK.
146const span = (text: string, tone: Tone = 'plain'): Span => ({ text, tone })
147
148function plural(n: number, word: string): string {
149 return `${n} ${word}${n === 1 ? '' : 's'}`
150}
151
152function joinParts(parts: Span[]): Segment {
153 const out: Segment = []
154 for (const part of parts) {
155 if (out.length > 0) out.push(span(SEPARATOR, 'dim'))
156 out.push(part)
157 }
158 return out
159}
160
161function staleText(sinceLastReview: number | 'gone'): string {
162 if (sinceLastReview === 'gone') return 'review stale'
163 return `review stale (+${plural(sinceLastReview, 'commit')})`
164}
165
166// Only the first of these that applies shows, so blockers hide warnings.
167function verdictPart(last: LedgerEntry): Span {
168 if (last.verdict === 'incomplete') return span('review incomplete', 'yellow')
169 if (last.blockers > 0) return span(plural(last.blockers, 'blocker'), 'red')
170 if (last.warnings > 0) return span(plural(last.warnings, 'warning'), 'yellow')
171 if (last.verdict === 'ready') return span(CHECK_MARK + ' ready', 'green')
172 return span('not ready', 'yellow')
173}
174
175/**
176 * Draws the batch as its size and its review state, joined by a dim middle
177 * dot, or returns null when the batch has no commits.
178 *
179 * The size reads `N issues` when the commits reference any issue, otherwise
180 * `N commits`. Then, in order of precedence:
181 *
182 * - no reviews: `no review yet`
183 * - commits since the last review: `review stale (+N commits)`, or `review stale` when its commit is gone
184 * - otherwise the review count, then the first that applies to the last review: `review incomplete`,
185 * its blockers, its warnings, a check mark and `ready`, or `not ready`
186 */
187export function batchSegment(state: BatchState): Segment | null {
188 if (state.commits === 0) return null
189 const lead = span(state.issues > 0 ? plural(state.issues, 'issue') : plural(state.commits, 'commit'))
190 const last = state.reviews.at(-1)
191 if (last === undefined) return joinParts([lead, span('no review yet', 'yellow')])
192 if (state.sinceLastReview !== 0) return joinParts([lead, span(staleText(state.sinceLastReview), 'yellow')])
193 return joinParts([lead, span(plural(state.reviews.length, 'review')), verdictPart(last)])
194}
195
196/** The ref the batch is measured against. */
197export const ORIGIN_MAIN_REF = 'refs/remotes/origin/main'
198
199/** The deep-review script whose presence marks a repository that keeps a review ledger. */
200export function ledgerScriptPath(workTree: string): string {
201 return `${workTree}/.claude/skills/deep-review/scripts/ledger.mjs`
202}
203
204/** The review ledger deep-review appends to, in the work tree. */
205export function ledgerPath(workTree: string): string {
206 return `${workTree}/tmp/reviews/ledger.jsonl`
207}
208
209/**
210 * The inputs of the batch segment's gate and cache key, all read without a
211 * process, once the work tree is known to have deep-review's `ledger.mjs`.
212 */
213export type BatchRefs = {
214 /** The work tree that holds `.git`, where deep-review keeps its ledger. */
215 workTree: string
216 head: Head | null
217 /** The sha `refs/remotes/origin/main` resolves to, or null when it does not. */
218 mainSha: string | null
219 /** The ledger file's stat, or null when there is no ledger yet. */
220 ledger: { mtimeMs: number; size: number } | null
221}
222
223/**
224 * Checks the rest of the batch segment's gate and builds the key its git
225 * results are cached under.
226 *
227 * The caller checks first that the work tree has deep-review's `ledger.mjs`
228 * script. Past that, the segment applies only when HEAD is on a branch other
229 * than `main` that has a commit and origin/main resolves. The key changes when
230 * the work tree, HEAD, origin/main, or the ledger's mtime or size does.
231 *
232 * @returns The key and the two shas the git calls take, or null when the segment does not apply.
233 */
234export function batchKey(refs: BatchRefs): { key: string; headSha: string; mainSha: string } | null {
235 const { head, mainSha } = refs
236 if (head?.kind !== 'branch' || head.ref === MAIN_REF || head.sha === null || mainSha === null) return null
237 const ledger = refs.ledger ?? { mtimeMs: 0, size: 0 }
238 const key = JSON.stringify([refs.workTree, head.sha, mainSha, ledger.mtimeMs, ledger.size])
239 return { key, headSha: head.sha, mainSha }
240}
241
242/** The batch cache: the key it was computed for, its result, and when a failed run may retry. */
243export type BatchCache = {
244 key: string
245 batch: BatchState | null
246 /** Epoch milliseconds after which the key is computed again; `Infinity` for a settled result. */
247 retryAtMs: number
248}
249
250/** How long a batch whose git calls timed out or could not start waits before it is tried again, in milliseconds. */
251export const GIT_RETRY_MS = 60_000
252
253/** Checks whether `cache` answers `key` at `nowMs`: the same key, and its retry time not yet reached. */
254export function isFresh(cache: BatchCache | null, key: string, nowMs: number): boolean {
255 return cache !== null && cache.key === key && nowMs < cache.retryAtMs
256}
257
258/** What the host environment says about where git would run. */
259export type GitHost = {
260 /** The host's `GIT_DIR`, when set. */
261 gitDir?: string
262 /** The host's `GIT_WORK_TREE`, when set. */
263 workTree?: string
264 /** The host's `USERPROFILE`, set on Windows. */
265 userProfile?: string
266}
267
268// A drive-letter path ("C:/x") or a UNC share ("//server/share"), the two
269// absolute forms on Windows, after backslashes become slashes.
270const WINDOWS_ABSOLUTE = /^(?:[A-Za-z]:\/|\/\/[^/]+\/[^/])/
271
272/**
273 * Checks whether the batch segment may run git: its toggle is on, the host
274 * sets neither `GIT_DIR` nor `GIT_WORK_TREE` (git would follow them instead of
275 * the work tree), and `configDir`, git's working folder, is absolute.
276 *
277 * A drive-letter or UNC path is absolute on any host. A path rooted at `/` is
278 * absolute only off Windows: on Windows it is relative to the current drive.
279 * The engine doesn't say which platform it runs on, so a set `USERPROFILE`,
280 * which Windows always sets, stands in for it.
281 */
282export function batchAllowed(on: boolean, host: GitHost, configDir: string): boolean {
283 if (!on || host.gitDir !== undefined || host.workTree !== undefined) return false
284 const dir = configDir.replaceAll('\\', '/')
285 if (WINDOWS_ABSOLUTE.test(dir)) return true
286 return host.userProfile === undefined && dir.startsWith('/') && !dir.startsWith('//')
287}
288
289/** The batch cache and the keys the background update is coordinated by. */
290export type BatchFlow = {
291 cache: BatchCache | null
292 /** The key a background update is computing now, or null. */
293 pending: string | null
294 /** The key the latest refresh asked for, or null when it skipped the batch. */
295 wanted: string | null
296}
297
298/**
299 * Decides, for one refresh, what batch to draw and whether to start a
300 * background update for `key`.
301 *
302 * It draws the cache only when the cache is for `key`. It starts an update
303 * when the cache doesn't answer `key` (see {@link isFresh}) and none is
304 * already computing it.
305 *
306 * @param key - The current cache key, or null when the refresh skipped the batch.
307 * @returns The next flow, the batch to draw, and true if an update should start; false otherwise.
308 */
309export function planBatch(
310 flow: BatchFlow,
311 key: string | null,
312 nowMs: number,
313): { next: BatchFlow; draw: BatchState | null; start: boolean } {
314 if (key === null) return { next: { ...flow, wanted: null }, draw: null, start: false }
315 const start = !isFresh(flow.cache, key, nowMs) && flow.pending !== key
316 const next = { cache: flow.cache, pending: start ? key : flow.pending, wanted: key }
317 return { next, draw: flow.cache?.key === key ? flow.cache.batch : null, start }
318}
319
320/**
321 * Takes a finished update's cache entry: it clears the entry's pending mark,
322 * and keeps the entry only when its key is still the one wanted, so an older
323 * result never replaces a newer one.
324 *
325 * @returns The next flow, and true if the entry was kept and the line should redraw; false otherwise.
326 */
327export function acceptResult(flow: BatchFlow, entry: BatchCache): { next: BatchFlow; redraw: boolean } {
328 const pending = flow.pending === entry.key ? null : flow.pending
329 if (entry.key !== flow.wanted) return { next: { ...flow, pending }, redraw: false }
330 return { next: { cache: entry, pending, wanted: flow.wanted }, redraw: true }
331}
332
333/** The arguments of `git merge-base` for origin/main and HEAD, by sha. */
334export function mergeBaseArgv(workTree: string, mainSha: string, headSha: string): string[] {
335 return ['-C', workTree, ...GIT_PIN, 'merge-base', mainSha, headSha]
336}
337
338/**
339 * The arguments of `git log` for the batch's messages, in the format
340 * {@link splitCommitMessages} reads, at most {@link MAX_BATCH_COMMITS} of them.
341 * `--no-show-signature` keeps a repo's `log.showSignature` from starting its
342 * `gpg.program`.
343 */
344export function logArgv(workTree: string, base: string, headSha: string): string[] {
345 return [
346 '-C',
347 workTree,
348 ...GIT_PIN,
349 'log',
350 '--no-show-signature',
351 `--max-count=${MAX_BATCH_COMMITS}`,
352 '--format=%x00%B',
353 `${base}..${headSha}`,
354 ]
355}
356
357/** The arguments of `git rev-list --count` from the reviewed commit to HEAD. */
358export function sinceArgv(workTree: string, reviewHead: string, headSha: string): string[] {
359 return ['-C', workTree, ...GIT_PIN, 'rev-list', '--count', `${reviewHead}^{commit}..${headSha}`]
360}
361
362/**
363 * The environment every batch git call adds to the host's. It turns off lazy
364 * fetches for a git that reads the variable. It sets no `GIT_DIR` or
365 * `GIT_WORK_TREE`: git finds the repository from `-C` itself, so its
366 * `safe.directory` ownership check still applies.
367 */
368export const GIT_ENV: Readonly<Record<string, string>> = { GIT_NO_LAZY_FETCH: '1' }
369
370/**
371 * Gets the reviewed commit to count new commits from: the last review's head,
372 * or null when there is no review, its head is not a commit id, or it saw HEAD.
373 */
374export function staleReviewHead(last: LedgerEntry | undefined, headSha: string): string | null {
375 if (last === undefined || last.head === null || last.head === headSha) return null
376 return last.head
377}
378
379/**
380 * Maps the last review and the output of `git rev-list --count` from its head
381 * to {@link BatchState.sinceLastReview}.
382 *
383 * @param countText - What git printed, or null when the count failed or did not run.
384 * @returns 0 with no review or when it saw HEAD; `gone` when its head is not a
385 * commit id or the count failed or is not a number; otherwise the count, up to 999.
386 */
387export function sinceLastReview(
388 last: LedgerEntry | undefined,
389 headSha: string,
390 countText: string | null,
391): number | 'gone' {
392 if (last === undefined || last.head === headSha) return 0
393 const n = countText?.trim() ?? ''
394 if (last.head === null || !/^\d+$/.test(n)) return 'gone'
395 return Math.min(Number(n), MAX_COUNT)
396}
397
398/** What a git call printed, when it exited 0. */
399export type GitOutput = { stdout: string; isStdoutTruncated: boolean }
400
401/**
402 * Runs one git call with the given arguments: its output on exit 0, or null on
403 * any other exit. Rejects when git times out or cannot start.
404 */
405export type GitRun = (argv: readonly string[]) => Promise<GitOutput | null>
406
407/** Reads the ledger's text, or null when it cannot be read. */
408export type LedgerRead = () => Promise<string | null>
409
410/**
411 * Runs git for the batch between origin/main and HEAD, reads its reviews, and
412 * returns what the segment draws, or null when there is no batch to draw.
413 *
414 * No batch when git finds no merge base, the log fails or passes the output
415 * cap (a cut message makes the counts untrustworthy), the batch has no
416 * commits, or the ledger exists but can't be read. The commits since the last
417 * review are counted only when it saw a commit other than HEAD.
418 *
419 * @param run - Runs one git call. A rejection passes through.
420 * @param readLedger - Reads the ledger, or null when there is no ledger yet.
421 */
422export async function computeBatch(
423 run: GitRun,
424 readLedger: LedgerRead | null,
425 workTree: string,
426 shas: { headSha: string; mainSha: string },
427): Promise<BatchState | null> {
428 const { headSha, mainSha } = shas
429 const base = (await run(mergeBaseArgv(workTree, mainSha, headSha)))?.stdout.trim() ?? ''
430 if (!FULL_SHA.test(base)) return null
431 const [log, reviews] = await Promise.all([run(logArgv(workTree, base, headSha)), reviewsOf(readLedger, base)])
432 if (log === null || log.isStdoutTruncated || reviews === null) return null
433 const messages = splitCommitMessages(log.stdout)
434 if (messages.length === 0) return null
435 const last = reviews.at(-1)
436 const from = staleReviewHead(last, headSha)
437 const counted = from === null ? null : await run(sinceArgv(workTree, from, headSha))
438 return {
439 commits: messages.length,
440 issues: countIssueRefs(messages),
441 reviews,
442 sinceLastReview: sinceLastReview(last, headSha, counted?.stdout ?? null),
443 }
444}
445
446// The batch's reviews: none without a ledger, null when it can't be read.
447async function reviewsOf(readLedger: LedgerRead | null, base: string): Promise<LedgerEntry[] | null> {
448 if (readLedger === null) return []
449 const text = await readLedger()
450 return text === null ? null : parseLedger(text, base)
451}
452
453/**
454 * Runs `compute` and turns its outcome into a cache entry for `key`. A result,
455 * including no batch, is kept until the key changes. A rejection (a git call
456 * that timed out or could not start) is kept as no batch for
457 * {@link GIT_RETRY_MS}, and returned as `error` for the caller to log.
458 */
459export async function settleBatch(
460 key: string,
461 nowMs: number,
462 compute: () => Promise<BatchState | null>,
463): Promise<{ cache: BatchCache; error?: unknown }> {
464 try {
465 return { cache: { key, batch: await compute(), retryAtMs: Infinity } }
466 } catch (error) {
467 return { cache: { key, batch: null, retryAtMs: nowMs + GIT_RETRY_MS }, error }
468 }
469}
470src/config.ts 146 lines1/**
2 * Reads paceline.json, the same file the Go paceline reads, and merges it
3 * onto the defaults. Only known keys with the right type and range are taken,
4 * so a malformed file degrades to the defaults instead of breaking the line.
5 */
6
7/** The segments that can be turned off. */
8export type Segments = {
9 model: boolean
10 effort: boolean
11 fastMode: boolean
12 project: boolean
13 branch: boolean
14 session: boolean
15 week: boolean
16 today: boolean
17 context: boolean
18 cache: boolean
19 duration: boolean
20 progress: boolean
21 /** The `/orc-loop` bar. Only the mod draws it; the Go paceline ignores the key. */
22 loop: boolean
23 /** The dev -> main review batch. Only the mod draws it; the Go paceline ignores the key. */
24 batch: boolean
25}
26
27/** Per usage segment, whether it counts down what's left instead of up. */
28export type CountDown = { session: boolean; week: boolean; today: boolean }
29
30/** The percentages where colors and warnings change. */
31export type Thresholds = {
32 headroomGreen: number
33 headroomYellow: number
34 contextWarn: number
35 contextCritical: number
36}
37
38/** paceline's merged configuration. */
39export type Config = {
40 segments: Segments
41 thresholds: Thresholds
42 countDown: CountDown
43 /** Effort levels considered everyday; any other level gets a label. */
44 quietEfforts: string[]
45 /** Pins project folder names to color slots 0-11. */
46 projectSlots: Record<string, number>
47 /** Spells out the usage labels (`session 82% used`) instead of `s 82%`. */
48 verbose: boolean
49 /**
50 * `auto` shows only the usage limit forecast to run out first; `both`
51 * shows the session and the week, as the Go paceline does. The Go paceline
52 * ignores this key.
53 */
54 limits: 'auto' | 'both'
55}
56
57/** Returns the built-in configuration. */
58export function defaultConfig(): Config {
59 return {
60 segments: {
61 model: true,
62 effort: true,
63 fastMode: true,
64 project: true,
65 branch: true,
66 session: false,
67 week: true,
68 today: true,
69 context: true,
70 cache: true,
71 duration: true,
72 progress: true,
73 loop: true,
74 batch: true,
75 },
76 thresholds: { headroomGreen: 30, headroomYellow: 15, contextWarn: 70, contextCritical: 85 },
77 countDown: { session: false, week: false, today: false },
78 quietEfforts: ['low', 'medium'],
79 projectSlots: {},
80 verbose: false,
81 limits: 'auto',
82 }
83}
84
85const isObject = (v: unknown): v is Record<string, unknown> =>
86 typeof v === 'object' && v !== null && !Array.isArray(v)
87
88// A JSON null reads as the zero value, the way the Go loader decodes it, so
89// both versions agree on the same file.
90const asBool = (v: unknown) => (typeof v === 'boolean' ? v : v === null ? false : undefined)
91const asNumber = (v: unknown) =>
92 typeof v === 'number' && Number.isFinite(v) ? v : v === null ? 0 : undefined
93
94/** Merges a parsed paceline.json document onto the defaults. */
95export function mergeConfig(doc: unknown): Config {
96 const c = defaultConfig()
97 if (!isObject(doc)) return c
98 // A section of the wrong type makes the Go loader fall back to the defaults
99 // for the whole file; match it.
100 const sections = ['segments', 'thresholds', 'countDirection', 'projectSlots'] as const
101 if (sections.some(k => doc[k] !== undefined && doc[k] !== null && !isObject(doc[k]))) return c
102
103 for (const [key, raw] of Object.entries((doc.segments as Record<string, unknown>) ?? {})) {
104 const v = asBool(raw)
105 if (key in c.segments && v !== undefined) c.segments[key as keyof Segments] = v
106 }
107
108 for (const [key, raw] of Object.entries((doc.thresholds as Record<string, unknown>) ?? {})) {
109 const v = asNumber(raw)
110 if (key in c.thresholds && v !== undefined && v >= 0 && v <= 100) {
111 c.thresholds[key as keyof Thresholds] = v
112 }
113 }
114
115 for (const [key, raw] of Object.entries((doc.countDirection as Record<string, unknown>) ?? {})) {
116 if (key in c.countDown && (raw === 'up' || raw === 'down')) {
117 c.countDown[key as keyof CountDown] = raw === 'down'
118 }
119 }
120
121 const efforts = doc.quietEfforts
122 if (Array.isArray(efforts) && efforts.every(e => typeof e === 'string' || e === null)) {
123 c.quietEfforts = efforts.map(e => e ?? '')
124 }
125
126 for (const [name, raw] of Object.entries((doc.projectSlots as Record<string, unknown>) ?? {})) {
127 const v = asNumber(raw)
128 if (v !== undefined && Number.isInteger(v) && v >= 0 && v < 12) c.projectSlots[name] = v
129 }
130
131 const verbose = asBool(doc.verbose)
132 if (verbose !== undefined) c.verbose = verbose
133
134 if (doc.limits === 'auto' || doc.limits === 'both') c.limits = doc.limits
135 return c
136}
137
138/** Parses and merges paceline.json text; text that is not JSON gives the defaults. */
139export function parseConfig(text: string): Config {
140 try {
141 return mergeConfig(JSON.parse(text))
142 } catch {
143 return defaultConfig()
144 }
145}
146src/forecast.ts 432 lines1/**
2 * Forecasts which usage limit runs out first, the five-hour session or the
3 * week. The compact line turns the pick into a warning only when it runs out
4 * before it resets; otherwise it shows neither limit.
5 *
6 * The model:
7 *
8 * - Activity: each refresh marks the local hour of the day in which usage
9 * went up. Over the recorded days that gives, per hour of the day, the
10 * chance the user is working then: a 9-to-5 user is near 1 during work
11 * hours and near 0 at night, an agent running around the clock is near 1
12 * everywhere. With no history yet, every hour is an even chance.
13 * - Burn rate: percentage points per active hour. The week uses today's
14 * average (points spent today over today's active hours), falling back to
15 * the week's average early in the day. The session uses its own window.
16 * - Projection: walk forward from now to the limit's reset, adding the burn
17 * rate times the chance of working in each stretch, and note when the
18 * limit would reach 100%.
19 *
20 * Every function here is pure: the activity record lives in the mod's store,
21 * and hooks/register.ts reads and writes it.
22 */
23
24import { dateKey, startOfDay, type Zone } from './timefmt.ts'
25import type { Limit } from './payload.ts'
26import type { LimitPick } from './render.ts'
27
28const MINUTE = 60 * 1000
29const HOUR = 60 * MINUTE
30const SESSION_MS = 5 * HOUR
31const WEEK_MS = 7 * 24 * HOUR
32const STEP_MS = 15 * MINUTE
33/** How many recorded days the activity profile keeps. */
34const KEEP_DAYS = 14
35/** Usage that rose this recently means the user is working now. */
36const ACTIVE_NOW_MS = 20 * MINUTE
37/** A rate needs at least this many active hours behind it to be trusted. */
38const MIN_RATE_HOURS = 2
39/** Neither limit runs out: switch only when the other one leads by this many points. */
40const SWITCH_MARGIN = 5
41/** Pseudo-days of evidence each way for every hour, before any history. */
42const PRIOR = 0.25
43/** With no history, a week's use is spread over at least this many hours. */
44const MIN_FALLBACK_HOURS = 3
45
46/** A day with this many active hours counts in full toward the burn rate. */
47const RATE_FULL_HOURS = 4
48/** The burn rate needs at least this much day weight behind it. */
49const MIN_RATE_WEIGHT = 0.25
50/** More weekly points than a day can spend; a day on record above it is corrupt. */
51const MAX_DAY_SPENT = 100
52/** Two readings whose reset times differ by less than this share a window. */
53const SAME_WINDOW_MS = 10 * MINUTE
54
55/**
56 * Which hours of a day had readings, and which saw usage go up, as 24-bit
57 * masks, plus the weekly points spent that day.
58 */
59export type DayActivity = { seen: number; active: number; spent?: number }
60
61/** What the mod remembers between refreshes, in its store. */
62export type Activity = {
63 /** Keyed by `yyyymmdd` in local time. */
64 days: Record<string, DayActivity>
65 /**
66 * The highest reading of each limit in its current window, to tell when
67 * usage went up, with the reset time that names the window.
68 */
69 last?: {
70 session?: number
71 sessionResetsAt?: number
72 week?: number
73 weekResetsAt?: number
74 changedAtMs?: number
75 }
76}
77
78const bit = (hour: number) => 1 << hour
79
80function hourOf(ms: number, zone: Zone): number {
81 const d = new Date(ms)
82 return zone === 'utc' ? d.getUTCHours() : d.getHours()
83}
84
85/** Reports whether `a` is a well-formed activity record from the store. */
86export function isActivity(a: unknown): a is Activity {
87 if (typeof a !== 'object' || a === null) return false
88 const days = (a as Activity).days
89 if (typeof days !== 'object' || days === null) return false
90 return Object.values(days).every(
91 d => typeof d === 'object' && d !== null && Number.isInteger(d.seen) && Number.isInteger(d.active),
92 )
93}
94
95/**
96 * Carries a record from builds before 0.4.1 over: the hours seen and active,
97 * without the last readings or any day's points spent above what a day can
98 * hold, since those builds counted an idle session's older reading as use.
99 */
100export function migrateActivity(old: unknown): Activity | null {
101 if (!isActivity(old)) return null
102 const days: Record<string, DayActivity> = {}
103 for (const [key, d] of Object.entries(old.days)) {
104 days[key] =
105 d.spent !== undefined && d.spent <= MAX_DAY_SPENT
106 ? { seen: d.seen, active: d.active, spent: d.spent }
107 : { seen: d.seen, active: d.active }
108 }
109 return { days }
110}
111
112/**
113 * Compares one limit's reading with the highest one on record. Every session
114 * on the machine records into the same store, and a session that has been
115 * idle still holds an older, lower reading, so a reading below the record
116 * counts only when its reset time says a new window began. Readings without a
117 * reset time fall back to taking any drop as a reset.
118 */
119function compare(
120 used: number | undefined,
121 resetsAt: number | undefined,
122 highest: number | undefined,
123 highestResetsAt: number | undefined,
124): { gained: number; highest?: number; resetsAt?: number } {
125 if (used === undefined) return { gained: 0, highest, resetsAt: highestResetsAt }
126 if (highest === undefined) return { gained: 0, highest: used, resetsAt }
127 if (resetsAt !== undefined && highestResetsAt !== undefined) {
128 const shift = (resetsAt - highestResetsAt) * 1000
129 // A window that ended already: a stale reading.
130 if (shift <= -SAME_WINDOW_MS) return { gained: 0, highest, resetsAt: highestResetsAt }
131 // A new window starts its record over.
132 if (shift >= SAME_WINDOW_MS) return { gained: 0, highest: used, resetsAt }
133 return { gained: Math.max(0, used - highest), highest: Math.max(used, highest), resetsAt: highestResetsAt }
134 }
135 return used < highest ? { gained: 0, highest: used, resetsAt } : { gained: used - highest, highest: used, resetsAt }
136}
137
138/**
139 * Records one reading: marks this hour as seen, and as active when either
140 * limit's usage went up since the last reading. Returns the new record, and
141 * whether it changed, so the store is written only when it must be.
142 */
143export function recordActivity(
144 prev: Activity | null,
145 nowMs: number,
146 zone: Zone,
147 session: Limit | undefined,
148 week: Limit | undefined,
149): { activity: Activity; changed: boolean } {
150 const days: Record<string, DayActivity> = { ...(prev?.days ?? {}) }
151 const key = dateKey(nowMs, zone)
152 const h = bit(hourOf(nowMs, zone))
153 const last = prev?.last
154 const s = compare(session?.usedPercentage, session?.resetsAt, last?.session, last?.sessionResetsAt)
155 const w = compare(week?.usedPercentage, week?.resetsAt, last?.week, last?.weekResetsAt)
156 const rose = s.gained > 0 || w.gained > 0
157
158 const day = days[key] ?? { seen: 0, active: 0 }
159 const next: DayActivity = { seen: day.seen | h, active: rose ? day.active | h : day.active }
160 // A total from before stale readings were ignored can't be trusted; start the day's count over.
161 const spent = day.spent !== undefined && day.spent <= MAX_DAY_SPENT ? day.spent : undefined
162 if (spent !== undefined || w.gained > 0) next.spent = (spent ?? 0) + w.gained
163 days[key] = next
164
165 // Keep only the newest days.
166 const keys = Object.keys(days).sort()
167 for (const old of keys.slice(0, Math.max(0, keys.length - KEEP_DAYS))) delete days[old]
168
169 const changedAtMs = rose ? nowMs : last?.changedAtMs
170 const activity: Activity = {
171 days,
172 last: {
173 session: s.highest,
174 sessionResetsAt: s.resetsAt,
175 week: w.highest,
176 weekResetsAt: w.resetsAt,
177 changedAtMs,
178 },
179 }
180 const changed =
181 !prev ||
182 next.seen !== day.seen ||
183 next.active !== day.active ||
184 next.spent !== day.spent ||
185 s.highest !== last?.session ||
186 s.resetsAt !== last?.sessionResetsAt ||
187 w.highest !== last?.week ||
188 w.resetsAt !== last?.weekResetsAt ||
189 keys.length > KEEP_DAYS
190 return { activity, changed }
191}
192
193/**
194 * The chance of working in each hour of the day, from the completed days on
195 * record (today is still in progress). A light prior (a quarter of a day each
196 * way) keeps a new install at an even chance and never rules an hour out,
197 * while a week of steady use moves an hour close to 0 or 1.
198 */
199export function hourProfile(a: Activity | null, nowMs: number, zone: Zone): number[] {
200 const today = dateKey(nowMs, zone)
201 const past = Object.entries(a?.days ?? {}).filter(([key]) => key < today)
202 return Array.from({ length: 24 }, (_, hour) => {
203 const active = past.filter(([, d]) => (d.active & bit(hour)) !== 0).length
204 return (active + PRIOR) / (past.length + 2 * PRIOR)
205 })
206}
207
208/** Active hours on record between two times, counting whole hours that saw usage rise. */
209function activeHoursBetween(a: Activity | null, fromMs: number, toMs: number, zone: Zone): number {
210 let hours = 0
211 // Step hour by hour from the hour that holds fromMs.
212 for (let t = fromMs - (fromMs % HOUR); t < toMs; t += HOUR) {
213 const day = a?.days[dateKey(t, zone)]
214 if (day && (day.active & bit(hourOf(t, zone))) !== 0) hours++
215 }
216 return hours
217}
218
219const countBits = (mask: number) => {
220 let n = 0
221 for (let m = mask >>> 0; m !== 0; m &= m - 1) n++
222 return n
223}
224
225/**
226 * Points per active hour, averaged over the recorded days with the newest
227 * counting most: a day's weight halves for each day back, and a day with few
228 * active hours counts for less. One hard day followed by a relaxed one then
229 * pulls the rate down instead of projecting the hard day forward. Returns
230 * undefined until a day on record has both points spent and active hours.
231 */
232function recentRate(
233 a: Activity | null,
234 nowMs: number,
235 zone: Zone,
236 spentToday: number | undefined,
237): number | undefined {
238 const today = dateKey(nowMs, zone)
239 let weighted = 0
240 let weight = 0
241 for (const [key, d] of Object.entries(a?.days ?? {})) {
242 const spent = key === today && spentToday !== undefined ? spentToday : d.spent
243 const hours = countBits(d.active)
244 if (spent === undefined || spent <= 0 || spent > MAX_DAY_SPENT || hours === 0) continue
245 const ageDays = Math.max(0, Math.round((startOfDay(nowMs, zone) - dayStartOf(key, zone)) / (24 * HOUR)))
246 const w = 0.5 ** ageDays * Math.min(hours, RATE_FULL_HOURS) / RATE_FULL_HOURS
247 weighted += (spent / hours) * w
248 weight += w
249 }
250 return weight >= MIN_RATE_WEIGHT ? weighted / weight : undefined
251}
252
253/** Epoch ms of the start of the day a `yyyymmdd` key names. */
254function dayStartOf(key: string, zone: Zone): number {
255 const y = Number(key.slice(0, 4))
256 const m = Number(key.slice(4, 6)) - 1
257 const d = Number(key.slice(6, 8))
258 return zone === 'utc' ? Date.UTC(y, m, d) : new Date(y, m, d).getTime()
259}
260
261export type Projection = {
262 /** Epoch seconds when the limit reaches 100%, or undefined if not before its reset. */
263 outAt?: number
264 /** The usage forecast at the reset, uncapped. */
265 atReset: number
266}
267
268/** Where the week's burn rate came from, in the order the forecast tries them. */
269export type WeekRateSource = 'recent-days' | 'today' | 'week-average' | 'fallback'
270
271/** Every figure `forecastLimits` worked from, for the debug tool. */
272export type ForecastTrace = {
273 /** The chance of working in each local hour, 0 to 23, to two decimals. */
274 profile: number[]
275 workingNow: boolean
276 session: { used: number; resetsAt: number; activeHours: number; rate: number; projection: Projection }
277 week: {
278 used: number
279 resetsAt: number
280 rateSource: WeekRateSource
281 rate: number
282 /** Points spent today after scaling to the hours the mod saw, if the day snapshot is known. */
283 spentToday?: number
284 /** Share of today's elapsed hours the mod has readings for, 0 to 1. */
285 coverage: number
286 todayActiveHours: number
287 weekActiveHours: number
288 projection: Projection
289 }
290}
291
292/**
293 * Walks from now to the reset in short steps, burning `rate` points per hour
294 * in proportion to the chance of working in each step.
295 */
296function project(
297 used: number,
298 rate: number,
299 nowMs: number,
300 resetMs: number,
301 chance: (ms: number) => number,
302): Projection {
303 let total = used
304 let outAt: number | undefined
305 for (let t = nowMs; t < resetMs; t += STEP_MS) {
306 const stepHours = Math.min(STEP_MS, resetMs - t) / HOUR
307 const burn = rate * chance(t) * stepHours
308 if (outAt === undefined && total < 100 && total + burn >= 100) {
309 const fraction = burn > 0 ? (100 - total) / burn : 0
310 outAt = Math.floor((t + fraction * stepHours * HOUR) / 1000)
311 }
312 total += burn
313 }
314 if (used >= 100) outAt = Math.floor(nowMs / 1000)
315 return { outAt, atReset: total }
316}
317
318/** What `forecastLimits` needs. */
319export type ForecastInput = {
320 nowMs: number
321 zone: Zone
322 session?: Limit
323 week?: Limit
324 activity: Activity | null
325 /** Weekly usage at the start of today, from the day snapshot, if known. */
326 weekAtDayStart?: number
327 /** The previous pick, so a near tie doesn't flip the line back and forth. */
328 previous?: LimitPick['show']
329}
330
331/**
332 * Picks the limit worth showing: the one forecast to run out first; if
333 * neither runs out before it resets, the one forecast to end its window
334 * closer to 100%. Returns null when there is nothing to choose between.
335 */
336export function forecastLimits(input: ForecastInput): LimitPick | null {
337 return explainForecast(input)?.pick ?? null
338}
339
340/** Like `forecastLimits`, and returns the figures behind the pick too. */
341export function explainForecast(input: ForecastInput): { pick: LimitPick; trace: ForecastTrace } | null {
342 const { nowMs, zone, activity } = input
343 const s = input.session
344 const w = input.week
345 const hasS = s?.usedPercentage !== undefined && s.resetsAt !== undefined
346 const hasW = w?.usedPercentage !== undefined && w.resetsAt !== undefined
347 if (!hasS || !hasW) return null
348
349 const profile = hourProfile(activity, nowMs, zone)
350 const workingNow =
351 activity?.last?.changedAtMs !== undefined && nowMs - activity.last.changedAtMs <= ACTIVE_NOW_MS
352 // Right now, recent use says more than the profile does.
353 const chance = (ms: number) => (workingNow && ms - nowMs < HOUR ? 1 : profile[hourOf(ms, zone)])
354
355 // The session: points per active hour since its window opened.
356 const sessionReset = s.resetsAt! * 1000
357 const sessionStart = sessionReset - SESSION_MS
358 const sessionUsed = s.usedPercentage!
359 const sessionActive = activeHoursBetween(activity, sessionStart, nowMs, zone)
360 const sessionElapsed = Math.max((nowMs - sessionStart) / HOUR, 0.25)
361 const sessionRate = sessionUsed / Math.max(sessionActive || sessionElapsed, 0.5)
362
363 // The week: today's average when today has enough behind it, else the week's.
364 const weekReset = w.resetsAt! * 1000
365 const weekStart = weekReset - WEEK_MS
366 const weekUsed = w.usedPercentage!
367 const todayStart = startOfDay(nowMs, zone)
368 const todayActive = activeHoursBetween(activity, todayStart, nowMs, zone)
369 // The day snapshot counts everything spent today, but the active hours only
370 // cover the time the mod was running. Scale the points to that coverage so a
371 // fresh install doesn't divide a whole day's use by an hour or two.
372 const seenToday = countBits(activity?.days[dateKey(nowMs, zone)]?.seen ?? 0)
373 const coverage = Math.min(1, seenToday / Math.max((nowMs - todayStart) / HOUR, 1))
374 const spentToday =
375 input.weekAtDayStart === undefined ? undefined : (weekUsed - input.weekAtDayStart) * coverage
376 const weekActive = activeHoursBetween(activity, Math.max(weekStart, nowMs - WEEK_MS), nowMs, zone)
377 const recent = recentRate(activity, nowMs, zone, spentToday)
378 let weekRate: number
379 let rateSource: WeekRateSource
380 if (recent !== undefined) {
381 weekRate = recent
382 rateSource = 'recent-days'
383 } else if (spentToday !== undefined && spentToday > 0 && todayActive >= MIN_RATE_HOURS) {
384 weekRate = spentToday / todayActive
385 rateSource = 'today'
386 } else if (weekActive >= MIN_RATE_HOURS) {
387 weekRate = weekUsed / weekActive
388 rateSource = 'week-average'
389 } else {
390 rateSource = 'fallback'
391 // No usable history: spread the week's use over its elapsed hours at an even chance.
392 weekRate = weekUsed / Math.max(((nowMs - weekStart) / HOUR) * 0.5, MIN_FALLBACK_HOURS)
393 }
394
395 const sp = project(sessionUsed, sessionRate, nowMs, sessionReset, chance)
396 const wp = project(weekUsed, weekRate, nowMs, weekReset, chance)
397
398 const round = (n: number) => Math.round(n * 100) / 100
399 const trace: ForecastTrace = {
400 profile: profile.map(round),
401 workingNow,
402 session: {
403 used: sessionUsed,
404 resetsAt: s.resetsAt!,
405 activeHours: sessionActive,
406 rate: round(sessionRate),
407 projection: sp,
408 },
409 week: {
410 used: weekUsed,
411 resetsAt: w.resetsAt!,
412 rateSource,
413 rate: round(weekRate),
414 spentToday: spentToday === undefined ? undefined : round(spentToday),
415 coverage: round(coverage),
416 todayActiveHours: todayActive,
417 weekActiveHours: weekActive,
418 projection: wp,
419 },
420 }
421
422 if (sp.outAt !== undefined || wp.outAt !== undefined) {
423 const sessionFirst = wp.outAt === undefined || (sp.outAt !== undefined && sp.outAt <= wp.outAt)
424 return { pick: sessionFirst ? { show: 'session', outAt: sp.outAt } : { show: 'week', outAt: wp.outAt }, trace }
425 }
426 const lead: LimitPick['show'] = sp.atReset >= wp.atReset ? 'session' : 'week'
427 if (input.previous && input.previous !== lead && Math.abs(sp.atReset - wp.atReset) < SWITCH_MARGIN) {
428 return { pick: { show: input.previous }, trace }
429 }
430 return { pick: { show: lead }, trace }
431}
432src/gitinfo.ts 185 lines1/**
2 * Finds a folder's git directory, its current branch, and the shas refs point
3 * at by reading `.git`, `HEAD`, the ref files, and `packed-refs` directly. No
4 * git process starts here, so a repository's config, hooks, or fsmonitor can
5 * never run code because the branch was drawn. The batch segment's narrow
6 * exception, which does run git, is recorded in docs/notes.md.
7 */
8
9/** The file reads gitinfo needs, so tests can stand in for a disk. */
10export type GitFs = {
11 /** `'file'`, `'dir'`, or `'other'`; null when the path does not exist. */
12 kind: (path: string) => Promise<'file' | 'dir' | 'other' | null>
13 /** The file's text; null when it cannot be read. */
14 read: (path: string) => Promise<string | null>
15}
16
17/** A repository found from a folder: its git directory and the work tree that holds `.git`. */
18export type Repo = { gitDir: string; workTree: string }
19
20/** What HEAD names: a branch, with a null sha while the branch has no commit yet, or a detached commit. */
21export type Head =
22 | {
23 kind: 'branch'
24 /** The full ref name, for example `refs/heads/dev`. */
25 ref: string
26 sha: string | null
27 }
28 | { kind: 'detached'; sha: string }
29
30/** A full SHA-1 or SHA-256 object id, in git's lowercase hex. */
31export const FULL_SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/
32
33const MAX_HEAD_CHARS = 512
34const MAX_BRANCH_CHARS = 32
35const MAX_REF_CHARS = 256
36const BRANCH_PREFIX = 'refs/heads/'
37// Characters git never allows in a ref name, plus the separators that could
38// turn a name read from HEAD into a path outside the git directory.
39const REF_FORBIDDEN = /[\x00-\x20\x7f~^:?*[\\]/
40
41/** Normalizes a path to forward slashes without a trailing separator. */
42export function slashPath(p: string): string {
43 const s = p.replaceAll('\\', '/')
44 return s.length > 1 && s.endsWith('/') && !/^[A-Za-z]:\/$/.test(s) ? s.replace(/\/+$/, '') : s
45}
46
47function parent(p: string): string {
48 const i = p.lastIndexOf('/')
49 if (i < 0) return p
50 if (i === 0) return '/'
51 if (/^[A-Za-z]:$/.test(p.slice(0, i))) return p.slice(0, i + 1)
52 return p.slice(0, i)
53}
54
55const join = (dir: string, name: string) => (dir.endsWith('/') ? dir + name : `${dir}/${name}`)
56const isAbsolute = (p: string) => p.startsWith('/') || /^[A-Za-z]:\//.test(p)
57
58// Joins a relative path onto `dir`, folding `.` and `..` segments.
59function resolvePath(dir: string, rel: string): string {
60 let out = dir
61 for (const part of rel.split('/')) {
62 if (part === '' || part === '.') continue
63 out = part === '..' ? parent(out) : join(out, part)
64 }
65 return out
66}
67
68async function firstLine(fs: GitFs, path: string): Promise<string | null> {
69 const text = await fs.read(path)
70 if (text === null) return null
71 return text.slice(0, MAX_HEAD_CHARS).split('\n')[0].replace(/\r$/, '')
72}
73
74function isRefName(name: string): boolean {
75 return (
76 name.length <= MAX_REF_CHARS &&
77 name.startsWith('refs/') &&
78 !name.endsWith('/') &&
79 !name.includes('..') &&
80 !name.includes('//') &&
81 !REF_FORBIDDEN.test(name)
82 )
83}
84
85// HEAD's first line as the ref it names or the commit it holds.
86function parseHead(line: string): { ref: string } | { sha: string } | null {
87 const ref = /^ref:\s*(.+)$/.exec(line)
88 if (ref) return { ref: ref[1].trim() }
89 const sha = line.trim()
90 return /^[0-9a-f]{40,64}$/.test(sha) ? { sha } : null
91}
92
93/**
94 * Finds the repository holding `dir`, walking up to the filesystem root. In a
95 * worktree or submodule, `.git` is a file that holds `gitdir: <path>`.
96 *
97 * @returns The repository, or null outside one.
98 */
99export async function findRepo(fs: GitFs, dir: string): Promise<Repo | null> {
100 let current = slashPath(dir)
101 for (;;) {
102 const dotGit = join(current, '.git')
103 const kind = await fs.kind(dotGit)
104 if (kind === 'dir') return { gitDir: dotGit, workTree: current }
105 if (kind === 'file') {
106 const line = await firstLine(fs, dotGit)
107 if (line === null || !line.startsWith('gitdir:')) return null
108 const target = slashPath(line.slice('gitdir:'.length).trim())
109 if (target === '') return null
110 return { gitDir: isAbsolute(target) ? target : join(current, target), workTree: current }
111 }
112 const up = parent(current)
113 if (up === current) return null
114 current = up
115 }
116}
117
118/**
119 * Returns the branch checked out in `gitDir`, `detached <sha7>`, or `''`.
120 * The result is raw repository data: clean it before drawing it.
121 */
122export async function branchIn(fs: GitFs, gitDir: string): Promise<string> {
123 if (gitDir === '') return ''
124 const line = await firstLine(fs, join(gitDir, 'HEAD'))
125 const head = line === null ? null : parseHead(line)
126 if (head === null) return ''
127 if ('sha' in head) return `detached ${head.sha.slice(0, 7)}`
128 if (!head.ref.startsWith(BRANCH_PREFIX)) return ''
129 const branch = [...head.ref.slice(BRANCH_PREFIX.length).trim()]
130 return branch.length > MAX_BRANCH_CHARS
131 ? branch.slice(0, MAX_BRANCH_CHARS - 1).join('') + '\u2026'
132 : branch.join('')
133}
134
135/**
136 * Gets the common git directory: `gitDir` itself, or, in a linked worktree,
137 * where its `commondir` file points. Branches, remotes, and `packed-refs` live
138 * there; `HEAD` stays in `gitDir`.
139 */
140export async function commonDir(fs: GitFs, gitDir: string): Promise<string> {
141 const line = await firstLine(fs, join(gitDir, 'commondir'))
142 const target = slashPath(line?.trim() ?? '')
143 if (target === '') return gitDir
144 return isAbsolute(target) ? target : resolvePath(gitDir, target)
145}
146
147/**
148 * Resolves a full ref name to its sha from the ref's loose file in the common
149 * git directory, falling back to that directory's `packed-refs`, as git does.
150 *
151 * @param refName - The full ref name, for example `refs/remotes/origin/main`. A
152 * name git would refuse, or one that could lead outside the git directory,
153 * resolves to null without a read.
154 * @returns The sha, or null when neither file names the ref.
155 */
156export async function resolveRef(fs: GitFs, gitDir: string, refName: string): Promise<string | null> {
157 if (gitDir === '' || !isRefName(refName)) return null
158 const common = await commonDir(fs, gitDir)
159 const loose = (await fs.read(join(common, refName)))?.trim() ?? ''
160 if (FULL_SHA.test(loose)) return loose
161 const packed = await fs.read(join(common, 'packed-refs'))
162 if (packed === null) return null
163 for (const lineText of packed.split('\n')) {
164 const [sha, name] = lineText.trim().split(' ')
165 if (name === refName && FULL_SHA.test(sha)) return sha
166 }
167 return null
168}
169
170/**
171 * Reads what HEAD names in `gitDir`, and the sha of the branch it names.
172 *
173 * @returns The head, or null when HEAD is missing, unreadable, or names
174 * something other than a valid branch under `refs/heads/` or a full sha.
175 */
176export async function readHead(fs: GitFs, gitDir: string): Promise<Head | null> {
177 if (gitDir === '') return null
178 const line = await firstLine(fs, join(gitDir, 'HEAD'))
179 const head = line === null ? null : parseHead(line)
180 if (head === null) return null
181 if ('sha' in head) return FULL_SHA.test(head.sha) ? { kind: 'detached', sha: head.sha } : null
182 if (!head.ref.startsWith(BRANCH_PREFIX) || !isRefName(head.ref)) return null
183 return { kind: 'branch', ref: head.ref, sha: await resolveRef(fs, gitDir, head.ref) }
184}
185src/live.ts 240 lines1/**
2 * Turns what a session reports through the mods API into the line.
3 *
4 * The Go paceline reads the status JSON Claude Code pipes to it; a mod asks
5 * the session instead. These functions turn those answers into the same
6 * payload shape, so render.ts runs unchanged against the parity fixtures and
7 * live. The calls on `$` themselves live in hooks/register.ts, because the
8 * engine only follows `$` within the hooks module.
9 */
10
11import type { SessionRateLimit, SessionUsage } from 'claude-code'
12
13import type { BatchState } from './batch.ts'
14import type { Config } from './config.ts'
15import { explainForecast, type Activity, type ForecastTrace } from './forecast.ts'
16import type { LoopFile } from './loop.ts'
17import { modelName } from './model.ts'
18import { isValidSnapshot, type Snapshot } from './pace.ts'
19import type { Limit, Payload } from './payload.ts'
20import type { Run } from './progress.ts'
21import { render, type Line, type LimitPick } from './render.ts'
22
23/** Where this mod keeps today's budget snapshot in its `$.store`. */
24export const SNAPSHOT_KEY = 'day'
25/**
26 * Where this mod keeps its record of active hours, for the forecast. Builds
27 * before 0.4.1 used `activity` and counted an idle session's older reading
28 * as use; they may still be running, so this build leaves that key to them
29 * and keeps its own.
30 */
31export const ACTIVITY_KEY = 'activity-v2'
32/** The key builds before 0.4.1 wrote, read once to carry the hour history over. */
33export const LEGACY_ACTIVITY_KEY = 'activity'
34
35/** What the session reported for one refresh. */
36export type Figures = {
37 usage: SessionUsage
38 modelId: string
39 cwd: string
40 root: string
41 sessionId: string
42 nowMs: number
43 settings: Readonly<Record<string, unknown>>
44 /** The effort level of the latest turn, from a classic hook event. */
45 effort?: string
46}
47
48/** Claude Code's config directory, honoring CLAUDE_CONFIG_DIR. */
49export function configDirFrom(env: {
50 claudeConfigDir?: string
51 userProfile?: string
52 home?: string
53}): string {
54 const slash = (p: string) => p.replaceAll('\\', '/').replace(/\/+$/, '')
55 if (env.claudeConfigDir) return slash(env.claudeConfigDir)
56 // USERPROFILE first: on Windows, HOME can hold a Git Bash path like /c/Users/me.
57 const home = env.userProfile || env.home
58 return home ? `${slash(home)}/.claude` : '.claude'
59}
60
61/** One rate-limit window in the payload's shape: epoch seconds for the reset. */
62export function toLimit(r: SessionRateLimit | undefined): Limit | undefined {
63 if (!r) return undefined
64 const resetsMs = r.resetsAt === undefined ? NaN : Date.parse(r.resetsAt)
65 return {
66 usedPercentage: r.percentUsed,
67 resetsAt: Number.isFinite(resetsMs) ? Math.floor(resetsMs / 1000) : undefined,
68 }
69}
70
71/**
72 * Returns the effort level the band should draw after a classic hook event:
73 * the event's level, or `cached` when the event has none.
74 *
75 * An event that carries `agent_id` fired inside a subagent, whose effort is its
76 * own and not the main thread's, so it never changes the level.
77 */
78export function turnEffort(
79 cached: string | undefined,
80 event: { effort?: { level?: string }; agent_id?: string },
81): string | undefined {
82 if (event.agent_id) return cached
83 return event.effort?.level || cached
84}
85
86/** Builds the status payload from a session's figures. */
87export function toPayload(f: Figures): Payload {
88 const perModel = (f.settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined)?.[
89 f.modelId
90 ]?.effortLevel
91 const configured = typeof perModel === 'string' ? perModel : f.settings.effortLevel
92 const effort = f.effort ?? (typeof configured === 'string' ? configured : '')
93 return {
94 model: { displayName: modelName(f.modelId) },
95 effort: effort ? { level: effort } : undefined,
96 fastMode: f.settings.fastMode === true,
97 cwd: f.cwd,
98 sessionId: f.sessionId,
99 workspace: { currentDir: f.cwd, projectDir: f.root },
100 rateLimits: {
101 fiveHour: toLimit(f.usage.rateLimits.find(r => r.kind === 'five_hour')),
102 sevenDay: toLimit(f.usage.rateLimits.find(r => r.kind === 'seven_day')),
103 },
104 contextWindow: { usedPercentage: f.usage.context.percent },
105 cost: { totalDurationMs: f.nowMs - f.usage.startedAt },
106 }
107}
108
109function parseSnapshot(text: string | null): Snapshot | null {
110 if (text === null) return null
111 try {
112 const value: unknown = JSON.parse(text)
113 return isValidSnapshot(value) ? value : null
114 } catch {
115 return null
116 }
117}
118
119/**
120 * Picks today's snapshot. When the Go paceline has already anchored this same
121 * day and week in paceline-day.json (`goText`), its anchor wins, so both lines
122 * show the same budget. Otherwise the mod's own snapshot from `$.store`
123 * (`stored`). The mod never writes the Go file.
124 */
125export function chooseSnapshot(
126 goText: string | null,
127 stored: unknown,
128 today: string,
129 week: Limit | undefined,
130): Snapshot | null {
131 const fromGo = parseSnapshot(goText)
132 if (
133 fromGo !== null &&
134 fromGo.date === today &&
135 fromGo.resetsAt === week?.resetsAt &&
136 (week.usedPercentage ?? -1) >= fromGo.usedAtStart
137 ) {
138 return fromGo
139 }
140 return isValidSnapshot(stored) ? stored : null
141}
142
143/** Everything a live render reads besides the payload and config. */
144export type LiveState = {
145 snapshot: Snapshot | null
146 branch: string
147 run: Run | null
148 /** The repository's `/orc-loop` state file, or null. */
149 loop: LoopFile | null
150 /** The repository's dev -> main review batch, or null when the segment doesn't apply or isn't known yet. */
151 batch: BatchState | null
152 activity: Activity | null
153 nowMs: number
154 /** The limit the forecast picked last time, so a near tie doesn't flip it. */
155 previous?: LimitPick['show']
156}
157
158/**
159 * The line in the compact layout, the limit the forecast picked, and the
160 * snapshot to save when today's anchor changed. Unless the config asks for
161 * both limits, the line warns only when the picked limit is forecast to run
162 * out before it resets.
163 */
164export function renderLive(
165 payload: Payload,
166 config: Config,
167 state: LiveState,
168): { line: Line; pick: LimitPick | null; trace: ForecastTrace | null; save?: Snapshot } {
169 const forecast =
170 config.limits === 'auto'
171 ? explainForecast({
172 nowMs: state.nowMs,
173 zone: 'local',
174 session: payload.rateLimits?.fiveHour,
175 week: payload.rateLimits?.sevenDay,
176 activity: state.activity,
177 weekAtDayStart: state.snapshot?.usedAtStart,
178 previous: state.previous,
179 })
180 : null
181 const pick = forecast?.pick ?? null
182 const changed: { save?: Snapshot } = {}
183 const line = render(payload, {
184 nowMs: state.nowMs,
185 zone: 'local',
186 config,
187 readSnapshot: () => state.snapshot,
188 saveSnapshot: s => {
189 changed.save = s
190 },
191 branch: () => state.branch,
192 progress: () => state.run,
193 loop: () => state.loop,
194 batch: () => state.batch,
195 compact: true,
196 limits: pick ?? undefined,
197 })
198 return { line, pick, trace: forecast?.trace ?? null, ...changed }
199}
200
201/** What `debugReport` needs besides the render's own result. */
202export type DebugInput = {
203 nowMs: number
204 payload: Payload
205 config: Config
206 state: LiveState
207 /** Which source the day snapshot came from. */
208 snapshotSource: 'go-file' | 'store' | 'none'
209 result: { pick: LimitPick | null; trace: ForecastTrace | null }
210}
211
212/**
213 * Everything behind the line's forecast as plain data, for the debug tool: the
214 * readings, the day snapshot and where it came from, the stored activity, and
215 * the forecast's own figures.
216 */
217export function debugReport(d: DebugInput) {
218 const iso = (sec: number | undefined) => (sec === undefined ? undefined : new Date(sec * 1000).toISOString())
219 const limit = (l: Limit | undefined) =>
220 l && { usedPercentage: l.usedPercentage, resetsAt: iso(l.resetsAt), resetsAtEpoch: l.resetsAt }
221 const pick = d.result.pick
222 return {
223 now: new Date(d.nowMs).toISOString(),
224 utcOffsetMinutes: -new Date(d.nowMs).getTimezoneOffset(),
225 limitsMode: d.config.limits,
226 readings: {
227 session: limit(d.payload.rateLimits?.fiveHour),
228 week: limit(d.payload.rateLimits?.sevenDay),
229 },
230 daySnapshot: { source: d.snapshotSource, value: d.state.snapshot },
231 activity: d.state.activity,
232 previousPick: d.state.previous,
233 pick: pick && { ...pick, outAtIso: iso(pick.outAt) },
234 forecast: d.result.trace,
235 note: d.result.trace
236 ? undefined
237 : 'No forecast: the config limits mode is not auto, or the session or week reading is missing.',
238 }
239}
240src/loop.ts 188 lines1/**
2 * Decodes an `/orc-loop` run's state file and draws it as the loop segment: one
3 * cell per issue in the batch, then the run's phase.
4 *
5 * orc-loop writes the file to `<git dir>/orc-loop/state.json`. Everything in
6 * it is untrusted: the decoder caps its size and the number of issues, keeps
7 * only the fields the segment draws, and reduces the in-flight marker to an
8 * issue number, so no string from the file reaches the line.
9 */
10
11import type { Segment, Span, Tone } from './render.ts'
12
13/** The largest state file the decoder accepts, in bytes. */
14export const MAX_STATE_BYTES = 256 * 1024
15const MAX_ISSUES = 50
16const MAX_REVIEW_ROUND = 99
17const REVIEW_ROUNDS = 3
18
19const MINUTE = 60 * 1000
20const DONE_HIDDEN_AFTER = 30 * MINUTE
21const PAUSED_AFTER = 6 * 60 * MINUTE
22
23// The same glyphs as src/render.ts, copied rather than imported so that
24// render.ts can import this module without the two loading each other.
25// Importing only types from render.ts adds no such cycle.
26const CELL_FULL = '\u25b0'
27const CELL_EMPTY = '\u25b1'
28const CHECK_MARK = '\u2713'
29
30const PHASES = ['plan', 'build', 'review', 'triage', 'fallow', 'comments', 'done', 'halted'] as const
31const STATUSES = ['pending', 'done', 'skipped', 'blocked'] as const
32const PAUSABLE: readonly LoopPhase[] = ['build', 'review', 'plan']
33
34// "44" or "#44": orc-loop writes the bare number while building, and a
35// hand-edited or older file may carry the hash.
36const ISSUE_REF = /^#?([1-9]\d{0,8})$/
37
38/** A phase orc-loop writes, or `other` for one this version does not know. */
39export type LoopPhase = (typeof PHASES)[number] | 'other'
40
41/** An issue's status, or `other` for a missing or unknown one. */
42export type IssueStatus = (typeof STATUSES)[number] | 'other'
43
44/** One issue in the run's batch, as the segment draws it. */
45export type LoopIssue = {
46 /** The issue number, or null when the file's value is not one. */
47 n: number | null
48 status: IssueStatus
49}
50
51/** The parts of an orc-loop state file the segment draws. */
52export type LoopState = {
53 phase: LoopPhase
54 issues: LoopIssue[]
55 /** The issue being built, or null when the run is between issues or past the build. */
56 inFlight: number | null
57 /** The review round in progress; 0 before the first. */
58 reviewRound: number
59 /** True when the run draws its own bar through paceline's progress tools. */
60 progressOn: boolean
61}
62
63/** A decoded state file and when it was last written, what {@link loopSegment} draws from. */
64export type LoopFile = {
65 state: LoopState
66 /** The file's last write time, in epoch milliseconds. */
67 mtimeMs: number
68}
69
70/** The state file inside a git directory. */
71export function loopStatePath(gitDir: string): string {
72 return `${gitDir}/orc-loop/state.json`
73}
74
75function isRecord(v: unknown): v is Record<string, unknown> {
76 return typeof v === 'object' && v !== null && !Array.isArray(v)
77}
78
79function oneOf<T extends string>(values: readonly T[], v: unknown): T | 'other' {
80 return values.find(known => known === v) ?? 'other'
81}
82
83function issueNumber(v: unknown): number | null {
84 if (typeof v === 'number') return Number.isSafeInteger(v) && v > 0 ? v : null
85 if (typeof v !== 'string') return null
86 const m = ISSUE_REF.exec(v)
87 return m ? Number(m[1]) : null
88}
89
90function reviewRound(v: unknown): number {
91 return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_REVIEW_ROUND ? v : 0
92}
93
94function parseIssue(v: unknown): LoopIssue | null {
95 if (!isRecord(v)) return null
96 return { n: issueNumber(v.n), status: oneOf(STATUSES, v.status) }
97}
98
99/**
100 * Decodes a state file's text, or returns null when it is oversized, not JSON,
101 * has no string `phase`, or has an `issues` value that is not a list of up to
102 * 50 objects. Missing optional fields take their empty values.
103 */
104export function decodeLoopState(fileText: string): LoopState | null {
105 if (new TextEncoder().encode(fileText).length > MAX_STATE_BYTES) return null
106 let doc: unknown
107 try {
108 doc = JSON.parse(fileText)
109 } catch {
110 return null
111 }
112 if (!isRecord(doc) || typeof doc.phase !== 'string') return null
113 const issuesRaw = doc.issues ?? []
114 if (!Array.isArray(issuesRaw) || issuesRaw.length > MAX_ISSUES) return null
115
116 const issues: LoopIssue[] = []
117 for (const raw of issuesRaw) {
118 const issue = parseIssue(raw)
119 if (issue === null) return null
120 issues.push(issue)
121 }
122 return {
123 phase: oneOf(PHASES, doc.phase),
124 issues,
125 inFlight: issueNumber(doc.inFlight),
126 reviewRound: reviewRound(doc.reviewRound),
127 progressOn: isRecord(doc.progress) && Boolean(doc.progress.on),
128 }
129}
130
131// The same helper as in src/render.ts, copied for the reason given above CELL_FULL.
132const span = (text: string, tone: Tone = 'plain'): Span => ({ text, tone })
133
134function cell(issue: LoopIssue, inFlight: number | null): Span {
135 if (issue.status === 'done') return span(CELL_FULL, 'green')
136 if (issue.status === 'blocked') return span(CELL_FULL, 'red')
137 if (issue.status === 'skipped') return span(CELL_FULL, 'dim')
138 if (issue.n !== null && issue.n === inFlight) return span(CELL_FULL, 'cyan')
139 return span(CELL_EMPTY, 'dim')
140}
141
142// Neighbors with the same glyph and tone share one span.
143function cells(s: LoopState): Span[] {
144 const out: Span[] = []
145 for (const issue of s.issues) {
146 const next = cell(issue, s.inFlight)
147 const last = out.at(-1)
148 if (last && last.tone === next.tone && last.text.startsWith(next.text)) last.text += next.text
149 else out.push(next)
150 }
151 return out
152}
153
154function label(s: LoopState): Span {
155 if (s.phase === 'halted') return span('halted', 'red')
156 if (s.phase === 'done') return span(CHECK_MARK + ' done', 'green')
157 if (s.phase === 'plan') return span('planning')
158 if (s.phase === 'review') return span(`review ${Math.max(s.reviewRound, 1)}/${REVIEW_ROUNDS}`)
159 const settled = s.issues.filter(i => i.status !== 'pending').length
160 const current = s.inFlight === null ? '' : `#${s.inFlight} `
161 return span(`${current}${settled}/${s.issues.length}`)
162}
163
164/**
165 * Draws the run as "loop cells label", or returns null when it is hidden.
166 *
167 * The segment hides when there is no state, when the run draws its own bar
168 * through paceline's progress tools, and once a finished run's file is over 30
169 * minutes old. A build, review, or plan whose file has gone 6 hours without a
170 * write gets a dim " (paused)" after its label.
171 *
172 * @param state - The decoded state file, or null when it is missing or unreadable.
173 * @param mtimeMs - The state file's last write time, in epoch milliseconds.
174 * @param nowMs - The current time, in epoch milliseconds.
175 */
176export function loopSegment(state: LoopState | null, mtimeMs: number, nowMs: number): Segment | null {
177 if (state === null || state.progressOn) return null
178 const age = nowMs - mtimeMs
179 if (state.phase === 'done' && age > DONE_HIDDEN_AFTER) return null
180
181 const out: Span[] = [span('loop'), span(' ')]
182 const drawn = cells(state)
183 if (drawn.length > 0) out.push(...drawn, span(' '))
184 out.push(label(state))
185 if (PAUSABLE.includes(state.phase) && age > PAUSED_AFTER) out.push(span(' (paused)', 'dim'))
186 return out
187}
188src/progress.ts 189 lines1/**
2 * Reads the progress file: a small JSON record of a long job's steps that the
3 * line draws as a bar.
4 *
5 * The file lives in the repository's git directory, at
6 * `paceline/progress-<session id>.json` for one Claude Code session's run, or
7 * `paceline/progress.json` for a writer outside Claude Code. The format is the
8 * one the Go paceline and paceline-mcp share. Everything in it is untrusted:
9 * the reader caps its size and rejects anything malformed, and every string
10 * is cleaned before it is drawn.
11 */
12
13export const MAX_FILE_BYTES = 32 * 1024
14const MAX_STEPS = 50
15const MAX_STAGES = 12
16const MAX_LABEL_CHARS = 40
17const MAX_WEIGHT = 1000
18const MAX_SESSION_LEN = 64
19
20const MINUTE = 60 * 1000
21const PAUSED_AFTER = 60 * MINUTE
22const QUIET_HIDDEN_FROM = 4 * 60 * MINUTE
23const DONE_HIDDEN_FROM = 30 * MINUTE
24
25// 0001-01-01T00:00:00Z, Go's zero time, which the format rejects as unset.
26const ZERO_TIME_MS = -62135596800000
27
28const PHASES = ['running', 'done', 'halted'] as const
29const STATUSES = ['pending', 'active', 'done', 'skipped', 'blocked'] as const
30
31export type Phase = (typeof PHASES)[number]
32export type Status = (typeof STATUSES)[number]
33
34export type Step = {
35 id: string
36 label: string
37 status: Status
38 /** Size relative to the other steps; 0 counts as 1. */
39 weight: number
40 stages: string[]
41 stage: string
42}
43
44export type Run = {
45 name: string
46 phase: Phase
47 label: string
48 steps: Step[]
49 updatedAtMs: number
50}
51
52/**
53 * Accepts a Claude Code session id: letters, digits, and dashes, so it can
54 * never name a path outside the progress directory.
55 */
56export function isValidSession(session: string): boolean {
57 return session.length > 0 && session.length <= MAX_SESSION_LEN && /^[0-9A-Za-z-]+$/.test(session)
58}
59
60/** The progress file inside a git directory for a session, or the shared file. */
61export function progressPath(gitDir: string, session: string): string {
62 const name = isValidSession(session) ? `progress-${session}.json` : 'progress.json'
63 return `${gitDir}/paceline/${name}`
64}
65
66// A JSON null decodes to the field's zero value, as it does for the Go reader.
67function text(v: unknown): string | undefined {
68 if (v === undefined || v === null) return ''
69 return typeof v === 'string' ? v : undefined
70}
71
72function textOk(s: string, required: boolean): boolean {
73 return !(required && s === '') && [...s].length <= MAX_LABEL_CHARS
74}
75
76// RFC 3339, as Go's time.Time decodes it.
77const RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/
78
79function parseStep(v: unknown): Step | null {
80 if (typeof v !== 'object' || v === null || Array.isArray(v)) return null
81 const o = v as Record<string, unknown>
82 const id = text(o.id)
83 const label = text(o.label)
84 const status = text(o.status)
85 const stage = text(o.stage)
86 const weight = o.weight === undefined || o.weight === null ? 0 : o.weight
87 const stagesRaw = o.stages === undefined || o.stages === null ? [] : o.stages
88 if (id === undefined || label === undefined || status === undefined || stage === undefined) return null
89 if (typeof weight !== 'number' || !Number.isFinite(weight)) return null
90 if (!Array.isArray(stagesRaw)) return null
91 const stages = stagesRaw.map(text)
92 if (stages.some(s => s === undefined)) return null
93
94 if (!textOk(id, true) || !textOk(label, false)) return null
95 if (!(STATUSES as readonly string[]).includes(status)) return null
96 if (weight < 0 || weight > MAX_WEIGHT) return null
97 if (stages.length > MAX_STAGES || !stages.every(s => textOk(s as string, true))) return null
98 if (stage !== '' && !stages.includes(stage)) return null
99 return { id, label, status: status as Status, weight, stages: stages as string[], stage }
100}
101
102/**
103 * Decodes and validates a progress file's text, or returns null when it is
104 * oversized, not JSON, or breaks any rule of the format.
105 */
106export function parseRun(fileText: string): Run | null {
107 if (new TextEncoder().encode(fileText).length > MAX_FILE_BYTES) return null
108 let doc: unknown
109 try {
110 doc = JSON.parse(fileText)
111 } catch {
112 return null
113 }
114 if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) return null
115 const o = doc as Record<string, unknown>
116 if (o.version !== 1) return null
117 const name = text(o.name)
118 const phase = text(o.phase)
119 const label = text(o.label)
120 if (name === undefined || phase === undefined || label === undefined) return null
121 if (!textOk(name, true) || !textOk(label, false)) return null
122 if (!(PHASES as readonly string[]).includes(phase)) return null
123 if (!Array.isArray(o.steps) || o.steps.length === 0 || o.steps.length > MAX_STEPS) return null
124 if (typeof o.updatedAt !== 'string' || !RFC3339.test(o.updatedAt)) return null
125 const updatedAtMs = Date.parse(o.updatedAt)
126 if (!Number.isFinite(updatedAtMs) || updatedAtMs === ZERO_TIME_MS) return null
127
128 const steps: Step[] = []
129 const seen = new Set<string>()
130 for (const raw of o.steps) {
131 const step = parseStep(raw)
132 if (step === null || seen.has(step.id)) return null
133 seen.add(step.id)
134 steps.push(step)
135 }
136 return { name, phase: phase as Phase, label, steps, updatedAtMs }
137}
138
139/**
140 * Reports whether the run belongs on the line: a finished run shows for a
141 * while, and any other run hides once it has gone quiet long enough that its
142 * writer is gone.
143 */
144export function isVisible(r: Run, nowMs: number): boolean {
145 const age = nowMs - r.updatedAtMs
146 return r.phase === 'done' ? age <= DONE_HIDDEN_FROM : age <= QUIET_HIDDEN_FROM
147}
148
149/** Reports whether a running run has been quiet so long its writer likely stopped. */
150export function isPaused(r: Run, nowMs: number): boolean {
151 return r.phase === 'running' && nowMs - r.updatedAtMs > PAUSED_AFTER
152}
153
154/** Reports whether any step has a weight or stages, which makes `percent` more than a count. */
155export function isPlanned(r: Run): boolean {
156 return r.steps.some(s => s.weight > 0 || s.stages.length > 0)
157}
158
159/** The first active step's index, or -1. */
160export function activeIndex(r: Run): number {
161 return r.steps.findIndex(s => s.status === 'active')
162}
163
164/** How many steps are no longer pending or active. */
165export function settledCount(r: Run): number {
166 return r.steps.filter(s => s.status !== 'pending' && s.status !== 'active').length
167}
168
169/**
170 * The weighted share of planned work finished, 0 to 100. Skipped and blocked
171 * steps leave the total, since the run will not do them. An active step counts
172 * the stages it has finished: a step in the third of four stages is half done.
173 */
174export function percent(r: Run): number {
175 let total = 0
176 let finished = 0
177 for (const s of r.steps) {
178 if (s.status === 'skipped' || s.status === 'blocked') continue
179 const w = s.weight === 0 ? 1 : s.weight
180 total += w
181 if (s.status === 'done') finished += w
182 if (s.status === 'active') {
183 const i = s.stages.indexOf(s.stage)
184 if (i > 0) finished += (w * i) / s.stages.length
185 }
186 }
187 return total === 0 ? 100 : (100 * finished) / total
188}
189src/render.ts 523 lines1/**
2 * Builds paceline's line from a status payload.
3 *
4 * The line is a list of segments, each a list of colored spans, so the same
5 * result can be drawn as elements in the band above the prompt (band.ts) or
6 * as ANSI text (`toAnsi`). In the Go layout, the tests compare that text byte
7 * for byte with what the Go paceline prints; the compact layout is the mod's
8 * own.
9 */
10
11import { batchSegment, type BatchState } from './batch.ts'
12import { headroomLevel, projectSlot, slotColor, type RGB } from './color.ts'
13import type { Config } from './config.ts'
14import { loopSegment, type LoopFile } from './loop.ts'
15import { computeBudget, EVEN_PACE, type Budget, type Snapshot } from './pace.ts'
16import type { Payload } from './payload.ts'
17import {
18 activeIndex,
19 isPaused,
20 isPlanned,
21 isVisible,
22 percent,
23 settledCount,
24 type Run,
25 type Status,
26} from './progress.ts'
27import { clean } from './sanitize.ts'
28import { clock, duration, floorInDay, startOfDay, weekday, type Zone } from './timefmt.ts'
29
30const MIDDLE_DOT = '\u00b7'
31const CELL_FULL = '\u25b0'
32const CELL_EMPTY = '\u25b1'
33const CHECK_MARK = '\u2713'
34const ELLIPSIS = '\u2026'
35const RIGHT_ARROW = '\u2192'
36// Keeps a long run from pushing the rest of the line off-screen.
37const MAX_CELLS = 20
38// Today's bar: budget left (or overage), budget spent, the part of the daily
39// base below a smaller budget, the base marker, and the bar's ends.
40const BAR_FULL = '\u2588'
41const BAR_DRAINED = '\u2591'
42const BAR_BELOW = '\u2592'
43const BAR_MARKER = '\u2502'
44const BAR_LEFT = '\u2595'
45const BAR_RIGHT = '\u258f'
46// Twice the daily base, so a big carryover late in the week doesn't push the
47// rest of the line off-screen.
48const MAX_BAR_CELLS = 28
49const MINUTE_MS = 60 * 1000
50const HOUR_MS = 60 * MINUTE_MS
51const DAY_MS = 24 * HOUR_MS
52/**
53 * A session's out time is shown to the quarter hour: a session always runs
54 * out within five hours of now, so a finer time is worth showing.
55 */
56const SESSION_OUT_STEP_MS = 15 * MINUTE_MS
57/** A week's out time is shown to the hour. */
58const WEEK_OUT_STEP_MS = HOUR_MS
59
60/** Windows Terminal's default red with 15% less chroma, easier on the eyes. */
61export const MUTED_RED: RGB = [185, 47, 47]
62
63/**
64 * A darker, grayer MUTED_RED for the part of the daily base that earlier
65 * overspending took from today's budget: where the day started, not a
66 * warning.
67 */
68const FAINT_RED: RGB = [136, 66, 66]
69
70/** How a span is colored. A named tone follows the terminal's own palette. */
71export type Tone = 'plain' | 'cyan' | 'green' | 'yellow' | 'red' | 'dim' | RGB
72
73export type Span = { text: string; tone: Tone; bold?: boolean }
74export type Segment = Span[]
75export type Line = Segment[]
76
77/** Everything the renderer needs besides the payload. */
78export type RenderContext = {
79 nowMs: number
80 zone: Zone
81 config: Config
82 readSnapshot?: () => Snapshot | null
83 saveSnapshot?: (s: Snapshot) => void
84 /** The git branch of the repository holding `dir`, or `''`. */
85 branch?: (dir: string) => string
86 /** The progress run of the repository holding `dir`, or null. */
87 progress?: (dir: string) => Run | null
88 /** The `/orc-loop` state file of the session's repository, or null. Only the compact layout draws it. */
89 loop?: () => LoopFile | null
90 /** Where the session's dev -> main batch stands in review, or null. Only the compact layout draws it. */
91 batch?: () => BatchState | null
92 /**
93 * Draws the mod's compact layout: the project ahead of the model, effort as
94 * the bare level, a limit's run-out time when the forecast says it runs out
95 * before it resets, and the loop and batch segments before the duration.
96 * Absent, the line is the Go paceline's, byte for byte.
97 */
98 compact?: boolean
99 /**
100 * Which usage limit the forecast picked. Only the compact layout reads it:
101 * when the pick runs out before it resets, its segment says when ("out
102 * ~Tue") in place of the week's days left or the session's reset time.
103 */
104 limits?: LimitPick
105}
106
107/** The usage limit the forecast picked, and when it is forecast to run out, if before it resets. */
108export type LimitPick = {
109 show: 'session' | 'week'
110 /** Epoch seconds when the picked limit is forecast to run out. Absent when it lasts until it resets. */
111 outAt?: number
112}
113
114/** The pieces of the line. Each layout draws them in its own order. */
115type Part =
116 | 'project'
117 | 'model'
118 | 'effort'
119 | 'fastMode'
120 | 'session'
121 | 'today'
122 | 'week'
123 | 'context'
124 | 'cache'
125 | 'progress'
126 | 'loop'
127 | 'batch'
128 | 'duration'
129
130const GO_ORDER: readonly Part[] = [
131 'model',
132 'effort',
133 'fastMode',
134 'project',
135 'session',
136 'today',
137 'week',
138 'context',
139 'cache',
140 'progress',
141 'duration',
142]
143
144const COMPACT_ORDER: readonly Part[] = [
145 'project',
146 'model',
147 'effort',
148 'fastMode',
149 'session',
150 'today',
151 'week',
152 'context',
153 'cache',
154 'progress',
155 'loop',
156 'batch',
157 'duration',
158]
159
160const span = (text: string, tone: Tone = 'plain'): Span => ({ text, tone })
161const spaced = (pieces: Span[][]) => pieces.flatMap((piece, i) => (i === 0 ? piece : [span(' '), ...piece]))
162const present = (segment: Segment | null): Segment[] => (segment ? [segment] : [])
163
164/** Rounds halves up, as the Go paceline (and its JavaScript 1.0) does. */
165export const round = (v: number) => Math.floor(v + 0.5)
166
167// Go's \s is ASCII whitespace only; JavaScript's would also match Unicode spaces.
168const MODEL_SUFFIX = /[\t\n\f\r ]*\(.*\)$/
169
170/** The last path segment, for either separator style. */
171function leaf(p: string): string {
172 const s = p.replace(/[\\/]+$/, '').replaceAll('\\', '/')
173 if (s === '') return '.'
174 return s.slice(s.lastIndexOf('/') + 1)
175}
176
177function workDir(p: Payload): string {
178 return p.workspace?.currentDir || p.cwd || ''
179}
180
181/** The short label, or the long one when the config is verbose. */
182function label(ctx: RenderContext, short: string, long: string): string {
183 return ctx.config.verbose ? long : short
184}
185
186/** `text` in the headroom tone for `left` percent left. */
187function headroom(ctx: RenderContext, left: number, text: string): Span {
188 const th = ctx.config.thresholds
189 return span(text, headroomLevel(left, th.headroomGreen, th.headroomYellow))
190}
191
192/** Returns the line for `p`, in the Go layout unless `ctx.compact` is set. */
193export function render(p: Payload, ctx: RenderContext): Line {
194 const on = ctx.config.segments
195 const pick = ctx.compact ? ctx.limits : undefined
196 const sd = p.rateLimits?.sevenDay
197 const r = (on.today || on.week) && sd ? budgetFor(sd, ctx) : null
198 const parts: Record<Part, Segment[]> = {
199 project: on.project ? present(project(p, ctx)) : [],
200 model: on.model ? present(model(p)) : [],
201 effort: on.effort ? present(effort(p, ctx)) : [],
202 fastMode: on.fastMode && p.fastMode === true ? [[span('fast mode', 'yellow')]] : [],
203 session: on.session ? session(p, ctx, runOut(pick, 'session', ctx)) : [],
204 today: on.today && r ? [today(r, ctx)] : [],
205 week: on.week && sd?.usedPercentage !== undefined ? week(sd.usedPercentage, r, ctx, runOut(pick, 'week', ctx)) : [],
206 context: on.context ? present(contextWarning(p, ctx)) : [],
207 cache: on.cache ? present(cacheWarning(p, ctx)) : [],
208 progress: on.progress ? present(progress(p, ctx)) : [],
209 loop: on.loop ? present(loop(ctx)) : [],
210 batch: on.batch ? present(batch(ctx)) : [],
211 duration: on.duration ? present(elapsed(p)) : [],
212 }
213 return (ctx.compact ? COMPACT_ORDER : GO_ORDER).flatMap(part => parts[part])
214}
215
216// Folder, colored by project. The color comes from the project root, so a
217// subfolder keeps its project's color.
218function project(p: Payload, ctx: RenderContext): Segment | null {
219 const dir = workDir(p)
220 if (dir === '') return null
221 const projectDir = p.workspace?.projectDir || dir
222 const slot = projectSlot(leaf(projectDir), ctx.config.projectSlots)
223 const segment: Segment = [span(clean(leaf(dir), 64), slotColor(slot))]
224 if (ctx.config.segments.branch && ctx.branch) {
225 const branch = clean(ctx.branch(dir), 40)
226 if (branch !== '') segment.push(span(' '), span('(', 'dim'), span(branch), span(')', 'dim'))
227 }
228 return segment
229}
230
231// The model, minus any "(1M context)"-style suffix.
232function model(p: Payload): Segment | null {
233 if (!p.model) return null
234 const name = clean(p.model.displayName, 64).replace(MODEL_SUFFIX, '')
235 return name === '' ? null : [span(name, 'cyan')]
236}
237
238// Effort, only above the everyday levels. The compact layout shows the level
239// alone ("high") unless the config is verbose.
240function effort(p: Payload, ctx: RenderContext): Segment | null {
241 if (!p.effort) return null
242 const level = clean(p.effort.level, 16)
243 if (level === '' || ctx.config.quietEfforts.includes(level)) return null
244 const text = ctx.compact ? label(ctx, level, level + ' effort') : level + ' effort'
245 return [span(text, 'dim')]
246}
247
248// The five-hour limit. It counts up what's used from 0%, or down what's left
249// from 100% when its countDirection is "down". Color always follows what's
250// left, so either way the segment turns red near the limit. A reading past
251// 100% shows 100% used or 0% left, never more or negative. When the session
252// runs out before it resets, `out` follows in place of the reset time.
253function session(p: Payload, ctx: RenderContext, out: Span | null): Segment[] {
254 const fh = p.rateLimits?.fiveHour
255 if (fh?.usedPercentage === undefined) return []
256 const used = round(fh.usedPercentage)
257 const left = Math.max(0, 100 - used)
258 const shown = ctx.config.countDown.session
259 ? `${left}%${label(ctx, '', ' left')}`
260 : `${Math.min(Math.max(used, 0), 100)}%${label(ctx, '', ' used')}`
261 const name = label(ctx, 's', 'session')
262 if (out) return [[headroom(ctx, left, `${name} ${shown}`)], [out]]
263 const when =
264 left < ctx.config.thresholds.headroomGreen && fh.resetsAt !== undefined
265 ? ` (resets ${clock(fh.resetsAt, ctx.nowMs, ctx.zone)})`
266 : ''
267 return [[headroom(ctx, left, `${name} ${shown}${when}`)]]
268}
269
270// When the forecast says `limit` runs out before it resets, when: the weekday
271// alone ("out ~Tue") in yellow while that is more than a day away, then the
272// time ("out ~9pm") in red. A session is always within five hours, so it
273// always shows the time. The time rounds down, to the hour for the week and
274// the quarter hour for the session, so it never promises more time than the
275// forecast gives, unless rounding would land before now: then it shows the
276// exact minute instead of a time already passed. The "~" marks an estimate,
277// as on today's last-day "out ~4pm".
278function runOut(pick: LimitPick | undefined, limit: LimitPick['show'], ctx: RenderContext): Span | null {
279 if (pick?.show !== limit || pick.outAt === undefined) return null
280 const outMs = pick.outAt * 1000
281 const isSession = limit === 'session'
282 if (!isSession && outMs - ctx.nowMs > DAY_MS) return span(`out ~${weekday(outMs, ctx.zone)}`, 'yellow')
283 const step = isSession ? SESSION_OUT_STEP_MS : WEEK_OUT_STEP_MS
284 const rounded = floorInDay(outMs, step, startOfDay(outMs, ctx.zone))
285 const at = rounded < ctx.nowMs ? outMs : rounded
286 return span(`out ~${clock(at / 1000, ctx.nowMs, ctx.zone)}`, 'red')
287}
288
289// Today's budget, from the weekly limit, or null without a reset time or once
290// it has passed. Reading it may re-anchor the day, so the snapshot is saved
291// here, for a week-only line too: the week needs the day count, and computing
292// it anchors the day.
293function budgetFor(sd: { usedPercentage?: number; resetsAt?: number }, ctx: RenderContext): Budget | null {
294 if (sd.usedPercentage === undefined || sd.resetsAt === undefined) return null
295 const r = computeBudget(sd.usedPercentage, sd.resetsAt, ctx.nowMs, ctx.readSnapshot?.() ?? null, ctx.zone)
296 if (r?.snapshotChanged) ctx.saveSnapshot?.(r.snapshot)
297 return r
298}
299
300/** The even daily budget in whole weekly points, where today's bar puts its marker. */
301const DAILY_BASE = round(EVEN_PACE)
302
303// Today as "Today: <bar> 7/12": the bar, then the weekly points spent of
304// today's budget. Counting down, the numbers show what's left ("5/12 left")
305// or, past the budget, how far over ("3 over/12"). A budget above the even
306// daily base adds what's left of that surplus ("+4 saved"). Over budget,
307// tomorrow's budget follows ("-> 11 tmrw"), unless the week has nothing left
308// for it or it is the last day, when the reset brings a fresh week. On the
309// last day, the time the week runs out at today's pace follows instead, when
310// that comes before the reset.
311function today(r: Budget, ctx: RenderContext): Segment {
312 const budget = round(r.budget)
313 // A reading just past the budget can round to it; never show 12/12 in red.
314 const spent = r.over ? Math.max(round(r.spent), budget + 1) : round(r.spent)
315 const left = budget - spent
316 const text = !ctx.config.countDown.today
317 ? `${spent}/${budget}`
318 : r.over
319 ? `${-left} over/${budget}`
320 : `${left}/${budget} left`
321 const pieces: Span[][] = [
322 [span('Today:')],
323 todayBar(budget, spent),
324 [r.over ? span(text, 'red') : headroom(ctx, round(r.pctLeft), text)],
325 ]
326 const tomorrow = round(r.tomorrow)
327 if (left > DAILY_BASE) pieces.push([span(`+${left - DAILY_BASE} saved`, 'green')])
328 else if (r.over && tomorrow > 0) pieces.push([span(`${RIGHT_ARROW} ${tomorrow} tmrw`, 'red')])
329 // computeBudget sets runsOutAt only on the last day.
330 if (r.runsOutAt > 0) pieces.push([span('out ~' + clock(r.runsOutAt, ctx.nowMs, ctx.zone), 'red')])
331 return spaced(pieces)
332}
333
334// Today's budget at one cell per weekly point, against a marker at the even
335// daily base. Full cells on the left are the budget left, drained cells the
336// rest of the base, and a faint red stretch the part of the base below a
337// smaller budget. A budget above the base starts with full cells past the
338// marker, and spending removes those first, so the bar shortens. Over budget,
339// the same full cells turn red and count the overage instead. A bar longer
340// than MAX_BAR_CELLS scales down to fit, marker included.
341function todayBar(budget: number, spent: number): Span[] {
342 const over = spent > budget
343 const full = over ? spent - budget : budget - spent
344 const length = Math.max(DAILY_BASE, full)
345 const scale = Math.min(1, MAX_BAR_CELLS / length)
346 const cellCount = round(length * scale)
347 const marker = round(DAILY_BASE * scale)
348
349 // Classifies a cell by the weekly point at its middle.
350 const kind = (i: number) => {
351 const point = (i + 0.5) / scale
352 if (point < full) return BAR_FULL
353 if (point < Math.min(budget, DAILY_BASE)) return BAR_DRAINED
354 return BAR_BELOW
355 }
356 const tone = (k: string): Tone =>
357 k === BAR_FULL ? (over ? 'red' : 'cyan') : k === BAR_BELOW ? FAINT_RED : 'dim'
358
359 const out: Span[] = [span(BAR_LEFT, 'dim')]
360 // Neighbors of the same kind share one span; the marker splits a run.
361 for (let i = 0; i < cellCount; ) {
362 let j = i + 1
363 while (j < cellCount && j !== marker && kind(j) === kind(i)) j++
364 out.push(span(kind(i).repeat(j - i), tone(kind(i))))
365 if (j === marker) out.push(span(BAR_MARKER))
366 i = j
367 }
368 // When nothing extends past the base, the marker closes the bar; an end cap
369 // right after it would read as a leftover sliver of surplus.
370 if (cellCount > marker) out.push(span(BAR_RIGHT, 'dim'))
371 return out
372}
373
374// The week as "w 58/100", then the days left until the reset, or `out` when
375// the week runs out before it resets. From three days out the week figure
376// turns bold: late in the week a single day absorbs all the slack or debt, so
377// today's budget can swing, and the week is the steadier reference.
378function week(usedPct: number, r: Budget | null, ctx: RenderContext, out: Span | null): Segment[] {
379 const used = Math.min(Math.max(round(usedPct), 0), 100)
380 const left = 100 - used
381 const name = label(ctx, 'w', 'week')
382 const figure = headroom(ctx, left, ctx.config.countDown.week ? `${name} ${left}/100 left` : `${name} ${used}/100`)
383 if (r !== null && round(r.daysLeft) <= 3) figure.bold = true
384 if (out) return [[figure], [out]]
385 if (r === null) return [[figure]]
386 return [[figure], [span(r.lastDay ? 'last day' : `${round(r.daysLeft)}d left`, 'dim')]]
387}
388
389// Context-window warning, silent while there is headroom.
390function contextWarning(p: Payload, ctx: RenderContext): Segment | null {
391 const ctxUsed = p.contextWindow?.usedPercentage
392 if (ctxUsed === undefined) return null
393 const th = ctx.config.thresholds
394 const used = round(ctxUsed)
395 if (used >= th.contextCritical) return [span(`ctx ${used}%`, 'red')]
396 if (used >= th.contextWarn) return [span(`ctx ${used}%`, 'yellow')]
397 return null
398}
399
400// Prompt-cache warning, only once the cache has gone cold.
401function cacheWarning(p: Payload, ctx: RenderContext): Segment | null {
402 const pc = p.promptCache
403 if (pc?.expiresAt === undefined || pc.expiresAt > Math.floor(ctx.nowMs / 1000)) return null
404 const text =
405 pc.recacheTokensIfCold !== undefined
406 ? `cache cold: ${round(pc.recacheTokensIfCold / 1000)}k @ ~2x`
407 : 'cache cold: ~2x'
408 return [span(text, 'red')]
409}
410
411function progress(p: Payload, ctx: RenderContext): Segment | null {
412 const dir = workDir(p)
413 if (dir === '' || !ctx.progress) return null
414 const run = ctx.progress(dir)
415 return run ? progressSegment(run, ctx.nowMs) : null
416}
417
418function loop(ctx: RenderContext): Segment | null {
419 const file = ctx.loop?.()
420 return file ? loopSegment(file.state, file.mtimeMs, ctx.nowMs) : null
421}
422
423function batch(ctx: RenderContext): Segment | null {
424 const state = ctx.batch?.()
425 return state ? batchSegment(state) : null
426}
427
428// Duration comes last in both layouts, so it sits at the right edge.
429function elapsed(p: Payload): Segment | null {
430 const ms = p.cost?.totalDurationMs
431 return ms !== undefined && ms > 0 ? [span(duration(ms), 'dim')] : null
432}
433
434/**
435 * Draws a run as "name cells status": one cell per step, colored by its
436 * status, then the active step and how far the run has come.
437 */
438function progressSegment(r: Run, nowMs: number): Segment | null {
439 if (!isVisible(r, nowMs)) return null
440 const parts: Span[][] = [[span(clean(r.name, 16))], cells(r)]
441 if (r.phase === 'halted') parts.push([span('halted', 'red')])
442 else if (r.phase === 'done') parts.push([span(CHECK_MARK + ' done', 'green')])
443 else {
444 const status = [span(progressStatus(r))]
445 if (isPaused(r, nowMs)) status.push(span(' (paused)', 'dim'))
446 parts.push(status)
447 }
448 return spaced(parts.filter(part => part.some(s => s.text !== '')))
449}
450
451const CELL_TONE: Record<Status, Tone> = {
452 done: 'green',
453 active: 'cyan',
454 skipped: 'dim',
455 blocked: 'red',
456 pending: 'dim',
457}
458
459// Up to MAX_CELLS cells. A longer run shows a window around the active (or
460// first pending) step, with an ellipsis on each side that was cut.
461function cells(r: Run): Span[] {
462 const steps = r.steps
463 let start = 0
464 let end = steps.length
465 if (steps.length > MAX_CELLS) {
466 let focus = activeIndex(r)
467 if (focus < 0) focus = steps.findIndex(s => s.status === 'pending')
468 if (focus < 0) focus = steps.length - 1
469 start = Math.min(Math.max(focus - Math.trunc(MAX_CELLS / 2), 0), steps.length - MAX_CELLS)
470 end = start + MAX_CELLS
471 }
472 const out: Span[] = []
473 if (start > 0) out.push(span(ELLIPSIS, 'dim'))
474 // Neighbors with the same status share one span.
475 for (let i = start; i < end; ) {
476 let j = i + 1
477 while (j < end && steps[j].status === steps[i].status) j++
478 const glyph = steps[i].status === 'pending' ? CELL_EMPTY : CELL_FULL
479 out.push(span(glyph.repeat(j - i), CELL_TONE[steps[i].status]))
480 i = j
481 }
482 if (end < steps.length) out.push(span(ELLIPSIS, 'dim'))
483 return out
484}
485
486// The writer's label, or the active step and its stage. A planned run adds
487// its weighted percentage, which moves within a step; any other run counts
488// settled steps instead, unless the writer set a label of its own.
489function progressStatus(r: Run): string {
490 let text = clean(r.label, 24)
491 if (text === '') {
492 const i = activeIndex(r)
493 if (i >= 0) {
494 const step = r.steps[i]
495 text = (clean(step.label, 24) + ' ' + clean(step.stage, 16)).trim()
496 }
497 }
498 // Never claim 100% while work remains.
499 if (isPlanned(r)) text += ` ${Math.min(Math.trunc(percent(r)), 99)}%`
500 else if (r.label === '') text += ` ${settledCount(r)}/${r.steps.length}`
501 return text.trim()
502}
503
504const ANSI: Record<string, string> = { green: '32', yellow: '33', cyan: '36', dim: '2' }
505
506function sgr(tone: Tone): string {
507 if (tone === 'plain') return ''
508 const rgb = tone === 'red' ? MUTED_RED : typeof tone === 'string' ? null : tone
509 return rgb ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : ANSI[tone as string]
510}
511
512/**
513 * The line as ANSI text, segments joined by a dim middle dot, exactly as the
514 * Go paceline prints it. With `noColor`, the same text with no escapes.
515 */
516export function toAnsi(line: Line, noColor = false): string {
517 const wrap = (code: string, text: string) => (noColor || code === '' ? text : `\x1b[${code}m${text}\x1b[0m`)
518 // Bold sits inside the color, as the Go paceline nests them.
519 const draw = (s: Span) => wrap(sgr(s.tone), s.bold ? wrap('1', s.text) : s.text)
520 const separator = ' ' + draw(span(MIDDLE_DOT, 'dim')) + ' '
521 return line.map(segment => segment.map(draw).join('')).join(separator)
522}
523src/timefmt.ts 105 lines1/**
2 * Formats reset times and durations, and finds day boundaries, in one time
3 * zone: the machine's own in a session, UTC in the parity tests.
4 */
5
6/** Which clock calendar math reads: the machine's local time or UTC. */
7export type Zone = 'local' | 'utc'
8
9const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
10
11type Parts = {
12 year: number
13 month: number
14 day: number
15 hour: number
16 minute: number
17 weekday: number
18}
19
20function parts(ms: number, zone: Zone): Parts {
21 const d = new Date(ms)
22 return zone === 'utc'
23 ? {
24 year: d.getUTCFullYear(),
25 month: d.getUTCMonth(),
26 day: d.getUTCDate(),
27 hour: d.getUTCHours(),
28 minute: d.getUTCMinutes(),
29 weekday: d.getUTCDay(),
30 }
31 : {
32 year: d.getFullYear(),
33 month: d.getMonth(),
34 day: d.getDate(),
35 hour: d.getHours(),
36 minute: d.getMinutes(),
37 weekday: d.getDay(),
38 }
39}
40
41/** Midnight at the start of the day that holds `ms`, in epoch milliseconds. */
42export function startOfDay(ms: number, zone: Zone): number {
43 const p = parts(ms, zone)
44 return zone === 'utc'
45 ? Date.UTC(p.year, p.month, p.day)
46 : new Date(p.year, p.month, p.day).getTime()
47}
48
49/**
50 * The next midnight after the day that holds `ms`. It steps a calendar day,
51 * not 24 hours, so a 23- or 25-hour day at a daylight saving change is still
52 * one day.
53 */
54export function startOfNextDay(ms: number, zone: Zone): number {
55 const p = parts(ms, zone)
56 return zone === 'utc'
57 ? Date.UTC(p.year, p.month, p.day + 1)
58 : new Date(p.year, p.month, p.day + 1).getTime()
59}
60
61/**
62 * Rounds `ms` down to a whole number of `stepMs` steps counted from
63 * `dayStartMs`, a midnight from `startOfDay`. Counting from the day's own
64 * midnight rather than the epoch keeps a zone with a half-hour offset on its
65 * own hours.
66 */
67export function floorInDay(ms: number, stepMs: number, dayStartMs: number): number {
68 return dayStartMs + Math.floor((ms - dayStartMs) / stepMs) * stepMs
69}
70
71/** The day that holds `ms` as `yyyymmdd`. */
72export function dateKey(ms: number, zone: Zone): string {
73 const p = parts(ms, zone)
74 const pad = (n: number, width: number) => String(n).padStart(width, '0')
75 return pad(p.year, 4) + pad(p.month + 1, 2) + pad(p.day, 2)
76}
77
78/**
79 * Formats epoch seconds as `9pm` or `3:40pm`, with the weekday in front
80 * (`Thu 9pm`) when the time falls on a different day than `nowMs`.
81 */
82export function clock(epochSeconds: number, nowMs: number, zone: Zone): string {
83 const ms = Math.trunc(epochSeconds) * 1000
84 const p = parts(ms, zone)
85 const hour = p.hour % 12 === 0 ? 12 : p.hour % 12
86 const minute = p.minute === 0 ? '' : ':' + String(p.minute).padStart(2, '0')
87 const text = `${hour}${minute}${p.hour < 12 ? 'am' : 'pm'}`
88 return dateKey(ms, zone) === dateKey(nowMs, zone) ? text : `${WEEKDAYS[p.weekday]} ${text}`
89}
90
91/** The short weekday name (`Tue`) of the day that holds `ms`. */
92export function weekday(ms: number, zone: Zone): string {
93 return WEEKDAYS[parts(ms, zone).weekday]
94}
95
96/** Formats milliseconds as `1h5m`, `12m`, or `40s`. */
97export function duration(ms: number): string {
98 const sec = Math.trunc(ms / 1000)
99 const h = Math.trunc(sec / 3600)
100 const m = Math.trunc((sec % 3600) / 60)
101 if (h > 0) return `${h}h${m}m`
102 if (m > 0) return `${m}m`
103 return `${sec}s`
104}
105src/color.ts 80 lines1/** Headroom thresholds and per-project colors. */
2
3import { md5 } from './md5.ts'
4
5/** How many project colors there are. */
6export const SLOTS = 12
7
8const PROJECT_LIGHTNESS = 0.8
9const PROJECT_MAX_CHROMA = 0.14
10
11/** An sRGB color with 0-255 channels. */
12export type RGB = readonly [number, number, number]
13
14/** A headroom color band, from most to least headroom. */
15export type Level = 'green' | 'yellow' | 'red'
16
17/** Buckets a "% left" value using the green and yellow thresholds. */
18export function headroomLevel(left: number, green: number, yellow: number): Level {
19 if (left >= green) return 'green'
20 if (left >= yellow) return 'yellow'
21 return 'red'
22}
23
24const cube = (x: number) => x * x * x
25
26/**
27 * Converts an OKLCH color to sRGB, or returns null when sRGB cannot show it.
28 * OKLCH is perceptually uniform: equal lightness looks equally bright across
29 * hues, which HSL does not manage.
30 */
31export function oklchToRGB(lightness: number, chroma: number, hueDegrees: number): RGB | null {
32 const h = (hueDegrees * Math.PI) / 180
33 const a = chroma * Math.cos(h)
34 const b = chroma * Math.sin(h)
35 const l = cube(lightness + 0.3963377774 * a + 0.2158037573 * b)
36 const m = cube(lightness - 0.1055613458 * a - 0.0638541728 * b)
37 const s = cube(lightness - 0.0894841775 * a - 1.291485548 * b)
38 const linear = [
39 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
40 -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
41 -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
42 ]
43 const rgb: number[] = []
44 for (const v of linear) {
45 if (v < -0.0001 || v > 1.0001) return null
46 let c = Math.min(1, Math.max(0, v))
47 c = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
48 rgb.push(Math.round(c * 255))
49 }
50 return [rgb[0], rgb[1], rgb[2]]
51}
52
53/**
54 * Picks a project's color slot: an override when one matches the name
55 * ignoring case, otherwise an MD5 hash of the lowercased name, so the slot is
56 * the same on every run and machine.
57 */
58export function projectSlot(name: string, overrides: Readonly<Record<string, number>>): number {
59 const lower = name.toLowerCase()
60 for (const [key, slot] of Object.entries(overrides)) {
61 if (key.toLowerCase() === lower) return ((slot % SLOTS) + SLOTS) % SLOTS
62 }
63 return md5(new TextEncoder().encode(lower))[0] % SLOTS
64}
65
66/**
67 * The color for a slot: evenly spaced hues at a fixed high lightness, so every
68 * project reads clearly on a dark background. Chroma backs off per hue until
69 * the color fits in sRGB, since blues and purples cannot be as vivid as other
70 * hues at this lightness.
71 */
72export function slotColor(slot: number): RGB {
73 const hue = 15 + (360 / SLOTS) * slot
74 for (let chroma = PROJECT_MAX_CHROMA; chroma > 0; chroma -= 0.01) {
75 const rgb = oklchToRGB(PROJECT_LIGHTNESS, chroma, hue)
76 if (rgb !== null) return rgb
77 }
78 return oklchToRGB(PROJECT_LIGHTNESS, 0, hue) ?? [204, 204, 204]
79}
80