A calm side pane: model, effort, first prompt, uptime, cache TTL, sub-agents with their history, and the session's reasoning steps

A calm side pane for a Claude Code session: what runs, since when, how warm the prompt cache still is, and what every sub-agent was asked and did.
Session panel tabs and topic title
╭──────╮ ╭──────╮ ╭────────╮ ╭──────╮ ╭────────────╮ ╭────────────╮ ╭─────╮
│ Main │ │ Misc │ │ Config │ │ Help │ │ ○ Opus 5.5 │ │ ○ high │ │ ✕ │
╰──────╯ ╰──────╯ ╰────────╯ ╰──────╯ ╰────────────╯ ╰────────────╯ ╰─────╯
⌛ running 1h12m ⏳ cache 54m context 78.2k/217k (250k − 33k) 36%
5h 30% →60% 🔄 2h30m 7d 71% out in 1d21h 🔄 2d07h
━━━━━━━┃──────────────────── ━━━━━━━━━━━━━━━━━━┃━━╸──────
Issue ◉ Publish the session-panel mod
Git
⎇ claude-plugins #7 Publish the session-panel mod draft
▣ session-panel-fixes
⎇ dotfiles no pull request yet
main checkout
Documents
Agent handovers
21h57-session-panel-0-8-0
Reports
26-10-06-claude-plugins-ci-ok-merge-wrap-up
Handover
loaded session-panel tracker/PR corner (142 lines · 2026-10-07 09:32)
✋ triggers at 185k · now 92k
Last prompt 14:32 (6 previous prompts)
Strip the XML tags and show only the last prompt…
── Misc ──
Steps ▸ 37 earlier · 2 refused · 1 repeated
✓ Sync, validate, commit, push, update PR
✗ Add file-tree tests and run them
failed: AssertionError: expect(received).toEqual()
● Searching engine types for link support
Sub-agents · 1 running · 1 done
▸ Find hooks
sub-agent · 42s · Grep: turn.step
▸ Review diff
background · 3m 10s · done
Git diff
claude-plugins ⎇ feat/session-panel +412 −3
├ plugins/session-panel/
│ ├ + README.md +58
│ └ hooks/
│ └ ~ register.tsx +40 −6
└ ~ README.md +1 −1
Git History
○ session-panel: tracker corner
3f2a1bc · 2 min ago · local
● session-panel: steps as an audit trail, last prompt with its history
2ad70a1 · 1 hour ago · pushed
session_title tool, once the first task is clear and again only when what the session is about changes significantly; once its turn ends, the title also goes to /rename, which names the session in /resume and the terminal tab. A session started from a handover takes the handover's title (its front matter summary, else its # Handover: heading) as soon as it has loaded, renamed at once, until the agent names it. A session that has no title of its own yet (Claude Code carries the previous name through /clear and a plugin reload) has its first typed prompt carry a one-line reminder to set one. Session until it has a title. Main shows the session at a glance (everything down to Documents, Handover and Last prompt, in that order); Misc shows the steps, the sub-agents and the git diff; Config lists each value the pane reads (its own cacheTtl option, Claude Code's autoCompactWindow and plansDirectory, CC_TOKEN_LIMIT and CC_TOKEN_RESERVE, the handover plugin's handover_dir and token thresholds), as in effect, with what it does and its default; Help explains each item of the pane, its colours and states, one per line: an example as the pane draws it, then greyed what it shows or does (the title, the model and effort selectors and the tabs, self-evident, are left out). ✕, framed so it is easy to hit, closes the pane; /session-panel reopens it on Main./model shows, the effort /effort saved), the model in its colour (Catppuccin Frappé: Opus peach, Fable mauve, Sonnet blue, Haiku green), the effort in the colour /effort gives its level. Press one to open its choices, press a choice to switch (it runs /model or /effort).⌛) and the whole minutes left before the prompt cache expires (⏳), graded as in the status line (green down to half the TTL, yellow down to a fifth, then orange; red once expired). Beneath, the context counter: the tokens in the window against the auto-compact trigger the engine sets from your autoCompactWindow (the figures /context uses), followed in brackets by how it is reckoned, the window less Claude Code's margin (217k (250k − 33k)); where the engine gives none, the status line's reckoning (CC_TOKEN_LIMIT, else autoCompactWindow, else 200k, less CC_TOKEN_RESERVE, 33k by default). The grading follows that trigger, whatever its size: green up to half of it, yellow to three quarters, orange to nine tenths, then red, and ⚠ compacting once reached.● 5h 30% │ ● 7d 71%, each window's use with a dot in its verdict's colour, then 🔽; press it (anywhere but its dots) to unfold, beneath the top lines, the 5-hour and 7-day windows as bars that fill with use, read as the status line reads them: the ┃ tick marks where even spending would be by now, fill up to it is green and fill past it takes the verdict's colour and breathes once a second; the verdict extrapolates the average burn to the reset (→N% green under 90%, yellow from 90%, out in D red when the limit comes first), judged once a tenth of the window has passed; 🔄 is the time to the reset. The two bars sit side by side, half the width each; press the pill again (its arrow now 🔼) to fold them. Absent off a subscription.Issue names on its line the tracker issue the session is about, with its platform's icon (GitHub ◉, Linear ◐) and its title, linked, or no issue. Git lists, a blank line apart, each repository the session worked on (its starting folder, and any it edited in) in the order it first touched them, no repository yet outside any: ⎇ name, then its pull request as #N title, linked, in GitHub's colours (lavender draft, a lighter tone than GitHub's grey so it reads, green open, red closed, purple merged) with its state, or no pull request yet; beneath, the linked worktree the session last edited in (▣ name), or main checkout. A pull request worked on in a repository not checked out here gets a block of its own, without the worktree line. The issue is the one your prompt names, else the one the session opened (gh issue create, a Linear tool's create), else one it worked on; the same for each repository's pull request. A bare #N in a prompt (a numbered list's #1, #2) counts below any reference the session worked on, and the pane reads the whole transcript again when it loads, so a plugin loaded mid-session finds the references made before it. GitHub items are read with gh api, the pull request's state again every minute. A Linear issue opens in the desktop app (linear://) where it is installed, else on linear.app; a bare KEY-N in a prompt counts once a Linear tool shows it, so an It's a Plan key is never taken for a Linear one.handover_dir, ~/Notes/claude/agent-handovers by default), then the plans (in Claude Code's plansDirectory, ~/.claude/plans by default), then any other Markdown file the agent wrote outside the project's repository, temporary and hidden folders, under its folder's name. A file counts once an editing tool wrote it, a shell command named it by an absolute or ~ path and it changed since the session began, or the handover plugin wrote it; sub-agents' included. Each shows its name without .md, linked in the terminal; on desktop, press it to open it. None written yet. until then.handover plugin's status line says (when the handover triggers, or that it is ready or writing), the handover this session loaded on a line of its own beneath the section's title (loaded none until one loads), a blank line on either side, and the one it wrote beneath the status, each by its title (front matter summary, else its # Handover: heading), linked to its file and followed, greyed, by its line count and last change ((142 lines · 2026-10-07 09:32)). Once the handover is written and the session has stopped, two buttons start the next session from it, the resume message the closing reply ended on shown beneath them: both copy it, run /clear, which has the handover plugin load the handover into the new session, wait until the plugin records the handover as loaded, then enter the message in the prompt box. 🚀 Start with this prompt (focused, so Enter alone starts it) then sends it; ✏️ Edit the prompt first leaves it there to edit and send yourself. When the handover does not load within its 3-minute wait, the message stays in the prompt box, with a toast. Without a resume message, only ✏️ Edit the prompt first shows, and it runs /clear alone. While no handover is under way, wherever the context stands, ⚡ Hand over now starts the wind-down at once (/handover:trigger): the work in progress and its sub-agents finish, then the handover is written. While a handover is under way the status line animates its phase: ◐ Winding down (the tasks in progress finish), ✎··· Writing the handover, then ● Ready for the next session.14:32, 24-hour) and the count of earlier prompts ((6 previous prompts)); press the count to see every prompt of the session, each numbered with its time (#2 · 14:32; no time for one read back after a resume), then ← Overview to come back.Edit register.tsx), printed in full, one after the other, and beneath it why it was refused or failed when it was. ↻ ×N marks the same action taken again within a few steps, a sign of a loop; ✗ a refused or failed call. The current step shows live, its latest whole sentence while the model thinks, then the call it runs until it returns, under the four latest finished steps, all in full; the earlier ones fold into the heading's line, which counts their refusals, failures and repeats, and unfolds with the reasoning (∴) of each.▴ Collapse at its foot closes it. Reopening the pane (/session-panel, a reload) starts on the overview, every sub-agent closed.HEAD as a tree (+ added, ~ modified, − deleted, lines added in green and removed in red), and below them, under Git History, full width, the commits made since the session began, by subject, ○ local or ● pushed; press a commit to read its body. Refreshed after each edit or Bash command.It is a mod: a plugin of function hooks, drawn by the engine (no shell script, no status line).
/plugin marketplace add FunDrivenDev/claude-plugins
/plugin install session-panel@fundrivendev
The pane opens at session start when the terminal is at least 144 columns wide; /session-panel opens it at any width. Docked beside a fullscreen transcript it opens 116 columns wide, unless a width was dragged or keyed since.
| Setting | Default | What it does |
|---|---|---|
cacheTtl | 1h | The prompt-cache lifetime the countdown starts from (1h or 5m), until a model switch reports the real one. In /config. |
options.json or its defaults (150k suggested, 185k trigger), not from CLAUDE_PLUGIN_OPTION_* overrides.claude plugin test plugins/session-panel runs tests/; just validate runs it with the validator. Once Claude Code has loaded the plugin, tsc -p plugins/session-panel type-checks it against the types the engine lays in .claude-plugin/types/ (ignored by git).
hooks/register.tsx 2162 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, ToolCallInput, ToolCallResult, UiCopyArgs } from 'claude-code'
3
4import type { Agent, Config, Doc, Entry, FileChange, Handover, HandoverFile, Info, Picker, Prompt, Quota, Repo, RepoChanges, Step, Tab, TrackedIssue, TrackedPr, Ttl } from '../types'
5
6import { LOG_FORMAT, parseLog, parseNumstat, parseStatus, treeRows } from './files'
7import { ARTIFACTS, HANDOVER_DIR, PLANS_DIR, artifactOf, type DocContext, docGroups, docOf, docPaths, tilde } from './documents'
8import { PR_COLOR, RANK, findRefs, linearOfResult, prState, refKey, refsOfGh, repoOfRemote, titleOfSlug } from './tracker'
9import type { GithubRef, LinearRef } from './tracker'
10
11const PANE = 'session-panel'
12const TITLE = 'Session'
13/** The dock's opening width in fullscreen; a width the person dragged or keyed wins. */
14const DOCK_COLUMNS = 116
15/** The colour an action without its own takes under the pointer: Catppuccin Frappé blue. */
16const ACTION = '#8caaee'
17const HISTORY_CAP = 400
18/** An open sub-agent's latest entries and the head of its task: the whole of either can pass the engine's 100,000 drawn characters. */
19const OPEN_HISTORY = 40
20const OPEN_PROMPT = 4000
21const STEPS_CAP = 300
22/** Finished steps shown while the list is folded, above the current one. */
23const DONE_SHOWN = 4
24
25const info = atom({ plugin: 'session-panel', key: 'info' } as const, {
26 model: null,
27 effort: null,
28 lastRequestAt: null,
29 ttl: null,
30} as Info)
31const agents = atom({ plugin: 'session-panel', key: 'agents' } as const, [] as Agent[])
32const steps = atom({ plugin: 'session-panel', key: 'steps' } as const, [] as Step[])
33const expanded = atom({ plugin: 'session-panel', key: 'expanded' } as const, null as string | null)
34const stepsOpen = atom({ plugin: 'session-panel', key: 'stepsOpen' } as const, false)
35const roots = atom({ plugin: 'session-panel', key: 'roots' } as const, [] as string[])
36const changes = atom({ plugin: 'session-panel', key: 'changes' } as const, [] as RepoChanges[])
37const UNTRACKED_COUNTED = 30
38const COMMITS_SHOWN = 8
39const EDITING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
40const prompts = atom({ plugin: 'session-panel', key: 'prompts' } as const, [] as (Prompt | string)[])
41const view = atom({ plugin: 'session-panel', key: 'view' } as const, 'overview' as 'overview' | 'prompts')
42/** The repositories the session worked on, in the order it first touched them. */
43const workedRepos = atom({ plugin: 'session-panel', key: 'repos' } as const, [] as Repo[])
44/** A repository as the Git section draws it; `isLocal` false for one known only by a pull request. */
45type RepoBlock = { key: string; name: string; pr: TrackedPr | null; worktree: string | null; isLocal: boolean }
46const picking = atom({ plugin: 'session-panel', key: 'picking' } as const, null as Picker)
47const tracker = atom({ plugin: 'session-panel', key: 'tracker' } as const, { issue: null, prs: [], mentioned: [] } as {
48 issue: TrackedIssue | null
49 prs: TrackedPr[]
50 mentioned: string[]
51})
52const handover = atom({ plugin: 'session-panel', key: 'handover' } as const, null as Handover | null)
53const documents = atom({ plugin: 'session-panel', key: 'documents' } as const, [] as Doc[])
54const config = atom({ plugin: 'session-panel', key: 'config' } as const, {
55 cacheTtl: '1h',
56 autoCompactWindow: null,
57 tokenLimit: null,
58 tokenReserve: null,
59 plansDirectory: null,
60 handoverDir: null,
61} as Config)
62/** The quota bars unfolded beneath the top lines, from the quota pill. */
63const quotasOpen = atom({ plugin: 'session-panel', key: 'quotasOpen' } as const, false)
64const tab = atom({ plugin: 'session-panel', key: 'tab' } as const, 'main' as Tab)
65/** The session's overall topic, as the main agent names it with the session_title tool. */
66const topic = atom({ plugin: 'session-panel', key: 'topic' } as const, null as string | null)
67/** The session whose title is set, and the one reminded to set it: a title carried over through /clear or a reload belongs to another. */
68const titled = atom({ plugin: 'session-panel', key: 'titled' } as const, { set: null, reminded: null } as { set: string | null; reminded: string | null })
69const WRITING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
70/** Where documents are told apart, for the handover and plans folders it was read with. */
71let docContext: { key: string; ctx: DocContext } | null = null
72/** Main-loop calls that returned, possibly before the step that made them ended. */
73const returned = new Set<string>()
74let hasLinearApp = false
75const home = atom({ plugin: 'session-panel', key: 'home' } as const, null as string | null)
76const LINEAR_ID = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g
77const ISSUE_ICON = { github: { glyph: '◉', color: '#3fb950' }, linear: { glyph: '◐', color: '#5e6ad2' } } as const
78
79/** The models the selector offers, each in its Catppuccin Frappé colour. */
80const MODELS = [
81 { id: 'claude-opus-5-5', color: '#ef9f76' },
82 { id: 'claude-fable-5-1', color: '#ca9ee6' },
83 { id: 'claude-sonnet-5-5', color: '#8caaee' },
84 { id: 'claude-haiku-4-5-20251001', color: '#a6d189' },
85] as const
86
87/** The effort scale, in the colours `/effort` gives each level (Claude Code's dark theme). */
88const EFFORTS = [
89 { level: 'low', color: '#ffc107' },
90 { level: 'medium', color: '#4eba65' },
91 { level: 'high', color: '#b1b9f9' },
92 { level: 'xhigh', color: '#af87ff' },
93 { level: 'max', color: '#eb5f57' },
94] as const
95
96export const modelColor = (id: string | null): string => {
97 const family = id ? /claude-([a-z]+)/.exec(id)?.[1] : undefined
98 return MODELS.find(m => family && m.id.startsWith(`claude-${family}`))?.color ?? '#a5adce'
99}
100
101export const effortColor = (level: string | null): string =>
102 EFFORTS.find(e => e.level === level)?.color ?? '#a5adce'
103
104export type Scheme = 'dark' | 'light'
105
106/**
107 * Each colour the pane draws, as written for a dark background, with its twin
108 * for a light one: Catppuccin Latte for Frappé; for the status line's bright
109 * grading, GitHub's and `/effort`'s colours, the nearest tone that reads on white.
110 */
111const LIGHT: Record<string, string> = {
112 '#a6d189': '#40a02b',
113 '#e5c890': '#9a6700',
114 '#e78284': '#d20f39',
115 '#ef9f76': '#fe640b',
116 '#8caaee': '#1e66f5',
117 '#ca9ee6': '#8839ef',
118 '#babbf1': '#7287fd',
119 '#81c8be': '#179299',
120 '#a5adce': '#6c6f85',
121 '#c6d0f5': '#4c4f69',
122 '#51576d': '#bcc0cc',
123 '#8a8a8a': '#7c7f93',
124 '#5fff00': '#40a02b',
125 '#ffff00': '#9a6700',
126 '#ffaf00': '#fe640b',
127 '#ff0000': '#d20f39',
128 '#ffc107': '#9a6700',
129 '#4eba65': '#40a02b',
130 '#b1b9f9': '#7287fd',
131 '#af87ff': '#8839ef',
132 '#eb5f57': '#d20f39',
133 '#3fb950': '#1a7f37',
134 '#f85149': '#cf222e',
135 '#a371f7': '#8250df',
136}
137
138const swap = (props: Record<string, unknown>): Record<string, unknown> =>
139 Object.fromEntries(Object.entries(props).map(([k, v]) => [k, typeof v === 'string' ? (LIGHT[v] ?? v) : v]))
140
141/** The pane's tree for the OS appearance: on a light one, every colour swapped for its twin (LIGHT). */
142export const recolor = <T,>(node: T, scheme: Scheme): T => {
143 if (scheme === 'dark' || node === null || typeof node !== 'object') return node
144 if (Array.isArray(node)) return node.map(child => recolor(child, scheme)) as T
145 const el = node as { props?: Record<string, unknown>; hover?: Record<string, unknown>; children?: unknown[] }
146 return {
147 ...el,
148 ...(el.props && { props: swap(el.props) }),
149 ...(el.hover && { hover: swap(el.hover) }),
150 ...(el.children && { children: el.children.map(child => recolor(child, scheme)) }),
151 } as T
152}
153
154/** macOS's appearance: `AppleInterfaceStyle` is `Dark` in dark mode and unset in light mode; dark elsewhere. */
155async function readScheme($: EngineInterface): Promise<Scheme> {
156 const r = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'])
157 if (r.exitCode === 0) return 'dark'
158 return /does not exist/.test(r.stderr) ? 'light' : 'dark'
159}
160
161const effortLabel = (level: string | null): string => level ?? 'default'
162
163/** An alias `/model` takes (`opus`, `sonnet[1m]`) → the id the selector offers; an id as given. */
164export const modelId = (model: string): string => {
165 if (model.includes('claude-')) return model
166 const family = /^[a-z]+/.exec(model.toLowerCase())?.[0]
167 return MODELS.find(m => family && m.id.startsWith(`claude-${family}-`))?.id ?? model
168}
169
170/** `claude-opus-5-5[1m]` → `Opus 5.5`; anything else as given. */
171export const prettyModel = (id: string): string => {
172 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/.exec(id)
173 if (!m) return id
174 const name = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1)
175 return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
176}
177
178export const duration = (ms: number): string => {
179 const s = Math.max(0, Math.floor(ms / 1000))
180 const h = Math.floor(s / 3600)
181 const m = Math.floor((s % 3600) / 60)
182 const sec = s % 60
183 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
184 if (m > 0) return `${m}m${String(sec).padStart(2, '0')}s`
185 return `${sec}s`
186}
187
188/** Prompt-cache time left, as the status line counts it: whole minutes, `<1m` in the last one. */
189export const minutesLeft = (ms: number): string => (ms < 60_000 ? '<1m' : `${Math.floor(ms / 60_000)}m`)
190
191/** The status line's grading: green while half the TTL is left, yellow down to a fifth, then orange. */
192export const cacheColor = (left: number, ttl: number): string =>
193 left * 2 >= ttl ? '#5fff00' : left * 5 >= ttl ? '#ffff00' : '#ffaf00'
194
195/** The status line's defaults: the auto-compact window, and how far below it compaction fires. */
196const WINDOW = 200_000
197const RESERVE = 33_000
198/** Where the context counter turns yellow, orange and red, as shares of the limit (CC_TOKEN_WARN, _DANGER, _ALERT). */
199const STAGES = [0.5, 0.75, 0.9] as const
200
201/** The auto-compact trigger (`limit`) and the window it sits a margin below. */
202export type Limit = { limit: number; window: number }
203
204/** `78234` → `78.2k`, `934` → `934`, as the status line counts tokens. */
205export const kfmt = (n: number): string => (n < 1000 ? String(n) : `${Math.floor(n / 1000)}.${Math.floor((n % 1000) / 100)}k`)
206
207const cap = (n: number): string => (n % 1000 === 0 ? `${n / 1000}k` : kfmt(n))
208
209/**
210 * The context counter: tokens against the auto-compact trigger, how it is
211 * reckoned in brackets (`250k − 33k`), green to half of the trigger, yellow to
212 * three quarters, orange to nine tenths, then red, and compacting once reached.
213 */
214export const contextOf = (tokens: number | undefined, { limit, window }: Limit): { text: string; color: string; isCompacting: boolean } => {
215 const sum = window > limit ? ` (${cap(window)} − ${cap(window - limit)})` : ''
216 if (tokens === undefined) return { text: `0/${cap(limit)}${sum}`, color: '#8a8a8a', isCompacting: false }
217 const text = `${kfmt(tokens)}/${cap(limit)}${sum} ${Math.floor((tokens * 100) / limit)}%`
218 if (tokens >= limit) return { text: `${text} ⚠ compacting`, color: '#ff0000', isCompacting: true }
219 const stage = STAGES.findIndex(share => tokens <= limit * share)
220 return { text, color: ['#5fff00', '#ffff00', '#ffaf00'][stage] ?? '#ff0000', isCompacting: false }
221}
222
223/** A whole positive number, from a number or its digits; null otherwise. */
224const whole = (value: unknown): number | null => {
225 const n = typeof value === 'string' && /^\d{1,12}$/.test(value) ? Number(value) : value
226 return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : null
227}
228
229/**
230 * The auto-compact trigger as the engine sets it (/context's figures); where
231 * the engine gives none, as the status line reckons it: CC_TOKEN_LIMIT, else
232 * `autoCompactWindow`, else 200k; less CC_TOKEN_RESERVE.
233 */
234async function readLimit($: EngineInterface): Promise<Limit> {
235 const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
236 if (b?.autoCompactThreshold && b.autoCompactThreshold > 0)
237 return { limit: b.autoCompactThreshold, window: Math.max(b.rawMaxTokens, b.autoCompactThreshold) }
238 if (b && !b.isAutoCompactEnabled && b.rawMaxTokens > 0) return { limit: b.rawMaxTokens, window: b.rawMaxTokens }
239 const window = whole(await $.env.get('CC_TOKEN_LIMIT')) ?? whole((await $.settings.read()).autoCompactWindow) ?? WINDOW
240 const limit = window - (whole(await $.env.get('CC_TOKEN_RESERVE')) ?? RESERVE)
241 return { limit: limit > 0 ? limit : window, window }
242}
243
244/** The message the closing reply hands the next session: its code block after `/clear`, on one line, as the handover plugin reads it. */
245export const resumeMessage = (reply: string): string | null => {
246 const at = reply.search(/\/clear\b/)
247 const block = at < 0 ? null : /```[^\n]*\n([\s\S]*?)```/.exec(reply.slice(at))
248 return block ? block[1]!.split(/\s+/).filter(Boolean).join(' ') || null : null
249}
250
251/** The rate-limit windows the status line shows, by their length. */
252const WINDOWS: Record<string, { label: string; ms: number }> = {
253 five_hour: { label: '5h', ms: 18_000_000 },
254 seven_day: { label: '7d', ms: 604_800_000 },
255}
256/** Share of a window that must elapse before its pace is judged, as the status line's CC_PACE_MIN. */
257const PACE_MIN = 0.1
258const TONE = { ok: '#a6d189', tight: '#e5c890', out: '#e78284' } as const
259
260/** Coarse time left, as the status line writes it: `2d07h`, `3h14m`, `42m`. */
261export const span = (ms: number): string => {
262 const m = Math.max(0, Math.floor(ms / 60_000))
263 const d = Math.floor(m / 1440)
264 const h = Math.floor((m % 1440) / 60)
265 if (d > 0) return `${d}d${String(h).padStart(2, '0')}h`
266 if (h > 0) return `${h}h${String(m % 60).padStart(2, '0')}m`
267 return `${m}m`
268}
269
270/**
271 * A rate-limit window read as the status line reads it: the average burn so
272 * far extrapolated to the reset lands under 90% (ok), at 90-100% (tight), or
273 * runs out before it; judged once a tenth of the window has elapsed.
274 */
275export const quotaOf = (kind: string, percentUsed: number, resetsAt: string | undefined, now: number): Quota | null => {
276 const win = WINDOWS[kind]
277 if (!win) return null
278 const used = Math.round(percentUsed)
279 const reset = resetsAt ? Date.parse(resetsAt) : NaN
280 const resetsIn = Number.isFinite(reset) && reset > now ? Math.min(reset - now, win.ms) : null
281 const elapsed = resetsIn === null ? null : (win.ms - resetsIn) / win.ms
282 let verdict: Quota['verdict'] = null
283 if (used >= 100) verdict = { text: 'max', tone: 'out' }
284 else if (elapsed !== null && elapsed > 0 && elapsed >= PACE_MIN) {
285 const projected = Math.round(used / elapsed)
286 if (projected > 100) {
287 const dry = ((100 - used) * elapsed * win.ms) / Math.max(used, 1)
288 verdict = { text: `out in ${span(dry)}`, tone: 'out' }
289 } else verdict = { text: `→${projected}%`, tone: projected >= 90 ? 'tight' : 'ok' }
290 }
291 return { label: win.label, used, elapsed, verdict, resetsIn }
292}
293
294export type BarRun = { text: string; color: string; isPace?: boolean; isAhead?: boolean }
295
296/**
297 * A quota bar `width` cells wide, in half-cell steps: fill up to the pace tick
298 * in green, fill past it (ahead of even spending) in the verdict's colour, the
299 * rest a faint track; the tick marks where even spending would be by now.
300 */
301export const barRuns = (q: Quota, width: number): BarRun[] => {
302 const tone = TONE[q.verdict?.tone ?? 'ok']
303 const halves = Math.round((Math.min(q.used, 100) / 100) * width * 2)
304 const pace = q.elapsed === null ? -1 : Math.min(width - 1, Math.round(q.elapsed * width))
305 const runs: BarRun[] = []
306 const push = (text: string, color: string, flags: Omit<BarRun, 'text' | 'color'> = {}) => {
307 const last = runs[runs.length - 1]
308 if (last && last.color === color && !last.isPace && !flags.isPace && !!last.isAhead === !!flags.isAhead) last.text += text
309 else runs.push({ text, color, ...flags })
310 }
311 for (let cell = 0; cell < width; cell++) {
312 const filled = halves - cell * 2
313 const isAhead = pace >= 0 && cell > pace
314 if (cell === pace) push('┃', '#c6d0f5', { isPace: true })
315 else if (filled >= 2) push('━', isAhead ? tone : TONE.ok, { isAhead })
316 else if (filled === 1) push('╸', isAhead ? tone : TONE.ok, { isAhead })
317 else push('─', '#51576d')
318 }
319 return runs
320}
321
322/** How long ago, coarsely: minutes within the hour, then hours, then days. */
323export const ago = (ms: number): string => {
324 const minutes = Math.floor(Math.max(0, ms) / 60_000)
325 if (minutes < 1) return 'just now'
326 if (minutes < 60) return `${minutes} min ago`
327 const hours = Math.floor(minutes / 60)
328 if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`
329 const days = Math.floor(hours / 24)
330 return `${days} day${days > 1 ? 's' : ''} ago`
331}
332
333const oneLine = (text: string, max = 160): string => {
334 const flat = text.replace(/\s+/g, ' ').trim()
335 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
336}
337
338/** The first sentence of a block of thinking or text: a step's headline. */
339export const headline = (text: string): string => {
340 const flat = text.replace(/\s+/g, ' ').trim()
341 const end = flat.search(/[.!?](\s|$)/)
342 return end > 0 ? flat.slice(0, end + 1) : flat
343}
344
345/** What a tool call is about, in a few words. */
346export const describeCall = (tool: string, input: Record<string, unknown>): string => {
347 for (const field of ['description', 'command', 'file_path', 'pattern', 'url', 'query', 'prompt', 'skill']) {
348 const value = input[field]
349 if (typeof value === 'string' && value.trim()) return `${tool}: ${oneLine(value, 100)}`
350 }
351 return tool
352}
353
354const SIGN: Record<FileChange['status'], string> = { added: '+', modified: '~', deleted: '−' }
355const SIGN_COLOR: Record<FileChange['status'], string> = { added: '#a6d189', modified: '#e5c890', deleted: '#e78284' }
356
357const fileName = (path: unknown) => (typeof path === 'string' ? path.split('/').pop() : undefined)
358
359/** What a call does, in the agent's words where it gave some, and how. */
360export const intentOf = (tool: string, input: Record<string, unknown>): { what: string; how?: string } => {
361 const how = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill']
362 .map(field => input[field])
363 .find((value): value is string => typeof value === 'string' && value.trim() !== '')
364 const description = typeof input.description === 'string' ? input.description.trim() : ''
365 if (description) return { what: description, how: how && oneLine(how, 200) }
366 const name = fileName(input.file_path ?? input.notebook_path)
367 if (name) return { what: `${tool === 'Write' ? 'Write' : tool === 'Read' ? 'Read' : 'Edit'} ${name}`, how: String(input.file_path ?? input.notebook_path) }
368 return { what: describeCall(tool, input), how: undefined }
369}
370
371/**
372 * The text a person typed, without the tags the engine wraps around it: a
373 * slash command's echo (`<command-name>`) or an injected block yields null.
374 */
375export const promptText = (raw: string): string | null => {
376 if (/<command-name>|<local-command-|<task-notification>/.test(raw)) return null
377 const text = raw
378 .replace(/<(system-reminder|local-command-caveat|local-command-stdout)>[\s\S]*?<\/\1>/g, '')
379 .replace(/<\/?[a-zA-Z][\w-]*(\s[^>]*)?>/g, '')
380 .trim()
381 return text || null
382}
383
384const isStop = (c: string | undefined) => c === '.' || c === '!' || c === '?'
385
386/**
387 * The last whole sentence of a text being streamed, once there is one: the
388 * last run of `.!?` followed by a space or the end, with the words before it
389 * back to the previous `.!?`. Read from the end: it is asked again for each
390 * streamed piece, and a scan of the whole text each time grows quadratic.
391 */
392export const lastSentence = (text: string): string | null => {
393 for (let end = text.length; end > 0; end--) {
394 if (!isStop(text[end - 1]) || (end < text.length && !/\s/.test(text[end]!))) continue
395 let stop = end - 1
396 while (stop > 0 && isStop(text[stop - 1])) stop--
397 let start = stop
398 while (start > 0 && !isStop(text[start - 1])) start--
399 return start < stop ? text.slice(start, end).replace(/\s+/g, ' ').trim() : null
400 }
401 return null
402}
403
404/** A step is current while its answer streams or a call it made has not returned. */
405export const isRunning = (s: Step): boolean => !s.isDone || !(s.toolIds ?? []).every(id => s.doneIds?.includes(id))
406
407/** A handover's title: its front matter's `summary`, else its `# Handover:` heading. */
408export const handoverTitle = (text: string): string | null => {
409 const front = /^---\n([\s\S]*?)\n---/.exec(text)?.[1]
410 const summary = front && /^summary:\s*["']?(.+?)["']?\s*$/m.exec(front)?.[1]
411 return summary || /^#\s*Hand(?:over|off):\s*(.+)$/m.exec(text)?.[1]?.trim() || null
412}
413
414/** The hint beside Last prompt, `11 previous prompts`: how many came before it. */
415export const previousPrompts = (n: number): string =>
416 n <= 0 ? 'no previous prompt' : `${n} previous ${n === 1 ? 'prompt' : 'prompts'}`
417
418/** When a prompt was submitted, `09:05` in local 24-hour time. */
419export const clockTime = (at: number): string => {
420 const d = new Date(at)
421 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
422}
423
424/** A stored prompt; one kept by an earlier version is bare text, without its time. */
425const promptOf = (p: Prompt | string): Prompt => (typeof p === 'string' ? { text: p, at: null } : p)
426
427/** A handover file's length and last change, `142 lines · 2026-10-07 09:32` in local time; null when neither is known. */
428export const fileMeta = (lines: number | null, modifiedAt: number | null): string | null => {
429 const pad = (n: number) => String(n).padStart(2, '0')
430 const d = modifiedAt === null ? null : new Date(modifiedAt)
431 const parts = [
432 lines === null ? null : `${lines} ${lines === 1 ? 'line' : 'lines'}`,
433 d && `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`,
434 ].filter(Boolean)
435 return parts.length ? parts.join(' · ') : null
436}
437
438const kilo = (n: number) => (n < 1000 ? String(n) : n < 10_000 ? `${(n / 1000).toFixed(1)}k` : `${Math.floor(n / 1000)}k`)
439
440/** What the handover plugin's status line says, in the same words and colours. */
441export const handoverStatus = (h: Handover, tokens: number): { text: string; color: string } => {
442 if (h.error) return { text: `failed · ${h.error}`, color: '#e78284' }
443 if (h.isWriting) return { text: 'writing…', color: '#ef9f76' }
444 if (h.written) return { text: 'ready · run /clear', color: '#a6d189' }
445 const suggested = h.suggest > 0 && tokens >= h.suggest
446 if (!h.trigger) return { text: suggested ? 'suggested' : 'trigger off', color: '#a5adce' }
447 if (tokens >= h.trigger) return { text: 'wind-down on the next tool call', color: '#ef9f76' }
448 return {
449 text: `${suggested ? 'suggested · ' : ''}triggers at ${kilo(h.trigger)} · now ${kilo(tokens)}`,
450 color: tokens >= h.trigger - h.warn ? '#e5c890' : '#a5adce',
451 }
452}
453
454/** The two ways to start the next session from a written handover, each in its Catppuccin Frappé colour. */
455const NEXT_ACTIONS = [
456 { key: 'handover:run', emoji: '🚀', label: 'Start with this prompt', color: '#a6d189', isSent: true },
457 { key: 'handover:edit', emoji: '✏️', label: 'Edit the prompt first', color: '#8caaee', isSent: false },
458] as const
459
460export type HandoverPhase = 'winding' | 'writing' | 'ready'
461
462/** Where a handover under way stands: winding down, being written, or written; null before it starts or once it failed. */
463export const handoverPhase = (h: Handover, tokens: number): HandoverPhase | null => {
464 if (h.error) return null
465 if (h.written) return 'ready'
466 if (h.isWriting) return 'writing'
467 if (h.isWindingDown || (h.trigger > 0 && tokens >= h.trigger)) return 'winding'
468 return null
469}
470
471/** Each phase of a handover under way, animated once a second by the pane's ticker. */
472const PHASES: Record<HandoverPhase, { frames: readonly string[]; label: string; color: string; does: string }> = {
473 winding: {
474 frames: ['◐', '◓', '◑', '◒'],
475 label: 'Winding down',
476 color: '#e5c890',
477 does: 'The handover has triggered: the tasks in progress and their sub-agents finish, nothing new starts.',
478 },
479 writing: {
480 frames: ['✎ ', '✎· ', '✎·· ', '✎···'],
481 label: 'Writing the handover',
482 color: '#ef9f76',
483 does: 'A separate model writes the handover from the transcript; the session then writes its closing reply.',
484 },
485 ready: {
486 frames: ['●', '◉'],
487 label: 'Ready for the next session',
488 color: '#a6d189',
489 does: 'The handover is written and the session stopped: pick a next action.',
490 },
491}
492
493/** A phase as drawn at `now`: its frame for this second, then its label. */
494export const phaseLine = (phase: HandoverPhase, now: number): string => {
495 const p = PHASES[phase]
496 return `${p.frames[Math.floor(now / 1000) % p.frames.length]} ${p.label}`
497}
498
499/** Width of the Help tab's example column. */
500const HELP_EXAMPLE = 34
501
502/** Starts the handover's wind-down at once, as `/handover:trigger` does. */
503const TRIGGER_NOW = { key: 'handover:trigger', emoji: '⚡', label: 'Hand over now', color: '#e5c890' } as const
504
505const ttlMs = (ttl: Ttl): number => (ttl === '1h' ? 3_600_000 : 300_000)
506
507async function addEntry($: EngineInterface, agentId: string, entry: Entry) {
508 await update($, agents, list =>
509 list.map(a =>
510 a.id === agentId
511 ? { ...a, history: [...a.history, { ...entry, text: oneLine(entry.text, 400) }].slice(-HISTORY_CAP) }
512 : a,
513 ),
514 )
515}
516
517/** Records one of a sub-agent's tool calls and its outcome in its history. */
518async function logCall($: EngineInterface, agentId: string, e: ToolCallInput, ran: ToolCallResult) {
519 await addEntry($, agentId, { kind: 'tool', text: describeCall(String(e.tool), e as unknown as Record<string, unknown>) })
520 if (ran.deny !== undefined) await addEntry($, agentId, { kind: 'error', text: `denied: ${ran.deny}` })
521 else if (ran.isError) await addEntry($, agentId, { kind: 'error', text: ran.text ?? 'failed' })
522 else if (ran.text) await addEntry($, agentId, { kind: 'result', text: ran.text })
523}
524
525/** Marks the main-loop step a refused or failed call belongs to. */
526async function flagStep($: EngineInterface, toolUseId: string, ran: ToolCallResult) {
527 const flag: Step['flag'] = ran.deny !== undefined ? 'refused' : ran.isError ? 'failed' : undefined
528 if (!flag) return
529 const note = oneLine(ran.deny ?? ran.text ?? '', 160)
530 await update($, steps, list => list.map((s): Step => (s.toolIds?.includes(toolUseId) ? { ...s, flag, note } : s)))
531}
532
533/** Adds the repository holding `dir` to those the Files and Git sections show, noting the linked worktree it lies in. */
534async function trackRepo($: EngineInterface, dir: string) {
535 const out = await $.process.run(['git', '-C', dir, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'])
536 const [root = '', gitDir = '', commonDir = ''] = out.stdout.trim().split('\n')
537 if (out.exitCode !== 0 || !root) return
538 await update($, roots, list => (list.includes(root) ? list : [...list, root]))
539 const common = commonDir || root
540 const worktree = gitDir && commonDir && gitDir !== commonDir ? (root.split('/').pop() ?? root) : null
541 const known = (await read($, workedRepos)).find(r => r.common === common)
542 if (known) {
543 if (known.worktree !== worktree) await update($, workedRepos, list => list.map(r => (r.common === common ? { ...r, worktree } : r)))
544 return
545 }
546 const remote = await $.process.run(['git', '-C', root, 'remote', 'get-url', 'origin'])
547 const slug = remote.exitCode === 0 ? repoOfRemote(remote.stdout) : null
548 const name = slug?.split('/').pop() ?? (common.replace(/\/\.git\/?$/, '').split('/').pop() || root)
549 await update($, workedRepos, list => (list.some(r => r.common === common) ? list : [...list, { common, slug, name, worktree }]))
550}
551
552/** One repository's changes against HEAD, untracked files included; null when git cannot read it or it has none. */
553async function changesOf($: EngineInterface, root: string, since: number): Promise<RepoChanges | null> {
554 const git = (...args: string[]) => $.process.run(['git', '-C', root, ...args])
555 const status = await git('status', '--porcelain=v1', '-z', '--untracked-files=all')
556 if (status.exitCode !== 0) return null
557 const kinds = parseStatus(status.stdout)
558 // Independent reads, run side by side rather than one after another.
559 const [numstat, log, ahead, current] = await Promise.all([
560 git('diff', '--numstat', 'HEAD'),
561 git('log', `-n${COMMITS_SHOWN}`, `--since=@${since}`, `--format=${LOG_FORMAT}`),
562 git('rev-list', '@{u}..HEAD'),
563 git('branch', '--show-current'),
564 ])
565 const counts = parseNumstat(numstat.stdout)
566 let untracked = 0
567 const files = await Promise.all(
568 [...kinds].map(async ([path, kind]): Promise<FileChange> => {
569 let count = counts.get(path)
570 if (!count && kind === 'added' && untracked++ < UNTRACKED_COUNTED) {
571 const diff = await git('diff', '--no-index', '--numstat', '/dev/null', path)
572 count = [...parseNumstat(diff.stdout).values()][0]
573 }
574 return { path, status: kind, added: count?.added ?? 0, removed: count?.removed ?? 0 }
575 }),
576 )
577 const unpushed = ahead.exitCode === 0 ? new Set(ahead.stdout.split('\n').filter(Boolean)) : ('all' as const)
578 const commits = log.exitCode === 0 ? parseLog(log.stdout, unpushed) : []
579 const branch = current.stdout.trim() || 'detached'
580 return files.length || commits.length ? { root, branch, files, commits } : null
581}
582
583/** Reads each tracked repository's changes, the repositories side by side, kept in their order. */
584async function refreshFiles($: EngineInterface) {
585 const since = Math.floor((await $.session.usage()).startedAt / 1000)
586 const found = await Promise.all((await read($, roots)).map(root => changesOf($, root, since)))
587 const next = found.filter((repo): repo is RepoChanges => repo !== null)
588 await update($, changes, () => next)
589}
590
591/**
592 * Selects the session's model and effort before its first turn reports them:
593 * the model `/model` shows, the effort from the variable or the settings it saves to.
594 */
595async function seedModel($: EngineInterface) {
596 const model = modelId(await $.session.model())
597 const saved = (await $.env.get('CLAUDE_CODE_EFFORT_LEVEL')) ?? (await $.settings.read()).effortLevel
598 const effort = typeof saved === 'string' && EFFORTS.some(e => e.level === saved) ? saved : null
599 await update($, info, i => ({ ...i, model: i.model ?? model, effort: i.effort ?? effort }))
600}
601
602/** Marks a main-loop call as returned, so the step that made it can end. */
603async function markReturned($: EngineInterface, toolUseId: string) {
604 await update($, steps, list =>
605 list.map(s => (s.toolIds?.includes(toolUseId) ? { ...s, doneIds: [...(s.doneIds ?? []), toolUseId] } : s)),
606 )
607}
608
609/** Keeps the issue seen with the best rank, the first one on a tie. */
610async function keep($: EngineInterface, next: TrackedIssue) {
611 await update($, tracker, t => {
612 const cur = t.issue
613 if (cur?.key === next.key) return { ...t, issue: { ...next, rank: Math.min(cur.rank, next.rank) } }
614 if (cur && cur.rank <= next.rank) return t
615 return { ...t, issue: next }
616 })
617}
618
619/** Keeps, per repository, the pull request seen with the best rank, the first one on a tie. */
620async function keepPr($: EngineInterface, next: TrackedPr) {
621 await update($, tracker, t => {
622 const prs = t.prs ?? []
623 const cur = prs.find(p => p.repo.toLowerCase() === next.repo.toLowerCase())
624 if (!cur) return { ...t, prs: [...prs, next] }
625 if (cur.number !== next.number && cur.rank <= next.rank) return t
626 const kept = cur.number === next.number ? { ...next, rank: Math.min(cur.rank, next.rank) } : next
627 return { ...t, prs: prs.map(p => (p === cur ? kept : p)) }
628 })
629}
630
631/** Reads a GitHub issue or pull request and keeps it in its slot; `seen` skips one already read at that rank. */
632async function noteGithub($: EngineInterface, ref: GithubRef, rank: number, seen?: Set<string>) {
633 if (seen?.has(`${refKey(ref)}@${rank}`)) return
634 seen?.add(`${refKey(ref)}@${rank}`)
635 const jq = '{title,state,url:.html_url,isPr:(.pull_request!=null),merged:(.pull_request.merged_at!=null),draft:(.draft // false)}'
636 const got = await $.process.run(['gh', 'api', `repos/${ref.repo}/issues/${ref.number}`, '--jq', jq])
637 let data: { title?: string; state?: string; url?: string; isPr?: boolean; merged?: boolean; draft?: boolean } | null = null
638 try {
639 data = got.exitCode === 0 ? JSON.parse(got.stdout) : null
640 } catch {
641 data = null
642 }
643 if (!data && ref.type === null) return
644 const isPr = data ? Boolean(data.isPr) : ref.type === 'pull'
645 const url = data?.url ?? `https://github.com/${ref.repo}/${isPr ? 'pull' : 'issues'}/${ref.number}`
646 if (isPr) {
647 const state = data ? prState(data) : null
648 await keepPr($, { repo: ref.repo, number: ref.number, title: data?.title ?? null, url, state, rank })
649 } else {
650 await keep($, { platform: 'github', key: `${ref.repo}#${ref.number}`, title: data?.title ?? null, url, appUrl: null, rank })
651 }
652}
653
654async function noteLinear($: EngineInterface, ref: LinearRef, title: string | null, rank: number) {
655 const mentioned = (await read($, tracker)).mentioned.includes(ref.id)
656 const base = ref.workspace ? `linear.app/${ref.workspace}/issue/${ref.id}` : null
657 await keep($, {
658 platform: 'linear',
659 key: ref.id,
660 title: title ?? titleOfSlug(ref.slug),
661 url: base && `https://${base}${ref.slug ? `/${ref.slug}` : ''}`,
662 appUrl: ref.workspace ? `linear://${ref.workspace}/issue/${ref.id}` : null,
663 rank: mentioned ? RANK.prompt : rank,
664 })
665}
666
667/** Looks for the issues and pull requests a prompt names. */
668async function scanPrompt($: EngineInterface, text: string, seen?: Set<string>) {
669 const ids = text.match(LINEAR_ID) ?? []
670 if (ids.length) await update($, tracker, t => ({ ...t, mentioned: [...new Set([...t.mentioned, ...ids])] }))
671 for (const ref of findRefs(text, await read($, home))) {
672 if (ref.platform === 'github') await noteGithub($, ref, ref.isBare ? RANK.mentioned : RANK.prompt, seen)
673 else await noteLinear($, ref, null, RANK.prompt)
674 }
675}
676
677/** Looks for an issue or pull request a `gh` command or a Linear tool worked on. */
678async function scanCall($: EngineInterface, tool: string, input: Record<string, unknown>, ran: ToolCallResult, seen?: Set<string>) {
679 if (ran.deny !== undefined || ran.isError) return
680 if (tool === 'Bash' && typeof input.command === 'string') {
681 const found = refsOfGh(input.command, ran.text ?? '', await read($, home))
682 for (const ref of found?.refs ?? []) {
683 if (ref.platform === 'github') await noteGithub($, ref, found!.rank, seen)
684 else await noteLinear($, ref, null, found!.rank)
685 }
686 } else if (/linear/i.test(tool) && ran.text) {
687 const issue = linearOfResult(ran.text)
688 if (issue) await noteLinear($, issue.ref, issue.title, /create/i.test(tool) ? RANK.created : RANK.worked)
689 }
690}
691
692/**
693 * Finds the session's issue and pull request again from its whole transcript,
694 * its prompts and calls in order: a plugin loaded mid-session missed the earlier ones.
695 */
696async function rescanTracker($: EngineInterface) {
697 const messages = await $.session.messages()
698 await update($, tracker, t => ({ ...t, issue: null, prs: [] }))
699 const seen = new Set<string>()
700 let lastDir: string | null = null
701 for (const m of messages) {
702 const text = m.role === 'user' && !m.toolResults?.length ? promptText(m.text) : null
703 if (text) await scanPrompt($, text, seen)
704 for (const call of m.toolUses) {
705 if (call.text !== undefined) await scanCall($, call.tool, call.input, { text: call.text, isError: call.isError } as ToolCallResult, seen)
706 const path = call.input.file_path
707 const dir = typeof path === 'string' && path.startsWith('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : null
708 if (dir && dir !== lastDir && !call.isError) {
709 lastDir = dir
710 await trackRepo($, dir)
711 }
712 }
713 }
714}
715
716/** Reads the shown pull request's state again: it moves on GitHub. */
717async function refreshPr($: EngineInterface) {
718 for (const pr of (await read($, tracker)).prs ?? []) await noteGithub($, { platform: 'github', repo: pr.repo, number: pr.number, type: 'pull' }, pr.rank)
719}
720
721/** `$HOME`, the project's git folder, then the real paths of the temporary folders and of each folder named. */
722const DOC_CONTEXT = `printf '%s\\n' "$HOME"
723git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo
724for d in /tmp "\${TMPDIR:-/tmp}" "$@"; do
725 case $d in "~/"*) d="$HOME/$(printf %s "$d" | cut -c3-)";; esac
726 (cd "$d" 2>/dev/null && pwd -P) || echo "$d"
727done`
728
729/** Each file changed since `$1` (epoch seconds): its path as named, its real path, and the git folder of its repository. */
730const WRITTEN_SINCE = `start=$1; shift
731for f; do
732 [ -f "$f" ] || continue
733 [ "$(date -r "$f" +%s)" -ge "$start" ] || continue
734 d=$(cd "$(dirname "$f")" && pwd -P) || continue
735 printf '%s\\n%s/%s\\n%s\\n' "$f" "$d" "$(basename "$f")" "$(cd "$d" && git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"
736done`
737
738/** Where documents are told apart, read again once the handover or plans folder changes. */
739async function contextOfDocs($: EngineInterface): Promise<DocContext> {
740 const c = await read($, config)
741 const dirs = [c.handoverDir ?? HANDOVER_DIR, c.plansDirectory ?? PLANS_DIR]
742 const key = dirs.join('\n')
743 if (docContext?.key === key) return docContext.ctx
744 const out = await $.process.run(['sh', '-c', DOC_CONTEXT, 'sh', ...dirs])
745 const [home = '', project = '', tmp = '', tmpdir = '', handovers = '', plans = ''] = out.stdout.split('\n')
746 const ctx = { home, project: project || null, tmp: [...new Set([tmp, tmpdir].filter(Boolean))], handovers, plans }
747 if (home) docContext = { key, ctx }
748 return ctx
749}
750
751/** Adds the Markdown files among `paths` changed since `since` (epoch seconds) that are documents, once each. */
752async function keepDocs($: EngineInterface, paths: string[], since = 0) {
753 const named = paths.filter(path => /\.md$/i.test(path))
754 if (!named.length) return
755 const ctx = await contextOfDocs($)
756 const out = await $.process.run(['sh', '-c', WRITTEN_SINCE, 'sh', String(since), ...named])
757 const lines = out.stdout.split('\n')
758 const found: Doc[] = []
759 for (let k = 0; k + 2 < lines.length; k += 3) {
760 const doc = docOf({ path: lines[k]!, real: lines[k + 1]!, repo: lines[k + 2] || null }, ctx)
761 if (doc) found.push(doc)
762 }
763 if (found.length) await addDocs($, found)
764}
765
766/** Adds documents, once each; one already listed takes the newer name (an artifact retitled). */
767const addDocs = ($: EngineInterface, found: Doc[]) =>
768 update($, documents, list => [...list.map(d => found.find(f => f.id === d.id) ?? d), ...found.filter(f => !list.some(d => d.id === f.id))])
769
770/** The documents a call wrote: an artifact it published, an editing tool's Markdown file, or one a command names that changed since the session began. */
771async function scanDocs($: EngineInterface, tool: string, input: Record<string, unknown>, ran: ToolCallResult) {
772 if (ran.deny !== undefined || ran.isError) return
773 if (tool === 'Artifact') {
774 const art = artifactOf(input, ran.result)
775 return art ? addDocs($, [art]) : undefined
776 }
777 const path = input.file_path ?? input.notebook_path
778 if (WRITING_TOOLS.has(tool) && typeof path === 'string') return keepDocs($, [path])
779 if (tool !== 'Bash' || typeof input.command !== 'string' || !/\.md\b/i.test(input.command)) return
780 const named = docPaths(input.command, (await contextOfDocs($)).home)
781 if (!named.length) return
782 await keepDocs($, named, Math.floor((await $.session.usage()).startedAt / 1000))
783}
784
785/** The values the pane reads from Claude Code's settings and the environment, as set. */
786async function readConfig($: EngineInterface, cacheTtl: Ttl) {
787 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
788 const plans = (settings as Record<string, unknown>).plansDirectory
789 const next = {
790 cacheTtl,
791 autoCompactWindow: whole((settings as Record<string, unknown>).autoCompactWindow),
792 tokenLimit: whole(await $.env.get('CC_TOKEN_LIMIT').catch(() => undefined)),
793 tokenReserve: whole(await $.env.get('CC_TOKEN_RESERVE').catch(() => undefined)),
794 plansDirectory: typeof plans === 'string' && plans.trim() ? plans.trim() : null,
795 }
796 await update($, config, c => ({ ...c, ...next }))
797}
798
799const HANDOVER_READ = `d="$HOME/.claude/plugins/data/handover-fundrivendev"
800test -d "$d" || d="$HOME/.claude/plugins/data/handover-fundriven"
801test -e "$d/live/$1" && echo on
802echo "@@"; cat "$d/sessions/$1.json" 2>/dev/null
803echo "@@"; cat "$d/options.json" 2>/dev/null`
804
805/** A handover's first lines, for its title, then its line count and last change (`date -r` works on macOS and GNU). */
806const HANDOVER_FILE = `head -n 40 "$1" || exit 1
807printf '\\n@@\\n'; wc -l < "$1"; date -r "$1" +%s`
808
809/** Reads the handover plugin's state for this session, as its status line does. */
810async function readHandover($: EngineInterface) {
811 const sid = await $.session.id()
812 const out = await $.process.run(['sh', '-c', HANDOVER_READ, 'sh', sid])
813 const [live = '', state = '', opts = ''] = out.stdout.split('@@\n')
814 const parse = (text: string): Record<string, unknown> => {
815 try {
816 return JSON.parse(text) as Record<string, unknown>
817 } catch {
818 return {}
819 }
820 }
821 const st = parse(state)
822 const opt = parse(opts)
823 const num = (key: string, fallback: number) => (typeof opt[key] === 'number' ? (opt[key] as number) : fallback)
824 const titled = async (path: unknown): Promise<HandoverFile | null> => {
825 if (typeof path !== 'string' || !path) return null
826 const out = await $.process.run(['sh', '-c', HANDOVER_FILE, 'sh', path])
827 const cut = out.stdout.lastIndexOf('\n@@\n')
828 if (out.exitCode !== 0 || cut < 0) return { path, title: null, lines: null, modifiedAt: null }
829 const [lines, seconds] = out.stdout.slice(cut + 4).split('\n').map(n => Number.parseInt(n.trim(), 10))
830 const known = (n: number | undefined) => (n !== undefined && Number.isFinite(n) ? n : null)
831 const at = known(seconds)
832 return { path, title: handoverTitle(out.stdout.slice(0, cut)), lines: known(lines), modifiedAt: at === null ? null : at * 1000 }
833 }
834 const written = st.written as { ok?: boolean } | undefined
835 const said = written?.ok ? (await $.session.messages()).filter(m => m.role === 'assistant').pop()?.text : undefined
836 const writer = st.writer as { since?: number } | undefined
837 const now = (await $.clock.now()) / 1000
838 const next: Handover = {
839 isOn: live.trim() === 'on',
840 loaded: await titled(st.loaded_from),
841 written: written?.ok ? await titled(st.path) : null,
842 suggest: num('suggest_tokens', 150_000),
843 trigger: num('trigger_tokens', 185_000),
844 warn: num('warn_tokens', 20_000),
845 isWriting: typeof writer?.since === 'number' && now - writer.since < 660,
846 isWindingDown: Boolean(st.wind_down),
847 error: typeof st.error === 'string' ? st.error : null,
848 resume: said ? resumeMessage(said) : null,
849 }
850 await update($, handover, () => next)
851 const dir = typeof opt.handover_dir === 'string' && opt.handover_dir.trim() ? opt.handover_dir.trim() : null
852 if ((await read($, config)).handoverDir !== dir) await update($, config, c => ({ ...c, handoverDir: dir }))
853 if (next.written) await keepDocs($, [next.written.path])
854}
855
856/** How long the handover plugin's SessionStart hook may take to load the handover (its timeout, plus a margin). */
857const LOAD_WAIT_MS = 190_000
858
859/**
860 * Waits for the session that followed `cleared` to have loaded its handover:
861 * the handover plugin records `loaded_from` in the new session's state once
862 * its SessionStart hook has put the handover in the context.
863 */
864async function waitForLoad($: EngineInterface, cleared: string): Promise<boolean> {
865 const sleep = (ms: number) => new Promise<void>(resolve => $.clock.after(ms, () => resolve()))
866 const start = await $.clock.now()
867 while ((await $.clock.now()) - start < LOAD_WAIT_MS) {
868 if ((await $.session.id()) !== cleared) {
869 await readHandover($)
870 if ((await read($, handover))?.loaded) return true
871 }
872 await sleep(1000)
873 }
874 return false
875}
876
877/**
878 * Starts the next session from the handover: copies the resume message, runs
879 * `/clear` (the handover plugin loads the handover into the new session),
880 * waits for the handover to load, then enters the message in the prompt box
881 * and, when `isSent`, sends it; left in the box when the handover does not load.
882 */
883async function startNext($: EngineInterface, resume: string | null, surface: UiCopyArgs['surface'], isSent: boolean) {
884 const text = resume?.trim()
885 if (text) await $.ui.copy({ text, surface })
886 const cleared = await $.session.id()
887 await $.command.run({ command: 'clear' })
888 if (!text) return
889 const isLoaded = await waitForLoad($, cleared)
890 await $.prompt.fill({ text })
891 if (!isLoaded) $.ui.toast('The handover did not load: the resume message waits in the prompt box.')
892 else if (isSent) {
893 await $.prompt.submit({ text })
894 await $.prompt.fill({ text: '' })
895 }
896}
897
898/** A session started from a handover takes the handover's title at once, until the agent names it. */
899async function titleFromHandover($: EngineInterface) {
900 const sid = await $.session.id()
901 const title = (await read($, titled)).set === sid ? null : cleanTitle((await read($, handover))?.loaded?.title ?? '')
902 if (!title) return
903 await update($, titled, t => ({ ...t, set: sid }))
904 await update($, topic, () => title)
905 const canRename = (await $.command.list()).some(c => c.name === 'rename')
906 if (canRename) void $.command.run({ command: 'rename', args: title }).catch(() => undefined)
907}
908
909const TITLE_TOOL = 'session_title'
910const TITLE_TOOL_ID = `mcp__session-panel__${TITLE_TOOL}`
911const TITLE_TOOL_DESCRIPTION = `Sets this session's title: its overall topic in 3 to 7 words, in the language of the person's prompts. It heads the session panel and names the session (/resume, the terminal tab), so the person can tell sessions apart at a glance.
912Call it once the first task is clear, then only when what the session is about changes significantly (a new task, not a new step of the same one). Never call it every turn.`
913
914/** Added once to a session's first typed prompt while it has no title of its own: the name the terminal tab shows may be the previous session's. */
915const TITLE_REMINDER = `This session has no title of its own yet (the terminal tab may still show the previous session's). Call ${TITLE_TOOL} once the task is clear.`
916
917/** A title as given: its first line, without a label, quotes or a final period; null when empty. */
918export const cleanTitle = (text: string): string | null => {
919 const line = text.split('\n').map(l => l.trim()).find(Boolean)
920 const title = line?.replace(/^title:\s*/i, '').replace(/^["'“«*`]+|["'”»*`.]+$/g, '').trim()
921 return title ? oneLine(title, 80) : null
922}
923
924async function finish($: EngineInterface, agentId: string, answer?: string) {
925 const now = await $.clock.now()
926 await update($, agents, list =>
927 list.map(a => {
928 if (a.id !== agentId || a.endedAt !== null) return a
929 const history = answer ? [...a.history, { kind: 'say' as const, text: oneLine(answer, 400) }] : a.history
930 return { ...a, endedAt: now, history: history.slice(-HISTORY_CAP) }
931 }),
932 )
933}
934
935export const register: Register = (on, options) => {
936 const defaultTtl: Ttl = options.cacheTtl === '5m' ? '5m' : '1h'
937 let ticker: { cancel: () => void } | null = null
938 let limit: Limit = { limit: WINDOW - RESERVE, window: WINDOW }
939 let scheme: Scheme = 'dark'
940
941 on('session.start', async ($, e, next) => {
942 await update($, expanded, () => null)
943 await update($, view, () => 'overview')
944 await $.command.register({ name: 'session-panel', description: 'Open the session overview pane' })
945 await $.tool
946 .register({
947 name: TITLE_TOOL,
948 description: TITLE_TOOL_DESCRIPTION,
949 inputSchema: {
950 type: 'object',
951 properties: { title: { type: 'string', description: 'The overall topic, 3 to 7 words, no final period' } },
952 required: ['title'],
953 },
954 isDeferred: false,
955 })
956 .catch(() => undefined)
957 void $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS })
958 ticker?.cancel()
959 let ticks = 0
960 ticker = $.clock.every(1000, () => {
961 ticks++
962 if (ticks % 5 === 0) void readHandover($).then(() => titleFromHandover($))
963 if (ticks % 5 === 0) void readScheme($).then(s => (scheme = s))
964 if (ticks % 30 === 0) void readLimit($).then(n => (limit = n))
965 if (ticks % 30 === 0) void readConfig($, defaultTtl)
966 if (ticks % 60 === 0) void refreshPr($)
967 $.ui.invalidate('ui.render')
968 })
969 const remote = await $.process.run(['git', '-C', e.cwd, 'remote', 'get-url', 'origin'])
970 await update($, home, () => (remote.exitCode === 0 ? repoOfRemote(remote.stdout) : null))
971 hasLinearApp = (await $.process.run(['test', '-d', '/Applications/Linear.app'])).exitCode === 0
972 await readHandover($)
973 await titleFromHandover($)
974 limit = await readLimit($)
975 await readConfig($, defaultTtl)
976 scheme = await readScheme($)
977 await trackRepo($, e.cwd)
978 await refreshFiles($)
979 await seedModel($)
980
981 if ((await read($, prompts)).length === 0) {
982 const typed = (await $.session.messages())
983 .filter(m => m.role === 'user' && !m.toolResults?.length)
984 .map(m => promptText(m.text))
985 .filter((text): text is string => text !== null)
986 if (typed.length) await update($, prompts, () => typed.map(text => ({ text, at: null })))
987 }
988 await rescanTracker($)
989
990 return next(e)
991 })
992
993 on('command.run', { command: 'session-panel' }, async $ => {
994 await update($, expanded, () => null)
995 await update($, view, () => 'overview')
996 await update($, tab, () => 'main')
997 await $.ui.open({ id: PANE, title: TITLE, focus: true, columns: DOCK_COLUMNS })
998 void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
999 return { text: 'Session panel opened.' }
1000 })
1001
1002 on('prompt.submit', async ($, e, next) => {
1003 const text = e.origin.kind === 'composer' ? promptText(e.text) : null
1004 if (text) {
1005 const at = await $.clock.now()
1006 await update($, prompts, list => [...list, { text, at }])
1007 await scanPrompt($, text)
1008 const sid = await $.session.id()
1009 const t = await read($, titled)
1010 if (t.set !== sid && t.reminded !== sid) {
1011 await update($, titled, cur => ({ ...cur, reminded: sid }))
1012 return next({ ...e, context: [...(e.context ?? []), TITLE_REMINDER] })
1013 }
1014 }
1015 return next(e)
1016 })
1017
1018 on('classic.PreModelSwitch', async ($, e, next) => {
1019 await update($, info, i => ({ ...i, ttl: e.cache_ttl }))
1020 return next(e)
1021 })
1022
1023 on('turn.step', async function* ($, e, next) {
1024 const agentId = e.agentId
1025 const stepId = `${e.turnId}:${e.index}`
1026 if (!agentId) {
1027 await update($, info, i => ({ ...i, model: e.model, effort: e.effort === undefined ? null : String(e.effort) }))
1028 await update($, steps, list => [...list, { id: stepId, label: 'Thinking…', isDone: false }].slice(-STEPS_CAP))
1029 }
1030
1031 const thinking = new Map<number, string>()
1032 const text = new Map<number, string>()
1033 const toolIds: string[] = []
1034 let live = 'Thinking…'
1035 const stream = next(e)
1036 let item = await stream.next()
1037 while (!item.done) {
1038 const chunk = item.value
1039 if (chunk.kind === 'thinking') thinking.set(chunk.index, (thinking.get(chunk.index) ?? '') + chunk.text)
1040 if (chunk.kind === 'text') text.set(chunk.index, (text.get(chunk.index) ?? '') + chunk.text)
1041 if (chunk.kind === 'tool') toolIds.push(chunk.id)
1042 if (!agentId) {
1043 const label =
1044 chunk.kind === 'tool'
1045 ? `${chunk.name}…`
1046 : lastSentence([...(chunk.kind === 'text' ? text : thinking).values()].join(' ')) ?? live
1047 if (label !== live) {
1048 live = label
1049 await update($, steps, list => list.map(s => (s.id === stepId ? { ...s, label } : s)))
1050 }
1051 }
1052 yield chunk
1053 item = await stream.next()
1054 }
1055 const result = item.value
1056
1057 const thought = [...thinking.values()].join(' ').trim()
1058 const said = [...text.values()].join(' ').trim()
1059 if (agentId) {
1060 if (thought) await addEntry($, agentId, { kind: 'think', text: thought })
1061 if (said) await addEntry($, agentId, { kind: 'say', text: said })
1062 } else {
1063 const now = await $.clock.now()
1064 const calls = result.toolUses.map(use =>
1065 intentOf(use.name, typeof use.input === 'object' && use.input !== null ? (use.input as Record<string, unknown>) : {}),
1066 )
1067 const label =
1068 (calls.length && calls.map(c => c.what).join(' · ')) || (thought && headline(thought)) || (said && headline(said)) || 'Answered'
1069 const how = calls.map(c => c.how).filter(Boolean).join(' · ') || undefined
1070 const why = calls.length && thought ? headline(thought) : undefined
1071 await update($, info, i => ({ ...i, lastRequestAt: now }))
1072 await update($, steps, list => {
1073 const index = list.findIndex(s => s.id === stepId)
1074 const earlier = list.slice(Math.max(0, index - 3), index)
1075 const same = calls.length ? earlier.reverse().find(s => s.label === label && s.how === how) : undefined
1076 const repeats = same ? (same.repeats ?? 1) + 1 : undefined
1077 return list.map(s =>
1078 s.id === stepId
1079 ? { ...s, label, how, why, toolIds, doneIds: toolIds.filter(id => returned.has(id)), isDone: true, ...(repeats ? { flag: 'repeat' as const, repeats } : {}) }
1080 : s,
1081 )
1082 })
1083 }
1084
1085 return result
1086 })
1087
1088 on('agent.spawn', async ($, e, next) => {
1089 const now = await $.clock.now()
1090 const draft: Agent = {
1091 id: null,
1092 toolUseId: e.tool_use_id,
1093 description: e.description,
1094 type: e.subagentType,
1095 prompt: e.prompt,
1096 model: null,
1097 isBackground: e.background,
1098 isTeammate: Boolean(e.isTeammate),
1099 startedAt: now,
1100 endedAt: null,
1101 history: [],
1102 }
1103 await update($, agents, list => [...list, draft])
1104 const spawned = await next(e)
1105 await update($, agents, list =>
1106 list.flatMap(a => {
1107 if (a.toolUseId !== draft.toolUseId) return [a]
1108 if (spawned.deny !== undefined || !spawned.agentId) return []
1109 return [{ ...a, id: spawned.agentId, model: spawned.model }]
1110 }),
1111 )
1112 return spawned
1113 })
1114
1115 let refresh: { cancel: () => void } | null = null
1116 /** The title the agent set, given to `/rename` once its turn ends: a command run from a tool call would wait on that turn. */
1117 let renaming: string | null = null
1118 /** Hand over now was pressed: hidden until the handover is under way. */
1119 let triggered = false
1120 /** Start next session was pressed: one launch per handover. */
1121 let launched: string | null = null
1122
1123 on('tool.call', async ($, e, next) => {
1124 const agentId = e.agentId
1125 const ran = await next(e)
1126 const tool = String(e.tool)
1127 const input = e as unknown as Record<string, unknown>
1128 if (EDITING_TOOLS.has(tool)) {
1129 const path = input.file_path
1130 if (typeof path === 'string' && path.includes('/')) await trackRepo($, path.slice(0, path.lastIndexOf('/')) || '/')
1131 refresh?.cancel()
1132 refresh = $.clock.after(400, () => void refreshFiles($))
1133 }
1134 if (agentId) await logCall($, agentId, e, ran)
1135 else {
1136 returned.add(e.tool_use_id)
1137 await flagStep($, e.tool_use_id, ran)
1138 await markReturned($, e.tool_use_id)
1139 }
1140 await scanCall($, tool, input, ran)
1141 await scanDocs($, tool, input, ran)
1142 return ran
1143 })
1144
1145 on('tool.call', { tool: TITLE_TOOL_ID }, async ($, e) => {
1146 const title = cleanTitle(String((e as unknown as Record<string, unknown>).title ?? ''))
1147 if (!title) return { deny: 'An empty title: give the overall topic in 3 to 7 words.' }
1148 await update($, topic, () => title)
1149 const sid = await $.session.id()
1150 await update($, titled, t => ({ ...t, set: sid }))
1151 renaming = title
1152 const said = `Session title set: ${title}`
1153 return { result: said as never, text: said }
1154 })
1155
1156 on('turn.complete', async ($, e, next) => {
1157 const title = e.agentId ? null : renaming
1158 if (title) {
1159 renaming = null
1160 const canRename = (await $.command.list()).some(c => c.name === 'rename')
1161 if (canRename) void $.command.run({ command: 'rename', args: title }).catch(() => undefined)
1162 }
1163 if (e.agentId && !(await read($, agents)).find(a => a.id === e.agentId)?.isTeammate) {
1164 await finish($, e.agentId, e.answer)
1165 }
1166 return next(e)
1167 })
1168
1169 on('classic.SubagentStop', async ($, e, next) => {
1170 await finish($, e.agent_id)
1171 return next(e)
1172 })
1173
1174 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1175 const { Box, Text, Button, Link } = $.ui.resolve(e)
1176 const [now, i, all, done, open, isStepsOpen, picker, repos, stored, shown, t, ho, repoList, written, conf, usage, shownTab, isQuotasOpen, title] = await Promise.all([
1177 $.clock.now(),
1178 read($, info),
1179 read($, agents),
1180 read($, steps),
1181 read($, expanded),
1182 read($, stepsOpen),
1183 read($, picking),
1184 read($, changes),
1185 read($, prompts),
1186 read($, view),
1187 read($, tracker),
1188 read($, handover),
1189 read($, workedRepos),
1190 read($, documents),
1191 read($, config),
1192 $.session.usage(),
1193 read($, tab),
1194 read($, quotasOpen),
1195 read($, topic),
1196 ])
1197 const typed = stored.map(promptOf)
1198 const lastPrompt = typed.at(-1)
1199 const quotas = usage.rateLimits
1200 .map(l => quotaOf(l.kind, l.percentUsed, l.resetsAt, now))hooks/files.ts 95 lines1import type { Commit, FileChange } from '../types'
2
3/** `git status --porcelain=v1 -z` → each changed path and how it changed. */
4export const parseStatus = (out: string): Map<string, FileChange['status']> => {
5 const changes = new Map<string, FileChange['status']>()
6 const parts = out.split('\0')
7 for (let k = 0; k < parts.length; k++) {
8 const entry = parts[k]!
9 if (entry.length < 4) continue
10 const xy = entry.slice(0, 2)
11 const path = entry.slice(3)
12 if (xy.includes('R') || xy.includes('C')) k++ // the source path follows
13 changes.set(
14 path,
15 xy === '??' || xy.includes('A') ? 'added' : xy.includes('D') ? 'deleted' : 'modified',
16 )
17 }
18 return changes
19}
20
21/** `git diff --numstat` → lines added and removed per path (binary: 0). */
22export const parseNumstat = (out: string): Map<string, { added: number; removed: number }> => {
23 const counts = new Map<string, { added: number; removed: number }>()
24 for (const line of out.split('\n')) {
25 const [added, removed, ...path] = line.split('\t')
26 if (!path.length) continue
27 const name = path.join('\t').replace(/^(.*)\{.* => (.*)\}(.*)$/, (_, pre, to, rest) => `${pre}${to}${rest}`)
28 counts.set(name.includes(' => ') ? name.split(' => ')[1]! : name, {
29 added: Number(added) || 0,
30 removed: Number(removed) || 0,
31 })
32 }
33 return counts
34}
35
36export type TreeRow = { indent: string; name: string; change?: FileChange }
37
38/**
39 * The changes as a tree following the hierarchy, a directory holding one
40 * directory and nothing else folded into it (`src/hooks/`), as an IDE does.
41 */
42export const treeRows = (changes: readonly FileChange[]): TreeRow[] => {
43 type Node = { dirs: Map<string, Node>; files: FileChange[] }
44 const root: Node = { dirs: new Map(), files: [] }
45 for (const change of changes) {
46 const parts = change.path.split('/')
47 let node = root
48 for (const dir of parts.slice(0, -1)) {
49 if (!node.dirs.has(dir)) node.dirs.set(dir, { dirs: new Map(), files: [] })
50 node = node.dirs.get(dir)!
51 }
52 node.files.push(change)
53 }
54
55 const rows: TreeRow[] = []
56 const walk = (node: Node, indent: string) => {
57 const dirs = [...node.dirs.entries()].sort(([a], [b]) => a.localeCompare(b))
58 const files = [...node.files].sort((a, b) => a.path.localeCompare(b.path))
59 const count = dirs.length + files.length
60 let index = 0
61 for (const [name, child] of dirs) {
62 let label = name
63 let deep = child
64 while (deep.files.length === 0 && deep.dirs.size === 1) {
65 const [next, inner] = [...deep.dirs.entries()][0]!
66 label += `/${next}`
67 deep = inner
68 }
69 const isLast = ++index === count
70 rows.push({ indent: indent + (isLast ? '└ ' : '├ '), name: `${label}/` })
71 walk(deep, indent + (isLast ? ' ' : '│ '))
72 }
73 for (const file of files) {
74 const isLast = ++index === count
75 rows.push({ indent: indent + (isLast ? '└ ' : '├ '), name: file.path.split('/').pop()!, change: file })
76 }
77 }
78 walk(root, '')
79 return rows
80}
81
82/** The `git log` format `parseLog` reads: hash, time, subject and body, unit-separated. */
83export const LOG_FORMAT = '%H%x1f%ct%x1f%s%x1f%b%x1e'
84
85/** `git log --format=LOG_FORMAT` → commits, pushed unless `unpushed` holds them. */
86export const parseLog = (out: string, unpushed: ReadonlySet<string> | 'all'): Commit[] =>
87 out
88 .split('\x1e')
89 .map(record => record.replace(/^\n/, ''))
90 .filter(record => record.includes('\x1f'))
91 .map(record => {
92 const [hash = '', at = '0', subject = '', body = ''] = record.split('\x1f')
93 return { hash, subject, body: body.trim(), at: Number(at) * 1000, isPushed: unpushed !== 'all' && !unpushed.has(hash) }
94 })
95hooks/documents.ts 76 lines1import type { Doc } from '../types'
2
3export const ARTIFACTS = 'Artifacts'
4export const HANDOVERS = 'Agent handovers'
5export const PLANS = 'Plans'
6/** The handover plugin's default `handover_dir`, and Claude Code's default `plansDirectory`. */
7export const HANDOVER_DIR = '~/Notes/claude/agent-handovers'
8export const PLANS_DIR = '~/.claude/plans'
9
10/** Where documents are told apart, as real paths: `$HOME`, the temporary folders, the project's git folder, and the handover and plans folders. */
11export type DocContext = { home: string; tmp: string[]; project: string | null; handovers: string; plans: string }
12
13/** A file as read on disk: its real path, and the git folder of the repository it lies in (null outside one). */
14export type Spot = { path: string; real: string; repo: string | null }
15
16const titleOf = (folder: string): string => {
17 const words = folder.replace(/[-_]+/g, ' ').trim()
18 return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Documents'
19}
20
21const nameOf = (path: string): string => (path.split('/').pop() ?? path).replace(/\.md$/i, '')
22
23/** `/Users/me/x` → `~/x`. */
24export const tilde = (path: string, home: string): string => (home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path)
25
26/**
27 * A Markdown file the agent wrote → the document it is, or null where it is
28 * none: temporary, in a hidden folder, or part of the project's repository
29 * (its git diff shows it). The handover and plans folders give their kind,
30 * hidden or not; any other file takes its folder's name.
31 */
32export const docOf = (spot: Spot, ctx: DocContext): Doc | null => {
33 if (!/\.md$/i.test(spot.real)) return null
34 const under = (dir: string) => Boolean(dir) && spot.real.startsWith(`${dir}/`)
35 const doc = (kind: string): Doc => ({ id: spot.real, path: spot.path, kind, name: nameOf(spot.real) })
36 if (under(ctx.handovers)) return doc(HANDOVERS)
37 if (under(ctx.plans)) return doc(PLANS)
38 if (ctx.tmp.some(under)) return null
39 if (ctx.project && spot.repo === ctx.project) return null
40 const parts = spot.real.split('/').filter(Boolean)
41 if (parts.some(part => part.startsWith('.'))) return null
42 return doc(titleOf(parts[parts.length - 2] ?? ''))
43}
44
45/** The absolute Markdown paths a shell command names, `~` and `$HOME` expanded. */
46export const docPaths = (command: string, home: string): string[] => {
47 const found = new Set<string>()
48 for (const token of command.split(/[\s'"<>|;&()=]+/)) {
49 if (!/\.md$/i.test(token)) continue
50 const path = token.replace(/^(~|\$HOME|\$\{HOME\})\//, `${home}/`)
51 if (path.startsWith('/')) found.add(path)
52 }
53 return [...found]
54}
55
56/** An Artifact call that published a page → the artifact, by its claude.ai link; null for any other call. */
57export const artifactOf = (input: Record<string, unknown>, result: unknown): Doc | null => {
58 if ((input.action ?? 'publish') !== 'publish' || !result || typeof result !== 'object') return null
59 const r = result as Record<string, unknown>
60 if (typeof r.url !== 'string') return null
61 const named = [r.title, input.title, typeof input.file_path === 'string' ? input.file_path.split('/').pop()?.replace(/\.[^.]+$/, '') : null]
62 const name = named.find((n): n is string => typeof n === 'string' && n.trim() !== '') ?? r.url
63 return { id: r.url, path: r.url, kind: ARTIFACTS, name }
64}
65
66/** Documents grouped by kind: artifacts, handovers and plans first, then the other folders as first written. */
67export const docGroups = (docs: Doc[]): { kind: string; docs: Doc[] }[] => {
68 const order = [ARTIFACTS, HANDOVERS, PLANS]
69 const kinds = [...new Set(docs.map(d => d.kind))].sort((a, b) => {
70 const ia = order.indexOf(a)
71 const ib = order.indexOf(b)
72 return (ia < 0 ? order.length : ia) - (ib < 0 ? order.length : ib)
73 })
74 return kinds.map(kind => ({ kind, docs: docs.filter(d => d.kind === kind) }))
75}
76hooks/tracker.ts 101 lines1/**
2 * Finds the tracker issue and the pull request a session is about, in the
3 * prompts it was given and the commands it ran.
4 */
5
6/**
7 * Where a reference was seen: the lower, the more it is the session's own. A
8 * bare `#N` in a prompt (`#1, #2, #3` numbering a list) counts least.
9 */
10export const RANK = { prompt: 0, created: 1, worked: 2, mentioned: 3 } as const
11
12/** `isBare` marks a `#N` written without its repository. */
13export type GithubRef = { platform: 'github'; repo: string; number: number; type: 'issue' | 'pull' | null; isBare?: true }
14export type LinearRef = { platform: 'linear'; id: string; workspace: string | null; slug: string | null }
15export type Ref = GithubRef | LinearRef
16
17const GITHUB_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/(issues|pull)\/(\d+)/g
18const GITHUB_SHORT = /(?<![\w/#])(?:([\w.-]+)\/)?([A-Za-z][\w.-]*)?#(\d+)\b/g
19const LINEAR_URL = /https:\/\/linear\.app\/([\w-]+)\/issue\/([A-Z][A-Z0-9]+-\d+)(?:\/([\w-]+))?/g
20/** The same, for the first match alone: `exec` on a non-global pattern keeps no state between calls. */
21const LINEAR_URL_FIRST = new RegExp(LINEAR_URL.source)
22
23/** `owner/repo` from a remote URL, ssh or https. */
24export const repoOfRemote = (remote: string): string | null =>
25 /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote.trim())?.[1] ?? null
26
27/**
28 * The issues and pull requests a text names: GitHub and Linear URLs, and
29 * `owner/repo#N`, `repo#N` or `#N` read against `home`, the session's repository.
30 */
31export const findRefs = (text: string, home: string | null): Ref[] => {
32 const refs: Ref[] = []
33 const add = (ref: Ref) => {
34 if (!refs.some(r => refKey(r) === refKey(ref))) refs.push(ref)
35 }
36 const bare = text.replace(GITHUB_URL, (_, repo: string, kind: string, n: string) => {
37 add({ platform: 'github', repo, number: Number(n), type: kind === 'pull' ? 'pull' : 'issue' })
38 return ' '
39 })
40 for (const m of bare.matchAll(GITHUB_SHORT)) {
41 const [, owner, name, n] = m
42 const homeOwner = home?.split('/')[0]
43 const repo = owner && name ? `${owner}/${name}` : name ? (homeOwner ? `${homeOwner}/${name}` : null) : home
44 if (repo) add({ platform: 'github', repo, number: Number(n), type: null, ...(name ? {} : { isBare: true as const }) })
45 }
46 for (const m of text.matchAll(LINEAR_URL)) add({ platform: 'linear', workspace: m[1]!, id: m[2]!, slug: m[3] ?? null })
47 return refs
48}
49
50export const refKey = (ref: Ref): string => (ref.platform === 'github' ? `${ref.repo}#${ref.number}` : ref.id)
51
52/**
53 * The references a `gh` command works on, `created` when it opened one: the
54 * URL `gh issue create` or `gh pr create` prints, or the number or URL it names.
55 */
56export const refsOfGh = (
57 command: string,
58 output: string,
59 home: string | null,
60): { refs: Ref[]; rank: number } | null => {
61 const m = /\bgh\s+(issue|pr)\s+([a-z-]+)(.*)/s.exec(command)
62 if (!m) return null
63 const [, noun, verb, rest] = m
64 const type = noun === 'pr' ? 'pull' : 'issue'
65 if (verb === 'create') {
66 const refs = findRefs(output, home).filter(r => r.platform === 'github' && r.type === type)
67 return { refs, rank: RANK.created }
68 }
69 if (verb === 'list' || verb === 'status') return null
70 const repo = /(?:-R|--repo)[\s=]+([\w.-]+\/[\w.-]+)/.exec(rest!)?.[1] ?? home
71 const target = /(?:^|\s)(\d+|https:\/\/github\.com\/\S+)(?=\s|$)/.exec(rest!.replace(/(?:-R|--repo)[\s=]+\S+/, ''))?.[1]
72 if (!target) return { refs: [], rank: RANK.worked }
73 if (/^\d+$/.test(target)) return repo ? { refs: [{ platform: 'github', repo, number: Number(target), type }], rank: RANK.worked } : null
74 return { refs: findRefs(target, home), rank: RANK.worked }
75}
76
77/** A Linear issue an MCP result describes: its identifier, title and URL. */
78export const linearOfResult = (text: string): { ref: LinearRef; title: string | null } | null => {
79 const url = LINEAR_URL_FIRST.exec(text)
80 if (!url) return null
81 let title: string | null = null
82 try {
83 const data = JSON.parse(text) as { title?: unknown; identifier?: unknown }
84 if (typeof data.title === 'string') title = data.title
85 } catch {
86 title = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text)?.[1] ?? null
87 }
88 return { ref: { platform: 'linear', workspace: url[1]!, id: url[2]!, slug: url[3] ?? null }, title }
89}
90
91/** `fix-the-login-bug` → `Fix the login bug`. */
92export const titleOfSlug = (slug: string | null): string | null =>
93 slug ? slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ') : null
94
95/** GitHub's own colours for a pull request's state. */
96export const PR_COLOR = { draft: '#babbf1', open: '#3fb950', closed: '#f85149', merged: '#a371f7' } as const
97
98/** A pull request's state from the issues API: draft, open, closed or merged. */
99export const prState = (data: { state?: string; draft?: boolean; merged?: boolean }): keyof typeof PR_COLOR =>
100 data.merged ? 'merged' : data.state === 'closed' ? 'closed' : data.draft ? 'draft' : 'open'
101types/index.d.ts 175 lines1export type Ttl = '5m' | '1h'
2
3export type Info = {
4 model: string | null
5 effort: string | null
6 lastRequestAt: number | null
7 ttl: Ttl | null
8}
9
10export type Entry = { kind: 'think' | 'say' | 'tool' | 'result' | 'error'; text: string }
11
12export type Agent = {
13 id: string | null
14 toolUseId: string
15 description: string
16 type: string
17 prompt: string
18 model: string | null
19 isBackground: boolean
20 isTeammate: boolean
21 startedAt: number
22 endedAt: number | null
23 history: Entry[]
24}
25
26export type FileChange = {
27 path: string
28 status: 'added' | 'modified' | 'deleted'
29 added: number
30 removed: number
31}
32
33export type Commit = { hash: string; subject: string; body: string; at: number; isPushed: boolean }
34
35export type RepoChanges = { root: string; branch: string; files: FileChange[]; commits: Commit[] }
36
37export type Picker = 'model' | 'effort' | null
38
39export type Quota = {
40 label: string
41 used: number
42 /** Share of the window elapsed, 0 to 1; null while the reset time is unknown. */
43 elapsed: number | null
44 /** Where the average burn lands at the reset, or when the limit runs out. */
45 verdict: { text: string; tone: 'ok' | 'tight' | 'out' } | null
46 /** Milliseconds until the window resets. */
47 resetsIn: number | null
48}
49
50export type Step = {
51 id: string
52 /** What the step did, in the agent's own words (a Bash call's description). */
53 label: string
54 /** How: the command, path or pattern. */
55 how?: string
56 /** Why: the first sentence of the thinking before it. */
57 why?: string
58 toolIds?: string[]
59 /** The calls of `toolIds` that have returned. */
60 doneIds?: string[]
61 /** The model's answer has streamed in full. */
62 isDone: boolean
63 /** Refused or failed calls, and the same action taken again. */
64 flag?: 'refused' | 'failed' | 'repeat'
65 note?: string
66 repeats?: number
67}
68
69export type TrackedIssue = {
70 platform: 'github' | 'linear'
71 /** `owner/repo#N` or `KEY-N`. */
72 key: string
73 title: string | null
74 url: string | null
75 /** Linear's desktop link, opened instead of `url` where the app is installed. */
76 appUrl: string | null
77 rank: number
78}
79
80export type TrackedPr = {
81 repo: string
82 number: number
83 title: string | null
84 url: string
85 state: 'draft' | 'open' | 'closed' | 'merged' | null
86 rank: number
87}
88
89export type Tracker = {
90 issue: TrackedIssue | null
91 /** One pull request per repository, `owner/repo`. */
92 prs: TrackedPr[]
93 /** Bare `KEY-N` identifiers the prompts named, matched once a Linear tool shows one. */
94 mentioned: string[]
95}
96
97/** A repository the session worked on: its git common dir, `owner/repo` from its origin, its name, and the linked worktree it last edited in. */
98export type Repo = { common: string; slug: string | null; name: string; worktree: string | null }
99
100/** A handover file, with its length and last change (epoch ms) as read, null when unread. */
101export type HandoverFile = { path: string; title: string | null; lines: number | null; modifiedAt: number | null }
102
103export type Handover = {
104 /** The handover plugin runs in this session. */
105 isOn: boolean
106 /** The handover this session started from, and the one it wrote. */
107 loaded: HandoverFile | null
108 written: HandoverFile | null
109 suggest: number
110 trigger: number
111 warn: number
112 isWriting: boolean
113 /** The wind-down has started (the trigger, a request or /handover:trigger): no new task, those in progress finish. */
114 isWindingDown: boolean
115 error: string | null
116 /** The message the closing reply gives the next session to start on, once the handover is written. */
117 resume: string | null
118}
119
120/** The pane's tabs: the session at a glance, the rest (steps, sub-agents, git), the values it reads from configuration, and what each item means. */
121/** A typed prompt and when it was submitted (epoch ms); `at` null for one read back from the transcript. */
122export type Prompt = { text: string; at: number | null }
123
124export type Tab = 'main' | 'misc' | 'config' | 'help'
125
126/** Something tangible the session wrote: an artifact it published, or a Markdown file outside the project. */
127export type Doc = {
128 /** Its real path, or the artifact's link: one entry each. */
129 id: string
130 /** The path as written, or the artifact's link. */
131 path: string
132 /** `Artifacts`, `Agent handovers`, `Plans`, else its folder's name. */
133 kind: string
134 /** Its file name without `.md`, or the artifact's title. */
135 name: string
136}
137
138/** The values the pane reads from configuration, as set; null where unset and the default applies. */
139export type Config = {
140 cacheTtl: Ttl
141 autoCompactWindow: number | null
142 tokenLimit: number | null
143 tokenReserve: number | null
144 plansDirectory: string | null
145 handoverDir: string | null
146}
147
148declare module 'claude-code' {
149 interface PluginState {
150 'session-panel': {
151 info: Info
152 agents: Agent[]
153 steps: Step[]
154 expanded: string | null
155 stepsOpen: boolean
156 picking: Picker
157 prompts: (Prompt | string)[]
158 view: 'overview' | 'prompts'
159 roots: string[]
160 changes: RepoChanges[]
161 tracker: Tracker
162 home: string | null
163 handover: Handover | null
164 repos: Repo[]
165 documents: Doc[]
166 config: Config
167 tab: Tab
168 quotasOpen: boolean
169 topic: string | null
170 /** The session whose title is set (by the agent or from its handover), and the one already reminded to set it. */
171 titled: { set: string | null; reminded: string | null }
172 }
173 }
174}
175