Band above the prompt: turn, 1h prompt cache countdown, speed, context, 5h and weekly limits, and a button to the activity pane (requests, tool calls and…

English · Tiếng Việt
A Claude Code mod in two parts, with a status line to go with it:
☰ beside Context: each prompt, the requests it sent to the model, their tool calls and subagents, and the shells still running in the background.statusline.js) under the prompt: model and effort, folder, session title. It also hands the band the figures the mod API lacks.
<sub>An illustration of what the mod draws, not a screenshot: your terminal's font and widths apply.</sub>
Needs a Claude Code with mods (on by default from 2.1.287; written against 2.1.310). Works on macOS, Linux and Windows.
The band and the activity pane, at the prompt of a terminal session:
/plugin install session-band --marketplace KhoaaTran/session-band
Answer y to add the marketplace, then pick a scope (the user scope is first). From a shell: claude plugin install session-band --marketplace KhoaaTran/session-band. In the desktop app: + next to the prompt box, Plugins, Add marketplace with KhoaaTran/session-band, then Add plugin.
The status line, the hour-long prompt cache and the pane's shortcut are not part of a plugin: for them, install with the prompt below instead, which also needs Node.js and git.
Open Claude Code in any folder and paste this prompt as it is:
Install the session-band mod for Claude Code on this machine, step by step:
1. Clone https://github.com/KhoaaTran/session-band.git into ~/.claude/mods/session-band. If that folder already holds this repo, run git pull instead.
2. Run node --version. If Node.js is missing, stop and tell me.
3. Merge into ~/.claude/settings.json, keeping everything already there (create the file if it is missing). Paths are this machine's absolute paths, with / even on Windows:
- env.CLAUDE_CODE_PLUGIN_DIRS = the mod's folder. If the variable already holds another value, ask me before changing it.
- env.ENABLE_PROMPT_CACHING_1H = "1"
- statusLine = { "type": "command", "command": "node <mod folder>/statusline.js", "refreshInterval": 1 }. If another statusLine is set, ask me before replacing it.
4. Merge into ~/.claude/keybindings.json, keeping the bindings already there (create the file if it is missing, with "$schema": "https://www.schemastore.org/claude-code-keybindings.json"): in the "Chat" context block, add "ctrl+x ctrl+p": "command:activity". If that key is bound to something else, ask me.
5. Run claude plugin validate ~/.claude/mods/session-band and tell me the result.
6. Sum up what changed in the two settings files, then remind me to open a new session so the mod loads.
~/.claude/mods/session-band.~/.claude/settings.json, with <home> your home folder as an absolute path: /Users/you on macOS, /home/you on Linux, C:/Users/you on Windows: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "<home>/.claude/mods/session-band",
"ENABLE_PROMPT_CACHING_1H": "1"
},
"statusLine": {
"type": "command",
"command": "node <home>/.claude/mods/session-band/statusline.js",
"refreshInterval": 1
}
}
~/.claude/keybindings.json the key that opens the pane: {
"bindings": [{ "context": "Chat", "bindings": { "ctrl+x ctrl+p": "command:activity" } }]
}
☰ beside Context opens the pane, and the status line shows model, folder and session title. No band? Run claude --debug and look for session-band loading in the debug log.ENABLE_PROMPT_CACHING_1H keeps the prompt cache for an hour; without it the cache lasts 5 minutes and the band shows (5m, 1h off). refreshInterval: 1 keeps the band's cache clock true to the second.
To update: git pull in ~/.claude/mods/session-band, then open a new session.
To add segments of your own machine at the end of the status line without touching the repo, create ~/.claude/statusline.local.js exporting (input, colors) => string: input is the JSON Claude Code hands the status line, colors the script's palette. Without the file the status line runs as usual.
module.exports = (d, C) => (d.session_id ? C.teal + "◆ my thing" + C.r : "")
| File | For |
|---|---|
claude-prompt-cache-<session id>.json in the system temp folder | the prompt cache's real deadline, the turn count |
~/.claude/rate-limits-cache.json | the latest 5h and weekly limits of every session |
statusline.js writes both. Without them the band still runs: the cache deadline is estimated from the last answer, and the limits come from the running session's $.session.usage().
| Folder | For |
|---|---|
session-band/<session id>/ in a temporary folder only you read: %TEMP% on Windows, $TMPDIR on macOS, $XDG_RUNTIME_DIR (else ~/.cache) on Linux | each tool call's whole input and output, one JSON file per call, each subagent's prompt and report, and an alive mark the session renews every minute |
The folder goes when its session ends (exit, /clear, a resume). A session's start removes the folders of sessions that ended without removing theirs, a killed one included, and leaves alone those another running session still marks. The session's state keeps only each call's first lines.
| Store | For |
|---|---|
totals:<session id> in the mod's own $.store (a JSON file under ~/.claude) | the session lines' figures alone, about ten numbers, saved at each answer and at the session's end; the last 50 sessions are kept |
A resumed session's pane goes on from those figures: its Session, Time, Cost and Token lines count what came before, and the prompts from before the resume show as one line, … 9 prompts before the resume. Their rows, requests and tool calls are not kept.
Nothing goes over the network: neither the mod nor statusline.js sends data anywhere or downloads anything, and neither changes a setting or a permission.
The mod (hooks/) | statusline.js (full install only) | |
|---|---|---|
| Watches | the session's prompts, requests, tool calls and subagents, to draw them; their text stays on this machine, in the files above | the JSON Claude Code hands a status line |
| Reads | the environment variables HOME, USERPROFILE, OS, TEMP, TMP, TMPDIR and XDG_RUNTIME_DIR, to find the temporary and home folders; the two files under What the band reads; its own folder and $.store; the session's usage figures | the session's transcript, for its title and turn count; ~/.claude/statusline.local.js when you made one |
| Writes | its own folder and $.store, as above | claude-statusline-<session id>.json and claude-prompt-cache-<session id>.json in the system temp folder, ~/.claude/rate-limits-cache.json |
| Runs | one command, to remove a session's folder of its own: cmd /c rmdir /s /q <folder> on Windows, rm -rf -- <folder> on macOS and Linux, only for a session-band/<session id> folder in the temporary folder | nothing |
| Calls | Claude Code's PushNotification tool, 10 and then 5 minutes before the prompt cache expires, while Claude is not working: <project folder>: the prompt cache expires in 5 min, shown on the desktop and, over Remote Control, on your phone; a toast when it is not delivered | nothing |
It writes no build, start-up, settings or instructions file: every write goes to its own session-band/<session id>/ folder in the temporary folder, or to its own $.store. It fetches nothing. The only program it runs is the folder removal above, which sends nothing.
Every hook passes what it sees on unchanged; the mod only records and draws.
| Event | What the hook does |
|---|---|
session.start | registers /activity and /session-band, starts the pane's one-second clock, removes the folders of sessions that ended |
session.end | saves the Session lines' figures, removes this session's folder; on /clear or a resume, empties the pane |
prompt.submit | reads a background task's notification, to end the shell row it names |
turn.start, turn.step, turn.complete | record each prompt, request (tokens, timing, model, effort) and answer, the main loop's and the subagents' |
tool.call | records each tool call, its input and its output, in the session's folder |
agent.spawn | records each subagent: its type, description, model and the prompt it was given |
command.run | answers its own /activity and /session-band alone, by matcher |
ui.render | draws the band above the prompt and the activity pane |
ui.close | notes that the pane closed, for the band's button |
| To | How |
|---|---|
| Open or close the pane | press ☰ beside Context (green while open), or /activity |
| Keyboard shortcut | Ctrl+X Ctrl+P from the prompt (bound at install) |
| Move into the pane | Ctrl+X Tab, then Tab and Enter; Ctrl+X X closes it |
| Widen or narrow the pane | drag its edge, or Ctrl+X ←/Ctrl+X → from inside it |
| See a tool call whole | press its row; ‹ Back returns to it |
| Clear what is done | /activity clear; /clear starts the pane over with the new conversation; a resume goes on from the session's figures |
| Preview the cache expiry notice | /session-band demo <minutes> |
Pressing the band's button writes nothing to the transcript. Running /activity (the shortcut too) leaves one command line, about ten tokens.
Cache tax under the pane's Session lines what the next request pays to send the context again, the cache held or expired.⚠ marks a failed call or a cache written again.‹ Back returns to it.| Where | Means |
|---|---|
| Session lines, top | requests, tool calls and failures; time and cost; Token: every token the API processed, input with its cache reads and output, subagents included (each request sends the conversation again, so it far outgrows the context) · Out: tokens written · Cache hit: the share of the main loop's input read from the cache |
Background: 1 running | under the session lines while a shell runs in the background (run_in_background, ctrl+b, a timeout): one row a command, timed until its task ends, whichever prompt started it |
│ | splits a row: the name on the left, its figures right-aligned on the right |
| Times | seconds under a minute (14s), a clock from a minute (01:39, 1:02:40); the right column pads them to one width so they line up |
| Prompt row, left | when it was sent (14:21, lit while it is open or runs), then its text; a turn a background task's notification started reads as its summary (Background command "…" completed (exit code 0)); where the prompts listed cross midnight, a rule names the day (── 24/10 ──) |
| Prompt row, right | what the prompt cost, subagents included · its whole time; the context it left is in its summary |
Turn | the main loop's turn number in the session, those before a resume counted |
Cache tax · expired | what a request pays to send the context again, going on with the task: read from the cache while it holds (read price) · written to it again once it expired (write price). One line under the session lines, from the main loop's context now |
Model | the model and effort the request was sent with |
Request row (#n), right | context after that request · time from sending it to its last tool's answer |
| Tool row, right | how long the tool ran, a background shell's until its task ended; orange from 30s |
In / Out | input tokens (cache included) / tokens the model wrote, from the API |
Wait / Write | from sending to the response's first item / from there to its end; Time when no item of it was seen |
⚠ | something worth a look: a failed tool, a failed request, a cache rewrite, a prompt stopped |
⚠ cache written again 365k | the cache expired, so the request wrote the whole context again (20k tokens or more, at least half its input) |
Cost | an estimate from the price table in hooks/register.tsx, subagents included; the engine reports tokens, not money |
Tools run while the model is still writing its reply, so a request's time is not Wait + Write plus its tools.
An Agent row opens on the subagent it spawned: its context, cost and tool calls. A subagent's row under Subagent, running or done, names its type, model · effort and task, with its cost · time on the right. It opens its own screen, drawn as the main list is: three Subagent lines (requests, tool calls, time, cost, tokens, cache hit), then each run of it as a prompt row with its #n requests and their calls; last, whole, the prompt its spawner gave it and the report it handed back. A call opened there goes Back to it. While a subagent's transcript is in view, the pane shows that screen.
Any other tool row opens its own screen: status, time and result size, then In as the tool took it and Out whole, as the model read it. In is a shell's command as written, with any argument that changes its run (a timeout, a background run) as JSON under it, or another tool's arguments as JSON. A tool that answered with no text (ToolSearch, an image read) shows its result record, an image's data as its size. Colours and control characters are left out of what the screen draws, not of the file. Past 80,000 characters the screen names the file that holds the rest.
The pane lists what this process saw of the session: up to 200 prompts and 1000 requests (the main loop's and the subagents' together), 1000 tool calls, 100 subagents. /clear, /resume or an exit removes it all, the subagents' prompt and report files too. Only the session lines' figures outlive it.
| File | Holds |
|---|---|
hooks/register.tsx | the hooks module, whole: the band and its ☰ button, then the activity pane and its data |
hooks/activity.test.tsx | tests of the pane and the band |
types/index.d.ts | the type of every value in $.state |
statusline.js | the status line; Node runs it every second, outside the mod, so the engine never loads it |
A plugin has one hooks module and hooks each event once without a matcher, so session.start, turn.start, turn.step and turn.complete are the band's hooks, which call the pane's activity* functions. It stays one file: the plugin directory reads $ passed only to a function declared in the same file.
The mod collects nothing for its author and sends nothing off this machine: it has no server.
While a session runs, the pane holds each prompt's title (300 characters), each request's figures and the first lines of each tool call; each tool call's whole input and output, and each subagent's prompt and report, are files in the session's own folder in a temporary folder only you read. All of it goes at exit, /clear or a resume. Across sessions it keeps the Session lines' figures alone, about ten numbers a session for the last 50 sessions, and no text of the conversation.
statusline.js, with the full install, reads the session's transcript file for three things: its title, its turn count and when the last answer ended. It keeps those three in a small file in the system temp folder, and nothing else of the transcript.
| What you see | What to do |
|---|---|
| No band above the prompt | open a new session after installing; /plugin lists the mod enabled; claude --debug writes session-band loading to the debug log |
The band shows (5m, 1h off) | set ENABLE_PROMPT_CACHING_1H to "1", as the full install does |
| The pane sits above the prompt, not beside the transcript | docking needs the fullscreen layout and 110 columns or more |
A subagent's screen says Prompt: not kept | it started before the mod loaded; the next ones keep it |
| A tool call's screen shows its first lines only | its file could not be read: over 4 MiB, or written before this version |
Questions, bugs and requests: open an issue. The mod is maintained by KhoaaTran.
The band is adapted from session-band in johnnyvizz's claude-kit (MIT), whose notice LICENSE keeps. The pane's per-request view follows turn-inspector in davila7/claude-code-templates.
npx -p typescript tsc -p . # needs .claude-plugin/types/, which the engine writes when it loads the mod
claude plugin validate .
claude plugin test .
hooks/register.tsx 2323 lines1import { atom, read, update } from 'claude-code'
2import type {
3 CommandPresentation,
4 EngineInterface,
5 Register,
6 RenderChildren,
7 TurnCompleteInput,
8 TurnStartInput,
9 TurnStepInput,
10 TurnStepResult,
11} from 'claude-code'
12
13import type {
14 AgentBody,
15 AgentRun,
16 Cache,
17 Call,
18 CallBody,
19 CallStatus,
20 Limit,
21 Prompt,
22 Stats,
23 Step,
24 Totals,
25 Usage,
26 View,
27} from '../types'
28
29// Adapted from JohnnyVizz/claude-kit session-band. Draws the band above the prompt in two
30// rows: turn, prompt cache countdown and speed beside the 5h limit; context beside the weekly
31// limit, the limits as Codex writes them. The status line, ../statusline.js, keeps one line
32// (model, cwd, title) and relays what this API lacks (the native prompt_cache deadline, the
33// turn count from the transcript) through a temp file. Context and limits come from
34// $.session.usage(), the status line's own figures. It measures output speed itself, and
35// notifies as the prompt cache runs out. The button beside Context opens the full view, the
36// activity pane, below. One file: the plugin directory reads $ passed only to a function of
37// the same file.
38
39// Responses shorter than this are mostly tool-call stubs: their timing is noise.
40const MIN_OUTPUT = 20
41// A response that arrived in one burst cannot be timed; such readings are dropped.
42const MIN_SECONDS = 0.5
43const MAX_SPEED = 1000
44
45// Used until the status line relays the real deadline (ENABLE_PROMPT_CACHING_1H).
46const CACHE_TTL_S = 60 * 60
47// Minutes left at which a 1h cache's expiry is announced, once each per answer.
48const CACHE_NOTIFY_MINUTES = [10, 5] as const
49
50// Catppuccin Mocha, as statusline.js.
51const TURN = '#f5c2e7'
52const SPEED = '#b4befe'
53const CACHE = '#94e2d5'
54const CONTEXT = '#74c7ec'
55const FIVE_HOUR = '#cba6f7'
56const WEEKLY = '#eba0ac'
57const SUB = '#a6adc8'
58const OK = '#a6e3a1'
59const YELLOW = '#f9e2af'
60const WARN = '#fab387'
61const ALERT = '#f38ba8'
62const MUTED = '#7f849c'
63
64const pad = (n: number) => String(n).padStart(2, '0')
65
66// The status line's old steps, by the share used: context and limits alike.
67const usedLevel = (used: number) => (used >= 85 ? ALERT : used >= 70 ? WARN : used >= 50 ? YELLOW : OK)
68
69// A bar of cells: the terminal draws no Svg.
70const BAR_CELLS = 10
71const textBar = (percent: number) => {
72 const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS)))
73 return { filled: '▰'.repeat(filled), empty: '▱'.repeat(BAR_CELLS - filled) }
74}
75
76// One decimal, a trailing .0 dropped: 289.1k, 300k, 1m.
77const oneDecimal = (n: number) => n.toFixed(1).replace(/\.0$/, '')
78// From 999,950 the k form would round to "1000k".
79const fmtAmount = (n: number) => (n >= 999_950 ?`${oneDecimal(n / 1e6)}m` : `${oneDecimal(n / 1e3)}k`)
80
81// Local time: "13:00 24/10".
82const resetText = (iso: string) => {
83 const d = new Date(iso)
84 if (Number.isNaN(d.getTime())) return null
85 return `${pad(d.getHours())}:${pad(d.getMinutes())} ${pad(d.getDate())}/${pad(d.getMonth() + 1)}`
86}
87
88const stats = atom({ plugin: 'session-band', key: 'stats' } as const, {
89 turn: 0,
90 speed: null,
91} as Stats)
92
93const cache = atom({ plugin: 'session-band', key: 'cache' } as const, {
94 ttlSeconds: CACHE_TTL_S,
95 expiresAt: null,
96 secondsLeft: null,
97} as Cache)
98
99// A turn is running: the cache stays warm, so no countdown warnings.
100const isBusy = atom({ plugin: 'session-band', key: 'isBusy' } as const, false)
101
102// The smallest CACHE_NOTIFY_MINUTES already announced since the last answer; 0 none.
103const warned = atom({ plugin: 'session-band', key: 'warned' } as const, 0)
104
105// Context and the 5h and weekly windows: this session's last API response, or another
106// session's newer figures from the shared cache.
107const usage = atom({ plugin: 'session-band', key: 'usage' } as const, {
108 context: null,
109 fiveHour: null,
110 weekly: null,
111} as Usage)
112
113// The activity pane is open (the pane writes it): the button's label.
114const isPaneOpen = atom({ plugin: 'session-band', key: 'isPaneOpen' } as const, false)
115
116const limitOf = (limits: { kind: string; percentUsed: number; resetsAt?: string }[], kind: string): Limit | null => {
117 const l = limits.find(x => x.kind === kind)
118 return l ? { percentUsed: l.percentUsed, resetsAt: l.resetsAt ?? null } : null
119}
120
121// The limits every session's status line last wrote to ~/.claude/rate-limits-cache.json;
122// $.session.usage() only moves when this session calls the API.
123type SharedLimits = Record<string, { used_percentage?: number; resets_at?: number }>
124
125// Never throws: the file is rewritten every second, so a read can catch it half written,
126// and a failure here must not stop the cache countdown below it.
127const readSharedLimits = async ($: EngineInterface): Promise<SharedLimits> => {
128 try {
129 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
130 if (!home) return {}
131 const text = await $.fs.read(`${home.replace(/\\/g, '/')}/.claude/rate-limits-cache.json`)
132 return (JSON.parse(text) as { rate_limits?: SharedLimits }).rate_limits ?? {}
133 } catch {
134 return {}
135 }
136}
137
138const sharedLimitOf = (limits: SharedLimits, kind: string): Limit | null => {
139 const l = limits[kind]
140 if (typeof l?.used_percentage !== 'number') return null
141 const resetsAt = typeof l.resets_at === 'number' ? new Date(l.resets_at * 1000).toISOString() : null
142 return { percentUsed: l.used_percentage, resetsAt }
143}
144
145// The later window wins; within one window use only grows, so the higher share is the newer.
146const newerLimit = (a: Limit | null, b: Limit | null): Limit | null => {
147 if (!a || !b) return a ?? b
148 const resetsA = a.resetsAt ? Date.parse(a.resetsAt) : NaN
149 const resetsB = b.resetsAt ? Date.parse(b.resetsAt) : NaN
150 if (!Number.isNaN(resetsA) && !Number.isNaN(resetsB) && Math.abs(resetsA - resetsB) > 60_000) {
151 return resetsA > resetsB ? a : b
152 }
153 return a.percentUsed >= b.percentUsed ? a : b
154}
155
156// The system temp folder, where the status line's node process (os.tmpdir()) looks too.
157const tempFile = async ($: EngineInterface, name: string) => {
158 const tmp = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
159 return `${tmp.replace(/\\/g, '/')}/${name}-${await $.session.id()}.json`
160}
161
162type Bridge = {
163 ttl?: string
164 expires_at?: number | null
165 warm?: boolean
166 caching_observed?: boolean
167 turns?: number | null
168}
169
170// What the status line relays per session; null until it has run once.
171// A half-written file reads as null too: the rejection handler of .then does not catch
172// a JSON.parse that throws in the fulfilment handler.
173const readBridge = async ($: EngineInterface): Promise<Bridge | null> => {
174 try {
175 return JSON.parse(await $.fs.read(await tempFile($, 'claude-prompt-cache'))) as Bridge
176 } catch {
177 return null
178 }
179}
180
181// Claude Code's own notification (desktop and phone over Remote Control); when it is
182// skipped because the person is at this session, a toast says it instead.
183const notifyCacheExpiry = async ($: EngineInterface, minutes: number) => {
184 const project = (await $.session.cwd()).split(/[\\/]/).filter(Boolean).pop() ?? 'session'
185 const text = `${project}: the prompt cache expires in ${minutes} min`
186
187 const sent = await $.tool
188 .call({ tool: 'PushNotification', message: text, status: 'proactive' })
189 .catch(() => null)
190 const isDelivered =
191 sent !== null &&
192 sent.deny === undefined &&
193 sent.isError !== true &&
194 (sent.result.pushSent === true || sent.result.localSent === true)
195
196 if (!isDelivered) $.ui.toast(`⏳ ${text}`, { timeoutMs: 30000 })
197}
198
199export const register: Register = (on, options) => {
200 registerActivity(on, options)
201
202 // Set by `/session-band demo`, cleared by the next answer.
203 let isDemo = false
204 // Output tokens and streaming seconds of the main thread's current turn.
205 let turnOutput = 0
206 let turnSeconds = 0
207
208 on('session.start', async ($, e, next) => {
209 const result = await next(e)
210
211 await startActivity($)
212 await $.command.register({
213 name: 'session-band',
214 description: '`demo <minutes>` previews the prompt cache expiry notification',
215 })
216
217 $.clock.every(1000, async () => {
218 const { context, rateLimits } = await $.session.usage()
219 const shared = await readSharedLimits($)
220 const nextUsage: Usage = {
221 context:
222 context.percent === undefined
223 ? null
224 : { percent: context.percent, tokens: context.tokens ?? null, window: context.window },
225 fiveHour: newerLimit(limitOf(rateLimits, 'five_hour'), sharedLimitOf(shared, 'five_hour')),
226 weekly: newerLimit(limitOf(rateLimits, 'seven_day'), sharedLimitOf(shared, 'seven_day')),
227 }
228 if (JSON.stringify(nextUsage) !== JSON.stringify(await read($, usage))) {
229 await update($, usage, () => nextUsage)
230 }
231
232 const bridge = isDemo ? null : await readBridge($)
233 // The status line counts the prompts the person sent from the transcript, so the
234 // count survives a reload; our own count stands in until it has drawn.
235 const turns = bridge?.turns
236 if (typeof turns === 'number' && turns !== (await read($, stats)).turn) {
237 await update($, stats, s => ({ ...s, turn: turns }))
238 }
239 const now = await $.clock.now()
240 const current = await read($, cache)
241 const ttlSeconds = bridge?.ttl === '5m' ? 300 : CACHE_TTL_S
242 const expiresAt = !bridge
243 ? current.expiresAt
244 : bridge.caching_observed === false || bridge.warm === false
245 ? null
246 : typeof bridge.expires_at === 'number'
247 ? bridge.expires_at * 1000
248 : current.expiresAt
249 if (expiresAt === null) {
250 if (current.secondsLeft !== null) await update($, cache, () => ({ ttlSeconds, expiresAt: null, secondsLeft: null }))
251 return
252 }
253
254 const left = Math.max(0, Math.floor((expiresAt - now) / 1000))
255 if (left !== current.secondsLeft || expiresAt !== current.expiresAt || ttlSeconds !== current.ttlSeconds) {
256 await update($, cache, () => ({ ttlSeconds, expiresAt, secondsLeft: left }))
257 }
258
259 // A 5m cache runs out between most turns: no notifications for it.
260 if (ttlSeconds !== CACHE_TTL_S) return
261 const due = CACHE_NOTIFY_MINUTES.filter(m => left > 0 && left <= m * 60)
262 if (due.length === 0 || (await read($, isBusy))) return
263
264 const minutes = Math.min(...due)
265 const last = await read($, warned)
266 if (last !== 0 && last <= minutes) return
267
268 await update($, warned, () => minutes)
269 await notifyCacheExpiry($, Math.ceil(left / 60))
270 })
271
272 return result
273 })
274
275 on('command.run', { command: 'session-band' }, async ($, e) => {
276 // `/session-band demo <minutes>` fakes the cache deadline to preview the notification;
277 // the next answer resets it.
278 const demo = e.args.trim().match(/^demo\s+(\d+(?:\.\d+)?)$/)
279 if (!demo) return { text: 'Usage: /session-band demo <minutes>, e.g. /session-band demo 10.5' }
280
281 const seconds = Math.round(Number(demo[1]) * 60)
282 const now = await $.clock.now()
283 isDemo = true
284 await update($, cache, () => ({ ttlSeconds: CACHE_TTL_S, expiresAt: now + seconds * 1000, secondsLeft: seconds }))
285 await update($, warned, () => 0)
286
287 return { text: `Cache demo: ${demo[1]} min left; the next answer resets it.` }
288 })
289
290 on('turn.start', async ($, e, next) => {
291 if (e.agentId === undefined) {
292 turnOutput = 0
293 turnSeconds = 0
294 await update($, stats, s => ({ ...s, turn: s.turn + 1 }))
295 await update($, isBusy, () => true)
296 }
297 await activityTurnStart($, e)
298
299 return next(e)
300 })
301
302 on('turn.step', async function* ($, e, next) {
303 const isMain = e.agentId === undefined
304 const step = await activityStepStart($, e)
305 const stream = next(e)
306
307 // Measured from the response's first item, so time-to-first-token is excluded but
308 // thinking that streams hidden or in one late burst is still timed. Every loop's request
309 // rows show it; the band's speed is the main loop's alone.
310 let startedAt: number | null = null
311
312 try {
313 for await (const chunk of stream) {
314 if (startedAt === null) {
315 startedAt = await $.clock.now()
316 }
317
318 if (isMain && startedAt !== null && chunk.kind === 'stop' && chunk.usage !== null) {
319 // Averaged over every request of the turn, so a short tool-call stub at the
320 // end does not stand for the whole reply.
321 turnOutput += chunk.usage.output_tokens
322 turnSeconds += ((await $.clock.now()) - startedAt) / 1000
323 const speed = turnOutput / turnSeconds
324 if (turnOutput >= MIN_OUTPUT && turnSeconds >= MIN_SECONDS && speed <= MAX_SPEED) {
325 await update($, stats, s => ({ ...s, speed: Math.round(speed) }))
326 }
327 }
328
329 yield chunk
330 }
331 } catch (err) {
332 await activityStepEnd($, e, step, null, startedAt)
333 throw err
334 }
335
336 const result = await stream.result
337 await activityStepEnd($, e, step, result, startedAt)
338 return result
339 })
340
341 on('turn.complete', async ($, e, next) => {
342 const result = await next(e)
343
344 if (e.agentId === undefined) {
345 isDemo = false
346 // Until the status line relays the real deadline, estimate it from this answer.
347 const now = await $.clock.now()
348 await update($, cache, c => ({ ...c, expiresAt: now + c.ttlSeconds * 1000, secondsLeft: c.ttlSeconds }))
349 await update($, isBusy, () => false)
350 await update($, warned, () => 0)
351 }
352 await activityTurnComplete($, e)
353
354 return result
355 })
356
357 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
358 const s = await read($, stats)
359 const { ttlSeconds, secondsLeft } = await read($, cache)
360 // Always drawn, except while Claude Code's own survey takes this row.
361 if (e.props.hasSurvey) return next(e)
362
363 const { Box, Text, Button } = $.ui.resolve(e)
364
365 // Amber at a third of the TTL left, red at a sixth: 20 and 10 minutes for a 1h cache.
366 // While a reply streams every request rewrites the cache: no countdown to show.
367 const level =
368 e.props.isWorking || secondsLeft === null || secondsLeft === 0 ? null
369 : secondsLeft <= ttlSeconds / 6 ? ALERT
370 : secondsLeft <= ttlSeconds / 3 ? WARN
371 : null
372 const cacheColor =
373 e.props.isWorking || secondsLeft === null ? MUTED : secondsLeft === 0 ? ALERT : (level ?? OK)
374 const cacheText =
375 e.props.isWorking ? '…'
376 : secondsLeft === null ? '—'
377 : secondsLeft === 0 ? 'expired'
378 : `${pad(Math.floor(secondsLeft / 60))}:${pad(secondsLeft % 60)}`
379
380 const u = await read($, usage)
381 const dot = <Text color={MUTED}>{' · '}</Text>
382 const ctxColor = u.context ? usedLevel(u.context.percent) : MUTED
383 const bar = textBar(u.context?.percent ?? 0)
384
385 // "⚠ 5h limit: 59% left · resets at 13:00 24/10", as Codex writes it.
386 const limitParts = (label: string, limit: Limit | null) => {
387 const value = limit ? `${Math.round(100 - limit.percentUsed)}% left` : '—'
388 const resets = limit?.resetsAt ? resetText(limit.resetsAt) : null
389 const suffix = resets ? ` · resets at ${resets}` : ''
390 return { value, suffix, text: `⚠ ${label}: ${value}${suffix}` }
391 }
392 const limitRow = (label: string, hue: string, limit: Limit | null) => {
393 const color = limit ? usedLevel(limit.percentUsed) : MUTED
394 const { value, suffix } = limitParts(label, limit)
395 return (
396 <Text wrap="truncate-end">
397 <Text color={color}>⚠ </Text>
398 <Text color={hue}>{`${label}: `}</Text>
399 <Text bold color={color}>{value}</Text>
400 {suffix ? <Text color={MUTED}>{suffix}</Text> : null}
401 </Text>
402 )
403 }
404
405 const speedText = s.speed === null ? '—' : `${s.speed} tok/s`
406 // A 5m cache on a subscription means ENABLE_PROMPT_CACHING_1H is not taking effect.
407 const cacheNote = ttlSeconds !== CACHE_TTL_S ? ' (5m, 1h off)' : ''
408 const ctxValue = u.context ? ` ${Math.round(u.context.percent)}%` : ' —'
409 const ctxSize = u.context?.tokens != null ? ` (${fmtAmount(u.context.tokens)}/${fmtAmount(u.context.window)})` : ''
410
411 // Each limit stays beside its left item while both fit with the gap between them;
412 // otherwise the limits take rows of their own under the left items instead of being cut.
413 // Beside Context: opens the activity pane, the full view; again closes it.
414 const isOpen = await read($, isPaneOpen)
415 // A symbol of the same block as the limits' ⚠ (Miscellaneous Symbols), which the
416 // terminal draws. One icon either way; its color says whether the pane is open.
417 const detailLabel = '☰'
418 const where = { isFullscreen: e.viewport?.isFullscreen ?? false, columns: e.viewport?.columns ?? 80 }
419 const detailButton = (
420 <Button
421 key="detail"
422 plain
423 onPress={async () => {
424 // The button says the rest by its color; a pane that could not be shown, by a toast.
425 const toggled = await togglePane($, where)
426 if (!toggled.isShown && isOpen === false) $.ui.toast(toggled.text)
427 }}
428 >
429 {/* Colored as the limits' ⚠: Context's hue while closed, the OK green while open. */}
430 <Text bold color={isOpen ? OK : CONTEXT}>
431 {detailLabel}
432 </Text>
433 </Button>
434 )
435
436 const GAP = 3
437 const inner = e.props.bodyColumns - 2 - (level ? 2 : 0)
438 const left1 = `Turn: ${s.turn} · Cache: ${cacheText}${cacheNote} · Speed: ${speedText}`
439 const left2 = `Context: ${bar.filled}${bar.empty}${ctxValue}${ctxSize} ${detailLabel}`
440 const isSideBySide =
441 left1.length + GAP + limitParts('5h limit', u.fiveHour).text.length <= inner &&
442 left2.length + GAP + limitParts('Weekly limit', u.weekly).text.length <= inner
443
444 const turnRow = (
445 <Text wrap="truncate-end">
446 <Text color={TURN}>Turn: </Text>
447 <Text bold color={TURN}>{String(s.turn)}</Text>
448 {dot}
449 <Text color={CACHE}>Cache: </Text>
450 <Text bold color={cacheColor}>{cacheText}</Text>
451 {cacheNote ? <Text color={WARN}>{cacheNote}</Text> : null}
452 {dot}
453 <Text color={SPEED}>Speed: </Text>
454 <Text bold color={SPEED}>{speedText}</Text>
455 </Text>
456 )
457 const contextRow = (
458 <Text wrap="truncate-end">
459 <Text color={CONTEXT}>Context: </Text>
460 <Text color={ctxColor}>{bar.filled}</Text>
461 <Text color={MUTED}>{bar.empty}</Text>
462 <Text bold color={ctxColor}>{ctxValue}</Text>
463 {ctxSize ? <Text color={SUB}>{ctxSize}</Text> : null}
464 </Text>
465 )
466
467 return (
468 <Box
469 flexDirection="column"
470 overflow="hidden"
471 paddingX={1}
472 {...(level ? { borderStyle: 'round', borderColor: level } : {})}
473 >
474 {/* Every item "Label: value", the label in the item's hue, the value bold in its
475 state's. Left: turn, cache and speed, then context; right: one limit a row. */}
476 {isSideBySide ? (
477 <>
478 <Box flexDirection="row" alignItems="center" flexWrap="nowrap">
479 {turnRow}
480 <Box flexGrow={1} flexShrink={1} minWidth={GAP} />
481 {limitRow('5h limit', FIVE_HOUR, u.fiveHour)}
482 </Box>
483 <Box flexDirection="row" alignItems="center" flexWrap="nowrap" columnGap={2}>
484 {contextRow}
485 {detailButton}
486 <Box flexGrow={1} flexShrink={1} minWidth={GAP} />
487 {limitRow('Weekly limit', WEEKLY, u.weekly)}
488 </Box>
489 </>
490 ) : (
491 <>
492 {turnRow}
493 <Box flexDirection="row" alignItems="center" flexWrap="nowrap" columnGap={2}>
494 {contextRow}
495 {detailButton}
496 </Box>
497 {limitRow('5h limit', FIVE_HOUR, u.fiveHour)}
498 {limitRow('Weekly limit', WEEKLY, u.weekly)}
499 </>
500 )}
501 </Box>
502 )
503 })
504}
505
506// The band's full view: a pane of what the session does, opened from the band's button
507// beside Context, `/activity` or ctrl+x ctrl+p. Each prompt lists its requests to the model,
508// each with a bar of the context it fills (after turn-inspector, davila7/claude-code-templates);
509// open a request for its input and output and the table of the tool calls it asked for.
510// The subagents sit above (merged from the old agents-panel), and while a subagent's
511// transcript is in view the pane shows that subagent's own calls. No session-wide context
512// bar: the band shows it.
513
514const PANE = 'activity'
515// The key of a call's screen's Back button.
516const BACK = 'back'
517const TITLE = 'Activity'
518// Asked of the dock, which gives at most 45% of the terminal: the widest it allows.
519// A width the person dragged or keyed it to wins.
520const COLUMNS = 100
521// Seated above the prompt, the pane asks for this many rows, not the third it would take,
522// so the band above the prompt (session-band) keeps room; it scrolls past them.
523const INLINE_ROWS = 10
524const DOCK_MIN_COLUMNS = 110
525// The right half of a request's or prompt's row: "⚠ 416k · 8s", "≈$0.31 · 01:20".
526const METRICS_COLUMNS = 16
527
528// Prompts and requests count the subagents' runs and requests too.
529const MAX_CALLS = 1000
530const MAX_PROMPTS = 200
531const MAX_STEPS = 1000
532const MAX_AGENTS = 100
533// A tool call that ran this long is drawn in the warning hue.
534const SLOW_MS = 30_000
535// Calls listed under one request, and prompts listed; the earlier ones fold into a line.
536const MAX_ROWS = 12
537const MAX_PROMPTS_SHOWN = 15
538const AVG_CHARS = 4
539// A prompt's title as kept: wider than any row draws.
540const PROMPT_TITLE_CHARS = 300
541
542const calls = atom({ plugin: 'session-band', key: 'calls' } as const, [], { shape: 'calls-1' })
543const prompts = atom({ plugin: 'session-band', key: 'prompts' } as const, [], { shape: 'prompts-1' })
544const steps = atom({ plugin: 'session-band', key: 'steps' } as const, [], { shape: 'steps-1' })
545const agents = atom({ plugin: 'session-band', key: 'agents' } as const, [], { shape: 'agents-6' })
546const view = atom(
547 { plugin: 'session-band', key: 'view' } as const,
548 { expanded: [], isFollowing: true, isDoneCollapsed: true },
549 { shape: 'view-1' },
550)
551const now = atom({ plugin: 'session-band', key: 'now' } as const, 0)
552// The whole input and output of the call whose screen is open, read from its file.
553const body = atom({ plugin: 'session-band', key: 'body' } as const, null)
554// The whole task and report of the subagent whose screen is open, read from its files.
555const agentBody = atom({ plugin: 'session-band', key: 'agentBody' } as const, null)
556// The header's figures a resumed session showed before, from `$.store`.
557const carried = atom({ plugin: 'session-band', key: 'carried' } as const, null)
558
559
560// The pane's own hues beside the band's: every item "Label: value", the label in the item's
561// hue, the value bold, items split by a muted " · ".
562const PROMPT = '#f5c2e7'
563const REQUEST = '#b4befe'
564const TOOL = '#94e2d5'
565const TIME = '#cba6f7'
566const COST = '#eba0ac'
567const SEPARATOR = '#89b4fa'
568const RULE = '#45475a'
569
570// Glyphs Claude Code draws itself, so the terminal's font has them.
571const CALL_GLYPH: Record<CallStatus, string> = { running: '●', background: '●', ok: '✓', error: '✗', denied: '✗' }
572const CALL_COLOR: Record<CallStatus, string> = {
573 running: YELLOW,
574 background: YELLOW,
575 ok: OK,
576 error: ALERT,
577 denied: WARN,
578}
579const AGENT_GLYPH: Record<string, string> = { done: '✓', failed: '✗' }
580const AGENT_COLOR: Record<string, string> = { running: YELLOW, done: OK, failed: ALERT }
581// The pane redraws once a second while something runs: one frame per redraw.
582const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
583
584// USD per million tokens: input, output, cache read, cache write. The first row that
585// matches a model id wins, so a release sits above its family. The engine reports
586// tokens, not money, so cost is an estimate; cache writes are priced at the 1-hour
587// rate (2x input), which ENABLE_PROMPT_CACHING_1H makes Claude Code write.
588type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
589
590const PRICES: [RegExp, Price][] = [
591 [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
592 [/fable|mythos/, [10, 50, 1, 20]],
593 [/opus-5-5/, [4, 20, 0.2, 8]],
594 [/opus/, [5, 25, 0.5, 10]],
595 [/sonnet-5-5/, [2, 10, 0.1, 4]],
596 [/sonnet-5/, [2, 10, 0.2, 4]],
597 [/sonnet/, [3, 15, 0.3, 6]],
598 [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
599 [/haiku/, [1, 5, 0.1, 2]],
600]
601
602// Haiku 5.5 is priced by prompt length: a request whose prompt (input, cache reads and
603// cache writes together) is over 100,000 tokens pays these prices instead.
604const HAIKU_LONG_PROMPT = 100_000
605const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1]
606
607type ApiUsage = {
608 input_tokens: number
609 output_tokens: number
610 cache_read_input_tokens: number
611 cache_creation_input_tokens: number
612}
613
614const promptOf = (u: ApiUsage): number =>
615 (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
616
617const priceOf = (model: string, prompt: number): Price => {
618 const id = model.toLowerCase()
619 if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG
620 return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8]
621}
622
623/**
624 * What the next request pays to send a context again: read from the prompt cache while it
625 * holds, written to it again once it expired.
626 */
627const cacheTaxOf = (model: string, context: number): { warm: number; expired: number } => {
628 const [, , r, w] = priceOf(model, context)
629 return { warm: (context * r) / 1e6, expired: (context * w) / 1e6 }
630}
631
632// `isRequest` says the usage is one model request, which is what a prompt-length price
633// is set by; a turn's sum is priced at the model's ordinary prices.
634const costOf = (model: string, u: ApiUsage, isRequest: boolean): number => {
635 const [i, o, r, w] = priceOf(model, isRequest ? promptOf(u) : 0)
636 return (
637 ((u.input_tokens || 0) * i +
638 (u.output_tokens || 0) * o +
639 (u.cache_read_input_tokens || 0) * r +
640 (u.cache_creation_input_tokens || 0) * w) /
641 1e6
642 )
643}
644
645// Haiku 4.5 and the Claude 3 models have a 200K window; the rest, Haiku 5.5 included, 1M.
646const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
647
648const modelName = (id: string): string => {
649 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
650 const [, family = '', major = '', minor] = m ?? []
651 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
652 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
653}
654
655const runModel = (a: AgentRun): string => (a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model))
656
657const fmtCount = (n: number): string =>
658 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : `${Math.round(n)}`
659
660const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
661
662// Every duration in the pane: seconds under a minute (2s, 48s), a clock from there
663// (01:39, 1:02:40).
664const fmtDuration = (ms: number): string => {
665 const whole = Math.round(Math.max(0, ms) / 1000)
666 if (whole < 60) return `${whole}s`
667 const hours = Math.floor(whole / 3600)
668 const clock = `${pad(Math.floor((whole % 3600) / 60))}:${pad(whole % 60)}`
669 return hours ? `${hours}:${clock}` : clock
670}
671
672// A duration in the right-hand column, padded to one width (` 12s` as wide as `01:39`)
673// so the dots and figures line up down the pane. Non-breaking spaces: a remote surface
674// collapses runs of ordinary ones.
675const TIME_CELLS = 5
676const timeCell = (ms: number): string => fmtDuration(ms).padStart(TIME_CELLS, '\u00a0')
677
678// When a prompt was sent, in local time, always five cells so the prompts' text lines up:
679// "14:32". Its day is a rule above the prompts of another day than the one above them.
680const fmtSentAt = (ms: number): string => {
681 const d = new Date(ms)
682 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
683}
684const fmtDay = (ms: number): string => {
685 const d = new Date(ms)
686 return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}`
687}
688const dayOf = (ms: number): string => new Date(ms).toDateString()
689
690const estimate = (chars: number): number => (chars <= 0 ? 0 : Math.max(1, Math.round(chars / AVG_CHARS)))
691
692const ctxOf = (a: AgentRun): number =>
693 a.contextMax ? Math.min(100, Math.round((a.contextUsed / a.contextMax) * 100)) : 0
694
695/** Cuts `text` to `width` cells with an ellipsis. */
696const fit = (text: string, width: number): string => {
697 const chars = Array.from(text)
698 const w = Math.max(1, Math.floor(width))
699 return chars.length <= w ? text : chars.slice(0, w - 1).join('') + '…'
700}
701
702const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
703
704// A Text refuses control characters: drop a CLI's escape sequences (colours, a terminal
705// link's OSC) and the rest. A lone \r redraws its line, as a progress bar does: the
706// line shows what a terminal leaves of it, its last part.
707const ANSI = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/g
708const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
709const printable = (text: string): string =>
710 text
711 .replace(ANSI, '')
712 .replace(/\t/g, ' ')
713 .split(/\r?\n/)
714 .map(line => (line.split('\r').filter(Boolean).at(-1) ?? '').replace(CONTROL, ''))
715 .join('\n')
716
717// What a call was given and what it answered, as the session's state keeps it: a few
718// first lines, for when its file is gone. The whole texts live in the call's file.
719const EXCERPT_LINES = 4
720const EXCERPT_CHARS = 400
721// What a call's screen reads from its file, input and output together: under the 100,000
722// characters a pane draws in all, so the line naming the file, last, still shows.
723const SCREEN_CHARS = 80_000
724const excerpt = (text: string): string | undefined => {
725 const lines = printable(text)
726 .split('\n')
727 .map(line => line.trimEnd())
728 .filter(Boolean)
729 .slice(0, EXCERPT_LINES)
730 const kept = lines.join('\n')
731 return kept ? fit(kept, EXCERPT_CHARS) : undefined
732}
733
734const safeJson = (v: unknown, indent?: number): string => {
735 try {
736 return typeof v === 'string' ? v : (JSON.stringify(v, null, indent) ?? '')
737 } catch {
738 return ''
739 }
740}
741
742// `mcp__server__tool` reads as `tool`: the column is narrow.
743const toolName = (tool: string): string => (tool.startsWith('mcp__') ? (tool.split('__').at(-1) ?? tool) : tool)
744
745const baseName = (path: string): string => path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
746
747const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent'])
748
749/** What a call works on, from its arguments: a file, a command, a pattern, a task. */
750const targetOf = (tool: string, input: Record<string, unknown>): string => {
751 const str = (key: string) => {
752 const v = input[key]
753 return typeof v === 'string' ? v : ''
754 }
755 switch (tool) {
756 case 'Read':
757 case 'Edit':
758 case 'Write':
759 return baseName(str('file_path'))
760 case 'NotebookEdit':
761 return baseName(str('notebook_path'))
762 case 'Bash':
763 case 'PowerShell':
764 return oneLine(str('command'))
765 case 'Grep':
766 case 'Glob':
767 return str('pattern')
768 case 'Agent':
769 return [str('subagent_type'), str('description')].filter(Boolean).join(': ')
770 case 'WebFetch':
771 return str('url')
772 case 'WebSearch':
773 return str('query')
774 case 'Skill':
775 return str('skill')
776 default: {
777 const first = Object.entries(input).find(([k, v]) => !RESERVED.has(k) && typeof v === 'string')
778 return first ? oneLine(String(first[1])) : ''
779 }
780 }
781}
782
783/**
784 * A call's input exactly as the tool takes it, so the same input to the same tool gives
785 * the same output again: any tool's arguments as JSON, a shell's command as written, to
786 * copy and run, with the arguments that change what it does (a timeout, a background
787 * run) as JSON under it. Left out: the engine's own fields, and a shell's description,
788 * which only names the call.
789 */
790const SHELLS = new Set(['Bash', 'PowerShell'])
791const fullInputOf = (tool: string, input: Record<string, unknown>): string => {
792 const args = Object.fromEntries(Object.entries(input).filter(([k, v]) => !RESERVED.has(k) && v !== undefined))
793 const command = args.command
794 if (!SHELLS.has(tool) || typeof command !== 'string') return safeJson(args, 2)
795 const rest = Object.fromEntries(Object.entries(args).filter(([k]) => k !== 'command' && k !== 'description'))
796 return Object.keys(rest).length ? `${command}\n\n${safeJson(rest, 2)}` : command
797}
798
799// A result record as JSON, for a tool that answers with no text. A long string that is
800// data rather than text (an image's base64) stands as its size: it cannot be read anyway.
801const DATA_CHARS = 2000
802const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
803const recordOf = (result: unknown): string | undefined => {
804 if (result === undefined || result === null) return undefined
805 try {
806 const json = JSON.stringify(
807 result,
808 (_, v) => (typeof v === 'string' && v.length > DATA_CHARS && BASE64.test(v) ? `<${v.length} characters of base64 data>` : v),
809 2,
810 )
811 return json && json !== '{}' ? json : undefined
812 } catch {
813 return undefined
814 }
815}
816
817// The background task a shell call left running (`run_in_background`, ctrl+b, a timeout,
818// a message queued for the model). None when the command ends with the caller's final
819// response: no notification follows it.
820const taskOf = (result: unknown): string | undefined => {
821 if (typeof result !== 'object' || result === null) return undefined
822 const r = result as { backgroundTaskId?: unknown; backgroundEndsWithFinalResponse?: unknown }
823 return typeof r.backgroundTaskId === 'string' && r.backgroundEndsWithFinalResponse !== true
824 ? r.backgroundTaskId
825 : undefined
826}
827
828// A background task's end, as its notification writes it:
829// <task-notification><task-id>…</task-id><tool-use-id>…</tool-use-id>…<status>completed</status>
830// <summary>Background command "…" completed (exit code 0)</summary></task-notification>
831type TaskEnd = { taskId?: string; toolUseId?: string; status?: string; summary?: string }
832const TASK_NOTIFICATION = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/g
833const tagOf = (block: string, tag: string): string | undefined =>
834 block.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() || undefined
835const taskEndsOf = (text: string): TaskEnd[] =>
836 [...text.matchAll(TASK_NOTIFICATION)].map(([, block = '']) => ({
837 taskId: tagOf(block, 'task-id'),
838 toolUseId: tagOf(block, 'tool-use-id'),
839 status: tagOf(block, 'status'),
840 summary: tagOf(block, 'summary'),
841 }))
842
843/** A prompt's row text: a task notification reads as its summary, not its markup. */
844const promptTitle = (text: string): string => {
845 const ends = taskEndsOf(text)
846 if (!ends.length) return oneLine(text)
847 return ends.map(t => oneLine(t.summary ?? `Task ${t.taskId ?? ''} ${t.status ?? 'ended'}`)).join(' · ')
848}
849
850// Each call's whole input and output, one file per call in the session's own folder:
851// the state keeps only their first lines, so the session stays small.
852// A temporary folder only this user reads, on each OS: TEMP or TMP on Windows, TMPDIR on
853// macOS (ending in a slash). On Linux /tmp is every user's, and TMPDIR often names it:
854// XDG_RUNTIME_DIR, the user's own (cleared at logout), goes before it, ~/.cache after.
855// Each name is read as written: the engine lists the variables a module reads. The helpers
856// that take $ are function declarations, which the engine follows $ into from another file.
857async function tempDir($: EngineInterface): Promise<string> {
858 const home = (await $.env.get('HOME'))?.trim()
859 const dir = [
860 await $.env.get('TEMP'),
861 await $.env.get('TMP'),
862 await $.env.get('XDG_RUNTIME_DIR'),
863 await $.env.get('TMPDIR'),
864 home ? `${home}/.cache` : undefined,
865 ]
866 .map(v => v?.trim())
867 .find(Boolean)
868 return dir ? dir.replace(/\\/g, '/').replace(/\/+$/, '') || '/' : '/tmp'
869}
870
871// Every session's folder sits in one of the mod's own, so a start lists only those and
872// never the whole temporary folder.
873const BODY_ROOT = 'session-band'
874async function bodyRoot($: EngineInterface): Promise<string> {
875 return `${await tempDir($)}/${BODY_ROOT}`
876}
877
878async function sessionDir($: EngineInterface, sessionId?: string): Promise<string> {
879 return `${await bodyRoot($)}/${sessionId ?? (await $.session.id())}`
880}
881
882async function bodyPath($: EngineInterface, id: string): Promise<string> {
883 return `${await sessionDir($)}/${id.replace(/[^\w-]/g, '_')}.json`
884}
885
886// The files are kept only while the pane can show them. A session marks its folder alive
887// every minute and with each call's file, so a session that starts leaves another one's
888// that still runs alone; an ended session marks its own 0.
889const ALIVE_FILE = 'alive'
890const ALIVE_EVERY_MS = 60_000
891const STALE_MS = 10 * 60_000
892
893async function markAlive($: EngineInterface, sessionId?: string, at?: number): Promise<void> {
894 try {
895 await $.fs.write(`${await sessionDir($, sessionId)}/${ALIVE_FILE}`, String(at ?? (await $.clock.now())))
896 } catch {
897 // The next minute marks it again.
898 }
899}
900
901// `$.fs` deletes nothing: the system's own command removes the folder, rmdir on Windows
902// and rm on macOS and Linux. Only a session's folder in the mod's own is removed.
903async function removeDir($: EngineInterface, dir: string): Promise<void> {
904 if (!new RegExp(`/${BODY_ROOT}/[\\w-]+$`).test(dir)) return
905 // Each command written whole at its call, the folder alone filled in: the plugin directory
906 // reads what a mod runs from the call as written.
907 try {
908 if ((await $.env.get('OS')) === 'Windows_NT') {
909 await $.process.run(['cmd', '/c', 'rmdir', '/s', '/q', dir.replace(/\//g, '\\')], { timeoutMs: 5000 })
910 } else {
911 await $.process.run(['rm', '-rf', '--', dir], { timeoutMs: 5000 })
912 }
913 } catch {
914 // Left for the next session's start.
915 }
916}
917
918/**
919 * At a session's start: the folders of sessions that ended (or were killed before their
920 * end ran), and this session's own when the pane shows none of its calls (a resume in a
921 * new process). A hot reload keeps the calls in view, so it keeps their folder.
922 */
923async function pruneBodies($: EngineInterface): Promise<void> {
924 let root: string
925 let own: string
926 let entries: Awaited<ReturnType<typeof $.fs.list>>
927 try {
928 root = await bodyRoot($)
929 own = await $.session.id()
930 entries = await $.fs.list(root)
931 } catch {
932 // No folder yet, or none to read: nothing to prune now, the next start tries again.
933 return
934 }
935 const hasCalls = (await read($, calls)).length > 0
936 const at = await $.clock.now()
937 for (const entry of entries) {
938 // A link is no folder of the mod's: `kind` is `other` for one.
939 if (entry.kind !== 'dir') continue
940 const dir = `${root}/${entry.name}`
941 if (entry.name === own) {
942 if (!hasCalls) await removeDir($, dir)
943 continue
944 }
945 // 0, or no mark: its session ended, or its folder is not one a session marks. A folder
946 // never stands unmarked while its session runs: the mark is written before any file.
947 let aliveAt = 0
948 try {
949 aliveAt = Number(await $.fs.read(`${dir}/${ALIVE_FILE}`)) || 0
950 } catch {
951 // No mark.
952 }
953 if (aliveAt === 0 || at - aliveAt > STALE_MS) await removeDir($, dir)
954 }
955}
956
957// Past what a pane draws, a text ends on how much is left and where the whole of it is,
958// cut between characters, never between the two halves of an emoji.
959const cutAt = (text: string | undefined, room: number, path: string): string | undefined => {
960 if (text === undefined || text.length <= room) return text
961 const end = /[\uD800-\uDBFF]/.test(text.charAt(room - 1)) ? room - 1 : room
962 return `${text.slice(0, end)}\n… ${text.length - end} more characters, whole in ${path}`
963}
964
965async function readBody($: EngineInterface, id: string): Promise<CallBody> {
966 try {
967 const path = await bodyPath($, id)
968 const kept = JSON.parse(await $.fs.read(path)) as { input?: string; output?: string }
969 // The input first (it is short but for a Write's content), the output in the rest.
970 const input = cutAt(kept.input, SCREEN_CHARS / 5, path)
971 const output = cutAt(kept.output, SCREEN_CHARS - (input?.length ?? 0), path)
972 return { id, input, output, isWhole: true }
973 } catch {
974 // No file: a call made before this version, a write that failed, or one over 4 MiB.
975 return { id, isWhole: false }
976 }
977}
978
979// A subagent's task and report, each in its own file beside the calls': the task written
980// at its spawn, the report at each end of its run.
981type AgentPart = 'prompt' | 'answer'
982async function agentPath($: EngineInterface, id: string, part: AgentPart): Promise<string> {
983 return bodyPath($, `agent-${id}-${part}`)
984}
985
986async function writeAgentPart($: EngineInterface, id: string, part: AgentPart, text: string): Promise<void> {
987 try {
988 await markAlive($)
989 await $.fs.write(await agentPath($, id, part), JSON.stringify(text))
990 } catch {
991 // Its screen keeps to the first lines the state holds.
992 }
993}
994
995async function readAgentPart($: EngineInterface, id: string, part: AgentPart): Promise<string | undefined> {
996 try {
997 const text = JSON.parse(await $.fs.read(await agentPath($, id, part))) as unknown
998 return typeof text === 'string' ? text : undefined
999 } catch {
1000 return undefined
1001 }
1002}
1003
1004async function readAgentBody($: EngineInterface, id: string): Promise<AgentBody> {
1005 const prompt = await readAgentPart($, id, 'prompt')
1006 const answer = await readAgentPart($, id, 'answer')
1007 // The task first, up to half the room; the report in the rest.
1008 const shownPrompt = cutAt(prompt, SCREEN_CHARS / 2, await agentPath($, id, 'prompt'))
1009 const shownAnswer = cutAt(answer, SCREEN_CHARS - (shownPrompt?.length ?? 0), await agentPath($, id, 'answer'))
1010 return { id, prompt: shownPrompt, answer: shownAnswer, isWhole: prompt !== undefined }
1011}
1012
1013/**
1014 * A request's response is whole: the API's usage, and its output split across text,
1015 * tool use and thinking. Text and tool use are sized by their characters; thinking,
1016 * which the stream shows a summary of at most, takes the rest of the real total.
1017 */
1018const endStep = (s: Step, r: TurnStepResult, endedAt: number, prev: Step | undefined): Step => {
1019 const tools = r.toolUses.map(t => t.name)
1020 if (!r.usage) return { ...s, status: 'failed', stopReason: r.stopReason, endedAt, tools }
1021 const output = r.usage.output_tokens
1022 let text = estimate(r.answer.length)
1023 let toolUse = estimate(r.toolUses.reduce((sum, t) => sum + t.name.length + safeJson(t.input).length, 0))
1024 if (text + toolUse > output) {
1025 text = Math.round((output * text) / (text + toolUse))
1026 toolUse = output - text
1027 }
1028 const input = promptOf(r.usage)
1029 return {
1030 ...s,
1031 status: 'done',
1032 stopReason: r.stopReason,
1033 endedAt,
1034 model: r.usage.model || s.model,
1035 input,
1036 cacheRead: r.usage.cache_read_input_tokens,
1037 cacheWrite: r.usage.cache_creation_input_tokens,
1038 output,
1039 text,
1040 toolUse,
1041 thinking: output - text - toolUse,
1042 tools,
1043 isCompacted: prev?.input !== undefined && input < prev.input,
1044 }
1045}
1046
1047/** What a request answered with, for its row. */
1048const gist = (s: Step): string => {
1049 if (s.status === 'streaming') return 'streaming…'
1050 if (s.status === 'failed') return 'no response'
1051 if (s.tools.length) {
1052 const names = [...new Set(s.tools.map(toolName))]
1053 return `${s.tools.length}× ${names.slice(0, 2).join(', ')}${names.length > 2 ? '…' : ''}`
1054 }
1055 if (s.stopReason === 'max_tokens' || s.stopReason === 'model_context_window_exceeded') return 'cut off'
1056 return s.text ? 'answer' : 'thinking only'
1057}
1058
1059// Closes the pane when it is on screen; otherwise opens it, or shows it when it is open but
1060// waiting undrawn or behind another pane's tab. Says what came of it, and whether the pane
1061// is on screen now (false once closed, or when it could not be placed).
1062async function togglePane(
1063 $: EngineInterface,
1064 where: CommandPresentation,
1065): Promise<{ text: string; isShown: boolean }> {
1066 const pane = (await $.ui.panes()).find(p => p.id === PANE)
1067 if (pane?.isPlaced && pane.isShown) {
1068 await $.ui.close({ id: PANE })
1069 await update($, isPaneOpen, () => false)
1070 return { text: 'Activity pane closed.', isShown: false }
1071 }
1072 await tick($)
1073 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS, rows: INLINE_ROWS })
1074 await update($, isPaneOpen, () => opened.isPlaced)
1075 if (!opened.isPlaced) return { text: `The pane is not shown yet: ${opened.reason}`, isShown: false }
1076 // The fullscreen layout docks it beside the transcript from 110 columns; anywhere else it
1077 // sits above the prompt, in the room session-band's rows share.
1078 const isDocked = where.isFullscreen && where.columns >= DOCK_MIN_COLUMNS
1079 const text = isDocked
1080 ? 'Activity pane opened beside the transcript.'
1081 : `Activity pane opened above the prompt (${where.isFullscreen ? 'fullscreen' : 'classic layout'}, ${where.columns} columns; docking beside needs fullscreen from ${DOCK_MIN_COLUMNS} columns).`
1082 return { text, isShown: true }
1083}
1084
1085async function tick($: EngineInterface): Promise<void> {
1086 const at = await $.clock.now()
1087 await update($, now, () => at)
1088}
1089
1090const stepKey = (n: number): string => `s:${n}`
1091const allKey = (list: string): string => `all:${list}`
1092
1093// A request writes what is new to the prompt cache, a few thousand tokens; one that writes
1094// most of its input found the cache expired and paid to write the whole context again.
1095const REWRITE_MIN = 20_000
1096const isCacheRewrite = (s: Step): boolean =>
1097 s.cacheWrite !== undefined &&
1098 s.input !== undefined &&
1099 s.cacheWrite >= REWRITE_MIN &&
1100 s.cacheWrite >= s.input / 2
1101
1102const failedOf = (list: Call[]) => list.filter(c => c.status === 'error' || c.status === 'denied').length
1103// Still at work: answering, or its command running on in the background.
1104const isLive = (c: Call) => c.status === 'running' || c.status === 'background'
1105// How long a call ran: a background command's, until its task ended.
1106const callMs = (c: Call, at: number) => ((c.taskId === undefined ? c.endedAt : c.taskEndedAt) ?? at) - c.startedAt
1107
1108/** The subagent an Agent call spawned, and what a subagent or a prompt cost with every subagent under it. */
1109const costsOf = (allCalls: Call[], agentList: AgentRun[]) => {
1110 // Matched by the call's id.
1111 const runOf = (c: Call): AgentRun | undefined =>
1112 c.tool === 'Agent' ? agentList.find(a => a.toolUseId === c.id) : undefined
1113 const callsOf = (a: AgentRun): Call[] => (a.agentId ? allCalls.filter(c => c.agentId === a.agentId) : [])
1114 const runCost = (a: AgentRun): number =>
1115 callsOf(a).reduce((sum, c) => {
1116 const child = runOf(c)
1117 return child ? sum + runCost(child) : sum
1118 }, a.costUsd)
1119 // The main loop's requests and every subagent its Agent calls spawned. A prompt kept
1120 // by the version that dropped cost has none: it counts nothing rather than NaN.
1121 const promptCost = (p: Prompt): number =>
1122 allCalls
1123 .filter(c => c.turnId === p.turnId)
1124 .reduce(
1125 (sum, c) => {
1126 const run = runOf(c)
1127 return run ? sum + runCost(run) : sum
1128 },
1129 Number.isFinite(p.costUsd) ? p.costUsd : 0,
1130 )
1131 return { runOf, callsOf, runCost, promptCost }
1132}
1133
1134const noTotals = (sessionId: string): Totals => ({
1135 sessionId,
1136 prompts: 0,
1137 requests: 0,
1138 tools: 0,
1139 failed: 0,
1140 durationMs: 0,
1141 costUsd: 0,
1142 tokens: 0,
1143 output: 0,
1144 mainInput: 0,
1145 cacheRead: 0,
1146})
1147
1148/**
1149 * The header's figures over the rows the pane keeps, on top of `base`, what the session
1150 * showed before this process. Every token the API processed, the loop's requests and the
1151 * subagents' under it: input with its cache reads, and output.
1152 *
1153 * `loop` is a subagent's id for its own screen, absent for the session; `agents` the
1154 * subagents its calls spawned.
1155 */
1156const totalsOf = (
1157 base: Totals,
1158 lists: { prompts: Prompt[]; steps: Step[]; calls: Call[]; agents: AgentRun[] },
1159 at: number,
1160 loop?: string,
1161): Totals => {
1162 const { promptCost } = costsOf(lists.calls, lists.agents)
1163 const own = <T extends { agentId?: string }>(list: T[]) => list.filter(x => x.agentId === loop)
1164 const [ownPrompts, ownSteps, ownCalls] = [own(lists.prompts), own(lists.steps), own(lists.calls)]
1165 const sum = <T,>(list: T[], of: (x: T) => number) => list.reduce((s, x) => s + of(x), 0)
1166 const mainInput = sum(ownSteps, s => s.input ?? 0)
1167 const mainOutput = sum(ownSteps, s => s.output ?? 0)
1168 return {
1169 sessionId: base.sessionId,
1170 prompts: base.prompts + ownPrompts.length,
1171 requests: base.requests + ownSteps.length,
1172 tools: base.tools + ownCalls.length,
1173 failed: base.failed + failedOf(ownCalls),
1174 durationMs: base.durationMs + sum(ownPrompts, p => (p.endedAt ?? at) - p.startedAt),
1175 costUsd: base.costUsd + sum(ownPrompts, promptCost),
1176 tokens: base.tokens + mainInput + mainOutput + sum(lists.agents, a => a.tokens),
1177 output: base.output + mainOutput + sum(lists.agents, a => a.output),
1178 mainInput: base.mainInput + mainInput,
1179 cacheRead: base.cacheRead + sum(ownSteps, s => s.cacheRead ?? 0),
1180 }
1181}
1182
1183// One small record per session in `$.store`, the most recently saved kept.
1184const TOTALS_PREFIX = 'totals:'
1185const MAX_SAVED_SESSIONS = 50
1186
1187async function readTotals($: EngineInterface): Promise<Totals> {
1188 const [prompts_, steps_, calls_, agents_] = [
1189 await read($, prompts),
1190 await read($, steps),
1191 await read($, calls),
1192 await read($, agents),
1193 ]
1194 const base = (await read($, carried)) ?? noTotals(await $.session.id())
1195 return totalsOf(base, { prompts: prompts_, steps: steps_, calls: calls_, agents: agents_ }, await $.clock.now())
1196}
1197
1198/** Saves the header's figures under the session's id; a session with none saves nothing. */
1199async function saveTotals($: EngineInterface, sessionId?: string): Promise<void> {
1200 try {types/index.d.ts 223 lines1export type Stats = {
2 turn: number
3 speed: number | null
4}
5
6export type Cache = {
7 ttlSeconds: number
8 expiresAt: number | null
9 secondsLeft: number | null
10}
11
12export type Limit = {
13 percentUsed: number
14 resetsAt: string | null
15}
16
17export type Usage = {
18 context: { percent: number; tokens: number | null; window: number } | null
19 fiveHour: Limit | null
20 weekly: Limit | null
21}
22
23// The activity pane (the second half of hooks/register.tsx).
24
25/** `background`: the call answered, its command still runs as a background task. */
26export type CallStatus = 'running' | 'background' | 'ok' | 'error' | 'denied'
27
28export type Call = {
29 id: string
30 tool: string
31 /** What the call works on: a file, a command, a pattern, an agent's task. */
32 target: string
33 /** The turn it ran in, the main loop's or its subagent's; absent when that turn went unheard. */
34 turnId?: string
35 /** The subagent's loop it ran in; absent on the main loop. */
36 agentId?: string
37 /** The request whose tool use it answers (`Step.n`). */
38 step?: number
39 status: CallStatus
40 startedAt: number
41 /** When the call answered; a background command runs on past it. */
42 endedAt?: number
43 /** The background task the call left running (a shell moved to the background). */
44 taskId?: string
45 /** When that task's notification said it ended. */
46 taskEndedAt?: number
47 /** Characters of the result as the model reads it. */
48 resultChars: number
49 /** The first lines of what an errored or denied call answered. */
50 error?: string
51 /** The first lines of the call's input, as `CallBody.input` writes it. */
52 input?: string
53 /** The first lines of what a call that ran answered. */
54 output?: string
55 /** The call answered with no text: `output` is its result record as JSON. */
56 isRecord?: boolean
57}
58
59/** The whole input and output of the call whose screen is open, from its file. */
60export type CallBody = {
61 id: string
62 /**
63 * The input exactly as the tool took it: a shell's command, with the arguments that
64 * change its run as JSON under it; any other tool's arguments as JSON.
65 */
66 input?: string
67 /** What the tool answered, as the model read it; its result record when it gave no text. */
68 output?: string
69 /** False when the call has no file: its screen keeps to the first lines `Call` holds. */
70 isWhole: boolean
71}
72
73/** One turn: a prompt and everything the model did for it; a subagent's run is one too. */
74export type Prompt = {
75 turnId: string
76 /** The main loop's turn number in the session, those before a resume counted; absent on a subagent's. */
77 n?: number
78 /** The subagent's loop it ran in; absent on the main loop. */
79 agentId?: string
80 /** Its row's title: the prompt on one line, a task notification as its summary, cut short. */
81 text: string
82 startedAt: number
83 endedAt?: number
84 isAborted: boolean
85 costUsd: number
86}
87
88export type StepStatus = 'streaming' | 'done' | 'failed'
89
90/**
91 * One request of a loop to the model, the main loop's or a subagent's. Token totals are the API's own usage;
92 * only the output's split across text, tool use and thinking is estimated.
93 */
94export type Step = {
95 /** 1-based, across the session and every loop: the request's key. */
96 n: number
97 /** 1-based within its loop, as its row numbers it; a request kept before it was added has `n`. */
98 seq?: number
99 /** The subagent's loop it ran in; absent on the main loop. */
100 agentId?: string
101 turnId: string
102 model: string
103 /** The effort it was sent with, when the model takes one. */
104 effort?: string
105 status: StepStatus
106 stopReason?: string | null
107 startedAt: number
108 endedAt?: number
109 /** Input in all: uncached + cache read + cache write. */
110 input?: number
111 cacheRead?: number
112 /** Input written to the prompt cache: the whole context again once the cache expired. */
113 cacheWrite?: number
114 /** When the response's first item arrived: before it the wait, after it the writing. */
115 firstChunkAt?: number
116 output?: number
117 /** The output's split, estimated from characters; thinking takes the rest. */
118 text: number
119 toolUse: number
120 thinking: number
121 /** The tools the response asked for, in order. */
122 tools: string[]
123 /** The input shrank against the request before: a compaction ran between. */
124 isCompacted: boolean
125}
126
127export type AgentStatus ='running' | 'done' | 'failed'
128
129export type AgentRun = {
130 id: string
131 agentId?: string
132 /** The Agent tool call that spawned it: that call's `Call.id`. */
133 toolUseId: string
134 type: string
135 description: string
136 model: string
137 effort?: string
138 status: AgentStatus
139 startedAt: number
140 endedAt?: number
141 contextUsed: number
142 contextMax: number
143 tokens: number
144 /** Output tokens over every request of its run. */
145 output: number
146 costUsd: number
147 steps: number
148 /** The first lines of the task its spawner gave it, as `AgentBody.prompt` writes it. */
149 prompt?: string
150 /** The first lines of the report its last run handed back. */
151 answer?: string
152}
153
154/** The whole task and report of the subagent whose screen is open, from its files. */
155export type AgentBody = {
156 /** `AgentRun.id`. */
157 id: string
158 /** The Agent call's `prompt`, as the subagent began with it. */
159 prompt?: string
160 /** The report its last run handed back; absent while it runs. */
161 answer?: string
162 /** False when its files are gone: its screen keeps to the first lines `AgentRun` holds. */
163 isWhole: boolean
164}
165
166export type View = {
167 /** What is open: a prompt by its turnId, a request as `s:<n>`. */
168 expanded: string[]
169 /** Opens the newest request of the prompt in progress; a new prompt sets it, a press clears it. */
170 isFollowing: boolean
171 isDoneCollapsed: boolean
172 /** The call whose own screen is open, by its `Call.id`; the list when absent. */
173 detail?: string
174 /** The subagent whose own screen is open, by its `AgentRun.id`; a call's screen opened from it goes back to it. */
175 agent?: string
176}
177
178/**
179 * The figures of the pane's session header, summed over what the pane keeps. Saved per
180 * session in `$.store`, so a resumed session's header starts from what it showed before;
181 * the rows themselves are not kept.
182 */
183export type Totals = {
184 sessionId: string
185 /** Prompts sent: those before a resume are counted, not listed. */
186 prompts: number
187 requests: number
188 /** Main-loop tool calls, and those of them that failed or were denied. */
189 tools: number
190 failed: number
191 durationMs: number
192 costUsd: number
193 /** Every token the API processed, the main loop's and the subagents'. */
194 tokens: number
195 output: number
196 /** The main loop's input, and the part of it read from the cache. */
197 mainInput: number
198 cacheRead: number
199}
200
201declare module 'claude-code' {
202 interface PluginState {
203 'session-band': {
204 stats: Stats
205 cache: Cache
206 isBusy: boolean
207 warned: number
208 usage: Usage
209 calls: Shaped<Call[]>
210 prompts: Shaped<Prompt[]>
211 steps: Shaped<Step[]>
212 agents: Shaped<AgentRun[]>
213 view: Shaped<View>
214 now: number
215 body: CallBody | null
216 agentBody: AgentBody | null
217 isPaneOpen: boolean
218 /** The header's figures from before this process: a resumed session's earlier ones. */
219 carried: Totals | null
220 }
221 }
222}
223