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…

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.

/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.
It gathers the session's issue, pull requests, documents, handover and git changes in one place.
/rename), and four tabs: Main, Misc, Config and Help.handover plugin's status, the handover loaded and written, and buttons to hand over now or start the next session.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.
| Option | Values | Default | What it sets |
|---|---|---|---|
cacheTtl | 1h, 5m | 1h | The 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.
hooks/register.tsx 31 lines1import 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}
31hooks/bar.tsx 434 lines1import { 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}
434hooks/panel.tsx 1752 lines1import { 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 lines1import type { Commit, FileChange } from '../types'
2
3/** `git status --porcelain=v1 -z` → each changed path and how it changed. */
4export const parseStatus = (out: string): Map<string, FileChange['status']> => {
5 const changes = new Map<string, FileChange['status']>()
6 const parts = out.split('\0')
7 for (let k = 0; k < parts.length; k++) {
8 const entry = parts[k]!
9 if (entry.length < 4) continue
10 const xy = entry.slice(0, 2)
11 const path = entry.slice(3)
12 if (xy.includes('R') || xy.includes('C')) k++ // the source path follows
13 changes.set(
14 path,
15 xy === '??' || xy.includes('A') ? 'added' : xy.includes('D') ? 'deleted' : 'modified',
16 )
17 }
18 return changes
19}
20
21/** `git diff --numstat` → lines added and removed per path (binary: 0). */
22export const parseNumstat = (out: string): Map<string, { added: number; removed: number }> => {
23 const counts = new Map<string, { added: number; removed: number }>()
24 for (const line of out.split('\n')) {
25 const [added, removed, ...path] = line.split('\t')
26 if (!path.length) continue
27 const name = path.join('\t').replace(/^(.*)\{.* => (.*)\}(.*)$/, (_, pre, to, rest) => `${pre}${to}${rest}`)
28 counts.set(name.includes(' => ') ? name.split(' => ')[1]! : name, {
29 added: Number(added) || 0,
30 removed: Number(removed) || 0,
31 })
32 }
33 return counts
34}
35
36export type TreeRow = { indent: string; name: string; change?: FileChange }
37
38/**
39 * The changes as a tree following the hierarchy, a directory holding one
40 * directory and nothing else folded into it (`src/hooks/`), as an IDE does.
41 */
42export const treeRows = (changes: readonly FileChange[]): TreeRow[] => {
43 type Node = { dirs: Map<string, Node>; files: FileChange[] }
44 const root: Node = { dirs: new Map(), files: [] }
45 for (const change of changes) {
46 const parts = change.path.split('/')
47 let node = root
48 for (const dir of parts.slice(0, -1)) {
49 if (!node.dirs.has(dir)) node.dirs.set(dir, { dirs: new Map(), files: [] })
50 node = node.dirs.get(dir)!
51 }
52 node.files.push(change)
53 }
54
55 const rows: TreeRow[] = []
56 const walk = (node: Node, indent: string) => {
57 const dirs = [...node.dirs.entries()].sort(([a], [b]) => a.localeCompare(b))
58 const files = [...node.files].sort((a, b) => a.path.localeCompare(b.path))
59 const count = dirs.length + files.length
60 let index = 0
61 for (const [name, child] of dirs) {
62 let label = name
63 let deep = child
64 while (deep.files.length === 0 && deep.dirs.size === 1) {
65 const [next, inner] = [...deep.dirs.entries()][0]!
66 label += `/${next}`
67 deep = inner
68 }
69 const isLast = ++index === count
70 rows.push({ indent: indent + (isLast ? '└ ' : '├ '), name: `${label}/` })
71 walk(deep, indent + (isLast ? ' ' : '│ '))
72 }
73 for (const file of files) {
74 const isLast = ++index === count
75 rows.push({ indent: indent + (isLast ? '└ ' : '├ '), name: file.path.split('/').pop()!, change: file })
76 }
77 }
78 walk(root, '')
79 return rows
80}
81
82/** The `git log` format `parseLog` reads: hash, time, subject and body, unit-separated. */
83export const LOG_FORMAT = '%H%x1f%ct%x1f%s%x1f%b%x1e'
84
85/** `git log --format=LOG_FORMAT` → commits, pushed unless `unpushed` holds them. */
86export const parseLog = (out: string, unpushed: ReadonlySet<string> | 'all'): Commit[] =>
87 out
88 .split('\x1e')
89 .map(record => record.replace(/^\n/, ''))
90 .filter(record => record.includes('\x1f'))
91 .map(record => {
92 const [hash = '', at = '0', subject = '', body = ''] = record.split('\x1f')
93 return { hash, subject, body: body.trim(), at: Number(at) * 1000, isPushed: unpushed !== 'all' && !unpushed.has(hash) }
94 })
95hooks/documents.ts 76 lines1import type { Doc } from '../types'
2
3export const ARTIFACTS = 'Artifacts'
4export const HANDOVERS = 'Agent handovers'
5export const PLANS = 'Plans'
6/** The handover plugin's default `handover_dir`, and Claude Code's default `plansDirectory`. */
7export const HANDOVER_DIR = '~/Notes/claude/agent-handovers'
8export const PLANS_DIR = '~/.claude/plans'
9
10/** Where documents are told apart, as real paths: `$HOME`, the temporary folders, the project's git folder, and the handover and plans folders. */
11export type DocContext = { home: string; tmp: string[]; project: string | null; handovers: string; plans: string }
12
13/** A file as read on disk: its real path, and the git folder of the repository it lies in (null outside one). */
14export type Spot = { path: string; real: string; repo: string | null }
15
16const titleOf = (folder: string): string => {
17 const words = folder.replace(/[-_]+/g, ' ').trim()
18 return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Documents'
19}
20
21const nameOf = (path: string): string => (path.split('/').pop() ?? path).replace(/\.md$/i, '')
22
23/** `/Users/me/x` → `~/x`. */
24export const tilde = (path: string, home: string): string => (home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path)
25
26/**
27 * A Markdown file the agent wrote → the document it is, or null where it is
28 * none: temporary, in a hidden folder, or part of the project's repository
29 * (its git diff shows it). The handover and plans folders give their kind,
30 * hidden or not; any other file takes its folder's name.
31 */
32export const docOf = (spot: Spot, ctx: DocContext): Doc | null => {
33 if (!/\.md$/i.test(spot.real)) return null
34 const under = (dir: string) => Boolean(dir) && spot.real.startsWith(`${dir}/`)
35 const doc = (kind: string): Doc => ({ id: spot.real, path: spot.path, kind, name: nameOf(spot.real) })
36 if (under(ctx.handovers)) return doc(HANDOVERS)
37 if (under(ctx.plans)) return doc(PLANS)
38 if (ctx.tmp.some(under)) return null
39 if (ctx.project && spot.repo === ctx.project) return null
40 const parts = spot.real.split('/').filter(Boolean)
41 if (parts.some(part => part.startsWith('.'))) return null
42 return doc(titleOf(parts[parts.length - 2] ?? ''))
43}
44
45/** The absolute Markdown paths a shell command names, `~` and `$HOME` expanded. */
46export const docPaths = (command: string, home: string): string[] => {
47 const found = new Set<string>()
48 for (const token of command.split(/[\s'"<>|;&()=]+/)) {
49 if (!/\.md$/i.test(token)) continue
50 const path = token.replace(/^(~|\$HOME|\$\{HOME\})\//, `${home}/`)
51 if (path.startsWith('/')) found.add(path)
52 }
53 return [...found]
54}
55
56/** An Artifact call that published a page → the artifact, by its claude.ai link; null for any other call. */
57export const artifactOf = (input: Record<string, unknown>, result: unknown): Doc | null => {
58 if ((input.action ?? 'publish') !== 'publish' || !result || typeof result !== 'object') return null
59 const r = result as Record<string, unknown>
60 if (typeof r.url !== 'string') return null
61 const named = [r.title, input.title, typeof input.file_path === 'string' ? input.file_path.split('/').pop()?.replace(/\.[^.]+$/, '') : null]
62 const name = named.find((n): n is string => typeof n === 'string' && n.trim() !== '') ?? r.url
63 return { id: r.url, path: r.url, kind: ARTIFACTS, name }
64}
65
66/** Documents grouped by kind: artifacts, handovers and plans first, then the other folders as first written. */
67export const docGroups = (docs: Doc[]): { kind: string; docs: Doc[] }[] => {
68 const order = [ARTIFACTS, HANDOVERS, PLANS]
69 const kinds = [...new Set(docs.map(d => d.kind))].sort((a, b) => {
70 const ia = order.indexOf(a)
71 const ib = order.indexOf(b)
72 return (ia < 0 ? order.length : ia) - (ib < 0 ? order.length : ib)
73 })
74 return kinds.map(kind => ({ kind, docs: docs.filter(d => d.kind === kind) }))
75}
76hooks/tracker.ts 101 lines1/**
2 * Finds the tracker issue and the pull request a session is about, in the
3 * prompts it was given and the commands it ran.
4 */
5
6/**
7 * Where a reference was seen: the lower, the more it is the session's own. A
8 * bare `#N` in a prompt (`#1, #2, #3` numbering a list) counts least.
9 */
10export const RANK = { prompt: 0, created: 1, worked: 2, mentioned: 3 } as const
11
12/** `isBare` marks a `#N` written without its repository. */
13export type GithubRef = { platform: 'github'; repo: string; number: number; type: 'issue' | 'pull' | null; isBare?: true }
14export type LinearRef = { platform: 'linear'; id: string; workspace: string | null; slug: string | null }
15export type Ref = GithubRef | LinearRef
16
17const GITHUB_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/(issues|pull)\/(\d+)/g
18const GITHUB_SHORT = /(?<![\w/#])(?:([\w.-]+)\/)?([A-Za-z][\w.-]*)?#(\d+)\b/g
19const LINEAR_URL = /https:\/\/linear\.app\/([\w-]+)\/issue\/([A-Z][A-Z0-9]+-\d+)(?:\/([\w-]+))?/g
20/** The same, for the first match alone: `exec` on a non-global pattern keeps no state between calls. */
21const LINEAR_URL_FIRST = new RegExp(LINEAR_URL.source)
22
23/** `owner/repo` from a remote URL, ssh or https. */
24export const repoOfRemote = (remote: string): string | null =>
25 /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote.trim())?.[1] ?? null
26
27/**
28 * The issues and pull requests a text names: GitHub and Linear URLs, and
29 * `owner/repo#N`, `repo#N` or `#N` read against `home`, the session's repository.
30 */
31export const findRefs = (text: string, home: string | null): Ref[] => {
32 const refs: Ref[] = []
33 const add = (ref: Ref) => {
34 if (!refs.some(r => refKey(r) === refKey(ref))) refs.push(ref)
35 }
36 const bare = text.replace(GITHUB_URL, (_, repo: string, kind: string, n: string) => {
37 add({ platform: 'github', repo, number: Number(n), type: kind === 'pull' ? 'pull' : 'issue' })
38 return ' '
39 })
40 for (const m of bare.matchAll(GITHUB_SHORT)) {
41 const [, owner, name, n] = m
42 const homeOwner = home?.split('/')[0]
43 const repo = owner && name ? `${owner}/${name}` : name ? (homeOwner ? `${homeOwner}/${name}` : null) : home
44 if (repo) add({ platform: 'github', repo, number: Number(n), type: null, ...(name ? {} : { isBare: true as const }) })
45 }
46 for (const m of text.matchAll(LINEAR_URL)) add({ platform: 'linear', workspace: m[1]!, id: m[2]!, slug: m[3] ?? null })
47 return refs
48}
49
50export const refKey = (ref: Ref): string => (ref.platform === 'github' ? `${ref.repo}#${ref.number}` : ref.id)
51
52/**
53 * The references a `gh` command works on, `created` when it opened one: the
54 * URL `gh issue create` or `gh pr create` prints, or the number or URL it names.
55 */
56export const refsOfGh = (
57 command: string,
58 output: string,
59 home: string | null,
60): { refs: Ref[]; rank: number } | null => {
61 const m = /\bgh\s+(issue|pr)\s+([a-z-]+)(.*)/s.exec(command)
62 if (!m) return null
63 const [, noun, verb, rest] = m
64 const type = noun === 'pr' ? 'pull' : 'issue'
65 if (verb === 'create') {
66 const refs = findRefs(output, home).filter(r => r.platform === 'github' && r.type === type)
67 return { refs, rank: RANK.created }
68 }
69 if (verb === 'list' || verb === 'status') return null
70 const repo = /(?:-R|--repo)[\s=]+([\w.-]+\/[\w.-]+)/.exec(rest!)?.[1] ?? home
71 const target = /(?:^|\s)(\d+|https:\/\/github\.com\/\S+)(?=\s|$)/.exec(rest!.replace(/(?:-R|--repo)[\s=]+\S+/, ''))?.[1]
72 if (!target) return { refs: [], rank: RANK.worked }
73 if (/^\d+$/.test(target)) return repo ? { refs: [{ platform: 'github', repo, number: Number(target), type }], rank: RANK.worked } : null
74 return { refs: findRefs(target, home), rank: RANK.worked }
75}
76
77/** A Linear issue an MCP result describes: its identifier, title and URL. */
78export const linearOfResult = (text: string): { ref: LinearRef; title: string | null } | null => {
79 const url = LINEAR_URL_FIRST.exec(text)
80 if (!url) return null
81 let title: string | null = null
82 try {
83 const data = JSON.parse(text) as { title?: unknown; identifier?: unknown }
84 if (typeof data.title === 'string') title = data.title
85 } catch {
86 title = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text)?.[1] ?? null
87 }
88 return { ref: { platform: 'linear', workspace: url[1]!, id: url[2]!, slug: url[3] ?? null }, title }
89}
90
91/** `fix-the-login-bug` → `Fix the login bug`. */
92export const titleOfSlug = (slug: string | null): string | null =>
93 slug ? slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ') : null
94
95/** GitHub's own colours for a pull request's state. */
96export const PR_COLOR = { draft: '#babbf1', open: '#3fb950', closed: '#f85149', merged: '#a371f7' } as const
97
98/** A pull request's state from the issues API: draft, open, closed or merged. */
99export const prState = (data: { state?: string; draft?: boolean; merged?: boolean }): keyof typeof PR_COLOR =>
100 data.merged ? 'merged' : data.state === 'closed' ? 'closed' : data.draft ? 'draft' : 'open'
101types/index.d.ts 173 lines1export 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