SLOPSHOPPER

keep-sessions

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

newcommandtoast
v0.1.0no licenseupdated 2026-10-19IlyaPryzhykau/claude-keep-sessions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keep-sessions
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keep-sessions ⎿ keep-sessions: keep-sessions: это не сессия десктоп-приложения (нет переменных аккаунта) или папка списка сессий ещё не созда ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

keep-sessions

English | Русский

A Claude Code mod (plugin) that keeps the Code-tab session list in the Claude desktop app when you switch accounts. macOS only.

The problem

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.

What the mod does

  • At the start of every session, and on /keep-sessions sync, copies the missing pointer files from other account folders into the current one.
  • Never overwrites a file.
  • Does not restore sessions you removed from the list (deleted_* markers).
  • Backs up the current list to ~/.claude/keep-sessions-backup-<date>/ before the first copy.
  • Does nothing outside the desktop app (in a terminal session).

Install

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

Switching accounts

  1. Sign out and sign in with the other account from the app menu.
  2. Open any Code-tab session. The mod copies the pointers and shows a toast with the number of chats added.
  3. Fully quit and reopen the app (Cmd+Q). The chats and project folders are back in the sidebar.

Commands inside a session:

CommandWhat it does
/keep-sessions statusChats per folder and how many would be added. Changes nothing.
/keep-sessions syncCopy the missing pointers now.

Limitations

  • The app's folder layout is undocumented and may change with an update.
  • Cloud and Remote Control sessions may show up in the list but will not resume locally.
  • The mod does not sign you in or out: the app issues the token.

Development

claude plugin validate .
claude plugin test .

The file-selection logic lives in hooks/plan.ts and is covered by hooks/keep-sessions.test.ts.

Source 2 files
hooks/register.ts 147 lines
1import 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}
147
hooks/plan.ts 68 lines
1/**
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