SLOPSHOPPER

alandovskis

Personal agent skills: requirements docs (BRD/PRD), planning and implementation, code review, plus a /flow conversation sidebar, and a session panel and…

newpanebandguardcommandprompt
v0.8.0MITupdated 2026-10-15alandovskis/ai-plugins/alandovskis
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · alandovskis
│ ┃ Flow ✕ › fix the failing auth test and add an audit log call │ ┃ ● You fix the failing auth test and add an a │ ┃ ├ ✓ Read /work/app/src/auth.ts ⏺ Read(src/auth.ts) │ ┃ ├ ✓ Grep /work/app/src ⎿ Read 6 lines │ ┃ ├ ✓ Edit /work/app/src/auth.ts ⏺ Update(src/auth.ts) │ ┃ ├ ✓ Write /work/app/src/audit.ts ⎿ Added 2 lines, removed 1 line │ ┃ ├ ✓ Write /work/app/src/cache.ts ⏺ Bash(bun test) │ ┃ ├ ✗ Bash Run the test suite ⎿ 3 pass, 1 fail │ ┃ ├ ✓ Bash Show working tree status │ ┃ ├ ✓ Bash Clean build output and push ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ├ ✓ Bash Show environment file │ ┃ └ ◆ Claude 42.0s ✻ Worked for 42s · done 4:20 PM │ │ › /flow │ ⎿ alandovskis: Conversation flow opened. │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Session │ │ opus-5-5 Context █████░░░░░ 49% 5h ███░░░░░░░ 31% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Session │ │ opus-5-5 Context █████░░░░░ 49% 5h ███░░░░░░░ 31% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Flow
● You fix the failing auth test and add an audit log ca… ├ ✓ Read /work/app/src/auth.ts ├ ✓ Grep /work/app/src ├ ✓ Edit /work/app/src/auth.ts ├ ✓ Write /work/app/src/audit.ts ├ ✓ Write /work/app/src/cache.ts ├ ✗ Bash Run the test suite ├ ✓ Bash Show working tree status ├ ✓ Bash Clean build output and push ├ ✓ Bash Show environment file └ ◆ Claude 42.0s
README

alandovskis

Agent skills in the open Agent Skills format: each skill is skills/<name>/SKILL.md with name and description frontmatter, plus optional references/. No runtime-specific frontmatter or tool names, so any runtime that reads SKILL.md can use them. Thin manifests make it installable as a plugin.

Skills

SkillPurpose
brd, prd, brd-to-prdRequirements documents, published to Confluence
plan-prd, implement-prdPRD → SDD/test plan/implementation plan → test-first implementation
prd-to-jiraJira epic/stories from a PRD
issue-trackerJira is the issue tracker, used through the Atlassian MCP
review-codeCode review through maintainability, efficiency, correctness, observability, scalability and security lenses, plus UX (accessibility, responsive, theming, via impeccable audit) for web UI
create-prReview with review-code, fix, open the PR, watch CI, merge
bilingual-appsApps are bilingual EN/FR: externalised strings, locale formatting, parity checks
web-stackWeb apps use Rust / WebAssembly
ciUse GitHub Actions for CI/CD
design-docWrite a design doc (objective, goals, SLOs, alternatives, open issues), reviewed with review-design-doc and fixed
review-design-docReview a design doc draft through clarity, conciseness and consistency subagents, leaving the text unedited
design-doc-confluenceReverse-engineer design docs in Confluence wiki markup

Mods (Claude Code only)

A Claude Code hooks module: hooks/register.tsx wires each event to the two features, whose state changes and drawing live in hooks/flow.tsx and hooks/subagents.tsx; the state contract is in types/ and the tests in tests/.

Conversation flow: /flow toggles a sidebar that shows the conversation one line per event:

● You Fix the parser
├ ✓ Read src/parser.ts
├ ✗ Bash rm -rf /
├ ✓ ⇢ Explore (haiku-5-5) Find callers
│ ├ ✓ Read src/lexer.ts
└ ◆ Claude 4.2s · All done.
  • ● your prompt (its first line).
  • ├ a tool call with its main argument and status: … running, ✓ done, ✗ failed or refused, ⊘ refused by another plugin's hook. A call you or a settings hook refuse reaches the sidebar as a failed call, so it shows ✗.
  • ⇢ a subagent, with the model it runs on, coloured as in the band; its own tool calls are drawn under it, even when several subagents run at once. Each of a workflow's agents gets a ⇢ row of its own under its Workflow call.
  • └ ◆ Claude's answer: how long the turn took and its first line. An interrupted or failed turn shows └ ✗ and the reason (aborted, error, refusal with its explanation).

It docks beside the transcript in the fullscreen layout from 110 columns; elsewhere it opens above the prompt. The flow starts when the plugin loads, empties on /clear and /resume, and keeps the last 400 lines; on each line the tool name is cut to 48 characters and the detail to 120. It only observes: it never changes or blocks a prompt or a tool call.

Session panel: a framed panel titled "Session" sits at the top of the band above the prompt, over the subagent band, and shows the session's figures as the status line has them:

╭─ Session ─────────────────────────────────────────────────────────────────────────────────╮
│ opus-5-5  Context ██░░░░░░░░ 18%  5h ██████░░░░ 55% ↻ 2h 10m  Week ████████░░ 81% ↻ 3d 4h │
╰───────────────────────────────────────────────────────────────────────────────────────────╯
  • The model the session runs on, as a badge coloured as in the subagent band.
  • Context: how full the session's context window is, from its latest response; 5h and Week: how much of the five-hour and the weekly rate limit is used. Each is a bar of ten cells and its percentage: green under 50%, yellow from 50%, red from 80%.
  • ↻ after a limit: how long until it resets, rounded up to the minute (42m, 2h 10m, or 3d 4h from a day). It is left out when the engine gives no reset time, and once the time has passed.
  • A figure the session has none for is left out: the context before the first response, the rate limits off a subscription. The figures are read every second, so a /model switch shows within a second.
  • It yields the band to a survey, as the subagent band does. /subagents hides only the subagent band.

Subagent band: while subagents run, a framed band titled "Subagents" under the session panel shows what each one is doing:

╭─ Subagents · plugins ⎇ main ──────────────────────────────────────────────────────────────╮
│ haiku-5-5 ⇢ Explore ██░░░░░░░░ 18% 12s · 7 calls                                          │
│    Find callers of parse()                                                                │
│    … Grep parse\(                                                                         │
│ sonnet-5-5 ✓ Plan ██████░░░░ 57% agent-a2 ⎇ worktree-a2 8s · 9 calls                      │
│    Design the lexer split                                                                 │
╰───────────────────────────────────────────────────────────────────────────────────────────╯
  • The title shows the session's git worktree (its directory's name) and branch, or the commit's short hash on a detached HEAD, and nothing outside git. It is read again as each subagent starts, and after the main conversation's Bash, EnterWorktree and ExitWorktree calls while the band shows.
  • Each entry starts with the model the subagent runs on, without its claude- prefix, as a badge coloured by family (opus orange, sonnet blue, haiku purple, fable teal, any other grey), with its description on the line under it.
  • After its type, a bar of ten cells shows how full its context window is, from its latest response: green under 50%, yellow from 50%, red from 80%. A subagent on the session's model is measured against the session's window; any other against its model's (1M for current models, 200K for those before Claude 4.6). No bar shows before its first response.
  • ⇢ a running subagent: its type (a workflow agent's name); its own worktree and branch where they differ from the session's, as in an isolated worktree (read on its first tool call); how long it has run and its tool calls so far, with its latest call under its description (… running, ✓, ✗, ⊘).
  • ✓ or ✗ a finished subagent, with its total time; it stays for 30 seconds, then leaves the band.
  • Running subagents come first. The band shows at most six, as many as its rows allow, and counts the rest (+2 more). It draws nothing while no subagent has run for 30 seconds, and empties on /clear and /resume.

/subagents hides the band, and shows it again; the choice is kept across sessions.

To work on them: claude --plugin-dir ./alandovskis loads the plugin and reloads it as files change; claude plugin test alandovskis runs the tests. Once loaded, the engine writes the API types to .claude-plugin/types/ (git-ignored), which tsconfig.json extends, so tsc -p alandovskis type-checks the module.

Install

  • Claude Code: add the repo as a marketplace, then install the plugin: /plugin marketplace add alandovskis/ai-plugins then /plugin install alandovskis@alandovskis. For local development, use claude --plugin-dir ./alandovskis.
  • Codex: .codex-plugin/plugin.json points at ./skills/.
  • Gemini CLI: gemini extensions install ./alandovskis (skills auto-discovered from skills/).
  • Anything else (Cursor, Copilot, OpenCode, ...): copy or symlink skills/* into the runtime's skills directory.

Runtime requirements

Context7 MCP (@upstash/context7-mcp, run via npx; requires Node.js) is declared as a dependency for up-to-date library documentation, replacing the former anti-hallucination skill. It is bundled in .mcp.json (Claude Code, Codex) and gemini-extension.json (Gemini CLI). For other runtimes, add it to their MCP config.

Skills describe operations, not host tool names. The Confluence/Jira skills (brd, prd, brd-to-prd, plan-prd, implement-prd, prd-to-jira) need an Atlassian MCP connector exposing getAccessibleAtlassianResources, getConfluencePage, createJiraIssue, etc. implement-prd uses read-only subagents where the runtime supports them, and can run them sequentially otherwise.

Evals: evals/<skill>-<n>/ in the claude plugin eval format (see AGENTS.md at the repo root); present for bilingual-apps, ci, design-doc, issue-tracker, review-code, review-design-doc and web-stack.

Not included (Claude-specific, not skills): rules, routines, settings, agent definitions. The one exception is the Claude Code hooks module above, which other runtimes ignore.

Source 7 files
hooks/register.tsx 367 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FlowNode, FlowStatus, Place, RunStatus, SubagentCall } from '../types'
5import { appended, changed, flowView, seconds, withSpawn } from './flow'
6import { PANEL_ROWS, gaugeOf, sameGauge, sessionView } from './session'
7import { AGENT_TOOLS, clip, detailOf, firstLine, statusOf } from './shared'
8import {
9  LABEL_MAX,
10  TEXT_MAX,
11  bandView,
12  called,
13  ended,
14  isShown,
15  measured,
16  placeOf,
17  placed,
18  settled,
19  started,
20  windowOf,
21} from './subagents'
22
23// Each event is hooked once here and fed to the features: the /flow sidebar
24// (flow.tsx), and above the prompt the session panel (session.tsx) over the
25// subagent band (subagents.tsx). Every
26// call on `$` is made in this file, as the engine follows `$` no further.
27//
28// The hooks only observe, so they carry no .catch: one that fails is skipped,
29// the event goes on, and the engine logs why. Where a hook feeds both
30// features, each one's writes go through `landed`, so one feature's failure
31// never costs the other its update.
32
33const PANE = 'conversation-flow'
34const PANE_TITLE = 'Flow'
35const BAND_COMMAND = 'subagents'
36
37const nodes = atom({ plugin: 'alandovskis', key: 'flowNodes' } as const, [])
38const runs = atom({ plugin: 'alandovskis', key: 'subagentRuns' } as const, [])
39const now = atom({ plugin: 'alandovskis', key: 'subagentsNow' } as const, 0)
40const isBandHidden = atom({ plugin: 'alandovskis', key: 'subagentsHidden' } as const, false)
41const place = atom({ plugin: 'alandovskis', key: 'subagentsPlace' } as const, null)
42const figures = atom({ plugin: 'alandovskis', key: 'sessionGauge' } as const, null)
43
44// Where the person's choice to hide the band is kept across sessions.
45const BAND_HIDDEN_KEY = 'subagentsHidden'
46const TICK_MS = 1_000
47const GIT_TOP = ['git', 'rev-parse', '--show-toplevel']
48const GIT_BRANCH = ['git', 'branch', '--show-current']
49const GIT_HASH = ['git', 'rev-parse', '--short', 'HEAD']
50const GIT_TIMEOUT_MS = 5_000
51// The main loop's tools that can move the session to another branch or worktree.
52const PLACE_TOOLS = new Set(['Bash', 'EnterWorktree', 'ExitWorktree'])
53
54// Resolves whether a state write landed, logging one that did not to the debug
55// log, so a write left running beside a tool never rejects unhandled.
56const landed = ($: EngineInterface, what: string, write: Promise<unknown>) =>
57  write.then(
58    () => true,
59    (error: unknown) => {
60      $.ui.log(`alandovskis: ${what} failed: ${error instanceof Error ? error.message : String(error)}`, {
61        to: 'debug',
62      })
63
64      return false
65    },
66  )
67
68const addNode = ($: EngineInterface, fields: FlowNode) => update($, nodes, list => appended(list, fields))
69
70const changeNode = ($: EngineInterface, id: string, change: (one: FlowNode) => FlowNode) =>
71  update($, nodes, list => changed(list, id, change))
72
73// The flow rows still being written, by tool_use_id: agent.spawn waits on its
74// call's row, so the link to the subagent lands on a row that exists.
75const rowWrites = new Map<string, Promise<boolean>>()
76
77// The subagents whose place has been read, or is being read.
78const placeReads = new Set<string>()
79
80// Where `cwd` is in git: null outside a repository, or in one with no commit
81// on a detached HEAD.
82const readPlace = async ($: EngineInterface, cwd: string): Promise<Place | null> => {
83  const git = (argv: readonly string[]) => $.process.run(argv, { cwd, timeoutMs: GIT_TIMEOUT_MS })
84  const [top, branch] = await Promise.all([git(GIT_TOP), git(GIT_BRANCH)])
85  if (top.exitCode !== 0 || branch.exitCode !== 0) return null
86  if (branch.stdout.trim() !== '') return placeOf(top.stdout, branch.stdout)
87  const hash = await git(GIT_HASH)
88
89  return hash.exitCode === 0 ? placeOf(top.stdout, hash.stdout) : null
90}
91
92// Each takes the directory as the hook asked for it, so a hook's own read
93// failing fails only this work, never the hook.
94const readSessionPlace = async ($: EngineInterface, cwd: Promise<string> | string) => {
95  const found = await readPlace($, await cwd)
96  await update($, place, () => found)
97}
98
99const readRunPlace = async ($: EngineInterface, agentId: string, cwd: Promise<string>) => {
100  const where = await cwd
101  // A call made before its run is on the band leaves the read to a later one.
102  if (!(await read($, runs)).some(run => run.agentId === agentId)) {
103    placeReads.delete(agentId)
104
105    return
106  }
107  const found = await readPlace($, where)
108  if (found !== null) await update($, runs, list => placed(list, agentId, found))
109}
110
111// The session's model and its context window, for a subagent on that model.
112const readSessionWindow = async ($: EngineInterface) => {
113  const [model, usage] = await Promise.all([$.session.model(), $.session.usage()])
114
115  return { model, window: usage.context.window }
116}
117
118// Rereads the session panel's figures, writing only when one moved, so an
119// unchanged second draws nothing again; the resets count down by the minute.
120// Read each tick, as the model can change between turns and no event says so.
121const readFigures = async ($: EngineInterface) => {
122  const [model, usage, at] = await Promise.all([$.session.model(), $.session.usage(), $.clock.now()])
123  const found = gaugeOf(model, usage, at)
124  if (!sameGauge(await read($, figures), found)) await update($, figures, () => found)
125}
126
127// Moves the clock the band draws its times from, and drops finished runs
128// whose time is up; it writes only while the band has something to show.
129const tick = async ($: EngineInterface) => {
130  const list = await read($, runs)
131  if (list.length === 0) return
132  const at = await $.clock.now()
133  await update($, now, () => at)
134  if (list.some(run => !isShown(run, at))) await update($, runs, all => all.filter(run => isShown(run, at)))
135}
136
137export const register: Register = on => {
138  on('session.start', async ($, e, next) => {
139    await $.command.register({ name: 'flow', description: 'Toggle the conversation flow sidebar' })
140    await $.command.register({ name: BAND_COMMAND, description: 'Hide or show the subagent band above the prompt' })
141    const stored = await $.store.get(BAND_HIDDEN_KEY)
142    await update($, isBandHidden, () => stored === true)
143    void landed($, "reading the session's place", readSessionPlace($, e.cwd))
144    void landed($, "reading the session's figures", readFigures($))
145    // Runs until the module reloads, which raises session.start again.
146    $.clock.every(TICK_MS, () => {
147      void landed($, 'a subagent band tick', tick($))
148      void landed($, "rereading the session's figures", readFigures($))
149    })
150
151    return next(e)
152  })
153
154  on('command.run', { command: 'flow' }, async $ => {
155    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
156      await $.ui.close({ id: PANE })
157
158      return { text: 'Conversation flow closed.' }
159    }
160    await $.ui.open({ id: PANE, title: PANE_TITLE })
161
162    return { text: 'Conversation flow opened.' }
163  })
164
165  on('command.run', { command: BAND_COMMAND }, async $ => {
166    const hidden = !(await read($, isBandHidden))
167    await update($, isBandHidden, () => hidden)
168    await $.store.set(BAND_HIDDEN_KEY, hidden)
169
170    return { text: hidden ? 'Subagent band hidden.' : 'Subagent band shown.' }
171  })
172
173  on('session.end', async ($, e, next) => {
174    // /clear and /resume put another conversation in this one's place.
175    if (e.reason === 'clear' || e.reason === 'resume') {
176      await update($, nodes, () => [])
177      await update($, runs, () => [])
178      placeReads.clear()
179    }
180
181    return next(e)
182  })
183
184  on('prompt.submit', async ($, e, next) => {
185    if (e.text.trim() !== '') {
186      await addNode($, {
187        id: `prompt-${crypto.randomUUID()}`,
188        kind: 'prompt',
189        label: 'You',
190        detail: firstLine(e.text),
191        status: 'done',
192      })
193    }
194
195    return next(e)
196  })
197
198  on('tool.call', async ($, e, next) => {
199    const { agentId } = e
200    const isAgent = AGENT_TOOLS.has(e.tool)
201    const subagentType = isAgent && 'subagent_type' in e && typeof e.subagent_type === 'string' ? e.subagent_type : 'agent'
202    const detail = detailOf(e)
203    const call = `${e.tool} ${e.tool_use_id}`
204
205    // A subagent's place is read once, from its own directory: the hook's,
206    // asked for inside the hook. It is read while the tool runs.
207    let placing = Promise.resolve(false)
208    if (agentId !== undefined && !placeReads.has(agentId)) {
209      placeReads.add(agentId)
210      placing = landed($, `reading where subagent ${agentId} works`, readRunPlace($, agentId, $.session.cwd()))
211    }
212
213    // Both are written while the tool runs, never holding it up.
214    const added = landed($, `writing the flow row of ${call}`, addNode($, {
215      id: e.tool_use_id,
216      kind: isAgent ? 'agent' : 'tool',
217      label: isAgent ? `⇢ ${subagentType}` : e.tool,
218      detail,
219      status: 'running',
220      ...(agentId === undefined ? {} : { agentId }),
221    }))
222    rowWrites.set(e.tool_use_id, added)
223    const latest: SubagentCall = {
224      id: e.tool_use_id,
225      tool: clip(e.tool, LABEL_MAX),
226      detail: clip(detail, TEXT_MAX),
227      status: 'running',
228    }
229    const counted =
230      agentId === undefined
231        ? Promise.resolve(false)
232        : landed($, `counting ${call} for the band`, update($, runs, list => called(list, agentId, latest)))
233
234    let status: FlowStatus = 'error'
235    try {
236      const ran = await next(e)
237      status = statusOf(ran)
238
239      return ran
240    } finally {
241      // Never throws, so what the tool answered or threw is what goes on. The
242      // entry stays until the row is written: a call that hands its work to
243      // the background (a Workflow) may see its agents spawn after it returns.
244      const isWritten = await added
245      if (rowWrites.get(e.tool_use_id) === added) rowWrites.delete(e.tool_use_id)
246      if (isWritten) {
247        await landed($, `settling the flow row of ${call}`, changeNode($, e.tool_use_id, one => ({ ...one, status })))
248      }
249      if (agentId !== undefined && (await counted)) {
250        await landed($, `settling ${call} for the band`, update($, runs, list => settled(list, agentId, e.tool_use_id, status)))
251      }
252      await placing
253      if (agentId === undefined && PLACE_TOOLS.has(e.tool) && (await read($, runs)).length > 0) {
254        await landed($, "rereading the session's place", readSessionPlace($, $.session.cwd()))
255      }
256    }
257  })
258
259  on('agent.spawn', async ($, e, next) => {
260    const spawned = await next(e)
261    const { agentId } = spawned
262    if (agentId === undefined) return spawned
263
264    const label = e.name ?? e.subagentType
265    const model = clip(spawned.model, LABEL_MAX)
266    const task = firstLine(e.description)
267    const linkFlow = async () => {
268      if (e.tool_use_id === '') return
269      await rowWrites.get(e.tool_use_id)
270      await update($, nodes, list => withSpawn(list, { toolUseId: e.tool_use_id, agentId, label, model, task }))
271    }
272    const startRun = async () => {
273      // The run starts measured against the model's own window when the
274      // session's cannot be read.
275      let session: Awaited<ReturnType<typeof readSessionWindow>> | null = null
276      await landed($, "reading the session's context window", readSessionWindow($).then(found => (session = found)))
277      const at = await $.clock.now()
278      await update($, runs, list =>
279        started(list, {
280          agentId,
281          label: clip(label, LABEL_MAX),
282          model,
283          task: clip(task, TEXT_MAX),
284          status: 'running',
285          startedAt: at,
286          calls: 0,
287          window: windowOf(spawned.model, session),
288        }),
289      )
290      await update($, now, () => at)
291    }
292    await landed($, `linking subagent ${agentId} in the flow`, linkFlow())
293    await landed($, `starting subagent ${agentId} in the band`, startRun())
294    // The session may have changed branch since the band last showed.
295    if (e.parentAgentId === undefined) {
296      await landed($, "rereading the session's place", readSessionPlace($, $.session.cwd()))
297    }
298
299    return spawned
300  })
301
302  // Each response a subagent's loop is answered: how full its window was.
303  on('turn.step', async function* ($, e, next) {
304    const stepped = yield* next(e)
305    const { agentId } = e
306    const { usage } = stepped
307    if (agentId !== undefined && usage !== null) {
308      await landed($, `measuring subagent ${agentId}'s context`, update($, runs, list => measured(list, agentId, usage)))
309    }
310
311    return stepped
312  })
313
314  on('turn.complete', async ($, e, next) => {
315    const done = await next(e)
316    const { agentId } = e
317    if (agentId !== undefined) {
318      // A subagent's turn ends its run, and a workflow agent's own flow row.
319      const status: RunStatus = e.reason === 'answer' ? 'done' : 'error'
320      const endRun = async () => {
321        const at = await $.clock.now()
322        await update($, runs, list => ended(list, agentId, status, at))
323        await update($, now, () => at)
324      }
325      await landed($, `settling subagent ${agentId} in the flow`, changeNode($, `agent-${agentId}`, one => ({ ...one, status })))
326      await landed($, `ending subagent ${agentId} in the band`, endRun())
327    } else {
328      const isAnswer = e.reason === 'answer'
329      const why = e.reason === 'refusal' ? (e.refusal.explanation ?? e.refusal.category ?? '') : ''
330      const parts = [seconds(e.durationMs), ...(isAnswer ? [] : [e.reason, firstLine(why)]), firstLine(done.text)]
331      await addNode($, {
332        id: `answer-${e.turnId}`,
333        kind: 'answer',
334        label: 'Claude',
335        detail: parts.filter(part => part !== '').join(' · '),
336        status: isAnswer ? 'done' : 'error',
337      })
338    }
339
340    return done
341  })
342
343  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
344    flowView($.ui.resolve(e), await read($, nodes), e.props.bodyColumns),
345  )
346
347  // The session panel on top, the subagent band under it in the rows left.
348  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
349    if (e.props.hasSurvey) return next(e)
350    const ui = $.ui.resolve(e)
351    const panel = sessionView(ui, await read($, figures), e.props.bodyColumns)
352    const room = { ...e.props, maxRows: e.props.maxRows - (panel === undefined ? 0 : PANEL_ROWS) }
353    const band = (await read($, isBandHidden))
354      ? undefined
355      : bandView(ui, await read($, runs), await read($, now), await read($, place), room)
356    if (panel === undefined && band === undefined) return next(e)
357    const { Box } = ui
358
359    return (
360      <Box key="above-prompt" flexDirection="column">
361        {panel}
362        {band}
363      </Box>
364    )
365  })
366}
367
hooks/flow.tsx 141 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { FlowNode } from '../types'
4import { MARKS, clip, modelColor, modelName } from './shared'
5
6// The /flow sidebar: the conversation one line per event. What is here is
7// pure: how the rows change, and how they are drawn. register.tsx wires it
8// to the events.
9
10// 400 rows of a label cut to LABEL_MAX, a detail cut to DETAIL_MAX and their
11// few marks and indent (about 175 characters) stay well inside the 100,000
12// characters a drawn tree keeps, so the newest rows are never cut.
13const KEEP = 400
14const LABEL_MAX = 48
15const DETAIL_MAX = 120
16
17export const seconds = (ms: number) => `${(ms / 1000).toFixed(1)}s`
18
19const node = (fields: FlowNode): FlowNode => ({
20  ...fields,
21  label: clip(fields.label, LABEL_MAX),
22  detail: clip(fields.detail, DETAIL_MAX),
23})
24
25export const appended = (list: readonly FlowNode[], fields: FlowNode) => [...list, node(fields)].slice(-KEEP)
26
27export const changed = (list: readonly FlowNode[], id: string, change: (one: FlowNode) => FlowNode) =>
28  list.map(one => (one.id === id ? change(one) : one))
29
30const linked = (one: FlowNode, agentId: string): FlowNode =>
31  (one.spawnedAgentIds ?? []).includes(agentId) ? one : { ...one, spawnedAgentIds: [...(one.spawnedAgentIds ?? []), agentId] }
32
33// An Agent call's row stands for its one subagent, so it shows that one's model.
34const linkedAgent = (one: FlowNode, spawn: Spawn): FlowNode => ({ ...linked(one, spawn.agentId), model: spawn.model })
35
36export interface Spawn {
37  /** The call that started the subagent: an Agent call, or a Workflow call. */
38  toolUseId: string
39  agentId: string
40  label: string
41  /** The model the subagent runs on. */
42  model: string
43  task: string
44}
45
46// Links the row of the call that started a subagent to that subagent, so the
47// agent's own calls are drawn under it. An Agent call's row stands for its
48// one subagent. A Workflow call's agents all share its id, so each gets a row
49// of its own under the call, linked through the run's id.
50export const withSpawn = (list: readonly FlowNode[], spawn: Spawn): FlowNode[] => {
51  const parent = list.find(one => one.id === spawn.toolUseId)
52  if (parent === undefined) return [...list]
53  if (parent.kind === 'agent') return list.map(one => (one === parent ? linkedAgent(one, spawn) : one))
54
55  const run = `run:${spawn.toolUseId}`
56  const agentRow: FlowNode = {
57    id: `agent-${spawn.agentId}`,
58    kind: 'agent',
59    label: `⇢ ${spawn.label}`,
60    detail: spawn.task,
61    status: 'running',
62    agentId: run,
63    spawnedAgentIds: [spawn.agentId],
64    model: spawn.model,
65  }
66
67  return appended(
68    list.map(one => (one === parent ? linked(one, run) : one)),
69    agentRow,
70  )
71}
72
73type Table = Pick<Elements[RenderSurface], 'Box' | 'Text'>
74
75export const flowView = ({ Box, Text }: Table, list: readonly FlowNode[], bodyColumns: number) => {
76  if (list.length === 0) {
77    return (
78      <Box flexDirection="column">
79        <Text dimColor>No conversation yet.</Text>
80      </Box>
81    )
82  }
83
84  // A subagent's calls are drawn under the row of the Agent call, or the
85  // workflow agent's row, that stands for it; a call whose row is gone is
86  // drawn nested, in place.
87  const spawned = new Set(list.flatMap(one => one.spawnedAgentIds ?? []))
88  const children = new Map<string, FlowNode[]>()
89  for (const one of list) {
90    if (one.agentId !== undefined && spawned.has(one.agentId)) {
91      children.set(one.agentId, [...(children.get(one.agentId) ?? []), one])
92    }
93  }
94
95  const row = (one: FlowNode, depth: number) => {
96    if (one.kind === 'prompt') {
97      return (
98        <Text wrap="truncate-end">
99          <Text color="suggestion">● </Text>
100          <Text bold>{one.label}</Text> {one.detail}
101        </Text>
102      )
103    }
104    if (one.kind === 'answer') {
105      const mark = one.status === 'done' ? { glyph: '◆', color: MARKS.done.color } : MARKS.error
106
107      return (
108        <Text wrap="truncate-end">
109          <Text color={mark.color}>└ {mark.glyph} </Text>
110          <Text bold color="claude">{one.label}</Text> <Text dimColor>{one.detail}</Text>
111        </Text>
112      )
113    }
114    const mark = MARKS[one.status]
115
116    return (
117      <Text wrap="truncate-end">
118        <Text dimColor>{'│ '.repeat(depth)}├ </Text>
119        <Text color={mark.color}>{mark.glyph} </Text>
120        <Text bold={one.kind === 'agent'}>{one.label}</Text>
121        {one.model === undefined ? null : <Text color={modelColor(one.model)}> ({modelName(one.model)})</Text>} <Text dimColor>{one.detail}</Text>
122      </Text>
123    )
124  }
125
126  const rows = (one: FlowNode, depth: number): ReturnType<typeof row>[] => [
127    row(one, depth),
128    ...(one.spawnedAgentIds ?? [])
129      .flatMap(agentId => children.get(agentId) ?? [])
130      .flatMap(child => rows(child, depth + 1)),
131  ]
132
133  const roots = list.filter(one => one.agentId === undefined || !spawned.has(one.agentId))
134
135  return (
136    <Box flexDirection="column" width={bodyColumns}>
137      {roots.flatMap(one => rows(one, one.agentId === undefined ? 0 : 1))}
138    </Box>
139  )
140}
141
hooks/session.tsx 97 lines
1import type { Elements, RenderSurface, SessionRateLimit, SessionUsage } from 'claude-code'
2
3import type { SessionGauge, SessionLimit } from '../types'
4import { gauge } from './gauge'
5import { modelColor, modelName } from './shared'
6
7// The panel at the top of the band above the prompt: the session's model, how
8// full its context window is, and how much of its rate limits is used and
9// when each resets. What is
10// here is pure; register.tsx reads the figures and draws it.
11
12// The panel's rows: its one, and the frame's top and bottom borders.
13export const PANEL_ROWS = 3
14const TITLE = 'Session'
15
16const MINUTE_MS = 60_000
17
18// The time left, to the minute and rounded up, so it reads 1m until the reset
19// itself: `42m`, `2h 10m`, or `3d 4h` from a day.
20export const until = (ms: number) => {
21  const minutes = Math.ceil(ms / MINUTE_MS)
22  const hours = Math.floor(minutes / 60)
23  if (hours === 0) return `${minutes}m`
24  if (hours < 24) return `${hours}h ${minutes % 60}m`
25
26  return `${Math.floor(hours / 24)}d ${hours % 24}h`
27}
28
29// The window of `kind`, its reset counted from `at`, the engine's clock.
30const limitOf = (limits: readonly SessionRateLimit[], kind: string, at: number): SessionLimit | undefined => {
31  const limit = limits.find(one => one.kind === kind)
32  if (limit === undefined) return undefined
33  const percent = Math.max(0, Math.min(100, Math.floor(limit.percentUsed)))
34  const left = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) - at
35
36  return left > 0 ? { percent, resetsIn: until(left) } : { percent }
37}
38
39// The figures from the session's model and `$.session.usage()`'s answer, at
40// `at` on the engine's clock.
41export const gaugeOf = (model: string, usage: SessionUsage, at: number): SessionGauge => {
42  const fiveHour = limitOf(usage.rateLimits, 'five_hour', at)
43  const week = limitOf(usage.rateLimits, 'seven_day', at)
44
45  return {
46    model,
47    ...(usage.context.percent === undefined ? {} : { contextPercent: usage.context.percent }),
48    ...(fiveHour === undefined ? {} : { fiveHour }),
49    ...(week === undefined ? {} : { week }),
50  }
51}
52
53const sameLimit = (one: SessionLimit | undefined, other: SessionLimit | undefined) =>
54  one?.percent === other?.percent && one?.resetsIn === other?.resetsIn
55
56export const sameGauge = (one: SessionGauge | null, other: SessionGauge) =>
57  one !== null &&
58  one.model === other.model &&
59  one.contextPercent === other.contextPercent &&
60  sameLimit(one.fiveHour, other.fiveHour) &&
61  sameLimit(one.week, other.week)
62
63// The panel's tree, or undefined before the figures are first read. A figure
64// the session has none for is left out.
65export const sessionView = (
66  { Box, Text }: Pick<Elements[RenderSurface], 'Box' | 'Text'>,
67  figures: SessionGauge | null,
68  bodyColumns: number,
69) => {
70  if (figures === null) return undefined
71  const item = (label: string, percent: number | undefined, resetsIn?: string) =>
72    percent === undefined ? null : (
73      <Text>
74        {'  '}
75        {label} {gauge({ Text }, percent)}
76        {resetsIn === undefined ? null : <Text dimColor>{` ↻ ${resetsIn}`}</Text>}
77      </Text>
78    )
79
80  return (
81    <Box key="session" flexDirection="column" width={bodyColumns} borderStyle="round" borderDimColor paddingX={1}>
82      <Box key="session-title" position="absolute" top={-1} left={1}>
83        <Text>
84          {' '}
85          <Text bold>{TITLE}</Text>{' '}
86        </Text>
87      </Box>
88      <Text wrap="truncate-end">
89        <Text color="inverseText" backgroundColor={modelColor(figures.model)}>{` ${modelName(figures.model)} `}</Text>
90        {item('Context', figures.contextPercent)}
91        {item('5h', figures.fiveHour?.percent, figures.fiveHour?.resetsIn)}
92        {item('Week', figures.week?.percent, figures.week?.resetsIn)}
93      </Text>
94    </Box>
95  )
96}
97
hooks/shared.ts 65 lines
1import type { Color, ToolCallResult } from 'claude-code'
2
3import type { FlowStatus } from '../types'
4
5// The argument that best names what a tool call is about, in order of preference.
6export const DETAIL_ARGS = ['description', 'file_path', 'notebook_path', 'path', 'pattern', 'command', 'url', 'query', 'skill', 'prompt']
7export const AGENT_TOOLS = new Set(['Agent', 'Task'])
8
9export const MARKS = {
10  running: { glyph: '…', color: 'warning' },
11  done: { glyph: '✓', color: 'success' },
12  error: { glyph: '✗', color: 'error' },
13  denied: { glyph: '⊘', color: 'error' },
14} as const satisfies Record<FlowStatus, { glyph: string; color: Color }>
15
16// Each model family's colour: theme keys, so it follows the theme, and
17// none of the marks' status colours. A model of no family here is grey.
18const MODEL_COLORS = [
19  { family: 'opus', color: 'claude' },
20  { family: 'sonnet', color: 'suggestion' },
21  { family: 'haiku', color: 'merged' },
22  { family: 'fable', color: 'planMode' },
23] as const satisfies readonly { family: string; color: Color }[]
24
25export const modelColor = (model: string): Color =>
26  MODEL_COLORS.find(({ family }) => model.includes(family))?.color ?? 'inactive'
27
28// A model is named without the `claude-` every model id starts with.
29export const modelName = (model: string) => model.replace(/^claude-/, '')
30
31const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
32
33// Cut by grapheme, so a cut never splits an emoji, a flag or a ZWJ sequence;
34// it reads no further than the cut, however long the line.
35export const clip = (text: string, max: number) => {
36  // A string's UTF-16 length is never less than its grapheme count.
37  if (text.length <= max) return text
38  const kept: string[] = []
39  for (const { segment } of graphemes.segment(text)) {
40    if (kept.length === max) return `${kept.slice(0, max - 1).join('')}…`
41    kept.push(segment)
42  }
43
44  return text
45}
46
47export const firstLine = (text: string) => text.trim().split('\n', 1)[0] ?? ''
48
49export const detailOf = (args: Readonly<Record<string, unknown>>) => {
50  for (const name of DETAIL_ARGS) {
51    const value = args[name]
52    if (typeof value === 'string' && value.trim() !== '') return firstLine(value)
53  }
54
55  return ''
56}
57
58// A deny from a plugin's hook is the only refusal a tool.call hook can tell
59// apart: the person's or a settings hook's arrives as an errored result.
60export const statusOf = (ran: ToolCallResult): FlowStatus => {
61  if (ran.deny !== undefined) return 'denied'
62
63  return ran.isError ? 'error' : 'done'
64}
65
hooks/subagents.tsx 181 lines
1import type { Color, Elements, ModelUsage, RenderSurface } from 'claude-code'
2
3import type { FlowStatus, Place, RunStatus, SubagentCall, SubagentRun } from '../types'
4import { gauge, percentOf } from './gauge'
5import { MARKS, clip, modelColor, modelName } from './shared'
6
7// The band above the prompt: what each subagent is doing, while it runs and
8// for a short while after. What is here is pure: how the runs change, and how
9// they are drawn. register.tsx wires it to the events.
10
11// A finished subagent stays this long, so a quick one is not missed.
12const LINGER_MS = 30_000
13const MAX_ENTRIES = 6
14// The frame's top and bottom borders.
15const FRAME_ROWS = 2
16export const LABEL_MAX = 48
17export const TEXT_MAX = 120
18
19// Every current model's window is 1M tokens. The generations before Claude
20// 4.6 had 200K, but for a 1M variant, which the engine marks `[1m]`.
21const WINDOW = 1_000_000
22const OLDER_WINDOW = 200_000
23const OLDER_MODEL = /^claude-(3-|(opus|sonnet|haiku)-4(-[015])?(-\d{8})?$)/
24
25const RUN_MARKS = {
26  running: { glyph: '⇢', color: 'claude' },
27  done: MARKS.done,
28  error: MARKS.error,
29} as const satisfies Record<RunStatus, { glyph: string; color: Color }>
30
31export const isShown =(run: SubagentRun, at: number) => run.endedAt === undefined || at - run.endedAt < LINGER_MS
32
33const changed = (list: readonly SubagentRun[], agentId: string, change: (run: SubagentRun) => SubagentRun) =>
34  list.map(run => (run.agentId === agentId ? change(run) : run))
35
36export const started = (list: readonly SubagentRun[], run: SubagentRun) => [
37  ...list.filter(one => one.agentId !== run.agentId),
38  run,
39]
40
41export const called = (list: readonly SubagentRun[], agentId: string, latest: SubagentCall) =>
42  changed(list, agentId, run => ({ ...run, calls: run.calls + 1, latest }))
43
44export const settled = (list: readonly SubagentRun[], agentId: string, callId: string, status: FlowStatus) =>
45  changed(list, agentId, run => (run.latest?.id === callId ? { ...run, latest: { ...run.latest, status } } : run))
46
47export const ended = (list: readonly SubagentRun[], agentId: string, status: RunStatus, at: number) =>
48  changed(list, agentId, run => (run.endedAt === undefined ? { ...run, status, endedAt: at } : run))
49
50// The input side of a response's usage: what the window held when it answered,
51// as the status line counts it.
52export const measured = (list: readonly SubagentRun[], agentId: string, usage: ModelUsage) =>
53  changed(list, agentId, run => ({
54    ...run,
55    tokens: usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens,
56  }))
57
58// The window a subagent on `model` is measured against: the session's own when
59// it runs the session's model, so a window the session was given holds;
60// otherwise the model's.
61export const windowOf = (model: string, session: { model: string; window: number } | null) => {
62  if (session !== null && session.model === model) return session.window
63  if (model.includes('[1m]')) return WINDOW
64
65  return OLDER_MODEL.test(model) ? OLDER_WINDOW : WINDOW
66}
67
68export const placed = (list: readonly SubagentRun[], agentId: string, place: Place) =>
69  changed(list, agentId, run => ({ ...run, place }))
70
71// A place from git's answers: the worktree's top directory, absolute, and the
72// branch, or the short hash when HEAD is detached.
73export const placeOf = (top: string, branch: string): Place => ({
74  worktree: clip(top.trim().split(/[\\/]/).filter(part => part !== '').at(-1) ?? top.trim(), LABEL_MAX),
75  branch: clip(branch.trim(), LABEL_MAX),
76})
77
78const samePlace = (one: Place, other: Place) => one.worktree === other.worktree && one.branch === other.branch
79
80const placeText = ({ worktree, branch }: Place) => `${worktree} ⎇ ${branch}`
81
82const duration = (ms: number) => {
83  const seconds = Math.max(0, Math.floor(ms / 1000))
84
85  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
86}
87
88const callCount = (calls: number) => `${calls} ${calls === 1 ? 'call' : 'calls'}`
89
90// Rows an entry takes: its own, its task's, and its latest call's while it runs.
91const rowsOf = (run: SubagentRun) =>
92  1 + (run.task === '' ? 0 : 1) + (run.status === 'running' && run.latest !== undefined ? 1 : 0)
93
94type Table = Pick<Elements[RenderSurface], 'Box' | 'Text'>
95
96const TITLE = 'Subagents'
97// The title's spaces either side, and the frame's corner, border and padding
98// either side of it.
99const TITLE_CHROME = 2 + 4
100
101// The band's tree, or undefined when it has nothing to show. `place` is the
102// session's: the title shows it, and an entry shows its own only where it
103// differs, as in a worktree of its own.
104export const bandView = (
105  { Box, Text }: Table,
106  runs: readonly SubagentRun[],
107  at: number,
108  place: Place | null,
109  room: { maxRows: number; bodyColumns: number },
110) => {
111  const visible = runs.filter(run => isShown(run, at))
112  if (visible.length === 0) return undefined
113
114  // Running subagents first, each group in the order they started; then as
115  // many entries as the frame's rows allow, a row kept for the rest's count.
116  const maxRows = room.maxRows - FRAME_ROWS
117  const ordered = [
118    ...visible.filter(run => run.status === 'running'),
119    ...visible.filter(run => run.status !== 'running'),
120  ]
121  const shown: SubagentRun[] = []
122  let rows = 0
123  for (const [index, run] of ordered.entries()) {
124    const reserve = index < ordered.length - 1 ? 1 : 0
125    if (shown.length === MAX_ENTRIES || rows + rowsOf(run) + reserve > maxRows) break
126    shown.push(run)
127    rows += rowsOf(run)
128  }
129  const more = ordered.length - shown.length
130
131  const entry = (run: SubagentRun) => {
132    const mark = RUN_MARKS[run.status]
133    const time = duration((run.endedAt ?? at) - run.startedAt)
134    const latest = run.status === 'running' ? run.latest : undefined
135    const away = run.place !== undefined && (place === null || !samePlace(run.place, place)) ? run.place : undefined
136    const context = () =>
137      run.tokens === undefined ? null : <Text> {gauge({ Text }, percentOf(run.tokens, run.window))}</Text>
138
139    return [
140      <Text wrap="truncate-end">
141        <Text color="inverseText" backgroundColor={modelColor(run.model)}>{` ${modelName(run.model)} `}</Text> <Text color={mark.color}>{mark.glyph} </Text>
142        <Text bold>{run.label}</Text>
143        {context()}
144        {away === undefined ? null : <Text>{` ${placeText(away)}`}</Text>} <Text dimColor>{time} · {callCount(run.calls)}</Text>
145      </Text>,
146      run.task === '' ? null : (
147        <Box paddingLeft={3}>
148          <Text wrap="truncate-end">{run.task}</Text>
149        </Box>
150      ),
151      latest === undefined ? null : (
152        <Box paddingLeft={3}>
153          <Text wrap="truncate-end">
154            <Text color={MARKS[latest.status].color}>{MARKS[latest.status].glyph} </Text>
155            {latest.tool} <Text dimColor>{latest.detail}</Text>
156          </Text>
157        </Box>
158      ),
159    ]
160  }
161
162  // The title sits on the frame's top row, over the border, taking no row; the
163  // session's place is cut to what the border leaves.
164  const titleRoom = Math.max(0, room.bodyColumns - TITLE_CHROME - TITLE.length)
165  const where = place === null || titleRoom === 0 ? '' : clip(` · ${placeText(place)}`, titleRoom)
166
167  return (
168    <Box key="subagents" flexDirection="column" width={room.bodyColumns} borderStyle="round" borderDimColor paddingX={1}>
169      <Box key="subagents-title" position="absolute" top={-1} left={1}>
170        <Text>
171          {' '}
172          <Text bold>{TITLE}</Text>
173          {where}{' '}
174        </Text>
175      </Box>
176      {shown.map(entry)}
177      {more > 0 ? <Text dimColor>+{more} more</Text> : null}
178    </Box>
179  )
180}
181
hooks/gauge.tsx 33 lines
1import type { Color, Elements, RenderSurface } from 'claude-code'
2
3// A fill drawn as a bar of cells and its percentage, coloured by how full it
4// is: the subagent band's context bars and the session panel's.
5
6const BAR_CELLS = 10
7// The fill at which a gauge turns a warning, then an error.
8const WARNING_PERCENT = 50
9const ERROR_PERCENT = 80
10
11// A whole percentage, 0 to 100.
12export const percentOf = (part: number, whole: number) => Math.max(0, Math.min(100, Math.floor((part / whole) * 100)))
13
14const levelOf = (percent: number): Color => {
15  if (percent >= ERROR_PERCENT) return 'error'
16
17  return percent >= WARNING_PERCENT ? 'warning' : 'success'
18}
19
20// `percent` is a whole percentage, 0 to 100.
21export const gauge = ({ Text }: Pick<Elements[RenderSurface], 'Text'>, percent: number) => {
22  const color = levelOf(percent)
23  const used = Math.round(percent / (100 / BAR_CELLS))
24
25  return (
26    <Text>
27      {used === 0 ? null : <Text color={color}>{'█'.repeat(used)}</Text>}
28      {used === BAR_CELLS ? null : <Text dimColor>{'░'.repeat(BAR_CELLS - used)}</Text>}{' '}
29      <Text color={color}>{percent}%</Text>
30    </Text>
31  )
32}
33
types/index.d.ts 90 lines
1export type FlowKind = 'prompt' | 'tool' | 'agent' | 'answer'
2export type FlowStatus = 'running' | 'done' | 'error' | 'denied'
3
4export interface FlowNode {
5  id: string
6  kind: FlowKind
7  label: string
8  detail: string
9  status: FlowStatus
10  /** The subagent the call ran in; absent on the main conversation's. */
11  agentId?: string
12  /** On the row of an Agent or Workflow call: the subagents it started. */
13  spawnedAgentIds?: string[]
14  /** On a subagent's row: the model it runs on. */
15  model?: string
16}
17
18export type RunStatus = 'running' | 'done' | 'error'
19
20/** Where a loop works: its git worktree's directory name and its branch. */
21export interface Place {
22  worktree: string
23  /** The branch checked out, or the commit's short hash when HEAD is detached. */
24  branch: string
25}
26
27/** A subagent's most recent tool call. */
28export interface SubagentCall {
29  id: string
30  tool: string
31  detail: string
32  status: FlowStatus
33}
34
35/** One subagent, from its spawn until its turn ends. */
36export interface SubagentRun {
37  agentId: string
38  /** Its type, or its name for a workflow agent. */
39  label: string
40  /** The model it runs on. */
41  model: string
42  task: string
43  status: RunStatus
44  /** Milliseconds since the epoch, on the engine's clock. */
45  startedAt: number
46  endedAt?: number
47  calls: number
48  latest?: SubagentCall
49  /** Where it works, read on its first tool call; absent until then, or outside git. */
50  place?: Place
51  /** The context window its fill is measured against, in tokens. */
52  window: number
53  /** Input tokens its last response was answered over; absent before its first. */
54  tokens?: number
55}
56
57/** A rate-limit window as the session panel shows it. */
58export interface SessionLimit {
59  /** How much of it is used, a whole percentage. */
60  percent: number
61  /** How long until it resets, as shown (`2h 10m`); absent when the engine gives no time, or it has passed. */
62  resetsIn?: string
63}
64
65/** The session's figures the panel above the band shows, as the status line has them. */
66export interface SessionGauge {
67  model: string
68  /** How full the context window is, a whole percentage; absent before the first response. */
69  contextPercent?: number
70  /** The five-hour and the weekly rate limit; absent off a subscription. */
71  fiveHour?: SessionLimit
72  week?: SessionLimit
73}
74
75declare module 'claude-code' {
76  interface PluginState {
77    alandovskis: {
78      flowNodes: FlowNode[]
79      subagentRuns: SubagentRun[]
80      /** The engine's clock as of the band's last tick, for elapsed times. */
81      subagentsNow: number
82      subagentsHidden: boolean
83      /** Where the session works, for the band's title; null outside git. */
84      subagentsPlace: Place | null
85      /** null until the session's figures are first read. */
86      sessionGauge: SessionGauge | null
87    }
88  }
89}
90