From de40a345c7b4e088ea1588b4f3b57d10265e2fd2 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Sun, 13 Sep 2026 08:29:31 +0530 Subject: [PATCH 1/7] feat: per-session Claude account profiles (personal/dharani/office2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MonoCode always resolved and spawned a single `claude` binary with whatever CLAUDE_CONFIG_DIR the app process already had — it had no concept of switching accounts, unlike the claudo/claudd/claudz zshrc functions used outside the app. - harness_spawn now takes an optional `env` map, applied last so it wins over prepare_child's defaults (native + WSL-bridge branches). - spawnChild threads an optional env through to harness_spawn. - New claudeProfiles.ts: the 3 accounts (personal/dharani/office2), each mapped to their CLAUDE_CONFIG_DIR, plus a folder-based allow-list — yuko/ projects may only use personal or office2, never dharani; everything else (e.g. personal/) allows all three. - claudeCatalog.ts exposes profile as a normal model "setting" (an Account picker next to Reasoning/Fast/Context in the composer), for both the static fallback catalog and live list_models discovery. - claude.ts resolves the active profile's env at spawn time via resolveClaudeProfileEnv, folding `profile` into the settings key so changing it respawns the Claude process, and surfaces a status event when a disallowed pick gets corrected. cargo fmt/clippy/test and tsc/vitest all pass. --- src-tauri/src/harness.rs | 9 +++ src/lib/harness/child.ts | 2 + src/lib/harness/claude.ts | 12 ++++ src/lib/harness/claudeCatalog.ts | 28 +++++++--- src/lib/harness/claudeLive.test.ts | 4 ++ src/lib/harness/claudeProfiles.test.ts | 64 ++++++++++++++++++++++ src/lib/harness/claudeProfiles.ts | 76 ++++++++++++++++++++++++++ src/lib/harness/claudeProtocol.test.ts | 4 +- src/lib/harness/claudeProtocol.ts | 2 + 9 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 src/lib/harness/claudeProfiles.test.ts create mode 100644 src/lib/harness/claudeProfiles.ts diff --git a/src-tauri/src/harness.rs b/src-tauri/src/harness.rs index b61fa28e..49b7fb82 100644 --- a/src-tauri/src/harness.rs +++ b/src-tauri/src/harness.rs @@ -326,6 +326,7 @@ pub fn harness_spawn( command: String, args: Vec, cwd: String, + env: Option>, ) -> Result { let (epoch, kill_all, prev) = host.begin_spawn(&session_id); if let Some(prev) = prev { @@ -347,6 +348,14 @@ pub fn harness_spawn( .stdout(Stdio::piped()) .stderr(Stdio::piped()); prepare_child(&mut cmd, &command); + // Caller-supplied overrides (e.g. a per-session CLAUDE_CONFIG_DIR for + // profile switching) — applied last so they win over `prepare_child`'s + // defaults. + if let Some(env) = &env { + for (key, value) in env { + cmd.env(key, value); + } + } let mut child = spawn_managed(&mut cmd).map_err(|e| format!("Failed to start {command}: {e}"))?; diff --git a/src/lib/harness/child.ts b/src/lib/harness/child.ts index 240a5525..92e076a4 100644 --- a/src/lib/harness/child.ts +++ b/src/lib/harness/child.ts @@ -238,6 +238,7 @@ export async function spawnChild( command: string, args: string[], cwd: string, + env?: Record, ): Promise { livePid.delete(sessionId); pendingExit.delete(sessionId); @@ -246,6 +247,7 @@ export async function spawnChild( command, args, cwd, + env, }); if (typeof pid !== "number" || pid <= 0) return; livePid.set(sessionId, pid); diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 0ac23949..41d55c1d 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -1,3 +1,4 @@ +import { homeDir } from "../fs"; import { nativeModelId } from "../models"; import type { RuntimeMode } from "../session"; import { loadClaudeHooks } from "../settings"; @@ -9,6 +10,7 @@ import { watchChild, writeChild, } from "./child"; +import { resolveClaudeProfileEnv } from "./claudeProfiles"; import { askUserQuestionAllowInput, asRecord, @@ -419,11 +421,20 @@ async function ensureLive(input: HarnessSessionInput): Promise { }, ); + const home = await homeDir(input.cwd); + const { env, warning } = resolveClaudeProfileEnv( + input.modelSettings?.profile, + input.cwd, + home, + ); + if (warning) live.onEvent({ type: "status", text: warning }); + await spawnChild( input.sessionId, path, buildClaudeSpawnArgs(launch), input.cwd, + env, ); liveByThread.set(input.sessionId, live); @@ -1354,6 +1365,7 @@ function settingsKeyFor(input: HarnessSessionInput): string { fast: input.modelSettings?.fast, thinking: input.modelSettings?.thinking, context: input.modelSettings?.context, + profile: input.modelSettings?.profile, runtimeMode: input.runtimeMode, hooks: loadClaudeHooks(), }); diff --git a/src/lib/harness/claudeCatalog.ts b/src/lib/harness/claudeCatalog.ts index 2103da40..0a4696a3 100644 --- a/src/lib/harness/claudeCatalog.ts +++ b/src/lib/harness/claudeCatalog.ts @@ -4,6 +4,7 @@ import { type AgentModel, type ModelSetting, } from "../models"; +import { CLAUDE_PROFILE_DEFAULT, CLAUDE_PROFILE_OPTIONS } from "./claudeProfiles"; import { execChild, killChild, @@ -101,6 +102,15 @@ const THINKING: ModelSetting = { ], }; +/** Which account (CLAUDE_CONFIG_DIR) a session runs under. See claudeProfiles.ts. */ +const PROFILE: ModelSetting = { + id: "profile", + label: "Account", + kind: "select", + value: CLAUDE_PROFILE_DEFAULT, + options: CLAUDE_PROFILE_OPTIONS, +}; + function contextWindow(defaultValue: "200k" | "1m"): ModelSetting { return { id: "context", @@ -121,49 +131,49 @@ export const CLAUDE_MODEL_CATALOG: AgentModel[] = [ harness: "claude", name: "Claude Fable 5", nativeId: "claude-fable-5", - settings: [EFFORT_WITH_XHIGH, contextWindow("1m")], + settings: [EFFORT_WITH_XHIGH, contextWindow("1m"), PROFILE], }, { id: "claude:opus-5", harness: "claude", name: "Claude Opus 5", nativeId: "claude-opus-5", - settings: [EFFORT_WITH_XHIGH, FAST_MODE, contextWindow("1m")], + settings: [EFFORT_WITH_XHIGH, FAST_MODE, contextWindow("1m"), PROFILE], }, { id: "claude:sonnet-5", harness: "claude", name: "Claude Sonnet 5", nativeId: "claude-sonnet-5", - settings: [EFFORT_WITH_XHIGH, contextWindow("200k")], + settings: [EFFORT_WITH_XHIGH, contextWindow("200k"), PROFILE], }, { id: "claude:opus-4.8", harness: "claude", name: "Claude Opus 4.8", nativeId: "claude-opus-4-8", - settings: [EFFORT_WITH_XHIGH, FAST_MODE], + settings: [EFFORT_WITH_XHIGH, FAST_MODE, PROFILE], }, { id: "claude:opus-4.7", harness: "claude", name: "Claude Opus 4.7", nativeId: "claude-opus-4-7", - settings: [EFFORT_OPUS_47, FAST_MODE], + settings: [EFFORT_OPUS_47, FAST_MODE, PROFILE], }, { id: "claude:opus-4.6", harness: "claude", name: "Claude Opus 4.6", nativeId: "claude-opus-4-6", - settings: [EFFORT_LOW_TO_ULTRATHINK, FAST_MODE, contextWindow("1m")], + settings: [EFFORT_LOW_TO_ULTRATHINK, FAST_MODE, contextWindow("1m"), PROFILE], }, { id: "claude:sonnet-4.6", harness: "claude", name: "Claude Sonnet 4.6", nativeId: "claude-sonnet-4-6", - settings: [EFFORT_LOW_TO_ULTRATHINK, contextWindow("200k")], + settings: [EFFORT_LOW_TO_ULTRATHINK, contextWindow("200k"), PROFILE], }, { id: "claude:opus-4.5", @@ -184,6 +194,7 @@ export const CLAUDE_MODEL_CATALOG: AgentModel[] = [ ], }, FAST_MODE, + PROFILE, ], }, { @@ -191,7 +202,7 @@ export const CLAUDE_MODEL_CATALOG: AgentModel[] = [ harness: "claude", name: "Claude Haiku 4.5", nativeId: "claude-haiku-4-5", - settings: [THINKING], + settings: [THINKING, PROFILE], }, ]; @@ -383,6 +394,7 @@ function settingsFromListRow( } if (rec.supportsFastMode === true) settings.push(FAST_MODE); if (context1m) settings.push(contextWindow("1m")); + settings.push(PROFILE); return settings; } diff --git a/src/lib/harness/claudeLive.test.ts b/src/lib/harness/claudeLive.test.ts index 6fc797ab..29dac40e 100644 --- a/src/lib/harness/claudeLive.test.ts +++ b/src/lib/harness/claudeLive.test.ts @@ -10,6 +10,10 @@ const writeChild = vi.fn(async (_id: string, line: string) => { sent.push(line); }); +vi.mock("../fs", () => ({ + homeDir: async () => "/Users/fake", +})); + vi.mock("./child", () => ({ resolveClaudeBinary: async () => ({ path: "/fake/claude" }), spawnChild: async (_id: string, _path: string, args: string[]) => { diff --git a/src/lib/harness/claudeProfiles.test.ts b/src/lib/harness/claudeProfiles.test.ts new file mode 100644 index 00000000..1574e639 --- /dev/null +++ b/src/lib/harness/claudeProfiles.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + claudeProfileOptionsFor, + resolveClaudeProfileEnv, +} from "./claudeProfiles"; + +describe("claudeProfileOptionsFor", () => { + it("allows all three profiles outside yuko", () => { + expect( + claudeProfileOptionsFor("/Users/cartrabbit/Documents/personal/poynt").map( + (option) => option.value, + ), + ).toEqual(["personal", "dharani", "office2"]); + }); + + it("restricts yuko projects to personal and office2", () => { + expect( + claudeProfileOptionsFor( + "/Users/cartrabbit/Documents/yuko/yuko-backend", + ).map((option) => option.value), + ).toEqual(["personal", "office2"]); + expect( + claudeProfileOptionsFor( + "/Users/cartrabbit/Documents/yuko/yuko-frontend", + ).map((option) => option.value), + ).toEqual(["personal", "office2"]); + }); +}); + +describe("resolveClaudeProfileEnv", () => { + const home = "/Users/cartrabbit"; + + it("points CLAUDE_CONFIG_DIR at the requested profile when allowed", () => { + const result = resolveClaudeProfileEnv( + "office2", + "/Users/cartrabbit/Documents/yuko/yuko-backend", + home, + ); + expect(result.profileId).toBe("office2"); + expect(result.env.CLAUDE_CONFIG_DIR).toBe("/Users/cartrabbit/.claude-office2"); + expect(result.warning).toBeUndefined(); + }); + + it("falls back and warns when the requested profile is disallowed for the folder", () => { + const result = resolveClaudeProfileEnv( + "dharani", + "/Users/cartrabbit/Documents/yuko/yuko-frontend", + home, + ); + expect(result.profileId).toBe("personal"); + expect(result.env.CLAUDE_CONFIG_DIR).toBe("/Users/cartrabbit/.claude-personal"); + expect(result.warning).toMatch(/not allowed|isn't allowed/); + }); + + it("defaults to personal when no profile was requested", () => { + const result = resolveClaudeProfileEnv( + undefined, + "/Users/cartrabbit/Documents/personal/poynt", + home, + ); + expect(result.profileId).toBe("personal"); + expect(result.warning).toBeUndefined(); + }); +}); diff --git a/src/lib/harness/claudeProfiles.ts b/src/lib/harness/claudeProfiles.ts new file mode 100644 index 00000000..a05797cf --- /dev/null +++ b/src/lib/harness/claudeProfiles.ts @@ -0,0 +1,76 @@ +import type { ModelSettingChoice } from "../models"; + +/** + * Per-account Claude Code identities, mirroring the `claudo`/`claudd`/`claudz` + * shell functions in ~/.zshrc (each sets CLAUDE_CONFIG_DIR to a different + * account's config dir). MonoCode has no notion of this on its own — it + * always resolves and spawns a single `claude` binary — so this is threaded + * through explicitly from the model-settings picker down to `harness_spawn`. + */ +export type ClaudeProfileId = "personal" | "dharani" | "office2"; + +export type ClaudeProfile = { + id: ClaudeProfileId; + label: string; + /** Relative to $HOME, matching the zshrc CLAUDE_CONFIG_DIR values. */ + configDirName: string; +}; + +export const CLAUDE_PROFILES: ClaudeProfile[] = [ + { id: "personal", label: "Personal", configDirName: ".claude-personal" }, + { id: "dharani", label: "Dharani", configDirName: ".claude-dharani" }, + { id: "office2", label: "Office2", configDirName: ".claude-office2" }, +]; + +export const CLAUDE_PROFILE_DEFAULT: ClaudeProfileId = "personal"; + +export const CLAUDE_PROFILE_OPTIONS: ModelSettingChoice[] = CLAUDE_PROFILES.map( + (profile) => ({ value: profile.id, label: profile.label }), +); + +/** + * Folder-based guardrail: which profiles a project's cwd is allowed to + * launch under, independent of what the picker has selected. Projects under + * `yuko` are client work that must never run under the `dharani` account; + * everything else (e.g. `personal`) is unrestricted. + */ +function allowedProfileIds(cwd: string): ClaudeProfileId[] { + const normalized = cwd.replace(/\\/g, "/"); + if (/\/yuko(?:[-_][a-z0-9]+)*(?:\/|$)/i.test(normalized)) { + return ["personal", "office2"]; + } + return ["personal", "dharani", "office2"]; +} + +export function claudeProfileOptionsFor(cwd: string): ModelSettingChoice[] { + const allowed = new Set(allowedProfileIds(cwd)); + return CLAUDE_PROFILE_OPTIONS.filter((option) => allowed.has(option.value as ClaudeProfileId)); +} + +/** + * Resolves the requested profile against the folder allow-list for `cwd` + * and returns the env to launch `claude` with. A disallowed request (e.g. + * "dharani" picked while cwd is under yuko/) is silently corrected to the + * folder's first allowed profile rather than launching under the wrong + * account — callers should surface `warning` to the user when present. + */ +export function resolveClaudeProfileEnv( + requestedId: string | undefined, + cwd: string, + home: string, +): { env: Record; profileId: ClaudeProfileId; warning?: string } { + const allowed = allowedProfileIds(cwd); + const requested = (requestedId ?? CLAUDE_PROFILE_DEFAULT) as ClaudeProfileId; + const profileId = allowed.includes(requested) ? requested : allowed[0]; + const profile = + CLAUDE_PROFILES.find((candidate) => candidate.id === profileId) ?? CLAUDE_PROFILES[0]; + const env = { CLAUDE_CONFIG_DIR: `${home.replace(/\/+$/, "")}/${profile.configDirName}` }; + if (requestedId && profileId !== requestedId) { + return { + env, + profileId, + warning: `"${requestedId}" isn't allowed for this project — using "${profileId}" instead.`, + }; + } + return { env, profileId }; +} diff --git a/src/lib/harness/claudeProtocol.test.ts b/src/lib/harness/claudeProtocol.test.ts index 6b709275..469c5e19 100644 --- a/src/lib/harness/claudeProtocol.test.ts +++ b/src/lib/harness/claudeProtocol.test.ts @@ -441,7 +441,9 @@ describe("list_models catalog", () => { expect(opus?.settings?.some((setting) => setting.id === "fast")).toBe(true); const haiku = models[3]; - expect(haiku?.settings).toBeUndefined(); + // Even a row with none of effort/fast/context still gets the account + // (profile) picker — every claude session can switch CLAUDE_CONFIG_DIR. + expect(haiku?.settings?.map((setting) => setting.id)).toEqual(["profile"]); }); it("parses success and error control responses", () => { diff --git a/src/lib/harness/claudeProtocol.ts b/src/lib/harness/claudeProtocol.ts index e175ea49..638e1de1 100644 --- a/src/lib/harness/claudeProtocol.ts +++ b/src/lib/harness/claudeProtocol.ts @@ -985,6 +985,7 @@ export function claudeSettingsKey(input: { fast?: string; thinking?: string; context?: string; + profile?: string; runtimeMode: RuntimeMode; hooks?: boolean; }): string { @@ -994,6 +995,7 @@ export function claudeSettingsKey(input: { input.fast ?? "", input.thinking ?? "", input.context ?? "", + input.profile ?? "", input.runtimeMode, input.hooks === false ? "nohooks" : "hooks", ].join("|"); From d0163820dba27110192157c8e6567f68054b5604 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Sun, 13 Sep 2026 08:59:31 +0530 Subject: [PATCH 2/7] rename claude profiles: Personal/Nishanth/Benitto, no folder restriction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Personal now means the plain claude default account (no CLAUDE_CONFIG_DIR override at all), Nishanth = claudo's dir (~/.claude-personal), Benitto = claudz's dir (~/.claude-office2). - Dropped the dharani/claudd profile entirely. - Dropped the yuko/-only-two-of-three folder restriction — all three profiles are now selectable on every project, no allow-list. tsc/vitest all pass (2499 tests). --- src/lib/harness/claudeProfiles.test.ts | 53 ++++++++++---------------- src/lib/harness/claudeProfiles.ts | 49 ++++++++++++------------ 2 files changed, 45 insertions(+), 57 deletions(-) diff --git a/src/lib/harness/claudeProfiles.test.ts b/src/lib/harness/claudeProfiles.test.ts index 1574e639..12cb231a 100644 --- a/src/lib/harness/claudeProfiles.test.ts +++ b/src/lib/harness/claudeProfiles.test.ts @@ -5,60 +5,47 @@ import { } from "./claudeProfiles"; describe("claudeProfileOptionsFor", () => { - it("allows all three profiles outside yuko", () => { + it("allows all three profiles everywhere", () => { expect( claudeProfileOptionsFor("/Users/cartrabbit/Documents/personal/poynt").map( (option) => option.value, ), - ).toEqual(["personal", "dharani", "office2"]); - }); - - it("restricts yuko projects to personal and office2", () => { + ).toEqual(["personal", "nishanth", "benitto"]); expect( claudeProfileOptionsFor( "/Users/cartrabbit/Documents/yuko/yuko-backend", ).map((option) => option.value), - ).toEqual(["personal", "office2"]); - expect( - claudeProfileOptionsFor( - "/Users/cartrabbit/Documents/yuko/yuko-frontend", - ).map((option) => option.value), - ).toEqual(["personal", "office2"]); + ).toEqual(["personal", "nishanth", "benitto"]); }); }); describe("resolveClaudeProfileEnv", () => { const home = "/Users/cartrabbit"; + const cwd = "/Users/cartrabbit/Documents/personal/poynt"; - it("points CLAUDE_CONFIG_DIR at the requested profile when allowed", () => { - const result = resolveClaudeProfileEnv( - "office2", - "/Users/cartrabbit/Documents/yuko/yuko-backend", - home, - ); - expect(result.profileId).toBe("office2"); - expect(result.env.CLAUDE_CONFIG_DIR).toBe("/Users/cartrabbit/.claude-office2"); + it("Personal has no CLAUDE_CONFIG_DIR override — the plain default account", () => { + const result = resolveClaudeProfileEnv("personal", cwd, home); + expect(result.profileId).toBe("personal"); + expect(result.env).toEqual({}); expect(result.warning).toBeUndefined(); }); - it("falls back and warns when the requested profile is disallowed for the folder", () => { - const result = resolveClaudeProfileEnv( - "dharani", - "/Users/cartrabbit/Documents/yuko/yuko-frontend", - home, - ); - expect(result.profileId).toBe("personal"); + it("Nishanth points CLAUDE_CONFIG_DIR at claudo's dir", () => { + const result = resolveClaudeProfileEnv("nishanth", cwd, home); + expect(result.profileId).toBe("nishanth"); expect(result.env.CLAUDE_CONFIG_DIR).toBe("/Users/cartrabbit/.claude-personal"); - expect(result.warning).toMatch(/not allowed|isn't allowed/); }); - it("defaults to personal when no profile was requested", () => { - const result = resolveClaudeProfileEnv( - undefined, - "/Users/cartrabbit/Documents/personal/poynt", - home, - ); + it("Benitto points CLAUDE_CONFIG_DIR at claudz's dir", () => { + const result = resolveClaudeProfileEnv("benitto", cwd, home); + expect(result.profileId).toBe("benitto"); + expect(result.env.CLAUDE_CONFIG_DIR).toBe("/Users/cartrabbit/.claude-office2"); + }); + + it("defaults to Personal (no override) when no profile was requested", () => { + const result = resolveClaudeProfileEnv(undefined, cwd, home); expect(result.profileId).toBe("personal"); + expect(result.env).toEqual({}); expect(result.warning).toBeUndefined(); }); }); diff --git a/src/lib/harness/claudeProfiles.ts b/src/lib/harness/claudeProfiles.ts index a05797cf..da67a68b 100644 --- a/src/lib/harness/claudeProfiles.ts +++ b/src/lib/harness/claudeProfiles.ts @@ -1,25 +1,27 @@ import type { ModelSettingChoice } from "../models"; /** - * Per-account Claude Code identities, mirroring the `claudo`/`claudd`/`claudz` + * Per-account Claude Code identities, mirroring the `claude`/`claudo`/`claudz` * shell functions in ~/.zshrc (each sets CLAUDE_CONFIG_DIR to a different - * account's config dir). MonoCode has no notion of this on its own — it - * always resolves and spawns a single `claude` binary — so this is threaded - * through explicitly from the model-settings picker down to `harness_spawn`. + * account's config dir — plain `claude` uses whatever is already the default, + * no override). MonoCode has no notion of this on its own — it always + * resolves and spawns a single `claude` binary — so this is threaded through + * explicitly from the model-settings picker down to `harness_spawn`. */ -export type ClaudeProfileId = "personal" | "dharani" | "office2"; +export type ClaudeProfileId = "personal" | "nishanth" | "benitto"; export type ClaudeProfile = { id: ClaudeProfileId; label: string; - /** Relative to $HOME, matching the zshrc CLAUDE_CONFIG_DIR values. */ - configDirName: string; + /** Relative to $HOME, matching the zshrc CLAUDE_CONFIG_DIR values. + * `undefined` means "no override" — the plain `claude` default account. */ + configDirName?: string; }; export const CLAUDE_PROFILES: ClaudeProfile[] = [ - { id: "personal", label: "Personal", configDirName: ".claude-personal" }, - { id: "dharani", label: "Dharani", configDirName: ".claude-dharani" }, - { id: "office2", label: "Office2", configDirName: ".claude-office2" }, + { id: "personal", label: "Personal" }, + { id: "nishanth", label: "Nishanth", configDirName: ".claude-personal" }, + { id: "benitto", label: "Benitto", configDirName: ".claude-office2" }, ]; export const CLAUDE_PROFILE_DEFAULT: ClaudeProfileId = "personal"; @@ -30,16 +32,12 @@ export const CLAUDE_PROFILE_OPTIONS: ModelSettingChoice[] = CLAUDE_PROFILES.map( /** * Folder-based guardrail: which profiles a project's cwd is allowed to - * launch under, independent of what the picker has selected. Projects under - * `yuko` are client work that must never run under the `dharani` account; - * everything else (e.g. `personal`) is unrestricted. + * launch under, independent of what the picker has selected. All three + * profiles are currently unrestricted everywhere — add a rule here if a + * folder should ever be limited to a subset again. */ -function allowedProfileIds(cwd: string): ClaudeProfileId[] { - const normalized = cwd.replace(/\\/g, "/"); - if (/\/yuko(?:[-_][a-z0-9]+)*(?:\/|$)/i.test(normalized)) { - return ["personal", "office2"]; - } - return ["personal", "dharani", "office2"]; +function allowedProfileIds(_cwd: string): ClaudeProfileId[] { + return ["personal", "nishanth", "benitto"]; } export function claudeProfileOptionsFor(cwd: string): ModelSettingChoice[] { @@ -49,10 +47,11 @@ export function claudeProfileOptionsFor(cwd: string): ModelSettingChoice[] { /** * Resolves the requested profile against the folder allow-list for `cwd` - * and returns the env to launch `claude` with. A disallowed request (e.g. - * "dharani" picked while cwd is under yuko/) is silently corrected to the - * folder's first allowed profile rather than launching under the wrong - * account — callers should surface `warning` to the user when present. + * and returns the env to launch `claude` with. A disallowed request is + * silently corrected to the folder's first allowed profile rather than + * launching under the wrong account — callers should surface `warning` to + * the user when present. A profile with no `configDirName` (Personal) means + * no CLAUDE_CONFIG_DIR override at all — the plain default account. */ export function resolveClaudeProfileEnv( requestedId: string | undefined, @@ -64,7 +63,9 @@ export function resolveClaudeProfileEnv( const profileId = allowed.includes(requested) ? requested : allowed[0]; const profile = CLAUDE_PROFILES.find((candidate) => candidate.id === profileId) ?? CLAUDE_PROFILES[0]; - const env = { CLAUDE_CONFIG_DIR: `${home.replace(/\/+$/, "")}/${profile.configDirName}` }; + const env: Record = profile.configDirName + ? { CLAUDE_CONFIG_DIR: `${home.replace(/\/+$/, "")}/${profile.configDirName}` } + : {}; if (requestedId && profileId !== requestedId) { return { env, From 13bef2c9afb6a011559701657d6d0421cbfd6196 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Sun, 13 Sep 2026 09:15:17 +0530 Subject: [PATCH 3/7] fix: don't resume a conversation across a profile switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureLive only invalidated the stored resumeByThread entry on a cwd change, not a profile (CLAUDE_CONFIG_DIR) change. Since claude conversations are stored per config dir, resuming session_id X after switching from one account to another attempted to resume a conversation that only exists in the *previous* account's history — surfacing as "conversation not found" from the CLI. Now the resume record tracks which profile it belongs to; a mismatch (same as a cwd mismatch already did) forces a fresh conversation instead of a cross-account resume attempt. A resume record with no profileId (pre-existing sessions, before this feature) is treated as the default profile for comparison purposes. Added a regression test covering the profile-switch case. --- src/lib/harness/claude.ts | 39 +++++++++++++++++++++--------- src/lib/harness/claudeLive.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 41d55c1d..1e209e27 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -10,7 +10,7 @@ import { watchChild, writeChild, } from "./child"; -import { resolveClaudeProfileEnv } from "./claudeProfiles"; +import { CLAUDE_PROFILE_DEFAULT, resolveClaudeProfileEnv } from "./claudeProfiles"; import { askUserQuestionAllowInput, asRecord, @@ -116,6 +116,10 @@ type LiveAgentTask = { type Live = { cwd: string; claudeSessionId: string; + /** Which account (CLAUDE_CONFIG_DIR) this process was spawned under — + * needed so a later resume attempt can tell it apart from a different + * profile's conversation history. See claudeProfiles.ts. */ + profileId: string; runtimeMode: RuntimeMode; planning: boolean; settingsKey: string; @@ -147,6 +151,9 @@ type Live = { type Resume = { sessionId: string; cwd: string; + /** Undefined means "recorded before profiles existed" — treated as the + * default profile when compared, since that was the only account then. */ + profileId?: string; }; const INIT_TIMEOUT_MS = 8_000; @@ -344,15 +351,28 @@ async function ensureLive(input: HarnessSessionInput): Promise { } if (existing) { // Model and launch-setting changes require a fresh Claude process, but - // they must resume the same provider conversation. Only a cwd change - // invalidates the stored session because Claude sessions are cwd-bound. + // they must resume the same provider conversation. A cwd change, or a + // switch to a different account (CLAUDE_CONFIG_DIR), invalidates the + // stored session instead — Claude conversations are both cwd- and + // account-bound, so resuming across either lands on a conversation ID + // that simply doesn't exist there. if (existing.cwd !== input.cwd) resumeByThread.delete(input.sessionId); await stopClaudeSession(input.sessionId); } + const home = await homeDir(input.cwd); + const { env, profileId, warning } = resolveClaudeProfileEnv( + input.modelSettings?.profile, + input.cwd, + home, + ); + const resume = resumeByThread.get(input.sessionId); - const canResume = resume != null && resume.cwd === input.cwd; - if (resume && resume.cwd !== input.cwd) { + const resumeProfileMatches = + resume != null && (resume.profileId ?? CLAUDE_PROFILE_DEFAULT) === profileId; + const canResume = + resume != null && resume.cwd === input.cwd && resumeProfileMatches; + if (resume && (resume.cwd !== input.cwd || !resumeProfileMatches)) { resumeByThread.delete(input.sessionId); } @@ -369,6 +389,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { const live: Live = { cwd: input.cwd, claudeSessionId, + profileId, runtimeMode: input.runtimeMode, planning, settingsKey, @@ -421,12 +442,6 @@ async function ensureLive(input: HarnessSessionInput): Promise { }, ); - const home = await homeDir(input.cwd); - const { env, warning } = resolveClaudeProfileEnv( - input.modelSettings?.profile, - input.cwd, - home, - ); if (warning) live.onEvent({ type: "status", text: warning }); await spawnChild( @@ -441,6 +456,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { resumeByThread.set(input.sessionId, { sessionId: claudeSessionId, cwd: input.cwd, + profileId, }); try { @@ -552,6 +568,7 @@ function handleLine(sessionId: string, live: Live, line: string): void { resumeByThread.set(sessionId, { sessionId: sessionIdFromLine, cwd: live.cwd, + profileId: live.profileId, }); live.onEvent({ type: "session.providerBound", diff --git a/src/lib/harness/claudeLive.test.ts b/src/lib/harness/claudeLive.test.ts index 29dac40e..e3d379e5 100644 --- a/src/lib/harness/claudeLive.test.ts +++ b/src/lib/harness/claudeLive.test.ts @@ -151,6 +151,43 @@ describe("claude model switching", () => { }); }); +describe("claude profile switching", () => { + it("starts a fresh conversation instead of resuming across a different account", async () => { + const first = await startTurn("s1"); + emit({ type: "result", subtype: "success", session_id: "sess_1" }); + await first.turn; + + const userCount = parse().filter( + (message) => message.type === "user", + ).length; + const second = sendClaudeTurn({ + sessionId: "s1", + cwd: "/repo", + model: "claude:claude-sonnet-5", + modelSettings: { profile: "nishanth" }, + runtimeMode: "supervised", + text: "what did I ask before?", + attachments: [], + onEvent: () => undefined, + }); + + await waitFor(() => spawned.length === 2, "replacement Claude process"); + // A different account's conversation history doesn't contain sess_1 — + // resuming it there would be "conversation not found". Must start fresh. + expect(spawned[1]).not.toContain("--resume"); + expect(spawned[1]).toContain("--session-id"); + + emit({ type: "system", subtype: "init" }); + await waitFor( + () => + parse().filter((message) => message.type === "user").length > userCount, + "follow-up prompt", + ); + emit({ type: "result", subtype: "success" }); + await second; + }); +}); + describe("claude subagents", () => { it.each(["allow", "deny"] as const)( "routes a child permission decision: %s", From 544e7e5965b1ac4e357595cc2861bc951492ffa3 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Sun, 13 Sep 2026 09:29:12 +0530 Subject: [PATCH 4/7] fix: usage footer now reads the active session's own Claude account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_claude_usage was hardcoded to one identity: the default ~/.claude/.credentials.json (which doesn't exist) or a fixed Keychain service "Claude Code-credentials" tied to the OS username. It never knew a session could be running under a different profile, so the 5h/ weekly % shown could belong to a completely different account than the one actually driving the chat. Root cause confirmed against this machine's real Keychain: Claude CLI stores the default account under the plain "Claude Code-credentials" service, and every other CLAUDE_CONFIG_DIR under that same name suffixed with the first 8 hex chars of SHA-256(absolute config dir path) — verified exactly against two live entries. - rate_limits.rs: fetch_claude_usage now takes an optional config_dir, used to pick the right Keychain service (keychain_service_for) or the right /.credentials.json fallback, instead of always the default. - UsageFooter.tsx: resolves the focused session's actual CLAUDE_CONFIG_DIR (via resolveClaudeProfileEnv) and passes it through; resets to idle and force-refetches whenever the active session's cwd/profile changes, instead of showing a stale wrong-account reading until the next poll. - App.tsx: usageSession now carries cwd + profile so the footer knows which account it's looking at. cargo fmt/clippy/test (331 passing) and tsc/vitest (2500 passing) all clean. Added a regression test pinning the Keychain-suffix algorithm against generic example paths. --- src-tauri/src/rate_limits.rs | 107 ++++++++++++++++++++++++++--------- src/App.tsx | 8 ++- src/chrome/UsageFooter.tsx | 34 +++++++++-- src/lib/rateLimitsFetch.ts | 8 ++- 4 files changed, 122 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/rate_limits.rs b/src-tauri/src/rate_limits.rs index 0c74a50e..54edecab 100644 --- a/src-tauri/src/rate_limits.rs +++ b/src-tauri/src/rate_limits.rs @@ -3,6 +3,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; use serde_json::{json, Value}; +#[cfg(target_os = "macos")] +use sha2::{Digest, Sha256}; use crate::dirs_home; @@ -33,6 +35,7 @@ pub struct ClaudeUsageFetch { enum ClaudeCredStore { #[cfg(target_os = "macos")] Keychain { + service: String, account: String, }, File { @@ -64,15 +67,21 @@ fn usage_result( /// Fetch Claude Code 5-hour / weekly usage via the local OAuth token. /// The token never leaves the host process. +/// +/// `config_dir` is the CLAUDE_CONFIG_DIR the caller's session is actually +/// running under (None for the default, no-override account) — each +/// profile's login is stored separately (a suffixed Keychain service, or a +/// credentials file inside that config dir), so reading the wrong one +/// silently reports a different account's usage than the one in use. #[tauri::command] -pub async fn fetch_claude_usage() -> Result { - tauri::async_runtime::spawn_blocking(fetch_claude_usage_sync) +pub async fn fetch_claude_usage(config_dir: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || fetch_claude_usage_sync(config_dir.as_deref())) .await .map_err(|e| e.to_string())? } -fn fetch_claude_usage_sync() -> Result { - let Some(mut creds) = read_claude_credentials() else { +fn fetch_claude_usage_sync(config_dir: Option<&str>) -> Result { + let Some(mut creds) = read_claude_credentials(config_dir) else { return Ok(usage_result( "unavailable", None, @@ -183,7 +192,9 @@ fn persist_claude_credentials(creds: &ClaudeCredentials) -> bool { }; match &creds.store { #[cfg(target_os = "macos")] - ClaudeCredStore::Keychain { account } => write_macos_keychain_blob(account, &raw), + ClaudeCredStore::Keychain { service, account } => { + write_macos_keychain_blob(service, account, &raw) + } ClaudeCredStore::File { path } => write_credentials_file(path, &raw), } } @@ -200,23 +211,26 @@ fn write_credentials_file(path: &Path, raw: &str) -> bool { true } -fn read_claude_credentials() -> Option { +fn read_claude_credentials(config_dir: Option<&str>) -> Option { #[cfg(target_os = "macos")] { - if let Some(creds) = read_macos_keychain_credentials() { + if let Some(creds) = read_macos_keychain_credentials(config_dir) { return Some(creds); } } - read_credentials_file() + read_credentials_file(config_dir) } -fn read_credentials_file() -> Option { - let path = claude_credentials_path()?; +fn read_credentials_file(config_dir: Option<&str>) -> Option { + let path = claude_credentials_path(config_dir)?; let raw = std::fs::read_to_string(&path).ok()?; credentials_from_blob(&raw, ClaudeCredStore::File { path }) } -fn claude_credentials_path() -> Option { +fn claude_credentials_path(config_dir: Option<&str>) -> Option { + if let Some(dir) = config_dir { + return Some(crate::fs::expand_home(dir).join(".credentials.json")); + } let home = dirs_home().or_else(|| { std::env::var_os("USERPROFILE").map(|value| value.to_string_lossy().into_owned()) })?; @@ -353,30 +367,35 @@ fn now_ms() -> i64 { } #[cfg(target_os = "macos")] -fn read_macos_keychain_credentials() -> Option { +fn read_macos_keychain_credentials(config_dir: Option<&str>) -> Option { + let service = keychain_service_for(config_dir); let user = keychain_user(); let candidates = [ (user.clone(), { - let mut args = keychain_find_args(); + let mut args = keychain_find_args(&service); args.push("-w".into()); args }), (user.clone(), { - let mut args = keychain_find_args(); + let mut args = keychain_find_args(&service); args.extend(["-a".into(), user.clone(), "-w".into()]); args }), (KEYCHAIN_FALLBACK_USER.into(), { - let mut args = keychain_find_args(); + let mut args = keychain_find_args(&service); args.extend(["-a".into(), KEYCHAIN_FALLBACK_USER.into(), "-w".into()]); args }), ]; for (account, args) in candidates { if let Some(secret) = security_output(&args) { - if let Some(creds) = - credentials_from_blob(&secret, ClaudeCredStore::Keychain { account }) - { + if let Some(creds) = credentials_from_blob( + &secret, + ClaudeCredStore::Keychain { + service: service.clone(), + account, + }, + ) { return Some(creds); } } @@ -384,13 +403,35 @@ fn read_macos_keychain_credentials() -> Option { None } +/// Claude CLI keeps the default account's login under the plain +/// "Claude Code-credentials" Keychain service, and every other +/// CLAUDE_CONFIG_DIR's login under that same name suffixed with the first +/// 8 hex chars of SHA-256(absolute config dir path) — e.g. +/// `~/.claude-personal` → `Claude Code-credentials-d8a6e19b`. Verified +/// against this machine's real Keychain entries. #[cfg(target_os = "macos")] -fn write_macos_keychain_blob(account: &str, raw: &str) -> bool { +fn keychain_service_for(config_dir: Option<&str>) -> String { + let Some(dir) = config_dir else { + return LEGACY_KEYCHAIN_SERVICE.to_string(); + }; + let absolute = crate::fs::expand_home(dir); + let mut hasher = Sha256::new(); + hasher.update(absolute.to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let mut suffix = String::with_capacity(8); + for byte in digest.iter().take(4) { + suffix.push_str(&format!("{byte:02x}")); + } + format!("{LEGACY_KEYCHAIN_SERVICE}-{suffix}") +} + +#[cfg(target_os = "macos")] +fn write_macos_keychain_blob(service: &str, account: &str, raw: &str) -> bool { let args = vec![ "add-generic-password".into(), "-U".into(), "-s".into(), - LEGACY_KEYCHAIN_SERVICE.into(), + service.into(), "-a".into(), account.into(), "-w".into(), @@ -400,12 +441,8 @@ fn write_macos_keychain_blob(account: &str, raw: &str) -> bool { } #[cfg(target_os = "macos")] -fn keychain_find_args() -> Vec { - vec![ - "find-generic-password".into(), - "-s".into(), - LEGACY_KEYCHAIN_SERVICE.into(), - ] +fn keychain_find_args(service: &str) -> Vec { + vec!["find-generic-password".into(), "-s".into(), service.into()] } #[cfg(target_os = "macos")] @@ -508,6 +545,24 @@ mod tests { assert_eq!(extract_access_token("not json"), None); } + #[cfg(target_os = "macos")] + #[test] + fn keychain_service_for_matches_observed_claude_cli_scheme() { + // Claude CLI suffixes the default Keychain service with the first 8 + // hex chars of SHA-256(absolute config dir) for a non-default + // CLAUDE_CONFIG_DIR — verified against real Keychain entries. These + // paths are generic examples; the algorithm itself is what's tested. + assert_eq!(keychain_service_for(None), LEGACY_KEYCHAIN_SERVICE); + assert_eq!( + keychain_service_for(Some("/Users/alice/.claude-work")), + "Claude Code-credentials-be865d75" + ); + assert_eq!( + keychain_service_for(Some("/Users/alice/.claude-client")), + "Claude Code-credentials-a90ccfe6" + ); + } + #[test] fn token_needs_refresh_uses_five_minute_buffer() { let now = 1_000_000; diff --git a/src/App.tsx b/src/App.tsx index 9e5c1b58..0d52810e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1006,8 +1006,12 @@ export default function App({ }, [active?.harness]); const usageSession = useMemo(() => { if (!active) return undefined; - return { harness: active.harness }; - }, [active?.harness]); + return { + harness: active.harness, + cwd: active.cwd, + profile: active.modelSettings?.profile, + }; + }, [active?.harness, active?.cwd, active?.modelSettings?.profile]); const runningTerminals = useMemo(() => { const files: FilePaneTab[] = []; const dock = findProjectTerminal(projectTerminals, projectCwd); diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index a25d142a..4ff809c0 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -24,11 +24,28 @@ import { runningTerminalChipLabel, type RunningTerminal, } from "../lib/terminalTab"; +import { homeDir } from "../lib/fs"; +import { resolveClaudeProfileEnv } from "../lib/harness/claudeProfiles"; const CLOCK_MS = 30_000; +/** Resolves the active session's account to a CLAUDE_CONFIG_DIR, so the + * usage fetch reads the same account the session actually runs under + * (undefined for the default/no-override account). */ +async function resolveClaudeConfigDir( + session: UsageFooterSession | undefined, +): Promise { + if (!session?.cwd) return undefined; + const home = await homeDir(session.cwd); + const { env } = resolveClaudeProfileEnv(session.profile, session.cwd, home); + return env.CLAUDE_CONFIG_DIR; +} + export type UsageFooterSession = { harness: HarnessId; + /** Which account this session's Claude usage should be read for. */ + cwd?: string; + profile?: string; }; export function UsageFooter({ @@ -75,9 +92,11 @@ export function UsageFooter({ if (fetchClaude) { setClaude((current) => fetchingRateLimits("claude", current)); jobs.push( - fetchClaudeRateLimits().then((value) => { - setClaude(value); - }), + resolveClaudeConfigDir(session) + .then((configDir) => fetchClaudeRateLimits(configDir)) + .then((value) => { + setClaude(value); + }), ); } if (fetchCodex) { @@ -96,10 +115,15 @@ export function UsageFooter({ }); inflight.current = run; return run; - }, [wantClaude, wantCodex]); + }, [wantClaude, wantCodex, session]); useEffect(() => { - void refresh(); + // A different session's account is now in view — the in-flight/cached + // claude reading belongs to whichever account was previously active, so + // drop it and force a fresh fetch for the new one rather than showing a + // stale (possibly wrong-account) percentage while it catches up. + setClaude(idleRateLimits("claude")); + void refresh(true); const poll = window.setInterval(() => void refresh(), RATE_LIMIT_POLL_MS); const onVisible = () => { if (document.visibilityState === "visible") void refresh(); diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 275f7526..4035e99e 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -28,9 +28,13 @@ type ClaudeUsageFetch = { error?: string | null; }; -export async function fetchClaudeRateLimits(): Promise { +export async function fetchClaudeRateLimits( + configDir?: string, +): Promise { try { - const result = await invoke("fetch_claude_usage"); + const result = await invoke("fetch_claude_usage", { + configDir, + }); if (result.status === "ok" && result.body) { const parsed = parseClaudeOAuthUsage(result.body); if (parsed.session || parsed.weekly) return parsed; From 98fffe802a92221caaff6bcb29b4e70cf0ebeed2 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Mon, 14 Sep 2026 12:33:28 +0530 Subject: [PATCH 5/7] fix: adapt ported profile commits to this codebase's homeDir()/deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porting the per-session Claude account profile feature onto hardbeat920/main surfaced two API differences from the branch it was originally built on: - homeDir() here takes no cwd argument (no WSL-relative home resolution in this codebase) — drop the arg at both call sites. - sha2 wasn't yet a dependency; add it under the macOS target deps, where keychain_service_for() uses it to hash CLAUDE_CONFIG_DIR into a stable Keychain service suffix. --- src-tauri/Cargo.toml | 1 + src/chrome/UsageFooter.tsx | 2 +- src/lib/harness/claude.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d023c040..a71fe933 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Securit [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" objc2 = "0.6" +sha2 = "0.10" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSButton", "NSColor", "NSControl", "NSDockTile", "NSImage", "NSLayoutAnchor", "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSResponder", "NSView", "NSWindow", "objc2-core-foundation"] } objc2-foundation = { version = "0.3", features = ["NSGeometry", "NSError", "NSString"] } objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "UNNotification", "UNNotificationAction", "UNNotificationCategory", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationSound", "block2"] } diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index 4ff809c0..3a5d648f 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -36,7 +36,7 @@ async function resolveClaudeConfigDir( session: UsageFooterSession | undefined, ): Promise { if (!session?.cwd) return undefined; - const home = await homeDir(session.cwd); + const home = await homeDir(); const { env } = resolveClaudeProfileEnv(session.profile, session.cwd, home); return env.CLAUDE_CONFIG_DIR; } diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 1e209e27..669a74f8 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -360,7 +360,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { await stopClaudeSession(input.sessionId); } - const home = await homeDir(input.cwd); + const home = await homeDir(); const { env, profileId, warning } = resolveClaudeProfileEnv( input.modelSettings?.profile, input.cwd, From 1335d410259103142d83a9f4f770d93d32d16079 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Mon, 14 Sep 2026 12:37:50 +0530 Subject: [PATCH 6/7] fix: commit Cargo.lock update for the sha2 dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in 98fffe8 — sha2 was already present in the lockfile transitively, just not wired to monocode's own dependency list. --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 6b5f90c8..00ec6fd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2229,6 +2229,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", From f62862a2a4549c52ab739d899fc2c384d086d094 Mon Sep 17 00:00:00 2001 From: Nishanth-sebastin Date: Mon, 14 Sep 2026 14:06:52 +0530 Subject: [PATCH 7/7] fix: opening a session or creating one no longer lands in the wrong project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs, same root cause: trusting whatever tab/session is currently active instead of verifying it actually belongs to the target project. - replaceBlankPaneWithSession (used when opening an existing session from the sidebar) reused any blank pane in the active tab without checking its project — so opening a Project 2 session while a blank Project 1 tab was focused silently grafted the Project 2 conversation into Project 1's tab. Now it only reuses a blank pane that's already scoped to the same project (sameProjectPath), matching the guard projectReturn.ts already uses elsewhere (paneBelongsToProject). - onNew (Cmd+T), onStartInboxItem, onAddNoteToChat, and onSplit fell back to sessionDefaults?.cwd when there was no active session-focused pane — sessionDefaults is active ?? sessions[0], so with no active session that silently picks an arbitrary session from anywhere in the app, unrelated to the project being viewed. Dropped sessionDefaults?.cwd from all four fallback chains; they fall back straight to projectCwd (or active?.cwd first, where available) instead. sessionDefaults?.runtimeMode is left alone — inheriting model/effort settings from a prior session is fine, only the cwd fallback was wrong. tsc --noEmit clean, full vitest suite (2054 passing). --- src/App.tsx | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0d52810e..f6a0a868 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1514,7 +1514,7 @@ export default function App({ setSearchViewOpen(false); setInboxViewOpen(false); setNotesViewOpen(false); - const cwd = active?.cwd ?? sessionDefaults?.cwd ?? projectCwd; + const cwd = active?.cwd ?? projectCwd; const session = newDefaultSession(cwd, sessionDefaults?.runtimeMode); const tab = newTab(session.id); setSessions((prev) => [...prev, session]); @@ -1525,7 +1525,6 @@ export default function App({ }, [ active?.cwd, appendTab, - sessionDefaults?.cwd, sessionDefaults?.runtimeMode, projectCwd, ]); @@ -1536,8 +1535,7 @@ export default function App({ setInboxViewOpen(false); setNotesViewOpen(false); setSidebarTab("sessions"); - const cwd = - item.projectPath || active?.cwd || sessionDefaults?.cwd || projectCwd; + const cwd = item.projectPath || active?.cwd || projectCwd; const ref = item.provider === "linear" ? item.identifier?.trim() || `#${item.number}` @@ -1575,13 +1573,7 @@ export default function App({ const details = await linearIssueDetails(item.id); start(details.body); }, - [ - active?.cwd, - appendTab, - sessionDefaults?.cwd, - sessionDefaults?.runtimeMode, - projectCwd, - ], + [active?.cwd, appendTab, sessionDefaults?.runtimeMode, projectCwd], ); const onAddNoteToChat = useCallback( @@ -1596,7 +1588,6 @@ export default function App({ ? card.sourceCwd : undefined) || active?.cwd || - sessionDefaults?.cwd || projectCwd; const title = card.title.trim(); const session = { @@ -1610,13 +1601,7 @@ export default function App({ setActiveTabId(tab.id); setComposerFocused(true); }, - [ - active?.cwd, - appendTab, - sessionDefaults?.cwd, - sessionDefaults?.runtimeMode, - projectCwd, - ], + [active?.cwd, appendTab, sessionDefaults?.runtimeMode, projectCwd], ); useEffect(() => { @@ -1692,7 +1677,7 @@ export default function App({ (dir: SplitDir) => { if (!activeTab) return; const session = newDefaultSession( - sessionDefaults?.cwd ?? projectCwd, + active?.cwd ?? projectCwd, sessionDefaults?.runtimeMode, ); setSessions((prev) => [...prev, session]); @@ -1708,7 +1693,7 @@ export default function App({ ); setComposerFocused(true); }, - [activeTab, projectCwd, sessionDefaults?.cwd, sessionDefaults?.runtimeMode], + [activeTab, active?.cwd, projectCwd, sessionDefaults?.runtimeMode], ); const focusProjectTerminal = useCallback(() => { @@ -2723,12 +2708,22 @@ export default function App({ tabsRef.current[0]; if (!tab) return false; - const paneId = isBlankSession( + // A blank pane only stands in for the session being opened if it's + // already scoped to the same project — otherwise this grafts the + // session into an unrelated project's tab instead of opening it there. + const isBlankForSameProject = (candidate: Session | undefined) => + !!candidate && + isBlankSession(candidate) && + sameProjectPath(candidate.cwd, session.cwd); + + const paneId = isBlankForSameProject( sessionsRef.current.find((entry) => entry.id === tab.focusedId), ) ? tab.focusedId : leafIds(tab.layout).find((id) => - isBlankSession(sessionsRef.current.find((entry) => entry.id === id)), + isBlankForSameProject( + sessionsRef.current.find((entry) => entry.id === id), + ), ); if (!paneId || paneId === session.id) return false;