Browse every Claude Code session across all projects; archive them to another folder, move old ones off this Mac, restore them.

Two mods for people who keep several Claude Code sessions going across different projects:
/sessions lists every session across all projects, with the command to resume any of them.Install either or both. At the prompt of a terminal session:
/plugin install sessions --marketplace alexandrstudio/claude-sessions
/plugin install waiting --marketplace alexandrstudio/claude-sessions
Answer y to add the marketplace, then pick a scope (user scope loads the mod in every session). From a shell:
claude plugin install sessions --marketplace alexandrstudio/claude-sessions
claude plugin install waiting --marketplace alexandrstudio/claude-sessions
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with alexandrstudio/claude-sessions, then Add plugin.
/sessions opens a pane listing every Claude Code session you have had, across all projects, newest first. /resume only shows the folder you are in; this shows them all, and gives you the command to continue any of them.
Each row: how long ago, the project folder, the session's title (or first prompt), and how many prompts you typed. Blue folders are on this machine, yellow ones exist only in the archive (see below), and ● marks a session that is running right now.
Everything is a button: click it (Claude Code's fullscreen mode reports clicks) or Tab to it and press Enter.
:archived shows archive-only sessions, :local the ones not archived yet, :running the open ones. Enter keeps the filter, [ Clear ] removes it. Outside the field, your typing never lands in it.cd '<folder>' && claude -r <id>: [ Copy resume command ] (paste it in a new terminal tab), [ Restore to this Mac ] for an archive-only session, [ Move to archive ] to remove the copy on this machine once the archive holds an identical one (press it twice), [ Back ].f filter, i info, s settings, 0 refresh, 1/2/3 all/active/archived, c copy, b back), and Esc hands the keys back to the prompt.Requires python3 on your PATH (it reads the transcripts and caches what it read in ~/.claude/cache/sessions-mod-index.json).
Give the mod an archive folder and it keeps a second copy of your sessions there, so /sessions still lists them after they are gone from ~/.claude/projects, and you can move old ones off your machine.
/config under sessions → Archive folder, for example /Volumes/MyDrive/claude-sessions/projects. It is created on the first run. Leave it empty for no archive.Don't put the archive in a git repository you push: transcripts hold whatever the sessions saw, file contents and command output included.
Claude Code deletes transcripts older than 30 days by default. With the archive running, that is fine: the archive keeps the old ones. Without an archive, set cleanupPeriodDays in ~/.claude/settings.json to keep them:
{ "cleanupPeriodDays": 36500 }
Transcripts grow (roughly 0.5–1 GB a month of heavy use), so keep an eye on ~/.claude/projects.
The mod only archives while a session is open. To archive on a schedule regardless, sessions/extras/archive-sessions.sh does the same copy from a shell, and on macOS sessions/extras/claude-session-archive.plist runs it daily: fill in the two paths and follow the comment at its top. Give the script the folder above projects; it writes into projects/ inside it.
When you work in several sessions at once, one of them often sits on a permission prompt or a question while you are in another. With waiting installed, every session shows a line above its prompt while another session needs you:
The line is only there while something waits. A session that has waited for 15 seconds also sends a notification, "Claude is waiting for you: <folder>: <reason>", through your usual notification setting (/config → notifications). A wait you answer within 15 seconds sends nothing.
Settings, in /config under waiting:
How it works: each session writes a small status file to ~/.claude/cache/waiting/ and reads the others' every few seconds. A session that is gone (closed, crashed) drops off the line within about 35 seconds. Only sessions that have the mod loaded take part, so install it at user scope and restart the sessions you already have open.
MIT
Workflow stage: solo
hooks/register.tsx 499 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Editing, IndexState, Scope, SessionRow, View } from '../types'
5import { INDEXER } from './indexer'
6import { ARCHIVER, MOVER, RESTORE } from './scripts'
7
8const PANE = 'sessions'
9// Past this many rows the list is cut; the filter narrows it down instead.
10const MAX_ROWS = 200
11// The first archive run waits until the session has settled in.
12const FIRST_ARCHIVE_MS = 20_000
13const ARCHIVE_EVERY_MS = 6 * 60 * 60 * 1000
14const FOCUS_RETRY_MS = 300
15
16// Settings, set again on every (re)load: a change in /config or in the pane's
17// settings view reloads the module with the new values.
18let archiveDir = ''
19let autoArchive = true
20// This session's own id: it is running, so it can never be moved.
21let currentId = ''
22
23const index = atom({ plugin: 'sessions', key: 'index' } as const, {
24 sessions: [],
25 isLoading: false,
26 isArchiveMounted: false,
27} as IndexState)
28const filter = atom({ plugin: 'sessions', key: 'filter' } as const, '')
29const selected = atom({ plugin: 'sessions', key: 'selected' } as const, null as string | null)
30const notice = atom({ plugin: 'sessions', key: 'notice' } as const, '')
31const view = atom({ plugin: 'sessions', key: 'view' } as const, 'list' as View)
32const confirmMove = atom({ plugin: 'sessions', key: 'confirmMove' } as const, null as string | null)
33const archiveStatus = atom({ plugin: 'sessions', key: 'archiveStatus' } as const, '')
34// Which text field is open, if any. Fields appear only on request, so the
35// pane's keys go to its buttons the rest of the time.
36const editing = atom({ plugin: 'sessions', key: 'editing' } as const, null as Editing)
37const scope = atom({ plugin: 'sessions', key: 'scope' } as const, 'all' as Scope)
38
39const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
40
41const resumeCommand = (s: SessionRow) =>
42 s.cwd ? `cd ${shellQuote(s.cwd)} && claude -r ${s.id}` : `claude -r ${s.id}`
43
44// The last two segments of the folder say which project it is; the full
45// path is in the detail view.
46const shortFolder = (s: SessionRow) => {
47 if (!s.cwd) return s.project
48 const parts = s.cwd.split('/').filter(Boolean)
49 return parts.slice(-2).join('/')
50}
51
52const ago = (mtime: number) => {
53 const minutes = Math.max(0, Math.round((Date.now() / 1000 - mtime) / 60))
54 if (minutes < 60) return `${minutes}m`
55 if (minutes < 60 * 24) return `${Math.round(minutes / 60)}h`
56 if (minutes < 60 * 24 * 60) return `${Math.round(minutes / 60 / 24)}d`
57 return `${Math.round(minutes / 60 / 24 / 30)}mo`
58}
59
60const day = (mtime: number) => new Date(mtime * 1000).toISOString().slice(0, 16).replace('T', ' ')
61
62const clock = (ms: number) => new Date(ms).toISOString().slice(11, 16)
63
64const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
65
66// Words narrow by text; `:archived`, `:local` and `:running` narrow by where
67// the session is.
68const TAGS: Record<string, (s: SessionRow) => boolean> = {
69 ':archived': s => !s.isLive,
70 ':local': s => s.isLive && !s.inArchive,
71 ':running': s => s.isRunning,
72}
73
74const matches = (s: SessionRow, needle: string) => {
75 const hay = `${s.title ?? ''} ${s.first} ${s.cwd ?? ''} ${s.project} ${s.id}`.toLowerCase()
76 return needle
77 .toLowerCase()
78 .split(/\s+/)
79 .filter(Boolean)
80 .every(word => (TAGS[word] ? TAGS[word](s) : hay.includes(word)))
81}
82
83const canMove = (s: SessionRow) =>
84 archiveDir !== '' && s.isLive && s.inArchive && s.archiveCurrent && !s.isRunning && s.id !== currentId
85
86const whereText = (s: SessionRow) => {
87 if (!s.isLive) return 'archive only'
88 if (!s.inArchive) return archiveDir ? 'on this Mac only (not archived yet)' : 'on this Mac'
89 return s.archiveCurrent ? 'on this Mac and archived' : 'on this Mac; the archive copy is older'
90}
91
92const lastLine = (text: string) => text.trim().split('\n').pop() ?? ''
93
94async function runPy($: EngineInterface, script: string, args: string[], timeoutMs = 120_000) {
95 const ran = await $.process.run(['/usr/bin/env', 'python3', '-I', '-c', script, ...args], { timeoutMs })
96 if (ran.exitCode !== 0) throw new Error(lastLine(ran.stderr) || `exit ${ran.exitCode}`)
97 return JSON.parse(ran.stdout) as Record<string, unknown>
98}
99
100async function refresh($: EngineInterface) {
101 await update($, index, old => ({ ...old, isLoading: true, error: undefined }))
102 const home = (await $.env.get('HOME')) ?? ''
103 try {
104 const parsed = (await runPy($, INDEXER, [
105 `${home}/.claude/projects`,
106 archiveDir,
107 `${home}/.claude/cache/sessions-mod-index.json`,
108 `${home}/.claude/sessions`,
109 ])) as { sessions: SessionRow[]; archiveMounted: boolean }
110 await update($, index, () => ({
111 sessions: parsed.sessions,
112 isLoading: false,
113 isArchiveMounted: parsed.archiveMounted,
114 }))
115 } catch (err) {
116 const message = err instanceof Error ? err.message : String(err)
117 await update($, index, old => ({ ...old, isLoading: false, error: message }))
118 }
119}
120
121async function archiveNow($: EngineInterface) {
122 if (archiveDir === '') {
123 await update($, archiveStatus, () => 'No archive folder set.')
124 return
125 }
126 await update($, archiveStatus, () => 'Archiving…')
127 const home = (await $.env.get('HOME')) ?? ''
128 const at = clock(await $.clock.now())
129 try {
130 const done = (await runPy($, ARCHIVER, [`${home}/.claude/projects`, archiveDir], 600_000)) as { copied: number }
131 await update($, archiveStatus, () => `Last archived ${at} UTC: ${done.copied} files copied.`)
132 } catch (err) {
133 const message = err instanceof Error ? err.message : String(err)
134 await update($, archiveStatus, () => `Archive skipped ${at} UTC: ${message}.`)
135 }
136 await refresh($)
137}
138
139async function restore($: EngineInterface, s: SessionRow) {
140 const home = (await $.env.get('HOME')) ?? ''
141 try {
142 await runPy($, RESTORE, [s.path, `${home}/.claude/projects/${s.project}`])
143 await update($, notice, () => 'Restored. The resume command works again.')
144 } catch (err) {
145 await update($, notice, () => `Restore failed: ${err instanceof Error ? err.message : String(err)}`)
146 }
147 await refresh($)
148}
149
150// The first press asks, the second removes the copy on this Mac; the script
151// checks again that the archive holds an identical copy before deleting.
152async function moveToArchive($: EngineInterface, s: SessionRow) {
153 if ((await read($, confirmMove)) !== s.id) {
154 await update($, confirmMove, () => s.id)
155 await update($, notice, () => 'Press m again to remove the copy on this Mac. The archive keeps it; r brings it back.')
156 return
157 }
158 await update($, confirmMove, () => null)
159 try {
160 await runPy($, MOVER, [s.path, archiveDir, s.project])
161 await update($, notice, () => 'Removed from this Mac. It stays in the archive.')
162 } catch (err) {
163 await update($, notice, () => `Not moved: ${err instanceof Error ? err.message : String(err)}.`)
164 }
165 await refresh($)
166}
167
168// The row is `sessions.<field>`; should an install name it otherwise, the
169// /config rows are searched for this mod's field.
170async function setOption($: EngineInterface, field: string, value: string | boolean) {
171 let done
172 try {
173 done = await $.config.set({ key: `sessions.${field}`, value })
174 } catch {
175 const row = (await $.config.list()).find(r => r.key.startsWith('sessions') && r.key.endsWith(`.${field}`))
176 if (!row) {
177 await update($, notice, () => `Not saved: no setting ${field} found. Set it in /config.`)
178 return
179 }
180 done = await $.config.set({ key: row.key, value })
181 }
182 await update($, notice, () => ('deny' in done && done.deny ? `Not saved: ${done.deny}` : 'Saved.'))
183}
184
185// The choice is kept across sessions, so someone who hides the archive keeps
186// it hidden.
187async function setScope($: EngineInterface, next: Scope) {
188 await update($, scope, () => next)
189 await $.store.set('scope', next)
190}
191
192async function go($: EngineInterface, next: View) {
193 await update($, view, () => next)
194 await update($, selected, () => null)
195 await update($, confirmMove, () => null)
196 await update($, editing, () => null)
197 await update($, notice, () => '')
198}
199
200const openPane = ($: EngineInterface) =>
201 $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true })
202
203const SCOPES: [Scope, string, string][] = [
204 ['all', 'All', '1'],
205 ['active', 'Active', '2'],
206 ['archived', 'Archived', '3'],
207]
208
209const INFO: [string, string][] = [
210 ['The list', 'Every session in ~/.claude/projects, plus the archive folder when one is set. Newest first; blue folders are on this Mac, yellow ones only in the archive, ● marks a session running now.'],
211 ['Show', '[ All ] lists active sessions (on this Mac) first and archived ones below them. [ Active ] hides the archive, [ Archived ] shows only archive-only sessions. The choice is remembered.'],
212 ['Filter', 'Press [ Filter ] and type words to match title, first prompt or folder. :archived shows archive-only sessions, :local the ones not archived yet, :running the open ones. Enter keeps the filter; [ Clear ] removes it.'],
213 ['A session', 'Click a row. [ Copy resume command ] puts it on the clipboard (paste it in a new terminal tab). [ Restore to this Mac ] brings back an archive-only session. [ Move to archive ] removes the copy on this Mac once the archive holds an identical one; press it twice.'],
214 ['Archive', 'With an archive folder set, the mod copies new and changed sessions there when a session starts and every 6 hours (turn it off in settings). It never deletes from the archive. An archive on an external drive waits until the drive is plugged in.'],
215 ['Keep sessions longer', 'Claude Code deletes transcripts after 30 days by default. Set "cleanupPeriodDays": 36500 in ~/.claude/settings.json to keep them; with the archive running you can also leave the default and let the archive keep the old ones.'],
216 ['Buttons and keys', 'Click any [ button ] or row. Without a mouse: ctrl+x tab gives the pane the keys, Tab walks the buttons, Enter presses one, and each button letter is a shortcut (f filter, i info, s settings, 0 refresh, c copy, b back). Esc hands the keys back to the prompt.'],
217]
218
219export const register: Register = (on, options) => {
220 archiveDir = typeof options.archiveDir === 'string' ? options.archiveDir.trim() : ''
221 autoArchive = options.autoArchive !== false
222
223 on('session.start', async ($, e, next) => {
224 await $.command.register({
225 name: 'sessions',
226 description: 'Browse all sessions across projects and the archive',
227 })
228 currentId = await $.session.id()
229 if (archiveDir !== '' && autoArchive) {
230 $.clock.after(FIRST_ARCHIVE_MS, () => void archiveNow($))
231 $.clock.every(ARCHIVE_EVERY_MS, () => void archiveNow($))
232 }
233
234 return next(e)
235 })
236
237 on('command.run', { command: 'sessions' }, async $ => {
238 await go($, 'list')
239 const kept = await $.store.get('scope')
240 if (kept === 'all' || kept === 'active' || kept === 'archived') await update($, scope, () => kept)
241 void refresh($)
242 await openPane($)
243 // The surface grants focus only to an idle, empty prompt, which it is not
244 // while this command still runs; asking again just after it ends lands.
245 $.clock.after(FOCUS_RETRY_MS, () => void openPane($))
246
247 return { text: 'Sessions pane opened (ctrl+x tab moves the keys to it).' }
248 })
249
250 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
251 const ui = $.ui.resolve(e)
252 const { Box, Text, Button } = ui
253 // Mobile draws no text field; there the list shows unfiltered.
254 const Input = 'Input' in ui ? ui.Input : undefined
255 const state = await read($, index)
256 const needle = await read($, filter)
257 const pickedId = await read($, selected)
258 const message = await read($, notice)
259 const current = await read($, view)
260 const status = await read($, archiveStatus)
261 const field = await read($, editing)
262 const shownScope = await read($, scope)
263 const width = Math.max(30, e.props.bodyColumns)
264
265 const back = <Button key="back" label="Back" hotkey="b" onPress={() => go($, 'list')} />
266 // Keys reach the pane only while it holds them; say how to hand them over.
267 const keysHint = !e.props.isFocused && (
268 <Text dimColor>ctrl+x tab (or a click) to use this pane · Esc gives the keys back</Text>
269 )
270 const footer = message !== '' && <Text color="green" wrap="wrap">{message}</Text>
271
272 if (current === 'info') {
273 return (
274 <Box flexDirection="column">
275 {keysHint}
276 <Text bold>What /sessions can do</Text>
277 {INFO.map(([title, text]) => (
278 <Box flexDirection="column" key={title}>
279 <Text> </Text>
280 <Text color="cyan">{title}</Text>
281 <Text wrap="wrap">{text}</Text>
282 </Box>
283 ))}
284 <Text> </Text>
285 <Box flexDirection="row" gap={2}>
286 <Button key="to-settings" label="Settings" hotkey="s" onPress={() => go($, 'settings')} />
287 {back}
288 </Box>
289 </Box>
290 )
291 }
292
293 if (current === 'settings') {
294 return (
295 <Box flexDirection="column">
296 {keysHint}
297 <Text bold>Archive settings</Text>
298 <Text> </Text>
299 <Text>Archive folder: {archiveDir || <Text dimColor>none (no archive)</Text>}</Text>
300 {Input && field === 'folder' ? (
301 <Box flexDirection="row" gap={2}>
302 <Input
303 key="archiveDir"
304 label="New folder: "
305 placeholder="/Volumes/MyDrive/claude-sessions/projects (empty: no archive)"
306 value={archiveDir}
307 submitLabel="save"
308 autoFocus
309 onSubmit={async (value: string) => {
310 await update($, editing, () => null)
311 await setOption($, 'archiveDir', value.trim())
312 }}
313 />
314 <Button key="cancel-folder" label="Cancel" onPress={() => update($, editing, () => null)} />
315 </Box>
316 ) : (
317 Input && (
318 <Button
319 key="change-folder"
320 label={archiveDir ? 'Change folder' : 'Set folder'}
321 hotkey="f"
322 onPress={() => update($, editing, () => 'folder')}
323 />
324 )
325 )}
326 <Text dimColor wrap="wrap">
327 The folder gets the same layout as ~/.claude/projects and is created on the first run. On an external drive
328 the archive waits until the drive is plugged in.
329 </Text>
330 <Text> </Text>
331 <Text>
332 Archive automatically: {autoArchive ? 'on' : 'off'}
333 <Text dimColor> (when a session starts and every 6 hours)</Text>
334 </Text>
335 {status !== '' && <Text dimColor wrap="wrap">{status}</Text>}
336 <Text> </Text>
337 <Box flexDirection="row" gap={2}>
338 <Button
339 key="toggle-auto"
340 label={autoArchive ? 'Turn automatic archive off' : 'Turn automatic archive on'}
341 hotkey="t"
342 onPress={() => setOption($, 'autoArchive', !autoArchive)}
343 />
344 {archiveDir !== '' && <Button key="archive-now" label="Archive now" hotkey="a" onPress={() => archiveNow($)} />}
345 {back}
346 </Box>
347 {footer}
348 </Box>
349 )
350 }
351
352 const picked = pickedId ? state.sessions.find(s => s.id === pickedId) : undefined
353 if (picked) {
354 const cmd = resumeCommand(picked)
355 const isConfirming = (await read($, confirmMove)) === picked.id
356
357 return (
358 <Box flexDirection="column">
359 {keysHint}
360 <Text bold wrap="truncate">{picked.title ?? oneLine(picked.first)}</Text>
361 <Text dimColor>
362 {day(picked.mtime)} · {picked.prompts} prompts · {Math.round(picked.size / 1024)} KB · {whereText(picked)}
363 {picked.isRunning ? ' · running now' : ''}
364 </Text>
365 <Text> </Text>
366 <Text>Folder: {picked.cwd ?? '(unknown)'}</Text>
367 <Text dimColor wrap="wrap">First prompt: {oneLine(picked.first).slice(0, 300)}</Text>
368 <Text> </Text>
369 <Text color="cyan" wrap="wrap">{cmd}</Text>
370 <Text> </Text>
371 <Box flexDirection="row" gap={2}>
372 <Button
373 key="copy"
374 label="Copy resume command"
375 hotkey="c"
376 variant="primary"
377 autoFocus
378 onPress={async press => {
379 const copied = await $.ui.copy({ text: cmd, surface: press.surface })
380 await update($, notice, () =>
381 copied.isCopied ? 'Copied. Paste it in a new terminal tab.' : `Could not copy (${copied.reason}).`,
382 )
383 }}
384 />
385 {!picked.isLive && (
386 <Button key="restore" label="Restore to this Mac" hotkey="r" onPress={() => restore($, picked)} />
387 )}
388 {canMove(picked) && (
389 <Button
390 key="move"
391 label={isConfirming ? 'Press again to remove from this Mac' : 'Move to archive'}
392 hotkey="m"
393 onPress={() => moveToArchive($, picked)}
394 />
395 )}
396 {archiveDir !== '' && picked.isLive && !picked.archiveCurrent && !picked.isRunning && (
397 <Button key="archive-now" label="Archive now" hotkey="a" onPress={() => archiveNow($)} />
398 )}
399 {back}
400 </Box>
401 {footer}
402 </Box>
403 )
404 }
405
406 const folderWidth = Math.min(32, Math.max(12, Math.floor(width * 0.3)))
407 const shown = state.sessions.filter(s => matches(s, needle))
408 const active = shown.filter(s => s.isLive)
409 const archived = shown.filter(s => !s.isLive)
410 const rowsOf = (list: SessionRow[]) => [
411 ...list.slice(0, MAX_ROWS).map(s => (
412 <Button key={s.id} plain onPress={() => update($, selected, () => s.id)}>
413 <Text color="green">{s.isRunning ? '●' : ' '}</Text>
414 <Text dimColor>{ago(s.mtime).padStart(4)} </Text>
415 <Text color={s.isLive ? 'blue' : 'yellow'}>
416 {shortFolder(s).slice(0, folderWidth).padEnd(folderWidth)}
417 </Text>
418 {' '}
419 {(s.title ?? oneLine(s.first)).slice(0, Math.max(10, width - folderWidth - 16))}
420 <Text dimColor> {s.prompts}</Text>
421 </Button>
422 )),
423 ...(list.length > MAX_ROWS ? [<Text dimColor>… {list.length - MAX_ROWS} more, narrow the filter</Text>] : []),
424 ]
425 const archivedOnly = state.sessions.filter(s => !s.isLive).length
426 const header = state.isLoading
427 ? 'Reading sessions…'
428 : `${shown.length} of ${state.sessions.length} sessions` +
429 (archivedOnly ? ` · ${archivedOnly} archive-only` : '') +
430 (archiveDir === '' || state.isArchiveMounted ? '' : ' · archive folder not found')
431
432 return (
433 <Box flexDirection="column">
434 {keysHint}
435 <Box flexDirection="row" gap={2}>
436 {Input && <Button key="filter-edit" label="Filter" hotkey="f" onPress={() => update($, editing, () => 'filter')} />}
437 <Button key="info" label="Info" hotkey="i" onPress={() => go($, 'info')} />
438 <Button key="to-settings" label="Settings" hotkey="s" onPress={() => go($, 'settings')} />
439 <Button key="refresh" label="Refresh" hotkey="0" onPress={() => refresh($)} />
440 <Text dimColor>{header}</Text>
441 </Box>
442 <Box flexDirection="row" gap={2}>
443 <Text dimColor>Show:</Text>
444 {SCOPES.map(([value, label, key]) => (
445 <Button
446 key={`show-${value}`}
447 label={label}
448 hotkey={key}
449 variant={shownScope === value ? 'primary' : 'secondary'}
450 onPress={() => setScope($, value)}
451 />
452 ))}
453 </Box>
454 {Input && field === 'filter' && (
455 <Box flexDirection="row" gap={2}>
456 <Input
457 key="filter"
458 label="Filter: "
459 placeholder="words, or :archived :local :running"
460 value={needle}
461 submitLabel="done"
462 autoFocus
463 onInput={(value: string) => update($, filter, () => value)}
464 onSubmit={async (value: string) => {
465 await update($, filter, () => value.trim())
466 await update($, editing, () => null)
467 }}
468 />
469 <Button key="filter-done" label="Done" onPress={() => update($, editing, () => null)} />
470 </Box>
471 )}
472 {field !== 'filter' && needle !== '' && (
473 <Box flexDirection="row" gap={2}>
474 <Text>
475 Filter: <Text color="cyan">{needle}</Text>
476 </Text>
477 <Button key="filter-clear" label="Clear" onPress={() => update($, filter, () => '')} />
478 </Box>
479 )}
480 {state.error && <Text color="red">Could not read sessions: {state.error}</Text>}
481 {shownScope !== 'archived' && (
482 <Box flexDirection="column">
483 {shownScope === 'all' && <Text bold>Active · {active.length}</Text>}
484 {rowsOf(active)}
485 </Box>
486 )}
487 {shownScope !== 'active' && (shownScope === 'archived' || archived.length > 0) && (
488 <Box flexDirection="column">
489 {shownScope === 'all' && <Text> </Text>}
490 {shownScope === 'all' && <Text bold>Archived · {archived.length}</Text>}
491 {archived.length === 0 && <Text dimColor>No archive-only sessions.</Text>}
492 {rowsOf(archived)}
493 </Box>
494 )}
495 </Box>
496 )
497 })
498}
499hooks/indexer.ts 124 lines1// The session index is built by a Python script on the host: the mod's own
2// environment has no filesystem walk, and the transcripts total hundreds of
3// megabytes, so the script caches per file on (size, mtime) and only re-reads
4// the ones that changed.
5export const INDEXER = String.raw`
6import json, os, sys
7
8live_root, archive_root, cache_path, running_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
9archive_root = os.path.expanduser(archive_root) if archive_root else ''
10
11try:
12 with open(cache_path) as f:
13 cache = json.load(f)
14except Exception:
15 cache = {}
16
17def text_of(content):
18 if isinstance(content, str):
19 return content
20 if isinstance(content, list):
21 for block in content:
22 if isinstance(block, dict) and block.get('type') == 'text':
23 return block.get('text', '')
24 return ''
25
26def is_prompt(row):
27 if row.get('type') != 'user' or row.get('isMeta') or row.get('isSidechain'):
28 return False
29 msg = row.get('message') or {}
30 content = msg.get('content')
31 if isinstance(content, list) and any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in content):
32 return False
33 text = text_of(content).strip()
34 return bool(text) and not text.startswith('<')
35
36def read_session(path):
37 cwd, title, custom, first, prompts = None, None, None, None, 0
38 with open(path, errors='replace') as f:
39 for line in f:
40 if '"type"' not in line:
41 continue
42 try:
43 row = json.loads(line)
44 except Exception:
45 continue
46 kind = row.get('type')
47 if kind == 'custom-title':
48 custom = row.get('customTitle') or custom
49 elif kind == 'ai-title':
50 title = row.get('aiTitle') or row.get('title') or title
51 elif kind == 'summary':
52 title = title or row.get('summary')
53 if cwd is None and row.get('cwd'):
54 cwd = row['cwd']
55 if is_prompt(row):
56 prompts += 1
57 if first is None:
58 first = text_of(row['message']['content']).strip()
59 return {'cwd': cwd, 'title': custom or title, 'first': (first or '')[:300], 'prompts': prompts}
60
61# Claude Code keeps one file per running session; one whose process is gone
62# is left over from a crash.
63running = set()
64if os.path.isdir(running_dir):
65 for name in os.listdir(running_dir):
66 if not name.endswith('.json'):
67 continue
68 try:
69 with open(os.path.join(running_dir, name)) as f:
70 entry = json.load(f)
71 os.kill(int(entry['pid']), 0)
72 running.add(entry['sessionId'])
73 except Exception:
74 continue
75
76sessions = {}
77fresh_cache = {}
78for source, root in (('live', live_root), ('archive', archive_root)):
79 if not os.path.isdir(root):
80 continue
81 for project in os.listdir(root):
82 folder = os.path.join(root, project)
83 if not os.path.isdir(folder):
84 continue
85 for name in os.listdir(folder):
86 if not name.endswith('.jsonl'):
87 continue
88 path = os.path.join(folder, name)
89 try:
90 st = os.stat(path)
91 except OSError:
92 continue
93 sid = name[:-6]
94 if sid in sessions:
95 if source == 'archive':
96 live = sessions[sid]
97 # Archive copies keep the live file's mtime, so an equal
98 # size and mtime mean the copy is current.
99 live['inArchive'] = True
100 live['archiveCurrent'] = st.st_size == live['size'] and int(st.st_mtime) == live['mtime']
101 continue
102 stamp = [st.st_size, int(st.st_mtime)]
103 hit = cache.get(path)
104 info = hit['info'] if hit and hit.get('stamp') == stamp else read_session(path)
105 fresh_cache[path] = {'stamp': stamp, 'info': info}
106 if info['prompts'] == 0:
107 continue
108 sessions[sid] = dict(info, id=sid, project=project, path=path,
109 mtime=int(st.st_mtime), size=st.st_size,
110 isLive=source == 'live', inArchive=source == 'archive',
111 archiveCurrent=source == 'archive', isRunning=sid in running)
112
113try:
114 os.makedirs(os.path.dirname(cache_path), exist_ok=True)
115 with open(cache_path + '.tmp', 'w') as f:
116 json.dump(fresh_cache, f)
117 os.replace(cache_path + '.tmp', cache_path)
118except Exception:
119 pass
120
121out = sorted(sessions.values(), key=lambda s: s['mtime'], reverse=True)
122json.dump({'sessions': out, 'archiveMounted': os.path.isdir(archive_root)}, sys.stdout)
123`
124hooks/scripts.ts 79 lines1// Host-side scripts for the archive: the mod's environment has no file
2// copies, so these run under python3 like the indexer. Each prints one JSON
3// line on success and exits non-zero with a one-line reason otherwise.
4
5// Copies every file under ~/.claude/projects whose archive copy is missing or
6// differs in size or mtime. Never deletes from the archive.
7export const ARCHIVER = String.raw`
8import json, os, shutil, sys
9live_root, archive_root = sys.argv[1], os.path.expanduser(sys.argv[2])
10# An archive on an external drive waits for the drive; any other folder is
11# created on first use.
12parts = os.path.abspath(archive_root).split('/')
13if parts[1:2] == ['Volumes'] and not os.path.isdir('/'.join(parts[:3])):
14 sys.exit('/'.join(parts[:3]) + ' is not mounted')
15os.makedirs(archive_root, exist_ok=True)
16copied = total = 0
17for folder, _dirs, files in os.walk(live_root):
18 rel = os.path.relpath(folder, live_root)
19 for name in files:
20 total += 1
21 src = os.path.join(folder, name)
22 dest = os.path.join(archive_root, rel, name)
23 try:
24 st = os.stat(src)
25 dt = os.stat(dest) if os.path.exists(dest) else None
26 if dt and dt.st_size == st.st_size and int(dt.st_mtime) == int(st.st_mtime):
27 continue
28 os.makedirs(os.path.dirname(dest), exist_ok=True)
29 shutil.copy2(src, dest)
30 copied += 1
31 except OSError:
32 continue
33json.dump({'copied': copied, 'files': total}, sys.stdout)
34`
35
36// Removes a session's copy in ~/.claude/projects, but only when every one of
37// its files has an identical copy in the archive.
38export const MOVER = String.raw`
39import json, os, shutil, sys
40live_path, archive_root, project = sys.argv[1], os.path.expanduser(sys.argv[2]), sys.argv[3]
41archive_dir = os.path.join(archive_root, project)
42
43def same(src, dest):
44 if not os.path.exists(dest):
45 return False
46 a, b = os.stat(src), os.stat(dest)
47 return a.st_size == b.st_size and int(a.st_mtime) == int(b.st_mtime)
48
49checks = [(live_path, os.path.join(archive_dir, os.path.basename(live_path)))]
50side = live_path[:-6]
51if os.path.isdir(side):
52 for folder, _dirs, files in os.walk(side):
53 rel = os.path.relpath(folder, os.path.dirname(side))
54 checks += [(os.path.join(folder, n), os.path.join(archive_dir, rel, n)) for n in files]
55stale = [src for src, dest in checks if not same(src, dest)]
56if stale:
57 sys.exit('the archive copy is not up to date; archive first')
58os.remove(live_path)
59if os.path.isdir(side):
60 shutil.rmtree(side)
61json.dump({'removed': len(checks)}, sys.stdout)
62`
63
64// Copies an archived transcript (and its subagent folder, when there is one)
65// back where claude -r looks for it.
66export const RESTORE = String.raw`
67import json, os, shutil, sys
68src, dest_dir = sys.argv[1], sys.argv[2]
69os.makedirs(dest_dir, exist_ok=True)
70dest = os.path.join(dest_dir, os.path.basename(src))
71if os.path.exists(dest):
72 sys.exit('already restored')
73shutil.copy2(src, dest)
74side = src[:-6]
75if os.path.isdir(side):
76 shutil.copytree(side, os.path.join(dest_dir, os.path.basename(side)), dirs_exist_ok=True)
77json.dump({'restored': True}, sys.stdout)
78`
79types/index.d.ts 50 lines1export type SessionRow = {
2 id: string
3 project: string
4 path: string
5 cwd: string | null
6 title: string | null
7 first: string
8 prompts: number
9 mtime: number
10 size: number
11 isLive: boolean
12 inArchive: boolean
13 // The archive copy matches the live file (size and mtime), so the live one
14 // can be removed safely.
15 archiveCurrent: boolean
16 isRunning: boolean
17}
18
19export type IndexState = {
20 sessions: SessionRow[]
21 isLoading: boolean
22 isArchiveMounted: boolean
23 error?: string
24}
25
26export type View = 'list' | 'info' | 'settings'
27
28export type Editing = 'filter' | 'folder' | null
29
30// Which sessions the list shows: all of them (active first, then archived),
31// only those on this machine, or only archive-only ones.
32export type Scope = 'all' | 'active' | 'archived'
33
34declare module 'claude-code' {
35 interface PluginState {
36 sessions: {
37 index: IndexState
38 filter: string
39 selected: string | null
40 notice: string
41 view: View
42 // The session whose "move to archive" waits for a second press.
43 confirmMove: string | null
44 archiveStatus: string
45 editing: Editing
46 scope: Scope
47 }
48 }
49}
50