feat(accounts): share the anchor's auto-memory, per project

Merged
GR
2mo ago
+771-28

Auto-memory is stored per project, under each account's own config dir, so two accounts on one machine keep two separate memories for the same repository. This adds an opt-in, per-account switch that points a non-anchor account at the anchor's memory for whichever repository a session starts in — one repository, one memory.

Off by default. The anchor is never offered it: its memory is the one the others share.

Mechanism

autoMemoryDirectory relocates auto-memory and --settings is a settings scope that accepts a JSON string, so a launcher can redirect one launch without touching either account's settings.json and without writing anything into the repository. The directory has to be computed per launch, because it depends on the repository the session starts in.

The slug rule, measured against Claude Code 2.1.276:

  • the key is the git common directory's parent, so a repository, its subdirectories and all of its linked worktrees resolve to one memory. Verified against a real linked worktree: it reports the main repository's .git, and projects/ holds only the main repository's slug;
  • outside a git repository the key is the working directory itself;
  • the slug is that absolute path with every non-alphanumeric byte replaced by -, case preserved.

Three launch paths, not one

A session under a non-anchor account can start in three ways. Covering only the shell dispatcher would have left the app's own buttons writing to the old place, which is most of how CodeV is used — so all three carry it:

PathHow
claude <name> … and claude-<name> (the generated accounts.sh)_codev_memory_settings computes the payload from $PWD at launch; inline JSON
CodeV resume (buildResumeCommand)--settings <file> under ~/.config/codev/memory-settings/
CodeV new session under a picked account (launchNewClaudeSession)the same file

claude-whoami is deliberately untouched — auth status is not a session.

Why CodeV's paths use a file rather than the same inline JSON: they embed the command in an AppleScript string, and two of the four terminals interpolate it unescaped — Ghostty's initial input:"…" and cmux's --command "…". A JSON payload's double quotes would end the string and the launch would fail. (iTerm2 and Terminal.app do escape; the repo already learned this once, which is why the CLAUDE_CONFIG_DIR prefix is single-quoted.) A path under ~/.config/codev contains no quotes at all. The shell dispatcher has no AppleScript layer, so inline JSON is safe there.

One rule, two implementations — pinned together

The rule lives once, in src/cli/memory-dir.ts. The shell function in accounts.sh is generated from that same module, and memory-dir.test.ts runs it under zsh and asserts it agrees with the TypeScript for a repository, a subdirectory, a linked worktree and a plain directory. Without that the two would drift, which is the failure mode this repo has hit before.

What is NOT shared

Session transcripts. Claude Code still writes them to the launching account's own projects/<slug>/ — confirmed by probe: the slug directory is created for the transcript, with no memory/ inside it. That is what keeps CodeV's per-account session attribution working, and it is why this is memory sharing rather than session sharing.

Known gap, stated in the UI rather than hidden: a session started outside both launchers — a bare CLAUDE_CONFIG_DIR=… claude, or the VS Code extension — uses that account's own memory for that session. Nothing breaks; it just does not see the shared copy.

Verification

Each link of the chain was measured, not assumed:

  1. --settings inline JSON does relocate auto-memory. Probe from a non-repository directory with a scratch target: the memory file and its MEMORY.md landed in the target. A slug directory was created under the account — containing only the session transcript, no memory/ — which is the transcript behaviour above.
  2. The slug rule matches Claude Code, checked against directories already on disk, including the worktree case.
  3. End to end, through the real dispatcher and a real non-anchor account: an accounts.sh generated with the anchor pointed at a scratch path, sourced in a subshell, claude <name> -p … run from a real repository. The memory landed at <scratch>/projects/<repo-slug>/memory/, and no memory directory appeared under the launching account for that repository. Nothing real was written.
  4. Mutation-verified: making the shell slug rule diverge from the TypeScript one, and making the shell ignore the git common directory, each turn the agreement test red — so the test is attached to the behaviour.
  5. 208 tests (13 new), tsc clean.

Notes for review

  • RegistryAccount / CodevAccount / the renderer's row type each gained the flag; that three-way duplication of one registry record predates this change.
  • #119 (worktree launch) touches some of the same files. It composes cleanly: a session launched in a worktree resolves to the parent repository's memory, which is the intended behaviour here.
  • The relayed request described only the shell launcher and inline JSON everywhere. The two deviations — all three launch paths, and a file for the app's own launches — are explained above; both were needed for the stated goal ("one memory per repo") to actually hold.

🤖 Generated with Claude Code

Review round 1 (18 threads, ~12 distinct after de-duplicating the two bots — all addressed in 35d409d)

CI was red on the first head and that was mine: both new tests shell out to zsh, which Ubuntu runners do not have. The workflow installs it now rather than the tests dropping to bash, because zsh is the shell that actually sources the generated accounts.sh.

The finding worth reading: both reviewers asked for git discovery variables to be cleared, and both spelled it GIT_DIR= git rev-parse …. Taken literally that breaks the lookup — an empty GIT_DIR is not an unset one, git answers fatal: not a git repository: '', and every subdirectory and worktree silently falls through to the working-directory branch. I applied it as written and the shell-versus-TypeScript agreement test went red on the subdirectory case, which is exactly what that test is for. It is env -u on the shell side and a deleted key on the TypeScript side, with a test that poisons GIT_DIR and asserts the repository still wins.

Also fixed: the settings path is single-quoted (a home directory with a space truncated the launch); the settings file is written temp-then-renamed; the registry flag is read as strictly true in both readers, so a hand-written "false" cannot switch an account into the anchor's memory; the helper is emitted whenever any account carries the flag, including a registry with no anchor marked, since that is exactly when a launcher would call it; getAnchorDir resolves; the shell slug translates newlines before sed; the checkbox disables while the IPC is in flight; the help text is no longer a <div> inside a <span>; and share-memory reports which half of its arguments was wrong.

One declined, with the reasoning in the code. The slug collides (/tmp/a-b and /tmp/a/b both become -tmp-a-b). That is Claude Code's own rule, it already applies to a single account today, and the job of this module is to name the directory Claude Code will actually read — a collision-free key would name one it never opens.

Review round 2 (3 threads — 9ed9c40)

The UTF-16 divergence is closed, not documented. Round 1 declined it on the reasoning that the TypeScript side matches Claude Code because Claude Code is JavaScript — an inference. Measuring it changed the answer: a real session run in a directory whose path contains 😀 makes Claude Code name its projects/ directory with four dashes for the surrogate pair, confirming it counts UTF-16 code units and that the shell twin was the wrong side. The helper now normalises with perl -CSD -pe 's/([^a-zA-Z0-9])/"-" x (ord($1) > 0xFFFF ? 2 : 1)/ge', which is exact for both planes; perl ships with macOS, the only platform this app runs on, and runs per launch rather than when a shell sources accounts.sh. An astral path joins the newline and BMP cases in the agreement test, and reverting the helper to sed turns it red.

The one prettier/prettier finding in src/cli/account-manager.ts is fixed — that file has no others, so formatting the line leaves it as clean as it was found. The same finding on src/popup.tsx is declined with counts: that file has 787 findings, this PR added 55 lines, 33 of the findings land on them, and all 33 are indentation — the same complaint the surrounding code has, since 15 of the 15 unchanged lines immediately after the block also have one. Formatting only these lines would indent them differently from the code they sit inside and would not make yarn lint cleaner; there is no lint gate in CI either.

Hand-testing found one more, and it was silent (f87d26d)

Walking the test steps turned up cd /tmp && _codev_memory_settings answering -tmp. Measured what Claude Code actually does with that same directory: a real session whose shell sits in /tmp is filed under -private-tmp. It keys on process.cwd(), which resolves symlinks, while $PWD stays logical after a cd through one.

The consequence defeated the feature exactly there and said nothing: the redirect named a directory the anchor never uses, so a shared account working in such a directory would have got an empty memory rather than the shared one. Only the fallback was affected — the git branch was always immune, because git rev-parse reports a resolved path, which is why every repository case passed.

Both sides take the physical path now (pwd -P, realpathSync). The regression test drives it the way a person does — zsh -c 'cd <symlink>' — because execFileSync's own cwd option cannot reproduce a logical $PWD, and reverting to $PWD turns it red.

Worth noting as a pattern: this and the UTF-16 slug were both cases where I had reasoned about Claude Code's behaviour instead of measuring it, and measuring flipped the answer both times.


Summary by cubic

Adds an opt-in per-account switch that points non-anchor sessions at the anchor's Claude Code auto-memory per repository, so multiple accounts on one machine share one memory per project. Off by default; session transcripts still land under the launching account.

  • Resolves the memory key per launch from the git common directory's parent, so a repository, its subdirectories, and linked worktrees share one memory; non-repository paths resolve symlinks, since Claude Code keys on the physical path.
  • Covers all three launch paths: the shell dispatcher (inline JSON), CodeV resume, and new-session launches (a safely quoted settings file, because two terminals interpolate embedded commands unescaped).
  • Configure via Settings → Accounts or codev account share-memory <name> on|off; the anchor can't enable it, and bare CLAUDE_CONFIG_DIR=… claude or the VS Code extension keep the account's own memory.
  • The shell helper is generated from the same module and pinned to the TypeScript rule under zsh, covering subdirectories, worktrees, plain directories, newline, BMP, astral, and trailing-newline paths, symlinks, and inherited git env vars; 209 tests pass and tsc is clean.

Summary by CodeRabbit

  • New Features

    • Added per-account auto-memory sharing with the anchor account, configurable per project and disabled by default.
    • Added a Settings checkbox and the codev account share-memory <name> on|off command.
    • Shared memory covers repositories, subdirectories, and linked worktrees while keeping session transcripts separate.
    • Added support across account launchers, resume flows, and new-session launches.
    • Sessions launched outside supported account flows continue using their own account memory.
  • Documentation

    • Documented configuration behavior, project scope, launch paths, and limitations.