Keeps the Claude desktop Code-tab session list when you switch accounts: copies session pointers from other account folders into the current one.

English | Русский
A Claude Code mod (plugin) that keeps the Code-tab session list in the Claude desktop app when you switch accounts. macOS only.
Transcripts live in ~/.claude/projects and are shared by every account. The sidebar list, however, is built from pointer files in
~/Library/Application Support/Claude/claude-code-sessions/<account>/<organization>/local_*.json
Each account has its own folder, so after signing in with another account the list is empty even though the chats are still on disk.
/keep-sessions sync, copies the missing pointer files from other account folders into the current one.deleted_* markers).~/.claude/keep-sessions-backup-<date>/ before the first copy.In a terminal Claude Code session:
/plugin install keep-sessions --marketplace IlyaPryzhykau/claude-keep-sessions
From a shell:
claude plugin install keep-sessions --marketplace IlyaPryzhykau/claude-keep-sessions
Answer y to Add marketplace?, then pick the user scope. Requires Claude Code 2.1.299 or newer (the desktop app already ships it).
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with IlyaPryzhykau/claude-keep-sessions, then Add plugin.
From a local folder, without GitHub:
claude plugin marketplace add /path/to/claude-keep-sessions
claude plugin install keep-sessions@addon-mods
Commands inside a session:
| Command | What it does |
|---|---|
/keep-sessions status | Chats per folder and how many would be added. Changes nothing. |
/keep-sessions sync | Copy the missing pointers now. |
claude plugin validate .
claude plugin test .
The file-selection logic lives in hooks/plan.ts and is covered by hooks/keep-sessions.test.ts.
hooks/register.ts 147 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { countPointers, isPointer, planSync, sameFolder } from './plan'
4import type { Folder, FolderId } from './plan'
5
6const SESSIONS_DIR = 'Library/Application Support/Claude/claude-code-sessions'
7
8type Scan = {
9 root: string
10 targetPath: string
11 target: Folder
12 others: Folder[]
13}
14
15const folderPath = (root: string, id: FolderId): string => `${root}/${id.account}/${id.org}`
16
17const names = async ($: EngineInterface, path: string): Promise<string[]> =>
18 (await $.fs.list(path)).map(entry => entry.name)
19
20/**
21 * Finds the current account's session-list folder and every other
22 * account/org folder beside it. Undefined when this is not a desktop
23 * session (no account variables) or the folder does not exist yet.
24 */
25async function scan($: EngineInterface): Promise<Scan | undefined> {
26 const home = await $.env.get('HOME')
27 const account = await $.env.get('CLAUDE_CODE_ACCOUNT_UUID')
28 const org = await $.env.get('CLAUDE_CODE_ORGANIZATION_UUID')
29 if (!home || !account || !org) return undefined
30
31 const root = `${home}/${SESSIONS_DIR}`
32 const current: FolderId = { account, org }
33 const targetPath = folderPath(root, current)
34 if (!(await $.fs.exists(targetPath))) return undefined
35
36 const target: Folder = { id: current, names: await names($, targetPath) }
37 const others: Folder[] = []
38 for (const acc of await $.fs.list(root)) {
39 if (acc.kind !== 'dir') continue
40 for (const o of await $.fs.list(`${root}/${acc.name}`)) {
41 if (o.kind !== 'dir') continue
42 const id: FolderId = { account: acc.name, org: o.name }
43 if (sameFolder(id, current)) continue
44 others.push({ id, names: await names($, folderPath(root, id)) })
45 }
46 }
47 return { root, targetPath, target, others }
48}
49
50type SyncOutcome = { copied: number; backupDir?: string }
51
52/** Copies missing pointers into the current folder; never overwrites. */
53async function sync($: EngineInterface, found: Scan): Promise<SyncOutcome> {
54 const plan = planSync(found.target, found.others)
55 if (plan.copies.length === 0) return { copied: 0 }
56
57 const home = await $.env.get('HOME')
58 const stamp = new Date(await $.clock.now()).toISOString().replace(/[:.]/g, '-')
59 const backupDir = `${home}/.claude/keep-sessions-backup-${stamp}`
60 let backedUp = 0
61 for (const name of found.target.names.filter(isPointer)) {
62 const text = await $.fs.read(`${found.targetPath}/${name}`)
63 await $.fs.write(`${backupDir}/${name}`, text)
64 backedUp++
65 }
66
67 let copied = 0
68 for (const copy of plan.copies) {
69 const destination = `${found.targetPath}/${copy.name}`
70 if (await $.fs.exists(destination)) continue
71 const text = await $.fs.read(`${folderPath(found.root, copy.from)}/${copy.name}`)
72 await $.fs.write(destination, text)
73 copied++
74 }
75 return backedUp > 0 ? { copied, backupDir } : { copied }
76}
77
78const short = (id: FolderId): string => `${id.account.slice(0, 8)}/${id.org.slice(0, 8)}`
79
80function statusText(found: Scan): string {
81 const plan = planSync(found.target, found.others)
82 const lines = [
83 `Текущий аккаунт ${short(found.target.id)}: ${countPointers(found.target.names)} чатов в списке.`,
84 ]
85 for (const other of found.others) {
86 lines.push(`Другой аккаунт ${short(other.id)}: ${countPointers(other.names)} чатов.`)
87 }
88 if (found.others.length === 0) lines.push('Других аккаунтов на этом компьютере нет.')
89 lines.push(
90 `Будет добавлено: ${plan.copies.length}` +
91 (plan.skippedExisting > 0 ? `, уже есть: ${plan.skippedExisting}` : '') +
92 (plan.skippedDeleted > 0 ? `, удалённых пропущено: ${plan.skippedDeleted}` : '') +
93 '.',
94 )
95 return lines.join('\n')
96}
97
98const RESTART_HINT =
99 'Полностью перезапусти приложение (Cmd+Q и открыть снова), чтобы они появились в списке.'
100
101const NOT_DESKTOP =
102 'keep-sessions: это не сессия десктоп-приложения (нет переменных аккаунта) или папка списка сессий ещё не создана.'
103
104export const register: Register = on => {
105 on('session.start', async ($, e, next) => {
106 await $.command.register({
107 name: 'keep-sessions',
108 description: 'Вернуть в список чаты Code из других аккаунтов на этом компьютере',
109 argumentHint: 'sync | status',
110 })
111 const started = await next(e)
112
113 try {
114 const found = await scan($)
115 const copied = found ? (await sync($, found)).copied : 0
116 if (copied > 0) {
117 $.ui.toast(`Добавлено ${copied} чатов из других аккаунтов. ${RESTART_HINT}`, {
118 timeoutMs: 15000,
119 })
120 }
121 } catch (error) {
122 $.ui.log(`keep-sessions: sync at start failed: ${String(error)}`, { to: 'debug' })
123 }
124
125 return started
126 })
127
128 on('command.run', { command: 'keep-sessions' }, async ($, e) => {
129 const action = e.args.trim().toLowerCase() || 'status'
130 if (action !== 'sync' && action !== 'status') {
131 return { text: 'Использование: /keep-sessions status | /keep-sessions sync' }
132 }
133 try {
134 const found = await scan($)
135 if (!found) return { text: NOT_DESKTOP }
136 if (action === 'status') return { text: statusText(found) }
137
138 const { copied, backupDir } = await sync($, found)
139 if (copied === 0) return { text: 'Уже синхронизировано: добавлять нечего.' }
140 const backupLine = backupDir ? `\nРезервная копия прежнего списка: ${backupDir}` : ''
141 return { text: `Добавлено ${copied} чатов. ${RESTART_HINT}${backupLine}` }
142 } catch (error) {
143 return { text: `keep-sessions: ошибка: ${String(error)}` }
144 }
145 })
146}
147hooks/plan.ts 68 lines1/**
2 * Pure planning: which session pointer files to copy into the current
3 * account's folder. No I/O here, so it is unit-tested directly.
4 *
5 * The desktop app keeps one folder per account and organization:
6 * claude-code-sessions/<accountUuid>/<orgUuid>/
7 * holding `local_<id>.json` (one per session listed in the sidebar) and
8 * `deleted_<id>` markers for sessions the person removed from the list.
9 */
10
11export type FolderId = { account: string; org: string }
12export type Folder = { id: FolderId; names: readonly string[] }
13export type Copy = { from: FolderId; name: string }
14export type Plan = {
15 copies: Copy[]
16 /** pointers already present in the target (or taken from an earlier source) */
17 skippedExisting: number
18 /** pointers whose session was deleted in the target or in its own source */
19 skippedDeleted: number
20}
21
22const POINTER = /^local_(.+)\.json$/
23const DELETED = /^deleted_(.+)$/
24
25export const isPointer = (name: string): boolean => POINTER.test(name)
26export const pointerId = (name: string): string | undefined => POINTER.exec(name)?.[1]
27
28export const deletedIds = (names: readonly string[]): Set<string> => {
29 const ids = new Set<string>()
30 for (const name of names) {
31 const id = DELETED.exec(name)?.[1]
32 if (id !== undefined) ids.add(id)
33 }
34 return ids
35}
36
37export const sameFolder = (a: FolderId, b: FolderId): boolean =>
38 a.account === b.account && a.org === b.org
39
40export function planSync(target: Folder, sources: readonly Folder[]): Plan {
41 const have = new Set(target.names)
42 const deletedHere = deletedIds(target.names)
43 const plan: Plan = { copies: [], skippedExisting: 0, skippedDeleted: 0 }
44
45 for (const source of sources) {
46 if (sameFolder(source.id, target.id)) continue
47 const deletedThere = deletedIds(source.names)
48 for (const name of source.names) {
49 const id = pointerId(name)
50 if (id === undefined) continue
51 if (have.has(name)) {
52 plan.skippedExisting++
53 continue
54 }
55 if (deletedHere.has(id) || deletedThere.has(id)) {
56 plan.skippedDeleted++
57 continue
58 }
59 plan.copies.push({ from: source.id, name })
60 have.add(name)
61 }
62 }
63 return plan
64}
65
66export const countPointers = (names: readonly string[]): number =>
67 names.filter(isPointer).length
68