SLOPSHOPPER

memory-sync

Keeps every project's auto memory in a private git repo, synced across machines

newtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-sync
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ memory-sync │ ⏺ Read(src/auth.ts) │ memory-sync: syncing memories with │ ⎿ Read 6 lines │ kokko-ng/claude-memory │ ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

kokko-claude-mods

Three Claude Code mods in one marketplace:

  • context-bar: a context-window bar above the prompt.
  • theme-sync: applies theme changes in ~/.claude/settings.json to running sessions.
  • memory-sync: keeps every project's auto memory in a private git repo, synced across machines.

context-bar

A band above the prompt: a context-window bar split by category, and how many tokens each category holds.

▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 42%  84k / 200k  effort high
● system 4k  ● tools 20k  ● memory 200  ● messages 59.8k
  • Colours are Gruvbox Material (medium contrast), one fixed colour per category; the percentage turns yellow at 50% and red at 80%. The light palette is used while the Claude Code theme is a light* one, dark otherwise, and the band switches as soon as the theme changes (/config, or theme-sync).
  • The thinking effort of the conversation's latest request (/effort, the settings' effortLevel, or the model's default) follows the token count.
  • Sized to the band's width: as the terminal narrows it drops the effort, then the token count, then the legend's smallest categories, then the legend, then the bar, and never wraps.

Install

At a Claude Code prompt in a terminal:

/plugin install context-bar --marketplace kokko-ng/kokko-claude-mods

Answer y to add the marketplace, then pick the user scope.

Or in ~/.claude/settings.json:

{
  "extraKnownMarketplaces": {
    "kokko-claude-mods": {
      "source": { "source": "github", "repo": "kokko-ng/kokko-claude-mods" }
    }
  },
  "enabledPlugins": { "context-bar@kokko-claude-mods": true }
}

Mods (function-hook plugins) are an early-access Claude Code feature; this one is built and tested against Claude Code 2.1.292.

theme-sync

Claude Code reloads ~/.claude/settings.json when it changes but keeps the running session's theme. theme-sync checks the file once a second and, when its theme differs from the session's, sets it as /config would, with a toast. Edit the file from anywhere (a script that flips light-ansi and dark-ansi alongside the terminal theme, for instance) and every open session follows.

/plugin install theme-sync --marketplace kokko-ng/kokko-claude-mods

or "theme-sync@kokko-claude-mods": true under enabledPlugins.

memory-sync

Keeps the auto memory of every project (~/.claude/projects/<project>/memory/) in a git repo, so each machine sees the others' memories.

  • The git work tree is ~/.claude/projects itself, with the git directory at ~/.claude/memory-sync.git; only each project's memory/ folder is tracked, never transcripts. Memories stay ordinary files where Claude Code reads and writes them, so nothing about recall or permissions changes.
  • Session start pulls (waiting up to 15 s, so the session loads the latest memories), every turn that changed a memory commits and pushes, and an idle session pulls every ten minutes.
  • The first run on a machine imports its memories: it restores what the repo has that the machine lacks and commits the rest on top.
  • Machines never clash. MEMORY.md indexes merge line by line (merge=union), so every machine's entries are kept. Any other memory both machines changed gets a normal three-way merge; if that conflicts, the version already pushed stays and this machine's is kept beside it as <name>.from-<host>.md (a git merge driver the mod installs), so nothing is lost or interleaved and the sync carries on. Reconcile those pairs when they appear. Only a memory deleted on one machine and changed on another stops the sync, with a toast saying how to resolve it.
  • Projects are matched by folder name, which Claude Code derives from the project's absolute path: use the same username and code folder on every machine for a project's memories to be shared.

The repo defaults to kokko-ng/claude-memory; set CLAUDE_MEMORY_REPO to owner/name (GitHub, using your git credentials) or any git URL. Create it private and empty first:

gh repo create <owner>/claude-memory --private
/plugin install memory-sync --marketplace kokko-ng/kokko-claude-mods

Mods do not run where disableAllHooks is set, and a settings.local.json in ~/.claude sets it for sessions started in your home folder.

Development

Run pre-commit install once per clone. It installs the pre-commit and commit-msg hooks: file hygiene, gitleaks, claude plugin validate --strict and claude plugin test for every mod (on a pinned Claude Code), and Conventional Commits messages checked by commitizen. CI runs the same hooks and checks every commit message.

Each mod is its own plugin under plugins/:

claude plugin validate plugins/context-bar
claude plugin test plugins/context-bar
claude plugin validate plugins/theme-sync
claude plugin test plugins/theme-sync
claude plugin validate plugins/memory-sync
claude plugin test plugins/memory-sync
Source 1 files
hooks/register.ts 209 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// The repo memories go to: `owner/name` on GitHub, or any git URL or path.
4// CLAUDE_MEMORY_REPO overrides it.
5const DEFAULT_REPO = 'kokko-ng/claude-memory'
6
7// How often an idle session pulls what other machines pushed.
8const PULL_EVERY_MS = 10 * 60 * 1000
9
10// How long session start waits for the first sync, so a new session loads the
11// memories other machines pushed; past it the sync carries on in the background.
12const START_WAIT_MS = 15_000
13
14// The git work tree is <config dir>/projects itself; only each project's
15// memory/ folder is tracked, never transcripts. Memories stay ordinary files
16// where Claude Code reads and writes them.
17const EXCLUDE = `# memory-sync: track only <project>/memory/ under projects/
18/*
19!/*/
20/*/*
21!/*/memory/
22/*/memory/.claude/
23.DS_Store
24`
25
26// Two machines editing the index (MEMORY.md) merge by keeping both sides'
27// lines. Any other memory both changed goes through MERGE_DRIVER: a clean
28// three-way merge when there is one, else the version already pushed stays and
29// this machine's is kept beside it as <name>.from-<host>.md. Nothing is lost,
30// no file is interleaved, and a sync never stalls on it.
31const ATTRIBUTES = `*.md merge=memory-sync
32MEMORY.md merge=union
33`
34
35// git runs it from the top of the work tree as: sh <script> %O %A %B %P <host>.
36// During the rebase a sync does, %A is what other machines pushed and %B is
37// this machine's change being replayed.
38const MERGE_DRIVER = `#!/bin/sh
39base=$1 ours=$2 theirs=$3 path=$4 host=$5
40if git merge-file -p -q "$ours" "$base" "$theirs" >"$ours.merged" 2>/dev/null; then
41    mv "$ours.merged" "$ours"
42    exit 0
43fi
44rm -f "$ours.merged"
45side="\${path%.md}.from-$host.md"
46n=2
47while [ -e "$side" ]; do
48    side="\${path%.md}.from-$host-$n.md"
49    n=$((n + 1))
50done
51cp "$theirs" "$side"
52exit 0
53`
54
55type Config = { dir: string; gitDir: string; repo: string; url: string; host: string }
56
57// Per load: one sync at a time; a request while busy runs once afterwards.
58let config: Config | undefined
59let isBusy = false
60let isPending = false
61let lastError = ''
62
63const isLockError = (text: string) => /index\.lock|\.lock'?: File exists|Another git process/.test(text)
64
65async function git($: EngineInterface, cfg: Config, args: string[]) {
66  return $.process.run(['git', ...args], {
67    cwd: cfg.dir,
68    env: { GIT_DIR: cfg.gitDir, GIT_WORK_TREE: `${cfg.dir}/projects` },
69    timeoutMs: 60_000,
70  })
71}
72
73async function gitOk($: EngineInterface, cfg: Config, args: string[]) {
74  const r = await git($, cfg, args)
75  if (r.exitCode !== 0) {
76    const why = (r.stderr || r.stdout).trim().split('\n').pop() ?? ''
77    throw new Error(`git ${args[0]} failed: ${why}`)
78  }
79  return r.stdout
80}
81
82async function hasRef($: EngineInterface, cfg: Config, ref: string) {
83  return (await git($, cfg, ['rev-parse', '-q', '--verify', ref])).exitCode === 0
84}
85
86async function commitChanges($: EngineInterface, cfg: Config) {
87  await gitOk($, cfg, ['add', '-A'])
88  if ((await git($, cfg, ['diff', '--cached', '--quiet'])).exitCode === 0) return
89  const files = (await gitOk($, cfg, ['diff', '--cached', '--name-only'])).split('\n').filter(Boolean)
90  const projects = [...new Set(files.map(f => f.split('/')[0]))]
91  const what = projects.length > 3 ? `${projects.length} projects` : projects.join(', ')
92  await gitOk($, cfg, ['commit', '-q', '-m', `chore(memory): update ${what} (${cfg.host})`])
93}
94
95async function writeRules($: EngineInterface, cfg: Config) {
96  await $.fs.write(`${cfg.gitDir}/info/exclude`, EXCLUDE)
97  await $.fs.write(`${cfg.gitDir}/info/attributes`, ATTRIBUTES)
98  await $.fs.write(`${cfg.gitDir}/memory-merge.sh`, MERGE_DRIVER)
99  const driver = `sh "${cfg.gitDir}/memory-merge.sh" %O %A %B %P ${cfg.host}`
100  if ((await git($, cfg, ['config', 'merge.memory-sync.driver'])).stdout.trim() !== driver) {
101    await gitOk($, cfg, ['config', 'merge.memory-sync.name', 'memory-sync: merge, else keep both'])
102    await gitOk($, cfg, ['config', 'merge.memory-sync.driver', driver])
103  }
104}
105
106async function setUp($: EngineInterface, cfg: Config) {
107  await gitOk($, cfg, ['init', '-q', '-b', 'main'])
108  await writeRules($, cfg)
109  await gitOk($, cfg, ['remote', 'add', 'origin', cfg.url])
110  await gitOk($, cfg, ['fetch', '-q', 'origin'])
111  if (await hasRef($, cfg, 'refs/remotes/origin/main')) {
112    // Start from what other machines pushed: restore memories missing here,
113    // then commit what this machine has on top.
114    await gitOk($, cfg, ['reset', '-q', 'origin/main'])
115    const missing = (await gitOk($, cfg, ['ls-files', '--deleted', '-z'])).split('\0').filter(Boolean)
116    if (missing.length > 0) await gitOk($, cfg, ['checkout', '--', ...missing])
117  }
118  await commitChanges($, cfg)
119  $.ui.toast(`memory-sync: syncing memories with ${cfg.repo}`)
120}
121
122async function syncOnce($: EngineInterface, cfg: Config) {
123  if (!(await $.fs.exists(`${cfg.gitDir}/HEAD`))) await setUp($, cfg)
124  await writeRules($, cfg)
125  if ((await $.fs.exists(`${cfg.gitDir}/rebase-merge`)) || (await $.fs.exists(`${cfg.gitDir}/rebase-apply`))) {
126    throw new Error(`a rebase is in progress in ${cfg.gitDir}; finish or abort it`)
127  }
128  await commitChanges($, cfg)
129  await gitOk($, cfg, ['fetch', '-q', 'origin'])
130  const hasRemote = await hasRef($, cfg, 'refs/remotes/origin/main')
131  const hasLocal = await hasRef($, cfg, 'HEAD')
132  if (hasRemote && hasLocal && (await gitOk($, cfg, ['rev-list', '--count', 'HEAD..origin/main'])).trim() !== '0') {
133    const rebased = await git($, cfg, ['rebase', '-q', 'origin/main'])
134    if (rebased.exitCode !== 0) {
135      await git($, cfg, ['rebase', '--abort'])
136      throw new Error(
137        `memories conflict with ${cfg.repo}; resolve with ` +
138          `GIT_DIR=${cfg.gitDir} GIT_WORK_TREE=${cfg.dir}/projects git rebase origin/main`,
139      )
140    }
141    // A conflicting memory kept as <name>.from-<host>.md goes up with this sync.
142    await commitChanges($, cfg)
143  }
144  const isAhead =
145    hasLocal && (!hasRemote || (await gitOk($, cfg, ['rev-list', '--count', 'origin/main..HEAD'])).trim() !== '0')
146  if (isAhead) await gitOk($, cfg, ['push', '-q', '-u', 'origin', 'main'])
147}
148
149function requestSync($: EngineInterface): Promise<void> {
150  const cfg = config
151  if (!cfg) return Promise.resolve()
152  if (isBusy) {
153    isPending = true
154    return Promise.resolve()
155  }
156  isBusy = true
157  return syncOnce($, cfg)
158    .then(() => {
159      lastError = ''
160    })
161    .catch((err: unknown) => {
162      const text = err instanceof Error ? err.message : String(err)
163      if (!isLockError(text) && text !== lastError) $.ui.toast(`memory-sync: ${text}`)
164      lastError = text
165    })
166    .finally(() => {
167      isBusy = false
168      if (isPending) {
169        isPending = false
170        void requestSync($)
171      }
172    })
173}
174
175async function hasChanges($: EngineInterface, cfg: Config) {
176  const status = await git($, cfg, ['status', '--porcelain'])
177  return status.exitCode === 0 && status.stdout.trim() !== ''
178}
179
180export const register: Register = on => {
181  on('session.start', async ($, e, next) => {
182    const result = await next(e)
183
184    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
185    const repo = (await $.env.get('CLAUDE_MEMORY_REPO')) ?? DEFAULT_REPO
186    const host = (await $.process.run(['hostname', '-s'])).stdout.trim().replace(/[^A-Za-z0-9-]/g, '-') || 'mac'
187    config = {
188      dir,
189      gitDir: `${dir}/memory-sync.git`,
190      repo,
191      url: /^[\w.-]+\/[\w.-]+$/.test(repo) ? `https://github.com/${repo}.git` : repo,
192      host,
193    }
194
195    await Promise.race([requestSync($), $.clock.sleep(START_WAIT_MS)])
196    $.clock.every(PULL_EVERY_MS, () => void requestSync($))
197
198    return result
199  })
200
201  // After each turn, sync only when a memory changed (a status is cheap), and
202  // finish before the turn does so a session that exits next loses nothing.
203  on('turn.complete', async ($, e, next) => {
204    const result = await next(e)
205    if (config && !isBusy && (await hasChanges($, config))) await requestSync($)
206    return result
207  })
208}
209