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…

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.
╭──────────────────────────────────────────────────────────────────────────────╮
│ █████████████████░░░░░░░░░░░░░░░░░░░░░░░ 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
Claude Code does the work well, but it tells you little about the session around the work. You end up guessing:
It does all this from what Claude Code already knows, so your bill barely notices it.
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.
| What you get | |
|---|---|
| Progress bar | Steps, % 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 estimates | Models 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 band | With no progress bar showing, the band says why: thinking, a quick task, no plan yet, or the progress tool not loaded (with the fix). |
| Subagents | One row per agent: its task, tools used, time and current action. Lots of agents collapse into a summary row. |
| Chat health | Chat size, a prompt-cache countdown, and usage windows past 50% with the time they fill at this pace. |
| Compact or Hand off | One 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 hold | A message into a huge or cold chat is held once with those two options. Press Enter again to send it anyway. |
| Usage limit, every chat | The chat that hits the limit tells all the others on this machine. A chat mid-task pauses with Continue / Log in here / Stop. |
| Alerts | A pop-up when Claude needs approval, has waited 15 s on a question, or finishes a run of 2 minutes or more. |
| Loop warning | Three failed tool calls in a row warn you that Claude may be going in circles. |
| Chrome reconnect | When 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 line | The 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.
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.
| Part | Cost |
|---|---|
The progress tool | About 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 reports | A 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 reminder | Once 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 reminder | When 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-task | About 30 tokens asking Claude to update the plan. |
| Everything else | Drawn 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.
Needs Claude Code 2.1.287 or newer (with mods). Tested on 2.1.301.
| Where | How |
|---|---|
| In a Claude Code session | /plugin install workbar --marketplace OskarasM/workbar |
| From a shell | claude plugin install workbar --marketplace OskarasM/workbar |
| Desktop app | Click + 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.
All in /config under workbar.
| Setting | Default | |
|---|---|---|
| Band above the prompt | auto | auto 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 plan | on | Off: zero tokens, and the bar follows Claude's todo list. |
| Hold a message into an oversized or cold chat | on | |
| Spot a new task in a long chat | off | See Costs almost nothing. |
| Chat size: warn at | 150k | Amber from here, red at twice this. |
| Desktop alerts | on | Claude Code's own notifications where it has them. On Windows Terminal, a Windows toast instead. |
| Activity marker folder | empty | Each 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.
auto mode workbar steps aside when it has nothing to show, so other mods get their turn./workbar and the status line still work.workbar/limit.json in your Claude config folder, so it reaches chats on this machine only.[-] (or ctrl+x ctrl+a) collapses the band, and a mod can't tell. If the band seems gone, press [-] again.Notification and StopFailure hooks don't run, so approval alerts are skipped there./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.
| workbar | claude-hud | cache-tax | |
|---|---|---|---|
| Plan progress and time left | yes | todos only | no |
| Subagent rows | yes | yes | no |
| Context and usage limits | yes, with pace | yes, very configurable | cache only |
| Acts for you (compact, hand off, hold) | yes | no | holds a cold send, keeps the cache warm |
| Alerts | yes | no | no |
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.
No network calls and no telemetry. The Haiku check runs only if you turn it on, and then on your own account.
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.
hooks/register.tsx 1610 lines1import { 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 lines1// 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}
238types/index.d.ts 93 lines1export 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