SLOPSHOPPER

paceline

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

bandguardcommandtoolprocesstimer
v0.5.2MITupdated 2026-10-26rogadev/paceline-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paceline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /paceline-debug ⎿ paceline: { ⎿ paceline: "now": "2025-10-09T08:53:20.080Z", ⎿ paceline: "utcOffsetMinutes": 0, ⎿ paceline: "limitsMode": "auto", ⎿ paceline: "readings": { ⎿ paceline: "session": { app · Opus 5.5 · 30m ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app · Opus 5.5 · 30m ⟨Claude Code's own drawing⟩
README

paceline (mod)

A Claude Code mod that paces your usage limits and shows, at a glance, where every session stands, in a line above the prompt.

paceline in thirteen states: a fresh day, working through the day, the session time, most of the budget used, over budget, over budget by a whole day, a week forecast to run out, a session running low, the last day, an agent job, an /orc-loop run with its review batch, verbose with counting down and fast mode, and both limits

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.

Install

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 Code v2.1.287 or later. Run claude --version to check.
  • The line draws in the terminal and in the Code tab of the Claude Desktop app. The VS Code extension's chat panel and 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.

What each segment means

SegmentExampleShows
Projectpaceline (dev)The folder, in a color unique to the project, and the git branch. Always first.
ModelOpus 5.5The model.
EfforthighThe effort level, only when it's above low or medium. Verbose: high effort.
Fast modefast modeOnly while fast mode is on.
Sessions 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.
TodayToday: ▕██████░░░░░░░░│ 8/14Today's budget as a bar, one cell per weekly point, then the points spent of the budget. See Today's bar.
Weekw 43/100 · 4d leftHow 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.
Contextctx 72%Only when the context window is 70% or more full.
Progressloop ▰▰▰▰▱ #44 3/5Only while an agent reports a long job's progress. See Progress bar.
Looploop ▰▰▰▱▱ #44 2/5Only during an /orc-loop run that doesn't report through the progress tools. See Loop and batch.
Batch7 issues · 2 reviews · ✓ readyWhere the current dev -> main batch stands in deep-review, in repos that keep a review ledger. See Loop and batch.
Duration16mSession 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.

Today's bar

The bar has one cell per weekly point, against a │ marker at the even daily share of 100 / 7, about 14 points.

  • Full cells (cyan) are the budget you have left; drained cells (░) are what you've spent.
  • A budget above the even share starts with full cells past the marker and a green +6 saved tag. You spend that surplus first, so the bar shortens and the tag shrinks.
  • A budget below the even share, after overspending earlier in the week, shows the missing part as a faint red stretch (▒) before the marker.
  • Past the budget, the numbers turn red, the full cells turn red and count the overage, and → 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.
  • A bar longer than 28 cells scales down to fit, marker included.

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.

Every state

The image above shows the line as it draws, in color, for the states you'll see most. Each one in turn:

StateWhat you seeWhy
Fresh dayA full bar up to the │ marker, 0/14Nothing spent yet, and the week is on pace, so the day's budget is the even share of about 14 points.
Working through the dayThe bar drains, 5/14 in greenSpent points drain the bar. Green while you have room.
Session time2h12m at the end of the lineHow long this session has been running. It counts up as you work, from 2m to 5h22m and beyond.
Most of the budget used11/14 in yellow, the bar mostly drainedYellow past 70% of the budget, red past 85%.
Over budgetA red bar and 17/14 → 13 tmrwThe overage is counted, and tomorrow's budget shrinks to match.
Over budget by a whole dayA full red bar and 28/14 → 7 tmrwTwice the day's budget is spent, so tomorrow gets about half a day.
Week forecast to run outw 67/100 · out ~FriAt your recent pace the week ends before it resets. The warning replaces the days left.
Session running lows 72% · out ~10:15am in redThe session segment is off by default; once it's on, a session forecast to run out says when.
The last day6/40 +20 saved · last dayThe budget is everything left in the week. With plenty left, this is the day to spend it if you need to.
Agent jobloop ▰▰▰▱▱ #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 batchloop ▰▰▰▱▱ #44 2/5 and 7 issues · 2 reviews · ✓ readyorc-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 modemax effort · fast mode, 8/14 left, week 36/100 leftverbose and countDirection in paceline.json; fast mode comes from your settings.
Both limitss 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.

Run-out warnings

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:

  • A limit that runs out before it resets says when. The week's 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 time rounds down, to the hour for the week and to the quarter hour for the session, so it never promises more time than the forecast gives. If rounding down would give a time that's already past, the exact minute shows instead.
  • Otherwise, nothing changes. A limit that lasts until it resets shows its days left or reset time as usual.
  • A warning needs its segment on. The session segment is off by default, so a session warning shows only once you turn it on.

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.

How it differs from the Go status line

  • It draws above the prompt, in the band mods share, not in the status line under it. A plugin can't set your status line, so the two can run side by side.
  • A compact layout. It puts the project ahead of the model, shows effort as the bare level, 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.
  • Two segments of its own. The loop and batch segments sit after the others, before the duration, and only the mod draws them.
  • No prompt-cache warning yet. The mods API doesn't report when the prompt cache expires, so the cache cold segment never shows.
  • Effort comes from the latest turn. Until the first tool call or reply in a session, it falls back to the effortLevel for the session's model under modelSettings in your settings, then to the global effortLevel.
  • Fast mode is read from the fastMode key in your settings, which may not match every way fast mode can be turned on.

Configuration

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.

Today's budget across sessions and tools

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.

Progress bar

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.

Loop and batch

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.

StateShows
No reviews7 issues · no review yet (yellow)
Commits since the last review7 issues · review stale (+2 commits) (yellow), or review stale when the reviewed commit was rebased away
Last review incomplete7 issues · 2 reviews · review incomplete (yellow)
Blockers7 issues · 2 reviews · 1 blocker (red)
Warnings7 issues · 2 reviews · 3 warnings (yellow)
Ready7 issues · 2 reviews · ✓ ready (green)
Anything else7 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.

What the mod can reach

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.

  • Reads your session's usage figures, model, working folder, and settings through the mods API.
  • Reads 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.
  • Reads the environment variables 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.
  • Writes two values to its own plugin store: today's budget anchor, and which hours of the last 14 days your usage went up in, for the forecast.
  • Watches tool calls and turns only to notice progress updates and the effort level. It never changes, blocks, or approves anything.
  • Starts 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.
  • It makes no network requests and calls no model.

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.

Development

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.

License

MIT

Source 17 files
hooks/register.ts 372 lines
1import 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}
372
src/band.ts 61 lines
1/** 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}
61
src/batch.ts 470 lines
1/**
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}
470
src/config.ts 146 lines
1/**
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}
146
src/forecast.ts 432 lines
1/**
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}
432
src/gitinfo.ts 185 lines
1/**
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}
185
src/live.ts 240 lines
1/**
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}
240
src/loop.ts 188 lines
1/**
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}
188
src/progress.ts 189 lines
1/**
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}
189
src/render.ts 523 lines
1/**
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}
523
src/timefmt.ts 105 lines
1/**
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}
105
src/color.ts 80 lines
1/** 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