A sidebar listing every open Claude Code session, the ones that need you first

An unofficial Claude Code mod that opens a sidebar listing every Claude Code session open on your machine, the ones that need you first. It tells you when another session finishes or waits on you, and lets you message or close a session without switching to it.
Built for running many Claude Code sessions at once: when long tasks run in six or seven sessions, it shows which one finished and which one is waiting on you. The full write-up: Manage multiple Claude Code sessions from one sidebar. Pairs with session-pings, our notifications mod, for when you are away from the terminal.
[!IMPORTANT] Fullscreen mode is recommended. It docks the list as a sidebar with mouse clicks. Run
/tui fullscreenonce, or start a single session withCLAUDE_CODE_NO_FLICKER=1 claude. In the default layout the list sits above the prompt instead.
You need Claude Code 2.1.289 or later, with mods available to your account. See Check whether mods can load.
From your shell:
claude plugin marketplace add fixter-dev/claude-code-session-tracker
claude plugin install session-tracker@claude-code-session-tracker
To update later, run claude plugin update session-tracker@claude-code-session-tracker.
To try it for one session without installing:
git clone https://github.com/fixter-dev/claude-code-session-tracker
claude --plugin-dir ./claude-code-session-tracker
Run /sessions to open the sidebar. In the fullscreen layout on a wide terminal it also opens by itself.
The current session is pinned at the top. The others are grouped, most recent first:
| Group | Meaning |
|---|---|
| Needs you | Waiting on a permission prompt or a question, with the reason |
| Finished | Ended a turn in the last few hours and not yet dismissed |
| Working | Mid-turn |
| Idle | Everything else; collapsed until you press show |
Each row shows the project, the session's title, its context use (context ▰▰▱▱▱▱▱▱ 312k 31%) and model, and, for a working session with a task list, the task it is on. Needs-you and Finished rows also show the branch and your last prompt.
Per row:
pin keeps the session in a Pinned group at the top, across restarts.seen dismisses a Finished session to Idle; clear does that for the whole group.message sends a line to that session, which its Claude picks up as a prompt.kill closes the session after a yes/no confirm.Sort: switches between the grouped view and one flat list by project. When another session comes to need you or finishes, you get a toast and a short sound; sound on/off mutes it. refresh re-reads everything now, and any failure to do so is shown in the pane.
Both are rows in /config.
| Setting | Default | What it does |
|---|---|---|
| Sidebar width | 56 | Columns the sidebar opens at when docked. Dragging its edge wins. |
| Finished for how long | 4 | Hours a finished session stays under Finished before it counts as idle. |
~/.claude, so sessions running in the cloud never appear. Headless runs (claude -p) are hidden.~/.claude/plugins/store/.afplay, so there is none on Linux or Windows; ps, grep, tail and git are expected on the PATH.Made by Fixter, monitoring for teams that build with coding agents.
hooks/register.tsx 914 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Health, SessionRow, Sort, Tier } from '../types'
5import {
6 arrivals,
7 buildRows,
8 contextFromTail,
9 isListed,
10 isLive,
11 isRegistryFile,
12 isSeen,
13 isSort,
14 META_PATTERN,
15 metaFromTranscript,
16 orderRows,
17 parseAlive,
18 parseRecord,
19 slugOf,
20 TIERS,
21} from './model'
22import type { Meta, RegistryRecord, Seen } from './model'
23
24const PANE = 'sessions'
25const DEFAULT_WIDTH = 56
26let finishedMs: number | undefined
27const POLL_MS = 3000
28// With the pane out of sight only every this many ticks poll, and lightly
29// (the registry alone, enough to notice a session that needs you).
30const HIDDEN_EVERY = 5
31// Every this many polls the registry's pids are checked against `ps` even
32// when the set of pids did not change.
33const ALIVE_EVERY = 5
34// A working session's context grows mid-turn: its transcript is read again
35// this often, an idle one's only when its status moves.
36const BUSY_REFRESH_MS = 30_000
37// Values other sessions' sidebars may have written are read again this often.
38const STORE_TTL_MS = 30_000
39// How many sessions' transcripts are read at once.
40const META_CONCURRENCY = 4
41const TAIL_BYTES = '262144'
42const BAR_CELLS = 8
43// After a kill, how many seconds the process gets to exit before we say so.
44const EXIT_CHECKS = 5
45// Several sessions finishing together make one sound.
46const SOUND_GAP_MS = 2000
47
48const rows = atom({ plugin: 'session-tracker', key: 'rows' } as const, [])
49const current = atom({ plugin: 'session-tracker', key: 'current' } as const, null)
50const armedPid = atom({ plugin: 'session-tracker', key: 'armedPid' } as const, null)
51const messagingPid = atom({ plugin: 'session-tracker', key: 'messagingPid' } as const, null)
52const sort = atom({ plugin: 'session-tracker', key: 'sort' } as const, 'recent')
53const isIdleOpen = atom({ plugin: 'session-tracker', key: 'isIdleOpen' } as const, false)
54const isMuted = atom({ plugin: 'session-tracker', key: 'isMuted' } as const, false)
55const health = atom({ plugin: 'session-tracker', key: 'health' } as const, null)
56const SORTS: readonly Sort[] = ['recent', 'project']
57
58const GLYPH: Record<Tier, string> = { waiting: '◆', done: '✓', busy: '●', idle: '○' }
59const HEADING: Record<Tier, string> = {
60 waiting: 'Needs you',
61 done: 'Finished',
62 busy: 'Working',
63 idle: 'Idle',
64}
65// Theme keys, so each follows the person's light or dark theme.
66const COLOR: Partial<Record<Tier, string>> = {
67 waiting: 'warning',
68 done: 'success',
69 busy: 'suggestion',
70}
71const SELF_COLOR = 'claude'
72const BRANCH_COLOR = 'autoAccept'
73const TASK_COLOR = 'suggestion'
74const PINNED_COLOR = 'permission'
75// A Button's label takes no color of its own, so each sits on a tinted chip:
76// theme backgrounds made to carry text, in light and dark alike.
77const CHIP = {
78 pin: 'memoryBackgroundColor',
79 seen: 'diffAddedDimmed',
80 message: 'bashMessageBackgroundColor',
81 kill: 'diffRemovedDimmed',
82 neutral: 'userMessageBackground',
83} as const
84const SOUND: Partial<Record<Tier, string>> = {
85 waiting: 'sounds/attention.wav',
86 done: 'sounds/finished.wav',
87}
88
89const barColor = (percent: number): string =>
90 percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
91
92type Held = Meta & { stamp: string; path: string | undefined }
93type PollOptions = { isAliveDue?: boolean; isLight?: boolean }
94type Cached<T> = { value: T; at: number }
95
96// The module's own memory: rebuilt from disk after a hot reload.
97const registry = new Map<string, { mtimeMs: number; record: RegistryRecord }>()
98const metas = new Map<string, Held>()
99const lastTiers = new Map<string, Tier>()
100let alive: Map<number, string> | undefined
101let aliveFor = ''
102let ticks = 0
103let published = ''
104let configDirHeld: string | undefined
105let selfIdHeld: string | undefined
106let seenHeld: Cached<Seen> | undefined
107let pinnedHeld: Cached<Set<string>> | undefined
108let lastSoundAt = 0
109let running: Promise<void> | undefined
110let pending: PollOptions | undefined
111
112const configDir = async ($: EngineInterface): Promise<string> => {
113 if (configDirHeld === undefined) {
114 const override = await $.env.get('CLAUDE_CONFIG_DIR')
115 configDirHeld = override || `${(await $.env.get('HOME')) ?? ''}/.claude`
116 }
117
118 return configDirHeld
119}
120
121const selfId = async ($: EngineInterface): Promise<string> => {
122 selfIdHeld ??= await $.session.id()
123
124 return selfIdHeld
125}
126
127const loadRegistry = async ($: EngineInterface, dir: string): Promise<void> => {
128 const entries = (await $.fs.list(`${dir}/sessions`)).filter(isRegistryFile)
129 const names = new Set(entries.map(entry => entry.name))
130
131 for (const name of registry.keys()) {
132 if (!names.has(name)) {
133 registry.delete(name)
134 }
135 }
136
137 await Promise.all(
138 entries.map(async entry => {
139 if (registry.get(entry.name)?.mtimeMs === entry.mtimeMs) {
140 return
141 }
142
143 const source = await $.fs.read(`${dir}/sessions/${entry.name}`).catch(() => '')
144 const record = parseRecord(source)
145
146 if (record) {
147 registry.set(entry.name, { mtimeMs: entry.mtimeMs, record })
148 } else {
149 registry.delete(entry.name)
150 }
151 }),
152 )
153}
154
155const records = (): RegistryRecord[] =>
156 [...registry.values()].map(held => held.record).filter(isListed)
157
158const liveRecords = (): RegistryRecord[] =>
159 records().filter(record => alive === undefined || isLive(record, alive))
160
161// Which registered pids run, and since when: a pid that `ps` lists with
162// another start time is a reused one, not the session.
163const loadAlive = async ($: EngineInterface, isDue: boolean): Promise<void> => {
164 const pids = records()
165 .map(record => record.pid)
166 .sort((a, b) => a - b)
167 .join(',')
168
169 if (!isDue && pids === aliveFor) {
170 return
171 }
172
173 aliveFor = pids
174
175 if (pids === '') {
176 alive = new Map()
177
178 return
179 }
180
181 try {
182 const { stdout } = await $.process.run(['ps', '-o', 'pid=,lstart=', '-p', pids], {
183 env: { TZ: 'UTC' },
184 })
185 alive = parseAlive(stdout)
186 } catch {
187 // no `ps` here: trust the registry
188 alive = undefined
189 }
190}
191
192const loadSeen = async ($: EngineInterface, now: number): Promise<Seen> => {
193 if (seenHeld && now - seenHeld.at < STORE_TTL_MS) {
194 return seenHeld.value
195 }
196
197 const held = await $.store.get('seen')
198 const seen: Seen = isSeen(held) ? { acked: held.acked } : { acked: {} }
199
200 seenHeld = { value: seen, at: now }
201
202 return seen
203}
204
205const saveSeen = async ($: EngineInterface, seen: Seen, now: number): Promise<void> => {
206 seenHeld = { value: seen, at: now }
207 await $.store.set('seen', seen)
208}
209
210// The pinned sessions' ids, kept across sessions: a pinned session that is
211// closed and resumed later is still pinned.
212const loadPinned = async ($: EngineInterface, now: number): Promise<Set<string>> => {
213 if (pinnedHeld && now - pinnedHeld.at < STORE_TTL_MS) {
214 return pinnedHeld.value
215 }
216
217 const held = await $.store.get('pinned').catch(() => undefined)
218 const pinned = new Set(
219 Array.isArray(held) ? held.filter((id): id is string => typeof id === 'string') : [],
220 )
221 pinnedHeld = { value: pinned, at: now }
222
223 return pinned
224}
225
226const savePinned = async ($: EngineInterface, pinned: Set<string>, now: number): Promise<void> => {
227 pinnedHeld = { value: pinned, at: now }
228 await $.store.set('pinned', [...pinned])
229}
230
231const findTranscript = async (
232 $: EngineInterface,
233 dir: string,
234 record: RegistryRecord,
235): Promise<string | undefined> => {
236 const file = `${record.sessionId}.jsonl`
237 const expected = `${dir}/projects/${slugOf(record.cwd)}/${file}`
238
239 if (await $.fs.exists(expected).catch(() => false)) {
240 return expected
241 }
242
243 // The session moved since it started (a worktree, a /cd): look it up by id.
244 const found = await $.process
245 .run(['find', `${dir}/projects`, '-maxdepth', '2', '-name', file])
246 .catch(() => undefined)
247
248 return found?.stdout.split('\n').find(line => line !== '')
249}
250
251// Reads a session's title, last prompt, context and branch again when its
252// status moved, and on a slow beat while it works. The branch is read only
253// for the rows that show it.
254const refreshMeta = async (
255 $: EngineInterface,
256 dir: string,
257 record: RegistryRecord,
258 tier: Tier,
259 now: number,
260): Promise<boolean> => {
261 const held = metas.get(record.sessionId)
262 const beat = record.status === 'busy' ? Math.floor(now / BUSY_REFRESH_MS) : 0
263 const wantsBranch = tier === 'waiting' || tier === 'done'
264 const stamp = `${record.statusUpdatedAt}:${beat}:${wantsBranch ? 'b' : ''}`
265
266 if (held?.stamp === stamp) {
267 return false
268 }
269
270 const path = held?.path ?? (await findTranscript($, dir, record))
271 const [lines, tail, git] = await Promise.all([
272 path === undefined
273 ? undefined
274 : $.process.run(['grep', '-a', '-E', META_PATTERN, path]).catch(() => undefined),
275 path === undefined
276 ? undefined
277 : $.process.run(['tail', '-c', TAIL_BYTES, path]).catch(() => undefined),
278 wantsBranch
279 ? $.process
280 .run(['git', '-C', record.cwd, 'rev-parse', '--abbrev-ref', 'HEAD'])
281 .catch(() => undefined)
282 : undefined,
283 ])
284 const branch = git?.exitCode === 0 ? git.stdout.trim() : ''
285 const context = contextFromTail(tail?.stdout ?? '')
286
287 metas.set(record.sessionId, {
288 ...metaFromTranscript(lines?.stdout ?? ''),
289 // a response pushed out of the tail by a huge tool result keeps its figure
290 ...(context.contextTokens > 0 ? context : { contextTokens: held?.contextTokens ?? 0, model: held?.model ?? '' }),
291 branch: wantsBranch ? (branch === 'HEAD' ? '' : branch) : (held?.branch ?? ''),
292 stamp,
293 path,
294 })
295
296 return true
297}
298
299// Runs `work` over `items`, at most `limit` at a time.
300const inBatches = async <T, R>(
301 items: readonly T[],
302 limit: number,
303 work: (item: T) => Promise<R>,
304): Promise<R[]> => {
305 const results: R[] = []
306
307 for (let index = 0; index < items.length; index += limit) {
308 results.push(...(await Promise.all(items.slice(index, index + limit).map(work))))
309 }
310
311 return results
312}
313
314const setHealth = async ($: EngineInterface, next: Health | null): Promise<void> => {
315 await update($, health, held =>
316 held?.error === next?.error ? held : next,
317 )
318}
319
320// A toast, and a sound unless muted, for each session that just came to need
321// you or finished; one sound per burst.
322const notify = async ($: EngineInterface, arrived: readonly SessionRow[], now: number): Promise<void> => {
323 if (arrived.length === 0) {
324 return
325 }
326
327 for (const row of arrived) {
328 $.ui.toast(
329 `${row.project}: ${row.tier === 'waiting' ? 'needs you' : 'finished'} · ${row.title}`,
330 { timeoutMs: 6000 },
331 )
332 }
333
334 const muted = await read($, isMuted)
335 const asset = SOUND[arrived.some(row => row.tier === 'waiting') ? 'waiting' : 'done']
336
337 if (muted || asset === undefined || now - lastSoundAt < SOUND_GAP_MS) {
338 return
339 }
340
341 lastSoundAt = now
342 await $.audio.play({ asset }).catch(error => {
343 $.ui.log(`session-tracker: sound failed: ${String(error)}`, { to: 'debug' })
344 })
345}
346
347const publish = async ($: EngineInterface, seen: Seen, now: number): Promise<void> => {
348 const usage = await $.session.usage().catch(() => undefined)
349 const built = buildRows(
350 liveRecords(),
351 metas,
352 seen,
353 now,
354 await selfId($),
355 usage?.context.window,
356 await loadPinned($, now),
357 finishedMs,
358 )
359 const arrived = arrivals(lastTiers, built.rows)
360
361 lastTiers.clear()
362 for (const row of built.rows) {
363 lastTiers.set(row.sessionId, row.tier)
364 }
365
366 const json = JSON.stringify(built)
367
368 if (json !== published) {
369 published = json
370 await update($, rows, () => built.rows)
371 await update($, current, () => built.current)
372 await update($, armedPid, pid => (built.rows.some(row => row.pid === pid) ? pid : null))
373 await update($, messagingPid, pid => (built.rows.some(row => row.pid === pid) ? pid : null))
374 }
375
376 await notify($, arrived, now)
377}
378
379const pollOnce = async ($: EngineInterface, options: PollOptions): Promise<void> => {
380 const now = await $.clock.now()
381 const dir = await configDir($)
382
383 await loadRegistry($, dir)
384 await loadAlive($, options.isAliveDue === true)
385
386 const seen = await loadSeen($, now)
387
388 // First what the registry alone says, then again once the titles are in.
389 await publish($, seen, now)
390
391 if (options.isLight) {
392 return
393 }
394
395 const live = liveRecords()
396 const tierOf = new Map([...lastTiers])
397 const byUrgency = [...live].sort(
398 (a, b) =>
399 TIERS.indexOf(tierOf.get(a.sessionId) ?? 'idle') -
400 TIERS.indexOf(tierOf.get(b.sessionId) ?? 'idle'),
401 )
402 const changed = await inBatches(byUrgency, META_CONCURRENCY, record =>
403 refreshMeta($, dir, record, tierOf.get(record.sessionId) ?? 'idle', now),
404 )
405
406 for (const id of metas.keys()) {
407 if (!live.some(record => record.sessionId === id)) {
408 metas.delete(id)
409 }
410 }
411
412 if (changed.some(Boolean)) {
413 await publish($, seen, now)
414 }
415}
416
417// One poll at a time. A poll asked for while one runs is folded into one more
418// run right after it, and every caller's promise settles once that has run.
419const poll = ($: EngineInterface, options: PollOptions = {}): Promise<void> => {
420 if (running) {
421 pending = {
422 isAliveDue: (pending?.isAliveDue ?? false) || options.isAliveDue === true,
423 isLight: (pending?.isLight ?? true) && options.isLight === true,
424 }
425
426 return running
427 }
428
429 running = (async () => {
430 let next: PollOptions | undefined = options
431
432 while (next) {
433 try {
434 await pollOnce($, next)
435 await setHealth($, null)
436 } catch (error) {
437 $.ui.log(`session-tracker: refresh failed: ${String(error)}`, { to: 'debug' })
438 await setHealth($, { error: String(error), at: await $.clock.now() })
439 }
440
441 next = pending
442 pending = undefined
443 }
444 })().finally(() => {
445 running = undefined
446 })
447
448 return running
449}
450
451const isPaneShown = async ($: EngineInterface): Promise<boolean> =>
452 (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
453
454const markSeen = async ($: EngineInterface, sessionIds: readonly string[]): Promise<void> => {
455 const now = await $.clock.now()
456 const seen = await loadSeen($, now)
457 const known = new Set(records().map(record => record.sessionId))
458 const kept = Object.entries(seen.acked).filter(([id]) => known.size === 0 || known.has(id))
459 const acked = Object.fromEntries([...kept, ...sessionIds.map(id => [id, now] as const)])
460
461 await saveSeen($, { acked }, now)
462 await poll($)
463}
464
465const togglePin = async ($: EngineInterface, sessionId: string): Promise<void> => {
466 const now = await $.clock.now()
467 const pinned = new Set(await loadPinned($, now))
468
469 if (!pinned.delete(sessionId)) {
470 pinned.add(sessionId)
471 }
472
473 await savePinned($, pinned, now)
474 await poll($)
475}
476
477const setSort = async ($: EngineInterface, next: Sort): Promise<void> => {
478 await update($, sort, () => next)
479 await $.store.set('sort', next)
480}
481
482// The state scanner wants each atom named where it is read, so one toggle each.
483const toggleIdleOpen = async ($: EngineInterface): Promise<void> => {
484 const next = !(await read($, isIdleOpen))
485 await update($, isIdleOpen, () => next)
486 await $.store.set('idleOpen', next)
487}
488
489const toggleMuted = async ($: EngineInterface): Promise<void> => {
490 const next = !(await read($, isMuted))
491 await update($, isMuted, () => next)
492 await $.store.set('muted', next)
493}
494
495const sendMessage = async ($: EngineInterface, row: SessionRow, text: string): Promise<void> => {
496 await update($, messagingPid, () => null)
497
498 if (text.trim() === '') {
499 return
500 }
501
502 const sent = await $.session
503 .send({ to: { sessionId: row.sessionId }, text: text.trim() })
504 .catch(error => ({ isDelivered: false as const, reason: String(error) }))
505
506 $.ui.toast(
507 sent.isDelivered
508 ? `Sent to ${row.project}: ${text.trim()}`
509 : `Not sent to ${row.project}: ${sent.reason}`,
510 )
511}
512
513// Says whether the killed process is gone, checking once a second for a while.
514const watchExit = ($: EngineInterface, row: SessionRow, checksLeft: number): void => {
515 $.clock.after(1000, () => {
516 void (async () => {
517 const check = await $.process.run(['ps', '-o', 'pid=', '-p', String(row.pid)]).catch(() => undefined)
518 const isGone = check === undefined || check.exitCode !== 0 || check.stdout.trim() === ''
519
520 if (isGone) {
521 $.ui.toast(`Closed ${row.project} (pid ${row.pid})`)
522 await poll($, { isAliveDue: true })
523 } else if (checksLeft > 1) {
524 watchExit($, row, checksLeft - 1)
525 } else {
526 $.ui.toast(`${row.project} (pid ${row.pid}) has not exited yet; it is still listed`)
527 await poll($, { isAliveDue: true })
528 }
529 })()
530 })
531}
532
533const killSession = async ($: EngineInterface, row: SessionRow): Promise<void> => {
534 await update($, armedPid, () => null)
535 await poll($, { isAliveDue: true })
536
537 const record = liveRecords().find(one => one.pid === row.pid)
538
539 if (record === undefined || record.sessionId === (await selfId($))) {
540 $.ui.toast(`Not killing pid ${row.pid}: it is no longer another live session`)
541
542 return
543 }
544
545 const killed = await $.process.run(['kill', '-TERM', String(row.pid)]).catch(() => undefined)
546
547 if (killed?.exitCode !== 0) {
548 $.ui.toast(`Could not signal pid ${row.pid}: ${killed?.stderr.trim() || 'kill failed'}`)
549
550 return
551 }
552
553 watchExit($, row, EXIT_CHECKS)
554}
555
556const loadStoredFlags = async ($: EngineInterface): Promise<void> => {
557 const [kept, idle, muted] = await Promise.all([
558 $.store.get('sort').catch(() => undefined),
559 $.store.get('idleOpen').catch(() => undefined),
560 $.store.get('muted').catch(() => undefined),
561 ])
562
563 if (isSort(kept)) {
564 await update($, sort, () => kept)
565 }
566
567 await update($, isIdleOpen, () => idle === true)
568 await update($, isMuted, () => muted === true)
569}
570
571export const register: Register = (on, options) => {
572 // The width is a request: a width the person dragged the dock to wins.
573 const width = typeof options.width === 'number' && options.width >= 24 ? options.width : DEFAULT_WIDTH
574 const paneArgs = { id: PANE, title: 'Sessions', columns: Math.round(width), rows: 14 } as const
575 finishedMs =
576 typeof options.finishedHours === 'number' && options.finishedHours > 0
577 ? options.finishedHours * 3_600_000
578 : undefined
579
580 on('session.start', async ($, e, next) => {
581 try {
582 await $.command.register({
583 name: 'sessions',
584 description: 'Show every open Claude Code session in a sidebar',
585 })
586 } catch (error) {
587 await setHealth($, { error: `/sessions not registered: ${String(error)}`, at: 0 })
588 }
589
590 await loadStoredFlags($)
591
592 // A desktop session may not count as interactive; anything that draws
593 // somewhere gets the sidebar and its refreshes. Only a bare `-p` run is out.
594 if (e.isInteractive || e.surface !== null) {
595 void $.ui.open(paneArgs)
596 void poll($, { isAliveDue: true })
597 $.clock.every(POLL_MS, () => {
598 ticks += 1
599 void (async () => {
600 if (await isPaneShown($)) {
601 await poll($, { isAliveDue: ticks % ALIVE_EVERY === 0 })
602 } else if (ticks % HIDDEN_EVERY === 0) {
603 await poll($, { isAliveDue: true, isLight: true })
604 }
605 })()
606 })
607 }
608
609 return next(e)
610 })
611
612 on('command.run', { command: 'sessions' }, async $ => {
613 await $.ui.open(paneArgs)
614 await poll($, { isAliveDue: true })
615
616 return { text: 'Sessions sidebar opened.' }
617 })
618
619 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
620 const table = $.ui.resolve(e)
621 const { Box, Button, Text } = table
622 const Input = 'Input' in table ? table.Input : undefined
623 const list = await read($, rows)
624 const self = await read($, current)
625 const armed = await read($, armedPid)
626 const messaging = await read($, messagingPid)
627 const order = await read($, sort)
628 const idleOpen = await read($, isIdleOpen)
629 const muted = await read($, isMuted)
630 const problem = await read($, health)
631 const doneIds = list.filter(row => row.tier === 'done').map(row => row.sessionId)
632 const pinnedRows = orderRows(list.filter(row => row.isPinned), order)
633 const rest = list.filter(row => !row.isPinned)
634
635 const drawContext = (row: SessionRow) => {
636 if (row.context === '') {
637 return null
638 }
639
640 const percent = row.contextPercent
641 const filled = percent === null ? 0 : Math.max(1, Math.round((percent / 100) * BAR_CELLS))
642
643 return (
644 <Box justifyContent="space-between">
645 <Box flexShrink={0}>
646 <Text dimColor>{' '}context </Text>
647 {percent !== null && <Text color={barColor(percent)}>{'▰'.repeat(filled)}</Text>}
648 {percent !== null && <Text dimColor>{'▱'.repeat(BAR_CELLS - filled)} </Text>}
649 <Text>{row.context}</Text>
650 {percent !== null && <Text color={barColor(percent)}> {percent}%</Text>}
651 </Box>
652 <Text dimColor wrap="truncate-end">
653 {' '}
654 {row.model}
655 </Text>
656 </Box>
657 )
658 }
659
660 const chip = (color: string, control: JSX.Element) => <Box backgroundColor={color}>{control}</Box>
661
662 const drawActions = (row: SessionRow) => (
663 <Box flexWrap="wrap" columnGap={1} paddingLeft={2}>
664 {chip(
665 CHIP.pin,
666 <Button
667 key={`pin-${row.pid}`}
668 label={row.isPinned ? '★ unpin' : '☆ pin'}
669 onPress={() => void togglePin($, row.sessionId)}
670 />,
671 )}
672 {row.tier === 'done' &&
673 chip(
674 CHIP.seen,
675 <Button
676 key={`seen-${row.pid}`}
677 label="✓ seen"
678 onPress={() => void markSeen($, [row.sessionId])}
679 />,
680 )}
681 {Input !== undefined &&
682 chip(
683 CHIP.message,
684 <Button
685 key={`message-${row.pid}`}
686 label="✉ message"
687 onPress={() => void update($, messagingPid, () => row.pid)}
688 />,
689 )}
690 {chip(
691 CHIP.kill,
692 <Button
693 key={`kill-${row.pid}`}
694 label="✕ kill"
695 onPress={() => void update($, armedPid, () => row.pid)}
696 />,
697 )}
698 </Box>
699 )
700
701 const drawConfirm = (row: SessionRow) => (
702 <Box flexWrap="wrap" columnGap={1} paddingLeft={2}>
703 <Text color="error">
704 {row.tier === 'busy' ? 'It is still working. Kill anyway?' : 'Kill this session?'}
705 </Text>
706 {chip(
707 CHIP.kill,
708 <Button key={`yes-${row.pid}`} label="yes" onPress={() => void killSession($, row)} />,
709 )}
710 {chip(
711 CHIP.neutral,
712 <Button
713 key={`no-${row.pid}`}
714 label="no"
715 onPress={() => void update($, armedPid, () => null)}
716 />,
717 )}
718 </Box>
719 )
720
721 const drawMessage = (row: SessionRow) =>
722 Input === undefined ? null : (
723 <Box flexDirection="column" paddingLeft={2}>
724 <Input
725 key={`message-${row.pid}`}
726 placeholder={`message to ${row.project}, Enter sends`}
727 submitLabel="send"
728 autoFocus
729 onSubmit={value => void sendMessage($, row, value)}
730 />
731 {chip(
732 CHIP.neutral,
733 <Button
734 key={`cancel-${row.pid}`}
735 label="cancel"
736 onPress={() => void update($, messagingPid, () => null)}
737 />,
738 )}
739 </Box>
740 )
741
742 const drawRow = (row: SessionRow, index: number) => (
743 <Box flexDirection="column" marginTop={index === 0 ? 0 : 1}>
744 <Box justifyContent="space-between">
745 <Text bold wrap="truncate-end" dimColor={row.tier === 'idle'} color={COLOR[row.tier]}>
746 {GLYPH[row.tier]} {row.project}
747 {row.isPinned ? ' ★' : ''}
748 </Text>
749 <Box flexShrink={0}>
750 <Text dimColor> {row.age}</Text>
751 </Box>
752 </Box>
753 <Text wrap="truncate-end" dimColor={row.tier === 'idle'}>
754 {' '}
755 {row.title}
756 </Text>
757 {drawContext(row)}
758 {row.task !== '' && (
759 <Box>
760 <Text dimColor>{' '}task </Text>
761 <Text wrap="truncate-end" color={TASK_COLOR}>
762 {row.task}
763 </Text>
764 </Box>
765 )}
766 {row.tier === 'waiting' && row.waitingFor !== '' && (
767 <Text wrap="truncate-end" color="warning">
768 {' '}waiting: {row.waitingFor}
769 </Text>
770 )}
771 {(row.tier === 'waiting' || row.tier === 'done') && row.branch !== '' && (
772 <Text wrap="truncate-end" color={BRANCH_COLOR}>
773 {' '}⎇ {row.branch}
774 </Text>
775 )}
776 {(row.tier === 'waiting' || row.tier === 'done') && row.lastPrompt !== '' && (
777 <Text wrap="truncate-end" dimColor italic>
778 {' '}› {row.lastPrompt}
779 </Text>
780 )}
781 {armed === row.pid
782 ? drawConfirm(row)
783 : messaging === row.pid
784 ? drawMessage(row)
785 : drawActions(row)}
786 </Box>
787 )
788
789 const drawTier = (tier: Tier) => {
790 const members = rest.filter(row => row.tier === tier)
791 const isCollapsed = tier === 'idle' && !idleOpen
792
793 if (members.length === 0) {
794 return null
795 }
796
797 return (
798 <Box flexDirection="column" marginTop={1}>
799 <Box justifyContent="space-between" marginBottom={isCollapsed ? 0 : 1}>
800 <Text bold dimColor={tier === 'idle'} color={COLOR[tier]}>
801 {HEADING[tier]} ({members.length})
802 </Text>
803 {tier === 'done' && (
804 <Button
805 key="seen-all"
806 label="clear"
807 plain
808 dimColor
809 onPress={() => void markSeen($, members.map(row => row.sessionId))}
810 />
811 )}
812 {tier === 'idle' && (
813 <Button
814 key="idle-toggle"
815 label={idleOpen ? 'hide' : 'show'}
816 plain
817 dimColor
818 onPress={() => void toggleIdleOpen($)}
819 />
820 )}
821 </Box>
822 {!isCollapsed && members.map(drawRow)}
823 </Box>
824 )
825 }
826
827 return (
828 <Box flexDirection="column">
829 <Text dimColor>This session</Text>
830 <Text bold wrap="truncate-end" color={SELF_COLOR}>
831 ▸ {self?.project ?? '…'}
832 </Text>
833 {self !== null && (
834 <Text wrap="truncate-end">
835 {' '}
836 {self.title}
837 </Text>
838 )}
839 {self !== null && drawContext(self)}
840 <Box flexWrap="wrap" columnGap={1} marginTop={1}>
841 <Button
842 key="refresh"
843 label="↻ refresh"
844 onPress={() => void poll($, { isAliveDue: true })}
845 />
846 <Button
847 key="sound"
848 label={muted ? '🔕 sound off' : '🔔 sound on'}
849 onPress={() => void toggleMuted($)}
850 />
851 </Box>
852 {problem !== null && (
853 <Text wrap="wrap" color="error">
854 refresh failed: {problem.error}
855 </Text>
856 )}
857 {list.length === 0 && (
858 <Box marginTop={1}>
859 <Text dimColor>No other sessions open.</Text>
860 </Box>
861 )}
862 {list.length > 0 && (
863 <Box justifyContent="space-between" marginTop={1}>
864 <Box columnGap={1}>
865 <Text dimColor>Sort:</Text>
866 {SORTS.map(one => (
867 <Button
868 key={`sort-${one}`}
869 label={one}
870 variant={one === order ? 'primary' : 'secondary'}
871 dimColor={one !== order}
872 onPress={() => void setSort($, one)}
873 />
874 ))}
875 </Box>
876 {order === 'project' && doneIds.length > 0 && (
877 <Button
878 key="seen-all"
879 label="clear"
880 plain
881 dimColor
882 onPress={() => void markSeen($, doneIds)}
883 />
884 )}
885 </Box>
886 )}
887 {pinnedRows.length > 0 && (
888 <Box flexDirection="column" marginTop={1}>
889 <Box marginBottom={1}>
890 <Text bold color={PINNED_COLOR}>
891 ★ Pinned ({pinnedRows.length})
892 </Text>
893 </Box>
894 {pinnedRows.map(drawRow)}
895 </Box>
896 )}
897 {order === 'recent' && TIERS.map(drawTier)}
898 {order === 'project' && rest.length > 0 && (
899 <Box flexDirection="column" marginTop={1}>
900 {pinnedRows.length > 0 && (
901 <Box marginBottom={1}>
902 <Text bold dimColor>
903 Others ({rest.length})
904 </Text>
905 </Box>
906 )}
907 {orderRows(rest, order).map(drawRow)}
908 </Box>
909 )}
910 </Box>
911 )
912 })
913}
914hooks/model.ts 480 lines1import type { SessionRow, Sort, Tier } from '../types'
2
3// One ~/.claude/sessions/<pid>.json file, the fields the sidebar reads.
4export type RegistryRecord = {
5 pid: number
6 sessionId: string
7 cwd: string
8 name: string
9 nameSource: string
10 kind: string
11 status: string
12 waitingFor: string
13 statusUpdatedAt: number
14 startedAt: number
15 // When the process started, as `ps -o lstart=` prints it in UTC; the
16 // registry's own pid-reuse guard, empty in an older record.
17 procStart: string
18}
19
20// What a session's transcript and working copy say about it.
21export type Meta = {
22 title: string
23 lastPrompt: string
24 task: string
25 branch: string
26 contextTokens: number
27 model: string
28}
29
30// Which finished turns the person has already dismissed: per session,
31// everything up to its `acked` time. (Older stores also carry a `baseline`
32// from the first run; it is ignored, the Finished window bounds that now.)
33export type Seen = {
34 acked: Record<string, number>
35}
36
37export const TIERS: readonly Tier[] = ['waiting', 'done', 'busy', 'idle']
38
39// The transcript rows the sidebar reads whole: the title and prompt
40// bookkeeping, and every row that made or moved a task.
41export const META_PATTERN =
42 '^\\{"type":"(ai-title|last-prompt)"|"name":"(TodoWrite|TaskCreate|TaskUpdate)"|"toolUseResult":\\{"task":\\{"id"'
43
44const text = (value: unknown): string => (typeof value === 'string' ? value : '')
45const number = (value: unknown): number =>
46 typeof value === 'number' && Number.isFinite(value) ? value : 0
47
48export const isRegistryFile = (entry: { name: string; kind: string }): boolean =>
49 entry.kind === 'file' && /^\d+\.json$/.test(entry.name)
50
51export const parseRecord = (source: string): RegistryRecord | undefined => {
52 let raw: unknown
53
54 try {
55 raw = JSON.parse(source)
56 } catch {
57 return undefined
58 }
59
60 if (typeof raw !== 'object' || raw === null) {
61 return undefined
62 }
63
64 const fields = raw as Record<string, unknown>
65 const pid = number(fields.pid)
66 const sessionId = text(fields.sessionId)
67
68 if (pid <= 0 || sessionId === '') {
69 return undefined
70 }
71
72 return {
73 pid,
74 sessionId,
75 cwd: text(fields.cwd),
76 name: text(fields.name),
77 nameSource: text(fields.nameSource),
78 kind: text(fields.kind),
79 status: text(fields.status),
80 waitingFor: text(fields.waitingFor),
81 statusUpdatedAt: number(fields.statusUpdatedAt) || number(fields.startedAt),
82 startedAt: number(fields.startedAt),
83 procStart: text(fields.procStart),
84 }
85}
86
87const squeeze = (value: string): string => value.trim().replace(/\s+/g, ' ')
88
89// `ps -o pid=,lstart=` (run under TZ=UTC): each live pid and when it started.
90export const parseAlive = (stdout: string): Map<number, string> => {
91 const alive = new Map<number, string>()
92
93 for (const line of stdout.split('\n')) {
94 const match = /^\s*(\d+)\s+(.*)$/.exec(line)
95
96 if (match) {
97 alive.set(Number(match[1]), squeeze(match[2] ?? ''))
98 }
99 }
100
101 return alive
102}
103
104// A registry file outlives its process, and a pid is reused: the session is
105// live only while a process with its pid AND its start time runs.
106export const isLive = (record: RegistryRecord, alive: ReadonlyMap<number, string>): boolean => {
107 const started = alive.get(record.pid)
108
109 if (started === undefined) {
110 return false
111 }
112
113 return record.procStart === '' || squeeze(record.procStart) === started
114}
115
116// A turn must have ended since the session started: a session that was only
117// opened carries its start time as its status time.
118const TURN_MIN_MS = 2000
119
120// Only sessions a person has open: a headless run (`claude -p`, as a status
121// line's usage feeder starts every few minutes) registers too, and is not one.
122export const isListed = (record: RegistryRecord): boolean =>
123 record.kind === '' || record.kind === 'interactive'
124
125export const isSeen = (value: unknown): value is Seen => {
126 if (typeof value !== 'object' || value === null) {
127 return false
128 }
129
130 const { acked } = value as Record<string, unknown>
131
132 return typeof acked === 'object' && acked !== null
133}
134
135export const projectOf = (cwd: string): string =>
136 cwd.split('/').filter(Boolean).at(-1) ?? cwd
137
138// The folder under ~/.claude/projects a session started in `cwd` writes to.
139export const slugOf = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-')
140
141type Task = { subject: string; activeForm: string; status: string }
142
143const record = (value: unknown): Record<string, unknown> =>
144 typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
145
146// Replays one transcript row onto the task list: a TodoWrite replaces it, a
147// TaskCreate's result adds a task under the id it was given, a TaskUpdate
148// moves one.
149const applyTaskRow = (
150 row: Record<string, unknown>,
151 tasks: Map<string, Task>,
152 forms: Map<string, string>,
153): void => {
154 if (row.isSidechain === true) {
155 return
156 }
157
158 const created = record(record(row.toolUseResult).task)
159
160 if (row.type === 'user' && typeof created.id === 'string' && !tasks.has(created.id)) {
161 const subject = text(created.subject)
162
163 tasks.set(created.id, {
164 subject,
165 activeForm: forms.get(subject) ?? '',
166 status: text(created.status) || 'pending',
167 })
168 }
169
170 const content = record(row.message).content
171
172 if (row.type !== 'assistant' || !Array.isArray(content)) {
173 return
174 }
175
176 for (const block of content.map(record)) {
177 const input = record(block.input)
178
179 if (block.type !== 'tool_use') {
180 continue
181 }
182
183 if (block.name === 'TaskCreate') {
184 forms.set(text(input.subject), text(input.activeForm))
185 }
186
187 if (block.name === 'TaskUpdate') {
188 const held = tasks.get(text(input.taskId))
189
190 if (input.status === 'deleted') {
191 tasks.delete(text(input.taskId))
192 } else if (held) {
193 held.subject = text(input.subject) || held.subject
194 held.activeForm = text(input.activeForm) || held.activeForm
195 held.status = text(input.status) || held.status
196 }
197 }
198
199 if (block.name === 'TodoWrite' && Array.isArray(input.todos)) {
200 tasks.clear()
201 input.todos.map(record).forEach((todo, index) => {
202 tasks.set(String(index), {
203 subject: text(todo.content),
204 activeForm: text(todo.activeForm),
205 status: text(todo.status),
206 })
207 })
208 }
209 }
210}
211
212const taskLine = (tasks: ReadonlyMap<string, Task>): string => {
213 const all = [...tasks.values()]
214 const done = all.filter(task => task.status === 'completed').length
215
216 if (all.length === 0 || done === all.length) {
217 return ''
218 }
219
220 const active = all.find(task => task.status === 'in_progress')
221
222 return active
223 ? `${done + 1}/${all.length} ${active.activeForm || active.subject}`
224 : `${done}/${all.length} done`
225}
226
227// The newest title and prompt among the transcript's bookkeeping lines, and
228// where its task list stands.
229export const metaFromTranscript = (
230 lines: string,
231): Pick<Meta, 'title' | 'lastPrompt' | 'task'> => {
232 let title = ''
233 let lastPrompt = ''
234 const tasks = new Map<string, Task>()
235 const forms = new Map<string, string>()
236
237 for (const line of lines.split('\n')) {
238 if (line === '') {
239 continue
240 }
241
242 try {
243 const row = JSON.parse(line) as Record<string, unknown>
244
245 if (row.type === 'ai-title') {
246 title = text(row.aiTitle) || title
247 }
248
249 if (row.type === 'last-prompt') {
250 lastPrompt = text(row.lastPrompt) || lastPrompt
251 }
252
253 applyTaskRow(row, tasks, forms)
254 } catch {
255 // a line cut short mid-write: the next poll reads it whole
256 }
257 }
258
259 return { title, lastPrompt: lastPrompt.replace(/\s+/g, ' ').trim(), task: taskLine(tasks) }
260}
261
262// The context the session's last response was answered over, read from the
263// transcript's tail: the newest main-thread assistant row that has a usage.
264export const contextFromTail = (
265 tail: string,
266): Pick<Meta, 'contextTokens' | 'model'> => {
267 const lines = tail.split('\n')
268
269 for (let index = lines.length - 1; index >= 0; index -= 1) {
270 const line = lines[index] ?? ''
271
272 if (!line.includes('"usage"')) {
273 continue
274 }
275
276 try {
277 const row = JSON.parse(line) as Record<string, unknown>
278 const message = row.message as Record<string, unknown> | undefined
279 const usage = message?.usage as Record<string, unknown> | undefined
280
281 if (row.type !== 'assistant' || row.isSidechain === true || !usage) {
282 continue
283 }
284
285 // an error row the engine wrote itself: no model answered it
286 if (text(message?.model).startsWith('<')) {
287 continue
288 }
289
290 return {
291 contextTokens:
292 number(usage.input_tokens) +
293 number(usage.cache_creation_input_tokens) +
294 number(usage.cache_read_input_tokens),
295 model: text(message?.model),
296 }
297 } catch {
298 // the tail's first line is cut mid-row
299 }
300 }
301
302 return { contextTokens: 0, model: '' }
303}
304
305// `claude-fable-5-1` reads `fable 5.1`, `claude-haiku-4-5-20251001` `haiku 4.5`.
306export const shortModel = (model: string): string =>
307 model
308 .replace(/^claude-/, '')
309 .replace(/\[.*\]$/, '')
310 .replace(/-\d{8}$/, '')
311 .replace(/-(\d+)-(\d+)$/, ' $1.$2')
312 .replace(/-(\d+)$/, ' $1')
313
314export const formatTokens = (tokens: number): string => {
315 if (tokens <= 0) {
316 return ''
317 }
318
319 if (tokens < 1000) {
320 return String(tokens)
321 }
322
323 return tokens < 999_500
324 ? `${Math.round(tokens / 1000)}k`
325 : `${(tokens / 1_000_000).toFixed(1)}M`
326}
327
328const STANDARD_WINDOW = 200_000
329const LONG_WINDOW = 1_000_000
330
331// A transcript does not record its model's window: a session on this
332// session's model has this session's window, and one already past the
333// standard window must be on the long one. Otherwise unknown.
334const windowOf = (
335 meta: Meta | undefined,
336 selfMeta: Meta | undefined,
337 selfWindow: number | undefined,
338): number | undefined => {
339 if (meta === undefined || meta.contextTokens <= 0) {
340 return undefined
341 }
342
343 if (selfWindow !== undefined && meta.model !== '' && meta.model === selfMeta?.model) {
344 return Math.max(selfWindow, meta.contextTokens > STANDARD_WINDOW ? LONG_WINDOW : 0)
345 }
346
347 return meta.contextTokens > STANDARD_WINDOW ? LONG_WINDOW : undefined
348}
349
350export const formatAge = (ms: number): string => {
351 const minutes = Math.floor(ms / 60_000)
352
353 if (minutes < 1) {
354 return 'now'
355 }
356
357 if (minutes < 60) {
358 return `${minutes}m`
359 }
360
361 const hours = Math.floor(minutes / 60)
362
363 return hours < 48 ? `${hours}h` : `${Math.floor(hours / 24)}d`
364}
365
366export const DEFAULT_FINISHED_MS = 4 * 3_600_000
367
368// Finished is for turns that ended recently and were not dismissed: an old
369// one is just idle.
370export const tierOf = (
371 record: RegistryRecord,
372 seen: Seen,
373 now: number,
374 finishedMs = DEFAULT_FINISHED_MS,
375): Tier => {
376 if (record.status === 'waiting') {
377 return 'waiting'
378 }
379
380 if (record.status === 'busy') {
381 return 'busy'
382 }
383
384 const seenAt = seen.acked[record.sessionId] ?? 0
385 const hasTurn = record.statusUpdatedAt - record.startedAt > TURN_MIN_MS
386 const isRecent = now - record.statusUpdatedAt < finishedMs
387
388 return hasTurn && isRecent && record.statusUpdatedAt > seenAt ? 'done' : 'idle'
389}
390
391// A name the person or a peer gave wins; then the transcript's own title; the
392// registry's derived name (`docs-8f`) only when there is nothing better.
393const titleOf = (record: RegistryRecord, meta: Meta | undefined): string => {
394 const isNamed = record.nameSource === 'user' || record.nameSource === 'peer'
395
396 if (isNamed && record.name !== '') {
397 return record.name
398 }
399
400 return meta?.title || record.name || record.sessionId.slice(0, 8)
401}
402
403// The sessions that just moved into a state worth a notice (waiting or done),
404// judged against the tiers last published; a session never seen before is not
405// one, so a fresh load stays quiet.
406export const arrivals = (
407 before: ReadonlyMap<string, Tier>,
408 rows: readonly SessionRow[],
409): SessionRow[] =>
410 rows.filter(row => {
411 const was = before.get(row.sessionId)
412
413 return was !== undefined && was !== row.tier && (row.tier === 'waiting' || row.tier === 'done')
414 })
415
416export const isSort = (value: unknown): value is Sort =>
417 value === 'recent' || value === 'project'
418
419// `rows` arrive by state then recency; by project, sessions of one project
420// sit together and keep that order among themselves.
421export const orderRows = (rows: readonly SessionRow[], sort: Sort): SessionRow[] =>
422 sort === 'recent'
423 ? [...rows]
424 : rows
425 .map((row, index) => ({ row, index }))
426 .sort(
427 (a, b) =>
428 a.row.project.localeCompare(b.row.project, undefined, { sensitivity: 'base' }) ||
429 a.index - b.index,
430 )
431 .map(({ row }) => row)
432
433export const buildRows = (
434 records: readonly RegistryRecord[],
435 metas: ReadonlyMap<string, Meta>,
436 seen: Seen,
437 now: number,
438 selfId: string,
439 selfWindow?: number,
440 pinned: ReadonlySet<string> = new Set(),
441 finishedMs = DEFAULT_FINISHED_MS,
442): { current: SessionRow | null; rows: SessionRow[] } => {
443 const selfMeta = metas.get(selfId)
444 const stamps = new Map(records.map(r => [r.sessionId, r.statusUpdatedAt]))
445 const all = records.map((record): SessionRow => {
446 const meta = metas.get(record.sessionId)
447 const tier = tierOf(record, seen, now, finishedMs)
448 const window = windowOf(meta, selfMeta, selfWindow)
449 const tokens = meta?.contextTokens ?? 0
450
451 return {
452 pid: record.pid,
453 sessionId: record.sessionId,
454 project: projectOf(record.cwd),
455 title: titleOf(record, meta),
456 tier,
457 age: formatAge(now - record.statusUpdatedAt),
458 waitingFor: record.waitingFor,
459 branch: meta?.branch ?? '',
460 lastPrompt: meta?.lastPrompt ?? '',
461 context: formatTokens(tokens),
462 model: shortModel(meta?.model ?? ''),
463 contextPercent:
464 window === undefined ? null : Math.min(100, Math.round((tokens / window) * 100)),
465 task: tier === 'busy' || tier === 'waiting' ? (meta?.task ?? '') : '',
466 isPinned: pinned.has(record.sessionId),
467 }
468 })
469 const rows = all
470 .filter(row => row.sessionId !== selfId)
471 .sort(
472 (a, b) =>
473 TIERS.indexOf(a.tier) - TIERS.indexOf(b.tier) ||
474 (stamps.get(b.sessionId) ?? 0) - (stamps.get(a.sessionId) ?? 0) ||
475 a.pid - b.pid,
476 )
477
478 return { current: all.find(row => row.sessionId === selfId) ?? null, rows }
479}
480types/index.d.ts 53 lines1export type Tier = 'waiting' | 'done' | 'busy' | 'idle'
2
3// How the list is ordered: by state then recency under group headings, or
4// one flat list by project name.
5export type Sort = 'recent' | 'project'
6
7export type SessionRow = {
8 pid: number
9 sessionId: string
10 project: string
11 title: string
12 tier: Tier
13 age: string
14 waitingFor: string
15 branch: string
16 lastPrompt: string
17 // Input tokens the session's last response was answered over, as `312k`;
18 // empty before its first response.
19 context: string
20 // That over the model's window, 0 to 100; null when the window is unknown.
21 contextPercent: number | null
22 // The model that answered last, short: `fable 5.1`; empty before a response.
23 model: string
24 // Where a working session stands in its task list, as `3/7 Running tests`;
25 // empty with no list, or with every task done.
26 task: string
27 // Pinned rows sit in a group of their own above every other.
28 isPinned: boolean
29}
30
31// The last refresh that failed, shown in the pane until one succeeds.
32export type Health = {
33 error: string
34 at: number
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'session-tracker': {
40 rows: SessionRow[]
41 current: SessionRow | null
42 // The row whose kill awaits its yes or no.
43 armedPid: number | null
44 // The row whose message field is open.
45 messagingPid: number | null
46 sort: Sort
47 isIdleOpen: boolean
48 isMuted: boolean
49 health: Health | null
50 }
51 }
52}
53