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

<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%">
| ▶️ Resume | Jump into any session of the current project, same as /resume, or open it as a background agent. |
| 📝 Recap | A 3–5 line summary written by Haiku, cached until the session changes. |
| ✏️ Rename | Give a session one name, shown in both /resume and the agents view. |
| 🍴 Fork | Start a new background agent from a handoff of a session: give it a goal, or let it wait for your instructions. |
| 🗜️ Compact | Shrink a heavy session like /compact, without opening it. |
| 🔗 Join | Merge several sessions into a new one, or into an existing one. |
| 🧹 Clean up | Delete 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.
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" } }
/tui fullscreen).PATH: the mod's file work runs in scripts/sm.cjs.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.
| Key | Action |
|---|---|
| <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 |
| Key | Action |
|---|---|
| Rename box | Saves 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 |
| Key | Action |
|---|---|
| <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 |
| Key | Action |
|---|---|
| <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
/compactand/renamethere.
<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:
<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.
<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.
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).
hooks/index.tsx 749 lines1import { 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}
749types/index.d.ts 45 lines1export 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