SLOPSHOPPER

session-mods

Two session mods: a calm side pane (first prompt, sub-agents with their history, the session's reasoning steps) and a bar beneath the prompt (running time…

newpanespinnerguardcommandtoast
v0.1.0no licenseupdated 2026-10-22FunDrivenDev/claude-plugins/plugins/session-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-mods
│ ┃ 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 │ ┃ Issue no issue ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Git no repository yet │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Documents │ ┃ None written yet. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Handover › /session-panel │ ┃ ⎿ session-mods: Session panel opened. │ ┃ 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 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⌛ running 30m00s ⏳ cache no request yet context 97.4k/167k (200k − 33k) 58% ○ Opus 5.5 ○ default ● 5h 31% 🔽

Draws

Pane · Session
Session ╭────────────────────────────────────────────────────────╮ │ Main │ ╰────────────────────────────────────────────────────────╯ 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
Prompt hint
⟨Claude Code's own drawing⟩ ⌛ running 30m00s ⏳ cache no request yet context 97.4k/167k (200k − 33k) 58% ○ Opus 5.5 ○ defa
README

session-mods

Two mods for a Claude Code session, drawn by the engine: a calm side pane with what every sub-agent was asked and did, and a one-row bar beneath the prompt with the session's live figures.

The session pane docked beside a Claude Code transcript

Install

/plugin marketplace add FunDrivenDev/claude-plugins
/plugin install session-mods@fundrivendev

The pane opens at session start when the terminal is at least 144 columns wide; /session-panel opens it at any width. The bar shows beneath the prompt from the start.

The pane

It gathers the session's issue, pull requests, documents, handover and git changes in one place.

  • Title and tabs: the session's topic as a title (also sent to /rename), and four tabs: Main, Misc, Config and Help.
  • Issue and Git: the tracker issue the session is about, then each repository it touched with its pull request and worktree.
  • Documents: the artifacts, handovers, plans and notes the session wrote, linked.
  • Handover: the handover plugin's status, the handover loaded and written, and buttons to hand over now or start the next session.
  • Last prompt: your last prompt, with the whole session's prompt history one click away.
  • Steps: an audit trail of the main loop, with refusals, failures and repeated actions flagged.
  • Sub-agents: every sub-agent, background agent and teammate, with its task and history on demand.
  • Git diff and history: the uncommitted changes as a tree and the commits made since the session began.
  • Colours: Catppuccin Frappé, switching to light twins with the macOS appearance.

The bar

How long the session has run, when the prompt cache expires, how full the context is, the 5-hour and 7-day quotas, and the model and effort, each switchable with a click. It sits beneath the engine's own hint line, which stays in place. See Bar.

Configuration

OptionValuesDefaultWhat it sets
cacheTtl1h, 5m1hThe prompt-cache lifetime the bar's countdown starts from; a model switch reports the real one.

The pane has no option of its own: it reads Claude Code's plansDirectory and the handover plugin's settings. See Configuration.

Documentation

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