SLOPSHOPPER

session-panel

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

newpaneguardcommandtoastprompt
v0.8.0no licenseupdated 2026-10-08FunDrivenDev/claude-plugins/plugins/session-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-panel
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ Session │ ┃ ╭────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Main │ ⎿ Read 6 lines │ ┃ ╰────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⌛ running 30m00s ⏳ cache no request yet… ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Issue no issue │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Git no repository yet │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Documents │ ┃ None written yet. › /session-panel │ ┃ ⎿ session-panel: Session panel opened. │ ┃ Handover │ ┃ │ ┃ The handover plugin is off in this │ ┃ session. │ ┃ │ ┃ Last prompt 08:53 (1 previous prompt) │ ┃ fix the failing auth test and add an audit │ ┃ log call │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
Session ╭────────────────────────────────────────────────────────╮ │ Main │ ╰────────────────────────────────────────────────────────╯ ⌛ running 30m00s ⏳ cache no request yet context 97.4k… Issue no issue Git no repository yet Documents None written yet. Handover The handover plugin is off in this session. Last prompt 08:53 (1 previous prompt) fix the failing auth test and add an audit log call
README

session-panel

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
  • Top lines: the session's title, its overall topic in a few words, on a line of its own; beneath, the tabs on the left, then the model and effort selectors beside the quota pill, and a close mark. The main agent names the topic with the plugin's 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.
  • Colours: Catppuccin Frappé and the status line's bright tones on a dark macOS appearance; on a light one, every colour turns to its twin that reads on white (Catppuccin Latte, darker tones for the status line's yellow and orange), following the system setting within five seconds. Off macOS, the dark palette. Under the pointer, every action (a tab, a pill, a fold, a sub-agent or a commit) lights in its colour, blue when it has none, and every link (a document, a handover, the issue and the pull request) underlines.
  • Selectors: the main loop's model and effort as two rounded pills beside the tabs, selected from the session's start (the model /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).
  • Header: the session's age (⌛) 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.
  • Quotas: one pill beside the close mark, ● 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 and Git, the first sections of Main: 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.
  • Documents: what the session wrote that lasts, grouped by kind: the artifacts it published (linked to claude.ai), then the handovers (in the handover plugin's 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: what the 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.
  • Last prompt: the last prompt you typed, without the tags the engine wraps around it; slash commands are left out. Greyed beside its title, the time you sent it (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.
  • Steps: an audit trail of the main loop, one entry per model request: what it did in the agent's own words (a Bash call's description, 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.
  • Sub-agents: how many run and are done, then one line per sub-agent, background agent or teammate: its mission, its kind, its age and what it is doing now (its last call or thought). Press one to expand it, brought to the top of the pane: its task (the first 4,000 characters) and its 40 latest entries, the earlier ones counted; ▴ Collapse at its foot closes it. Reopening the pane (/session-panel, a reload) starts on the overview, every sub-agent closed.
  • Git diff, at the bottom: for the session's repository and every repository a tool edited in (worktrees included), the changes against 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).

Install

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

Settings

SettingDefaultWhat it does
cacheTtl1hThe prompt-cache lifetime the countdown starts from (1h or 5m), until a model switch reports the real one. In /config.

Limits

  • Steps and sub-agents are recorded from the moment the plugin loads; the first prompt is read back from the transcript.
  • The handover thresholds come from the handover plugin's options.json or its defaults (150k suggested, 185k trigger), not from CLAUDE_PLUGIN_OPTION_* overrides.
  • The TTL is not reported per request: the countdown uses the setting until a model switch tells the real one.

Developing

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

Source 5 files
hooks/register.tsx 2162 lines
1import { 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 lines
1import 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    })
95
hooks/documents.ts 76 lines
1import 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}
76
hooks/tracker.ts 101 lines
1/**
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'
101
types/index.d.ts 175 lines
1export 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