Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test_ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <name> 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 <name>` 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:`
Expand Down
60 changes: 59 additions & 1 deletion docs/multi-account-support-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>/memory/`, `MEMORY.md`) | Config dir | No by default | Per-account, or advanced: symlink individual `memory/` subdirs |
| Auto-memory (`projects/<path>/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/<name>` entries
Expand Down Expand Up @@ -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 <keys>`: `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 `<config dir>/projects/<slug>/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
Comment thread
grimmerk marked this conversation as resolved.
`-`, 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 <file>` written to `~/.config/codev/memory-settings/<slug>.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/<slug>/`, confirmed by probe — the slug
directory is created for the transcript with no `memory/` inside it. That is
what keeps CodeV's per-account session attribution (§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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 19 additions & 0 deletions src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,6 +44,7 @@ interface RawAccount {
configDirEnv?: string | null;
identityFile?: string;
isAnchor?: boolean;
shareMemoryWithAnchor?: boolean;
email?: string;
org?: string;
subscription?: string;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

/**
Expand Down
71 changes: 55 additions & 16 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1289,27 +1291,53 @@ const detectActiveSessionsLegacy = async (
}
};

/**
* ` --settings <file>` 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)
Comment thread
grimmerk marked this conversation as resolved.
: '';

/**
* 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);
Expand All @@ -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.
Expand All @@ -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}`;
};

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -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);
}
};
Expand All @@ -2645,7 +2684,7 @@ export const openSessionInCmux = async (
accountLabel?: string,
): Promise<void> => {
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) {
Expand Down Expand Up @@ -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');
}
};
Expand All @@ -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;
Expand Down
Loading
Loading