Pane listing the Claude Code and Codex sessions working in this repo or folder

A Claude Code mod that shows which Claude Code sessions and Codex threads are working in the current repository or folder, and whether each one is busy or idle.

/sessions-here): expands the strip into a swimlane of every session on a shared 10-minute axis, busy periods drawn as bars in the agent's color.claude://code/continue, Codex threads through codex://threads/<id>. Terminal sessions have no link and are not clickable./sessions-pane, optional): the same timeline with a header, docked beside the conversation.A session counts as "here" when it works in this folder, a subfolder, a parent folder (not your home folder or /), or another git worktree of the same repository. The desktop Code tab draws SVG; the terminal draws a text version.
| Agent | Source | Busy when |
|---|---|---|
| Claude Code | ~/.claude/sessions/<pid>.json (skipped when the process has exited) | the session reports busy |
| Codex | ~/.codex/sessions/**/rollout-*.jsonl written in the last 60 minutes; title from ~/.codex/session_index.jsonl | its log was written in the last 30 seconds |
A Codex thread waiting for your approval shows as idle. Activity history starts when the mod loads.
Requires Claude Code 2.1.286 or later on macOS (clicking a session uses open and the Claude and Codex desktop apps' links).
git clone https://github.com/gblacher2/repo-sessions.git ~/repo-sessions
Add the folder to the env block of ~/.claude/settings.json, then start a new session:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/repo-sessions"
}
For a single session: claude --plugin-dir ~/repo-sessions.
claude plugin validate .
npx -p typescript tsc -p .
claude plugin test .
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates when it loads the mod (run /plugin-types otherwise). claude plugin test needs the hooks-module rollout switch on for your account.
MIT. See LICENSE.
hooks/register.tsx 382 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Peer, Snapshot, Subagent } from '../types'
4
5import { BUCKET_MS, HISTORY_BUCKETS, ago, chipWidth, nameSvg, paneSvg, pixelTextSvg, relativeCwd, spriteSvg, stripText, summarySvg } from './draw'
6
7const PANE = 'repo-sessions'
8const TITLE = 'Sessions here'
9const POLL_MS = 3000
10// A Codex thread counts as busy while its log was written this recently,
11// and is listed at all while written within the window.
12const CODEX_BUSY_MS = 30_000
13const CODEX_WINDOW_MIN = 60
14
15const SNAPSHOT = { plugin: 'repo-sessions', key: 'snapshot' } as const
16const EXPANDED = { plugin: 'repo-sessions', key: 'isExpanded' } as const
17
18type SessionFile = {
19 pid: number
20 sessionId: string
21 cwd: string
22 name?: string
23 status?: string
24 statusUpdatedAt?: number
25 updatedAt?: number
26 startedAt?: number
27 hostSessionId?: string
28}
29
30type Found = Omit<Peer, 'branch' | 'isWorktree' | 'history'>
31
32const isInside = (child: string, parent: string) =>
33 child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`)
34
35// Git's shared .git dir per folder: two worktrees of one repo share it.
36const commonDirCache = new Map<string, string | null>()
37// Codex rollout file -> its cwd, read once from the file's first line.
38const codexCwdCache = new Map<string, string | null>()
39let home: string | null = null
40let selfId = ''
41let polling = false
42// Busy/idle per 15 s bucket for each session, kept across scans.
43const histories = new Map<string, { bucket: number; values: number[] }>()
44
45function recordHistory(key: string, isBusy: boolean, now: number) {
46 const bucket = Math.floor(now / BUCKET_MS)
47 const h = histories.get(key) ?? { bucket, values: new Array<number>(HISTORY_BUCKETS).fill(0) }
48 const shift = Math.min(HISTORY_BUCKETS, bucket - h.bucket)
49 if (shift > 0) h.values = [...h.values.slice(shift), ...new Array<number>(shift).fill(0)]
50 h.bucket = bucket
51 if (isBusy) h.values[HISTORY_BUCKETS - 1] = 1
52 histories.set(key, h)
53 return [...h.values]
54}
55
56async function run($: EngineInterface, argv: string[]) {
57 try {
58 const r = await $.process.run(argv, { timeoutMs: 5000 })
59 return r.exitCode === 0 ? r.stdout : null
60 } catch {
61 return null
62 }
63}
64
65async function commonDir($: EngineInterface, cwd: string) {
66 if (!commonDirCache.has(cwd)) {
67 const out = await run($, ['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
68 commonDirCache.set(cwd, out === null ? null : out.trim())
69 }
70 return commonDirCache.get(cwd) ?? null
71}
72
73async function claudeSessions($: EngineInterface, now: number): Promise<Found[]> {
74 const dir = `${home}/.claude/sessions`
75 const entries = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
76 const files: SessionFile[] = []
77 for (const entry of entries) {
78 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
79 try {
80 files.push(JSON.parse(await $.fs.read(`${dir}/${entry.name}`)))
81 } catch {
82 // mid-write; the next tick reads it
83 }
84 }
85 if (files.length === 0) return []
86
87 const ps = await run($, ['ps', '-o', 'pid=', '-p', files.map(f => f.pid).join(',')])
88 const alive = new Set((ps ?? '').split('\n').map(l => Number(l.trim())).filter(Boolean))
89
90 return files
91 .filter(f => alive.has(f.pid) && typeof f.cwd === 'string')
92 .map(f => ({
93 id: f.sessionId,
94 agent: 'claude' as const,
95 name: f.name || f.sessionId.slice(0, 8),
96 cwd: f.cwd,
97 status: f.status === 'busy' ? ('busy' as const) : ('idle' as const),
98 since: f.statusUpdatedAt ?? f.updatedAt ?? f.startedAt ?? now,
99 isSelf: f.sessionId === selfId,
100 // Desktop sessions reopen through the Claude app; terminal ones have no link.
101 openUrl: /^local_[A-Za-z0-9-]{1,64}$/.test(f.hostSessionId ?? '')
102 ? `claude://code/continue?session=${f.hostSessionId}`
103 : undefined,
104 }))
105}
106
107async function codexThreads($: EngineInterface, now: number): Promise<Found[]> {
108 const out = await run($, [
109 'find', `${home}/.codex/sessions`, '-name', 'rollout-*.jsonl',
110 '-mmin', `-${CODEX_WINDOW_MIN}`, '-exec', 'stat', '-f', '%m %N', '{}', '+',
111 ])
112 if (!out) return []
113
114 const recent = out.split('\n').filter(Boolean).map(line => {
115 const space = line.indexOf(' ')
116 return { mtime: Number(line.slice(0, space)) * 1000, path: line.slice(space + 1) }
117 })
118 if (recent.length === 0) return []
119
120 const index = await run($, ['tail', '-n', '1000', `${home}/.codex/session_index.jsonl`])
121 const names = new Map<string, string>()
122 for (const line of (index ?? '').split('\n')) {
123 try {
124 const row = JSON.parse(line)
125 if (row.id && row.thread_name) names.set(row.id, row.thread_name)
126 } catch {
127 // partial line
128 }
129 }
130
131 const found: Found[] = []
132 for (const { mtime, path } of recent) {
133 if (!codexCwdCache.has(path)) {
134 const head = await run($, ['head', '-c', '4000', path])
135 const match = head?.match(/"cwd":("(?:[^"\\]|\\.)*")/)
136 codexCwdCache.set(path, match?.[1] ? JSON.parse(match[1]) : null)
137 }
138 const cwd = codexCwdCache.get(path)
139 if (!cwd) continue
140 const id = path.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/)?.[1] ?? path
141 found.push({
142 id,
143 agent: 'codex',
144 name: names.get(id) ?? id.slice(0, 8),
145 cwd,
146 status: now - mtime < CODEX_BUSY_MS ? 'busy' : 'idle',
147 since: mtime,
148 isSelf: false,
149 openUrl: /^[0-9a-f-]{36}$/.test(id) ? `codex://threads/${id}` : undefined,
150 })
151 }
152 return found
153}
154
155async function scan($: EngineInterface) {
156 if (polling) return
157 polling = true
158 try {
159 if (home === null) home = ((await run($, ['printenv', 'HOME'])) ?? '').trim()
160 const now = await $.clock.now()
161 const root = await $.session.root()
162 const myCommon = await commonDir($, root)
163
164 const all = [...(await claudeSessions($, now)), ...(await codexThreads($, now))]
165 const peers: Peer[] = []
166 for (const f of all) {
167 // A session in a parent folder counts too, but not one opened at home or /.
168 const isBroad = f.cwd === home || f.cwd === '/'
169 const sameFolder = isInside(f.cwd, root) || (!isBroad && isInside(root, f.cwd))
170 const sameRepo = !sameFolder && myCommon !== null && (await commonDir($, f.cwd)) === myCommon
171 if (!sameFolder && !sameRepo) continue
172 const branch = myCommon === null ? null : await run($, ['git', '-C', f.cwd, 'branch', '--show-current'])
173 const history = recordHistory(`${f.agent}:${f.id}`, f.status === 'busy', now)
174 peers.push({ ...f, branch: branch?.trim() || null, isWorktree: sameRepo, history })
175 }
176 peers.sort((a, b) =>
177 a.isSelf !== b.isSelf ? (a.isSelf ? -1 : 1)
178 : a.status !== b.status ? (a.status === 'busy' ? -1 : 1)
179 : b.since - a.since,
180 )
181
182 const subagents: Subagent[] = (await $.agent.list())
183 .filter(a => a.status === 'running')
184 .map(a => ({ id: a.id, label: a.name ?? a.description, type: a.type }))
185
186 // The strip above the prompt shows the counts; clear any status entry an
187 // earlier version left.
188 $.ui.status(undefined)
189
190 const snap: Snapshot = { root, peers, subagents, checkedAt: now }
191 await $.state.set(SNAPSHOT, snap)
192 } finally {
193 polling = false
194 }
195}
196
197export const register: Register = on => {
198 on('session.start', async ($, e, next) => {
199 const started = await next(e)
200 selfId = await $.session.id()
201 await $.command.register({
202 name: 'sessions-here',
203 description: 'Expand or collapse the sessions strip above the prompt',
204 })
205 await $.command.register({
206 name: 'sessions-pane',
207 description: 'Open the sessions timeline in a side pane',
208 })
209 // The side pane opens only on /sessions-pane: close one left open by an
210 // earlier version or a previous load.
211 await $.ui.close({ id: PANE })
212 await scan($)
213 $.clock.every(POLL_MS, () => scan($))
214
215 return started
216 })
217
218 on('command.run', { command: 'sessions-here' }, async $ => {
219 const { value: isExpanded = false } = await $.state.get(EXPANDED)
220 await $.state.set(EXPANDED, !isExpanded)
221 await scan($)
222
223 return { text: isExpanded ? 'Sessions strip collapsed.' : 'Sessions strip expanded.' }
224 })
225
226 on('command.run', { command: 'sessions-pane' }, async $ => {
227 await scan($)
228 await $.ui.open({ id: PANE, title: TITLE })
229
230 return { text: 'Sessions pane opened.' }
231 })
232
233 // Default view: a strip above the prompt, collapsed to one row of chips,
234 // expanding in place to the full timeline.
235 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236 const { value: snap } = await $.state.get(SNAPSHOT)
237 const { value: isExpanded = false } = await $.state.get(EXPANDED)
238 const others = (snap?.peers ?? []).filter(p => !p.isSelf)
239 if (!snap || e.props.hasSurvey) return next(e)
240
241 const toggle = () => $.state.set(EXPANDED, !isExpanded)
242 const label = isExpanded ? 'Collapse' : 'Timeline'
243
244 if (e.surface !== 'terminal') {
245 const { Box, Button, Svg } = $.ui.resolve(e)
246 const header = summarySvg(snap)
247 const timeline = isExpanded ? paneSvg(snap, 560, { withHeader: false, isFixed: true }) : null
248
249 // Chips that fit beside the header and the toggle; the rest fold into +N.
250 let room = Math.min(1100, Math.max(320, e.props.bodyColumns * 7.5)) - header.width - 110
251 const shown = others.filter(p => {
252 const w = chipWidth(p.name, ago(snap.checkedAt - p.since), p.openUrl !== undefined)
253 if (w > room) return false
254 room -= w
255 return true
256 })
257 const hidden = others.length - shown.length
258 const more = hidden > 0 ? pixelTextSvg(`+${hidden}`) : null
259
260 return (
261 <Box flexDirection="column">
262 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
263 <Box flexDirection="row" alignItems="center" gap={2}>
264 <Svg source={header.source} width={header.width} height={header.height} alt={`${others.length} other sessions in this repo`} />
265 {shown.map(p => {
266 const isBusy = p.status === 'busy'
267 const sprite = spriteSvg(p.agent, isBusy)
268 const time = pixelTextSvg(ago(snap.checkedAt - p.since))
269 const url = p.openUrl
270
271 return (
272 <Box key={`chip-${p.agent}-${p.id}`} flexDirection="row" alignItems="center" gap={1}>
273 <Svg source={sprite.source} width={sprite.width} height={sprite.height} alt={`${p.agent} ${p.status}`} />
274 {url ? (
275 <Button
276 key={`open-${p.agent}-${p.id}`}
277 label={p.name}
278 plain
279 dimColor={!isBusy}
280 onPress={() => $.process.run(['open', url])}
281 />
282 ) : (
283 <Svg source={nameSvg(p.name, isBusy).source} width={nameSvg(p.name, isBusy).width} height={16} alt={p.name} />
284 )}
285 <Svg source={time.source} width={time.width} height={time.height} alt={time.text} />
286 </Box>
287 )
288 })}
289 {more && <Svg source={more.source} width={more.width} height={more.height} alt={more.text} />}
290 </Box>
291 <Button key="toggle" label={label} dimColor onPress={toggle} />
292 </Box>
293 {timeline && (
294 <Svg source={timeline.source} width={timeline.width} height={timeline.height} alt="Session activity, last 10 minutes" />
295 )}
296 </Box>
297 )
298 }
299
300 const { Box, Button, Text } = $.ui.resolve(e)
301 const busy = others.filter(p => p.status === 'busy').length
302
303 return (
304 <Box flexDirection="column">
305 <Box flexDirection="row" gap={2}>
306 <Text wrap="truncate-end">
307 <Text bold>{snap.root.split('/').pop()}</Text>
308 <Text dimColor> {busy} busy · {others.length - busy} idle</Text>
309 </Text>
310 {others.map(p => {
311 const url = p.openUrl
312
313 return (
314 <Box key={`chip-${p.agent}-${p.id}`} flexDirection="row">
315 <Text color={p.status === 'busy' ? '#0ca30c' : 'gray'}>{p.status === 'busy' ? '●' : '○'}</Text>
316 <Text color={p.agent === 'codex' ? '#3987e5' : '#d95926'}>■ </Text>
317 {url ? (
318 <Button
319 key={`open-${p.agent}-${p.id}`}
320 label={p.name}
321 plain
322 dimColor={p.status !== 'busy'}
323 onPress={() => $.process.run(['open', url])}
324 />
325 ) : (
326 <Text dimColor={p.status !== 'busy'} wrap="truncate-end">{p.name}</Text>
327 )}
328 </Box>
329 )
330 })}
331 <Button key="toggle" label={label} dimColor onPress={toggle} />
332 </Box>
333 {isExpanded &&
334 others.map(p => (
335 <Text key={`row-${p.agent}-${p.id}`} wrap="truncate-end">
336 <Text color={p.agent === 'codex' ? '#3987e5' : '#d95926'}>{stripText(p.history)}</Text>
337 <Text dimColor> {p.status} {ago(snap.checkedAt - p.since)} · </Text>
338 {p.name}
339 </Text>
340 ))}
341 </Box>
342 )
343 })
344
345 // Optional side pane (/sessions-pane): the same timeline with a header.
346 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347 const { value: snap } = await $.state.get(SNAPSHOT)
348
349 if (e.surface !== 'terminal') {
350 const { Svg, Text } = $.ui.resolve(e)
351 if (!snap) return <Text>Scanning…</Text>
352 const pane = paneSvg(snap)
353
354 return <Svg source={pane.source} alt={`${snap.peers.length} sessions in this repo`} />
355 }
356
357 const { Box, Text } = $.ui.resolve(e)
358 if (!snap) return <Text dimColor>Scanning…</Text>
359
360 return (
361 <Box flexDirection="column">
362 {snap.peers.map(p => (
363 <Box key={`${p.agent}-${p.id}`} flexDirection="column" marginTop={1}>
364 <Text wrap="truncate-end">
365 <Text color={p.status === 'busy' ? '#0ca30c' : 'gray'}>{p.status === 'busy' ? '●' : '○'} </Text>
366 <Text bold={p.status === 'busy'}>{p.name}</Text>
367 </Text>
368 <Text wrap="truncate-end">
369 {' '}<Text color={p.agent === 'codex' ? '#3987e5' : '#d95926'}>{stripText(p.history)}</Text>
370 <Text dimColor>
371 {' '}{p.isSelf ? 'You · ' : ''}{p.status} {ago(snap.checkedAt - p.since)}
372 {p.branch ? ` · ${p.branch}` : ''}
373 {relativeCwd(p.cwd, snap.root) ? ` · ${relativeCwd(p.cwd, snap.root)}` : ''}
374 </Text>
375 </Text>
376 </Box>
377 ))}
378 </Box>
379 )
380 })
381}
382hooks/draw.ts 307 lines1import type { Peer, Snapshot } from '../types'
2
3// Pure drawing helpers: no `$`, so they can be rendered outside Claude Code.
4// Pixel style: labels in a 5x7 bitmap font, a pixel sprite per agent, and
5// one block per 15 s on the timeline, all drawn with crisp edges.
6
7export const HISTORY_BUCKETS = 40
8export const BUCKET_MS = 15_000
9
10const PAD = 10
11
12// Agent identity: categorical slots 1 and 2, validated for both modes.
13const AGENT = {
14 claude: { label: 'Claude', light: '#eb6834', dark: '#d95926' },
15 codex: { label: 'Codex', light: '#2a78d6', dark: '#3987e5' },
16} as const
17
18const STYLE = `
19 .t1 { fill: #0b0b0b; } .t2 { fill: #52514e; } .t3 { fill: #8a8880; }
20 .edge { stroke: #0b0b0b; stroke-opacity: .16; fill: none; } .grid { stroke: #0b0b0b; stroke-opacity: .12; }
21 .off { fill: #0b0b0b; fill-opacity: .13; } .ring { stroke: #8a8880; fill: none; }
22 .self, .chip { fill: #0b0b0b; } .self { fill-opacity: .04; } .chip { fill-opacity: .045; }
23 .claude { fill: ${AGENT.claude.light}; } .codex { fill: ${AGENT.codex.light}; } .eye { fill: #1a1a19; }
24 @media (prefers-color-scheme: dark) {
25 .t1 { fill: #f2f1ec; } .t2 { fill: #c3c2b7; } .t3 { fill: #8f8d85; }
26 .edge, .grid { stroke: #ffffff; } .edge { stroke-opacity: .16; } .grid { stroke-opacity: .1; }
27 .off { fill: #ffffff; fill-opacity: .14; } .ring { stroke: #8f8d85; }
28 .self, .chip { fill: #ffffff; } .self { fill-opacity: .045; } .chip { fill-opacity: .06; }
29 .claude { fill: ${AGENT.claude.dark}; } .codex { fill: ${AGENT.codex.dark}; }
30 }
31 text { font-family: "SF Mono", ui-monospace, Menlo, Monaco, monospace; }
32`
33
34// 5x7 bitmap glyphs, one string of five bits per row.
35const GLYPHS: Record<string, string> = {
36 A: '01110100011000111111100011000110001', B: '11110100011000111110100011000111110',
37 C: '01110100011000010000100001000101110', D: '11110100011000110001100011000111110',
38 E: '11111100001000011110100001000011111', F: '11111100001000011110100001000010000',
39 G: '01110100011000010111100011000101111', H: '10001100011000111111100011000110001',
40 I: '01110001000010000100001000010001110', J: '00111000100001000010000101001001100',
41 K: '10001100101010011000101001001010001', L: '10000100001000010000100001000011111',
42 M: '10001110111010110101100011000110001', N: '10001100011100110101100111000110001',
43 O: '01110100011000110001100011000101110', P: '11110100011000111110100001000010000',
44 Q: '01110100011000110001101011001001101', R: '11110100011000111110101001001010001',
45 S: '01111100001000001110000010000111110', T: '11111001000010000100001000010000100',
46 U: '10001100011000110001100011000101110', V: '10001100011000110001100010101000100',
47 W: '10001100011000110101101011010101010', X: '10001100010101000100010101000110001',
48 Y: '10001100010101000100001000010000100', Z: '11111000010001000100010001000011111',
49 '0': '01110100011001110101110011000101110', '1': '00100011000010000100001000010001110',
50 '2': '01110100010000100010001000100011111', '3': '11111000100010000010000011000101110',
51 '4': '00010001100101010010111110001000010', '5': '11111100001111000001000011000101110',
52 '6': '00110010001000011110100011000101110', '7': '11111000010001000100010000100001000',
53 '8': '01110100011000101110100011000101110', '9': '01110100011000101111000010001001100',
54 '-': '00000000000000011111000000000000000', '/': '00001000010001000100010001000010000',
55 '.': '00000000000000000000000000110001100', ':': '00000011000110000000011000110000000',
56 '+': '00000001000010011111001000010000000', '_': '00000000000000000000000000000011111',
57 '·': '00000000000000000100000000000000000', '?': '01110100010000100010001000000000100',
58 ' ': '00000000000000000000000000000000000',
59}
60
61// Draws `text` in the bitmap font with its top-left at (x, y); `px` is the
62// size of one font pixel. Lit runs in each row become one path.
63export function pixelText(x: number, y: number, text: string, px: number, cls: string) {
64 const d: string[] = []
65 const chars = [...text.toUpperCase()]
66 chars.forEach((ch, i) => {
67 const bits = GLYPHS[ch] ?? GLYPHS['?']!
68 const gx = x + i * 6 * px
69 for (let row = 0; row < 7; row++) {
70 let col = 0
71 while (col < 5) {
72 if (bits[row * 5 + col] !== '1') {
73 col++
74 continue
75 }
76 const start = col
77 while (col < 5 && bits[row * 5 + col] === '1') col++
78 d.push(`M${+(gx + start * px).toFixed(2)} ${+(y + row * px).toFixed(2)}h${+((col - start) * px).toFixed(2)}v${px}h${-((col - start) * px).toFixed(2)}z`)
79 }
80 }
81 })
82 return { svg: `<path d="${d.join('')}" class="${cls}"/>`, width: chars.length * 6 * px - px }
83}
84
85export const pixelWidth = (text: string, px: number) => [...text].length * 6 * px - px
86
87export const esc = (s: string) =>
88 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
89
90// Fits monospace text to a pixel width.
91export const fit = (s: string, px: number, size: number) => {
92 const max = Math.max(1, Math.floor(px / (size * 0.61)))
93 return s.length <= max ? s : `${s.slice(0, max - 1)}…`
94}
95
96export const ago = (ms: number) => {
97 const s = Math.max(0, Math.round(ms / 1000))
98 if (s < 60) return `${s}s`
99 if (s < 3600) return `${Math.round(s / 60)}m`
100 if (s < 86400) return `${Math.round(s / 3600)}h`
101 return `${Math.round(s / 86400)}d`
102}
103
104export const relativeCwd = (cwd: string, root: string) =>
105 cwd === root ? '' : cwd.startsWith(`${root}/`) ? `./${cwd.slice(root.length + 1)}` : cwd.split('/').pop() ?? cwd
106
107const svg = (width: number, height: number, body: string, isFixed: boolean) =>
108 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges"${
109 isFixed ? ` width="${width}" height="${height}"` : ''
110 }><style>${STYLE}</style>${body}</svg>`
111
112// Pixel sprites, one per agent: rows of `x` (body), `e` (eye), `.` (empty).
113// Busy sprites walk (two leg frames and a bob); idle ones nap with closed
114// eyes and a rising "z".
115const SPRITES = {
116 claude: {
117 awake: ['..xxxxx..', '.xxxxxxx.', '.xexxxex.', 'xxxxxxxxx', '.xxxxxxx.'],
118 asleep: ['..xxxxx..', '.xxxxxxx.', '.xeexeex.', 'xxxxxxxxx', '.xxxxxxx.'],
119 legs: ['.x.x.x.x.', 'x.x.x.x.x'],
120 },
121 codex: {
122 awake: ['....x....', '.xxxxxxx.', '.xexxxex.', '.xxxxxxx.', '.xxeeexx.'],
123 asleep: ['....x....', '.xxxxxxx.', '.xeexeex.', '.xxxxxxx.', '.xxxxxxx.'],
124 legs: ['..x...x..', '...x.x...'],
125 },
126} as const
127const SPRITE_W = 9
128const SPRITE_H = 6
129
130const pixels = (x: number, y: number, rows: readonly string[], px: number, ch: string, cls: string) => {
131 const d: string[] = []
132 rows.forEach((row, r) => {
133 ;[...row].forEach((c, i) => {
134 if (c === ch) d.push(`M${x + i * px} ${y + r * px}h${px}v${px}h${-px}z`)
135 })
136 })
137 return d.length ? `<path d="${d.join('')}" class="${cls}"/>` : ''
138}
139
140// A sprite with its top-left at (x, y); `px` is one sprite pixel.
141export function sprite(x: number, y: number, agent: Peer['agent'], isBusy: boolean, px = 1.5) {
142 const s = SPRITES[agent]
143 const body = isBusy ? s.awake : s.asleep
144 const legsY = y + body.length * px
145 const still = pixels(x, y, body, px, 'x', agent) + pixels(x, y, body, px, 'e', 'eye')
146 if (!isBusy) {
147 const zx = x + SPRITE_W * px + 1
148 return `${still}${pixels(x, legsY, [s.legs[0]], px, 'x', agent)}
149 <g class="t3">
150 <path d="M0 0h3v1h-1v1h-1v1h2v1h-3v-1h1v-1h1v-1h-2z" transform="translate(${zx} ${y})">
151 <animateTransform attributeName="transform" type="translate" values="${zx} ${y + 3};${zx + 2} ${y - 3}" dur="2.4s" repeatCount="indefinite"/>
152 <animate attributeName="opacity" values="0;1;0" dur="2.4s" repeatCount="indefinite"/>
153 </path>
154 </g>`
155 }
156 const frame = (legs: string, isFirst: boolean) =>
157 `<g>${pixels(x, legsY, [legs], px, 'x', agent)}
158 <animate attributeName="opacity" values="${isFirst ? '1;0' : '0;1'}" calcMode="discrete" dur=".5s" repeatCount="indefinite"/></g>`
159 return `<g>
160 <animateTransform attributeName="transform" type="translate" values="0 0;0 -1;0 0" calcMode="discrete" dur=".5s" repeatCount="indefinite"/>
161 ${still}${frame(s.legs[0], true)}${frame(s.legs[1], false)}
162 </g>`
163}
164
165const agentMark = (x: number, y: number, agent: Peer['agent']) =>
166 `<rect x="${x}" y="${y}" width="5" height="5" class="${agent}"/>`
167
168// Geometry for one drawing width: the shared time lane (last 10 minutes,
169// left to right) takes the right-hand part, one cell per bucket.
170type Geo = { W: number; LX: number; RX: number; CELL: number }
171const geo = (W: number): Geo => {
172 const RX = W - PAD
173 const CELL = Math.max(3, Math.floor(Math.min(240, W * 0.4) / HISTORY_BUCKETS))
174 return { W, LX: RX - CELL * HISTORY_BUCKETS, RX, CELL }
175}
176
177const lane = (g: Geo, y: number, history: readonly number[], agent: Peer['agent']) =>
178 history
179 .map((v, i) => {
180 const x = g.LX + i * g.CELL
181 return v
182 ? `<rect x="${x}" y="${y - 3}" width="${g.CELL - 1}" height="6" class="${agent}"/>`
183 : `<rect x="${x + Math.floor((g.CELL - 1) / 2)}" y="${y}" width="1" height="1" class="off"/>`
184 })
185 .join('')
186
187const ROW = 24
188
189const sessionRow = (g: Geo, p: Peer, y: number, snap: Snapshot, selfBranch: string | null) => {
190 const isBusy = p.status === 'busy'
191 const mid = y + ROW / 2
192 const textX = PAD + 26
193 const meta = [
194 p.isSelf ? 'you' : '',
195 ago(snap.checkedAt - p.since),
196 p.branch && p.branch !== selfBranch ? p.branch : '',
197 p.isWorktree ? 'worktree' : '',
198 relativeCwd(p.cwd, snap.root),
199 ].filter(Boolean).join(' · ')
200 const nameW = Math.min(p.name.length * 11 * 0.61, (g.LX - 12 - textX) * 0.6)
201 const name = fit(p.name, nameW, 11)
202 const metaX = textX + name.length * 11 * 0.61 + 8
203
204 return `
205 ${p.isSelf ? `<rect x="2" y="${y + 1}" width="${g.W - 4}" height="${ROW - 2}" class="self"/>` : ''}
206 ${sprite(PAD, mid - 6, p.agent, isBusy, 2)}
207 <text x="${textX}" y="${mid + 4}" font-size="11" font-weight="${isBusy ? 700 : 500}" class="${isBusy ? 't1' : 't2'}">${esc(name)}</text>
208 <text x="${metaX}" y="${mid + 4}" font-size="10" class="t3">${esc(fit(meta, g.LX - 12 - metaX, 10))}</text>
209 ${lane(g, mid, p.history, p.agent)}`
210}
211
212// The timeline: one line per session on a shared 10-minute axis. `width` is
213// the drawing's width in CSS pixels; `isFixed` pins it rather than scaling.
214export function paneSvg(snap: Snapshot, width = 360, options: { withHeader?: boolean; isFixed?: boolean } = {}) {
215 const { withHeader = true, isFixed = false } = options
216 const g = geo(width)
217 const { W, LX, RX } = g
218 const busy = snap.peers.filter(p => p.status === 'busy').length
219 const selfBranch = snap.peers.find(p => p.isSelf)?.branch ?? null
220 const parts: string[] = []
221
222 let axisY = 4
223 if (withHeader) {
224 const repo = pixelText(PAD, 8, snap.root.split('/').pop() ?? snap.root, 1.5, 't1')
225 parts.push(repo.svg)
226 parts.push(pixelText(PAD + repo.width + 10, 10, `${busy} busy · ${snap.peers.length - busy} idle`, 1, 't3').svg)
227 axisY = 30
228 }
229
230 const mid = LX + (RX - LX) / 2
231 parts.push(pixelText(LX, axisY, '10m', 1, 't3').svg)
232 parts.push(pixelText(mid - pixelWidth('5m', 1) / 2, axisY, '5m', 1, 't3').svg)
233 parts.push(pixelText(RX - pixelWidth('now', 1), axisY, 'now', 1, 't3').svg)
234
235 let y = axisY + 9
236 const rowsTop = y
237 const rows: string[] = []
238 for (const p of snap.peers) {
239 rows.push(sessionRow(g, p, y, snap, selfBranch))
240 y += ROW
241 }
242 for (const gx of [LX, Math.round(mid), RX]) {
243 parts.push(`<line x1="${gx - 0.5}" x2="${gx - 0.5}" y1="${rowsTop + 3}" y2="${y - 3}" class="grid" stroke-dasharray="1 3"/>`)
244 }
245 parts.push(...rows)
246
247 if (snap.subagents.length > 0) {
248 y += 6
249 parts.push(pixelText(PAD, y, 'subagents', 1, 't3').svg)
250 y += 8
251 for (const a of snap.subagents) {
252 parts.push(`${sprite(PAD, y + 6, 'claude', true, 2)}
253 <text x="${PAD + 26}" y="${y + 16}" font-size="11" class="t1">${esc(fit(a.label, W - 120, 11))}</text>
254 <text x="${RX}" y="${y + 16}" font-size="10" text-anchor="end" class="t3">${esc(fit(a.type, 80, 10))}</text>`)
255 y += ROW
256 }
257 }
258
259 const h = y + 4
260 return { width: W, height: h, source: svg(W, h, parts.join(''), isFixed) }
261}
262
263// The band's header: repository and counts of the other sessions.
264export function summarySvg(snap: Snapshot) {
265 const others = snap.peers.filter(p => !p.isSelf)
266 const busy = others.filter(p => p.status === 'busy').length
267 const H = 18
268 const repo = pixelText(1, 5, (snap.root.split('/').pop() ?? snap.root).slice(0, 24), 1.15, 't1')
269 const counts = pixelText(1 + repo.width + 8, 6, `${busy} busy · ${others.length - busy} idle`, 1, 't3')
270 const width = Math.ceil(1 + repo.width + 8 + counts.width + 2)
271 return { width, height: H, source: svg(width, H, repo.svg + counts.svg, true) }
272}
273
274// One sprite as its own drawing, for a chip.
275export function spriteSvg(agent: Peer['agent'], isBusy: boolean) {
276 const width = SPRITE_W * 2 + (isBusy ? 0 : 7)
277 const height = 16
278 return { width, height, source: svg(width, height, sprite(0, 3, agent, isBusy, 2), true) }
279}
280
281// A short label in the bitmap font as its own drawing.
282export function pixelTextSvg(text: string, cls = 't3') {
283 const t = pixelText(0, 4, text, 1, cls)
284 const width = Math.ceil(t.width) + 1
285 return { text, width, height: 14, source: svg(width, 14, t.svg, true) }
286}
287
288// A session name in the monospace face as its own drawing, for a chip that
289// has no link to open.
290export function nameSvg(name: string, isBusy: boolean) {
291 const text = fit(name, 160, 11)
292 const width = Math.ceil(text.length * 11 * 0.61) + 2
293 return {
294 text,
295 width,
296 height: 16,
297 source: svg(width, 16, `<text x="0" y="12" font-size="11" font-weight="${isBusy ? 700 : 500}" class="${isBusy ? 't1' : 't2'}">${esc(text)}</text>`, true),
298 }
299}
300
301// Estimated width of one chip in the band: sprite, name, time and gaps.
302export const chipWidth = (name: string, time: string, isButton: boolean) =>
303 SPRITE_W * 2 + 7 + 8 + (isButton ? name.length * 7.2 + 12 : Math.min(name.length, 26) * 11 * 0.61) + 8 + pixelWidth(time, 1) + 16
304
305// Terminal version of the lane.
306export const stripText = (history: readonly number[]) => history.map(v => (v ? '█' : '·')).join('')
307types/index.d.ts 31 lines1export type Peer = {
2 id: string
3 agent: 'claude' | 'codex'
4 name: string
5 cwd: string
6 branch: string | null
7 status: 'busy' | 'idle'
8 since: number
9 isSelf: boolean
10 isWorktree: boolean
11 /** Deep link that opens the session in its app; absent when none exists (a terminal session). */
12 openUrl?: string
13 /** Busy (1) or idle (0) per 15 s bucket, oldest first, last 10 minutes. */
14 history: number[]
15}
16
17export type Subagent = { id: string; label: string; type: string }
18
19export type Snapshot = {
20 root: string
21 peers: Peer[]
22 subagents: Subagent[]
23 checkedAt: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'repo-sessions': { snapshot: Snapshot | null; isExpanded: boolean }
29 }
30}
31