SLOPSHOPPER

session-band

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…

newpanebandguardcommandtoast
★ 1v1.1.1MITupdated 2026-10-26KhoaaTran/session-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-band
│ ┃ Activity ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No prompts yet: send one to start. │ session-band │ │ ⏺ Read(src/auth.ts) │ Activity pane opened beside the │ │ ⎿ Read 6 lines │ transcript. │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /activity │ │ Turn: 1 · Cache: 60:00 · Speed: —⚠ 5h limit: — Context: ▱▱▱▱▱▱▱▱▱▱ — detail ⚠ Weekly limit: — ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Turn: 1 · Cache: 60:00 · Speed: —⚠ 5h limit: — Context: ▱▱▱▱▱▱▱▱▱▱ — detail ⚠ Weekly limit: —
Pane · Activity
No prompts yet: send one to start.
README

session-band

English · Tiếng Việt

A Claude Code mod in two parts, with a status line to go with it:

  • The band, always above the prompt: turn, time left on the prompt cache, output speed, context, and the 5h and weekly limits.
  • The activity pane, opened from ☰ beside Context: each prompt, the requests it sent to the model, their tool calls and subagents, and the shells still running in the background.
  • The status line (statusline.js) under the prompt: model and effort, folder, session title. It also hands the band the figures the mod API lacks.

session-band in Claude Code: the band and status line on the left, the activity pane docked on the right

<sub>An illustration of what the mod draws, not a screenshot: your terminal's font and widths apply.</sub>

Install

Needs a Claude Code with mods (on by default from 2.1.287; written against 2.1.310). Works on macOS, Linux and Windows.

One line

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.

One prompt

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.

By hand

  1. Clone the repo into ~/.claude/mods/session-band.
  2. Merge into ~/.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
     }
   }
  1. Merge into ~/.claude/keybindings.json the key that opens the pane:
   {
     "bindings": [{ "context": "Chat", "bindings": { "ctrl+x ctrl+p": "command:activity" } }]
   }
  1. Open a new session. The band shows above the prompt, ☰ 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.

Your own status line segments

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 : "")

What the band reads

FileFor
claude-prompt-cache-<session id>.json in the system temp folderthe prompt cache's real deadline, the turn count
~/.claude/rate-limits-cache.jsonthe 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().

What the pane writes

FolderFor
session-band/<session id>/ in a temporary folder only you read: %TEMP% on Windows, $TMPDIR on macOS, $XDG_RUNTIME_DIR (else ~/.cache) on Linuxeach 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.

StoreFor
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.

Everything it runs, reads and sends

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)
Watchesthe session's prompts, requests, tool calls and subagents, to draw them; their text stays on this machine, in the files abovethe JSON Claude Code hands a status line
Readsthe 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 figuresthe session's transcript, for its title and turn count; ~/.claude/statusline.local.js when you made one
Writesits own folder and $.store, as aboveclaude-statusline-<session id>.json and claude-prompt-cache-<session id>.json in the system temp folder, ~/.claude/rate-limits-cache.json
Runsone 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 foldernothing
CallsClaude 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 deliverednothing

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.

Its hooks

Every hook passes what it sees on unchanged; the mod only records and draws.

EventWhat the hook does
session.startregisters /activity and /session-band, starts the pane's one-second clock, removes the folders of sessions that ended
session.endsaves the Session lines' figures, removes this session's folder; on /clear or a resume, empties the pane
prompt.submitreads a background task's notification, to end the shell row it names
turn.start, turn.step, turn.completerecord each prompt, request (tokens, timing, model, effort) and answer, the main loop's and the subagents'
tool.callrecords each tool call, its input and its output, in the session's folder
agent.spawnrecords each subagent: its type, description, model and the prompt it was given
command.runanswers its own /activity and /session-band alone, by matcher
ui.renderdraws the band above the prompt and the activity pane
ui.closenotes that the pane closed, for the band's button

Use

ToHow
Open or close the panepress ☰ beside Context (green while open), or /activity
Keyboard shortcutCtrl+X Ctrl+P from the prompt (bound at install)
Move into the paneCtrl+X Tab, then Tab and Enter; Ctrl+X X closes it
Widen or narrow the panedrag its edge, or Ctrl+X ←/Ctrl+X → from inside it
See a tool call wholepress 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.

Examples

  • Back after a break, before the next prompt: the band's countdown says whether the prompt cache still holds, and Cache tax under the pane's Session lines what the next request pays to send the context again, the cache held or expired.
  • Why a prompt cost what it did: press its row. Its requests show the context each sent and how long it took, its tool calls what ran; ⚠ marks a failed call or a cache written again.
  • What a subagent was asked and what it answered: press its row under Subagent. Its requests and tool calls, then the prompt it was given and its report, whole.
  • A tool call's whole output after the transcript folded it: press the call's row; ‹ Back returns to it.

Reading the pane

WhereMeans
Session lines, toprequests, 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 runningunder 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
Timesseconds 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, leftwhen 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, rightwhat the prompt cost, subagents included · its whole time; the context it left is in its summary
Turnthe main loop's turn number in the session, those before a resume counted
Cache tax · expiredwhat 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
Modelthe model and effort the request was sent with
Request row (#n), rightcontext after that request · time from sending it to its last tool's answer
Tool row, righthow long the tool ran, a background shell's until its task ended; orange from 30s
In / Outinput tokens (cache included) / tokens the model wrote, from the API
Wait / Writefrom 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 365kthe cache expired, so the request wrote the whole context again (20k tokens or more, at least half its input)
Costan 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.

Layout

FileHolds
hooks/register.tsxthe hooks module, whole: the band and its ☰ button, then the activity pane and its data
hooks/activity.test.tsxtests of the pane and the band
types/index.d.tsthe type of every value in $.state
statusline.jsthe 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.

Privacy

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.

Troubleshooting

What you seeWhat to do
No band above the promptopen 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 transcriptdocking needs the fullscreen layout and 110 columns or more
A subagent's screen says Prompt: not keptit started before the mod loaded; the next ones keep it
A tool call's screen shows its first lines onlyits file could not be read: over 4 MiB, or written before this version

Support

Questions, bugs and requests: open an issue. The mod is maintained by KhoaaTran.

Credits

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.

Checks

npx -p typescript tsc -p .        # needs .claude-plugin/types/, which the engine writes when it loads the mod
claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 2323 lines
1import { 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 lines
1export 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