SLOPSHOPPER

sessions

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

newpanecommandprocesstimer
v0.4.0MITupdated 2026-10-22alexandrstudio/claude-sessions/sessions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sessions
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ ctrl+x tab (or a click) to use this pane · │ ┃ Esc gives the keys back ⏺ Read(src/auth.ts) │ ┃ [ Filter ] [ Info ] [ Settings ] [ Refres ⎿ Read 6 lines │ ┃ Show: [ All ] [ Active ] [ Archived ] ⏺ Update(src/auth.ts) │ ┃ Active · 0 ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sessions │ ⎿ sessions: Sessions pane opened (ctrl+x tab moves the keys to it) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
ctrl+x tab (or a click) to use this pane · Esc gives the keys back [ Filter ] [ Info ] [ Settings ] [ Refresh ] Reading ses Show: [ All ] [ Active ] [ Archived ] Active · 0
README

claude-sessions: Claude Code mods for many sessions

The /sessions pane: toolbar buttons and every session across projects

Two mods for people who keep several Claude Code sessions going across different projects:

  • sessions: /sessions lists every session across all projects, with the command to resume any of them.
  • waiting: every session shows which other sessions are waiting for you, and you get a notification when one starts waiting.

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

/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.

  • Show: [ All ] [ Active ] [ Archived ]: All lists the sessions on this machine first and the archive-only ones below them, each newest first. Active hides the archive, Archived shows only the archive. The choice is remembered for the next time.
  • [ Filter ] opens a text field: words match title, first prompt and folder; :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.
  • Click a row for the details and the resume command, 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 ].

One session: details, resume command and actions

  • [ Info ] explains all of this inside the pane; [ Settings ] holds the archive folder and the automatic archive.
  • Without a mouse: ctrl+x tab gives the pane the keys, each button's letter is a shortcut (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).

The archive

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.

  • Set the folder in the pane ([ Settings ] → [ Set folder ], type it, Enter) or in /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.
  • Archiving runs by itself when a session starts and every 6 hours, copying new and changed sessions. It never deletes from the archive. An archive on an external drive waits until the drive is plugged in. Turn it off in the settings and use [ Archive now ] instead.

Don't put the archive in a git repository you push: transcripts hold whatever the sessions saw, file contents and command output included.

Keep your sessions longer

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.

Optional: archive while Claude Code is closed

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.


waiting

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 waiting line above the prompt: two other sessions need 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:

  • Notify (on): the notification after 15 seconds.
  • Show finished sessions (off): also list sessions whose turn has finished and that wait for your next prompt.

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.


License

MIT

Workflow stage: solo

Source 4 files
hooks/register.tsx 499 lines
1import { 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}
499
hooks/indexer.ts 124 lines
1// 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`
124
hooks/scripts.ts 79 lines
1// 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`
79
types/index.d.ts 50 lines
1export 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