SLOPSHOPPER

workbar

Shows how far along Claude is and when it will really be done, what it is waiting on, and when a chat is getting too big or going cold. Small, close to zero…

panebandguardcommandtoaststatus
★ 1v0.5.0MITupdated 2026-10-20OskarasM/workbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbar
› fix the failing auth test and add an audit log call ╭─────────────────────────────────────╮ │ workbar │ ⏺ Read(src/auth.ts) │ Writing the handoff from this chat… │ ⎿ 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 › /handoff ⎿ workbar: Writing a handoff, then starting a fresh chat with it in the prompt box. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ workbar: chat 97k · cache 60m
README

workbar

See how far along Claude Code is, when it will really be done, and what it needs from you.

A small mod for Claude Code that adds one band above the prompt. It costs close to zero tokens.

checks release license: MIT Claude Code 2.1.287+

╭──────────────────────────────────────────────────────────────────────────────╮
│ █████████████████░░░░░░░░░░░░░░░░░░░░░░░  43%  ~4m left · ≈15:32             │
│ ✓ Read code   ✓ Find cause   ▶ Fix the band   ○ Tests   ○ Publish            │
│ ● Explore the API (Explore) · 12 tools · 1m 40s · Read claude-code.d.ts      │
╰──────────────────────────────────────────────────────────────────────────────╯
/plugin install workbar --marketplace OskarasM/workbar

Why you'd want it

Claude Code does the work well, but it tells you little about the session around the work. You end up guessing:

  • "Is it nearly done, or should I go make coffee?" workbar shows the plan, each step as it finishes, and a time left that learns how fast Claude really works on your machine, not how long a person would take.
  • "Why has it gone quiet?" The band says what it is waiting on: an agent, a question, your approval, a login. You get a desktop alert when Claude needs you, or when a long run finishes, so you can step away.
  • "Why is this chat getting slow and expensive?" A gauge shows how big the chat is and when the prompt cache goes cold. When it is worth acting, one button compacts the chat or hands the work to a fresh one.
  • "I hit my usage limit in one chat. What about the other five?" Every open chat shows Log in within seconds. Chats in the middle of a task pause cleanly and carry on by themselves after you log in.

It does all this from what Claude Code already knows, so your bill barely notices it.

What it looks like

While Claude works: a progress bar with every step named, time left and a finish time, plus one row per subagent.

Between tasks: a short health check that offers an action only when one is worth taking.

╭──────────────────────────────────────────────────────────────────────────────╮
│  ACTION  chat 312k ━━━━━━━━━━  cache ✓ 41m  5h 72% full ≈16:10               │
│ ▲ Long chat: every message re-reads all 312k, and focus drops.               │
│ [ ★ Hand off ] new task   [ Compact ] same task                              │
╰──────────────────────────────────────────────────────────────────────────────╯

★ marks the action that fits. Hand off writes a summary, starts a fresh chat and puts the summary in the prompt box. Compact shrinks the chat in place.

Features

What you get
Progress barSteps, % done, time left and a finish time for any task of more than a few steps. When Claude's last report is a few minutes old, the bar says so ("report 4m old") rather than pretending. An unfinished plan carries into your next message.
Honest estimatesModels guess how long a person would take, about 3x too long in the author's own history. workbar learns from your finished tasks how long Claude really takes (median of the last 20), leaves out time spent waiting on you, and blends in the real pace as steps finish.
Never a blank bandWith no progress bar showing, the band says why: thinking, a quick task, no plan yet, or the progress tool not loaded (with the fix).
SubagentsOne row per agent: its task, tools used, time and current action. Lots of agents collapse into a summary row.
Chat healthChat size, a prompt-cache countdown, and usage windows past 50% with the time they fill at this pace.
Compact or Hand offOne press, when the chat is big or the cache has gone cold. A running compaction shows its time, and one that cannot run says why.
Message holdA message into a huge or cold chat is held once with those two options. Press Enter again to send it anyway.
Usage limit, every chatThe chat that hits the limit tells all the others on this machine. A chat mid-task pauses with Continue / Log in here / Stop.
AlertsA pop-up when Claude needs approval, has waited 15 s on a question, or finishes a run of 2 minutes or more.
Loop warningThree failed tool calls in a row warn you that Claude may be going in circles.
Chrome reconnectWhen the Claude in Chrome extension drops, workbar shows how to reconnect and tells Claude to wait instead of giving up on the browser step.
Status lineThe one thing that matters right now, mirrored into Claude Code's status line.

Two commands: /workbar opens a pane with everything in full (every step with its time, every agent, the session's cost and limits, and how estimates are scaled). /handoff writes a handoff and starts a fresh chat with it.

Costs almost nothing

workbar reads what Claude Code already reports. It never calls a model on its own, unless you press a button or turn on the optional new-task check.

PartCost
The progress toolAbout 180 tokens of description and schema. They sit in the cached tool list, so after the first request they're read at a tenth of the price.
Progress reportsA few dozen output tokens each, sent alongside a tool call Claude was making anyway, so no extra round trips. Roughly 200 tokens for a typical task, and none for a quick answer.
Plan reminderOnce a message has taken more than 3 tool calls with no plan, one note of about 40 tokens asks for one. At most once per message.
Progress reminderWhen the plan falls behind (6 tool calls and 90 s with no report), one note of about 40 tokens. At most 3 per message.
A message typed mid-taskAbout 30 tokens asking Claude to update the plan.
Everything elseDrawn locally. No model calls.
New-task check (off by default)A free local word check first. Only a message that shares almost no words with your recent ones goes to Haiku, as a note of about 150 tokens.
Hand off (only when you press it)One question over the transcript, at the cached price while the cache is warm.

Want zero? Turn off Ask Claude for a progress plan. The bar then follows Claude's own todo list when it keeps one, and nothing at all is added to your requests.

Install

Needs Claude Code 2.1.287 or newer (with mods). Tested on 2.1.301.

WhereHow
In a Claude Code session/plugin install workbar --marketplace OskarasM/workbar
From a shellclaude plugin install workbar --marketplace OskarasM/workbar
Desktop appClick + next to the prompt box, then Plugins, Add marketplace with OskarasM/workbar, then Add plugin.

To update: claude plugin marketplace update workbar, then claude plugin update workbar. To remove: claude plugin uninstall workbar.

Settings

All in /config under workbar.

SettingDefault
Band above the promptautoauto shows the band when there is something to see or do, and keeps the chat size in the status line otherwise. always also keeps the chat gauge up between tasks. off uses the status line only.
Ask Claude for a progress planonOff: zero tokens, and the bar follows Claude's todo list.
Hold a message into an oversized or cold chaton
Spot a new task in a long chatoffSee Costs almost nothing.
Chat size: warn at150kAmber from here, red at twice this.
Desktop alertsonClaude Code's own notifications where it has them. On Windows Terminal, a Windows toast instead.
Activity marker folderemptyEach session writes busy <ms> / idle <ms> to <folder>/<session id>.txt, for your own scripts: sleep the machine when Claude is done, light a lamp, and so on.

The prompt cache lifetime is detected: 1 hour on a Claude subscription, 5 minutes on an API key, or your promptCacheTtl setting.

Known limitations

  • Only one mod can draw above the prompt at a time. In auto mode workbar steps aside when it has nothing to show, so other mods get their turn.
  • The progress bar needs Claude to report a plan or keep a todo list. Quick answers get none on purpose, and the band says so.
  • The band shows in the terminal and the desktop app. Claude Code doesn't offer it in VS Code, on the web or on mobile; there, /workbar and the status line still work.
  • Pausing at the usage limit saves no tokens, because the prompt cache belongs to the account. It saves a half-done step and typing "continue".
  • The usage-limit flag is shared through workbar/limit.json in your Claude config folder, so it reaches chats on this machine only.
  • Claude Code's [-] (or ctrl+x ctrl+a) collapses the band, and a mod can't tell. If the band seems gone, press [-] again.
  • Where an organization manages Claude Code, a user-installed mod's Notification and StopFailure hooks don't run, so approval alerts are skipped there.
  • Used daily on Windows 11 with Windows Terminal, and tested on all three operating systems in CI. The /workbar pane in the desktop app, and day-to-day use on macOS and Linux, have had less real-world testing. Issues are welcome.

The band shares the bottom of the terminal with the prompt and Claude Code's own dialogs. Anything taller than its room scrolls in a small window, and on the main (non-fullscreen) screen a tall bottom area breaks the redraw. So workbar fits a fixed budget: at most a third of the terminal's height and never more than 12 rows, borders included. Every line truncates rather than wraps.

The step list comes first, every step by name, with names shortened before anything folds. When room runs short, the actions stay and the details give way: finished agents first, and last the step list, which folds into one line naming the current step and as many of the next ones as fit. While Claude shows a question or a plan for approval, the band steps aside completely and the status line says what is waiting.

Compared with

workbarclaude-hudcache-tax
Plan progress and time leftyestodos onlyno
Subagent rowsyesyesno
Context and usage limitsyes, with paceyes, very configurablecache only
Acts for you (compact, hand off, hold)yesnoholds a cold send, keeps the cache warm
Alertsyesnono

They overlap. If you already use claude-hud's status line, set workbar's band to auto and they sit fine together. If you use cache-tax, turn off workbar's message hold so a cold send isn't held twice.

Privacy

No network calls and no telemetry. The Haiku check runs only if you turn it on, and then on your own account.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

The hooks module is hooks/register.tsx. Pure helpers (estimates, layout budget, topic check, the shared usage-limit flag) are in hooks/lib.ts, with tests beside them. See CHANGELOG.md for what changed in each release.

License

MIT

Source 3 files
hooks/register.tsx 1610 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { AgentRun, ChromeIssue, Health, Hold, Live, Notice, Plan, Stuck } from '../types'
5import {
6  activeMinutes,
7  advancePlan,
8  bandBudget,
9  calibrationFrom,
10  calibrationSample,
11  clip,
12  compactSkipText,
13  fitRows,
14  fmtDur,
15  fmtK,
16  fmtMin,
17  freshTokens,
18  hhmm,
19  isSharedLimitOn,
20  limitLabel,
21  limitUntil,
22  looksUnrelated,
23  minutesLeft,
24  nextPaceBases,
25  paceFullAt,
26  parseSharedLimit,
27  plural,
28  sharesStep,
29  wrappedRows,
30} from './lib'
31import type { SharedLimit } from './lib'
32
33// ---- options (Claude Code's /config, under workbar) ----
34type Opts = {
35  band: 'auto' | 'always' | 'off'
36  progressTool: boolean
37  holdMessages: boolean
38  topicCheck: boolean
39  amberTokens: number // "warn at": amber from here, red at twice this
40  redTokens: number
41  notify: boolean
42  activityDir: string
43}
44function readOpts(raw: unknown): Opts {
45  const o = (raw ?? {}) as Record<string, unknown>
46  const warn = typeof o.warnTokens === 'number' && Number.isFinite(o.warnTokens) && o.warnTokens >= 10_000 ? o.warnTokens : 150_000
47  return {
48    band: o.band === 'always' || o.band === 'off' ? o.band : 'auto',
49    progressTool: o.progressTool !== false,
50    holdMessages: o.holdMessages !== false,
51    topicCheck: o.topicCheck === true,
52    amberTokens: warn,
53    redTokens: warn * 2,
54    notify: o.notify !== false,
55    activityDir: typeof o.activityDir === 'string' ? o.activityDir.trim() : '',
56  }
57}
58let opts = readOpts(undefined)
59
60const COLD_HOLD_TOKENS = 100_000 // with the cache expired, hold messages above this size
61const TOPIC_CHECK_TOKENS = 60_000
62const NOTIFY_AFTER_S = 120 // alert when a run takes longer than this
63const ASK_ALERT_MS = 15_000 // alert when a question from Claude waits this long
64const STUCK_AFTER = 3 // failed tool calls in a row
65
66// One description, read by Claude with the tool list; nothing goes into CLAUDE.md or the system prompt.
67const PROGRESS_TOOL = 'mcp__workbar__progress'
68const PROGRESS_DESCRIPTION =
69  "Live progress bar for the user. For a task needing more than ~3 tool calls, call it first with `steps` (3-10 short labels for the whole task; count any already finished in `done`) and `estimate_minutes`: your own wall-clock minutes from now to finish, not the hours a person would take. Each time a step finishes, put `done` (steps finished) in the same response as your next tool call, never alone; the last step needs no call. If the plan changes, resend the full `steps`, `done` and a new estimate. Skip it for quick answers."
70const MIDRUN_NOTE = 'If this changes the work, resend mcp__workbar__progress with the full updated `steps` and estimate alongside your next tool call.'
71// Claude often skips the tool on work of 4-10 tool calls. One reminder per message, only once a task has
72// proved longer than a quick answer, so short replies cost nothing.
73const QUICK_TOOLS = 3 // up to this many tool calls with no plan reads as a quick task
74const NUDGE_NOTE =
75  'This task is taking several steps: send mcp__workbar__progress (`steps`, `estimate_minutes`) alongside your next tool call so the user can see progress.'
76const NUDGE_CARRIED_NOTE =
77  "The progress bar still shows your plan from the user's last message: alongside your next tool call send mcp__workbar__progress with `done`, or new `steps` and `estimate_minutes` if this is different work."
78// A plan left behind the work: after this many tool calls and this long with no report, one short note
79// (about 30 tokens) reminds Claude to send `done`, at most a few times per message.
80const STALE_TOOLS = 6
81const STALE_MS = 90_000
82const STALE_NUDGES = 3
83const staleNote = (p: Plan) =>
84  `The progress bar shows step ${p.done + 1}/${p.steps.length} "${p.steps[p.done] ?? ''}". If steps have finished since, send mcp__workbar__progress with \`done\` alongside your next tool call.`
85// A report older than this, with tool calls since, is marked in the band so a lagging bar is not taken at its word.
86const REPORT_OLD_MS = 3 * 60_000
87const NOTICE_MS = 3 * 60_000 // how long a notice stays in the band
88const TASKS_PANE = 'workbar-tasks'
89const CHROME_PREFIX = 'mcp__claude-in-chrome__'
90const CHROME_DOWN = /not connected|receiving end does not exist|not detected|no browser (is )?connected/i
91const CHROME_NOTE =
92  'The user has been shown how to reconnect the Chrome extension (/chrome, then Reconnect extension). Do not switch approach or drop the browser step: say in one line what you need the browser for, then wait for the user to say it is reconnected, and retry.'
93
94const EMPTY_LIVE: Live = {
95  isWorking: false,
96  startedAt: 0,
97  tools: 0,
98  lastAction: '',
99  plan: null,
100  waitingSince: 0,
101  lastRun: null,
102  carryPlan: false,
103  endedAt: 0,
104  nudged: false,
105}
106const EMPTY_HEALTH: Health = {
107  tokens: 0,
108  prompts: 0,
109  lastResponseAt: 0,
110  firstPrompt: '',
111  recentPrompts: [],
112  allowedUntilTokens: 0,
113  isHandingOff: false,
114  isCompacting: false,
115  limits: [],
116  paceBases: [],
117  costUsd: 0,
118  limitNoticeAt: 0,
119  compactStartedAt: 0,
120  compactedAt: 0,
121  compactedFrom: 0,
122  compactedTo: 0,
123  promptsAtCompact: 0,
124}
125
126const live = atom({ plugin: 'workbar', key: 'live' } as const, EMPTY_LIVE)
127const health = atom({ plugin: 'workbar', key: 'health' } as const, EMPTY_HEALTH)
128const hold = atom({ plugin: 'workbar', key: 'hold' } as const, null as Hold)
129const tick = atom({ plugin: 'workbar', key: 'tick' } as const, 0)
130const chrome = atom({ plugin: 'workbar', key: 'chrome' } as const, null as ChromeIssue)
131const stuck = atom({ plugin: 'workbar', key: 'stuck' } as const, null as Stuck)
132const agents = atom({ plugin: 'workbar', key: 'agents' } as const, [] as AgentRun[])
133const asking = atom({ plugin: 'workbar', key: 'asking' } as const, null as string | null)
134const notice = atom({ plugin: 'workbar', key: 'notice' } as const, null as Notice)
135
136async function say($: EngineInterface, text: string, tone: 'info' | 'warning' = 'info') {
137  const at = await $.clock.now()
138  await update($, notice, () => ({ text, tone, until: at + NOTICE_MS }))
139}
140
141// Whether Claude is offered the progress tool in this chat (null: not known yet), from the last prompt built.
142let progressOffered: boolean | null = null
143
144// Why there is no progress bar while Claude works: a full line for the band, a short one for the status line.
145function noPlanReason(l: Live): { long: string; short: string } {
146  if (!opts.progressTool) return { long: 'No progress bar: plans are off in /config (workbar), and Claude keeps no todo list here', short: 'no plan (plans off)' }
147  if (progressOffered === false) return { long: 'No progress bar: the progress tool is not loaded in this chat. Run /reload-plugins', short: 'no plan: /reload-plugins' }
148  if (l.tools === 0) return { long: 'Thinking. A progress bar appears if this takes several steps', short: 'thinking' }
149  if (l.tools <= QUICK_TOOLS) return { long: 'Quick task: no progress bar needed', short: `quick task · ${plural(l.tools, 'tool')}` }
150  return { long: l.nudged ? 'No plan from Claude yet: asked it for one' : 'No plan from Claude yet', short: `no plan yet · ${plural(l.tools, 'tool')}` }
151}
152
153// Claude's task list (TaskCreate / TaskUpdate), kept by id, for the bar when it reports no plan.
154const tasks = new Map<string, { content: string; status: string }>()
155
156// Facts about this machine and session, read once at session start.
157let isWindows = false
158let settingsTtl: '5m' | '1h' | '' = ''
159let hasActivityDir = false
160let sharedDir = '' // where every chat on this machine reads and writes the usage-limit flag
161let errStreak = { tool: '', count: 0 }
162
163// ---- activity markers for your own scripts ----
164// Named after the session, which outlives a reload of this module, so a reload never leaves a "busy" file behind.
165async function markActivity($: EngineInterface, state: 'busy' | 'idle', sessionId?: string) {
166  if (!hasActivityDir) return
167  try {
168    const id = (sessionId ?? (await $.session.id())).replace(/[^\w.-]/g, '_')
169    await $.fs.write(`${opts.activityDir.replace(/[\\/]+$/, '')}/${id}.txt`, `${state} ${await $.clock.now()}`)
170  } catch {
171    // a script reading these falls back to its own timeout
172  }
173}
174
175// ---- alerts: Claude Code's own notification first (the desktop app, terminals with a channel) ----
176// Windows Terminal has none ($.ui.notify only rings the bell there), so on Windows a bell-only or
177// failed notification becomes a Windows toast. Title and text travel in the environment, not the script.
178const TOAST_PS = `$ErrorActionPreference = 'Stop'
179[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
180$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
181$n = $t.GetElementsByTagName('text')
182$n.Item(0).AppendChild($t.CreateTextNode($env:WB_TITLE)) > $null
183$n.Item(1).AppendChild($t.CreateTextNode($env:WB_TEXT)) > $null
184$app = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
185[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show([Windows.UI.Notifications.ToastNotification]::new($t))`
186let lastAlertAt = 0
187
188async function alert($: EngineInterface, text: string) {
189  if (!opts.notify) return
190  const at = await $.clock.now()
191  if (at - lastAlertAt < 5000) return // one alert for one moment
192  lastAlertAt = at
193  try {
194    const r = await $.ui.notify(text, { title: 'Claude Code' }).catch(() => ({ isSent: false as const, reason: 'error' }))
195    const shown = r.isSent && r.channel !== 'terminal_bell'
196    if (shown || !isWindows || (!r.isSent && r.reason === 'disabled')) return
197    void $.process
198      .run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', TOAST_PS], {
199        env: { WB_TITLE: 'Claude Code', WB_TEXT: text },
200        timeoutMs: 15000,
201      })
202      .catch(() => undefined)
203  } catch {
204    // a missed alert is not worth failing anything for
205  }
206}
207
208// Claude asking you something: the dialog stays open exactly as long as the tool call does.
209const QUESTION_TOOLS: Record<string, string> = { AskUserQuestion: 'Claude has a question for you', ExitPlanMode: 'Claude has a plan for you to approve' }
210
211const HANDOFF_PROMPT = `Write a handoff prompt that I will paste as the first message of a brand-new Claude Code chat in this same project, so a fresh session continues with no loss of quality.
212Output only the prompt itself, no preamble or closing remarks. Cover, concisely:
2131. Goal and what "done" means.
2142. Current state: what is finished and verified, what is in progress (exact file paths, functions, commands).
2153. Decisions made and approaches rejected, each with a one-line reason.
2164. Open problems, errors seen, and anything still unverified.
2175. The next steps, in order.
2186. Files to read first (paths only; if the project has docs/STATE.md, list it first).
219Point to files rather than pasting their contents. Stay under 350 words unless more is truly needed.`
220
221// Each finished plan's actual/estimated. Before 0.5.0 a plan sent a few tool calls in counted its
222// already-finished steps as instant, so those samples ran short; they stay behind under the old key.
223const CALIB_KEY = 'calib2'
224async function calibration($: EngineInterface): Promise<number> {
225  return calibrationFrom((await $.store.get(CALIB_KEY)) as number[] | undefined)
226}
227
228// 1 hour on a Claude subscription (it reports usage windows), 5 minutes otherwise, unless promptCacheTtl says.
229const cacheTtlMs = (h: Health) => {
230  const ttl = settingsTtl || (h.limits.length > 0 ? '1h' : '5m')
231  return (ttl === '1h' ? 60 : 5) * 60_000
232}
233
234type Level = 'ok' | 'amber' | 'red' | 'cold'
235
236function level(h: Health, now: number): Level {
237  const isCold = h.lastResponseAt > 0 && now - h.lastResponseAt > cacheTtlMs(h)
238  if (isCold && h.tokens >= Math.min(COLD_HOLD_TOKENS, opts.amberTokens)) return 'cold'
239  if (h.tokens >= opts.redTokens) return 'red'
240  if (h.tokens >= opts.amberTokens) return 'amber'
241  return 'ok'
242}
243
244// A usage limit reached: Claude Code says so itself; the band only adds a way to switch account. Its notice
245// is the reliable sign (a turn can end normally on a grace allowance with the window under 100%).
246// Never a bare "limit reached": Claude Code's "Context limit reached" is about this chat alone.
247const LIMIT_TEXT = /usage limit reached|(?:5-hour|session|weekly|opus|sonnet) limit reached|(?:hit|reached) your (?:usage |weekly |session )?limit|out of (?:extra )?usage/i
248const isLimitHit = (h: Health, now: number) =>
249  (h.limitNoticeAt ?? 0) > 0 || h.limits.some(x => x.pct >= 100 && (!x.resetsAt || !(Date.parse(x.resetsAt) <= now)))
250
251// The limit is the account's, so every chat on this machine shares one flag file: the chat that hits
252// it writes it, the others read it on their 5 s tick and show Log in too, and a chat that starts a
253// request meanwhile pauses and asks first. A login anywhere, or any chat's next reply, clears it.
254let sharedLimit: SharedLimit | null = null // the flag as last read or written
255let lastOkAt = 0 // when this chat's last request that got a reply was sent
256const limitPath = () => `${sharedDir}/limit.json`
257
258async function readSharedLimit($: EngineInterface): Promise<SharedLimit | null> {
259  if (!sharedDir) return null
260  try {
261    sharedLimit = parseSharedLimit(String(await $.fs.read(limitPath())))
262  } catch {
263    sharedLimit = null // no file: no chat has hit a limit
264  }
265  return sharedLimit
266}
267async function writeSharedLimit($: EngineInterface, s: SharedLimit) {
268  sharedLimit = s
269  if (!sharedDir) return
270  try {
271    await $.fs.write(limitPath(), JSON.stringify(s))
272  } catch {
273    // the other chats find out from their own next request instead
274  }
275}
276
277// scope 'all': this chat learnt it first-hand (a notice, a login), so every chat hears. 'here': only this chat's view.
278async function markLimit($: EngineInterface, hit: boolean, scope: 'all' | 'here' = 'all') {
279  const at = await $.clock.now()
280  await update($, health, h => ({ ...h, limitNoticeAt: hit ? at : 0, limitFromOther: false, limits: hit ? h.limits : h.limits.filter(x => x.pct < 100) }))
281  if (scope === 'all') {
282    const prev = await readSharedLimit($)
283    if (hit) {
284      await writeSharedLimit($, { hitAt: at, until: limitUntil((await read($, health)).limits, at), clearedAt: prev?.clearedAt ?? 0 })
285      if (!isSharedLimitOn(prev, at, lastOkAt)) void alert($, 'Usage limit reached. Log in from any chat; the others wait for it.')
286    } else if (prev && prev.hitAt > prev.clearedAt) {
287      await writeSharedLimit($, { ...prev, clearedAt: at })
288    }
289  }
290  await syncStatus($)
291}
292
293// On the tick: take up another chat's limit, or drop this one's once a login anywhere has cleared it.
294async function syncSharedLimit($: EngineInterface) {
295  if (!sharedDir) return
296  const s = await readSharedLimit($)
297  const now = await $.clock.now()
298  const h = await read($, health)
299  if (s && isSharedLimitOn(s, now, lastOkAt)) {
300    if (h.limitNoticeAt) return
301    await update($, health, x => ({ ...x, limitNoticeAt: s.hitAt, limitFromOther: true }))
302    await syncStatus($)
303  } else if (h.limitNoticeAt && s && s.clearedAt >= h.limitNoticeAt) {
304    await update($, health, x => ({ ...x, limitNoticeAt: 0, limitFromOther: false, limits: x.limits.filter(l => l.pct < 100) }))
305    await syncStatus($)
306  }
307}
308
309// A request about to go out while the account is at its limit: ask before sending it, so the chat
310// waits for the login and then carries on by itself. One question at a time, however many requests wait.
311const ASK_CONTINUE = 'Continue'
312const ASK_LOGIN = 'Log in here'
313const ASK_STOP = 'Stop'
314let pausing: Promise<'go' | 'stop'> | null = null
315let mainTurnId = '' // the main loop's running turn, which Stop ends
316
317// The dialog is an AskUserQuestion call, so the tool.call hook below shows it in the status line, keeps
318// its time out of the estimate and alerts after a while, as for Claude's own questions.
319const PAUSED = 'Paused at the usage limit: log in, then Continue'
320async function askToContinue($: EngineInterface): Promise<'go' | 'stop'> {
321  let pick: string
322  try {
323    pick = await $.ui.ask(
324      'The usage limit is reached, so this chat is paused before its next request. Log in (Log in in another chat, or here), then Continue. Continue?',
325      { header: 'Usage limit', options: [ASK_CONTINUE, ASK_LOGIN, ASK_STOP] },
326    )
327  } catch {
328    pick = ASK_CONTINUE // dismissed, or nobody to ask: send it, and Claude Code says if it fails
329  }
330  if (pick === ASK_STOP) return 'stop'
331  if (pick === ASK_LOGIN) {
332    $.clock.after(200, () => void $.command.run({ command: 'login' }).catch(() => undefined))
333    return 'stop'
334  }
335  await markLimit($, false) // you say it is sorted: the other chats go on too
336  return 'go'
337}
338
339// Unfinished plan or a same-task message: Compact. Finished plan or a new-task message: Hand off.
340function recommendsHandoff(l: Live, held: Hold): boolean {
341  if (held) return held.isNewTask
342  const r = l.lastRun
343  return !!r && r.steps > 0 && r.done >= r.steps
344}
345
346function timeLeft(p: Plan, now: number, calib: number, waitingSince: number): string {
347  if (p.done >= p.steps.length) return 'finishing up'
348  const mins = minutesLeft(p, now, calib, waitingSince)
349  return mins === null ? '' : `~${fmtMin(mins)} left`
350}
351
352// "report 4m old" once Claude's last progress report is old and work has gone on since: the bar may lag.
353function reportAge(l: Live, now: number): string {
354  const p = l.plan
355  if (!p || p.source !== 'progress' || p.carried || !l.reportedAt || p.done >= p.steps.length) return ''
356  const age = now - l.reportedAt
357  return age >= REPORT_OLD_MS && l.tools > (l.staleFrom ?? 0) ? `report ${Math.floor(age / 60000)}m old` : ''
358}
359
360// Claude Code's own status line slot for this mod: the one thing that matters, even with the band folded.
361let lastStatus: string | undefined = '\u0000'
362async function syncStatus($: EngineInterface) {
363  try {
364    const l = await read($, live)
365    const h = await read($, health)
366    const ags = await read($, agents)
367    const held = await read($, hold)
368    const ask = await read($, asking)
369    const st = await read($, stuck)
370    const now = await $.clock.now()
371    const running = ags.filter(a => a.status === 'running').length
372    const agentsTxt = running ? ` · ${plural(running, 'agent')}` : ''
373    let text: string | undefined
374    if (ask) text = `◆ ${ask}`
375    else if (h.compactStartedAt) text = `◐ compacting · ${fmtDur(now - h.compactStartedAt)}`
376    else if (!l.isWorking && isLimitHit(h, now)) text = '★ Log in to continue'
377    else if (l.isWorking && st) text = `▲ ${st.count} failed ${st.tool} calls in a row${agentsTxt}`
378    else if (l.isWorking && h.limitNoticeAt) text = `▲ usage limit: pauses before its next request${agentsTxt}`
379    else if (l.isWorking && l.plan && l.plan.steps.length > 0) {
380      const p = l.plan
381      const left = timeLeft(p, now, await calibration($), l.waitingSince)
382      const age = reportAge(l, now)
383      text = `▶ ${p.done}/${p.steps.length}${left ? ` · ${left}` : ''}${age ? ` · ${age}` : ''}${agentsTxt}`
384    } else if (running > 0) text = `● ${plural(running, 'agent')} running`
385    else if (l.isWorking) text = `○ ${noPlanReason(l).short}`
386    else if (held) text = `‖ message held · ${held.isNewTask ? 'Hand off' : 'Compact'} ★`
387    else if (!l.isWorking && h.tokens > 0) {
388      const lv = level(h, now)
389      if (lv === 'red' || lv === 'cold') text = `★ ${recommendsHandoff(l, held) ? 'Hand off' : 'Compact'} · chat ${fmtK(h.tokens)}`
390      else if (opts.band !== 'always') {
391        const left = cacheTtlMs(h) - (now - h.lastResponseAt)
392        text = `chat ${fmtK(h.tokens)}${lv === 'amber' ? ' (big)' : ''} · cache ${left > 0 ? `${Math.ceil(left / 60000)}m` : 'cold'}`
393      }
394    }
395    if (text !== lastStatus) {
396      lastStatus = text
397      $.ui.status(text)
398    }
399  } catch {
400    // the status line is a mirror; the band still shows everything
401  }
402}
403
404// A chat-size reading, unless it is the pre-compaction size Claude Code still reports until the next reply.
405function withReading(h: Health, reading: number): Health {
406  const r = freshTokens(reading, h.tokens, h.compactedFrom ?? 0)
407  return r.isStale ? h : { ...h, tokens: r.tokens, compactedFrom: 0 }
408}
409
410async function refreshTokens($: EngineInterface) {
411  try {
412    const usage = await $.session.usage()
413    const tokens = usage.context.tokens ?? 0
414    const at = await $.clock.now()
415    const limits = usage.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt ?? '' }))
416    await update($, health, h => ({
417      ...withReading(h, tokens),
418      lastResponseAt: at,
419      limits,
420      paceBases: nextPaceBases(h.paceBases ?? [], limits, at),
421      costUsd: usage.cost?.usd ?? h.costUsd ?? 0,
422    }))
423  } catch {
424    // usage is best effort; the band keeps its last reading
425  }
426}
427
428// Is a new message a different task? A free local check first: a message that shares words with the
429// recent requests is the same work, and most are. Only one that shares almost none goes to Haiku, as a
430// note of about 150 tokens (never the chat), at low effort with a tiny reply.
431async function isNewTopic($: EngineInterface, h: Health, text: string): Promise<boolean> {
432  if (!looksUnrelated(text, [h.firstPrompt, ...h.recentPrompts])) return false
433  const prompt = `Requests so far in one coding chat:\n- ${clip(h.firstPrompt, 200)}\n${h.recentPrompts.map(p => `- ${clip(p, 150)}`).join('\n')}\nNew request: ${clip(text, 300)}\nSame piece of work, or a new task better started fresh? Reply SAME or NEW.`
434  const r = await $.model.complete({ model: 'haiku', prompt, maxTokens: 16, effort: 'low', timeoutMs: 4000 })
435  return r.isAnswered === true && /\bNEW\b/i.test(r.text)
436}
437
438const COMPACT_FOCUS =
439  'Keep the goal, decisions with their reasons, exact file paths and commands, the current state, anything unverified, errors seen, and the next steps. Drop exploration that led nowhere.'
440
441const LEVEL_RANK: Record<Level, number> = { ok: 0, amber: 1, cold: 2, red: 2 }
442let lastAdvised: Level = 'ok'
443
444// Same task, big chat: compact in place (keeps this session: approvals, background tasks, browser tabs).
445// Compacted since your last message: there is nothing new to summarise yet.
446const justCompacted = (h: Health) => (h.compactedAt ?? 0) > 0 && h.prompts === (h.promptsAtCompact ?? -1)
447const compactedLine = (h: Health) =>
448  `Compacted at ${hhmm(new Date(h.compactedAt ?? 0))}${(h.compactedTo ?? 0) > 0 ? `: now ${fmtK(h.compactedTo ?? 0)}` : ''}`
449
450async function compactNow($: EngineInterface, thenSend?: string) {
451  const h = await read($, health)
452  if (h.compactStartedAt) {
453    await say($, `Already compacting (${fmtDur((await $.clock.now()) - h.compactStartedAt)} so far). The size updates when it finishes.`, 'warning')
454    return
455  }
456  if (h.isCompacting || h.isHandingOff) return
457  if (justCompacted(h) && !thenSend) {
458    await say($, `Nothing new to compact. ${compactedLine(h)}, and no message has been sent since.`, 'warning')
459    return
460  }
461  const at = await $.clock.now()
462  await update($, health, x => ({ ...x, isCompacting: true, compactStartedAt: x.compactStartedAt || at }))
463  await syncStatus($)
464  $.ui.toast('Compacting this chat…')
465  $.clock.after(50, async () => {
466    try {
467      const r = await $.session.compact({ instructions: COMPACT_FOCUS })
468      if ('skip' in r && r.skip) return // the session.compact hook has said why
469      await update($, hold, () => null)
470      if (thenSend) await $.prompt.submit({ text: thenSend, asUser: true })
471    } catch (err) {
472      const why = err instanceof Error ? err.message : String(err)
473      await say($, /not enough|too few/i.test(why) ? compactSkipText(why, justCompacted(await read($, health))) : `Compact failed: ${clip(why, 100)}. Try /compact, or Hand off.`, 'warning')
474    } finally {
475      await update($, health, x => ({ ...x, isCompacting: false, compactStartedAt: 0 }))
476      await syncStatus($)
477    }
478  })
479}
480
481async function handOff($: EngineInterface, nextTask?: string) {
482  const h = await read($, health)
483  if (h.isHandingOff) return
484  await update($, health, x => ({ ...x, isHandingOff: true }))
485  $.ui.toast('Writing the handoff from this chat…')
486  try {
487    const prompt = nextTask
488      ? `${HANDOFF_PROMPT}\n\nEnd with a section "Next task" that hands over this request, which the user wants done in the new chat:\n"""${nextTask}"""`
489      : HANDOFF_PROMPT
490    const r = await $.model.fork({ prompt })
491    if (!r.isAnswered || !('text' in r) || !r.text.trim()) {
492      $.ui.toast(`Handoff failed (${'reason' in r ? r.reason : 'no text'}). Nothing was cleared.`)
493      return
494    }
495    const text = r.text.trim()
496    await $.ui.copy({ text })
497    await update($, hold, () => null)
498    $.clock.after(50, async () => {
499      try {
500        await $.command.run({ command: 'clear' })
501        await $.prompt.fill({ text })
502        $.ui.toast('Fresh chat ready: the handoff is in the prompt box and on the clipboard. Check it, then press Enter.')
503      } catch {
504        $.ui.toast('The handoff is on your clipboard. Run /clear and paste it.')
505      }
506    })
507  } finally {
508    await update($, health, x => ({ ...x, isHandingOff: false }))
509  }
510}
511
512// Time spent waiting on you (a question, plan approval, a permission prompt) is not Claude's working
513// time: it is kept out of the estimates and out of calibration.
514let permissionWaiting = false
515async function beginWait($: EngineInterface, at: number) {
516  await update($, live, l => (l.waitingSince ? l : { ...l, waitingSince: at }))
517}
518async function endWait($: EngineInterface) {
519  permissionWaiting = false
520  const at = await $.clock.now()
521  await update($, live, l => {
522    if (!l.waitingSince) return l
523    const waited = Math.max(0, at - l.waitingSince)
524    const plan = l.plan && l.plan.startedAt <= l.waitingSince ? { ...l.plan, waitMs: l.plan.waitMs + waited } : l.plan
525    return { ...l, waitingSince: 0, plan }
526  })
527}
528
529// Claude's own todo list (TodoWrite) as a plan, for when it keeps one instead of reporting progress.
530function planFromTodos(prev: Plan | null, todos: { content: string; status: string }[], at: number): Plan | null {
531  if (todos.length === 0) return null
532  const order = { completed: 0, in_progress: 1, pending: 2 } as Record<string, number>
533  const sorted = [...todos].sort((a, b) => (order[a.status] ?? 2) - (order[b.status] ?? 2))
534  const steps = sorted.map(t => clip(String(t.content), 80)).slice(0, 20)
535  const done = Math.min(steps.length, sorted.filter(t => t.status === 'completed').length)
536  return advancePlan(prev && prev.source === 'todos' ? prev : null, steps, 0, done, at, 'todos')
537}
538
539// Claude's task list as a plan. A new list after a finished one starts over.
540function planFromTasks(prev: Plan | null, at: number): Plan | null {
541  return planFromTodos(prev, [...tasks.values()], at)
542}
543function trackTask(tool: string, args: Record<string, unknown>, result: unknown): boolean {
544  if (tool === 'TaskCreate') {
545    const id = (result as { task?: { id?: unknown } } | undefined)?.task?.id
546    if (typeof id !== 'string' && typeof id !== 'number') return false
547    if ([...tasks.values()].every(t => t.status === 'completed')) tasks.clear()
548    tasks.set(String(id), { content: String(args.subject ?? ''), status: 'pending' })
549    return true
550  }
551  if (tool === 'TaskUpdate') {
552    const id = String(args.taskId ?? '')
553    const t = tasks.get(id)
554    if (!t) return false
555    if (args.status === 'deleted') tasks.delete(id)
556    else tasks.set(id, { content: typeof args.subject === 'string' ? args.subject : t.content, status: typeof args.status === 'string' ? args.status : t.status })
557    return true
558  }
559  return false
560}
561
562export const register: Register = (on, options) => {
563  opts = readOpts(options)
564
565  on('session.start', async ($, e, next) => {
566    // Each setup step on its own: one that fails is reported and never takes the others down with it.
567    const failed: string[] = []
568    const step = async (name: string, run: () => Promise<unknown>) => {
569      try {
570        await run()
571      } catch (err) {
572        failed.push(`${name} (${err instanceof Error ? err.message : String(err)})`)
573      }
574    }
575    await step('machine', async () => {
576      isWindows = (await $.env.get('OS')) === 'Windows_NT' || /^[a-zA-Z]:[\\/]/.test(e.cwd)
577      const home = ((isWindows ? await $.env.get('USERPROFILE') : undefined) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
578      const config = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/') || (home ? `${home}/.claude` : '')
579      sharedDir = config ? `${config.replace(/\/+$/, '')}/workbar` : ''
580      const ttl = ((await $.settings.read()) as Record<string, unknown>).promptCacheTtl
581      settingsTtl = ttl === '1h' || ttl === '5m' ? ttl : ''
582    })
583    if (opts.progressTool) {
584      await step('progress tool', () =>
585        $.tool.register({
586          name: 'progress',
587          description: PROGRESS_DESCRIPTION,
588          inputSchema: {
589            type: 'object',
590            properties: {
591              steps: { type: 'array', items: { type: 'string' } },
592              estimate_minutes: { type: 'number' },
593              done: { type: 'integer' },
594            },
595          },
596          isDeferred: false,
597        }),
598      )
599    }
600    await step('/handoff', () =>
601      $.command.register({ name: 'handoff', description: 'Write a handoff from this chat, start a fresh chat and put the handoff in the prompt box' }),
602    )
603    await step('/workbar', () => $.command.register({ name: 'workbar', description: 'Workbar: the task plan, agents and session health' }))
604    if (opts.activityDir) {
605      await step('activity folder', async () => {
606        hasActivityDir = await $.fs
607          .stat(opts.activityDir)
608          .then(st => st.kind === 'dir')
609          .catch(() => false)
610      })
611    }
612    await step('refresh timer', async () => {
613      $.clock.every(5000, async () => {
614        await update($, tick, n => (n + 1) % 1_000_000)
615        await syncSharedLimit($).catch(() => undefined)
616        await syncStatus($)
617      })
618    })
619    if (failed.length > 0) $.ui.log(`workbar: could not set up ${failed.join('; ')}`)
620    return next(e)
621  })
622
623  // The progress bar only exists when Claude can report a plan; the band says so when the tool is missing.
624  // Only the main conversation's prompt counts (a subagent's has no Agent tool).
625  on('prompt.compose', async ($, e, next) => {
626    const r = await next(e)
627    if (e.tools.includes('Agent')) progressOffered = e.tools.includes(PROGRESS_TOOL)
628    return r
629  }).catch(($, e, next) => next(e))
630
631  on('session.end', async ($, e, next) => {
632    await markActivity($, 'idle', e.sessionId)
633    if (e.reason === 'clear') {
634      await update($, health, () => EMPTY_HEALTH)
635      await update($, live, () => EMPTY_LIVE)
636      await update($, hold, () => null)
637      await update($, agents, () => [])
638      await update($, asking, () => null)
639      await update($, stuck, () => null)
640      await update($, notice, () => null)
641      await update($, chrome, () => null)
642      tasks.clear()
643      lastAdvised = 'ok'
644      errStreak = { tool: '', count: 0 }
645      permissionWaiting = false
646      lastOkAt = 0
647    }
648    return next(e)
649  })
650
651  on('command.run', { command: 'workbar' }, async $ => {
652    await $.ui.open({ id: TASKS_PANE, title: 'Workbar' })
653    return { text: 'Opened.' }
654  })
655
656  // The pane: everything in full, where height is no problem (it scrolls on its own).
657  on('ui.render', { component: 'Pane', requestId: TASKS_PANE }, async ($, e) => {
658    await read($, tick)
659    const l = await read($, live)
660    const h = await read($, health)
661    const ags = await read($, agents)
662    const now = await $.clock.now()
663    const calib = await calibration($)
664    const ratios = ((await $.store.get(CALIB_KEY)) as number[] | undefined) ?? []
665    const { Box, Text } = $.ui.resolve(e)
666    const out: RenderChildren[] = []
667    const head = (key: string, text: string) => (
668      <Text key={key} bold>
669        {out.length > 0 ? '\n' : ''}
670        {text}
671      </Text>
672    )
673
674    const p = l.plan
675    if (!p || p.steps.length === 0) {
676      out.push(
677        <Text key="noplan" dimColor>
678          {l.isWorking ? `${noPlanReason(l).long}.` : 'No plan for the last task. One shows when Claude works through several steps.'}
679        </Text>,
680      )
681    } else {
682      const left = timeLeft(p, now, calib, l.waitingSince)
683      out.push(
684        head(
685          'plan-h',
686          `Plan ${p.done}/${p.steps.length}  ·  ${fmtMin(activeMinutes(p, now, l.waitingSince))} of work${left ? `  ·  ${left}` : ''}${reportAge(l, now) ? `  ·  ${reportAge(l, now)}` : ''}${p.source === 'todos' ? '  ·  from Claude’s todo list' : ''}`,
687        ),
688      )
689      p.steps.forEach((s, i) => {
690        const began = i === 0 ? p.startedAt : p.doneAt[i - 1] ?? p.startedAt
691        out.push(
692          i < p.done ? (
693            <Text key={`s${i}`} dimColor wrap="truncate-end">
694              ✓ {s}  {i < (p.baseDone ?? 0) ? 'before the plan' : fmtDur((p.doneAt[i] ?? now) - began)}
695            </Text>
696          ) : i === p.done && l.isWorking ? (
697            <Text key={`s${i}`} bold wrap="truncate-end">
698              ▶ {s}  <Text dimColor>{fmtDur(now - began)} so far</Text>
699            </Text>
700          ) : (
701            <Text key={`s${i}`} wrap="truncate-end">
702              ○ {s}
703            </Text>
704          ),
705        )
706      })
707      if (p.firstEstimateMin > 0) {
708        out.push(
709          <Text key="est" dimColor wrap="truncate-end">
710            Claude’s estimate {fmtMin(p.firstEstimateMin)}, shown as {fmtMin(p.firstEstimateMin * calib)} (×{calib.toFixed(2)}
711            {ratios.length >= 3 ? `, median of your last ${ratios.length} plans` : ', default until 3 plans finish'})
712          </Text>,
713        )
714      }
715    }
716
717    if (ags.length > 0) {
718      out.push(head('ag-h', `Agents  ·  ${ags.filter(a => a.status === 'running').length} running`))
719      for (const a of ags) {
720        const t = a.status === 'running' ? now - a.startedAt : a.endedAt - a.startedAt
721        out.push(
722          <Text key={`ag-${a.id}`} wrap="truncate-end">
723            <Text color={a.status === 'running' ? 'claude' : a.status === 'done' ? 'success' : 'error'}>
724              {a.status === 'running' ? '● ' : a.status === 'done' ? '✓ ' : '✗ '}
725            </Text>
726            {a.label}
727            <Text dimColor>
728              {' '}
729              ({a.type}) · {plural(a.tools, 'tool')} · {fmtDur(t)}
730              {a.lastAction ? ` · ${a.lastAction}` : ''}
731            </Text>
732          </Text>,
733        )
734      }
735    }
736
737    if (h.tokens > 0) {
738      out.push(head('h-h', 'Session'))
739      const cacheLeft = cacheTtlMs(h) - (now - h.lastResponseAt)
740      out.push(
741        <Text key="h-chat" wrap="truncate-end">
742          chat {fmtK(h.tokens)} tokens  ·  cache {cacheLeft > 0 ? `warm for ${Math.ceil(cacheLeft / 60000)}m` : 'expired'} (
743          {cacheTtlMs(h) / 60000}m lifetime){h.costUsd > 0 ? `  ·  $${h.costUsd.toFixed(2)} so far (list price)` : ''}
744        </Text>,
745      )
746      for (const x of h.limits) {
747        const full = paceFullAt(h.paceBases.find(b => b.kind === x.kind), x.pct, x.resetsAt, now)
748        const reset = x.resetsAt ? new Date(x.resetsAt) : null
749        out.push(
750          <Text key={`lim-${x.kind}`} wrap="truncate-end">
751            {limitLabel(x.kind)} limit {Math.round(x.pct)}%
752            {reset && !isNaN(reset.getTime()) ? `  ·  resets ${hhmm(reset)}` : ''}
753            {full ? <Text color="warning">  ·  at this pace full ≈{hhmm(new Date(full))}</Text> : ''}
754          </Text>,
755        )
756      }
757    }
758    return <Box flexDirection="column">{out}</Box>
759  })
760
761  // Keep the usage windows current between turns too, including the moment a limit is hit.
762  on('session.measure', async ($, e, next) => {
763    const reading = e.context.tokens
764    if (e.changed.includes('context') && typeof reading === 'number' && reading > 0) await update($, health, h => withReading(h, reading))
765    if (e.changed.includes('rateLimits')) {
766      const at = await $.clock.now()
767      const limits = e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt ?? '' }))
768      await update($, health, h => ({ ...h, limits, paceBases: nextPaceBases(h.paceBases ?? [], limits, at) }))
769    }
770    return next(e)
771  }).catch(($, e, next) => next(e))
772
773  // Any compaction of the main chat (the band's button, /compact, auto): shown while it runs, and its
774  // result is the chat size until the next reply (Claude Code's own figure lags until then).
775  on('session.compact', async ($, e, next) => {
776    const isMain = e.trigger !== 'precompute' && e.agentId === undefined
777    if (isMain) {
778      const start = await $.clock.now()
779      await update($, health, x => ({ ...x, compactStartedAt: x.compactStartedAt || start }))
780      await syncStatus($)
781    }
782    let r: Awaited<ReturnType<typeof next>>
783    try {
784      r = await next(e)
785    } finally {
786      if (isMain) await update($, health, x => ({ ...x, compactStartedAt: 0 }))
787    }
788    if (!isMain) return r
789    try {
790      const h = await read($, health)
791      if ('skip' in r && r.skip) {
792        await say($, compactSkipText(r.skip, justCompacted(h)), 'warning')
793      } else {
794        const at = await $.clock.now()
795        const after = 'tokensAfter' in r && typeof r.tokensAfter === 'number' ? r.tokensAfter : 0
796        const before = 'tokensBefore' in r && typeof r.tokensBefore === 'number' && r.tokensBefore > 0 ? r.tokensBefore : h.tokens
797        await update($, health, x => ({
798          ...x,
799          tokens: after,
800          allowedUntilTokens: 0,
801          lastResponseAt: at,
802          compactedAt: at,
803          compactedFrom: before,
804          compactedTo: after,
805          promptsAtCompact: x.prompts,
806        }))
807        await update($, hold, () => null)
808        lastAdvised = 'ok'
809        const text = `Compacted ${before > 0 ? fmtK(before) : 'the chat'} → ${after ? fmtK(after) : 'small'}`
810        await say($, `${text}. Compact again once the chat has grown.`)
811        if (opts.band === 'off') $.ui.toast(text)
812      }
813      await syncStatus($)
814    } catch {
815      // the compaction itself is done; only the band's account of it is missing
816    }
817    return r
818  }).catch(($, e, next) => next(e))
819
820  on('command.run', { command: 'handoff' }, async $ => {
821    void handOff($)
822    return { text: 'Writing a handoff, then starting a fresh chat with it in the prompt box.' }
823  })
824
825  // Hold a message before it goes into a chat that is too big, cold, or about something else.
826  on('prompt.submit', async ($, e, next) => {
827    if (e.command) {
828      if (e.command.name === 'login') {
829        const limited = (await read($, health)).limitNoticeAt > 0 || isSharedLimitOn(await readSharedLimit($), await $.clock.now(), lastOkAt)
830        if (limited) await markLimit($, false) // a login here counts for every chat
831      }
832      return next(e)
833    }
834    if (e.isMeta) return next(e)
835    if (e.turnId) {
836      // Typed while a run is going: ask Claude to fold it into the visible plan.
837      const l = await read($, live)
838      return l.plan?.source === 'progress' ? next({ ...e, context: [...(e.context ?? []), MIDRUN_NOTE] }) : next(e)
839    }
840    const text = e.text
841    const h = await read($, health)
842    const held = await read($, hold)
843    const now = await $.clock.now()
844
845    // Second Enter on the same text sends it here anyway.
846    const isRepeat = held !== null && held.text.trim() === text.trim()
847    let reason = ''
848    let isNewTask = false
849    const agentsBusy = (await read($, agents)).some(a => a.status === 'running')
850    const isFresh = justCompacted(h) || (h.compactStartedAt ?? 0) > 0 // nothing to gain from compacting again
851    if (opts.holdMessages && !isRepeat && !agentsBusy && !isFresh && !isLimitHit(h, now) && h.tokens > h.allowedUntilTokens) {
852      const lv = level(h, now)
853      if (lv === 'cold') {
854        reason = `cache expired, this would re-send all ${fmtK(h.tokens)} at full price`
855      } else if (lv === 'red') {
856        reason = `chat is ${fmtK(h.tokens)}, every message re-reads all of it`
857      } else if (opts.topicCheck && h.tokens >= TOPIC_CHECK_TOKENS && text.length >= 60 && h.firstPrompt) {
858        try {
859          if (await isNewTopic($, h, text)) {
860            reason = 'looks like a new task'
861            isNewTask = true
862          }
863        } catch {
864          // a failed check never blocks a message
865        }
866      }
867    }
868
869    if (reason) {
870      await update($, hold, () => ({ text, reason, isNewTask }))
871      void $.clock.after(30, () => {
872        void $.prompt.fill({ text }).catch(() => undefined)
873      })
874      return { drop: `Held: ${reason}. ${isNewTask ? 'New task: Hand off.' : 'Same task: Compact and send.'} Or press Enter again to send it here anyway.` }
875    }
876
877    if (isRepeat) {
878      await update($, health, x => ({ ...x, allowedUntilTokens: x.tokens + 100_000 }))
879    }
880    await update($, hold, () => null)
881    const result = await next(e)
882    if (!result.drop) {
883      await update($, agents, list => (list.some(a => a.status !== 'running') ? list.filter(a => a.status === 'running') : list))
884      await update($, notice, () => null)
885      await update($, health, x => ({
886        ...x,
887        prompts: x.prompts + 1,
888        firstPrompt: x.firstPrompt || text,
889        recentPrompts: [...x.recentPrompts, text].slice(-2),
890      }))
891    }
892    return result
893  }).catch(($, e, next) => next(e))
894
895  // Claude Code's own notice that the usage limit is reached (the transcript row, never read by the model).
896  on('session.append', async ($, e, next) => {
897    const r = await next(e)
898    if (e.message.type === 'system' && e.agentId === undefined) {
899      const content: unknown = e.message.content
900      const text =
901        typeof content === 'string'
902          ? content
903          : (Array.isArray(content) ? content : [])
904              .map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
905              .join(' ')
906      if (LIMIT_TEXT.test(text)) await markLimit($, true)
907    }
908    return r
909  }).catch(($, e, next) => next(e))
910
911  // The same when an API error ends the turn over a usage limit or billing.
912  on('classic.StopFailure', async ($, e, next) => {
913    const r = await next(e)
914    if (e.error === 'billing_error' || (e.error === 'rate_limit' && LIMIT_TEXT.test(e.error_details ?? ''))) await markLimit($, true)
915    return r
916  }).catch(($, e, next) => next(e))
917
918  on('turn.start', async ($, e, next) => {
919    if (e.agentId === undefined) {
920      const at = await $.clock.now()
921      mainTurnId = e.turnId
922      // Logged in, or the limit reset: this chat tries again. If another chat's flag still stands, the
923      // request pauses and asks; if this one fails again, the notice brings Log in back.
924      if ((await read($, health)).limitNoticeAt) await markLimit($, false, 'here')
925      errStreak = { tool: '', count: 0 }
926      await update($, stuck, () => null)
927      // An unfinished plan carries into this message ("continue", an answer to Claude's question), with
928      // the time between the two messages left out of its working time. It goes if Claude does not update it.
929      await update($, live, l => {
930        const p = l.carryPlan && l.plan ? l.plan : null
931        const gap = p && l.endedAt && l.endedAt >= p.startedAt ? Math.max(0, at - l.endedAt) : 0
932        const plan = p ? { ...p, carried: true, waitMs: p.waitMs + gap } : null
933        return { ...l, isWorking: true, startedAt: at, tools: 0, lastAction: '', plan, waitingSince: 0, carryPlan: false, nudged: false, reportedAt: 0, staleFrom: 0, staleNudges: 0 }
934      })
935      await markActivity($, 'busy')
936      await syncStatus($)
937    }
938    return next(e)
939  })
940
941  // Every model request, main or subagent: paused while the account is at its usage limit (one
942  // question for all of them), and a reply that arrives is the sign this chat is through it.
943  on('turn.step', async function* ($, e, next) {
944    const sentAt = await $.clock.now()
945    if (isSharedLimitOn(await readSharedLimit($), sentAt, lastOkAt)) {
946      pausing ??= askToContinue($).finally(() => {
947        pausing = null
948      })
949      if ((await pausing) === 'stop') {
950        await $.turn.abort({ turnId: mainTurnId || e.turnId }).catch(() => undefined)
951        return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: null, usage: null }
952      }
953    }
954    const r = yield* next(e)
955    if (r.stopReason !== null && sentAt > lastOkAt) {
956      lastOkAt = sentAt
957      // Through the limit: tell the other chats, which may still show Log in or wait on a question.
958      if (sharedLimit && sharedLimit.hitAt > sharedLimit.clearedAt && sharedLimit.hitAt < sentAt) await markLimit($, false)
959    }
960    return r
961  })
962
963  on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
964    const args = e as unknown as { steps?: unknown; estimate_minutes?: unknown; done?: unknown; agentId?: string }
965    if (args.agentId !== undefined) {
966      const id = args.agentId
967      await update($, agents, list =>
968        list.map(a => {
969          if (a.id !== id) return a
970          const steps = Array.isArray(args.steps) ? args.steps.length : a.plan?.steps ?? 0
971          const done = typeof args.done === 'number' ? Math.max(0, Math.min(steps, Math.floor(args.done))) : a.plan?.done ?? 0
972          return steps > 0 ? { ...a, plan: { steps, done } } : a
973        }),
974      )
975      return { result: 'ok' }
976    }
977    const at = await $.clock.now()
978    await update($, live, l => {
979      const newSteps = Array.isArray(args.steps) ? args.steps.map(s => clip(String(s), 80)).filter(Boolean).slice(0, 20) : null
980      // A plan carried from your last message continues only if this one is the same work.
981      const isNewWork = !!l.plan?.carried && !!newSteps && !sharesStep(l.plan.steps, newSteps)
982      const prev = l.plan?.source === 'progress' && !isNewWork ? l.plan : null
983      const steps = newSteps ?? prev?.steps ?? []
984      const est = typeof args.estimate_minutes === 'number' && args.estimate_minutes > 0 ? args.estimate_minutes : 0
985      const done = typeof args.done === 'number' ? Math.max(0, Math.min(steps.length, Math.floor(args.done))) : prev?.done ?? 0
986      // A plan that switches from the todo list keeps its start, so elapsed time stays honest.
987      const base =
988        prev ??
989        (l.plan && !isNewWork ? { ...l.plan, done: 0, doneAt: [], doneActive: [], firstEstimateMin: 0, estimateMin: 0, baseDone: 0, estActive: 0, estDone: 0 } : null)
990      return { ...l, plan: advancePlan(base, steps, est, done, at, 'progress'), reportedAt: at, staleFrom: l.tools }
991    })
992    await syncStatus($)
993    return { result: 'ok' }
994  }).catch(() => ({ result: 'ok' }))
995
996  on('tool.call', async ($, e, next) => {
997    const tool = String(e.tool)
998    const any = e as unknown as Record<string, unknown>
999    const agentId = (e as { agentId?: string }).agentId
1000    // Workbar's own pause at the usage limit asks through AskUserQuestion too; it is not Claude's work.
1001    const isPause = pausing !== null && tool === 'AskUserQuestion'
1002    if (tool !== PROGRESS_TOOL && !isPause) {
1003      const target = String(any.file_path ?? any.path ?? any.command ?? any.pattern ?? any.url ?? '')
1004      const name = tool.replace(/^mcp__([^_]+)__/, '$1:')
1005      const file = target.split(/[\\/]/).pop() ?? ''
1006      const action = clip(`${name} ${any.file_path || any.path ? file : target}`, 60)
1007      if (agentId === undefined) await update($, live, l => ({ ...l, tools: l.tools + 1, lastAction: action }))
1008      else await update($, agents, list => list.map(a => (a.id === agentId ? { ...a, tools: a.tools + 1, lastAction: action } : a)))
1009    }
1010    // A tool call starting means any permission prompt before it has been answered.
1011    if (permissionWaiting) await endWait($)
1012    const question = isPause ? PAUSED : agentId === undefined ? QUESTION_TOOLS[tool] : undefined
1013    if (question) {
1014      await update($, asking, () => question)
1015      await beginWait($, await $.clock.now())
1016      await syncStatus($)
1017      $.clock.after(ASK_ALERT_MS, async () => {
1018        if ((await read($, asking)) === question) await alert($, question)
1019      })
1020    }
1021    let ran: Awaited<ReturnType<typeof next>>
1022    try {
1023      ran = await next(e)
1024    } finally {
1025      if (question || permissionWaiting) await endWait($)
1026      if (question) {
1027        await update($, asking, () => null)
1028        await syncStatus($)
1029      }
1030    }
1031
1032    if (agentId === undefined && tool !== PROGRESS_TOOL && !isPause) {
1033      // Claude's own todo list stands in for a plan when it has not reported one.
1034      if (tool === 'TodoWrite' && !ran.isError && Array.isArray(any.todos)) {
1035        const at = await $.clock.now()
1036        await update($, live, l =>
1037          l.plan?.source === 'progress' && !l.plan.carried ? l : { ...l, plan: planFromTodos(l.plan?.source === 'todos' ? l.plan : null, any.todos as { content: string; status: string }[], at) },
1038        )
1039        await syncStatus($)
1040      }
1041      // Or its task list, the newer kind.
1042      if ((tool === 'TaskCreate' || tool === 'TaskUpdate') && !ran.isError && ran.deny === undefined && trackTask(tool, any, ran.result)) {
1043        const at = await $.clock.now()
1044        await update($, live, l =>
1045          l.plan?.source === 'progress' && !l.plan.carried ? l : { ...l, plan: planFromTasks(l.plan?.source === 'todos' ? l.plan : null, at) },
1046        )
1047        await syncStatus($)
1048      }
1049      // Several failures in a row: worth a look before more go the same way. A refusal by you is not one.
1050      if (ran.isError && ran.deny === undefined) {
1051        errStreak = errStreak.tool === tool ? { tool, count: errStreak.count + 1 } : { tool, count: 1 }
1052        if (errStreak.count >= STUCK_AFTER) {
1053          await update($, stuck, () => ({ tool: tool.replace(/^mcp__([^_]+)__/, '$1:'), count: errStreak.count }))
1054          await syncStatus($)
1055        }
1056      } else if (!ran.isError && errStreak.count > 0) {
1057        errStreak = { tool: '', count: 0 }
1058        if (await read($, stuck)) {
1059          await update($, stuck, () => null)
1060          await syncStatus($)
1061        }
1062      }
1063    }
1064
1065    if (ran.deny !== undefined) return ran
1066    let out = ran
1067    // A task past a quick answer with no plan on screen: ask Claude for one, once per message.
1068    if (agentId === undefined && tool !== PROGRESS_TOOL && !question && opts.progressTool && progressOffered !== false) {
1069      const l = await read($, live)
1070      const p = l.plan
1071      const now = await $.clock.now()
1072      if (l.isWorking && !l.nudged && l.tools > QUICK_TOOLS && (!p || p.carried)) {
1073        await update($, live, x => ({ ...x, nudged: true }))
1074        out = { ...ran, context: [...(ran.context ?? []), p ? NUDGE_CARRIED_NOTE : NUDGE_NOTE] }
1075      } else if (
1076        // A plan the work has run ahead of: remind Claude to report the steps it has finished.
1077        l.isWorking &&
1078        p?.source === 'progress' &&
1079        !p.carried &&
1080        p.done < p.steps.length &&
1081        (l.staleNudges ?? 0) < STALE_NUDGES &&
1082        l.tools - (l.staleFrom ?? 0) >= STALE_TOOLS &&
1083        now - (l.reportedAt ?? now) >= STALE_MS
1084      ) {
1085        await update($, live, x => ({ ...x, staleFrom: x.tools, staleNudges: (x.staleNudges ?? 0) + 1 }))
1086        out = { ...ran, context: [...(ran.context ?? []), staleNote(p)] }
1087      }
1088    }
1089
1090    // Browser extension dropped: show how to reconnect, and keep Claude from giving up on the browser step.
1091    if (!tool.startsWith(CHROME_PREFIX)) return out
1092    const text = typeof ran.text === 'string' ? ran.text : ''
1093    if (ran.isError && CHROME_DOWN.test(text)) {
1094      const was = await read($, chrome)
1095      await update($, chrome, () => ({ message: clip(text, 160) }))
1096      if (!was) {
1097        $.ui.toast('Chrome extension not connected: run /chrome and pick Reconnect extension.')
1098        void alert($, 'Chrome extension disconnected. In Claude Code run /chrome, then Reconnect extension.')
1099      }
1100      return { ...out, context: [...(out.context ?? []), CHROME_NOTE] }
1101    }
1102    if (!ran.isError) await update($, chrome, () => null)
1103    return out
1104  }).catch(($, e, next) => next(e))
1105
1106  // Claude Code's own "needs you" moments (a permission dialog, an MCP form).
1107  on('classic.Notification', async ($, e, next) => {
1108    const r = await next(e)
1109    if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
1110      if ((await read($, live)).isWorking) {
1111        permissionWaiting = true
1112        await beginWait($, await $.clock.now())
1113      }
1114      void alert($, e.message || 'Claude needs your approval')
1115    }
1116    return r
1117  }).catch(($, e, next) => next(e))
1118
1119  // Subagents: one row each in the band, from start to finish, so it is clear what the chat is waiting on.
1120  on('agent.spawn', async ($, e, next) => {
1121    const r = await next(e)
1122    const id = 'agentId' in r ? r.agentId : undefined
1123    if (id) {
1124      try {
1125        const at = await $.clock.now()
1126        const run: AgentRun = {
1127          id,
1128          label: clip(e.description || e.name || e.subagentType, 48),
1129          type: e.fork ? 'fork' : e.subagentType,
1130          startedAt: at,
1131          endedAt: 0,
1132          tools: 0,
1133          lastAction: '',
1134          status: 'running',
1135          plan: null,
1136        }
1137        await update($, agents, list => [...list.filter(a => a.id !== id), run].slice(-24))
1138        await syncStatus($)
1139      } catch {
1140        // tracking is best effort; the spawn already happened
1141      }
1142    }
1143    return r
1144  }).catch(($, e, next) => next(e))
1145
1146  on('turn.complete', async ($, e, next) => {
1147    const at = await $.clock.now()
1148    if (e.agentId !== undefined) {
1149      const id = e.agentId
1150      await update($, agents, list => list.map((a): AgentRun => (a.id === id ? { ...a, status: e.isAborted ? 'stopped' : 'done', endedAt: at } : a)))
1151      await syncStatus($)
1152      return next(e)
1153    }
1154    await endWait($)
1155    const l = await read($, live)
1156    const seconds = Math.round((at - l.startedAt) / 1000)
1157    // Claude is told the last step needs no report, so a run that ends one step short has finished it.
1158    const isLastOpen = !!l.plan && !e.isAborted && l.plan.source === 'progress' && !l.plan.carried && l.plan.done === l.plan.steps.length - 1
1159    const plan = l.plan && isLastOpen ? advancePlan(l.plan, l.plan.steps, 0, l.plan.steps.length, at, 'progress') : l.plan
1160    let sampled = plan?.sampled ?? false
1161    if (plan && !e.isAborted && plan.source === 'progress' && !plan.sampled && !plan.carried) {
1162      const sample = calibrationSample(plan, activeMinutes(plan, at, 0))
1163      if (sample !== null) {
1164        const ratios = ((await $.store.get(CALIB_KEY)) as number[] | undefined) ?? []
1165        await $.store.set(CALIB_KEY, [...ratios, sample].slice(-20))
1166        sampled = true
1167      }
1168    }
1169    // Unfinished and updated in this message: your next message continues it.
1170    const carry = !!plan && plan.done < plan.steps.length && !plan.carried
1171    await update($, live, x => ({
1172      ...x,
1173      isWorking: false,
1174      waitingSince: 0,
1175      plan: plan ? { ...plan, sampled } : plan,
1176      carryPlan: carry,
1177      endedAt: at,
1178      lastRun: { seconds, estimateMin: plan?.firstEstimateMin || null, steps: plan?.steps.length ?? 0, done: plan?.done ?? 0, tools: x.tools },
1179    }))
1180    await refreshTokens($)
1181    await markActivity($, 'idle')
1182    const hNow = await read($, health)
1183    const lvNow = level(hNow, at)
1184    if (LEVEL_RANK[lvNow] > LEVEL_RANK[lastAdvised]) {
1185      $.ui.toast(
1186        lvNow === 'amber'
1187          ? `This chat is ${fmtK(hNow.tokens)}. Finish this task here; for the next one press Compact (same task) or Hand off (new task).`
1188          : `This chat is ${fmtK(hNow.tokens)}. Before your next message: Compact if it continues this task, Hand off if it is a new one.`,
1189      )
1190    }
1191    lastAdvised = lvNow
1192    await syncStatus($)
1193    if (seconds >= NOTIFY_AFTER_S && !e.isAborted) await alert($, `Finished in ${fmtDur(seconds * 1000)}`)
1194    return next(e)
1195  })
1196
1197  // The band: one framed panel that always fits its row budget. Frame colour and badge give the state
1198  // from across the room; ★ marks the recommended action. Claude Code's own [-] beside it folds it.
1199  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1200    if (e.props.hasSurvey || opts.band === 'off') return next(e)
hooks/lib.ts 238 lines
1// Pure helpers: formatting, time estimates, usage pace and the band's row budget.
2// Nothing here touches the engine, so tests can call it directly.
3import type { Plan } from '../types'
4
5export const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
6
7export const fmtK = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
8export const fmtDur = (ms: number) => {
9  const s = Math.max(0, Math.round(ms / 1000))
10  if (s < 60) return `${s}s`
11  const m = Math.floor(s / 60)
12  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
13  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
14}
15export const fmtMin = (min: number) => (min < 1 ? 'under 1m' : min < 90 ? `${Math.round(min)}m` : `${(min / 60).toFixed(1)}h`)
16export const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
17export const clip = (s: string, n: number) => {
18  const one = s.replace(/\s+/g, ' ').trim()
19  return one.length > n ? one.slice(0, Math.max(1, n - 1)) + '…' : one
20}
21export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
22
23// ---- time estimates ----
24// Models tend to estimate how long a person would take, not how long they take themselves, so first
25// estimates run long. Each finished plan stores actual/estimated; the median of the last 20 scales the next.
26export const CALIB_MIN = 0.15
27export const CALIB_MAX = 3
28export const DEFAULT_CALIB = 0.5 // until 3 plans have finished
29
30export function median(values: number[]): number {
31  const s = values.filter(x => Number.isFinite(x) && x > 0).sort((a, b) => a - b)
32  if (s.length === 0) return NaN
33  const mid = Math.floor(s.length / 2)
34  return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
35}
36
37export function calibrationFrom(ratios: number[] | undefined): number {
38  const r = (ratios ?? []).filter(x => Number.isFinite(x) && x > 0)
39  if (r.length < 3) return DEFAULT_CALIB
40  return clamp(median(r), CALIB_MIN, CALIB_MAX)
41}
42
43// Minutes of work since the plan started, leaving out time spent waiting on you (questions, plan approval).
44export function activeMinutes(p: Plan, now: number, waitingSince: number): number {
45  const waiting = waitingSince > 0 && waitingSince >= p.startedAt ? now - waitingSince : 0
46  return Math.max(0, now - p.startedAt - p.waitMs - waiting) / 60000
47}
48
49// A plan from Claude's report, or the next state of one. Steps already finished when a plan first
50// arrives (it often comes a few tool calls in) are its `baseDone`: they took no time on this plan's
51// clock, so they never count towards its pace. An estimate is minutes from when it was given.
52export function advancePlan(prev: Plan | null, steps: string[], estimateMin: number, done: number, at: number, source: Plan['source']): Plan {
53  const startedAt = prev?.startedAt ?? at
54  const waitMs = prev?.waitMs ?? 0
55  const keep = Math.min(done, prev?.done ?? 0)
56  const doneAt = (prev?.doneAt ?? []).slice(0, keep)
57  const doneActive = (prev?.doneActive ?? []).slice(0, keep)
58  const activeNow = Math.max(0, at - startedAt - waitMs) / 60000
59  while (doneAt.length < done) doneAt.push(at)
60  while (doneActive.length < done) doneActive.push(activeNow)
61  const isNewEstimate = estimateMin > 0
62  return {
63    steps,
64    estimateMin: isNewEstimate ? estimateMin : prev?.estimateMin ?? 0,
65    firstEstimateMin: prev?.firstEstimateMin || estimateMin,
66    done,
67    startedAt,
68    doneAt,
69    doneActive,
70    waitMs,
71    source,
72    sampled: prev?.sampled,
73    baseDone: Math.min(done, prev ? prev.baseDone ?? 0 : done),
74    estActive: isNewEstimate ? activeNow : prev?.estActive ?? 0,
75    estDone: isNewEstimate ? done : prev?.estDone ?? 0,
76  }
77}
78
79// Time left on something expected to take `expected` minutes, `spent` minutes in. Once past three
80// quarters of it, expect a third as much again as has gone already: a step that runs over usually
81// has more to go, so the count never sits at "under 1m" while the work goes on.
82const OVERRUN_SHARE = 1 / 3
83const remainingOf = (expected: number, spent: number) => Math.max(expected - spent, spent * OVERRUN_SHARE, 0)
84
85// Before a step is finished on this plan's clock: the calibrated estimate counts down from when it was
86// given. After that, the pace of those steps takes over, weighed against the estimate's per-step share
87// as if it were one finished step (early steps, often reading, tend to be the quick ones).
88const PRIOR_WEIGHT = 1
89export function minutesLeft(p: Plan, now: number, calib: number, waitingSince = 0): number | null {
90  const n = Math.max(1, p.steps.length)
91  if (p.done >= n) return 0
92  const active = activeMinutes(p, now, waitingSince)
93  const base = clamp(p.baseDone ?? 0, 0, p.done)
94  const estDone = clamp(p.estDone ?? 0, 0, n - 1)
95  const prior = p.estimateMin > 0 ? p.estimateMin * calib : 0
96  const sinceEstimate = Math.max(0, active - (p.estActive ?? 0))
97  const ran = p.done - base
98  if (ran <= 0) return prior > 0 ? remainingOf(prior, sinceEstimate) : null
99  const firstAt = base > 0 ? p.doneActive[base - 1] ?? 0 : 0
100  const lastAt = clamp(p.doneActive[p.done - 1] ?? active, firstAt, Math.max(firstAt, active))
101  const observed = Math.max(0.05, (lastAt - firstAt) / ran)
102  const priorPerStep = prior / (n - estDone)
103  const perStep = prior > 0 ? (PRIOR_WEIGHT * priorPerStep + ran * observed) / (PRIOR_WEIGHT + ran) : observed
104  return remainingOf(perStep, Math.max(0, active - lastAt)) + perStep * (n - p.done - 1)
105}
106
107// actual/estimated for a finished (or mostly finished) plan, or null when it says nothing reliable.
108// Only the work this plan's clock saw counts: the steps left when it arrived, against its first estimate.
109export function calibrationSample(p: Plan, activeMin: number): number | null {
110  const base = clamp(p.baseDone ?? 0, 0, p.done)
111  const todo = p.steps.length - base
112  const ran = p.done - base
113  if (p.firstEstimateMin <= 0 || todo <= 0 || ran < Math.ceil(todo / 2) || activeMin < 0.5) return null
114  const projected = activeMin * (todo / ran)
115  return clamp(projected / p.firstEstimateMin, 0.03, 20)
116}
117
118// ---- after a compaction ----
119// Claude Code's context figure comes from the last reply, so until the next one it can still read the
120// size from before a compaction. A reading close to that old size is stale; the first lower one ends the guard.
121export function freshTokens(reading: number, current: number, compactedFrom: number): { tokens: number; isStale: boolean } {
122  if (compactedFrom > 0 && reading >= compactedFrom * 0.9) return { tokens: current, isStale: true }
123  return { tokens: reading, isStale: false }
124}
125
126// Why a compaction did not run, in words that say what to do next.
127export function compactSkipText(reason: string, justCompacted: boolean): string {
128  if (/not enough|too few|nothing to compact/i.test(reason)) {
129    return justCompacted
130      ? 'Nothing new to compact: this chat was just compacted. Compact again once it has grown.'
131      : 'Too few messages to compact yet: there is nothing to save.'
132  }
133  return `Compact did not run: ${clip(reason, 120)}`
134}
135
136// Two plans are the same piece of work when they share a step.
137export const sharesStep = (a: string[], b: string[]) => {
138  const seen = new Set(a.map(s => s.toLowerCase()))
139  return b.some(s => seen.has(s.toLowerCase()))
140}
141
142// ---- topic check, the free part ----
143const STOP = new Set(
144  'that this with from have what when then them they your about into also just like make sure want need please could would should there their here will more some only than very each which while still after before because does done being been were these those other really thing things okay yeah'.split(' '),
145)
146const words = (s: string) => new Set((s.toLowerCase().match(/[a-z0-9_.\-/]{4,}/g) ?? []).filter(w => !STOP.has(w)))
147
148// True when a message shares (almost) no words with the earlier requests: the only case worth a model call.
149export function looksUnrelated(text: string, earlier: string[]): boolean {
150  const mine = words(text)
151  if (mine.size < 4) return false // too short to judge; follow-ups are usually short
152  const seen = words(earlier.join(' '))
153  let shared = 0
154  for (const w of mine) if (seen.has(w)) shared++
155  return shared / mine.size < 0.15
156}
157
158// ---- usage pace: when the current rate would use up a limit window ----
159export type PaceBase = { kind: string; resetsAt: string; pct: number; at: number }
160
161export function nextPaceBases(bases: PaceBase[], limits: { kind: string; pct: number; resetsAt: string }[], now: number): PaceBase[] {
162  return limits.map(x => {
163    const b = bases.find(y => y.kind === x.kind)
164    return b && b.resetsAt === x.resetsAt && x.pct >= b.pct ? b : { kind: x.kind, resetsAt: x.resetsAt, pct: x.pct, at: now }
165  })
166}
167
168// When the window would hit 100% at the rate since it was first seen, if that is before it resets.
169export function paceFullAt(base: PaceBase | undefined, pct: number, resetsAt: string, now: number): number | null {
170  if (!base || now - base.at < 10 * 60000 || pct <= base.pct) return null
171  const rate = (pct - base.pct) / (now - base.at)
172  const at = now + (100 - pct) / rate
173  const reset = Date.parse(resetsAt)
174  return Number.isFinite(reset) && at >= reset ? null : at
175}
176
177// ---- a usage limit, shared by every chat on this machine ----
178// The limit is the account's, so when one chat hits it every chat is at it. One small file says so:
179// when it was hit, until when it can last at most, and when someone logged in after it.
180export type SharedLimit = { hitAt: number; until: number; clearedAt: number }
181export const LIMIT_MAX_MS = 5 * 60 * 60_000 // a 5-hour window resets by then at the latest
182
183export function parseSharedLimit(text: string): SharedLimit | null {
184  try {
185    const o = JSON.parse(text) as Record<string, unknown>
186    const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
187    const hitAt = num(o.hitAt)
188    return hitAt > 0 ? { hitAt, until: num(o.until) || hitAt + LIMIT_MAX_MS, clearedAt: num(o.clearedAt) } : null
189  } catch {
190    return null
191  }
192}
193
194// Whether a chat should treat the account as at its limit: hit, not logged in or reset since, and not
195// answered for this chat since (its own successful reply after the hit says otherwise).
196export const isSharedLimitOn = (s: SharedLimit | null, now: number, lastOkAt: number) =>
197  !!s && s.hitAt > s.clearedAt && now < s.until && s.hitAt > lastOkAt
198
199// When a limit hit now ends at the latest: the earliest reset of a window that is full, else 5 hours.
200export function limitUntil(limits: { pct: number; resetsAt: string }[], now: number): number {
201  const resets = limits
202    .filter(x => x.pct >= 100)
203    .map(x => Date.parse(x.resetsAt))
204    .filter(t => Number.isFinite(t) && t > now)
205  return resets.length > 0 ? Math.min(...resets) : now + LIMIT_MAX_MS
206}
207
208export const limitLabel =(kind: string) => (kind === 'five_hour' ? '5h' : kind === 'seven_day' ? 'week' : kind.replace(/_/g, ' '))
209
210// ---- the band's height ----
211// The band shares the bottom of the screen with the prompt and with Claude Code's own dialogs. A tree
212// taller than its room scrolls in a small window, and on the main screen a tall bottom area pushes past
213// the terminal and breaks the redraw. So the band always fits a fixed budget, borders included.
214export const BAND_MAX_ROWS = 12
215
216export function bandBudget(maxRows: number | undefined, viewportRows: number | undefined, isFullscreen: boolean | undefined): number {
217  const room = maxRows ?? 20
218  const screen = viewportRows && viewportRows > 0 ? viewportRows : 40
219  const share = isFullscreen === true ? room : Math.min(room, Math.max(5, Math.floor(screen / 3)))
220  return clamp(share, 3, BAND_MAX_ROWS)
221}
222
223// Rows a wrapping line takes at a width (a little pessimistic, as word wrap leaves gaps).
224export const wrappedRows = (chars: number, width: number) => Math.max(1, Math.ceil((chars * 1.1) / Math.max(10, width)))
225
226// Keep the most important rows that fit, shown in their original order.
227export function fitRows<T>(items: { prio: number; rows: number; el: T }[], room: number): T[] {
228  const ranked = items.map((it, i) => ({ ...it, i })).sort((a, b) => b.prio - a.prio || a.i - b.i)
229  const kept: typeof ranked = []
230  let used = 0
231  for (const it of ranked) {
232    if (used + it.rows > room) continue
233    kept.push(it)
234    used += it.rows
235  }
236  return kept.sort((a, b) => a.i - b.i).map(it => it.el)
237}
238
types/index.d.ts 93 lines
1export type Plan = {
2  steps: string[]
3  estimateMin: number // latest estimate for the whole task
4  firstEstimateMin: number // the first one, which calibration compares against
5  done: number
6  startedAt: number
7  doneAt: number[] // wall-clock time each step finished
8  doneActive: number[] // minutes of work (waiting on you left out) when each step finished
9  waitMs: number // time spent waiting on you since the plan started
10  source: 'progress' | 'todos' // Claude's progress reports, or its own todo or task list
11  carried?: boolean // brought over from your previous message and not updated by Claude since
12  sampled?: boolean // already counted towards calibration
13  baseDone?: number // steps already finished when the plan arrived: no time on its clock
14  estActive?: number // minutes of work when the latest estimate was given (it counts from then)
15  estDone?: number // steps done when the latest estimate was given
16}
17
18export type Live = {
19  isWorking: boolean
20  startedAt: number
21  tools: number
22  lastAction: string
23  plan: Plan | null
24  waitingSince: number // a question or plan dialog is open since then; 0 when none
25  lastRun: { seconds: number; estimateMin: number | null; steps: number; done: number; tools: number } | null
26  carryPlan?: boolean // the plan was unfinished when the last turn ended: the next message continues it
27  endedAt?: number // when the last turn ended
28  nudged?: boolean // Claude was asked for a plan in this turn
29  reportedAt?: number // when Claude last reported progress
30  staleFrom?: number // tool calls counted at that report, or at the last reminder to send one
31  staleNudges?: number // reminders to report progress in this turn
32}
33
34// A short line the band keeps for a while: what just happened, or why an action did not run.
35export type Notice = { text: string; tone: 'info' | 'warning'; until: number } | null
36
37export type Health = {
38  tokens: number
39  prompts: number
40  lastResponseAt: number
41  firstPrompt: string
42  recentPrompts: string[]
43  allowedUntilTokens: number
44  isHandingOff: boolean
45  isCompacting: boolean
46  limits: { kind: string; pct: number; resetsAt: string }[]
47  paceBases: { kind: string; resetsAt: string; pct: number; at: number }[]
48  costUsd: number
49  limitNoticeAt: number // when this chat learnt the usage limit was reached (its own notice or another chat's); 0 when not
50  limitFromOther?: boolean // learnt from another chat
51  compactStartedAt?: number // a compaction is running since then
52  compactedAt?: number // when this chat was last compacted
53  compactedFrom?: number // its size just before; readings near it until the next reply are stale
54  compactedTo?: number // its size just after (0: not reported)
55  promptsAtCompact?: number // messages sent before that compaction
56}
57
58export type Hold = { text: string; reason: string; isNewTask: boolean } | null
59
60export type ChromeIssue = { message: string } | null
61
62// Several failed tool calls in a row in the main conversation: Claude may be going in circles.
63export type Stuck = { tool: string; count: number } | null
64
65// A subagent Claude started (Explore, a review agent, a fork...), tracked from spawn to its last answer.
66export type AgentRun = {
67  id: string
68  label: string
69  type: string
70  startedAt: number
71  endedAt: number
72  tools: number
73  lastAction: string
74  status: 'running' | 'done' | 'stopped'
75  plan: { steps: number; done: number } | null
76}
77
78declare module 'claude-code' {
79  interface PluginState {
80    workbar: {
81      live: Live
82      health: Health
83      hold: Hold
84      tick: number
85      chrome: ChromeIssue
86      stuck: Stuck
87      agents: AgentRun[]
88      asking: string | null
89      notice: Notice
90    }
91  }
92}
93