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

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.
| Skill | Purpose |
|---|---|
| brd, prd, brd-to-prd | Requirements documents, published to Confluence |
| plan-prd, implement-prd | PRD → SDD/test plan/implementation plan → test-first implementation |
| prd-to-jira | Jira epic/stories from a PRD |
| issue-tracker | Jira is the issue tracker, used through the Atlassian MCP |
| review-code | Code review through maintainability, efficiency, correctness, observability, scalability and security lenses, plus UX (accessibility, responsive, theming, via impeccable audit) for web UI |
| create-pr | Review with review-code, fix, open the PR, watch CI, merge |
| bilingual-apps | Apps are bilingual EN/FR: externalised strings, locale formatting, parity checks |
| web-stack | Web apps use Rust / WebAssembly |
| ci | Use GitHub Actions for CI/CD |
| design-doc | Write a design doc (objective, goals, SLOs, alternatives, open issues), reviewed with review-design-doc and fixed |
| review-design-doc | Review a design doc draft through clarity, conciseness and consistency subagents, leaving the text unedited |
| design-doc-confluence | Reverse-engineer design docs in Confluence wiki markup |
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 │
╰───────────────────────────────────────────────────────────────────────────────────────────╯
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./model switch shows within a second./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 │
╰───────────────────────────────────────────────────────────────────────────────────────────╯
Bash, EnterWorktree and ExitWorktree calls while the band shows.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.⇢ 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.+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.
/plugin marketplace add alandovskis/ai-plugins then /plugin install alandovskis@alandovskis. For local development, use claude --plugin-dir ./alandovskis..codex-plugin/plugin.json points at ./skills/.gemini extensions install ./alandovskis (skills auto-discovered from skills/).skills/* into the runtime's skills directory.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.
hooks/register.tsx 367 lines1import { 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}
367hooks/flow.tsx 141 lines1import 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}
141hooks/session.tsx 97 lines1import 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}
97hooks/shared.ts 65 lines1import 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}
65hooks/subagents.tsx 181 lines1import 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}
181hooks/gauge.tsx 33 lines1import 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}
33types/index.d.ts 90 lines1export 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