From d0a8e4a35c8f976885aa3000111a30a77dadef3a Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 18 Sep 2026 20:34:03 +0800 Subject: [PATCH 1/5] feat(accounts): share the anchor's auto-memory, per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. `autoMemoryDirectory` relocates it and `--settings` is a settings scope, so a launcher can point a non-anchor account at the anchor's copy without touching either settings.json and without writing into the repository. The directory is computed per launch — it depends on the repository the session starts in: the git common dir's parent, so a repo, its subdirectories and all its linked worktrees resolve to one memory; outside a repo, the directory itself. All three launch paths carry it, not just the shell one. Covering only the dispatcher would have left CodeV's own resume and new-session buttons writing to the old place, which is most of how the app is used. CodeV's two paths pass a settings FILE rather than inline JSON: they embed the command in an AppleScript string and Ghostty's `initial input` and cmux's `--command` interpolate it unescaped, so a JSON payload's quotes would break those launches. The dispatcher has no AppleScript layer and uses inline JSON. The rule lives once (memory-dir.ts); the shell function is generated from the same module and a test runs it under zsh to assert the two agree for a repo, a subdirectory, a worktree and a plain directory. Off by default, per account, and never offered on the anchor. Session transcripts are NOT shared — Claude Code still writes them under the launching account, which keeps per-account session attribution working. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++ docs/multi-account-support-design.md | 60 ++++++++++++- package.json | 2 +- src/accounts.ts | 13 +++ src/claude-session-utility.ts | 71 +++++++++++---- src/cli/account-manager.test.ts | 58 +++++++++++- src/cli/account-manager.ts | 60 +++++++++++-- src/cli/codev-account.ts | 19 ++++ src/cli/memory-dir.test.ts | 104 ++++++++++++++++++++++ src/cli/memory-dir.ts | 126 +++++++++++++++++++++++++++ src/electron-api.d.ts | 7 ++ src/main.ts | 13 +++ src/popup.tsx | 52 +++++++++++ src/preload.ts | 2 + 14 files changed, 566 insertions(+), 28 deletions(-) create mode 100644 src/cli/memory-dir.test.ts create mode 100644 src/cli/memory-dir.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6288917..35f945a 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). 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..baba23c 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; +- 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..a5063ec 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,7 @@ export const getAccounts = (): CodevAccount[] => { configDirEnv, identityFile, isAnchor, + shareMemoryWithAnchor: !!a.shareMemoryWithAnchor, email: a.email, org: a.org, subscription: a.subscription, @@ -171,6 +176,14 @@ 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 => + 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..c0a9b0f 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,58 @@ 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('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 +196,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..1de9ee9 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 + ? '--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,16 @@ 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. + const anchor = accounts.find((a) => a.isAnchor); + if (anchor && accounts.some((a) => a.shareMemoryWithAnchor && a.configDirEnv)) { + L.push(memoryShellHelper(expandHome(anchor.dir))); + 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 +302,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 +382,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 +633,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..503cf73 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,22 @@ function main(): number { return 0; } + case 'share-memory': { + const [name, state] = rest.filter((a) => !a.startsWith('-')); + if (!name || (state !== 'on' && state !== 'off')) { + console.error('share-memory: and on|off are required'); + 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..99a17f5 --- /dev/null +++ b/src/cli/memory-dir.test.ts @@ -0,0 +1,104 @@ +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('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..1be9992 --- /dev/null +++ b/src/cli/memory-dir.ts @@ -0,0 +1,126 @@ +/** + * 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'; + +/** The directory Claude Code keys this project's auto-memory on. */ +export const memoryProjectRoot = (cwd: string): string => { + const start = 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'] }, + ).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. */ +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 }); + fs.writeFileSync(file, memorySettingsJson(anchorMemoryDir(anchorDir, cwd))); + return ` --settings ${file}`; + } 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', + ' root=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)', + ' if [ -n "$root" ]; then root="${root%/*}"; else root="$PWD"; fi', + " slug=$(printf '%s' \"$root\" | sed 's/[^a-zA-Z0-9]/-/g')", + ` 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..f65c149 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,40 @@ 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') => From 35d409dd5b2914af2a5aea333f4f4af1d6f9d7e7 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 18 Sep 2026 21:49:01 +0800 Subject: [PATCH 2/5] fix(accounts): CI needs zsh; harden the memory redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (CodeRabbit + cubic, 18 threads, ~12 distinct). CI was red and that was mine: both new tests shell out to zsh, which the Ubuntu runner does not have. zsh is what sources accounts.sh in real use, so the workflow installs it rather than the tests dropping to bash. Git discovery variables are now unset for both implementations, so an inherited GIT_DIR cannot key the memory to another repository. Both reviewers suggested the `VAR=` form; taken literally it BREAKS the lookup — an empty GIT_DIR makes git fail outright and sends every lookup down the $PWD fallback. `env -u` on the shell side, a deleted key on the TypeScript side, and a test that pins it. Also: the settings path is single-quoted, so a home directory with a space no longer truncates the launch; the settings file is written temp-then-renamed, so two launches for one repository cannot read a truncated one; the registry flag is read as strictly `true`, so a hand-written "false" cannot switch an account into the anchor's memory; the generated 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, so a relative registry dir cannot put memory under the working directory; the shell slug translates newlines before sed; the checkbox disables while the IPC is in flight; the help text is a span, not a div inside one; and `share-memory` says which half of its arguments was wrong. Declined, with the reasons in the code: the slug collides (`/tmp/a-b` vs `/tmp/a/b`) and counts UTF-16 code units. Both are Claude Code's own rule, and the point of this module is to name the directory Claude Code will actually use. The UTF-16 half does leave a real divergence outside the Basic Multilingual Plane, measured and documented. 208 tests (3 new: newline and non-ASCII paths, inherited git variables, a registry with no anchor). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test_ci.yaml | 5 +++ src/accounts.ts | 12 ++++-- src/cli/account-manager.test.ts | 10 +++++ src/cli/account-manager.ts | 17 ++++++-- src/cli/codev-account.ts | 10 ++++- src/cli/memory-dir.test.ts | 36 +++++++++++++++++ src/cli/memory-dir.ts | 70 ++++++++++++++++++++++++++++++--- src/popup.tsx | 7 +++- 8 files changed, 150 insertions(+), 17 deletions(-) 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/src/accounts.ts b/src/accounts.ts index a5063ec..4af5c0c 100644 --- a/src/accounts.ts +++ b/src/accounts.ts @@ -131,7 +131,9 @@ export const getAccounts = (): CodevAccount[] => { configDirEnv, identityFile, isAnchor, - shareMemoryWithAnchor: !!a.shareMemoryWithAnchor, + // 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, @@ -181,8 +183,12 @@ export const getAccountByLabel = (label: string | undefined): CodevAccount => { * lives. Falls back to the literal path when no registry names an anchor. */ export const getAnchorDir = (): string => - getAccounts().find((a) => a.isAnchor)?.dir ?? - path.join(os.homedir(), '.claude'); + // 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/cli/account-manager.test.ts b/src/cli/account-manager.test.ts index c0a9b0f..46f1ad2 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -80,6 +80,16 @@ describe('generateAccountsSh — shared auto-memory', () => { 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-')), diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 1de9ee9..0b02a14 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -222,7 +222,7 @@ function launchCmd( // 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 + forSession && account.shareMemoryWithAnchor === true ? '--settings "$(_codev_memory_settings)" ' : ''; return `env CLAUDE_CONFIG_DIR="${toShellPath(expandHome(account.configDirEnv))}" claude ${memory}`; @@ -283,9 +283,18 @@ export function generateAccountsSh(reg: Registry): string { 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. - const anchor = accounts.find((a) => a.isAnchor); - if (anchor && accounts.some((a) => a.shareMemoryWithAnchor && a.configDirEnv)) { - L.push(memoryShellHelper(expandHome(anchor.dir))); + // 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 "$@") ---'); diff --git a/src/cli/codev-account.ts b/src/cli/codev-account.ts index 503cf73..3bf6ff0 100644 --- a/src/cli/codev-account.ts +++ b/src/cli/codev-account.ts @@ -282,8 +282,14 @@ function main(): number { case 'share-memory': { const [name, state] = rest.filter((a) => !a.startsWith('-')); - if (!name || (state !== 'on' && state !== 'off')) { - console.error('share-memory: and on|off are required'); + 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'); diff --git a/src/cli/memory-dir.test.ts b/src/cli/memory-dir.test.ts index 99a17f5..1e835ae 100644 --- a/src/cli/memory-dir.test.ts +++ b/src/cli/memory-dir.test.ts @@ -95,6 +95,42 @@ describe('the shell helper agrees with the TypeScript rule', () => { } }); + it('agrees on a path with a newline and on a non-ASCII path', () => { + // Both are the shell side's failure modes: `sed` treats a newline as a + // line separator, and a non-ASCII byte must collapse to one dash. + for (const name of ['line\nbreak', 'ünïcøde']) { + const dir = path.join(base, name); + fs.mkdirSync(dir, { recursive: true }); + expect(runHelper(dir), `cwd=${JSON.stringify(name)}`).toBe( + memorySettingsJson(anchorMemoryDir(ANCHOR, dir)), + ); + } + }); + + 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( diff --git a/src/cli/memory-dir.ts b/src/cli/memory-dir.ts index 1be9992..02c1729 100644 --- a/src/cli/memory-dir.ts +++ b/src/cli/memory-dir.ts @@ -28,6 +28,24 @@ import { execFileSync } from 'child_process'; import * as os from 'os'; import * as path from 'path'; +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 => { const start = path.resolve(cwd); @@ -35,7 +53,15 @@ export const memoryProjectRoot = (cwd: string): string => { const out = execFileSync( 'git', ['-C', start, 'rev-parse', '--path-format=absolute', '--git-common-dir'], - { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }, + { + 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. @@ -46,7 +72,24 @@ export const memoryProjectRoot = (cwd: string): string => { return start; }; -/** Absolute path → the `projects/` directory name Claude Code uses for it. */ +/** + * 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**, since Claude Code is JavaScript. The shell + * twin uses `sed`, which counts characters, so the two differ outside the + * Basic Multilingual Plane: measured 2026-09-18, `/x/😀/y` gives `-x----y` + * here and `-x---y` in the shell (`ü` and every BMP character agree, and are + * covered by a test). A repository path containing an emoji would get one + * memory directory from `claude ` and another from CodeV's own launch. + * Known, and not worth a `perl` dependency inside the generated + * `accounts.sh`; revisit if such a path ever turns up. + */ export const memorySlug = (root: string): string => root.replace(/[^a-zA-Z0-9]/g, '-'); @@ -91,8 +134,17 @@ export const memorySettingsArg = (anchorDir: string, cwd: string): string => { const fs = require('fs'); const file = memorySettingsPath(cwd); fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, memorySettingsJson(anchorMemoryDir(anchorDir, cwd))); - return ` --settings ${file}`; + // 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 ''; @@ -118,9 +170,15 @@ export const memoryShellHelper = (anchorDir: string): string => '# or the working directory outside a repository.', '_codev_memory_settings() {', ' local root slug', - ' root=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)', + // `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 root="$PWD"; fi', - " slug=$(printf '%s' \"$root\" | sed 's/[^a-zA-Z0-9]/-/g')", + // `tr` first: a newline in the path would otherwise survive `sed` as a + // line separator and land raw inside the JSON string. + " slug=$(printf '%s' \"$root\" | tr '\\n' '-' | sed 's/[^a-zA-Z0-9]/-/g')", ` printf '{"autoMemoryDirectory":"%s/projects/%s/memory"}' ${JSON.stringify(anchorDir)} "$slug"`, '}', ].join('\n'); diff --git a/src/popup.tsx b/src/popup.tsx index f65c149..c7f8d1f 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -1139,6 +1139,7 @@ const PopupDefaultExample = ({ setShareMemory(a.label, e.target.checked) } @@ -1146,7 +1147,9 @@ const PopupDefaultExample = ({ /> Memory: share with the anchor, per project -
+ Auto-memory is per project, and each account keeps its own. This points "{a.label}" at the anchor's copy for whichever repository a session starts in, so one @@ -1157,7 +1160,7 @@ const PopupDefaultExample = ({ IDE extension — keeps using this account's own memory for that session; nothing breaks, it just does not see the shared one. -
+
{!shareStatus && ( From 9ed9c4072eea49dc5855af52d8c170dc5c50546c Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 18 Sep 2026 22:28:07 +0800 Subject: [PATCH 3/5] fix(accounts): the shell slug counts UTF-16 units, like Claude Code Review round 2 (CodeRabbit, 3 threads). The astral-plane divergence is closed rather than documented. Measured it first: a real session run in a directory whose path contains an emoji makes Claude Code name the projects/ directory with FOUR dashes for the surrogate pair, so it counts UTF-16 code units and the TypeScript side was the correct one. The shell twin normalised with sed, which counts characters and emitted three. The helper now normalises with perl, which can count code units; perl ships with macOS, the only platform this app runs on, and the call is per launch, not per shell startup. The agreement test gains an astral path beside the newline and BMP ones. Also: the one prettier violation in account-manager.ts, in a file that has no others. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/account-manager.ts | 4 +++- src/cli/memory-dir.test.ts | 10 ++++++---- src/cli/memory-dir.ts | 26 +++++++++++++++----------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 0b02a14..a94b61b 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -286,7 +286,9 @@ export function generateAccountsSh(reg: Registry): string { // 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)) { + if ( + accounts.some((a) => a.shareMemoryWithAnchor === true && a.configDirEnv) + ) { const anchor = accounts.find((a) => a.isAnchor); L.push( memoryShellHelper( diff --git a/src/cli/memory-dir.test.ts b/src/cli/memory-dir.test.ts index 1e835ae..4c35799 100644 --- a/src/cli/memory-dir.test.ts +++ b/src/cli/memory-dir.test.ts @@ -95,10 +95,12 @@ describe('the shell helper agrees with the TypeScript rule', () => { } }); - it('agrees on a path with a newline and on a non-ASCII path', () => { - // Both are the shell side's failure modes: `sed` treats a newline as a - // line separator, and a non-ASCII byte must collapse to one dash. - for (const name of ['line\nbreak', 'ünïcøde']) { + it('agrees on newline, BMP and astral paths', () => { + // The shell side's three 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. + for (const name of ['line\nbreak', 'ü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( diff --git a/src/cli/memory-dir.ts b/src/cli/memory-dir.ts index 02c1729..9349ec9 100644 --- a/src/cli/memory-dir.ts +++ b/src/cli/memory-dir.ts @@ -81,14 +81,12 @@ export const memoryProjectRoot = (cwd: string): string => { * - **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**, since Claude Code is JavaScript. The shell - * twin uses `sed`, which counts characters, so the two differ outside the - * Basic Multilingual Plane: measured 2026-09-18, `/x/😀/y` gives `-x----y` - * here and `-x---y` in the shell (`ü` and every BMP character agree, and are - * covered by a test). A repository path containing an emoji would get one - * memory directory from `claude ` and another from CodeV's own launch. - * Known, and not worth a `perl` dependency inside the generated - * `accounts.sh`; revisit if such a path ever turns up. + * - **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, '-'); @@ -176,9 +174,15 @@ export const memoryShellHelper = (anchorDir: string): string => ' -u GIT_DISCOVERY_ACROSS_FILESYSTEM \\', ' git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)', ' if [ -n "$root" ]; then root="${root%/*}"; else root="$PWD"; fi', - // `tr` first: a newline in the path would otherwise survive `sed` as a - // line separator and land raw inside the JSON string. - " slug=$(printf '%s' \"$root\" | tr '\\n' '-' | sed 's/[^a-zA-Z0-9]/-/g')", + // `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'); From f87d26d3437cf0d883947973fb9b12c4f4aa0e87 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 19 Sep 2026 02:06:48 +0800 Subject: [PATCH 4/5] fix(accounts): resolve symlinks for the non-repo memory key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by hand-testing: `cd /tmp && _codev_memory_settings` answered `-tmp`, but a real session whose shell sits in /tmp is filed by Claude Code under `-private-tmp`. It keys on process.cwd(), which resolves symlinks, while $PWD stays logical after a cd through one. The consequence was silent and defeated the feature exactly there: the redirect named a directory the anchor never uses, so a shared account in such a directory got an empty memory rather than the shared one. Only the fallback was affected — the git branch was always immune, since git rev-parse reports a resolved path. Both sides now take the physical path: `pwd -P` in the helper, realpathSync in TypeScript. The regression test drives it the way a person does, `zsh -c cd `, because execFileSync cwd cannot reproduce a logical $PWD. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/multi-account-support-design.md | 2 +- src/cli/memory-dir.test.ts | 24 ++++++++++++++++++++++++ src/cli/memory-dir.ts | 23 +++++++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f945a..31b5a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 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). Off by default, and the anchor is never offered it: its memory is the one the others share +- 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 diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index baba23c..c5cd33f 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -277,7 +277,7 @@ the session starts in. 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; +- 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. diff --git a/src/cli/memory-dir.test.ts b/src/cli/memory-dir.test.ts index 4c35799..f84be4d 100644 --- a/src/cli/memory-dir.test.ts +++ b/src/cli/memory-dir.test.ts @@ -109,6 +109,30 @@ describe('the shell helper agrees with the TypeScript rule', () => { } }); + 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 diff --git a/src/cli/memory-dir.ts b/src/cli/memory-dir.ts index 9349ec9..e8c836f 100644 --- a/src/cli/memory-dir.ts +++ b/src/cli/memory-dir.ts @@ -28,6 +28,16 @@ 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', @@ -48,7 +58,13 @@ const envWithoutGitDiscovery = (): NodeJS.ProcessEnv => { /** The directory Claude Code keys this project's auto-memory on. */ export const memoryProjectRoot = (cwd: string): string => { - const start = path.resolve(cwd); + // 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', @@ -173,7 +189,10 @@ export const memoryShellHelper = (anchorDir: string): string => ' 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 root="$PWD"; fi', + // `pwd -P`, not `$PWD`: `cd` through a symlink leaves $PWD logical + // (`/tmp`), while Claude Code files the session under the physical path + // (`/private/tmp`). + ' if [ -n "$root" ]; then root="${root%/*}"; else root="$(pwd -P)"; fi', // `tr` first: a newline would otherwise survive as a line separator (perl // is line-based here) and land raw inside the JSON string. // From 8a67394d5e57915f3250d6bd64ead19a2ee1c402 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 19 Sep 2026 02:39:28 +0800 Subject: [PATCH 5/5] fix(accounts): readable label; keep a trailing newline in the slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3, plus one bug grimmer hit on the built app. The Sharing panel's memory checkbox label set no colour, so it inherited the platform default — black text on the dark panel, which is what he saw. Every other line in that panel sets its own colour; this one now uses THEME.text.primary like the rest. cubic's P2 is real and measured: a directory name that ENDS in a newline loses it to `$(pwd -P)`, because command substitution strips every trailing newline, while `process.cwd()` on the TypeScript side keeps it. Same shape as the astral-plane divergence last round, and the same verdict — Claude Code is JavaScript, so TypeScript is the correct side and the shell twin was wrong. `pwd -P` now runs behind an `x` sentinel, and the agreement test grows a trailing-newline path; reverting the sentinel turns it red. Also CodeRabbit's prettier error on the generated helper's slug line. Taken for the same reason as last round's: this file has exactly one prettier finding in it, so formatting the line leaves it clean. 209 tests. The generated helper still parses and runs identically under both bash and zsh. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/memory-dir.test.ts | 15 +++++++++++---- src/cli/memory-dir.ts | 15 +++++++++++++-- src/popup.tsx | 3 +++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/cli/memory-dir.test.ts b/src/cli/memory-dir.test.ts index f84be4d..27835f7 100644 --- a/src/cli/memory-dir.test.ts +++ b/src/cli/memory-dir.test.ts @@ -95,12 +95,19 @@ describe('the shell helper agrees with the TypeScript rule', () => { } }); - it('agrees on newline, BMP and astral paths', () => { - // The shell side's three failure modes. The astral one is why the helper + 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. - for (const name of ['line\nbreak', 'ünïcøde', 'emoji-\u{1F600}-dir']) { + // 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( diff --git a/src/cli/memory-dir.ts b/src/cli/memory-dir.ts index e8c836f..3821e86 100644 --- a/src/cli/memory-dir.ts +++ b/src/cli/memory-dir.ts @@ -189,10 +189,21 @@ export const memoryShellHelper = (anchorDir: string): string => ' 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`). - ' if [ -n "$root" ]; then root="${root%/*}"; else root="$(pwd -P)"; fi', + // + // 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. // @@ -200,7 +211,7 @@ export const memoryShellHelper = (anchorDir: string): string => // 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\' \'-\' |', + " 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"`, '}', diff --git a/src/popup.tsx b/src/popup.tsx index c7f8d1f..d871811 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -1132,6 +1132,9 @@ const PopupDefaultExample = ({ alignItems: 'flex-start', gap: '6px', fontSize: '11px', + // The panel sets no colour, so an unstyled label inherits + // the platform default — black on this dark background. + color: THEME.text.primary, padding: '2px 0 8px', cursor: 'pointer', }}