SLOPSHOPPER

sessions-sidebar

Sidebar listing every Claude Code session open on this machine

newpaneguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-09narley/sessions-sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sessions-sidebar
│ ┃ sessions-sidebar ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⌂ No repo ▾ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▾ Live 0 ⌕ ≡ ⏺ Update(src/auth.ts) │ ┃ All 0 · Worktrees 0 · Ad hoc 0 ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ None ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▾ Closed 0 ⌕ ✻ Worked for 42s · done 4:20 PM │ ┃ All 0 · Worktrees 0 · Ad hoc 0 │ ┃ ──────────────────────────────────────────── › /sessions-sidebar │ ┃ ──────────── ⎿ sessions-sidebar: Sessions sidebar closed. │ ┃ │ ┃ None │ ┃ │ ┃ ▸ Done 0 │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sessions-sidebar
⌂ No repo ▾ ▾ Live 0 ⌕ ≡ All 0 · Worktrees 0 · Ad hoc 0 ──────────────────────────────────────────────────────── None ▾ Closed 0 ⌕ All 0 · Worktrees 0 · Ad hoc 0 ──────────────────────────────────────────────────────── None ▸ Done 0 ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── [ + New Session ] ──────────────────────────────────────────────────────── 5h ━━━╺━━━━━━ 31% resets NaNm
Source 2 files
hooks/register.tsx 3055 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  CheckoutRow,
6  KindFilter,
7  ClosedRow,
8  DetailLevel,
9  DormantRow,
10  RepoOption,
11  Section,
12  SessionRow,
13} from '../types'
14
15const PANE = 'sessions-sidebar'
16// the width the person settled on; one they drag the dock to still wins, kept by Claude Code
17const PANE_COLUMNS = 45
18const REFRESH_MS = 3000
19const rows = atom({ plugin: 'sessions-sidebar', key: 'rows' } as const, [])
20const dormantRows = atom({ plugin: 'sessions-sidebar', key: 'dormant' } as const, [])
21const closedRows = atom({ plugin: 'sessions-sidebar', key: 'closed' } as const, [])
22const checkoutRows = atom({ plugin: 'sessions-sidebar', key: 'checkout' } as const, [])
23// Ad hoc lists its sessions used this recently; older ones are offered in pages from a menu
24const RECENT_MS = 7 * 24 * 3_600_000
25const OLDER_PAGE_SIZE = 10
26// what the older sessions' menu is filtered by, as typed; this sidebar's own
27const olderSearch = atom({ plugin: 'sessions-sidebar', key: 'olderSearch' } as const, '')
28// the repos the sidebar shows, and every repo there is to pick from
29const repoChoice = atom({ plugin: 'sessions-sidebar', key: 'repos' } as const, {
30  selected: [],
31  options: [],
32})
33// what the repo menu is filtered by, as typed; this sidebar's own
34const repoSearch = atom({ plugin: 'sessions-sidebar', key: 'repoSearch' } as const, '')
35// each section's search as typed, while its ⌕ has it open; this sidebar's own
36const sectionSearches = atom({ plugin: 'sessions-sidebar', key: 'searches' } as const, {})
37// ids of closed sessions moved to Archived, shared by every session's sidebar
38const archivedIds = atom({ plugin: 'sessions-sidebar', key: 'archived' } as const, [])
39const SECTIONS: Section[] = ['live', 'dormant', 'closed']
40// how a section starts until someone folds or opens it: Done only grows, and is the least looked at
41const IS_COLLAPSED_BY_DEFAULT: Record<Section, boolean> = {
42  live: false,
43  dormant: false,
44  closed: true,
45}
46// which of a section's two kinds it lists: those with a worktree, and ad hoc ones without
47const KIND_FILTERS: { id: KindFilter; label: string }[] = [
48  { id: 'all', label: 'All' },
49  { id: 'worktrees', label: 'Worktrees' },
50  { id: 'adhoc', label: 'Ad hoc' },
51]
52// each section's, shared by every session's sidebar like the detail level; absent is all
53const sectionFilters = atom({ plugin: 'sessions-sidebar', key: 'filters' } as const, {})
54// New Session's picker while open: the profile's default model, each model's saved effort, and the
55// effort picked for this launch (absent: each keeps its saved one); this sidebar's own
56const newSessionMenu = atom({ plugin: 'sessions-sidebar', key: 'newSession' } as const, null)
57// what New Session offers, by the alias `claude --model` takes and the key settings saves effort under
58const NEW_SESSION_MODELS = [
59  { model: 'opus[1m]', family: 'opus', label: 'Opus 5.5 1M', settingsKey: 'claude-opus-5-5' },
60  { model: 'sonnet', family: 'sonnet', label: 'Sonnet 5.5', settingsKey: 'claude-sonnet-5-5' },
61  { model: 'fable', family: 'fable', label: 'Fable 5.1', settingsKey: 'claude-fable-5-1' },
62  { model: 'haiku', family: 'haiku', label: 'Haiku 4.5', settingsKey: 'claude-haiku-4-5' },
63]
64const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
65const DEFAULT_COLLAPSED = SECTIONS.filter(section => IS_COLLAPSED_BY_DEFAULT[section])
66const collapsedSections = atom(
67  { plugin: 'sessions-sidebar', key: 'collapsed' } as const,
68  DEFAULT_COLLAPSED,
69)
70// how much each live session shows, shared by every session's sidebar
71const DETAIL_LEVELS: DetailLevel[] = ['compact', 'standard', 'full']
72const detailLevel = atom({ plugin: 'sessions-sidebar', key: 'detail' } as const, 'full')
73// how many blocks (a heading, a session) the list is scrolled past
74const listOffset = atom({ plugin: 'sessions-sidebar', key: 'offset' } as const, 0)
75// flips each PULSE_MS while a session runs ioi-autopilot, swapping its card's colours
76const PULSE_MS = 1000
77const pulse = atom({ plugin: 'sessions-sidebar', key: 'pulse' } as const, false)
78// the account's 5h and 7d windows and this session's prompt cache, drawn above New Session
79const usageInfo = atom({ plugin: 'sessions-sidebar', key: 'usage' } as const, { limits: [] })
80// the one question the sidebar is asking, drawn under the row its key names
81const question = atom({ plugin: 'sessions-sidebar', key: 'question' } as const, null)
82// set by each render, read by the scroll hook, which has no layout of its own to clamp against
83let maxListOffset = 0
84
85// shape of <config dir>/sessions/<pid>.json, the engine's registry of running sessions
86type RegisteredSession = {
87  pid: number
88  sessionId: string
89  cwd: string
90  startedAt: number
91  kind: string
92  name?: string
93  status?: string
94  // set while Remote Control is on, null once it is turned off
95  bridgeSessionId?: string | null
96  // how it was started: `cli` in a terminal, `sdk-py`/`sdk-ts` when a script drives it
97  entrypoint?: string
98  // a background session's (kind `bg`) id, as `claude attach` and `claude stop` take it
99  jobId?: string
100}
101
102type Worktree = { path: string; branch?: string; repo: string; isMain: boolean }
103
104const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
105
106const placeOf = (cwd: string) => {
107  const worktree = /\/\.claude\/worktrees\/([^/]+)/.exec(cwd)
108
109  return worktree ? `worktree ${worktree[1]}` : baseName(cwd)
110}
111
112const isInside = (path: string, dir: string) => path === dir || path.startsWith(`${dir}/`)
113
114const ticketOf = (text: string | undefined) =>
115  text === undefined ? undefined : /^(\d+)(?:-|$)/.exec(text)?.[1]
116
117const configDirOf = async ($: EngineInterface) =>
118  (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
119
120// how Claude Code names a folder's transcript directory under <config dir>/projects
121const projectKeyOf = (path: string) => path.replace(/[^A-Za-z0-9]/g, '-')
122
123const transcriptOf = (configDir: string, one: RegisteredSession) =>
124  `${configDir}/projects/${projectKeyOf(one.cwd)}/${one.sessionId}.jsonl`
125
126const tailOf = async ($: EngineInterface, configDir: string, one: RegisteredSession) =>
127  (
128    await $.process
129      .run(['tail', '-c', '262144', transcriptOf(configDir, one)])
130      .catch(() => undefined)
131  )?.stdout ?? ''
132
133// the registry keeps the launch folder; the transcript's newest cwd is where the session works now
134const currentCwdIn = (tail: string, one: RegisteredSession) =>
135  [...tail.matchAll(/"cwd":"([^"]+)"/g)].at(-1)?.[1] ?? one.cwd
136
137type CacheWrites = { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }
138
139// the prompt cache lives for its lifetime (1h or 5m, by the kind of cache write the responses made)
140// past the last API response
141const cacheExpiryIn = (tail: string) => {
142  const responses = tail.split('\n').flatMap(line => {
143    if (!line.includes('"type":"assistant"') || !line.includes('"cache_creation"')) return []
144    try {
145      const entry = JSON.parse(line) as {
146        timestamp?: string
147        message?: { usage?: { cache_creation?: CacheWrites } }
148      }
149
150      return entry.timestamp === undefined ? [] : [entry]
151    } catch {
152      return []
153    }
154  })
155  const last = responses.at(-1)
156  if (last?.timestamp === undefined) return undefined
157  const isHourLong = responses.some(
158    one => (one.message?.usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0,
159  )
160
161  return Date.parse(last.timestamp) + (isHourLong ? 3_600_000 : 300_000)
162}
163
164const worktreesOf = async ($: EngineInterface, cwd: string): Promise<Worktree[]> => {
165  const listed = await $.process
166    .run(['git', '-C', cwd, 'worktree', 'list', '--porcelain'])
167    .catch(() => undefined)
168  if (listed === undefined || listed.exitCode !== 0) return []
169
170  // git always lists the main worktree first
171  const blocks = listed.stdout.trim().split(/\n\n+/)
172  const repo = /^worktree (.+)$/m.exec(blocks[0] ?? '')?.[1] ?? cwd
173
174  return blocks.flatMap((block, index) => {
175    const path = /^worktree (.+)$/m.exec(block)?.[1]
176    if (path === undefined || /^prunable/m.test(block)) return []
177
178    return [
179      { path, repo, isMain: index === 0, branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] },
180    ]
181  })
182}
183
184// a folder's repo: the main checkout git lists first, a Claude worktree's though it is gone; a
185// folder outside git is its own, and one that is gone has none
186const repoOf = async ($: EngineInterface, cwd: string) => {
187  const folder = cwd.replace(/\/\.claude\/worktrees\/.*$/, '')
188  const [main] = await worktreesOf($, folder)
189  if (main !== undefined) return main.repo
190
191  return (await $.fs.exists(folder).catch(() => false)) ? folder : undefined
192}
193
194// ponytail: a transcript folder's name cannot be read back as a path, so its repo comes from the cwd
195// of its newest transcript, found once per folder; one with no transcript yet is tried again
196const repoOfProject = new Map<string, string | undefined>()
197
198const projectFoldersOf = async ($: EngineInterface, projects: string) => {
199  const folders = (await $.fs.list(projects).catch(() => [])).filter(entry => entry.kind === 'dir')
200  const unknown = await Promise.all(
201    folders
202      .filter(entry => !repoOfProject.has(entry.name))
203      .map(async entry => {
204        const newest = (await $.fs.list(`${projects}/${entry.name}`).catch(() => []))
205          .filter(file => file.kind === 'file' && file.name.endsWith('.jsonl'))
206          .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
207
208        return {
209          name: entry.name,
210          transcript: newest === undefined ? undefined : `${projects}/${entry.name}/${newest.name}`,
211        }
212      }),
213  )
214  const transcripts = unknown.flatMap(one => (one.transcript === undefined ? [] : [one.transcript]))
215  const found =
216    transcripts.length === 0
217      ? ''
218      : (
219          await $.process
220            .run(['grep', '-m', '1', '-o', '-H', '"cwd":"[^"]*"', ...transcripts])
221            .catch(() => undefined)
222        )?.stdout
223  const cwdOf = new Map(
224    [...(found ?? '').matchAll(/^(.+?):"cwd":"([^"]*)"$/gm)].map(match => [match[1], match[2]]),
225  )
226  await Promise.all(
227    unknown.map(async one => {
228      const cwd = cwdOf.get(one.transcript ?? '')
229      if (cwd !== undefined) repoOfProject.set(one.name, await repoOf($, cwd))
230    }),
231  )
232
233  return folders.flatMap(entry => {
234    const repo = repoOfProject.get(entry.name)
235
236    return repo === undefined ? [] : [{ dir: `${projects}/${entry.name}`, repo }]
237  })
238}
239
240// which session started each background one, as that session recorded it the moment its shell ran
241// `claude --bg`: { <job id>: <session id> }, shared by every sidebar
242const launchesFileOf = (configDir: string) => `${configDir}/sessions-sidebar/launches.json`
243
244// after one of this session's shell commands: the background sessions it started are those whose
245// id its output shows, or, when it ran `claude --bg` and printed none, those registered while it ran
246const recordLaunches = async (
247  $: EngineInterface,
248  { since, command, stdout }: { since: number; command: string; stdout: string },
249) => {
250  const configDir = await configDirOf($)
251  const dir = `${configDir}/sessions`
252  const until = await $.clock.now()
253  const jobs = (
254    await Promise.all(
255      (await $.fs.list(dir).catch(() => []))
256        .filter(entry => entry.name.endsWith('.json'))
257        .map(entry =>
258          $.fs
259            .read(`${dir}/${entry.name}`)
260            .then(text => JSON.parse(String(text)) as RegisteredSession)
261            .catch(() => undefined),
262        ),
263    )
264  ).flatMap(one => (one?.kind === 'bg' && one.jobId !== undefined ? [one] : []))
265  // a session that only lists or greps them sees their ids too: those are no launch; nor is a
266  // command that merely names a .claude path and --bg, so claude must be the command word
267  const isLaunch = /(?:^|[\s;&|(])claude\s[^;&|\n]*--(?:bg|background)\b/.test(command)
268  const shown = jobs.filter(
269    one =>
270      one.jobId !== undefined &&
271      (isLaunch
272        ? stdout.includes(one.jobId)
273        : new RegExp(`\\bbackgrounded\\s*·\\s*${one.jobId}\\b`).test(stdout)),
274  )
275  const started =
276    shown.length > 0 || !isLaunch
277      ? shown
278      : jobs.filter(one => one.startedAt >= since - 1000 && one.startedAt <= until + 1000)
279  if (started.length === 0) return
280
281  const own = await $.session.id()
282  const saved: unknown = await $.fs
283    .read(launchesFileOf(configDir))
284    .then(text => JSON.parse(String(text)) as unknown)
285    .catch(() => ({}))
286  // only the jobs still registered are kept, so the file stays small; the first session to record
287  // a job keeps it, as a launcher sees its worker's id before anyone else can
288  const kept = Object.fromEntries(
289    Object.entries(typeof saved === 'object' && saved !== null ? saved : {}).filter(([id]) =>
290      jobs.some(one => one.jobId === id),
291    ),
292  )
293  const added = started.filter(one => one.jobId !== undefined && !(one.jobId in kept))
294  if (added.length === 0) return
295
296  await $.fs.write(
297    launchesFileOf(configDir),
298    JSON.stringify({ ...kept, ...Object.fromEntries(added.map(one => [one.jobId, own])) }),
299  )
300}
301
302// which transcript started each background session: what `claude --bg` prints varies (`backgrounded ·
303// <id>`, or `<id> <name> busy …`), so it is whichever live transcript holds the id; at launch only
304// the launcher knows it, and if more do by the time this looks, the one that wrote it first. Found
305// once per id; one not found yet is looked for again
306const launcherOfJob = new Map<string, string>()
307
308const findLaunchers = async (
309  $: EngineInterface,
310  jobIds: readonly string[],
311  transcripts: readonly string[],
312) => {
313  const unknown = jobIds.filter(id => !launcherOfJob.has(id))
314  if (unknown.length === 0 || transcripts.length === 0) return
315
316  const found =
317    (
318      await $.process
319        .run(['grep', '-o', '-H', '-F', ...unknown.flatMap(id => ['-e', id]), ...transcripts])
320        .catch(() => undefined)
321    )?.stdout ?? ''
322  const holders = [...found.matchAll(/^(.+):([0-9a-f]{8})$/gm)]
323  await Promise.all(
324    unknown.map(async id => {
325      const files = [
326        ...new Set(
327          holders.flatMap(match => (match[2] === id && match[1] !== undefined ? [match[1]] : [])),
328        ),
329      ]
330      const first =
331        files.length < 2
332          ? files[0]
333          : (
334              await Promise.all(
335                files.map(async file => {
336                  const line =
337                    (
338                      await $.process
339                        .run(['grep', '-m', '1', '-F', id, file])
340                        .catch(() => undefined)
341                    )?.stdout ?? ''
342
343                  return { file, at: /"timestamp":"([^"]+)"/.exec(line)?.[1] ?? '~' }
344                }),
345              )
346            ).sort((a, b) => a.at.localeCompare(b.at))[0]?.file
347      if (first !== undefined) launcherOfJob.set(id, first)
348    }),
349  )
350}
351
352// a session belongs to the linked worktree it works inside, else the one in its repo named after
353// its ticket (sessions often sit in the repo while working on a worktree), else the main checkout
354const worktreeOf = (cwd: string, name: string | undefined, worktrees: readonly Worktree[]) => {
355  const containing = worktrees
356    .filter(tree => isInside(cwd, tree.path))
357    .sort((a, b) => b.path.length - a.path.length)
358  const ticket = ticketOf(name)
359
360  return (
361    containing.find(tree => !tree.isMain) ??
362    worktrees.find(
363      tree =>
364        !tree.isMain &&
365        tree.repo === containing[0]?.repo &&
366        ticket !== undefined &&
367        (ticketOf(baseName(tree.path)) ?? ticketOf(tree.branch)) === ticket,
368    ) ??
369    containing[0]
370  )
371}
372
373// a run starts with /ioi-autopilot or the model's Skill call and ends when it moves the ticket to
374// stage:: review; the skill's own text names that label in backticks, bare or as
375// `"add_labels=stage:: review"`, so a quote must follow it, and no backtick after that
376const AUTOPILOT_MARKERS =
377  '"(content|text)":"<command-message>ioi-autopilot</command-message>|"name":"Skill","input":\\{"skill":"ioi-autopilot"|stage:: review\\\\*"[^`]'
378
379const isRunningAutopilot = async ($: EngineInterface, transcript: string) => {
380  const found = await $.process
381    .run(['grep', '-o', '-E', AUTOPILOT_MARKERS, transcript])
382    .catch(() => undefined)
383
384  return (found?.stdout ?? '').trim().split('\n').at(-1)?.includes('ioi-autopilot') ?? false
385}
386
387// what /color offers; `default` takes the colour away
388const SESSION_COLORS = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
389
390// /color appends its choice to the transcript, the last one wins
391const sessionColorOf = async ($: EngineInterface, transcript: string) => {
392  const found = await $.process
393    .run(['grep', '-o', '-E', '"agentColor":"[a-z]+"', transcript])
394    .catch(() => undefined)
395  const color = (found?.stdout ?? '').trim().split('\n').at(-1)?.split('"')[3]
396
397  return SESSION_COLORS.find(known => known === color)
398}
399
400// the effort its last response ran at, which /effort changes from the next one on; from the whole
401// transcript, as a tail can hold none (big tool results, or a resume's "No response requested.")
402const lastEffortOf = async ($: EngineInterface, transcript: string) => {
403  const found = await $.process
404    .run(['grep', '-o', '-E', '"effort":"(low|medium|high|xhigh|max)"', transcript])
405    .catch(() => undefined)
406
407  return (found?.stdout ?? '').trim().split('\n').at(-1)?.split('"')[3]
408}
409
410// before its first response, the effort a session started at: CLAUDE_CODE_EFFORT_LEVEL, its
411// --effort, or the one saved for its model, else for every model
412const startingEffortOf = async (
413  $: EngineInterface,
414  { configDir, own, model }: { configDir: string; own: RegisteredSession; model?: string },
415) => {
416  const fromEnv = await $.env.get('CLAUDE_CODE_EFFORT_LEVEL')
417  if (fromEnv !== undefined) return fromEnv
418
419  const args = await $.process
420    .run(['ps', '-ww', '-o', 'args=', '-p', String(own.pid)])
421    .catch(() => undefined)
422  const fromFlag = /--effort[ =](\S+)/.exec(args?.stdout ?? '')?.[1]
423  if (fromFlag !== undefined) return fromFlag
424
425  const settings = await $.fs
426    .read(`${configDir}/settings.json`)
427    .then(
428      text =>
429        JSON.parse(String(text)) as {
430          effortLevel?: string
431          modelSettings?: Record<string, { effortLevel?: string } | undefined>
432        },
433    )
434    .catch(() => undefined)
435
436  // settings key the model without its context-window suffix ([1m])
437  return (
438    settings?.modelSettings?.[(model ?? '').replace(/\[.*\]$/, '')]?.effortLevel ??
439    settings?.effortLevel
440  )
441}
442
443// `model` as /model shows it, `color` as /color set it and `effort` as the last response ran, so
444// another session's sidebar can draw its avatar
445type OwnState = {
446  running: number
447  isAutopilot: boolean
448  model?: string
449  color?: string
450  effort?: string
451  cost?: number
452  context?: { percent: number; window: number }
453}
454
455// $.agent.list and the transcript are cheap only to the session itself, so each one publishes its
456// own state for the others' sidebars
457const stateFileOf = (configDir: string, sessionId: string) =>
458  `${configDir}/sessions-sidebar/state/${sessionId}.json`
459
460const readState = ($: EngineInterface, configDir: string, sessionId: string): Promise<OwnState> =>
461  $.fs
462    .read(stateFileOf(configDir, sessionId))
463    .then(text => JSON.parse(String(text)) as Partial<OwnState>)
464    .then(state => ({
465      running: state.running ?? 0,
466      isAutopilot: state.isAutopilot ?? false,
467      model: state.model,
468      color: state.color,
469      effort: state.effort,
470      cost: state.cost,
471      context: state.context,
472    }))
473    .catch(() => ({ running: 0, isAutopilot: false }))
474
475// $.agent.list leaves out a workflow's agents; like the status line, count those of runs not yet
476// finished (a run writes workflows/<run>.json at its end) that wrote in the last minute
477const WORKFLOW_AGENT_ACTIVE_MS = 60_000
478
479const runningWorkflowAgents = async ($: EngineInterface, sessionDir: string) => {
480  const runsDir = `${sessionDir}/subagents/workflows`
481  const [runs, now] = await Promise.all([$.fs.list(runsDir).catch(() => []), $.clock.now()])
482  const counts = await Promise.all(
483    runs
484      .filter(run => run.kind === 'dir')
485      .map(async run => {
486        if (await $.fs.exists(`${sessionDir}/workflows/${run.name}.json`).catch(() => false)) {
487          return 0
488        }
489        const files = await $.fs.list(`${runsDir}/${run.name}`).catch(() => [])
490
491        return files.filter(
492          file =>
493            /^agent-.+\.jsonl$/.test(file.name) && now - file.mtimeMs <= WORKFLOW_AGENT_ACTIVE_MS,
494        ).length
495      }),
496  )
497
498  return counts.reduce((sum, count) => sum + count, 0)
499}
500
501const publishOwnState = async (
502  $: EngineInterface,
503  configDir: string,
504  own: RegisteredSession | undefined,
505): Promise<OwnState> => {
506  if (own === undefined) return { running: 0, isAutopilot: false }
507
508  const transcript = transcriptOf(configDir, own)
509  const [agents, workflowAgents, isAutopilot, model, color, lastEffort, usage] = await Promise.all([
510    $.agent.list().catch(() => []),
511    runningWorkflowAgents($, transcript.replace(/\.jsonl$/, '')),
512    isRunningAutopilot($, transcript),
513    $.session.model().catch(() => undefined),
514    sessionColorOf($, transcript),
515    lastEffortOf($, transcript),
516    $.session.usage().catch(() => undefined),
517  ])
518  const usd = usage?.cost?.usd
519  const percent = usage?.context.percent
520  const state = {
521    running: agents.filter(agent => agent.status === 'running').length + workflowAgents,
522    isAutopilot,
523    model,
524    color,
525    effort: lastEffort ?? (await startingEffortOf($, { configDir, own, model })),
526    // to the cent, so the file is rewritten as often as the figure drawn changes
527    cost: usd === undefined ? undefined : Math.round(usd * 100) / 100,
528    // absent until the live window's first response, and again just after a compaction
529    context:
530      usage === undefined || percent === undefined
531        ? undefined
532        : { percent, window: usage.context.window },
533  }
534  const published = await readState($, configDir, own.sessionId)
535  if (JSON.stringify(state) !== JSON.stringify(published)) {
536    await $.fs.write(stateFileOf(configDir, own.sessionId), JSON.stringify(state))
537  }
538
539  return state
540}
541
542// ponytail: grepping every transcript takes ~1 s, so each one is read once per change; a session's
543// own module keeps this, so a long-lived session holds one entry per transcript in the repo
544const transcriptCache = new Map<
545  string,
546  { mtimeMs: number; name?: string; worktree?: string; cost?: number }
547>()
548
549// named sessions not running whose worktree is gone; a session's worktree is the first it worked in
550const loadClosedSessions = async (
551  $: EngineInterface,
552  {
553    folders,
554    worktrees,
555    liveIds,
556    liveNames,
557  }: {
558    // the transcript folders of the repos shown
559    folders: readonly { dir: string; repo: string }[]
560    worktrees: readonly Worktree[]
561    liveIds: ReadonlySet<string>
562    liveNames: ReadonlySet<string | undefined>
563  },
564): Promise<{
565  closed: ClosedRow[]
566  checkout: CheckoutRow[]
567  costOfTicket: ReadonlyMap<string, number>
568  spend: { week: number; repo: number }
569}> => {
570  const transcripts = (
571    await Promise.all(
572      folders.map(async ({ dir, repo }) =>
573        (await $.fs.list(dir).catch(() => []))
574          .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
575          .map(entry => ({
576            path: `${dir}/${entry.name}`,
577            sessionId: entry.name.replace(/\.jsonl$/, ''),
578            mtimeMs: entry.mtimeMs,
579            repo,
580          })),
581      ),
582    )
583  )
584    .flat()
585    .filter(one => !liveIds.has(one.sessionId))
586    .sort((a, b) => b.mtimeMs - a.mtimeMs)
587
588  const stale = transcripts.filter(one => transcriptCache.get(one.path)?.mtimeMs !== one.mtimeMs)
589  const names = await Promise.all(
590    stale.map(one =>
591      $.fs
592        .read(one.path.replace(/\.jsonl$/, '/custom-title.json'))
593        .then(text => (JSON.parse(String(text)) as { customTitle?: string }).customTitle)
594        .catch(() => undefined),
595    ),
596  )
597  const named = stale.filter((_, index) => names[index] !== undefined)
598  const firsts =
599    named.length === 0
600      ? ''
601      : (
602          await $.process
603            .run([
604              'grep',
605              '-o',
606              '-H',
607              '-m',
608              '1',
609              '-E',
610              '"cwd":"[^"]*/\\.claude/worktrees/[^/"]+',
611              ...named.map(one => one.path),
612            ])
613            .catch(() => undefined)
614        )?.stdout
615  // Claude Code saves a session's running /cost total as it goes; the last one is what it cost
616  // every one, named or not, for the footer's totals; the first scan reads them all (~2 s for
617  // 375 transcripts), then only those that changed
618  const costs =
619    stale.length === 0
620      ? ''
621      : (
622          await $.process
623            .run([
624              'grep',
625              '-o',
626              '-H',
627              '-E',
628              '"totalCostUSD":[0-9.eE+-]+',
629              ...stale.map(one => one.path),
630            ])
631            .catch(() => undefined)
632        )?.stdout
633  // a failed grep is retried next refresh rather than cached as "no worktree"
634  if (firsts !== undefined && costs !== undefined) {
635    const firstWorktreeOf = new Map(
636      [...firsts.matchAll(/^(.+?):"cwd":"(.+)$/gm)].map(match => [match[1], match[2]]),
637    )
638    // in file order, so each transcript's last total wins
639    const costOf = new Map(
640      [...costs.matchAll(/^(.+?):"totalCostUSD":(.+)$/gm)].map(match => [
641        match[1],
642        Number(match[2]),
643      ]),
644    )
645    stale.forEach((one, index) =>
646      transcriptCache.set(one.path, {
647        mtimeMs: one.mtimeMs,
648        name: names[index],
649        worktree: firstWorktreeOf.get(one.path),
650        cost: costOf.get(one.path),
651      }),
652    )
653  }
654
655  // by repo and ticket: two repos may each have a 123
656  const ticketsWithWorktree = new Set(
657    worktrees
658      .filter(tree => !tree.isMain)
659      .map(tree => `${tree.repo}#${ticketOf(baseName(tree.path)) ?? ticketOf(tree.branch)}`),
660  )
661  const now = await $.clock.now()
662  const spend = transcripts.reduce(
663    (sum, one) => {
664      const cost = transcriptCache.get(one.path)?.cost ?? 0
665
666      return {
667        week: sum.week + (now - one.mtimeMs <= RECENT_MS ? cost : 0),
668        repo: sum.repo + cost,
669      }
670    },
671    { week: 0, repo: 0 },
672  )
673  const titled = transcripts.flatMap(one => {
674    const { name, worktree, cost } = transcriptCache.get(one.path) ?? {}
675    if (name === undefined) return []
676    if (worktree !== undefined && worktrees.some(tree => tree.path === worktree)) return []
677    const ticket = ticketOf(name)
678    // a worktree for its ticket still exists: it shows as live or dormant instead
679    if (ticket !== undefined && ticketsWithWorktree.has(`${one.repo}#${ticket}`)) return []
680
681    return [
682      {
683        sessionId: one.sessionId,
684        name,
685        place: placeOf(worktree ?? one.repo),
686        repo: one.repo,
687        transcript: one.path,
688        hasWorktree: worktree !== undefined,
689        isRecent: now - one.mtimeMs <= RECENT_MS,
690        modifiedAt: one.mtimeMs,
691        cost,
692      },
693    ]
694  })
695  // newest first; a name reused by an older session shows once
696  const unique = titled.filter(
697    (one, index) => titled.findIndex(other => other.name === one.name) === index,
698  )
699
700  return {
701    closed: unique.filter(one => one.hasWorktree).map(({ hasWorktree, ...one }) => one),
702    // one resumed under its old name is live, its new transcript id aside
703    checkout: unique
704      .filter(one => !one.hasWorktree && !liveNames.has(one.name))
705      .map(({ hasWorktree, ...one }) => one),
706    spend,
707    // a dormant worktree's cost: the newest session in its repo titled after its ticket, the one its
708    // ↻ resumes; by `<repo>#<ticket>`
709    costOfTicket: new Map(
710      [...transcripts].reverse().flatMap(one => {
711        const { name, cost } = transcriptCache.get(one.path) ?? {}
712        const ticket = ticketOf(name)
713
714        return ticket === undefined || cost === undefined
715          ? []
716          : [[`${one.repo}#${ticket}`, cost] as const]
717      }),
718    ),
719  }
720}
721
722const loadSessions = async (
723  $: EngineInterface,
724): Promise<{
725  live: SessionRow[]
726  dormant: DormantRow[]
727  closed: ClosedRow[]
728  checkout: CheckoutRow[]
729  cacheExpiresAt?: number
730  spend?: { week: number; repo: number }
731  repos: { selected: string[]; options: RepoOption[] }
732}> => {
733  const configDir = await configDirOf($)
734  const dir = `${configDir}/sessions`
735  const entries = await $.fs.list(dir).catch(() => [])
736  const parsed = await Promise.all(
737    entries
738      .filter(entry => entry.name.endsWith('.json'))
739      .map(entry =>
740        $.fs
741          .read(`${dir}/${entry.name}`)
742          .then(text => JSON.parse(String(text)) as RegisteredSession)
743          .catch(() => undefined),
744      ),
745  )
746  // a script's headless run (the Agent SDK) registers too, inside its parent's tab: not a session
747  // of its own to list or switch to; a background one (claude --bg) is, through `claude attach`
748  const registered = parsed.filter(
749    (one): one is RegisteredSession =>
750      (one?.kind === 'interactive' || one?.kind === 'bg') &&
751      typeof one.pid === 'number' &&
752      !(one.entrypoint ?? 'cli').startsWith('sdk'),
753  )
754  if (registered.length === 0) {
755    return { live: [], dormant: [], closed: [], checkout: [], repos: { selected: [], options: [] } }
756  }
757
758  // ponytail: a crashed session's file stays until its pid is reused; compare procStart if that bites
759  const ps = await $.process
760    .run(['ps', '-o', 'pid=', '-p', registered.map(one => one.pid).join(',')])
761    .catch(() => undefined)
762  const alive = new Set((ps?.stdout ?? '').split(/\s+/).filter(Boolean).map(Number))
763  // this session is always alive, so an empty set means ps could not see anything: keep all
764  const isLivenessKnown = alive.size > 0
765  const live = registered
766    .filter(one => !isLivenessKnown || alive.has(one.pid))
767    .sort((a, b) => a.startedAt - b.startedAt)
768  const [current, tails, folders, saved] = await Promise.all([
769    $.session.id(),
770    Promise.all(live.map(one => tailOf($, configDir, one))),
771    projectFoldersOf($, `${configDir}/projects`),
772    $.fs
773      .read(reposFileOf(configDir))
774      .then((text): unknown => JSON.parse(String(text)))
775      .catch(() => undefined),
776  ])
777  const cwds = live.map((one, index) => currentCwdIn(tails[index] ?? '', one))
778  // a background session runs no sidebar of its own: its model, effort and cost come from its
779  // transcript, its colour from its job, and the session that started it from the transcripts
780  const background = live.filter(one => one.kind === 'bg')
781  const [jobColors, launches] = await Promise.all([
782    Promise.all(
783      background.map(one =>
784        $.fs
785          .read(`${configDir}/jobs/${one.jobId}/state.json`)
786          .then(text => (JSON.parse(String(text)) as { color?: string }).color)
787          .catch(() => undefined),
788      ),
789    ),
790    background.length === 0
791      ? Promise.resolve<Record<string, string>>({})
792      : $.fs
793          .read(launchesFileOf(configDir))
794          .then(text => JSON.parse(String(text)) as Record<string, string>)
795          .catch((): Record<string, string> => ({})),
796  ])
797  // workers started some other way (a script that runs claude --bg) are looked for in transcripts
798  await findLaunchers(
799    $,
800    background.flatMap(one =>
801      one.jobId === undefined || launches[one.jobId] !== undefined ? [] : [one.jobId],
802    ),
803    live.filter(one => one.kind !== 'bg').map(one => transcriptOf(configDir, one)),
804  )
805  const launcherOf = (jobId: string | undefined) =>
806    jobId === undefined
807      ? undefined
808      : live.find(one =>
809          launches[jobId] !== undefined
810            ? one.sessionId === launches[jobId]
811            : transcriptOf(configDir, one) === launcherOfJob.get(jobId),
812        )?.pid
813  const lastIn = (text: string, pattern: RegExp) => [...text.matchAll(pattern)].at(-1)?.[1]
814  // where each was launched and where it works now: a session can move on to another repo
815  const worktreeLists = await Promise.all(
816    [...new Set([...live.map(one => one.cwd), ...cwds])].map(cwd => worktreesOf($, cwd)),
817  )
818  const ownIndex = live.findIndex(one => one.sessionId === current)
819  const ownState = await publishOwnState(
820    $,
821    configDir,
822    live.find(one => one.sessionId === current),
823  )
824  const states = await Promise.all(
825    live.map(one =>
826      one.sessionId === current ? ownState : readState($, configDir, one.sessionId),
827    ),
828  )
829  const worktrees = [...new Map(worktreeLists.flat().map(tree => [tree.path, tree])).values()]
830  const trees = live.map((one, index) => worktreeOf(cwds[index] ?? one.cwd, one.name, worktrees))
831  // a session outside git counts its folder as its repo
832  const rowRepos = live.map((one, index) => trees[index]?.repo ?? cwds[index] ?? one.cwd)
833  const liveRepos = [...new Set([...worktrees.map(tree => tree.repo), ...rowRepos])]
834  // with no choice saved, the repos the live sessions run in
835  const selected = isPathList(saved) ? saved : liveRepos
836  // a live session's own worktrees as they are, so a dormant one is told apart by identity
837  const selectedWorktrees = (
838    await Promise.all(
839      selected.map(repo => {
840        const known = worktrees.filter(tree => tree.repo === repo)
841
842        return known.length > 0 ? known : worktreesOf($, repo)
843      }),
844    )
845  ).flat()
846  const { closed, checkout, costOfTicket, spend } = await loadClosedSessions($, {
847    folders: folders.filter(one => selected.includes(one.repo)),
848    worktrees: selectedWorktrees,
849    liveIds: new Set(live.map(one => one.sessionId)),
850    liveNames: new Set(live.map(one => one.name)),
851  })
852
853  return {
854    closed,
855    checkout,
856    spend,
857    repos: {
858      selected,
859      // the busiest first, then by name
860      options: [...new Set([...liveRepos, ...folders.map(one => one.repo)])]
861        .map(path => ({ path, live: rowRepos.filter(repo => repo === path).length }))
862        .sort((a, b) => b.live - a.live || baseName(a.path).localeCompare(baseName(b.path))),
863    },
864    cacheExpiresAt: ownIndex === -1 ? undefined : cacheExpiryIn(tails[ownIndex] ?? ''),
865    live: live.map((one, index) => {
866      const tree = trees[index]
867      const place = placeOf(tree?.path ?? cwds[index] ?? one.cwd)
868
869      const tail = tails[index] ?? ''
870      const isBackground = one.kind === 'bg'
871      // a background session publishes its state like any other once its own mod has run; until
872      // then its transcript tells
873      const savedCost = isBackground ? lastIn(tail, /"totalCostUSD":([0-9.eE+-]+)/g) : undefined
874
875      return {
876        pid: one.pid,
877        name: one.name ?? place,
878        status: one.status ?? 'idle',
879        place,
880        branch: tree?.branch,
881        repo: rowRepos[index] ?? one.cwd,
882        worktree:
883          tree === undefined || tree.isMain ? undefined : { path: tree.path, repo: tree.repo },
884        // a busy session's own loop is an agent at work too
885        agents: (one.status === 'busy' ? 1 : 0) + (states[index]?.running ?? 0),
886        isAutopilot: states[index]?.isAutopilot ?? false,
887        model:
888          states[index]?.model ??
889          (isBackground ? lastIn(tail, /"model":"(claude-[^"]+)"/g) : undefined),
890        color: isBackground ? jobColors[background.indexOf(one)] : states[index]?.color,
891        effort:
892          states[index]?.effort ??
893          (isBackground ? lastIn(tail, /"effort":"(low|medium|high|xhigh|max)"/g) : undefined),
894        cost:
895          states[index]?.cost ??
896          (savedCost === undefined ? undefined : Math.round(Number(savedCost) * 100) / 100),
897        context: states[index]?.context,
898        jobId: isBackground ? one.jobId : undefined,
899        launchedBy: isBackground ? launcherOf(one.jobId) : undefined,
900        // Remote Control is on; whether a phone has it open is not told to mods
901        isRemote: typeof one.bridgeSessionId === 'string',
902        isCurrent: one.sessionId === current,
903      }
904    }),
905    dormant: selectedWorktrees
906      .filter(tree => !tree.isMain && !trees.includes(tree))
907      .map(tree => ({
908        path: tree.path,
909        repo: tree.repo,
910        name: tree.branch ?? baseName(tree.path),
911        place: placeOf(tree.path),
912        cost: costOfTicket.get(
913          `${tree.repo}#${ticketOf(baseName(tree.path)) ?? ticketOf(tree.branch)}`,
914        ),
915      }))
916      .sort((a, b) => a.name.localeCompare(b.name)),
917  }
918}
919
920// the collapsed sections, shared so every session's sidebar folds the same way
921const collapsedFileOf = (configDir: string) => `${configDir}/sessions-sidebar/collapsed.json`
922
923// the file maps each section to whether it is folded, so one added later starts as its default;
924// an older copy is the list of folded sections among the three there were then
925const collapsedOf = (saved: unknown): Section[] => {
926  const isFolded: Partial<Record<Section, boolean>> = Array.isArray(saved)
927    ? {
928        live: saved.includes('live'),
929        dormant: saved.includes('dormant'),
930        closed: saved.includes('closed'),
931      }
932    : typeof saved === 'object' && saved !== null
933      ? saved
934      : {}
935
936  return SECTIONS.filter(section => isFolded[section] ?? IS_COLLAPSED_BY_DEFAULT[section])
937}
938
939// written before the atom, so a refresh reading the file in between cannot fold it back
940const toggleSection = async ($: EngineInterface, section: Section) => {
941  const sections = await read($, collapsedSections)
942  const toggled = sections.includes(section)
943    ? sections.filter(one => one !== section)
944    : [...sections, section]
945  await $.fs.write(
946    collapsedFileOf(await configDirOf($)),
947    JSON.stringify(Object.fromEntries(SECTIONS.map(one => [one, toggled.includes(one)]))),
948  )
949  await update($, collapsedSections, () => toggled)
950}
951
952const archivedFileOf = (configDir: string) => `${configDir}/sessions-sidebar/archived.json`
953
954const detailFileOf = (configDir: string) => `${configDir}/sessions-sidebar/detail.json`
955
956// the repos picked, shared by every session's sidebar; absent until the first pick
957const reposFileOf = (configDir: string) => `${configDir}/sessions-sidebar/repos.json`
958
959const isPathList = (value: unknown): value is string[] =>
960  Array.isArray(value) && value.every(one => typeof one === 'string')
961
962const filtersFileOf = (configDir: string) => `${configDir}/sessions-sidebar/filters.json`
963
964const setSectionFilter = async ($: EngineInterface, section: Section, filter: KindFilter) => {
965  const changed = { ...(await read($, sectionFilters)), [section]: filter }
966  await $.fs.write(filtersFileOf(await configDirOf($)), JSON.stringify(changed))
967  await update($, sectionFilters, () => changed)
968}
969
970const isKindFilter = (value: unknown): value is KindFilter =>
971  KIND_FILTERS.some(one => one.id === value)
972
973const setDetailLevel = async ($: EngineInterface, level: DetailLevel) => {
974  await $.fs.write(detailFileOf(await configDirOf($)), JSON.stringify(level))
975  await update($, detailLevel, () => level)
976}
977
978const setArchived = async (
979  $: EngineInterface,
980  sessionIds: readonly string[],
981  isArchived: boolean,
982) => {
983  const ids = await read($, archivedIds)
984  const others = ids.filter(one => !sessionIds.includes(one))
985  const changed = isArchived ? [...others, ...sessionIds] : others
986  await $.fs.write(archivedFileOf(await configDirOf($)), JSON.stringify(changed))
987  await update($, archivedIds, () => changed)
988}
989
990// 3d 4h, 2h 15m, 38m: a minute's resolution, so the footer redraws at most once a minute
991const durationText = (ms: number) => {
992  const minutes = Math.max(0, Math.round(ms / 60_000))
993  const days = Math.floor(minutes / 1440)
994  const hours = Math.floor((minutes % 1440) / 60)
995
996  return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
997}
998
999// the 7d window resets days away, so its weekday and time beat a countdown; `date` formats it in the
1000// local zone like the status line does, once per reset
1001const weekdayTimeOf = new Map<string, Promise<string | undefined>>()
1002
1003const weekdayTime = ($: EngineInterface, resetsAt: string) => {
1004  const known = weekdayTimeOf.get(resetsAt)
1005  if (known !== undefined) return known
1006
1007  const seconds = String(Math.floor(Date.parse(resetsAt) / 1000))
1008  const formatted = $.process
1009    .run(['date', '-r', seconds, '+%a %H:%M'])
1010    .then(ran => (ran.exitCode === 0 && ran.stdout.trim() !== '' ? ran.stdout.trim() : undefined))
1011    .catch(() => undefined)
1012  weekdayTimeOf.set(resetsAt, formatted)
1013
1014  return formatted
1015}
1016
1017const refresh = async ($: EngineInterface) => {
1018  const configDir = await configDirOf($)
1019  const [
1020    { live, dormant, closed, checkout, cacheExpiresAt, spend, repos },
1021    collapsed,
1022    archived,
1023    detail,
1024    filters,
1025    usage,
1026    now,
1027  ] = await Promise.all([
1028    loadSessions($),
1029    $.fs
1030      .read(collapsedFileOf(configDir))
1031      .then(text => collapsedOf(JSON.parse(String(text))))
1032      .catch(() => DEFAULT_COLLAPSED),
1033    $.fs
1034      .read(archivedFileOf(configDir))
1035      .then(text => JSON.parse(String(text)) as string[])
1036      .catch(() => []),
1037    $.fs
1038      .read(detailFileOf(configDir))
1039      .then(text => {
1040        const saved: unknown = JSON.parse(String(text))
1041
1042        return DETAIL_LEVELS.find(level => level === saved) ?? 'full'
1043      })
1044      .catch((): DetailLevel => 'full'),
1045    $.fs
1046      .read(filtersFileOf(configDir))
1047      .then((text): Partial<Record<Section, KindFilter>> => {
1048        const saved: unknown = JSON.parse(String(text))
1049        if (typeof saved !== 'object' || saved === null) return {}
1050
1051        return Object.fromEntries(
1052          Object.entries(saved).filter(
1053            ([section, kind]) => SECTIONS.some(one => one === section) && isKindFilter(kind),
1054          ),
1055        )
1056      })
1057      .catch((): Partial<Record<Section, KindFilter>> => ({})),
1058    $.session.usage().catch(() => undefined),
1059    $.clock.now(),
1060  ])
1061  const info = {
1062    limits: await Promise.all(
1063      (usage?.rateLimits ?? []).flatMap(limit => {
1064        const label = { five_hour: '5h', seven_day: '7d' }[limit.kind]
1065        if (label === undefined) return []
1066
1067        const { resetsAt } = limit
1068        const resetsIn =
1069          resetsAt === undefined
1070            ? Promise.resolve(undefined)
1071            : label === '7d'
1072              ? weekdayTime($, resetsAt)
1073              : Promise.resolve(durationText(Date.parse(resetsAt) - now))
1074
1075        return [
1076          resetsIn.then(text => ({
1077            label,
1078            percent: Math.round(limit.percentUsed),
1079            resetsIn: text,
1080          })),
1081        ]
1082      }),
1083    ),
1084    cache:
1085      cacheExpiresAt === undefined
1086        ? undefined
1087        : {
1088            isWarm: cacheExpiresAt > now,
1089            left: durationText(cacheExpiresAt - now),
1090          },
1091    spend,
1092  }
1093  if (JSON.stringify(info) !== JSON.stringify(await read($, usageInfo))) {
1094    await update($, usageInfo, () => info)
1095  }
1096  if (JSON.stringify(collapsed) !== JSON.stringify(await read($, collapsedSections))) {
1097    await update($, collapsedSections, () => collapsed)
1098  }
1099  if (detail !== (await read($, detailLevel))) await update($, detailLevel, () => detail)
1100  if (JSON.stringify(filters) !== JSON.stringify(await read($, sectionFilters))) {
1101    await update($, sectionFilters, () => filters)
1102  }
1103  if (JSON.stringify(repos) !== JSON.stringify(await read($, repoChoice))) {
1104    await update($, repoChoice, () => repos)
1105  }
1106  if (JSON.stringify(archived) !== JSON.stringify(await read($, archivedIds))) {
1107    await update($, archivedIds, () => archived)
1108  }
1109  if (JSON.stringify(live) !== JSON.stringify(await read($, rows))) {
1110    await update($, rows, () => live)
1111  }
1112  if (JSON.stringify(dormant) !== JSON.stringify(await read($, dormantRows))) {
1113    await update($, dormantRows, () => dormant)
1114  }
1115  if (JSON.stringify(checkout) !== JSON.stringify(await read($, checkoutRows))) {
1116    await update($, checkoutRows, () => checkout)
1117  }
1118  if (JSON.stringify(closed) !== JSON.stringify(await read($, closedRows))) {
1119    await update($, closedRows, () => closed)
1120  }
1121}
1122
1123// Warp puts WARP_FOCUS_URL (warp://session/<uuid>, warppreview:// on Preview) in each tab's env;
1124// the claude process inherits it, and opening it brings that tab to the front
1125const focusUrlOf = async ($: EngineInterface, pid: number) => {
1126  const ps = await $.process.run(['ps', '-E', '-ww', '-o', 'command=', '-p', String(pid)])
1127
1128  return /(?:^|\s)WARP_FOCUS_URL=(warp[a-z]*:\/\/session\/[0-9a-f]+)(?:\s|$)/.exec(ps.stdout)?.[1]
1129}
1130
1131const focusWarpTab = async ($: EngineInterface, one: SessionRow) => {
1132  const url = await focusUrlOf($, one.pid)
1133  if (url === undefined) {
1134    $.ui.toast(`${one.name}: not running in a Warp tab`)
1135
1136    return
1137  }
1138
1139  const opened = await $.process.run(['open', url])
1140  if (opened.exitCode !== 0) $.ui.toast(`${one.name}: could not open its Warp tab`)
1141}
1142
1143const newestOf = (files: readonly { path: string; mtimeMs: number }[], paths: readonly string[]) =>
1144  files.filter(file => paths.includes(file.path)).sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
1145
1146const grepFiles = async (
1147  $: EngineInterface,
1148  needles: readonly string[],
1149  paths: readonly string[],
1150) => {
1151  if (paths.length === 0) return []
1152  const found = await $.process.run([
1153    'grep',
1154    '-l',
1155    '-F',
1156    ...needles.flatMap(needle => ['-e', needle]),
1157    ...paths,
1158  ])
1159
1160  return found.stdout.split('\n').filter(Boolean)
1161}
1162
1163// the session a dormant worktree belongs to: one titled after its ticket anywhere in the repo's
1164// transcripts (sessions often start in the repo and work in the worktree), else the newest
1165// interactive one started inside the worktree; headless (-p, SDK) runs are never picked
1166const findSessionOf = async ($: EngineInterface, configDir: string, one: DormantRow) => {
1167  const projects = `${configDir}/projects`
1168  const repoKey = projectKeyOf(one.repo)
1169  const treeKey = projectKeyOf(one.path)
1170  const dirs = (await $.fs.list(projects).catch(() => [])).filter(
1171    entry =>
1172      entry.kind === 'dir' &&
1173      (entry.name === repoKey ||
1174        entry.name === treeKey ||
1175        entry.name.startsWith(`${repoKey}--claude-worktrees-`)),
1176  )
1177  const files = (
1178    await Promise.all(
1179      dirs.map(async dir =>
1180        (await $.fs.list(`${projects}/${dir.name}`).catch(() => []))
1181          .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
1182          .map(entry => ({
1183            path: `${projects}/${dir.name}/${entry.name}`,
1184            mtimeMs: entry.mtimeMs,
1185          })),
1186      ),
1187    )
1188  ).flat()
1189  const ticket = ticketOf(baseName(one.path)) ?? ticketOf(one.name)
1190  const titled =
1191    ticket === undefined
1192      ? []
1193      : await grepFiles(
1194          $,
1195          [`"customTitle":"${ticket}-`, `"customTitle":"${ticket}"`],
1196          files.map(file => file.path),
1197        )
1198  const startedInside = await grepFiles(
1199    $,
1200    ['"entrypoint":"cli"'],
types/index.d.ts 101 lines
1export type SessionRow = {
2  pid: number
3  name: string
4  status: string
5  place: string
6  branch?: string
7  // the main checkout of the repo it works in; outside git, its folder
8  repo: string
9  // the linked worktree it works in, and the repo that owns it; absent in the main checkout
10  worktree?: { path: string; repo: string }
11  agents: number
12  isAutopilot: boolean
13  // the main loop's model, as /model shows it
14  model?: string
15  // the session's colour as /color set it; absent when it has none
16  color?: string
17  // the effort its last response ran at; absent before its first
18  effort?: string
19  // what it has cost so far in US dollars, as /cost totals it
20  cost?: number
21  // how full its context window is, as the status line reads it; absent before its first response
22  context?: { percent: number; window: number }
23  // Remote Control is on
24  isRemote: boolean
25  // a background session's (claude --bg) id; it has no tab, `claude attach` opens it
26  jobId?: string
27  // the live session that started it, by pid, when its transcript shows the launch
28  launchedBy?: number
29  isCurrent: boolean
30}
31
32export type DormantRow = {
33  path: string
34  repo: string
35  name: string
36  place: string
37  // what the session titled after its ticket last saved as its cost, in US dollars
38  cost?: number
39}
40
41// how much each live session shows: its name line alone; with worktree, branch and context; with all
42export type DetailLevel = 'compact' | 'standard' | 'full'
43
44export type Section = 'live' | 'dormant' | 'closed'
45
46// what a section lists: both kinds, those with a worktree only, or the ad hoc ones only
47export type KindFilter = 'all' | 'worktrees' | 'adhoc'
48
49// a repo there are sessions of, by its main checkout's path, with how many run in it now
50export type RepoOption = { path: string; live: number }
51
52export type ClosedRow = {
53  sessionId: string
54  name: string
55  place: string
56  repo: string
57  transcript: string
58  // what it last saved as its cost, in US dollars
59  cost?: number
60  // used in the last week
61  isRecent: boolean
62  modifiedAt: number
63}
64
65// a named session that only ever worked in its repo's main checkout, now not running
66export type CheckoutRow = ClosedRow
67
68declare module 'claude-code' {
69  interface PluginState {
70    'sessions-sidebar': {
71      rows: SessionRow[]
72      dormant: DormantRow[]
73      closed: ClosedRow[]
74      checkout: CheckoutRow[]
75      collapsed: Section[]
76      archived: string[]
77      offset: number
78      pulse: boolean
79      usage: {
80        limits: { label: string; percent: number; resetsIn?: string }[]
81        cache?: { isWarm: boolean; left: string }
82        // what every session not running saved as its cost: those used in the last week, and all
83        spend?: { week: number; repo: number }
84      }
85      question: { key: string; text: string; options: string[] } | null
86      olderSearch: string
87      searches: Partial<Record<Section, string>>
88      detail: DetailLevel
89      filters: Partial<Record<Section, KindFilter>>
90      newSession: {
91        defaultModel?: string
92        // each model's saved effort, by its --model alias
93        saved: Record<string, string | undefined>
94        effort?: string
95      } | null
96      repos: { selected: string[]; options: RepoOption[] }
97      repoSearch: string
98    }
99  }
100}
101