SLOPSHOPPER

session-tracker

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

newpanecommandtoastprocesstimer
v0.2.0MITupdated 2026-10-06fixter-dev/claude-code-session-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-tracker
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ This session │ ┃ ▸ … ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ ↻ refresh ] [ 🔔 sound on ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No other sessions open. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sessions │ ⎿ session-tracker: Sessions sidebar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
This session ▸ … [ ↻ refresh ] [ 🔔 sound on ] No other sessions open.
README

Claude Code Session Tracker

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.

Claude Code 2.1.289+ Mods API: early access Unofficial License: MIT

[!IMPORTANT] Fullscreen mode is recommended. It docks the list as a sidebar with mouse clicks. Run /tui fullscreen once, or start a single session with CLAUDE_CODE_NO_FLICKER=1 claude. In the default layout the list sits above the prompt instead.

Install

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

Use

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:

GroupMeaning
Needs youWaiting on a permission prompt or a question, with the reason
FinishedEnded a turn in the last few hours and not yet dismissed
WorkingMid-turn
IdleEverything 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.

Settings

Both are rows in /config.

SettingDefaultWhat it does
Sidebar width56Columns the sidebar opens at when docked. Dragging its edge wins.
Finished for how long4Hours a finished session stays under Finished before it counts as idle.

Notes

  • Only this machine. It reads the session registry and transcripts under ~/.claude, so sessions running in the cloud never appear. Headless runs (claude -p) are hidden.
  • Context percentages are partly inferred. A transcript does not record the model's window, so sessions on this session's model use its window, anything past 200k tokens is taken as a 1M window, and the rest show tokens only.
  • Finished is a guess. The mod cannot tell whether you looked at a terminal, so a session stays Finished until you dismiss it, it works again, or the hours run out.
  • Pins, dismissals and the sort order are shared by every session on the machine, under ~/.claude/plugins/store/.
  • Early access API. Mods can change between Claude Code releases, so an update may break this one.
  • macOS first. Sounds play through afplay, so there is none on Linux or Windows; ps, grep, tail and git are expected on the PATH.
  • Unofficial. This project is not affiliated with or endorsed by Anthropic.

License

MIT


Made by Fixter, monitoring for teams that build with coding agents.

Source 3 files
hooks/register.tsx 914 lines
1import { 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}
914
hooks/model.ts 480 lines
1import 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}
480
types/index.d.ts 53 lines
1export 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