diff --git a/.github/workflows/test_ci.yaml b/.github/workflows/test_ci.yaml index 316905f..a9e3079 100644 --- a/.github/workflows/test_ci.yaml +++ b/.github/workflows/test_ci.yaml @@ -16,6 +16,11 @@ jobs: with: node-version: 22 cache: yarn + # The generated accounts.sh is sourced by zsh in real use, and the + # memory-dir tests run its helper to pin the shell rule against the + # TypeScript one. Ubuntu runners have bash but not zsh. + - name: Install zsh + run: sudo apt-get update && sudo apt-get install -y zsh - name: Install dependencies run: yarn install --frozen-lockfile - name: Run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 6288917..31b5a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.0.92 + +- Feat: **a non-anchor account can share the anchor's auto-memory, per project** (Settings → Accounts → Sharing → *Memory: share with the anchor, per project*; `codev account share-memory on|off`). Auto-memory is stored per project under each account's own config dir, so two accounts on one machine kept two memories for the same repository. Turning this on points the other account at the anchor's copy of whichever repository a session starts in, computed at launch rather than baked in — a repository, its subdirectories and all of its linked worktrees resolve to one memory (the key is the git common directory's parent; outside a repository it is the directory itself, with symlinks resolved — Claude Code files a session under the physical path). Off by default, and the anchor is never offered it: its memory is the one the others share +- **Session transcripts are not shared** by it. Claude Code still writes them under the launching account, which is what keeps CodeV's per-account session attribution working — this shares memory, not sessions +- All three launch paths carry it, not just the shell one: `claude ` through the generated `accounts.sh`, CodeV's resume, and CodeV's new-session launch under a picked account. A session started outside both — a bare `CLAUDE_CONFIG_DIR=… claude`, or the VS Code extension — keeps using that account's own memory for that session, which the Sharing panel says in as many words +- The rule lives once (`src/cli/memory-dir.ts`); the shell function in `accounts.sh` is generated from the same module, and a test runs it under zsh to assert the two agree for a repository, a subdirectory, a linked worktree and a plain directory + ## 1.0.91 - Fix: **a space after an operator's colon now works.** `title: ci time` was read as a bare `title:` with no value, reported as unreadable, and then searched as the two plain words `ci` and `time` — 113 sessions instead of the 7 that `title:ci time` returns. The space is what people type, and a bare `title:` meant nothing before, so for a plain word the rule only changes queries that were already being reported as errors. Applies to every operator (`pr:`, `has:`, `is:`, `after:`, `before:` and all six field terms), and a quoted phrase still works after the space (`title: "two words"`). **A token that is already a search term is never taken** — another operator or a PR reference — so `title: is:live` and `title: #137` keep searching for the live filter and for PR 137 respectively, each with its `title:` reported, rather than silently losing the term inside a field value. **Quotes are the escape hatch**, identically with and without the space: `title: "is:live"` and `title:"is:live"` both mean a title containing that text. When a taken value turns out to be unusable the warning now shows both halves (`ignored: after: soon`) instead of a bare `after:` diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index 4870fbc..c5cd33f 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -211,7 +211,7 @@ The registry records `configDirEnv` (null for default, the dir for extras) and | `settings.json` (model, hooks, permissions, env) | Config dir | Optional symlink (careful) | Symlink ⇒ hooks/permissions shared; or install hook per-dir (§6.F) | | Single `settings.json` keys (`statusLine`, `model`, `effortLevel`, `theme`) | Config dir | **Yes — per-key copy** | Copy the key into the other account's settings.json (statusLine done for `work` 2026-07; HOME-based script serves all accounts) | | `.claude.json` (identity + per-project trust) | Config dir | **No — never symlink** | Holds `oauthAccount`; must stay per-account | -| Auto-memory (`projects//memory/`, `MEMORY.md`) | Config dir | No by default | Per-account, or advanced: symlink individual `memory/` subdirs | +| Auto-memory (`projects//memory/`, `MEMORY.md`) | Config dir | No by default → **opt-in, per project** | A launch-time redirect, not a symlink — see §5.1 | | Session data (`history.jsonl`, `projects/*.jsonl`, `sessions/`) | Config dir | **No — aggregated for display, not shared** | CodeV scans all dirs (§6.E) | **Verified 2026-07-09:** Claude Code follows symlinks for both `skills/` entries @@ -255,6 +255,64 @@ path is HOME-based and Claude Code feeds it the session's own context via stdin) Candidates for a future `codev account sync-settings `: `statusLine`, `model`, `effortLevel`, `theme`. Hooks stay per-dir (§6.F installs them per account). +### 5.1 Shared auto-memory — a launch-time redirect, not a symlink + +Auto-memory is stored per project, under `/projects//memory/`, +so two accounts on one machine keep two separate memories for the same +repository. Symlinking is the wrong tool here: there is one directory per +project, a new one appears whenever a repository is first opened, and each +would need its own link. + +Claude Code reads `autoMemoryDirectory` from any settings scope, and +`--settings` is a scope that accepts a JSON string, so a launcher can point one +launch at another account's memory without writing to either account's +`settings.json` and without putting anything inside 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, 2026-09-18): + +- the key is the **git common directory's parent**, so a repository, its + subdirectories and all of its linked worktrees share one memory. Verified on + disk against a real linked worktree: it reports the main repository's `.git`, + and `projects/` holds only the main repository's slug — no second directory + for the worktree's own path; +- outside a git repository the key is the working directory itself, **resolved**: Claude Code keys on `process.cwd()`, so a shell sitting in `/tmp` is filed under `-private-tmp`. The git branch is already immune, since `git rev-parse` reports a resolved path; +- the slug is that absolute path with every non-alphanumeric byte replaced by + `-`, case preserved. + +**Three launch paths, one rule.** A session under a non-anchor account can +start in three ways, and each needed the redirect separately — covering only +the shell dispatcher would have left CodeV's own buttons writing to the old +place: + +| Path | Where the redirect is added | +|---|---| +| `claude ff …` (the generated `accounts.sh` dispatcher, and `claude-ff`) | `_codev_memory_settings` computes the payload from `$PWD` at launch; inline JSON is safe in a plain shell | +| CodeV resume (`buildResumeCommand`) | `--settings ` written to `~/.config/codev/memory-settings/.json` | +| CodeV new session under a picked account (`launchNewClaudeSession`) | the same file | + +CodeV's two paths use a **file** rather than inline JSON because 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 "…"` — so a +JSON payload's double quotes would end the string and the launch would fail. +(iTerm2 and Terminal.app do escape.) A path under `~/.config/codev` contains no +quotes at all. The rule itself lives once, in `src/cli/memory-dir.ts`; the +shell function is generated from the same module and `memory-dir.test.ts` runs +it under zsh to assert the two agree for a repository, a subdirectory, a linked +worktree and a plain directory. + +**What is not shared: the session transcript.** Claude Code still writes it to +the launching account's own `projects//`, 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 (§6.E) working, and it is +why this is memory sharing rather than session sharing. + +**Known gap, stated in the UI.** 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 simply does not see +the shared copy. + --- ## 6. Sub-feature designs (approaches, pros/cons, recommendation) diff --git a/package.json b/package.json index 9508b40..83a65c7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.91", + "version": "1.0.92", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/accounts.ts b/src/accounts.ts index b83aa1b..4af5c0c 100644 --- a/src/accounts.ts +++ b/src/accounts.ts @@ -28,6 +28,9 @@ export interface CodevAccount { // (registry `defaultAccount`, what bare `claude` opens). Historically this // field was called `isDefault`, which conflated the two concepts. isAnchor: boolean; + // Launch this account with its auto-memory redirected to the anchor's, per + // project (src/cli/memory-dir.ts). Meaningless on the anchor itself. + shareMemoryWithAnchor?: boolean; email?: string; org?: string; subscription?: string; @@ -41,6 +44,7 @@ interface RawAccount { configDirEnv?: string | null; identityFile?: string; isAnchor?: boolean; + shareMemoryWithAnchor?: boolean; email?: string; org?: string; subscription?: string; @@ -127,6 +131,9 @@ export const getAccounts = (): CodevAccount[] => { configDirEnv, identityFile, isAnchor, + // Strictly `true`: a hand-written registry carrying the STRING + // "false" must not switch an account into the anchor's memory. + shareMemoryWithAnchor: a.shareMemoryWithAnchor === true, email: a.email, org: a.org, subscription: a.subscription, @@ -171,6 +178,18 @@ export const getAccountByLabel = (label: string | undefined): CodevAccount => { }; /** True when more than one account is configured (used to gate account UI). */ +/** + * The anchor account's config dir (`~/.claude`) — where shared auto-memory + * lives. Falls back to the literal path when no registry names an anchor. + */ +export const getAnchorDir = (): string => + // Resolved: a registry written with a relative dir would otherwise put the + // shared memory under whatever directory the launch happened to run in. + path.resolve( + getAccounts().find((a) => a.isAnchor)?.dir ?? + path.join(os.homedir(), '.claude'), + ); + export const isMultiAccount = (): boolean => getAccounts().length > 1; /** diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index c45047b..b8cb02f 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -11,10 +11,12 @@ import { getCurrentIDEBundleId } from './vscode-based-ide-utility'; import { CodevAccount, getAccounts, + getAnchorDir, getScannableAccounts, getProjectsDir, getAccountByLabel, } from './accounts'; +import { memorySettingsArg } from './cli/memory-dir'; import { compileQuery, explainMatch, @@ -1289,27 +1291,53 @@ const detectActiveSessionsLegacy = async ( } }; +/** + * ` --settings ` when this account shares the anchor's auto-memory, else + * ''. Anchor accounts get nothing: their memory IS the shared copy. + */ +const memoryArgFor = ( + account: CodevAccount | undefined, + projectPath: string, +): string => + account && !account.isAnchor && account.shareMemoryWithAnchor && projectPath + ? memorySettingsArg(getAnchorDir(), projectPath) + : ''; + /** * codev multi-account: look up which account a session belongs to (via the * cached session list, which tags each session with its config dir) and return - * the CLAUDE_CONFIG_DIR to prefix at resume — or null for the default account. + * the CLAUDE_CONFIG_DIR to prefix at resume — null for the anchor account — + * together with the account itself, which the memory redirect above needs. */ -const getResumeConfigDirEnv = ( +const resumeAccountFor = ( sessionId: string, accountLabel?: string, -): string | null => { +): { configDirEnv: string | null; account?: CodevAccount } => { const s = readClaudeSessions(Number.MAX_SAFE_INTEGER).find( (x) => x.sessionId === sessionId, ); - if (s) return s.accountConfigDirEnv; + if (s) { + // The config dir still comes from the session record, which is the + // authority; the account rides along from the SAME lookup so the two + // cannot disagree about which identity this resume belongs to. + return { + configDirEnv: s.accountConfigDirEnv, + account: s.accountLabel ? findAccountByLabel(s.accountLabel) : undefined, + }; + } // Not in any history (a /branch child, a pruned history): trust the label // a saved list captured, or the resume lands under the anchor account and // never finds its transcript. Strict lookup — a label no account carries // (renamed, removed) is rejected by the launch paths before they get here. const account = accountLabel ? findAccountByLabel(accountLabel) : undefined; - return account ? account.configDirEnv : null; + return { configDirEnv: account ? account.configDirEnv : null, account }; }; +const getResumeConfigDirEnv = ( + sessionId: string, + accountLabel?: string, +): string | null => resumeAccountFor(sessionId, accountLabel).configDirEnv; + /** The account with exactly this label, or undefined — never a fallback. */ export const findAccountByLabel = (label: string): CodevAccount | undefined => getAccounts().find((a) => a.label === label); @@ -1335,9 +1363,16 @@ export const isSafeLaunchPath = (p: unknown): p is string => */ const buildResumeCommand = ( sessionId: string, - accountLabel?: string, + accountLabel: string | undefined, + // Required, not optional: the memory redirect below is per project, and a + // call site that forgot to pass it must be a type error rather than a + // session that silently keeps writing to the wrong memory directory. + projectPath: string, ): string => { - const configDir = getResumeConfigDirEnv(sessionId, accountLabel); + const { configDirEnv: configDir, account } = resumeAccountFor( + sessionId, + accountLabel, + ); // Single-quote the value: some terminal injections (Ghostty `initial input`) // don't escape the command, so a double-quoted prefix would break their // AppleScript string. Single quotes are safe across all terminals + handle spaces. @@ -1352,7 +1387,7 @@ const buildResumeCommand = ( // global-default (§2e) — that would resume an anchor-account session under the // wrong account. Every terminal here runs the string through a shell, so the // `command` builtin is available. - return `${prefix}command claude --resume ${sessionId}`; + return `${prefix}command claude${memoryArgFor(account, projectPath)} --resume ${sessionId}`; }; /** @@ -1753,9 +1788,13 @@ export const launchNewClaudeSession = ( } // Explicit pick of the default account: clear any inherited // CLAUDE_CONFIG_DIR too (matches the generated accounts.sh launchers). + // The memory redirect is appended for the same reason it is on resume — + // this launch bypasses the accounts.sh dispatcher, so the dispatcher's + // copy of the rule never runs. + const memory = memoryArgFor(account, projectPath); claudeCmd = account.configDirEnv - ? `CLAUDE_CONFIG_DIR='${account.configDirEnv.replace(/'/g, "'\\''")}' command claude` - : 'env -u CLAUDE_CONFIG_DIR claude'; + ? `CLAUDE_CONFIG_DIR='${account.configDirEnv.replace(/'/g, "'\\''")}' command claude${memory}` + : `env -u CLAUDE_CONFIG_DIR claude${memory}`; } // Pass claudeCmd as the 2nd arg too — Ghostty/cmux build their launch // scripts from it (iTerm2/Terminal.app use fullCommand), so the account @@ -1910,7 +1949,7 @@ export const openSessionInITerm2 = async ( try { fs.unlinkSync(tmpScript); } catch {} }); } else { - const resumeCmd = buildResumeCommand(sessionId, accountLabel); + const resumeCmd = buildResumeCommand(sessionId, accountLabel, projectPath); return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'iterm2', terminalMode); } }; @@ -2585,7 +2624,7 @@ end tell`; try { fs.unlinkSync(tmpScript); } catch {} }); } else { - const resumeCmd = buildResumeCommand(sessionId, accountLabel); + const resumeCmd = buildResumeCommand(sessionId, accountLabel, projectPath); return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'ghostty', terminalMode); } }; @@ -2624,7 +2663,7 @@ export const openSessionInTerminalApp = async ( try { fs.unlinkSync(tmpScript); } catch {} }); } else { - const resumeCmd = buildResumeCommand(sessionId, accountLabel); + const resumeCmd = buildResumeCommand(sessionId, accountLabel, projectPath); return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'terminal', terminalMode); } }; @@ -2645,7 +2684,7 @@ export const openSessionInCmux = async ( accountLabel?: string, ): Promise => { const { exec } = require('child_process'); - const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel)}`; + const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel, projectPath)}`; console.log('[cmux] openSession:', { sessionId, projectPath, isActive, activePid, customTitle }); if (isActive) { @@ -2770,7 +2809,7 @@ export const openSessionInCmux = async ( exec('osascript -e \'tell application "cmux" to activate\''); })(); } else { - const resumeCmd = buildResumeCommand(sessionId, accountLabel); + const resumeCmd = buildResumeCommand(sessionId, accountLabel, projectPath); return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'cmux'); } }; @@ -2783,7 +2822,7 @@ export const copyResumeCommand = ( projectPath: string, accountLabel?: string, ): string => { - const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel)}`; + const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel, projectPath)}`; const { execFileSync } = require('child_process'); execFileSync('pbcopy', { input: command }); return command; diff --git a/src/cli/account-manager.test.ts b/src/cli/account-manager.test.ts index d6c4ba1..46f1ad2 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'child_process'; +import * as fs from 'fs'; import { writeFileSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -38,6 +39,68 @@ function reg(overrides: Partial = {}): Registry { }; } +describe('generateAccountsSh — shared auto-memory', () => { + /** The same registry with the work account sharing the anchor's memory. */ + const shared = () => { + const r = reg(); + r.accounts[1].shareMemoryWithAnchor = true; + return r; + }; + + it('adds nothing at all while no account shares memory', () => { + expect(generateAccountsSh(reg())).not.toContain('_codev_memory_settings'); + }); + + it('redirects only the sharing account, at every one of its launchers', () => { + const sh = generateAccountsSh(shared()); + expect(sh).toContain('_codev_memory_settings() {'); + expect(sh).toContain( + 'claude-work() { env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude --settings "$(_codev_memory_settings)" "$@"; }', + ); + expect(sh).toContain( + 'work) shift; env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude --settings "$(_codev_memory_settings)" "$@" ;;', + ); + // The anchor already writes to the shared directory, so it is untouched. + expect(sh).toContain( + 'claude-personal() { env -u CLAUDE_CONFIG_DIR claude "$@"; }', + ); + }); + + it('follows the global default when that is the sharing account', () => { + const r = shared(); + r.defaultAccount = 'work'; + expect(generateAccountsSh(r)).toContain( + '*) env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude --settings "$(_codev_memory_settings)" "$@" ;;', + ); + }); + + it('leaves claude-whoami alone — `auth status` is not a session', () => { + const sh = generateAccountsSh(shared()); + const whoami = sh.slice(sh.indexOf('claude-whoami() {')); + expect(whoami).not.toContain('_codev_memory_settings'); + }); + + it('still defines the helper when no account is marked as the anchor', () => { + // A partial hand-written registry: the launcher calls the helper, so the + // helper has to exist — falling back to ~/.claude the way getAnchorDir does. + const r = shared(); + r.accounts = r.accounts.map((a) => ({ ...a, isAnchor: false })); + const sh = generateAccountsSh(r); + expect(sh).toContain('_codev_memory_settings() {'); + expect(sh).toContain(path.join(HOME, '.claude')); + }); + + it('is still syntactically valid shell with the helper in it', () => { + const file = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'accounts-sh-')), + 'accounts.sh', + ); + fs.writeFileSync(file, generateAccountsSh(shared())); + expect(() => execFileSync('bash', ['-n', file])).not.toThrow(); + expect(() => execFileSync('zsh', ['-n', file])).not.toThrow(); + }); +}); + describe('generateAccountsSh', () => { it('launches the default account with CLAUDE_CONFIG_DIR unset', () => { const sh = generateAccountsSh(reg()); @@ -143,15 +206,16 @@ describe('generateAccountsSh', () => { expect(sh).toContain('_codev() {'); expect(sh).toContain('compdef _codev codev'); expect(sh).toContain( - 'compadd list add default remove rm rename share unshare sync-settings regenerate show install uninstall help', + 'compadd list add default remove rm rename share unshare share-memory sync-settings regenerate show install uninstall help', ); expect(sh).toContain('default|rename) compadd personal work ;;'); // anchor (personal) is not removable/shareable-to expect(sh).toContain( - 'remove|rm|share|unshare|sync-settings) compadd work ;;', + 'remove|rm|share|unshare|share-memory|sync-settings) compadd work ;;', ); // item + key completion at position 5 expect(sh).toContain('share|unshare) compadd claude-md skills commands ;;'); + expect(sh).toContain('share-memory) compadd on off ;;'); expect(sh).toContain( 'sync-settings) compadd statusLine model effortLevel theme ;;', ); diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 4baad81..a94b61b 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -20,6 +20,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { memoryShellHelper } from './memory-dir'; export interface RegistryAccount { label: string; @@ -31,6 +32,10 @@ export interface RegistryAccount { // GLOBAL DEFAULT (`defaultAccount` below, what bare `claude` opens) — the // field was historically called `isDefault`, which conflated the two. isAnchor: boolean; + // Redirect this account's auto-memory to the anchor's, per project, at every + // launch (see memory-dir.ts). Anchor accounts ignore it — their memory is + // already the shared copy. + shareMemoryWithAnchor?: boolean; email?: string; org?: string; subscription?: string; @@ -203,13 +208,24 @@ export function resolveDefaultLabel(reg: Registry): string | undefined { * dispatcher. `env` runs the real binary (no shell-function recursion) and lets * us set — or explicitly unset — CLAUDE_CONFIG_DIR. */ -function launchCmd(account: RegistryAccount | undefined): string { +function launchCmd( + account: RegistryAccount | undefined, + // A session launch, as opposed to `claude-whoami`'s `auth status`: only a + // session needs its auto-memory redirected. + forSession = false, +): string { if (!account || !account.configDirEnv) { // Default/anchor account: unset CLAUDE_CONFIG_DIR so a stray exported value - // can't hijack it (§3.4). + // can't hijack it (§3.4). The anchor's memory is already the shared one. return 'env -u CLAUDE_CONFIG_DIR claude '; } - return `env CLAUDE_CONFIG_DIR="${toShellPath(expandHome(account.configDirEnv))}" claude `; + // Computed per launch, not baked in: the directory depends on the repository + // the shell is sitting in when the command runs. + const memory = + forSession && account.shareMemoryWithAnchor === true + ? '--settings "$(_codev_memory_settings)" ' + : ''; + return `env CLAUDE_CONFIG_DIR="${toShellPath(expandHome(account.configDirEnv))}" claude ${memory}`; } /** Render the accounts.sh contents from a registry object. Pure function. */ @@ -265,9 +281,27 @@ export function generateAccountsSh(reg: Registry): string { '# All native flags pass through, e.g. `claude work -r`, `claude-work mcp list`.', ); L.push(''); + // The helper the launchers below call. Emitted only when an account shares + // memory, so the generated file stays as short as it was for everyone else. + // The launchers below call the helper whenever an account carries the flag, + // so the helper has to exist in exactly those cases — including a partial + // registry with no anchor marked, which falls back the way getAnchorDir does. + if ( + accounts.some((a) => a.shareMemoryWithAnchor === true && a.configDirEnv) + ) { + const anchor = accounts.find((a) => a.isAnchor); + L.push( + memoryShellHelper( + path.resolve( + anchor ? expandHome(anchor.dir) : path.join(os.homedir(), '.claude'), + ), + ), + ); + L.push(''); + } L.push('# --- per-account launchers (full passthrough via "$@") ---'); for (const a of accounts) { - L.push(`claude-${a.label}() { ${launchCmd(a)}"$@"; }`); + L.push(`claude-${a.label}() { ${launchCmd(a, true)}"$@"; }`); } L.push(''); L.push( @@ -279,9 +313,9 @@ export function generateAccountsSh(reg: Registry): string { L.push('claude() {'); L.push(' case "$1" in'); for (const a of accounts) { - L.push(` ${a.label}) shift; ${launchCmd(a)}"$@" ;;`); + L.push(` ${a.label}) shift; ${launchCmd(a, true)}"$@" ;;`); } - L.push(` *) ${launchCmd(defaultAccount)}"$@" ;;`); + L.push(` *) ${launchCmd(defaultAccount, true)}"$@" ;;`); L.push(' esac'); L.push('}'); L.push(''); @@ -359,20 +393,21 @@ export function generateAccountsSh(reg: Registry): string { L.push(' compadd account'); L.push(' elif (( CURRENT == 3 )) && [ "${words[2]}" = "account" ]; then'); L.push( - ' compadd list add default remove rm rename share unshare sync-settings regenerate show install uninstall help', + ' compadd list add default remove rm rename share unshare share-memory sync-settings regenerate show install uninstall help', ); L.push(' elif (( CURRENT == 4 )) && [ "${words[2]}" = "account" ]; then'); L.push(' case "${words[3]}" in'); if (allLabels) L.push(` default|rename) compadd ${allLabels} ;;`); if (removable) { L.push( - ` remove|rm|share|unshare|sync-settings) compadd ${removable} ;;`, + ` remove|rm|share|unshare|share-memory|sync-settings) compadd ${removable} ;;`, ); } L.push(' esac'); L.push(' elif (( CURRENT == 5 )) && [ "${words[2]}" = "account" ]; then'); L.push(' case "${words[3]}" in'); L.push(' share|unshare) compadd claude-md skills commands ;;'); + L.push(' share-memory) compadd on off ;;'); L.push( ' sync-settings) compadd statusLine model effortLevel theme ;;', ); @@ -609,6 +644,26 @@ export function setDefault(label: string): void { regenerate(reg); } +/** + * Turn shared auto-memory on or off for one non-anchor account, then + * regenerate accounts.sh so the shell dispatcher agrees with CodeV's own + * launchers. Refuses the anchor: its memory already IS the shared copy, so a + * flag there would only look like it did something. + */ +export function setShareMemory(label: string, on: boolean): void { + const reg = readRegistry(); + const account = reg.accounts.find((a) => a.label === label); + if (!account) throw new Error(`No account "${label}"`); + if (account.isAnchor || !account.configDirEnv) { + throw new Error( + `"${label}" is the anchor account — its memory is the one others share`, + ); + } + account.shareMemoryWithAnchor = on; + writeRegistry(reg); + regenerate(reg); +} + export interface ListedAccount extends RegistryAccount { isCurrentDefault: boolean; } diff --git a/src/cli/codev-account.ts b/src/cli/codev-account.ts index 0b267fe..3bf6ff0 100644 --- a/src/cli/codev-account.ts +++ b/src/cli/codev-account.ts @@ -40,6 +40,9 @@ Usage: Remove the link (source untouched); --restore-backup = undo a share that displaced own content; --keep-copy = keep a real copy + codev account share-memory on|off + Point this account's auto-memory at the + anchor's, per project, at every launch codev account sync-settings Copy settings.json keys from the anchor (allowed: statusLine, model, effortLevel, theme) @@ -277,6 +280,28 @@ function main(): number { return 0; } + case 'share-memory': { + const [name, state] = rest.filter((a) => !a.startsWith('-')); + if (!name) { + console.error('share-memory: is required'); + return 1; + } + if (state !== 'on' && state !== 'off') { + console.error( + `share-memory: state must be on or off (got "${state ?? ''}")`, + ); + return 1; + } + manager.setShareMemory(name, state === 'on'); + console.log( + state === 'on' + ? `✓ "${name}" now uses the anchor's auto-memory, per project` + : `✓ "${name}" uses its own auto-memory again`, + ); + reloadHint(); + return 0; + } + case 'sync-settings': { const [name, ...keys] = rest.filter((a) => !a.startsWith('-')); if (!name || keys.length === 0) { diff --git a/src/cli/memory-dir.test.ts b/src/cli/memory-dir.test.ts new file mode 100644 index 0000000..27835f7 --- /dev/null +++ b/src/cli/memory-dir.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + anchorMemoryDir, + memoryProjectRoot, + memorySettingsJson, + memoryShellHelper, + memorySlug, +} from './memory-dir'; + +const ANCHOR = '/Users/probe/.claude'; + +let base: string; +let repo: string; +let subdir: string; +let worktree: string; +let plain: string; + +const git = (cwd: string, ...args: string[]) => + execFileSync('git', ['-C', cwd, ...args], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + +beforeAll(() => { + // realpath: macOS tmpdirs are symlinked, and git reports the resolved path + // while `path.resolve` does not — a test artefact, not a product rule. + base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'memdir-'))); + repo = path.join(base, 'repo'); + subdir = path.join(repo, 'src', 'deep'); + worktree = path.join(base, 'repo-wt'); + plain = path.join(base, 'not-a-repo'); + fs.mkdirSync(subdir, { recursive: true }); + fs.mkdirSync(plain, { recursive: true }); + git(repo, 'init', '-q'); + git(repo, 'config', 'user.email', 'probe@example.com'); + git(repo, 'config', 'user.name', 'probe'); + git(repo, 'commit', '-q', '--allow-empty', '-m', 'root'); + git(repo, 'worktree', 'add', '-q', '--detach', worktree); +}); + +afterAll(() => { + if (base) fs.rmSync(base, { recursive: true, force: true }); +}); + +describe('memorySlug', () => { + it('replaces every non-alphanumeric byte and keeps case', () => { + expect(memorySlug('/Users/g/git/codev')).toBe('-Users-g-git-codev'); + // A path that already contains a dash-heavy segment doubles up, exactly as + // Claude Code's own directories do. + expect(memorySlug('/private/tmp/-Users-g/x')).toBe( + '-private-tmp--Users-g-x', + ); + }); +}); + +describe('memoryProjectRoot', () => { + it('is the repository root from the root, a subdirectory, or a worktree', () => { + expect(memoryProjectRoot(repo)).toBe(repo); + expect(memoryProjectRoot(subdir)).toBe(repo); + // The case the whole feature rests on: a linked worktree keys on the MAIN + // repository, so both share one memory. + expect(memoryProjectRoot(worktree)).toBe(repo); + }); + + it('falls back to the directory itself outside a repository', () => { + expect(memoryProjectRoot(plain)).toBe(plain); + }); +}); + +describe('the shell helper agrees with the TypeScript rule', () => { + // One rule, two implementations (TypeScript for CodeV's own launches, shell + // for the generated accounts.sh dispatcher). Pin them together or they drift. + const runHelper = (cwd: string): string => { + const script = path.join(base, 'helper.sh'); + fs.writeFileSync( + script, + `${memoryShellHelper(ANCHOR)}\n_codev_memory_settings\n`, + ); + return execFileSync('zsh', [script], { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + }; + + it('produces the same payload for a repo, a subdirectory, a worktree and a plain directory', () => { + for (const cwd of [repo, subdir, worktree, plain]) { + expect(runHelper(cwd), `cwd=${cwd}`).toBe( + memorySettingsJson(anchorMemoryDir(ANCHOR, cwd)), + ); + } + }); + + it('agrees on newline, trailing-newline, BMP and astral paths', () => { + // The shell side's failure modes. The astral one is why the helper + // normalises with perl rather than sed: Claude Code counts UTF-16 code + // units, so a surrogate pair becomes TWO dashes (measured against a real + // session run in such a directory), and sed would emit one. The TRAILING + // newline is why `pwd -P` runs behind an `x` sentinel: `$(…)` strips every + // trailing newline and `process.cwd()` does not. + for (const name of [ + 'line\nbreak', + 'trailing-newline\n', + 'ünïcøde', + 'emoji-\u{1F600}-dir', + ]) { + const dir = path.join(base, name); + fs.mkdirSync(dir, { recursive: true }); + expect(runHelper(dir), `cwd=${JSON.stringify(name)}`).toBe( + memorySettingsJson(anchorMemoryDir(ANCHOR, dir)), + ); + } + }); + + it('uses the physical path when a directory is reached through a symlink', () => { + // `cd` through a symlink leaves $PWD logical, and Claude Code files the + // session under the physical path — measured against a real session whose + // shell sat in /tmp. `zsh -c 'cd …'` reproduces the logical $PWD that + // execFileSync's own cwd option cannot. + const real = path.join(base, 'real-dir'); + const link = path.join(base, 'link-to-dir'); + fs.mkdirSync(real, { recursive: true }); + fs.symlinkSync(real, link); + const script = path.join(base, 'helper-link.sh'); + fs.writeFileSync( + script, + `${memoryShellHelper(ANCHOR)}\ncd ${JSON.stringify(link)}\n_codev_memory_settings\n`, + ); + const out = execFileSync('zsh', [script], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(out).toBe(memorySettingsJson(anchorMemoryDir(ANCHOR, real))); + expect(JSON.parse(out).autoMemoryDirectory).toContain(memorySlug(real)); + // And the TypeScript side resolves the link the same way. + expect(memoryProjectRoot(link)).toBe(real); + }); + + it('ignores inherited git discovery variables', () => { + // A launching shell exporting GIT_DIR must not key the memory to another + // repository — and the fix must UNSET it, since an empty GIT_DIR makes + // git fail outright and silently sends both sides to the $PWD fallback. + const poisoned = { + ...process.env, + GIT_DIR: path.join(base, 'nowhere', '.git'), + GIT_CEILING_DIRECTORIES: base, + }; + const script = path.join(base, 'helper-env.sh'); + fs.writeFileSync( + script, + `${memoryShellHelper(ANCHOR)}\n_codev_memory_settings\n`, + ); + const out = execFileSync('zsh', [script], { + cwd: subdir, + encoding: 'utf-8', + env: poisoned, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(out).toBe(memorySettingsJson(anchorMemoryDir(ANCHOR, subdir))); + expect(JSON.parse(out).autoMemoryDirectory).toContain(memorySlug(repo)); + }); + + it('emits valid JSON naming a directory under the anchor account', () => { + const parsed = JSON.parse(runHelper(repo)); + expect(parsed.autoMemoryDirectory).toBe( + `${ANCHOR}/projects/${memorySlug(repo)}/memory`, + ); + }); +}); diff --git a/src/cli/memory-dir.ts b/src/cli/memory-dir.ts new file mode 100644 index 0000000..3821e86 --- /dev/null +++ b/src/cli/memory-dir.ts @@ -0,0 +1,218 @@ +/** + * Auto-memory sharing: point a non-anchor account's memory at the anchor's. + * + * Claude Code keeps auto-memory per project, under + * `/projects//memory/`, so two accounts on one machine keep + * two separate trees for the same repository. `autoMemoryDirectory` in any + * settings scope relocates it, and `--settings` is a scope, so a launcher can + * redirect a non-anchor account per launch without touching either account's + * settings file — and without writing anything into the repository. + * + * The slug rule, measured against Claude Code 2.1.276 on 2026-09-18: + * + * - the key is the **git common directory's parent**, so every worktree and + * every subdirectory of one repository share one memory. Verified on disk + * against a real linked worktree: it reports the MAIN repository's `.git`, + * and only the main repository's slug exists under `projects/`, with no + * second one for the worktree's own path; + * - outside a git repository the key is the directory itself; + * - the slug is that absolute path with every non-alphanumeric byte replaced + * by `-`, case preserved (`/Users/g/git/codev` → `-Users-g-git-codev`). + * + * What is NOT shared: the session transcript. Claude Code still writes it to + * the launching account's own `projects//` (confirmed by probe — the + * slug directory is created for the transcript, with no `memory/` inside it), + * which is what keeps CodeV's per-account session attribution working. + */ +import { execFileSync } from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; + +/** `fs.realpathSync`, falling back to the path itself when it does not exist. */ +const realPath = (p: string): string => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('fs').realpathSync(p); + } catch { + return p; + } +}; + +const GIT_DISCOVERY_VARS = [ + 'GIT_DIR', + 'GIT_COMMON_DIR', + 'GIT_CEILING_DIRECTORIES', + 'GIT_DISCOVERY_ACROSS_FILESYSTEM', +] as const; + +/** + * `process.env` with those removed — DELETED, never set to '': an empty + * `GIT_DIR` is not an unset one, git reads it as "the git dir is ''" and fails, + * which would send every lookup down the working-directory fallback. + */ +const envWithoutGitDiscovery = (): NodeJS.ProcessEnv => { + const env = { ...process.env }; + for (const k of GIT_DISCOVERY_VARS) delete env[k]; + return env; +}; + +/** The directory Claude Code keys this project's auto-memory on. */ +export const memoryProjectRoot = (cwd: string): string => { + // Physical, not logical. Claude Code keys on `process.cwd()`, which resolves + // symlinks, so a directory reached through one has to resolve the same way + // here: measured 2026-09-19, a session whose shell sat in `/tmp` (a symlink + // to `private/tmp`) was filed under `-private-tmp`. The git branch below is + // already immune — `git rev-parse` reports a resolved path — so this only + // matters for the fallback, which is exactly where it used to differ. + const start = realPath(path.resolve(cwd)); + try { + const out = execFileSync( + 'git', + ['-C', start, 'rev-parse', '--path-format=absolute', '--git-common-dir'], + { + encoding: 'utf-8', + timeout: 3000, + stdio: ['ignore', 'pipe', 'ignore'], + // A launching shell that exports any of these would key the memory to + // a different repository, or suppress discovery so the two + // implementations fall back to different paths. + env: envWithoutGitDiscovery(), + }, + ).trim(); + // `.../repo/.git` → `.../repo`. A linked worktree reports the MAIN + // repository's git dir, which is exactly why worktrees share memory. + if (out) return path.dirname(out); + } catch { + // Not a repository (or no git) — the directory itself is the key. + } + return start; +}; + +/** + * Absolute path → the `projects/` directory name Claude Code uses for it. + * + * Two properties of this rule are inherited, not chosen, because the whole + * point is to name the directory Claude Code itself will use: + * + * - **it collides.** `/tmp/a-b` and `/tmp/a/b` both become `-tmp-a-b`, so they + * share one memory. That is already true for a single account today; a + * collision-free key here would just name a directory Claude Code never reads. + * - **it counts UTF-16 code units, not characters.** Measured 2026-09-18 by + * running a real session in `…/x/😀/y`: Claude Code named the directory + * `…-x----y`, four dashes — the surrogate pair counts twice. `sed` counts + * characters and produced three, so the shell twin used to disagree outside + * the Basic Multilingual Plane; it now normalises with `perl`, which can + * count code units, and the agreement test covers an astral path. + */ +export const memorySlug = (root: string): string => + root.replace(/[^a-zA-Z0-9]/g, '-'); + +/** The anchor account's memory directory for the project `cwd` belongs to. */ +export const anchorMemoryDir = (anchorDir: string, cwd: string): string => + path.join( + anchorDir, + 'projects', + memorySlug(memoryProjectRoot(cwd)), + 'memory', + ); + +/** The settings payload that relocates auto-memory to `dir`. */ +export const memorySettingsJson = (dir: string): string => + JSON.stringify({ autoMemoryDirectory: dir }); + +/** + * Where CodeV keeps the generated settings files, one per project slug. + * + * A FILE rather than the inline JSON the shell dispatcher uses, because + * CodeV's own launches embed the command in an AppleScript string and two of + * the four terminals do not escape it: Ghostty's `initial input:"…"` and + * cmux's `--command "…"` interpolate it raw, so a JSON payload's double + * quotes would end the string and the launch would fail. (iTerm2 and + * Terminal.app do escape — the split is why this is a file and not a + * quoting fix.) A path under `~/.config/codev` has no quotes in it at all. + */ +export const memorySettingsDir = (): string => + path.join(os.homedir(), '.config', 'codev', 'memory-settings'); + +export const memorySettingsPath = (cwd: string): string => + path.join(memorySettingsDir(), `${memorySlug(memoryProjectRoot(cwd))}.json`); + +/** + * Write the settings file for this project and return the `--settings ` + * argument, or '' when anything fails — a launch must never be blocked by + * memory sharing, it just falls back to the account's own memory. + */ +export const memorySettingsArg = (anchorDir: string, cwd: string): string => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('fs'); + const file = memorySettingsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + // Temp + rename: two launches for one repository race otherwise, and a + // reader catching the truncated moment gets unparseable settings. + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, memorySettingsJson(anchorMemoryDir(anchorDir, cwd))); + fs.renameSync(tmp, file); + // Single-quoted: the slug is alphanumerics and dashes, but the home + // directory in front of it is not (`/Users/John Doe`). Single quotes are + // what the CLAUDE_CONFIG_DIR prefix beside this already uses, and they + // survive every terminal — including the two that embed the command in an + // AppleScript double-quoted string without escaping it. + return ` --settings '${file.replace(/'/g, "'\\''")}'`; + } catch (err) { + console.error('[memory-dir] could not write the settings file:', err); + return ''; + } +}; + +/** + * The same rule as a shell function, for the generated `accounts.sh`. + * + * A second implementation of one rule, which is a defect generator unless the + * two are pinned together — `memory-dir.test.ts` runs this function under zsh + * and asserts it agrees with `anchorMemoryDir` for a table of paths, including + * a real repository, a subdirectory, a linked worktree and a non-repository. + * + * Inline JSON is safe here: the dispatcher runs in a plain shell with no + * AppleScript layer in between. + */ +export const memoryShellHelper = (anchorDir: string): string => + [ + '# --- shared auto-memory (Settings > Accounts > Sharing) ---', + '# Echoes the --settings payload that points this launch at the anchor', + "# account's memory for the current project: the git common dir's parent,", + '# or the working directory outside a repository.', + '_codev_memory_settings() {', + ' local root slug', + // `env -u`, not `VAR=`: an EMPTY GIT_DIR is not an unset one — git fails + // outright on it and both sides would silently take the $PWD fallback. + ' root=$(env -u GIT_DIR -u GIT_COMMON_DIR -u GIT_CEILING_DIRECTORIES \\', + ' -u GIT_DISCOVERY_ACROSS_FILESYSTEM \\', + ' git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)', + ' if [ -n "$root" ]; then', + ' root="${root%/*}"', + ' else', + // `pwd -P`, not `$PWD`: `cd` through a symlink leaves $PWD logical + // (`/tmp`), while Claude Code files the session under the physical path + // (`/private/tmp`). + // + // The `x` sentinel keeps a directory name that ENDS in a newline: `$(…)` + // strips every trailing newline, so a bare `$(pwd -P)` would lose it while + // `process.cwd()` on the TypeScript side keeps it. Strip the sentinel, + // then exactly one newline — pwd's own line terminator. + ' root=$(pwd -P; printf x)', + ' root="${root%x}"', + ' root="${root%$\'\\n\'}"', + ' fi', + // `tr` first: a newline would otherwise survive as a line separator (perl + // is line-based here) and land raw inside the JSON string. + // + // Then perl, not sed, because the replacement count has to be UTF-16 code + // UNITS to match Claude Code — one dash for a BMP character, two for an + // astral one. `sed` counts characters and got `😀` wrong. perl ships with + // macOS, which is the only platform CodeV runs on. + " slug=$(printf '%s' \"$root\" | tr '\\n' '-' |", + ' perl -CSD -pe \'s/([^a-zA-Z0-9])/"-" x (ord($1) > 0xFFFF ? 2 : 1)/ge\')', + ` printf '{"autoMemoryDirectory":"%s/projects/%s/memory"}' ${JSON.stringify(anchorDir)} "$slug"`, + '}', + ].join('\n'); diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 856d981..27ee30c 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -8,6 +8,9 @@ interface CodevAccountInfo { dir: string; isAnchor: boolean; // the anchor ~/.claude account (not the dispatcher default) isCurrentDefault: boolean; // what bare `claude` resolves to + // Launch this account with its auto-memory redirected to the anchor's, per + // project. Always false/absent on the anchor itself. + shareMemoryWithAnchor?: boolean; email?: string; org?: string; loggedIn?: boolean; @@ -62,6 +65,10 @@ interface IElectronAPI { changed?: boolean; path?: string; }>; + setAccountShareMemory: ( + label: string, + on: boolean, + ) => Promise<{ ok: boolean; error?: string }>; getAccountShareStatus: (label: string) => Promise<{ ok: boolean; error?: string; diff --git a/src/main.ts b/src/main.ts index b7d35e7..922221a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -901,6 +901,19 @@ ipcMain.handle('accounts-set-default', (_event, label: string) => { } }); +ipcMain.handle( + 'accounts-share-memory', + (_event, label: string, on: boolean) => { + try { + accountManager.setShareMemory(label, on); + invalidateAccountsCache(); + return { ok: true }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } + }, +); + // --- cross-account sharing (Batch 3) — thin wrappers over share-manager --- ipcMain.handle('accounts-share-status', (_event, label: string) => { try { diff --git a/src/popup.tsx b/src/popup.tsx index 9d3fe41..d871811 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -100,6 +100,7 @@ const PopupDefaultExample = ({ dir: string; isAnchor: boolean; isCurrentDefault: boolean; + shareMemoryWithAnchor?: boolean; email?: string; loggedIn?: boolean; }; @@ -265,6 +266,23 @@ const PopupDefaultExample = ({ }); }; + const setShareMemory = (label: string, on: boolean) => { + runAccountOp(async () => { + const r = await window.electronAPI.setAccountShareMemory(label, on); + if (r.ok) { + setAccountsNotice( + on + ? `"${label}" will use the anchor's memory for each project — applies to sessions started from now on.` + : `"${label}" is back to its own memory. Anything already written to the anchor's stays there.`, + ); + await refreshAccounts(); + } else { + setAccountsNotice(''); + setAccountsError(r.error || 'Failed to change memory sharing'); + } + }); + }; + // Guards rapid toggling between accounts: a stale response for a // previously-requested label must not render under the current panel. const shareReqRef = useRef(null); @@ -1108,6 +1126,46 @@ const PopupDefaultExample = ({ Share from the anchor (~/.claude) — Link stays in sync; Copy is an independent fork + {!shareStatus && (
Loading… diff --git a/src/preload.ts b/src/preload.ts index 7fdc8d2..dc34333 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('accounts-set-default', label), setAccountsShellHook: (action: 'install' | 'uninstall') => ipcRenderer.invoke('accounts-shell-hook', action), + setAccountShareMemory: (label: string, on: boolean) => + ipcRenderer.invoke('accounts-share-memory', label, on), getAccountShareStatus: (label: string) => ipcRenderer.invoke('accounts-share-status', label), shareAccountItem: (label: string, item: string, mode: 'link' | 'copy') =>