SLOPSHOPPER

session-manager

Browse saved Claude Code sessions in a pane: recap, rename, compact or delete them.

newpanecommandmodelprocess
v0.2.0MITupdated 2026-10-23oskarinmix/claude-session-manager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-manager
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ This project 0 sessions · 1 KB[ Refresh ⏺ Read(src/auth.ts) │ ┃ │ ✗ Failed: JSON Parse error: Unexpected ⎿ Read 6 lines │ ┃ │ identifier "dev" ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ No sessions found. ⏺ Bash(bun test) │ ┃ │ Click here (or ctrl+x then tab) to use ⎿ 3 pass, 1 fail │ ┃ │ this pane's keys. │ ┃ │ r refresh · esc close ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ✻ Worked for 42s · done 4:20 PM │ │ › /sessions │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
╭──────────────────────────────────────────────────────────╮ │ ◆ This project 0 sessions · 1 KB [ Refresh ] │ │ ✗ Failed: JSON Parse error: Unexpected identifier "dev" │ │ │ │ No sessions found. │ │ Click here (or ctrl+x then tab) to use this pane's keys. │ │ r refresh · esc close │ ╰──────────────────────────────────────────────────────────╯
README

<img src="docs/banner.svg" alt="Claude Session Manager" width="100%">

<img alt="Version 0.2.0" src="https://img.shields.io/badge/version-0.2.0-d97757?style=flat-square"> <img alt="Claude Code mod" src="https://img.shields.io/badge/Claude%20Code-mod-1b1f27?style=flat-square"> <img alt="Requires Node.js" src="https://img.shields.io/badge/requires-Node.js-3c873a?style=flat-square&logo=node.js&logoColor=white"> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue?style=flat-square"></a>

<a href="#install">Install</a> · <a href="#use">Use</a> · <a href="#fork-from-a-handoff">Fork</a> · <a href="#join-sessions">Join sessions</a> · <a href="#how-it-works">How it works</a>

A Claude Code mod that adds a Sessions pane for browsing your saved sessions (the .jsonl transcripts under ~/.claude/projects). From the pane you can resume any session (here or as a background agent), read a short recap of it, rename it, fork it into a new agent from a handoff, compact it like /compact, join several into one, or delete it.

<img src="docs/pane.svg" alt="The Sessions pane open beside a conversation: a list of sessions with sizes and ages, two marked for joining, and the selected session's recap, the Fork goal box and actions" width="100%">

Features

▶️ ResumeJump into any session of the current project, same as /resume, or open it as a background agent.
📝 RecapA 3–5 line summary written by Haiku, cached until the session changes.
✏️ RenameGive a session one name, shown in both /resume and the agents view.
🍴 ForkStart a new background agent from a handoff of a session: give it a goal, or let it wait for your instructions.
🗜️ CompactShrink a heavy session like /compact, without opening it.
🔗 JoinMerge several sessions into a new one, or into an existing one.
🧹 Clean upDelete one session or the copies left by resuming open ones; empty sessions are removed automatically.

Sizes are color-coded so heavy sessions stand out: yellow from 2 MB, red from 5 MB.

Install

At the prompt of a Claude Code terminal session:

/plugin install session-manager --marketplace oskarinmix/claude-session-manager

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

claude plugin install session-manager --marketplace oskarinmix/claude-session-manager

/plugin doesn't run there. Click + next to the prompt box → Plugins → Add marketplace with oskarinmix/claude-session-manager → Add plugin.

Clone the repo, then either start a session with claude --plugin-dir /path/to/claude-session-manager, or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-session-manager" } }

Requirements

  • Claude Code with mod (function hooks) support. The side pane and mouse clicks need fullscreen mode (/tui fullscreen).
  • Node.js on your PATH: the mod's file work runs in scripts/sm.cjs.

Use

The pane opens by itself beside the chat when a session starts (in fullscreen mode on a wide enough terminal); /sessions opens it again after you close it. Click a row (or press Enter on it) to select a session.

Browse
KeyAction
<kbd>r</kbd>Refresh the list
—⇣ compacted mark: shown while the last thing in the session is a compaction (/compact, auto-compact or the pane's Compact). Once more is added it goes away; the details then say "compacted before, more added since". The file keeps the full history, Claude reads from the last summary on. The footer lists only the keys usable right now
<kbd>o</kbd>Open as agent: opens the session as a new background agent (claude --bg --resume), same id, shown in the agents view. This window's session is left alone
<kbd>f</kbd>Fork: opens a Goal box. The mod writes a handoff of the session (Sonnet) to %TEMP%/claude-handoffs/ and starts a new background agent that reads it. With a goal, the agent (named after the goal) starts on it; with Enter on an empty box, the agent (<name> (fork)) replies with a short summary and waits for your instructions. Not a copy: the new session starts fresh from the handoff. Works on any session, including the current one
<kbd>h</kbd>Resume here: closes the pane and opens the session in this window, same as /resume
<kbd>s</kbd>Recap: 3–5 line summary of the session, written by Haiku and cached until the session changes
<kbd>Esc</kbd>Close the pane
Edit
KeyAction
Rename boxSaves the name for both /resume and the agents view. When a session starts or is resumed, a newer name from either place is copied to the other
<kbd>c</kbd>Compact: Sonnet writes a full summary and appends a compact boundary, so resuming starts from the summary. A .jsonl.bak backup is saved first
Join
KeyAction
<kbd>m</kbd>Mark to join: ticks the selected session (✓). Press again to unmark
<kbd>n</kbd>Join as new session (2+ marked): Sonnet writes one summary of all marked sessions and a new session is created from it, titled Joined: …. The originals stay untouched
<kbd>i</kbd>Join into the selected session (select one that isn't marked): the summary of the marked sessions is appended to it as a message, keeping its own history. A .jsonl.bak backup is saved first
<kbd>x</kbd>Clear marks
Delete
KeyAction
<kbd>d</kbd>Delete: asks to confirm, then removes the transcript, its backup and its sidecar folder
(auto)Empty sessions are deleted each time the pane loads: sessions with no conversation (only a title, /clear or commands, left behind by restarts and /clear). The current session and any session open in a window or agent are kept
<kbd>p</kbd>Delete copies: shown when the project has copies made by resuming a session while it was open elsewhere (marked · copy). Asks to confirm, and warns when a copy holds messages its original lacks. Copies open in a window and the current session are never included

A session marked open is held by another Claude Code process (another window, or a background agent in the agents list). Claude Code forks a copy when you resume a session that is already open, so Resume, Compact and Delete are turned off for it. Switch to it where it runs instead. A session marked copy is such a fork; its details name the session it was copied from.

[!WARNING] Deleting cannot be undone.

[!NOTE] The current session is shown in green. Resume, Compact, Delete and Join into are disabled for it: use /compact and /rename there.

Fork from a handoff

<img src="docs/fork.svg" alt="Pressing f on a session: Sonnet writes a handoff of it, then a new background agent reads the handoff and either starts on the goal you typed or waits for your instructions" width="100%">

Select a session, press <kbd>f</kbd>, and a Goal box opens with the cursor in it:

  • Type a goal and press Enter: the handoff is written with that goal in mind, and the new agent, named after the goal, reads it and starts working.
  • Press Enter on an empty box: the new agent, named <session name> (fork), reads the handoff, replies with a short summary and waits. Open it in the agents view and tell it what to build.

The handoff is a Markdown file under %TEMP%/claude-handoffs/ (your OS temp folder) covering what was built and decided, the files involved, open issues, where the work was left and which skills to use. The new session starts fresh from it, so a long conversation becomes a small, focused starting point. The original session is not changed.

Join sessions

<img src="docs/join.svg" alt="Marked sessions are summarized by Sonnet into one summary, which either becomes a new session (n) or is appended to the selected session (i)" width="100%">

Joined sessions carry a summary of each source, not its full message history.

[!IMPORTANT] Recap, Fork, Compact and Join make model calls through your Claude Code login, so they count toward your usage.

How it works

claude-session-manager/
├── hooks/index.tsx   the pane, the /sessions command and the model calls
└── scripts/sm.cjs    lists sessions, extracts transcripts, writes rename/compact/join rows,
                      creates joined sessions, saves handoffs and deletes files

scripts/sm.cjs refuses any path outside ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects).

License

MIT

Source 2 files
hooks/index.tsx 749 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SessionInfo, SessionSummary } from '../types'
5
6const PANE = 'session-manager'
7const TITLE = 'Sessions'
8
9const sessions = atom({ plugin: 'session-manager', key: 'sessions' } as const, [])
10const selected = atom({ plugin: 'session-manager', key: 'selected' } as const, null)
11const confirmDelete = atom({ plugin: 'session-manager', key: 'confirmDelete' } as const, null)
12const busy = atom({ plugin: 'session-manager', key: 'busy' } as const, null)
13const message = atom({ plugin: 'session-manager', key: 'message' } as const, null)
14const summaries = atom({ plugin: 'session-manager', key: 'summaries' } as const, {})
15const marked = atom({ plugin: 'session-manager', key: 'marked' } as const, [])
16const confirmCopies = atom({ plugin: 'session-manager', key: 'confirmCopies' } as const, false)
17const forking = atom({ plugin: 'session-manager', key: 'forking' } as const, null)
18
19const COMPACT_SYSTEM =
20  'You summarize a coding conversation between a user and Claude Code so the work can continue ' +
21  'in a fresh context. Be precise and complete; keep file paths, commands, code identifiers, ' +
22  'errors and decisions exactly as written.'
23
24const COMPACT_PROMPT = (transcript: string) =>
25  'Write a detailed summary of the conversation below with these sections:\n' +
26  '1. Primary Request and Intent\n2. Key Technical Concepts\n3. Files and Code Sections\n' +
27  '4. Errors and Fixes\n5. Problem Solving\n6. All User Messages (brief, in order)\n' +
28  '7. Pending Tasks\n8. Current Work\n9. Optional Next Step\n\n' +
29  'Reply with the summary only.\n\n<conversation>\n' +
30  transcript +
31  '\n</conversation>'
32
33const BRIEF_SYSTEM =
34  'You write short recaps of Claude Code sessions so a person can recognise one at a glance. ' +
35  'Write in the language the user mostly wrote in.'
36
37const BRIEF_PROMPT = (transcript: string) =>
38  'Recap the conversation below in 3 to 5 short bullet lines starting with "- ": what the user ' +
39  'wanted, what got done, and where it was left. Plain text, no headings, under 70 words.\n\n' +
40  '<conversation>\n' +
41  transcript +
42  '\n</conversation>'
43
44const BRIEF_LIMIT = 200000
45
46const JOIN_PROMPT = (parts: string[]) =>
47  'Below are several separate Claude Code conversations that are being joined so the work can ' +
48  'continue as one. Write one detailed summary covering all of them, with these sections:\n' +
49  '1. Primary Requests and Intent (per conversation)\n2. Key Technical Concepts\n' +
50  '3. Files and Code Sections\n4. Errors and Fixes\n5. Decisions and Problem Solving\n' +
51  '6. Pending Tasks\n7. Where Each Conversation Was Left\n8. Optional Next Step\n\n' +
52  'Name each conversation by its title where it matters. Reply with the summary only.\n\n' +
53  parts.join('\n\n')
54
55const JOIN_LIMIT = 600000
56
57const HANDOFF_PROMPT = (transcript: string, goal: string) =>
58  'Write a handoff document so a fresh Claude Code agent can start new work using the context of ' +
59  'the conversation below. ' +
60  (goal === ''
61    ? 'The user will say what to build next, so do not assume a goal.\n\n'
62    : `The new session will be used for: ${goal}\nPoint the sections at that goal.\n\n`) +
63  'Sections:\n1. What Was Built and Decided\n2. Files and Where Things Live (paths, not copies)\n' +
64  '3. Open Issues and Gotchas\n4. Where the Work Was Left\n5. Suggested Skills (which skills the agent should call the Skill tool for)\n\n' +
65  'Reference commits, specs, plans and files by path or URL instead of copying them. Redact API ' +
66  'keys, passwords and personal information. Reply with the document only.\n\n<conversation>\n' +
67  transcript +
68  '\n</conversation>'
69
70const helper = async ($: EngineInterface, args: string[], stdin?: string) => {
71  const script = `${$.plugin.root}/scripts/sm.cjs`
72  const ran = await $.process.run(['node', script, ...args], { stdin, timeoutMs: 120000 })
73  if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `helper exited ${ran.exitCode}`)
74  return ran.stdout
75}
76
77// One name for the session in /resume and in the agents view. A resume keeps
78// the agent name the window had, so this renames the agent to the session's.
79const syncName = async ($: EngineInterface) => {
80  const id = await $.session.id()
81  const names = JSON.parse(await helper($, ['sync-name', id])) as { title: string; agentName: string }
82  if (names.title !== '' && names.title !== names.agentName) {
83    await $.command.run({ command: 'rename', args: names.title })
84  }
85}
86
87const describeError = (error: unknown) => (error instanceof Error ? error.message : String(error))
88
89const refresh = async ($: EngineInterface) => {
90  const currentId = await $.session.id()
91  const root = await $.session.root()
92  const list = JSON.parse(await helper($, ['list', currentId, root])) as SessionInfo[]
93  await update($, sessions, () => list)
94}
95
96// Empty sessions (only a title or commands, left by restarts and /clear) are
97// deleted whenever the pane loads; the current one and any open one are kept.
98const deleteEmpty = async ($: EngineInterface) => {
99  const currentId = await $.session.id()
100  const list = await read($, sessions)
101  const empties = list.filter(session => session.isEmpty && session.id !== currentId && session.live === undefined)
102  for (const session of empties) {
103    await helper($, ['delete', session.file])
104    await $.store.delete(`summary:${session.id}`)
105  }
106  if (empties.length > 0) await refresh($)
107  return empties.length
108}
109
110const run = async ($: EngineInterface, label: string, work: () => Promise<string>) => {
111  await update($, busy, () => label)
112  await update($, message, () => null)
113  try {
114    const done = await work()
115    await update($, message, () => done)
116  } catch (error) {
117    await update($, message, () => `Failed: ${describeError(error)}`)
118  }
119  await update($, busy, () => null)
120}
121
122const loadSummary = async ($: EngineInterface, session: SessionInfo) => {
123  const kept = (await $.store.get(`summary:${session.id}`)) as SessionSummary | undefined
124  if (kept === undefined || kept.mtimeMs !== session.mtimeMs) return
125  await update($, summaries, all => ({ ...all, [session.id]: kept }))
126}
127
128const formatSize = (bytes: number) =>
129  bytes >= 1048576
130    ? `${(bytes / 1048576).toFixed(1)} MB`
131    : `${Math.max(1, Math.round(bytes / 1024))} KB`
132
133const sizeColor = (bytes: number) =>
134  bytes >= 5242880 ? 'red' : bytes >= 2097152 ? 'yellow' : undefined
135
136const formatAge = (ms: number) => {
137  const minutes = Math.round(ms / 60000)
138  if (minutes < 60) return `${minutes}m ago`
139  const hours = Math.round(minutes / 60)
140  if (hours < 48) return `${hours}h ago`
141  return `${Math.round(hours / 24)}d ago`
142}
143
144const nameOf = (session: SessionInfo) =>
145  session.title || session.preview || session.id.slice(0, 8)
146
147const fit = (text: string, width: number) =>
148  text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
149
150export const register: Register = on => {
151  on('session.start', async ($, e, next) => {
152    await $.command.register({
153      name: 'sessions',
154      description: 'Browse saved sessions: resume, recap, rename, compact, join or delete them',
155    })
156
157    const started = await next(e)
158    await syncName($).catch(() => undefined)
159    // Open beside the chat from the start; unfocused, so typing stays in the prompt.
160    if (e.isInteractive) {
161      const opened = await $.ui
162        .open({ id: PANE, title: TITLE, closeOnEscape: true })
163        .catch(() => undefined)
164      if (opened?.isPlaced) {
165        await refresh($)
166          .then(() => deleteEmpty($))
167          .catch(() => undefined)
168      }
169    }
170    return started
171  })
172
173  on('command.run', { command: 'sessions' }, async $ => {
174    const opened = await $.ui.open({
175      id: PANE,
176      title: TITLE,
177      focus: true,
178      closeOnEscape: true,
179    })
180    if (!opened.isPlaced) return { text: `Sessions pane not shown: ${opened.reason}` }
181    await run($, 'Loading sessions…', async () => {
182      await refresh($)
183      const removed = await deleteEmpty($)
184      return removed > 0 ? `Deleted ${removed} empty sessions.` : ''
185    })
186
187    // The pane is the output; no transcript line.
188    return {}
189  })
190
191  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
192    const elements = $.ui.resolve(e)
193    const { Box, Text, Button } = elements
194    const Input = 'Input' in elements ? elements.Input : undefined
195    const list = await read($, sessions)
196    const pickedId = await read($, selected)
197    const deleting = await read($, confirmDelete)
198    const forkingId = await read($, forking)
199    const working = await read($, busy)
200    const note = await read($, message)
201    const recaps = await read($, summaries)
202    const currentId = await $.session.id()
203    const now = await $.clock.now()
204    const width = Math.max(24, (e.props.bodyColumns ?? 80) - 6)
205    const isFullscreen = e.viewport?.isFullscreen === true
206    const picked = list.find(session => session.id === pickedId)
207    const recap = picked === undefined ? undefined : recaps[picked.id]
208    const original = list.find(session => session.id === picked?.copyOf)
209    const recapLines = recap === undefined ? 0 : recap.text.split('\n').length
210    const markedIds = await read($, marked)
211    const marks = list.filter(session => markedIds.includes(session.id))
212    const joinLines = marks.length > 0 ? 3 : 0
213    const room = Math.max(
214      3,
215      e.props.scroll.bodyRows - (picked ? 18 + recapLines + (forkingId === picked.id ? 3 : 0) : 9) - joinLines,
216    )
217    // Empty sessions stay out of the list, as in /resume; loading the pane deletes them.
218    const shown = list.filter(session => !session.isEmpty || session.id === currentId || session.id === pickedId)
219    const rows = Math.max(1, Math.floor(room / 2))
220    // Another process holds it: resuming forks a copy and edits race its writes.
221    const pickedIsOpen = picked !== undefined && picked.id !== currentId && picked.live !== undefined
222    const intoTarget =
223      picked !== undefined && picked.id !== currentId && !pickedIsOpen && !markedIds.includes(picked.id)
224        ? picked
225        : undefined
226
227    const pick = (session: SessionInfo) => async () => {
228      await update($, selected, current => (current === session.id ? null : session.id))
229      await update($, confirmDelete, () => null)
230      await loadSummary($, session)
231    }
232
233    const reload = () =>
234      run($, 'Loading sessions…', async () => {
235        await refresh($)
236        return ''
237      })
238
239    const summarize = (session: SessionInfo) => () =>
240      run($, `Writing a recap of ${nameOf(session)}…`, async () => {
241        const transcript = await helper($, ['transcript', session.file])
242        if (!transcript.trim()) return 'Nothing to recap yet.'
243        const reply = await $.model.complete({
244          model: 'haiku',
245          system: BRIEF_SYSTEM,
246          prompt: BRIEF_PROMPT(transcript.slice(-BRIEF_LIMIT)),
247          maxTokens: 400,
248          timeoutMs: 120000,
249        })
250        if (!reply.isAnswered) throw new Error(`no recap (${reply.reason})`)
251        const kept: SessionSummary = { text: reply.text.trim(), mtimeMs: session.mtimeMs }
252        await $.store.set(`summary:${session.id}`, kept)
253        await update($, summaries, all => ({ ...all, [session.id]: kept }))
254        return ''
255      })
256
257    const renameTo = (session: SessionInfo) => (value: string) =>
258      run($, 'Renaming…', async () => {
259        await helper($, ['rename', session.file, value])
260        // A running session holds its agent name in memory; /rename updates it.
261        if (session.id === (await $.session.id())) {
262          await $.command.run({ command: 'rename', args: value.trim() })
263        }
264        await refresh($)
265        return `Renamed to "${value.trim()}".`
266      })
267
268    const compactNow = (session: SessionInfo) => () =>
269      run($, `Compacting ${nameOf(session)}…`, async () => {
270        const transcript = await helper($, ['transcript', session.file])
271        if (!transcript.trim()) return 'Nothing to compact.'
272        const reply = await $.model.complete({
273          model: 'sonnet',
274          system: COMPACT_SYSTEM,
275          prompt: COMPACT_PROMPT(transcript),
276          maxTokens: 8000,
277          timeoutMs: 300000,
278        })
279        if (!reply.isAnswered) throw new Error(`no summary (${reply.reason})`)
280        await helper($, ['compact', session.file], reply.text)
281        await refresh($)
282        return 'Compacted. Resuming it now starts from the summary (backup saved as .jsonl.bak).'
283      })
284
285    const askDelete = (session: SessionInfo) => () =>
286      update($, confirmDelete, () => session.id)
287
288    const deleteNow = (session: SessionInfo) => () =>
289      run($, 'Deleting…', async () => {
290        await helper($, ['delete', session.file])
291        await $.store.delete(`summary:${session.id}`)
292        await update($, selected, () => null)
293        await update($, confirmDelete, () => null)
294        await refresh($)
295        return `Deleted ${nameOf(session)}.`
296      })
297
298    const cancelDelete = () => update($, confirmDelete, () => null)
299
300    const resumeNow = (session: SessionInfo) => async () => {
301      try {
302        await $.ui.close({ id: PANE })
303        await $.command.run({ command: 'resume', args: session.id })
304        // A resume fires no session.start and keeps the window's agent name,
305        // so once the switch lands, name the agent after the session.
306        for (let tries = 0; tries < 20; tries++) {
307          if ((await $.session.id()) === session.id) {
308            if (session.title !== '') await $.command.run({ command: 'rename', args: session.title })
309            break
310          }
311          await $.clock.sleep(250)
312        }
313      } catch (error) {
314        await update($, message, () => `Failed: ${describeError(error)}`)
315      }
316    }
317
318    // A background agent under the same id: it shows in the agents view and
319    // leaves this window's session alone.
320    const openAsAgent = (session: SessionInfo) => () =>
321      run($, `Opening ${nameOf(session)} as an agent…`, async () => {
322        const ran = await $.process.run(
323          ['claude', '--bg', '--resume', session.id, '--name', nameOf(session)],
324          { cwd: await $.session.root(), timeoutMs: 60000 },
325        )
326        if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `claude exited ${ran.exitCode}`)
327        await refresh($)
328        return `Opened "${nameOf(session)}" as a background agent: find it in the agents view.`
329      })
330
331    // A new background agent seeded with a handoff of this session: the summary
332    // goes to a file, the prompt only points at it, so argv stays short.
333    // With a goal the agent starts on it; without one it reads the handoff and
334    // waits for the person to open it and say what to build.
335    const handOff = (session: SessionInfo) => (value: string) =>
336      run($, `Writing a handoff of ${nameOf(session)}…`, async () => {
337        await update($, forking, () => null)
338        const goal = value.trim()
339        const transcript = await helper($, ['transcript', session.file])
340        if (!transcript.trim()) return 'Nothing to hand off yet.'
341        const reply = await $.model.complete({
342          model: 'sonnet',
343          system: COMPACT_SYSTEM,
344          prompt: HANDOFF_PROMPT(transcript.slice(-JOIN_LIMIT), goal),
345          maxTokens: 8000,
346          timeoutMs: 300000,
347        })
348        if (!reply.isAnswered) throw new Error(`no handoff (${reply.reason})`)
349        const name = fit(goal || `${nameOf(session)} (fork)`, 60)
350        const saved = JSON.parse(await helper($, ['handoff-file', name], reply.text)) as { path: string }
351        const prompt =
352          `Read the handoff at ${saved.path}; it carries the context of an earlier session. ` +
353          (goal === ''
354            ? 'Do not start any work: reply with a short summary of what you now know, then wait for my instructions.'
355            : `Then: ${goal}`)
356        const ran = await $.process.run(['claude', '--bg', '--name', name, '--', prompt], {
357          cwd: await $.session.root(),
358          timeoutMs: 60000,
359        })
360        if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `claude exited ${ran.exitCode}`)
361        await refresh($)
362        return goal === ''
363          ? `Forked into "${name}": open it in the agents view and give it instructions. Handoff: ${saved.path}`
364          : `Forked into "${name}", working on the goal: see the agents view. Handoff: ${saved.path}`
365      })
366
367    // Inside the agents view this pane runs in a background agent; the terminal
368    // under it is an interactive process holding the session it started with.
369    const inAgentView = list.find(session => session.id === currentId)?.live?.kind === 'bg'
370    const isHost = (session: SessionInfo) => inAgentView && session.live?.kind === 'interactive'
371
372    const toggleMark = (session: SessionInfo) => () =>
373      update($, marked, current =>
374        current.includes(session.id)
375          ? current.filter(id => id !== session.id)
376          : [...current, session.id],
377      )
378
379    const clearMarks = () => update($, marked, () => [])
380
381    const joinedSummary = async (sources: SessionInfo[]) => {
382      const share = Math.floor(JOIN_LIMIT / sources.length)
383      const parts: string[] = []
384      for (const source of sources) {
385        const transcript = await helper($, ['transcript', source.file])
386        if (!transcript.trim()) continue
387        parts.push(
388          `<conversation title="${nameOf(source).replace(/"/g, "'")}">\n` +
389            transcript.slice(-share) +
390            '\n</conversation>',
391        )
392      }
393      if (parts.length === 0) throw new Error('the marked sessions have no messages')
394      const reply = await $.model.complete({
395        model: 'sonnet',
396        system: COMPACT_SYSTEM,
397        prompt: JOIN_PROMPT(parts),
398        maxTokens: 8000,
399        timeoutMs: 300000,
400      })
401      if (!reply.isAnswered) throw new Error(`no summary (${reply.reason})`)
402      return reply.text
403    }
404
405    const joinAsNew = (sources: SessionInfo[]) => () =>
406      run($, `Joining ${sources.length} sessions into a new one…`, async () => {
407        const summary = await joinedSummary(sources)
408        const anchor = list.find(session => session.id === currentId) ?? sources[0]!
409        const title = fit(`Joined: ${sources.map(nameOf).join(' + ')}`, 100)
410        const files = [anchor, ...sources].map(session => session.file)
411        const made = JSON.parse(await helper($, ['join-new', title, ...files], summary)) as {
412          id: string
413        }
414        await update($, marked, () => [])
415        await update($, selected, () => made.id)
416        await refresh($)
417        return `Created "${title}". Press Resume to open it.`
418      })
419
420    const joinInto = (target: SessionInfo, sources: SessionInfo[]) => () =>
421      run($, `Joining ${sources.length} sessions into ${nameOf(target)}…`, async () => {
422        const summary = await joinedSummary(sources)
423        await helper($, ['join-into', target.file], summary)
424        await $.store.delete(`summary:${target.id}`)
425        await update($, marked, () => [])
426        await refresh($)
427        return `Joined into ${nameOf(target)} (backup saved as .jsonl.bak).`
428      })
429
430    const deleteAll = async (doomed: SessionInfo[]) => {
431      for (const session of doomed) {
432        await helper($, ['delete', session.file])
433        await $.store.delete(`summary:${session.id}`)
434      }
435      const gone = doomed.map(session => session.id)
436      await update($, marked, current => current.filter(id => !gone.includes(id)))
437      await update($, selected, current => (current !== null && gone.includes(current) ? null : current))
438      await refresh($)
439    }
440
441    // Copies made by resuming a session while it was open elsewhere.
442    const copies = list.filter(
443      session => session.copyOf !== undefined && session.id !== currentId && session.live === undefined,
444    )
445    const withWork = copies.filter(session => (session.newMessages ?? 0) > 0).length
446    const clearingCopies = await read($, confirmCopies)
447
448    const askDeleteCopies = () => update($, confirmCopies, () => true)
449
450    const cancelDeleteCopies = () => update($, confirmCopies, () => false)
451
452    const deleteCopiesNow = () =>
453      run($, `Deleting ${copies.length} copies…`, async () => {
454        await update($, confirmCopies, () => false)
455        await deleteAll(copies)
456        return `Deleted ${copies.length} copies.`
457      })
458
459    const totalBytes = list.reduce((sum, session) => sum + session.size, 0)
460
461    // The footer names only the keys that do something right now.
462    const canAct = picked !== undefined && picked.id !== currentId && !pickedIsOpen
463    const keys = [
464      'r refresh',
465      ...(picked === undefined
466        ? []
467        : [
468            ...(canAct ? ['o open as agent'] : []),
469            ...(canAct ? ['h resume here'] : []),
470            'f fork',
471            's recap',
472            'm mark',
473            ...(canAct ? ['c compact', 'd delete'] : []),
474          ]),
475      ...(marks.length >= 2 ? ['n join new'] : []),
476      ...(marks.length > 0 && intoTarget !== undefined ? ['i join into'] : []),
477      ...(marks.length > 0 ? ['x clear marks'] : []),
478      ...(copies.length > 0 ? ['p delete copies'] : []),
479      'esc close',
480    ]
481
482    return (
483      <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
484        <Box flexDirection="row" justifyContent="space-between">
485          <Box flexDirection="row" gap={1}>
486            <Text bold color="cyan">
487              ◆ This project
488            </Text>
489            <Text dimColor>
490              {list.length} sessions · {formatSize(totalBytes)}
491            </Text>
492          </Box>
493          <Box flexDirection="row" gap={1}>
494            <Button key="refresh" hotkey="r" onPress={reload}>
495              Refresh
496            </Button>
497          </Box>
498        </Box>
499        {copies.length > 0 && working === null && (
500          <Box flexDirection="column">
501            <Box flexDirection="row" gap={1}>
502              {clearingCopies ? (
503                <>
504                  <Text color="red" bold>
505                    Delete {copies.length} copies permanently?
506                  </Text>
507                  <Button key="copies-yes" onPress={deleteCopiesNow}>
508                    Yes, delete
509                  </Button>
510                  <Button key="copies-no" onPress={cancelDeleteCopies}>
511                    Cancel
512                  </Button>
513                </>
514              ) : (
515                <>
516                  <Text dimColor>{copies.length} copies (from resuming open sessions)</Text>
517                  <Button key="copies" hotkey="p" onPress={askDeleteCopies}>
518                    Delete copies
519                  </Button>
520                </>
521              )}
522            </Box>
523            {clearingCopies && withWork > 0 && (
524              <Text color="yellow">
525                {withWork} of them have messages their original lacks: that work goes too.
526              </Text>
527            )}
528          </Box>
529        )}
530        {working !== null && <Text color="yellow">⟳ {working}</Text>}
531        {working === null && note !== null && note !== '' && (
532          <Text color={note.startsWith('Failed') ? 'red' : 'green'}>
533            {note.startsWith('Failed') ? '✗ ' : '✓ '}
534            {note}
535          </Text>
536        )}
537        <Box flexDirection="column" marginTop={1}>
538          {shown.length === 0 && working === null && <Text dimColor>No sessions found.</Text>}
539          {shown.slice(0, rows).map(session => {
540            const isCurrent = session.id === currentId
541            const isPicked = session.id === pickedId
542            const isMarked = markedIds.includes(session.id)
543            const label = fit(nameOf(session), Math.max(10, width - 4))
544
545            // Two lines like /resume: the name, then age · branch · size.
546            return (
547              <Box key={`row-${session.id}`} flexDirection="column">
548                <Button key={`row-${session.id}`} plain onPress={pick(session)}>
549                  <Text color={isPicked ? 'magenta' : isCurrent ? 'green' : 'cyan'}>
550                    {isPicked ? '▸ ' : isCurrent ? '● ' : '  '}
551                  </Text>
552                  <Text color="yellow">{isMarked ? '✓ ' : ''}</Text>
553                  <Text bold={isPicked} color={isPicked ? 'magenta' : undefined}>
554                    {label}
555                  </Text>
556                </Button>
557                <Button key={`meta-${session.id}`} plain onPress={pick(session)}>
558                  <Text dimColor>
559                    {'  '}
560                    {formatAge(now - session.mtimeMs)}
561                    {session.branch === '' ? '' : ` · ${session.branch}`} ·{' '}
562                  </Text>
563                  <Text color={sizeColor(session.size)} dimColor={sizeColor(session.size) === undefined}>
564                    {formatSize(session.size)}
565                  </Text>
566                  {isCurrent && <Text color="green"> · current</Text>}
567                  {!isCurrent && session.live !== undefined && (
568                    <Text color="yellow">{isHost(session) ? ' · this window' : ' · open'}</Text>
569                  )}
570                  {session.copyOf !== undefined && <Text dimColor> · copy</Text>}
571                  {session.isCompacted && <Text color="blue"> · ⇣ compacted</Text>}
572                </Button>
573              </Box>
574            )
575          })}
576          {shown.length > rows && <Text dimColor>  …and {shown.length - rows} more</Text>}
577        </Box>
578        {marks.length > 0 && (
579          <Box flexDirection="column" marginTop={1}>
580            <Text color="yellow">
581              ✓ {marks.length} marked: {fit(marks.map(nameOf).join(', '), width - 14)}
582            </Text>
583            <Box flexDirection="row" gap={1}>
584              {marks.length >= 2 && (
585                <Button key="join-new" hotkey="n" variant="primary" onPress={joinAsNew(marks)}>
586                  Join as new session
587                </Button>
588              )}
589              {intoTarget !== undefined && (
590                <Button key="join-into" hotkey="i" onPress={joinInto(intoTarget, marks)}>
591                  {fit(`Join into ${nameOf(intoTarget)}`, 40)}
592                </Button>
593              )}
594              <Button key="join-clear" hotkey="x" onPress={clearMarks}>
595                Clear marks
596              </Button>
597            </Box>
598          </Box>
599        )}
600        {picked !== undefined && (
601          <Box
602            flexDirection="column"
603            marginTop={1}
604            borderStyle="round"
605            borderColor="magenta"
606            paddingX={1}
607          >
608            <Text bold color="magenta">
609              {fit(nameOf(picked), width - 4)}
610            </Text>
611            <Text dimColor>
612              {fit(`${picked.id} · ${picked.project}`, width - 4)}
613            </Text>
614            <Text>
615              <Text color={sizeColor(picked.size)}>{formatSize(picked.size)}</Text>
616              <Text dimColor> · last active {formatAge(now - picked.mtimeMs)}</Text>
617              {picked.isCompacted ? (
618                <Text color="blue"> · ⇣ compacted: nothing added since</Text>
619              ) : (
620                picked.compactions > 0 && <Text dimColor> · compacted before, more added since</Text>
621              )}
622            </Text>
623            {picked.preview !== '' && (
624              <Text italic dimColor>
625                {fit(`“${picked.preview}”`, width - 4)}
626              </Text>
627            )}
628            {pickedIsOpen && picked.live !== undefined && (
629              <Text color="yellow">
630                {picked.live.kind === 'bg'
631                  ? `Running as a background agent (${picked.live.status || 'idle'}). Open it from the agents list.`
632                  : isHost(picked)
633                    ? `The session this terminal started with (process ${picked.live.pid}). It stays held under the agents view and frees when you close the terminal.`
634                    : `Open in a Claude Code window${picked.live.name === '' ? '' : ` named "${picked.live.name}"`} (process ${picked.live.pid}).`}{' '}
635                Resuming it here would fork a copy, so Resume, Compact and Delete are off. To delete it, exit that window (/exit), then press r and d.
636              </Text>
637            )}
638            {picked.copyOf !== undefined && (
639              <Text dimColor>
640                {fit(
641                  `Copy of ${original === undefined ? picked.copyOf.slice(0, 8) : nameOf(original)}, made by resuming it while it was open.`,
642                  (width - 4) * 2,
643                )}
644              </Text>
645            )}
646            <Box flexDirection="column" marginTop={1}>
647              <Text bold color="yellow">
648                Recap
649              </Text>
650              {recap !== undefined ? (
651                <Text>{recap.text}</Text>
652              ) : (
653                <Text dimColor>No recap yet. Press Recap to write one (uses Haiku).</Text>
654              )}
655            </Box>
656            {Input !== undefined && (
657              <Box marginTop={1}>
658                <Input
659                  key={`rename-${picked.id}`}
660                  label="Rename"
661                  placeholder={picked.title || 'New title'}
662                  submitLabel="Save"
663                  onSubmit={renameTo(picked)}
664                />
665              </Box>
666            )}
667            {Input !== undefined && forkingId === picked.id && (
668              <Box marginTop={1} flexDirection="column">
669                <Text color="cyan">
670                  Fork from a handoff. Type a goal and the new agent starts on it, or press Enter empty and it waits for your instructions.
671                </Text>
672                <Box flexDirection="row" gap={1}>
673                  <Input
674                    key={`handoff-${picked.id}`}
675                    label="Goal"
676                    placeholder="optional"
677                    submitLabel="Fork"
678                    autoFocus
679                    onSubmit={handOff(picked)}
680                  />
681                  <Button key="fork-cancel" onPress={() => update($, forking, () => null)}>
682                    Cancel
683                  </Button>
684                </Box>
685              </Box>
686            )}
687            <Box flexDirection="row" gap={1} marginTop={1}>
688              {picked.id !== currentId && !pickedIsOpen && (
689                <Button key="open-agent" hotkey="o" variant="primary" onPress={openAsAgent(picked)}>
690                  Open as agent
691                </Button>
692              )}
693              {picked.id !== currentId && !pickedIsOpen && (
694                <Button key="resume" hotkey="h" onPress={resumeNow(picked)}>
695                  Resume here
696                </Button>
697              )}
698              <Button key="fork" hotkey="f" onPress={() => update($, forking, () => picked.id)}>
699                Fork
700              </Button>
701              <Button key="recap" hotkey="s" onPress={summarize(picked)}>
702                {recap !== undefined ? 'Redo recap' : 'Recap'}
703              </Button>
704              <Button key="mark" hotkey="m" onPress={toggleMark(picked)}>
705                {markedIds.includes(picked.id) ? 'Unmark' : 'Mark to join'}
706              </Button>
707              {picked.id === currentId ? (
708                <Text dimColor>Current session: use /compact or /rename here.</Text>
709              ) : pickedIsOpen ? null : deleting === picked.id ? (
710                <>
711                  <Text color="red" bold>
712                    Delete permanently?
713                  </Text>
714                  <Button key="delete-yes" onPress={deleteNow(picked)}>
715                    Yes, delete
716                  </Button>
717                  <Button key="delete-no" onPress={cancelDelete}>
718                    Cancel
719                  </Button>
720                </>
721              ) : (
722                <>
723                  <Button key="compact" hotkey="c" onPress={compactNow(picked)}>
724                    Compact
725                  </Button>
726                  <Button key="delete" hotkey="d" onPress={askDelete(picked)}>
727                    Delete
728                  </Button>
729                </>
730              )}
731            </Box>
732          </Box>
733        )}
734        {!isFullscreen && (
735          <Text color="yellow" bold>
736            Not in fullscreen mode: the side pane and mouse clicks need it. Run /tui fullscreen.
737          </Text>
738        )}
739        {isFullscreen && !e.props.isFocused && (
740          <Text color="yellow">Click here (or ctrl+x then tab) to use this pane's keys.</Text>
741        )}
742        <Text dimColor>
743          {keys.join(' · ')}
744        </Text>
745      </Box>
746    )
747  })
748}
749
types/index.d.ts 45 lines
1export type SessionInfo = {
2  id: string
3  project: string
4  file: string
5  size: number
6  mtimeMs: number
7  title: string
8  isCustomTitle: boolean
9  preview: string
10  isEmpty: boolean
11  /** Git branch of the last message, as /resume shows it. */
12  branch: string
13  /** How many times it was compacted (/compact, auto-compact or the pane's Compact). */
14  compactions: number
15  /** Compacted, with no reply after the last compaction. */
16  isCompacted: boolean
17  /** The last custom-title row (the /resume name) and agent-name row (the agents view). */
18  sessionTitle: string
19  agentName: string
20  /** Set while a Claude Code process holds the session (from ~/.claude/sessions). */
21  live?: { pid: number; kind: 'bg' | 'interactive'; status: string; name: string }
22  /** The session this one was forked from by resuming it while it was open. */
23  copyOf?: string
24  /** Messages in a copy that its original does not hold: work done after the fork. */
25  newMessages?: number
26}
27
28export type SessionSummary = { text: string; mtimeMs: number }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'session-manager': {
33      sessions: SessionInfo[]
34      selected: string | null
35      confirmDelete: string | null
36      busy: string | null
37      message: string | null
38      summaries: Record<string, SessionSummary>
39      marked: string[]
40      confirmCopies: boolean
41      forking: string | null
42    }
43  }
44}
45