Sidebar listing every Claude Code session open on this machine

hooks/register.tsx 3055 lines1import { 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 lines1export 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