diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx
index bf3baa8a472..843383817fc 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/UsageView.tsx
@@ -10,6 +10,8 @@ import { cn } from "@superset/ui/utils";
import { useState } from "react";
import {
LuCheck,
+ LuCircle,
+ LuCircleCheck,
LuCopy,
LuEllipsis,
LuPlus,
@@ -86,12 +88,16 @@ function creditsLine(account: UsageAccount): string | null {
return null;
}
+const DEFAULT_TITLE =
+ "New agent launches use this account. Relaunch a running agent to switch it.";
+
function AccountCard({
account,
onMakeDefault,
onSwitchSignIn,
onRemove,
isSwitching,
+ selectable,
}: {
account: UsageAccount;
onMakeDefault: () => void;
@@ -99,14 +105,41 @@ function AccountCard({
/** Null on the system-default card — the main login is never removable. */
onRemove: (() => void) | null;
isSwitching: boolean;
+ /** True when the provider has several accounts, so the cards read as a
+ * radio group: the default gets a check + accent border, the rest get a
+ * selectable circle. */
+ selectable: boolean;
}) {
const credits = creditsLine(account);
const { copyToClipboard, copied } = useCopyToClipboard();
const expiredCommand =
account.status === "token_expired" ? switchSignInCommand(account) : null;
return (
-
+
+ {selectable &&
+ (account.isDefault ? (
+
+
+
+ ) : (
+
+
+
+ ))}
{account.email ?? PROVIDER_LABELS[account.provider]}
@@ -124,38 +157,17 @@ function AccountCard({
: "Unavailable"}
)}
- {account.isDefault ? (
-
- Default
-
- ) : (
-
- Make default
-
- )}
{/* Source label always shows — it is the only thing that tells two
profiles of the same account apart. */}
- {credits
- ? `${credits} · ${account.sourceLabel}`
- : account.sourceLabel}
+ {account.sourceLabel}
@@ -205,6 +217,39 @@ function AccountCard({
{account.statusDetail ?? "Usage unavailable."}
)}
+ {/* The radio + accent border already mark the default when the cards
+ read as a group; the footer label only carries it for a lone card. */}
+ {(!account.isDefault || !selectable || credits) && (
+
+ {account.isDefault ? (
+ !selectable && (
+
+
+ Default for new agents
+
+ )
+ ) : (
+
+ Make default
+
+ )}
+ {credits && (
+
+ {credits}
+
+ )}
+
+ )}
);
}
@@ -231,10 +276,9 @@ export function UsageView({ hostUrl }: { hostUrl: string | null }) {
{
onSuccess: () => {
toast.success(
- `New ${PROVIDER_LABELS[account.provider]} terminals and agents will use ${account.email ?? account.sourceLabel}.`,
+ `New ${PROVIDER_LABELS[account.provider]} agents will use ${account.email ?? account.sourceLabel}.`,
{
- description:
- "Running sessions keep their current account — restart them to switch.",
+ description: "Relaunch running agents to switch them.",
},
);
},
@@ -333,6 +377,7 @@ export function UsageView({ hostUrl }: { hostUrl: string | null }) {
: () => setRemoveTarget(account)
}
isSwitching={setDefault.isPending}
+ selectable={providerAccounts.length > 1}
/>
))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx
index 22496237690..5ea3acd8948 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/usage/components/UsageView/components/AddAccountDialog/AddAccountDialog.tsx
@@ -155,13 +155,14 @@ export function AddAccountDialog({
}
if (found) return;
- const seedProfile = (configDir: string) => {
- // A fresh (or re-signed) Claude profile may have the CLI's first-boot
- // wizard armed; mark onboarding done so its first agent launch opens
- // the prompt.
+ const provisionProfile = (selection: string) => {
+ // Shares the default account's skills, plugins, MCP servers, and
+ // settings into the new profile dir — a bare login there would boot
+ // the CLI on an empty install (and, for Claude, into the first-boot
+ // wizard).
if (!hostUrl) return;
void getHostServiceClientByUrl(hostUrl)
- .usage.prepareClaudeProfile.mutate({ configDir })
+ .usage.prepareAccount.mutate({ provider, selection })
.catch(() => {});
};
@@ -174,9 +175,8 @@ export function AddAccountDialog({
if (change) {
setFound({ selection: null, label: change.label });
onAccountAdded();
- if (switchTarget.provider === "claude" && switchTarget.selection) {
- seedProfile(switchTarget.selection);
- }
+ // The system default (null) is the share's source, not a target.
+ if (switchTarget.selection) provisionProfile(switchTarget.selection);
}
return;
}
@@ -184,7 +184,7 @@ export function AddAccountDialog({
if (fresh) {
setFound(fresh);
onAccountAdded();
- if (provider === "claude") seedProfile(fresh.selection);
+ provisionProfile(fresh.selection);
}
}, [
open,
@@ -225,7 +225,7 @@ export function AddAccountDialog({
{switchDescription ??
- "Sign in to a second subscription as a separate profile. Your current login is untouched."}
+ "Sign in to a second subscription as a separate profile. Your current login is untouched, and the new profile shares your skills, plugins, MCP servers, and settings."}
diff --git a/apps/docs/content/docs/usage.mdx b/apps/docs/content/docs/usage.mdx
index 2779cbbf6b4..9589dbcb515 100644
--- a/apps/docs/content/docs/usage.mdx
+++ b/apps/docs/content/docs/usage.mdx
@@ -1,6 +1,6 @@
---
title: Usage
-description: Subscription quota meters, token-cost analytics, and machine resources
+description: Subscription quota meters, token-cost analytics, machine resources, and multi-account switching
---
## Overview
@@ -19,11 +19,16 @@ Quota comes from the providers' own usage endpoints using your existing sign-ins
Run different agents on different subscriptions from one machine:
+
+
- **Add account** creates a second profile: pick a profile name, then run the shown sign-in command (e.g. `CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth login`) in any terminal on this host. The dialog detects the sign-in automatically.
-- **Make default** (hover an account card) routes **new** terminals and agents to that account. Running sessions keep their current account until restarted.
-- The **…** menu on a card offers **Switch sign-in…** (re-login that profile to a different account) and, for secondary profiles, **Remove…**. The system-default login can't be removed.
+- **Make default** picks which account new agent launches use — when a provider has several accounts, the cards read as a radio group with the default checked. Switching reaches terminals you already have open too: a running agent keeps its account until you relaunch it.
+- Your setup comes along: skills, plugins, MCP servers, and settings are shared across accounts, and for Claude Code there's one conversation history — `--resume` shows the same sessions whichever account is active.
+- Logins, credentials, and account identity stay per account. The **…** menu on a card offers **Switch sign-in…** (re-login that profile to a different account) and, for secondary profiles, **Remove…** — which deletes that profile and its credentials, never your shared setup or history. The system-default login can't be removed.
- If a sign-in expires, the card shows the exact login command to run, with click-to-copy.
+Under the hood, a profile is just a config dir the CLI is pointed at (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`). Superset's agent wrappers re-resolve the current default every time the agent starts in a Superset terminal — so switching works for agents you launch by hand too, and a value you export yourself always wins.
+
## Token Costs
Below the meters, the **Token usage** chart estimates spend from the agents' local session logs, priced at API list rates. Subscription usage isn't billed per token (the page shows **Cost to you: $0**); the estimate is what the same work would cost at API rates, plus how much caching saved.
diff --git a/apps/docs/public/images/usage-accounts.png b/apps/docs/public/images/usage-accounts.png
new file mode 100644
index 00000000000..c6343cf5fd2
Binary files /dev/null and b/apps/docs/public/images/usage-accounts.png differ
diff --git a/docs/agent-tooling.md b/docs/agent-tooling.md
index 5022cacfb0d..49c5c56ba2f 100644
--- a/docs/agent-tooling.md
+++ b/docs/agent-tooling.md
@@ -34,6 +34,40 @@ Everything else links to those:
Agents other than Claude Code should read the relevant `.agents/skills/*/SKILL.md` when its
description matches the task.
+## Provider accounts (multi-login)
+
+The Usage tab can hold several Claude Code / Codex logins and pick which one agents use. A login
+is just a config dir the CLI is pointed at — `CLAUDE_CONFIG_DIR` / `CODEX_HOME`, injected at PTY
+and agent launch by `packages/host-service/src/trpc/router/usage/default-account.ts`, which also
+publishes the selection to `$SUPERSET_HOME_DIR/state/default-*` pointer files. The agent wrappers
+re-read those at every launch (`buildDefaultAccountResolver` in agent-setup), so a switch reaches
+existing terminals the next time the agent starts; a value the user exported by hand always wins.
+Superset never touches the credential stores; the provider CLIs own every login end to end.
+
+Because those CLIs read *everything* from their active config dir, a second dir would otherwise
+mean a second, empty setup. `packages/agent-setup/src/provider-profiles.ts` provisions each
+non-default profile from the default account (`~/.claude`, `~/.codex`):
+
+- Capability directories (`skills/`, `plugins/`, `agents/`, `commands/`, `output-styles/`;
+ `prompts/` for Codex) are **symlinked** at the default account's, so anything installed later is
+ shared with no sync step.
+- Files (`CLAUDE.md`, `config.toml`, `AGENTS.md`) are **copied**, and `settings.json` /
+ `.claude.json` are **key-merged** — the CLIs rename-replace files, which would silently break a
+ file symlink.
+- Copies and merged keys are recorded in `/.superset-profile.json`, so anything the user
+ changes inside a profile is never overwritten by a later provision.
+- Claude session state (`projects/`, `sessions/`, `history.jsonl`, …) is shared too — by symlink,
+ which is safe there because transcripts are append-only — so every account sees one
+ conversation history and `--resume` list
+ (`packages/host-service/src/trpc/router/usage/session-share.ts`; existing trees are merged in,
+ live sessions included).
+- Per-account and never shared: credentials, `oauthAccount`/`userID` identity and per-project
+ state in `.claude.json`, auth-related settings keys, and Superset's own lifecycle hooks
+ (written per profile).
+
+Provisioning is idempotent and runs when an account is added, when one is selected, and at host
+boot for the selected accounts (`usage/account-provisioning.ts`).
+
## Testing the CLI and skills against the dev app
The dev desktop app is local-first: it registers its host service under a
diff --git a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts
index 35aa5f5a56f..b629a2f3cc7 100644
--- a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts
+++ b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
+ buildDefaultAccountResolver,
buildWrapperScript,
createWrapper,
getManagedNotifyHookCommand,
@@ -251,25 +252,32 @@ export function getOpenCodePluginContent(notifyPath: string): string {
}
/**
- * Pass-through wrapper for Claude. Hooks live in ~/.claude/settings.json
- * (createClaudeSettingsJson); the wrapper exists only to forward SUPERSET_*
- * env vars into the agent process tree.
+ * Wrapper for Claude: forwards SUPERSET_* env vars into the agent process
+ * tree and follows the Usage-tab account switch at launch time. Hooks live
+ * in ~/.claude/settings.json (createClaudeSettingsJson).
*/
export function createClaudeWrapper(): void {
- const script = buildWrapperScript("claude", `exec "$REAL_BIN" "$@"`, {
- agentId: "claude",
- });
+ const script = buildWrapperScript(
+ "claude",
+ buildDefaultAccountResolver(
+ "CLAUDE_CONFIG_DIR",
+ "default-claude-config-dir",
+ ) + `exec "$REAL_BIN" "$@"`,
+ { agentId: "claude" },
+ );
createWrapper("claude", script);
}
/**
- * Creates the Codex wrapper that enables native hooks and session-log signals.
+ * Creates the Codex wrapper that enables native hooks and session-log signals,
+ * and follows the Usage-tab account switch at launch time.
*/
export function createCodexWrapper(): void {
const notifyPath = getNotifyScriptPath();
const script = buildWrapperScript(
"codex",
- buildCodexWrapperExecLine(notifyPath),
+ buildDefaultAccountResolver("CODEX_HOME", "default-codex-home") +
+ buildCodexWrapperExecLine(notifyPath),
{ agentId: "codex" },
);
createWrapper("codex", script);
diff --git a/packages/agent-setup/src/agent-wrappers-common.ts b/packages/agent-setup/src/agent-wrappers-common.ts
index 52b4749d625..04cc7452d8e 100644
--- a/packages/agent-setup/src/agent-wrappers-common.ts
+++ b/packages/agent-setup/src/agent-wrappers-common.ts
@@ -105,6 +105,34 @@ function buildRealBinaryResolver(): string {
`;
}
+/**
+ * Shell block that re-resolves the Usage-tab default account at launch.
+ * The PTY env is frozen at terminal spawn, so an account switch would
+ * otherwise reach only brand-new terminals; this re-reads the host's
+ * pointer file every time the agent starts instead. Superset terminals
+ * only, and a value the user exported by hand — one that differs from what
+ * Superset injected at spawn — always wins. A missing pointer file (older
+ * host build) changes nothing; an empty one means the system default.
+ */
+export function buildDefaultAccountResolver(
+ envVar: string,
+ pointerName: string,
+): string {
+ const pointer = `"$SUPERSET_HOME_DIR/state/${pointerName}"`;
+ return `if [ -n "$SUPERSET_TERMINAL_ID" ] && [ -n "$SUPERSET_HOME_DIR" ] \\
+ && { [ -z "\${${envVar}}" ] || [ "\${${envVar}}" = "\${SUPERSET_DEFAULT_${envVar}}" ]; } \\
+ && [ -f ${pointer} ]; then
+ superset_default_account="$(cat ${pointer} 2>/dev/null)"
+ if [ -n "$superset_default_account" ] && [ -d "$superset_default_account" ]; then
+ export ${envVar}="$superset_default_account"
+ else
+ unset ${envVar}
+ fi
+fi
+
+`;
+}
+
function getMissingBinaryMessage(name: string): string {
return `Superset: ${name} not found in PATH. Install it and ensure it is on PATH, then retry.`;
}
diff --git a/packages/agent-setup/src/agent-wrappers.test.ts b/packages/agent-setup/src/agent-wrappers.test.ts
index dbed78030a0..04cce83385f 100644
--- a/packages/agent-setup/src/agent-wrappers.test.ts
+++ b/packages/agent-setup/src/agent-wrappers.test.ts
@@ -173,7 +173,11 @@ describe("agent-wrappers copilot", () => {
expect(wrapper).toContain(
'projects={\\"$_superset_workspace_path_toml\\"={trust_level=\\"trusted\\"}}',
);
- expect(wrapper).not.toContain("export CODEX_HOME=");
+ // The Usage-tab default resolver may export CODEX_HOME dynamically from
+ // the pointer file, but the wrapper must never hardcode a home.
+ expect(wrapper).toContain("state/default-codex-home");
+ expect(wrapper).toContain('export CODEX_HOME="$superset_default_account"');
+ expect(wrapper).not.toContain('export CODEX_HOME="$HOME');
expect(wrapper).not.toContain("rollout-*.jsonl");
expect(wrapper).not.toContain("_superset_sessions_dir");
expect(wrapper).not.toContain("$" + "{CODEX_HOME:-$HOME/.codex}");
diff --git a/packages/agent-setup/src/default-account-resolver.test.ts b/packages/agent-setup/src/default-account-resolver.test.ts
new file mode 100644
index 00000000000..a8b4e1b4390
--- /dev/null
+++ b/packages/agent-setup/src/default-account-resolver.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from "bun:test";
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildDefaultAccountResolver } from "./agent-wrappers-common";
+
+/** Runs the resolver block under bash and returns the resulting env var. */
+function resolve(env: Record): string {
+ const script = `${buildDefaultAccountResolver(
+ "CLAUDE_CONFIG_DIR",
+ "default-claude-config-dir",
+ )}printf "%s" "\${CLAUDE_CONFIG_DIR:-}"`;
+ const cleanEnv: Record = { PATH: process.env.PATH ?? "" };
+ for (const [key, value] of Object.entries(env)) {
+ if (value !== undefined) cleanEnv[key] = value;
+ }
+ return execFileSync("bash", ["-c", script], {
+ env: cleanEnv,
+ encoding: "utf-8",
+ });
+}
+
+function makeHome(pointer: string | null): {
+ home: string;
+ profile: string;
+} {
+ const home = mkdtempSync(join(tmpdir(), "superset-resolver-"));
+ const profile = join(home, "profile");
+ mkdirSync(join(home, "state"), { recursive: true });
+ mkdirSync(profile);
+ if (pointer !== null) {
+ writeFileSync(join(home, "state", "default-claude-config-dir"), pointer);
+ }
+ return { home, profile };
+}
+
+describe("buildDefaultAccountResolver", () => {
+ it("adopts the pointer in a Superset terminal with no spawn-time value", () => {
+ const { home, profile } = makeHome("");
+ writeFileSync(join(home, "state", "default-claude-config-dir"), profile);
+ expect(
+ resolve({ SUPERSET_TERMINAL_ID: "t1", SUPERSET_HOME_DIR: home }),
+ ).toBe(profile);
+ });
+
+ it("re-resolves over a stale Superset-injected value", () => {
+ const { home, profile } = makeHome(null);
+ writeFileSync(join(home, "state", "default-claude-config-dir"), profile);
+ expect(
+ resolve({
+ SUPERSET_TERMINAL_ID: "t1",
+ SUPERSET_HOME_DIR: home,
+ CLAUDE_CONFIG_DIR: "/tmp/old-spawn-time-default",
+ SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: "/tmp/old-spawn-time-default",
+ }),
+ ).toBe(profile);
+ });
+
+ it("clears a stale injected value when the pointer says system default", () => {
+ const { home, profile } = makeHome("");
+ expect(
+ resolve({
+ SUPERSET_TERMINAL_ID: "t1",
+ SUPERSET_HOME_DIR: home,
+ CLAUDE_CONFIG_DIR: profile,
+ SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile,
+ }),
+ ).toBe("");
+ });
+
+ it("never overrides a value the user exported by hand", () => {
+ const { home, profile } = makeHome(null);
+ writeFileSync(join(home, "state", "default-claude-config-dir"), profile);
+ expect(
+ resolve({
+ SUPERSET_TERMINAL_ID: "t1",
+ SUPERSET_HOME_DIR: home,
+ CLAUDE_CONFIG_DIR: "/tmp/user-picked-this",
+ SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile,
+ }),
+ ).toBe("/tmp/user-picked-this");
+ });
+
+ it("does nothing outside Superset terminals", () => {
+ const { home, profile } = makeHome(null);
+ writeFileSync(join(home, "state", "default-claude-config-dir"), profile);
+ expect(resolve({ SUPERSET_HOME_DIR: home })).toBe("");
+ });
+
+ it("does nothing when the pointer file is missing (older host build)", () => {
+ const { home } = makeHome(null);
+ expect(
+ resolve({
+ SUPERSET_TERMINAL_ID: "t1",
+ SUPERSET_HOME_DIR: home,
+ CLAUDE_CONFIG_DIR: "/tmp/spawn-time",
+ SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: "/tmp/spawn-time",
+ }),
+ ).toBe("/tmp/spawn-time");
+ });
+
+ it("ignores a pointer at a vanished dir instead of booting signed out", () => {
+ const { home } = makeHome("/tmp/deleted-profile-dir-that-is-gone");
+ expect(
+ resolve({ SUPERSET_TERMINAL_ID: "t1", SUPERSET_HOME_DIR: home }),
+ ).toBe("");
+ });
+});
diff --git a/packages/agent-setup/src/index.ts b/packages/agent-setup/src/index.ts
index b3c1d0ac48a..2dbbee19a2b 100644
--- a/packages/agent-setup/src/index.ts
+++ b/packages/agent-setup/src/index.ts
@@ -60,6 +60,11 @@ export {
ensureClaudeManagedHooksAt,
ensureCodexManagedHooksAt,
} from "./agent-wrappers-claude-codex-opencode";
+export {
+ type ProfileProvisionReport,
+ provisionClaudeProfile,
+ provisionCodexProfile,
+} from "./provider-profiles";
export { getCommandShellArgs, getShellArgs, getShellEnv };
diff --git a/packages/agent-setup/src/managed-skills.ts b/packages/agent-setup/src/managed-skills.ts
index 54a1ab6105a..7e6f5801bbd 100644
--- a/packages/agent-setup/src/managed-skills.ts
+++ b/packages/agent-setup/src/managed-skills.ts
@@ -120,17 +120,17 @@ async function syncDir(src: string, dest: string): Promise {
}
}
-/** Provisions the bundled plugin as a Claude Code skills-directory plugin. */
+/**
+ * Provisions the bundled plugin as a Claude Code skills-directory plugin
+ * inside one Claude config dir — `~/.claude` for the default account, or a
+ * profile dir whose own `skills/` the user owns (a shared profile links
+ * `skills/` at the default account instead, and gets it that way).
+ */
async function provisionClaudePlugin(
bundledPluginDir: string,
- homeDir: string,
+ claudeDir: string,
): Promise {
- const target = path.join(
- homeDir,
- ".claude",
- "skills",
- CLAUDE_PLUGIN_DIR_NAME,
- );
+ const target = path.join(claudeDir, "skills", CLAUDE_PLUGIN_DIR_NAME);
const sentinel = path.join(target, MANAGED_SENTINEL_NAME);
if (fs.existsSync(target) && !fs.existsSync(sentinel)) {
console.log(`[agent-setup] Skipping user-owned plugin dir at ${target}`);
@@ -245,7 +245,10 @@ export async function createManagedSkills(
);
try {
- await provisionClaudePlugin(bundledPluginDir, homeDir);
+ await provisionClaudePlugin(
+ bundledPluginDir,
+ path.join(homeDir, ".claude"),
+ );
} catch (error) {
console.warn("[agent-setup] Failed to provision Claude plugin:", error);
}
@@ -331,3 +334,25 @@ export async function createManagedSkills(
console.log("[agent-setup] Managed skills provisioned");
}
+
+/**
+ * Provisions the bundled Superset plugin into one Claude config dir. Used for
+ * a secondary account whose `skills/` directory the user owns, so it can't be
+ * linked at the default account's — the plugin is written into it directly
+ * instead of being shared.
+ */
+export async function provisionManagedClaudePluginAt(
+ claudeDir: string,
+ options: ManagedSkillsOptions = {},
+): Promise {
+ const bundledPluginDir = options.templatesDir
+ ? path.join(options.templatesDir, "plugin")
+ : getBundledPluginDir();
+ if (!fs.existsSync(path.join(bundledPluginDir, "skills"))) {
+ console.warn(
+ `[agent-setup] Bundled plugin missing at ${bundledPluginDir}; skipping plugin provisioning for ${claudeDir}`,
+ );
+ return;
+ }
+ await provisionClaudePlugin(bundledPluginDir, claudeDir);
+}
diff --git a/packages/agent-setup/src/profile-sharing.test.ts b/packages/agent-setup/src/profile-sharing.test.ts
new file mode 100644
index 00000000000..afe469c7291
--- /dev/null
+++ b/packages/agent-setup/src/profile-sharing.test.ts
@@ -0,0 +1,262 @@
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ readlinkSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ linkSharedDir,
+ mergeSharedJsonKeys,
+ type ProfileLedger,
+ readProfileLedger,
+ syncSharedFile,
+ writeProfileLedger,
+} from "./profile-sharing";
+
+const TEST_ROOT = path.join(
+ os.tmpdir(),
+ `superset-profile-sharing-${process.pid}-${Date.now()}`,
+);
+const SOURCE = path.join(TEST_ROOT, "default");
+const PROFILE = path.join(TEST_ROOT, "profile");
+
+function ledger(): ProfileLedger {
+ return readProfileLedger(PROFILE, SOURCE);
+}
+
+beforeEach(() => {
+ rmSync(TEST_ROOT, { recursive: true, force: true });
+ mkdirSync(SOURCE, { recursive: true });
+ mkdirSync(PROFILE, { recursive: true });
+});
+
+afterEach(() => {
+ rmSync(TEST_ROOT, { recursive: true, force: true });
+});
+
+describe("linkSharedDir", () => {
+ it("links a free path and is idempotent", () => {
+ mkdirSync(path.join(SOURCE, "skills", "demo"), { recursive: true });
+ writeFileSync(path.join(SOURCE, "skills", "demo", "SKILL.md"), "hi");
+
+ const target = path.join(PROFILE, "skills");
+ expect(linkSharedDir(path.join(SOURCE, "skills"), target)).toBe("linked");
+ expect(lstatSync(target).isSymbolicLink()).toBe(true);
+ expect(readFileSync(path.join(target, "demo", "SKILL.md"), "utf-8")).toBe(
+ "hi",
+ );
+ expect(linkSharedDir(path.join(SOURCE, "skills"), target)).toBe(
+ "unchanged",
+ );
+ });
+
+ it("shares later additions without re-provisioning", () => {
+ mkdirSync(path.join(SOURCE, "skills"), { recursive: true });
+ linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills"));
+
+ mkdirSync(path.join(SOURCE, "skills", "added"), { recursive: true });
+ expect(existsSync(path.join(PROFILE, "skills", "added"))).toBe(true);
+ });
+
+ it("reports absent when the default account has no such dir", () => {
+ expect(
+ linkSharedDir(
+ path.join(SOURCE, "plugins"),
+ path.join(PROFILE, "plugins"),
+ ),
+ ).toBe("absent");
+ expect(existsSync(path.join(PROFILE, "plugins"))).toBe(false);
+ });
+
+ it("adopts an empty dir but never one with content", () => {
+ mkdirSync(path.join(SOURCE, "skills"), { recursive: true });
+ mkdirSync(path.join(PROFILE, "skills"), { recursive: true });
+ expect(
+ linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")),
+ ).toBe("linked");
+
+ rmSync(path.join(PROFILE, "skills"), { recursive: true, force: true });
+ mkdirSync(path.join(PROFILE, "skills", "mine"), { recursive: true });
+ expect(
+ linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")),
+ ).toBe("user-owned");
+ expect(lstatSync(path.join(PROFILE, "skills")).isDirectory()).toBe(true);
+ expect(lstatSync(path.join(PROFILE, "skills")).isSymbolicLink()).toBe(
+ false,
+ );
+ });
+
+ it("leaves a link the user aimed elsewhere, replaces a dangling one", () => {
+ mkdirSync(path.join(SOURCE, "skills"), { recursive: true });
+ const elsewhere = path.join(TEST_ROOT, "elsewhere");
+ mkdirSync(elsewhere, { recursive: true });
+ symlinkSync(elsewhere, path.join(PROFILE, "skills"), "dir");
+ expect(
+ linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")),
+ ).toBe("user-owned");
+ expect(readlinkSync(path.join(PROFILE, "skills"))).toBe(elsewhere);
+
+ rmSync(elsewhere, { recursive: true, force: true });
+ expect(
+ linkSharedDir(path.join(SOURCE, "skills"), path.join(PROFILE, "skills")),
+ ).toBe("linked");
+ });
+});
+
+describe("syncSharedFile", () => {
+ const args = () => ({
+ sourcePath: path.join(SOURCE, "CLAUDE.md"),
+ targetPath: path.join(PROFILE, "CLAUDE.md"),
+ surface: "CLAUDE.md",
+ ledger: ledger(),
+ });
+
+ it("copies, then reports unchanged", () => {
+ writeFileSync(path.join(SOURCE, "CLAUDE.md"), "be brief");
+ const first = args();
+ expect(syncSharedFile(first)).toBe("synced");
+ expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe(
+ "be brief",
+ );
+ expect(syncSharedFile({ ...first, ledger: first.ledger })).toBe(
+ "unchanged",
+ );
+ });
+
+ it("re-syncs its own copy when the default account changes", () => {
+ writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v1");
+ const state = args();
+ syncSharedFile(state);
+ writeProfileLedger(PROFILE, state.ledger);
+
+ writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v2");
+ expect(syncSharedFile(args())).toBe("synced");
+ expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe("v2");
+ });
+
+ it("never clobbers an edit made inside the profile", () => {
+ writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v1");
+ const state = args();
+ syncSharedFile(state);
+ writeProfileLedger(PROFILE, state.ledger);
+
+ writeFileSync(path.join(PROFILE, "CLAUDE.md"), "mine");
+ writeFileSync(path.join(SOURCE, "CLAUDE.md"), "v2");
+ expect(syncSharedFile(args())).toBe("user-owned");
+ expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe("mine");
+ });
+
+ it("leaves a pre-existing file the profile already had", () => {
+ writeFileSync(path.join(SOURCE, "CLAUDE.md"), "shared");
+ writeFileSync(path.join(PROFILE, "CLAUDE.md"), "theirs");
+ expect(syncSharedFile(args())).toBe("user-owned");
+ });
+});
+
+describe("mergeSharedJsonKeys", () => {
+ const settings = () => ({
+ sourcePath: path.join(SOURCE, "settings.json"),
+ targetPath: path.join(PROFILE, "settings.json"),
+ surface: "settings.json",
+ ledger: ledger(),
+ pick: (source: Record) => source,
+ });
+
+ function readTarget(): Record {
+ return JSON.parse(
+ readFileSync(path.join(PROFILE, "settings.json"), "utf-8"),
+ );
+ }
+
+ it("copies shared keys and preserves keys the profile owns", () => {
+ writeFileSync(
+ path.join(SOURCE, "settings.json"),
+ JSON.stringify({ model: "opus", effortLevel: "high" }),
+ );
+ writeFileSync(
+ path.join(PROFILE, "settings.json"),
+ JSON.stringify({ hooks: { Stop: [] } }),
+ );
+
+ const state = settings();
+ expect(mergeSharedJsonKeys(state)).toBe("merged");
+ writeProfileLedger(PROFILE, state.ledger);
+ expect(readTarget()).toEqual({
+ hooks: { Stop: [] },
+ model: "opus",
+ effortLevel: "high",
+ });
+ expect(mergeSharedJsonKeys(settings())).toBe("unchanged");
+ });
+
+ it("keeps a value the user changed inside the profile", () => {
+ writeFileSync(
+ path.join(SOURCE, "settings.json"),
+ JSON.stringify({ model: "opus" }),
+ );
+ const state = settings();
+ mergeSharedJsonKeys(state);
+ writeProfileLedger(PROFILE, state.ledger);
+
+ writeFileSync(
+ path.join(PROFILE, "settings.json"),
+ JSON.stringify({ model: "sonnet" }),
+ );
+ writeFileSync(
+ path.join(SOURCE, "settings.json"),
+ JSON.stringify({ model: "fable" }),
+ );
+ expect(mergeSharedJsonKeys(settings())).toBe("unchanged");
+ expect(readTarget().model).toBe("sonnet");
+ });
+
+ it("skips a state file the profile does not have yet", () => {
+ writeFileSync(
+ path.join(SOURCE, "settings.json"),
+ JSON.stringify({ mcpServers: { linear: {} } }),
+ );
+ expect(
+ mergeSharedJsonKeys({
+ ...settings(),
+ force: { hasCompletedOnboarding: true },
+ requireExistingTarget: true,
+ }),
+ ).toBe("absent");
+ expect(existsSync(path.join(PROFILE, "settings.json"))).toBe(false);
+ });
+
+ it("re-applies forced values even when they were changed", () => {
+ writeFileSync(path.join(SOURCE, "settings.json"), JSON.stringify({}));
+ writeFileSync(
+ path.join(PROFILE, "settings.json"),
+ JSON.stringify({ hasCompletedOnboarding: false }),
+ );
+ expect(
+ mergeSharedJsonKeys({
+ ...settings(),
+ force: { hasCompletedOnboarding: true },
+ requireExistingTarget: true,
+ }),
+ ).toBe("merged");
+ expect(readTarget().hasCompletedOnboarding).toBe(true);
+ });
+
+ it("leaves an unparsable profile file alone", () => {
+ writeFileSync(
+ path.join(SOURCE, "settings.json"),
+ JSON.stringify({ model: "opus" }),
+ );
+ writeFileSync(path.join(PROFILE, "settings.json"), "{ not json");
+ expect(mergeSharedJsonKeys(settings())).toBe("user-owned");
+ expect(readFileSync(path.join(PROFILE, "settings.json"), "utf-8")).toBe(
+ "{ not json",
+ );
+ });
+});
diff --git a/packages/agent-setup/src/profile-sharing.ts b/packages/agent-setup/src/profile-sharing.ts
new file mode 100644
index 00000000000..1c83248aac6
--- /dev/null
+++ b/packages/agent-setup/src/profile-sharing.ts
@@ -0,0 +1,298 @@
+/**
+ * Shared-config plumbing behind provider "accounts".
+ *
+ * A secondary account is just a config dir the provider CLI gets pointed at
+ * (CLAUDE_CONFIG_DIR / CODEX_HOME), so switching to one handed the CLI a
+ * blank install: no skills, no plugins, no MCP servers, none of the user's
+ * settings. These primitives make a profile dir mirror the default account
+ * for everything that isn't a login.
+ *
+ * Two mechanisms, picked by what the CLI does to the path:
+ *
+ * - Directories are symlinked at the default account's dir. The share stays
+ * live — a skill or plugin installed later shows up in every account with
+ * no sync step — and a directory can't be atomically replaced out from
+ * under the link.
+ * - Files are copied, and JSON files key-merged, because the CLIs write them
+ * with write-tmp-then-rename: a symlink would be replaced by a regular
+ * file on the first write and the config would silently fork.
+ *
+ * Copies and merges record what they wrote in a per-profile ledger, so a
+ * value the user changed inside the profile is never clobbered by the next
+ * provision. Nothing here reads or writes credentials.
+ */
+
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { writeFileIfChanged } from "./agent-wrappers-common";
+
+/** Per-profile record of what we last wrote, kept inside the profile dir. */
+export const PROFILE_LEDGER_NAME = ".superset-profile.json";
+
+export type SurfaceOutcome =
+ /** A symlink now points the profile at the default account's dir. */
+ | "linked"
+ /** Content copied from the default account. */
+ | "synced"
+ /** JSON keys copied from the default account. */
+ | "merged"
+ /** Already matched the default account. */
+ | "unchanged"
+ /** The profile has its own version — left untouched. */
+ | "user-owned"
+ /** Nothing to share: the default account has no such surface. */
+ | "absent";
+
+export interface ProfileLedger {
+ version: 1;
+ /** Default-account dir these values were shared from. */
+ sharedFrom: string;
+ /** Surface name -> sha256 of the content we last wrote. */
+ files: Record;
+ /** Surface name -> key -> serialized value we last wrote. */
+ keys: Record>;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function sha256(content: string): string {
+ return createHash("sha256").update(content).digest("hex");
+}
+
+function readJsonObject(filePath: string): Record | null {
+ try {
+ const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
+ return isPlainObject(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function readProfileLedger(
+ profileDir: string,
+ sharedFrom: string,
+): ProfileLedger {
+ const empty: ProfileLedger = {
+ version: 1,
+ sharedFrom,
+ files: {},
+ keys: {},
+ };
+ const stored = readJsonObject(path.join(profileDir, PROFILE_LEDGER_NAME));
+ if (!stored) return empty;
+ return {
+ version: 1,
+ sharedFrom,
+ files: isPlainObject(stored.files)
+ ? (stored.files as Record)
+ : {},
+ keys: isPlainObject(stored.keys)
+ ? (stored.keys as Record>)
+ : {},
+ };
+}
+
+export function writeProfileLedger(
+ profileDir: string,
+ ledger: ProfileLedger,
+): void {
+ try {
+ writeFileIfChanged(
+ path.join(profileDir, PROFILE_LEDGER_NAME),
+ JSON.stringify(ledger, null, 2),
+ 0o644,
+ );
+ } catch (error) {
+ console.warn(
+ `[agent-setup] Could not write the profile ledger in ${profileDir}:`,
+ error,
+ );
+ }
+}
+
+function lstatOrNull(target: string): fs.Stats | null {
+ try {
+ return fs.lstatSync(target);
+ } catch {
+ return null;
+ }
+}
+
+function realpathOrNull(target: string): string | null {
+ try {
+ return fs.realpathSync(target);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Points `targetPath` at `sourceDir` with a symlink. Only ever claims a path
+ * that is free: an existing real directory with anything in it, or a symlink
+ * the user aimed somewhere else, is reported as user-owned and left alone.
+ */
+export function linkSharedDir(
+ sourceDir: string,
+ targetPath: string,
+): SurfaceOutcome {
+ const source = realpathOrNull(sourceDir);
+ if (source === null) return "absent";
+
+ const existing = lstatOrNull(targetPath);
+ if (existing?.isSymbolicLink()) {
+ const current = realpathOrNull(targetPath);
+ if (current === source) return "unchanged";
+ // A link the user aimed elsewhere is deliberate; a dangling one is
+ // debris (a profile whose old share target was deleted) and ours to
+ // replace.
+ if (current !== null) return "user-owned";
+ fs.unlinkSync(targetPath);
+ } else if (existing) {
+ if (!existing.isDirectory()) return "user-owned";
+ if (fs.readdirSync(targetPath).length > 0) return "user-owned";
+ fs.rmdirSync(targetPath);
+ }
+
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
+ // "junction" is the one dir link Windows grants without elevation.
+ fs.symlinkSync(
+ source,
+ targetPath,
+ process.platform === "win32" ? "junction" : "dir",
+ );
+ return "linked";
+}
+
+export interface SyncSharedFileArgs {
+ sourcePath: string;
+ targetPath: string;
+ /** Ledger key, normally the file name. */
+ surface: string;
+ ledger: ProfileLedger;
+}
+
+/**
+ * Copies a file from the default account into the profile. The profile's copy
+ * is only overwritten while it still holds what we last put there — once the
+ * user edits it inside the profile, that edit wins from then on.
+ */
+export function syncSharedFile({
+ sourcePath,
+ targetPath,
+ surface,
+ ledger,
+}: SyncSharedFileArgs): SurfaceOutcome {
+ let content: string;
+ try {
+ content = fs.readFileSync(sourcePath, "utf-8");
+ } catch {
+ return "absent";
+ }
+ const hash = sha256(content);
+
+ if (fs.existsSync(targetPath)) {
+ let current: string;
+ try {
+ current = fs.readFileSync(targetPath, "utf-8");
+ } catch {
+ return "user-owned";
+ }
+ const currentHash = sha256(current);
+ if (currentHash === hash) {
+ ledger.files[surface] = hash;
+ return "unchanged";
+ }
+ if (ledger.files[surface] !== currentHash) return "user-owned";
+ }
+
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
+ writeFileIfChanged(targetPath, content, 0o644);
+ ledger.files[surface] = hash;
+ return "synced";
+}
+
+export interface MergeSharedJsonKeysArgs {
+ sourcePath: string;
+ targetPath: string;
+ /** Ledger key, normally the file name. */
+ surface: string;
+ ledger: ProfileLedger;
+ /** Shared subset of the default account's file. */
+ pick(source: Record): Record;
+ /**
+ * Values written regardless of the default account — facts about being a
+ * secondary profile rather than shared config, so they are not tracked in
+ * the ledger and are re-applied whenever they drift.
+ */
+ force?: Record;
+ /**
+ * Skip entirely when the profile has no readable file yet. Set for state
+ * files the CLI owns end to end (`.claude.json`), where creating one
+ * ourselves would fabricate a login that has not happened.
+ */
+ requireExistingTarget?: boolean;
+}
+
+/**
+ * Copies selected top-level keys from the default account's JSON file into
+ * the profile's. Per key, the same rule as syncSharedFile: we only overwrite
+ * a value that is still the one we wrote. A key we wrote and the user then
+ * deleted comes back — the surface is shared, and re-adding it is the whole
+ * point of the share.
+ */
+export function mergeSharedJsonKeys({
+ sourcePath,
+ targetPath,
+ surface,
+ ledger,
+ pick,
+ force,
+ requireExistingTarget,
+}: MergeSharedJsonKeysArgs): SurfaceOutcome {
+ const existing = readJsonObject(targetPath);
+ if (requireExistingTarget && existing === null) return "absent";
+ if (existing === null && fs.existsSync(targetPath)) {
+ console.warn(
+ `[agent-setup] Could not parse ${targetPath}; skipping the shared-config merge`,
+ );
+ return "user-owned";
+ }
+
+ const source = readJsonObject(sourcePath);
+ const desired = source ? pick(source) : {};
+ if (source === null && !force) return "absent";
+
+ const target = existing ?? {};
+ const written = ledger.keys[surface] ?? {};
+ let changed = false;
+
+ for (const [key, value] of Object.entries(desired)) {
+ const serialized = JSON.stringify(value);
+ const current = key in target ? JSON.stringify(target[key]) : undefined;
+ if (current === serialized) {
+ written[key] = serialized;
+ continue;
+ }
+ // Present with a value we never wrote: the user set it in this profile.
+ if (current !== undefined && written[key] !== current) continue;
+ target[key] = value;
+ written[key] = serialized;
+ changed = true;
+ }
+
+ for (const [key, value] of Object.entries(force ?? {})) {
+ if (JSON.stringify(target[key]) === JSON.stringify(value)) continue;
+ target[key] = value;
+ changed = true;
+ }
+
+ ledger.keys[surface] = written;
+ if (!changed) return "unchanged";
+
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
+ writeFileIfChanged(targetPath, JSON.stringify(target, null, 2), 0o644);
+ return "merged";
+}
diff --git a/packages/agent-setup/src/provider-profiles.test.ts b/packages/agent-setup/src/provider-profiles.test.ts
new file mode 100644
index 00000000000..f5540202ad0
--- /dev/null
+++ b/packages/agent-setup/src/provider-profiles.test.ts
@@ -0,0 +1,238 @@
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ provisionClaudeProfile,
+ provisionCodexProfile,
+} from "./provider-profiles";
+
+const TEST_ROOT = path.join(
+ os.tmpdir(),
+ `superset-provider-profiles-${process.pid}-${Date.now()}`,
+);
+const HOME = path.join(TEST_ROOT, "home");
+const DEFAULT_DIR = path.join(HOME, ".claude");
+const PROFILE = path.join(HOME, ".claude-work");
+
+function writeJson(filePath: string, value: unknown): void {
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ writeFileSync(filePath, JSON.stringify(value, null, 2));
+}
+
+function readJson(filePath: string): Record {
+ return JSON.parse(readFileSync(filePath, "utf-8"));
+}
+
+/** A default account with something to share on every surface. */
+function seedDefaultAccount(): void {
+ mkdirSync(path.join(DEFAULT_DIR, "skills", "redesign"), { recursive: true });
+ writeFileSync(
+ path.join(DEFAULT_DIR, "skills", "redesign", "SKILL.md"),
+ "# redesign",
+ );
+ mkdirSync(path.join(DEFAULT_DIR, "plugins"), { recursive: true });
+ writeJson(path.join(DEFAULT_DIR, "plugins", "installed_plugins.json"), {
+ version: 2,
+ });
+ mkdirSync(path.join(DEFAULT_DIR, "agents"), { recursive: true });
+ writeFileSync(path.join(DEFAULT_DIR, "CLAUDE.md"), "be brief");
+ writeJson(path.join(DEFAULT_DIR, "settings.json"), {
+ model: "claude-opus-5",
+ effortLevel: "high",
+ enabledPlugins: { "superset@superset": true },
+ env: { DISABLE_TELEMETRY: "1", ANTHROPIC_API_KEY: "sk-secret" },
+ apiKeyHelper: "/usr/local/bin/key.sh",
+ hooks: { Stop: [{ hooks: [{ type: "command", command: "user-hook" }] }] },
+ });
+ writeJson(path.join(HOME, ".claude.json"), {
+ mcpServers: { linear: { command: "linear-mcp" } },
+ theme: "dark",
+ oauthAccount: { emailAddress: "default@example.com" },
+ userID: "default-user",
+ });
+}
+
+/** A profile that has completed its own login. */
+function seedProfile(): void {
+ writeJson(path.join(PROFILE, ".claude.json"), {
+ oauthAccount: { emailAddress: "work@example.com" },
+ userID: "work-user",
+ });
+ writeFileSync(path.join(PROFILE, ".credentials.json"), "{}");
+}
+
+beforeEach(() => {
+ rmSync(TEST_ROOT, { recursive: true, force: true });
+ mkdirSync(HOME, { recursive: true });
+});
+
+afterEach(() => {
+ rmSync(TEST_ROOT, { recursive: true, force: true });
+});
+
+describe("provisionClaudeProfile", () => {
+ it("shares every capability surface into the profile", async () => {
+ seedDefaultAccount();
+ seedProfile();
+
+ const report = await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+
+ expect(report.surfaces["skills/"]).toBe("linked");
+ expect(report.surfaces["plugins/"]).toBe("linked");
+ expect(report.surfaces["agents/"]).toBe("linked");
+ // The default account has none of these; nothing to share.
+ expect(report.surfaces["commands/"]).toBe("absent");
+ expect(lstatSync(path.join(PROFILE, "skills")).isSymbolicLink()).toBe(true);
+ expect(
+ readFileSync(
+ path.join(PROFILE, "skills", "redesign", "SKILL.md"),
+ "utf-8",
+ ),
+ ).toBe("# redesign");
+ expect(readFileSync(path.join(PROFILE, "CLAUDE.md"), "utf-8")).toBe(
+ "be brief",
+ );
+ });
+
+ it("shares settings but never the login's own resolution", async () => {
+ seedDefaultAccount();
+ seedProfile();
+
+ await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+
+ const settings = readJson(path.join(PROFILE, "settings.json"));
+ expect(settings.model).toBe("claude-opus-5");
+ expect(settings.effortLevel).toBe("high");
+ expect(settings.enabledPlugins).toEqual({ "superset@superset": true });
+ expect(settings.env).toEqual({ DISABLE_TELEMETRY: "1" });
+ expect(settings.apiKeyHelper).toBeUndefined();
+ // Hooks are provisioned per profile, not copied from the default's file.
+ const hooks = settings.hooks as Record;
+ expect(JSON.stringify(hooks)).not.toContain("user-hook");
+ expect(JSON.stringify(hooks)).toContain("notify.sh");
+ });
+
+ it("shares MCP servers and completes onboarding without touching identity", async () => {
+ seedDefaultAccount();
+ seedProfile();
+
+ await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+
+ const state = readJson(path.join(PROFILE, ".claude.json"));
+ expect(state.mcpServers).toEqual({ linear: { command: "linear-mcp" } });
+ expect(state.theme).toBe("dark");
+ expect(state.hasCompletedOnboarding).toBe(true);
+ expect(state.oauthAccount).toEqual({ emailAddress: "work@example.com" });
+ expect(state.userID).toBe("work-user");
+ // Credentials are the one thing an account owns outright.
+ expect(readFileSync(path.join(PROFILE, ".credentials.json"), "utf-8")).toBe(
+ "{}",
+ );
+ expect(existsSync(path.join(DEFAULT_DIR, ".credentials.json"))).toBe(false);
+ });
+
+ it("skips the state merge until the profile has logged in", async () => {
+ seedDefaultAccount();
+ mkdirSync(PROFILE, { recursive: true });
+
+ const report = await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+
+ expect(report.surfaces[".claude.json"]).toBe("absent");
+ expect(existsSync(path.join(PROFILE, ".claude.json"))).toBe(false);
+ });
+
+ it("is idempotent", async () => {
+ seedDefaultAccount();
+ seedProfile();
+
+ await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+ const second = await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+
+ expect(second.surfaces["skills/"]).toBe("unchanged");
+ expect(second.surfaces["CLAUDE.md"]).toBe("unchanged");
+ expect(second.surfaces["settings.json"]).toBe("unchanged");
+ expect(second.surfaces[".claude.json"]).toBe("unchanged");
+ });
+
+ it("writes the bundled plugin into a skills dir the profile owns", async () => {
+ seedDefaultAccount();
+ seedProfile();
+ mkdirSync(path.join(PROFILE, "skills", "mine"), { recursive: true });
+ writeFileSync(path.join(PROFILE, "skills", "mine", "SKILL.md"), "# mine");
+
+ const report = await provisionClaudeProfile(PROFILE, { homeDir: HOME });
+
+ expect(report.surfaces["skills/"]).toBe("user-owned");
+ expect(existsSync(path.join(PROFILE, "skills", "mine", "SKILL.md"))).toBe(
+ true,
+ );
+ expect(existsSync(path.join(PROFILE, "skills", "superset", "skills"))).toBe(
+ true,
+ );
+ });
+
+ it("does nothing to the default account itself", async () => {
+ seedDefaultAccount();
+
+ const report = await provisionClaudeProfile(DEFAULT_DIR, { homeDir: HOME });
+
+ expect(report.surfaces).toEqual({});
+ expect(existsSync(path.join(DEFAULT_DIR, ".superset-profile.json"))).toBe(
+ false,
+ );
+ });
+});
+
+describe("provisionCodexProfile", () => {
+ const DEFAULT_CODEX = path.join(HOME, ".codex");
+ const CODEX_PROFILE = path.join(HOME, ".codex-work");
+
+ it("shares prompts, config, and instructions but not auth", async () => {
+ mkdirSync(path.join(DEFAULT_CODEX, "prompts"), { recursive: true });
+ writeFileSync(path.join(DEFAULT_CODEX, "prompts", "review.md"), "review");
+ writeFileSync(
+ path.join(DEFAULT_CODEX, "config.toml"),
+ 'model = "gpt-5"\n[mcp_servers.linear]\ncommand = "linear-mcp"\n',
+ );
+ writeFileSync(path.join(DEFAULT_CODEX, "AGENTS.md"), "be brief");
+ writeFileSync(path.join(DEFAULT_CODEX, "auth.json"), '{"tokens":{}}');
+ mkdirSync(CODEX_PROFILE, { recursive: true });
+
+ const report = await provisionCodexProfile(CODEX_PROFILE, {
+ homeDir: HOME,
+ });
+
+ expect(report.surfaces["prompts/"]).toBe("linked");
+ expect(
+ readFileSync(path.join(CODEX_PROFILE, "config.toml"), "utf-8"),
+ ).toContain("linear-mcp");
+ expect(readFileSync(path.join(CODEX_PROFILE, "AGENTS.md"), "utf-8")).toBe(
+ "be brief",
+ );
+ expect(existsSync(path.join(CODEX_PROFILE, "auth.json"))).toBe(false);
+ expect(
+ readFileSync(path.join(CODEX_PROFILE, "hooks.json"), "utf-8"),
+ ).toContain("notify.sh");
+ });
+});
+
+describe("default homes are never provisioning targets", () => {
+ it("refuses ~/.config/claude and the home dir itself", async () => {
+ const configClaude = path.join(HOME, ".config", "claude");
+ mkdirSync(configClaude, { recursive: true });
+ for (const target of [configClaude, HOME, path.join(HOME, ".config")]) {
+ const report = await provisionClaudeProfile(target, { homeDir: HOME });
+ expect(report.surfaces).toEqual({});
+ }
+ expect(existsSync(path.join(configClaude, "skills"))).toBe(false);
+ expect(existsSync(path.join(HOME, ".superset-profile.json"))).toBe(false);
+ });
+});
diff --git a/packages/agent-setup/src/provider-profiles.ts b/packages/agent-setup/src/provider-profiles.ts
new file mode 100644
index 00000000000..798e106d948
--- /dev/null
+++ b/packages/agent-setup/src/provider-profiles.ts
@@ -0,0 +1,305 @@
+/**
+ * Makes a provider account (a CLAUDE_CONFIG_DIR / CODEX_HOME profile dir) a
+ * complete environment rather than a bare login.
+ *
+ * The CLIs read *everything* — skills, plugins, subagents, commands, MCP
+ * servers, settings — from their active config dir, so pointing one at a
+ * second dir to switch accounts also swapped the user's whole setup for an
+ * empty one. Provisioning shares each of those surfaces from the default
+ * account (see profile-sharing.ts for the link/sync/merge rules) and leaves
+ * exactly one thing per-account: the login, plus the transcript and session
+ * state that belongs to it.
+ *
+ * Idempotent and safe to re-run: it is called when an account is added, when
+ * one is selected, and for the selected accounts at host boot.
+ */
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ ensureClaudeManagedHooksAt,
+ ensureCodexManagedHooksAt,
+} from "./agent-wrappers-claude-codex-opencode";
+import { provisionManagedClaudePluginAt } from "./managed-skills";
+import {
+ linkSharedDir,
+ mergeSharedJsonKeys,
+ readProfileLedger,
+ type SurfaceOutcome,
+ syncSharedFile,
+ writeProfileLedger,
+} from "./profile-sharing";
+
+export interface ProfileProvisionReport {
+ configDir: string;
+ /** Surface name -> what provisioning did with it, for setup logs. */
+ surfaces: Record;
+}
+
+export interface ProvisionProfileOptions {
+ /** Overridden in tests; the real home in every other caller. */
+ homeDir?: string;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Real path when the dir exists, plain resolution otherwise — a symlink
+ * alias of a protected dir must compare equal to it. */
+function canonical(target: string): string {
+ try {
+ return fs.realpathSync(target);
+ } catch {
+ return path.resolve(target);
+ }
+}
+
+/**
+ * True for the share's source and the other default homes — provisioning a
+ * default home would link it into itself (discovery excludes them, but a
+ * CLAUDE_CONFIG_DIR env entry can name one directly, including via symlink).
+ */
+function isProtectedTarget(
+ target: string,
+ homeDir: string,
+ defaultDir: string,
+): boolean {
+ const resolved = canonical(target);
+ const protectedDirs = [
+ homeDir,
+ defaultDir,
+ path.join(homeDir, ".config"),
+ path.join(homeDir, ".config", "claude"),
+ ];
+ return protectedDirs.some((dir) => resolved === canonical(dir));
+}
+
+function logReport(provider: string, report: ProfileProvisionReport): void {
+ const summary = Object.entries(report.surfaces)
+ .map(([surface, outcome]) => `${surface}=${outcome}`)
+ .join(" ");
+ console.log(
+ `[agent-setup] Provisioned ${provider} account ${report.configDir}: ${summary}`,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Claude Code
+// ---------------------------------------------------------------------------
+
+/**
+ * Directories shared by symlink. Everything Claude Code loads as user-level
+ * capability lives in one of these; what's left in a config dir is the login
+ * and its own history (projects/, sessions/, shell-snapshots/, …).
+ */
+const CLAUDE_SHARED_DIRS = [
+ "skills",
+ "plugins",
+ "agents",
+ "commands",
+ "output-styles",
+] as const;
+
+/** Files copied verbatim (the CLI rename-replaces them, so no symlink). */
+const CLAUDE_SHARED_FILES = ["CLAUDE.md"] as const;
+
+/**
+ * settings.json keys that must NOT follow the account. Everything else is
+ * shared by default so a key added by a future CLI release comes along
+ * without a code change here.
+ */
+const CLAUDE_PRIVATE_SETTINGS_KEYS = new Set([
+ // Provisioned per profile right after the merge — the hooks are identical,
+ // but they are ours to write, not the user's to share.
+ "hooks",
+ // Auth resolution: sharing these would let one account's credential
+ // mechanism silently answer for another.
+ "apiKeyHelper",
+ "awsAuthRefresh",
+ "awsCredentialExport",
+ "forceLoginMethod",
+ "forceLoginOrgUUID",
+]);
+
+/** `env` entries holding a credential; the rest of `env` is shared. */
+const CLAUDE_PRIVATE_ENV_KEYS = new Set([
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_AUTH_TOKEN",
+ "CLAUDE_CODE_OAUTH_TOKEN",
+]);
+
+/**
+ * `.claude.json` keys that are shared — an allowlist, inverted from
+ * settings.json on purpose: that file is mostly identity (oauthAccount,
+ * userID), per-project transcript history, and server-driven caches, none of
+ * which may cross accounts.
+ */
+const CLAUDE_SHARED_STATE_KEYS = ["mcpServers", "theme"] as const;
+
+function pickClaudeSettings(
+ source: Record,
+): Record {
+ const shared: Record = {};
+ for (const [key, value] of Object.entries(source)) {
+ if (CLAUDE_PRIVATE_SETTINGS_KEYS.has(key)) continue;
+ if (key === "env" && isPlainObject(value)) {
+ shared.env = Object.fromEntries(
+ Object.entries(value).filter(
+ ([name]) => !CLAUDE_PRIVATE_ENV_KEYS.has(name),
+ ),
+ );
+ continue;
+ }
+ shared[key] = value;
+ }
+ return shared;
+}
+
+function pickClaudeState(
+ source: Record,
+): Record {
+ const shared: Record = {};
+ for (const key of CLAUDE_SHARED_STATE_KEYS) {
+ if (key in source) shared[key] = source[key];
+ }
+ return shared;
+}
+
+/**
+ * Brings one Claude config dir up to date with the default account. A no-op
+ * for `~/.claude` itself, which is the source of every share.
+ */
+export async function provisionClaudeProfile(
+ configDir: string,
+ options: ProvisionProfileOptions = {},
+): Promise {
+ const homeDir = options.homeDir ?? os.homedir();
+ const defaultDir = path.join(homeDir, ".claude");
+ const target = path.resolve(configDir);
+ const surfaces: Record = {};
+ if (isProtectedTarget(target, homeDir, defaultDir)) {
+ return { configDir: target, surfaces };
+ }
+
+ fs.mkdirSync(target, { recursive: true });
+ const ledger = readProfileLedger(target, defaultDir);
+
+ for (const name of CLAUDE_SHARED_DIRS) {
+ surfaces[`${name}/`] = linkSharedDir(
+ path.join(defaultDir, name),
+ path.join(target, name),
+ );
+ }
+ for (const name of CLAUDE_SHARED_FILES) {
+ surfaces[name] = syncSharedFile({
+ sourcePath: path.join(defaultDir, name),
+ targetPath: path.join(target, name),
+ surface: name,
+ ledger,
+ });
+ }
+
+ surfaces["settings.json"] = mergeSharedJsonKeys({
+ sourcePath: path.join(defaultDir, "settings.json"),
+ targetPath: path.join(target, "settings.json"),
+ surface: "settings.json",
+ ledger,
+ pick: pickClaudeSettings,
+ });
+
+ // Only the default account keeps its state next door at `~/.claude.json`;
+ // a custom config dir keeps it inside.
+ surfaces[".claude.json"] = mergeSharedJsonKeys({
+ sourcePath: path.join(homeDir, ".claude.json"),
+ targetPath: path.join(target, ".claude.json"),
+ surface: ".claude.json",
+ ledger,
+ pick: pickClaudeState,
+ // Without this the profile's first launch opens the first-boot wizard,
+ // where a stray Enter starts a login that silently rebinds the profile.
+ force: { hasCompletedOnboarding: true },
+ // No parsable state file means no completed login yet — writing one
+ // would fabricate an account that doesn't exist.
+ requireExistingTarget: true,
+ });
+
+ writeProfileLedger(target, ledger);
+ // After the settings merge: the merge only writes shared keys, and the
+ // hook entries have to survive it.
+ ensureClaudeManagedHooksAt(target);
+
+ if (surfaces["skills/"] === "user-owned") {
+ // The profile brought its own skills dir, so it isn't sharing ours —
+ // the bundled Superset plugin has to be written into it directly.
+ await provisionManagedClaudePluginAt(target);
+ surfaces["skills/superset"] = "synced";
+ }
+
+ const report = { configDir: target, surfaces };
+ logReport("Claude", report);
+ return report;
+}
+
+// ---------------------------------------------------------------------------
+// Codex
+// ---------------------------------------------------------------------------
+
+/** `prompts/` holds custom slash-command prompts; the rest of a Codex home
+ * is auth, history, and rollouts. */
+const CODEX_SHARED_DIRS = ["prompts"] as const;
+
+/**
+ * `config.toml` carries the model, approval policy, and MCP servers;
+ * `AGENTS.md` the global instructions. Neither holds credentials — Codex
+ * keeps those in `auth.json`, which stays per-account.
+ */
+const CODEX_SHARED_FILES = ["config.toml", "AGENTS.md"] as const;
+
+function defaultCodexHome(homeDir: string, homeDirOverridden: boolean): string {
+ // An overridden homeDir (tests) must win over the ambient CODEX_HOME, or
+ // the provision would share from the real machine's Codex home.
+ if (homeDirOverridden) return path.join(homeDir, ".codex");
+ const fromEnv = process.env.CODEX_HOME?.trim();
+ return fromEnv ? path.resolve(fromEnv) : path.join(homeDir, ".codex");
+}
+
+/** Brings one Codex home up to date with the default one. */
+export async function provisionCodexProfile(
+ home: string,
+ options: ProvisionProfileOptions = {},
+): Promise {
+ const homeDir = options.homeDir ?? os.homedir();
+ const defaultDir = defaultCodexHome(homeDir, options.homeDir !== undefined);
+ const target = path.resolve(home);
+ const surfaces: Record = {};
+ if (isProtectedTarget(target, homeDir, defaultDir)) {
+ return { configDir: target, surfaces };
+ }
+
+ fs.mkdirSync(target, { recursive: true });
+ const ledger = readProfileLedger(target, defaultDir);
+
+ for (const name of CODEX_SHARED_DIRS) {
+ surfaces[`${name}/`] = linkSharedDir(
+ path.join(defaultDir, name),
+ path.join(target, name),
+ );
+ }
+ for (const name of CODEX_SHARED_FILES) {
+ surfaces[name] = syncSharedFile({
+ sourcePath: path.join(defaultDir, name),
+ targetPath: path.join(target, name),
+ surface: name,
+ ledger,
+ });
+ }
+
+ writeProfileLedger(target, ledger);
+ ensureCodexManagedHooksAt(target);
+
+ const report = { configDir: target, surfaces };
+ logReport("Codex", report);
+ return report;
+}
diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts
index f51ce8e582c..dca7c54c944 100644
--- a/packages/host-service/src/app.ts
+++ b/packages/host-service/src/app.ts
@@ -30,6 +30,7 @@ import {
TerminalAgentStore,
} from "./terminal-agents";
import { appRouter } from "./trpc/router";
+import { provisionSelectedAccounts } from "./trpc/router/usage/account-provisioning";
import {
execGh as defaultExecGh,
type ExecGh,
@@ -239,6 +240,12 @@ export function createApp(options: CreateAppOptions): CreateAppResult {
}).catch((err) => {
console.warn("[host-service] archived-workspace reconcile failed:", err);
});
+ // Re-share the default account's Claude/Codex config into the selected
+ // provider profiles. Last: it touches no host state the sweeps above
+ // repair, and a slow filesystem must not delay them.
+ await provisionSelectedAccounts(db).catch((err) => {
+ console.warn("[host-service] account provisioning failed:", err);
+ });
})();
const wsAuth: MiddlewareHandler = async (c, next) => {
diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts
index b688c015fde..fa6ecd7c393 100644
--- a/packages/host-service/src/terminal/terminal.ts
+++ b/packages/host-service/src/terminal/terminal.ts
@@ -2480,8 +2480,8 @@ export async function createTerminalSessionInternal({
hostAgentHookUrl: getHostAgentHookUrl(),
}),
// Usage-tab default account: provider CLIs typed or preset-launched in
- // this terminal run on the selected login. Baked at spawn — existing
- // terminals keep the account they started with.
+ // this terminal run on the selected login. Baked at spawn as the fast
+ // path; the agent wrappers re-resolve later switches at launch time.
...resolveDefaultAccountTerminalEnv(db),
};
diff --git a/packages/host-service/src/trpc/router/usage/account-provisioning.ts b/packages/host-service/src/trpc/router/usage/account-provisioning.ts
new file mode 100644
index 00000000000..9e1bdf23537
--- /dev/null
+++ b/packages/host-service/src/trpc/router/usage/account-provisioning.ts
@@ -0,0 +1,64 @@
+/**
+ * Keeps the selected provider accounts provisioned — see
+ * packages/agent-setup/src/provider-profiles.ts for what that means. Split
+ * from default-account.ts so the terminal's env-resolution path (loaded by
+ * node --test) doesn't pull the whole agent-setup surface in.
+ */
+
+import { existsSync } from "node:fs";
+import {
+ provisionClaudeProfile,
+ provisionCodexProfile,
+} from "@superset/agent-setup";
+import type { HostDb } from "../../../db/index.ts";
+import {
+ getDefaultAccountSelections,
+ syncDefaultAccountPointers,
+} from "./default-account.ts";
+import { shareClaudeSessionState } from "./session-share.ts";
+
+/**
+ * Everything a Claude account needs on this host: the shared session state
+ * (one `--resume` history across accounts — session-share.ts) plus the
+ * config surfaces agent-setup owns (skills, plugins, settings, MCP servers).
+ */
+export async function provisionClaudeAccount(configDir: string): Promise {
+ shareClaudeSessionState(configDir);
+ await provisionClaudeProfile(configDir);
+}
+
+/**
+ * Re-shares the default account's config into whichever profiles are
+ * currently selected. Runs at host boot so a profile keeps up with skills,
+ * plugins, and settings added since it was selected, and so one provisioned
+ * by an older build — or by a switch that failed halfway — is repaired
+ * before the next agent launches on it.
+ */
+export async function provisionSelectedAccounts(db: HostDb): Promise {
+ // Heal the wrapper pointer files first — a build predating them (or a
+ // crashed switch) leaves agents launching on a stale spawn-time default.
+ syncDefaultAccountPointers(db);
+ const { claudeConfigDir, codexHome } = getDefaultAccountSelections(db);
+ const targets: Array Promise]> = [];
+ // A pointer at a vanished dir is skipped, not recreated: agent launches
+ // already fall back to the system-default login in that case.
+ if (claudeConfigDir && existsSync(claudeConfigDir)) {
+ targets.push([
+ claudeConfigDir,
+ () => provisionClaudeAccount(claudeConfigDir),
+ ]);
+ }
+ if (codexHome && existsSync(codexHome)) {
+ targets.push([codexHome, () => provisionCodexProfile(codexHome)]);
+ }
+ for (const [dir, provision] of targets) {
+ try {
+ await provision();
+ } catch (error) {
+ console.warn(
+ `[host-service] provisioning the selected account ${dir} failed (continuing):`,
+ error,
+ );
+ }
+ }
+}
diff --git a/packages/host-service/src/trpc/router/usage/default-account.ts b/packages/host-service/src/trpc/router/usage/default-account.ts
index ab209f1349d..879a8fdaed0 100644
--- a/packages/host-service/src/trpc/router/usage/default-account.ts
+++ b/packages/host-service/src/trpc/router/usage/default-account.ts
@@ -5,11 +5,55 @@
* provider CLI itself keeps owning every login end to end.
*/
-import { existsSync } from "node:fs";
+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
import type { HostDb } from "../../../db/index.ts";
import { hostSettings } from "../../../db/schema.ts";
import type { UsageProvider } from "./types.ts";
+const POINTER_NAMES: Record = {
+ claude: "default-claude-config-dir",
+ codex: "default-codex-home",
+};
+
+/**
+ * Mirror of agent-setup's resolveSupersetHomeDir, not imported: this module
+ * sits on the terminal env-resolution path (loaded by node --test) and must
+ * stay free of the agent-setup surface — see account-provisioning.ts.
+ */
+function supersetHomeDir(): string {
+ return process.env.SUPERSET_HOME_DIR?.trim() || join(homedir(), ".superset");
+}
+
+/**
+ * Publishes a selection where the agent wrappers can re-read it on every
+ * launch (buildDefaultAccountResolver in agent-setup), so switching accounts
+ * reaches existing terminals the next time the agent starts — the PTY env
+ * alone is frozen at spawn. Empty file = system default. Best-effort: the DB
+ * stays the source of truth and the wrapper falls back to the spawn-time env.
+ */
+export function syncDefaultAccountPointer(
+ provider: UsageProvider,
+ selection: string | null,
+): void {
+ try {
+ const dir = join(supersetHomeDir(), "state");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, POINTER_NAMES[provider]), selection ?? "");
+ } catch {
+ // Wrapper keeps using the spawn-time env until the next successful sync.
+ }
+}
+
+/** Reconciles both pointer files from the DB — run at host boot so files
+ * from an older build (or a crashed switch) heal. */
+export function syncDefaultAccountPointers(db: HostDb): void {
+ const selections = getDefaultAccountSelections(db);
+ syncDefaultAccountPointer("claude", selections.claudeConfigDir);
+ syncDefaultAccountPointer("codex", selections.codexHome);
+}
+
export interface DefaultAccountSelections {
/** CLAUDE_CONFIG_DIR to inject, or null for the system-default login. */
claudeConfigDir: string | null;
@@ -40,12 +84,15 @@ export function setDefaultAccountSelection(
.values({ id: 1, ...values })
.onConflictDoUpdate({ target: hostSettings.id, set: values })
.run();
+ syncDefaultAccountPointer(provider, selection);
}
/**
* Env for a new terminal so provider CLIs typed or launched in it run on the
* host-default accounts. Both providers' vars — a shell can run either CLI.
- * Baked at PTY spawn; existing terminals keep the account they started with.
+ * Baked at PTY spawn as the fast path; the agent wrappers re-resolve from the
+ * pointer files at every launch, so a later switch still reaches this
+ * terminal when the agent is relaunched.
*/
export function resolveDefaultAccountTerminalEnv(
db: HostDb,
@@ -72,14 +119,23 @@ export function resolveDefaultAccountEnv(
selections.claudeConfigDir &&
existsSync(selections.claudeConfigDir)
) {
- return { CLAUDE_CONFIG_DIR: selections.claudeConfigDir };
+ // The SUPERSET_DEFAULT_* twin marks the value as Superset-injected, so
+ // the agent wrapper can re-resolve a later switch without ever
+ // overriding a value the user exported by hand.
+ return {
+ CLAUDE_CONFIG_DIR: selections.claudeConfigDir,
+ SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: selections.claudeConfigDir,
+ };
}
if (
presetId === "codex" &&
selections.codexHome &&
existsSync(selections.codexHome)
) {
- return { CODEX_HOME: selections.codexHome };
+ return {
+ CODEX_HOME: selections.codexHome,
+ SUPERSET_DEFAULT_CODEX_HOME: selections.codexHome,
+ };
}
return {};
}
diff --git a/packages/host-service/src/trpc/router/usage/history/aggregate.ts b/packages/host-service/src/trpc/router/usage/history/aggregate.ts
index c6535a22dd5..09c3e57400e 100644
--- a/packages/host-service/src/trpc/router/usage/history/aggregate.ts
+++ b/packages/host-service/src/trpc/router/usage/history/aggregate.ts
@@ -1,3 +1,4 @@
+import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import { discoverClaudeProfiles, discoverCodexHomes } from "../profiles";
@@ -188,18 +189,34 @@ export async function computeUsageHistory(
]);
for (const profile of claudeProfiles) claudeHomes.add(profile.configDir);
- const [claudeFileGroups, codexFileGroups] = await Promise.all([
- Promise.all(
- [...claudeHomes].map((root) =>
- collectLogFiles(join(root, "projects"), days + 1),
- ),
- ),
- Promise.all(
- codexHomes.map((codexHome) =>
- collectLogFiles(join(codexHome.home, "sessions"), days + 1),
- ),
+ // Shared-history profiles symlink their projects/ into ~/.claude (see
+ // session-share.ts), so two homes can name the same tree under different
+ // paths — resolve every scan root and dedupe by real path, or the tree
+ // gets walked and parsed once per profile.
+ const resolveRoots = async (roots: string[]): Promise => {
+ const resolved = await Promise.all(
+ roots.map(async (root) => {
+ try {
+ return await realpath(root);
+ } catch {
+ return null; // Dir absent — collectLogFiles would find nothing.
+ }
+ }),
+ );
+ return [
+ ...new Set(resolved.filter((root): root is string => root !== null)),
+ ];
+ };
+ const [claudeRoots, codexRoots] = await Promise.all([
+ resolveRoots([...claudeHomes].map((root) => join(root, "projects"))),
+ resolveRoots(
+ codexHomes.map((codexHome) => join(codexHome.home, "sessions")),
),
]);
+ const [claudeFileGroups, codexFileGroups] = await Promise.all([
+ Promise.all(claudeRoots.map((root) => collectLogFiles(root, days + 1))),
+ Promise.all(codexRoots.map((root) => collectLogFiles(root, days + 1))),
+ ]);
const claudeFiles = dedupeLogFiles(claudeFileGroups.flat());
const codexFiles = dedupeLogFiles(codexFileGroups.flat());
diff --git a/packages/host-service/src/trpc/router/usage/profile-remove.test.ts b/packages/host-service/src/trpc/router/usage/profile-remove.test.ts
index 8cea1f54535..c240fa8d9d0 100644
--- a/packages/host-service/src/trpc/router/usage/profile-remove.test.ts
+++ b/packages/host-service/src/trpc/router/usage/profile-remove.test.ts
@@ -1,5 +1,13 @@
import { describe, expect, it } from "bun:test";
-import { homedir } from "node:os";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import {
assertRemovableProfileDir,
@@ -58,3 +66,34 @@ describe("remove functions refuse protected dirs before touching the disk", () =
);
});
});
+
+// A provisioned profile links its capability dirs at the default account's
+// (packages/agent-setup/src/provider-profiles.ts). Removing the profile must
+// unlink those, never delete through them.
+describe("removeClaudeProfile with shared-config links", () => {
+ it("leaves the default account's linked dirs intact", async () => {
+ const root = mkdtempSync(join(tmpdir(), "superset-profile-remove-"));
+ try {
+ const sharedSkills = join(root, "default-skills");
+ mkdirSync(join(sharedSkills, "redesign"), { recursive: true });
+ writeFileSync(join(sharedSkills, "redesign", "SKILL.md"), "# redesign");
+
+ // Under the home dir so the removal guards accept it.
+ const profile = mkdtempSync(join(homedir(), ".claude-remove-test-"));
+ try {
+ symlinkSync(sharedSkills, join(profile, "skills"), "dir");
+
+ await removeClaudeProfile(profile);
+
+ expect(existsSync(profile)).toBe(false);
+ expect(existsSync(join(sharedSkills, "redesign", "SKILL.md"))).toBe(
+ true,
+ );
+ } finally {
+ rmSync(profile, { recursive: true, force: true });
+ }
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/host-service/src/trpc/router/usage/profile-seed.test.ts b/packages/host-service/src/trpc/router/usage/profile-seed.test.ts
deleted file mode 100644
index ddf242648ef..00000000000
--- a/packages/host-service/src/trpc/router/usage/profile-seed.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "bun:test";
-import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { seedClaudeProfileOnboarding } from "./profile-seed";
-
-function makeProfile(state: unknown): string {
- const dir = mkdtempSync(join(tmpdir(), "claude-profile-seed-"));
- if (state !== undefined) {
- writeFileSync(join(dir, ".claude.json"), JSON.stringify(state));
- }
- return dir;
-}
-
-function readState(dir: string): Record {
- return JSON.parse(readFileSync(join(dir, ".claude.json"), "utf-8"));
-}
-
-describe("seedClaudeProfileOnboarding", () => {
- it("marks onboarding complete and preserves the CLI's other state", () => {
- const dir = makeProfile({
- oauthAccount: { emailAddress: "a@b.c" },
- numStartups: 3,
- });
- seedClaudeProfileOnboarding(dir);
- const state = readState(dir);
- expect(state.hasCompletedOnboarding).toBe(true);
- expect(state.oauthAccount).toEqual({ emailAddress: "a@b.c" });
- expect(state.numStartups).toBe(3);
- });
-
- it("never overwrites a theme the profile already chose", () => {
- const dir = makeProfile({ theme: "light" });
- seedClaudeProfileOnboarding(dir);
- expect(readState(dir).theme).toBe("light");
- });
-
- it("does nothing when onboarding is already complete", () => {
- const dir = makeProfile({ hasCompletedOnboarding: true, theme: "light" });
- const before = readFileSync(join(dir, ".claude.json"), "utf-8");
- seedClaudeProfileOnboarding(dir);
- expect(readFileSync(join(dir, ".claude.json"), "utf-8")).toBe(before);
- });
-
- it("does not create a state file where none exists (no login yet)", () => {
- const dir = makeProfile(undefined);
- seedClaudeProfileOnboarding(dir);
- expect(() => readState(dir)).toThrow();
- });
-});
diff --git a/packages/host-service/src/trpc/router/usage/profile-seed.ts b/packages/host-service/src/trpc/router/usage/profile-seed.ts
deleted file mode 100644
index 7db17770583..00000000000
--- a/packages/host-service/src/trpc/router/usage/profile-seed.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Marks a secondary Claude profile's onboarding as complete so its first
- * agent launch goes straight to the prompt instead of the first-boot wizard
- * (where a stray Enter can start a login that silently rebinds the profile).
- *
- * This edits the CLI's state file (`.claude.json`), never a credential
- * store, and only ever adds two preference keys — everything else is left
- * exactly as the CLI wrote it.
- */
-
-import { readFileSync, writeFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-
-export function seedClaudeProfileOnboarding(configDir: string): void {
- const statePath = join(configDir, ".claude.json");
- let state: Record;
- try {
- state = JSON.parse(readFileSync(statePath, "utf-8"));
- } catch {
- // No parsable state file means no completed login yet — nothing to seed.
- return;
- }
- if (state.hasCompletedOnboarding === true) return;
- state.hasCompletedOnboarding = true;
- if (state.theme === undefined) {
- // Carry the default profile's theme so the first launch looks familiar.
- try {
- const defaultState = JSON.parse(
- readFileSync(join(homedir(), ".claude.json"), "utf-8"),
- ) as { theme?: unknown };
- if (typeof defaultState.theme === "string") {
- state.theme = defaultState.theme;
- }
- } catch {
- // Theme stays unset; the CLI falls back to its own default.
- }
- }
- writeFileSync(statePath, JSON.stringify(state, null, 2));
-}
diff --git a/packages/host-service/src/trpc/router/usage/session-share.test.ts b/packages/host-service/src/trpc/router/usage/session-share.test.ts
new file mode 100644
index 00000000000..b486b91d8f6
--- /dev/null
+++ b/packages/host-service/src/trpc/router/usage/session-share.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it } from "bun:test";
+import {
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ readFileSync,
+ readlinkSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { shareableProfileDir, shareClaudeSessionState } from "./session-share";
+
+function makeDirs(): { profile: string; main: string } {
+ const root = mkdtempSync(join(tmpdir(), "claude-session-share-"));
+ const profile = join(root, "profile");
+ const main = join(root, "main");
+ mkdirSync(profile);
+ mkdirSync(main);
+ return { profile, main };
+}
+
+function isLinkTo(path: string, target: string): boolean {
+ return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
+}
+
+function lstatOrNull(path: string) {
+ try {
+ return lstatSync(path);
+ } catch {
+ return null;
+ }
+}
+
+describe("shareableProfileDir", () => {
+ it("refuses the default homes and the main home itself", () => {
+ const home = homedir();
+ const main = join(home, ".claude");
+ expect(shareableProfileDir(home, main)).toBeNull();
+ expect(shareableProfileDir(join(home, ".claude"), main)).toBeNull();
+ expect(
+ shareableProfileDir(join(home, ".config", "claude"), main),
+ ).toBeNull();
+ expect(
+ shareableProfileDir("/tmp/custom-main", "/tmp/custom-main"),
+ ).toBeNull();
+ });
+
+ it("accepts an ordinary profile dir", () => {
+ const home = homedir();
+ expect(
+ shareableProfileDir(join(home, ".claude-work"), join(home, ".claude")),
+ ).toBe(join(home, ".claude-work"));
+ });
+});
+
+describe("shareClaudeSessionState", () => {
+ it("links a fresh profile's session entries into main", () => {
+ const { profile, main } = makeDirs();
+ shareClaudeSessionState(profile, main);
+ for (const name of ["projects", "sessions", "file-history", "todos"]) {
+ expect(isLinkTo(join(profile, name), join(main, name))).toBe(true);
+ expect(lstatSync(join(main, name)).isDirectory()).toBe(true);
+ }
+ expect(
+ isLinkTo(join(profile, "history.jsonl"), join(main, "history.jsonl")),
+ ).toBe(true);
+ });
+
+ it("merges existing session trees into main before linking", () => {
+ const { profile, main } = makeDirs();
+ mkdirSync(join(profile, "projects", "-repo-a"), { recursive: true });
+ writeFileSync(join(profile, "projects", "-repo-a", "s1.jsonl"), "a");
+ mkdirSync(join(main, "projects", "-repo-b"), { recursive: true });
+ writeFileSync(join(main, "projects", "-repo-b", "s2.jsonl"), "b");
+ shareClaudeSessionState(profile, main);
+ expect(isLinkTo(join(profile, "projects"), join(main, "projects"))).toBe(
+ true,
+ );
+ expect(
+ readFileSync(join(main, "projects", "-repo-a", "s1.jsonl"), "utf-8"),
+ ).toBe("a");
+ expect(
+ readFileSync(join(main, "projects", "-repo-b", "s2.jsonl"), "utf-8"),
+ ).toBe("b");
+ // The merged file is reachable through the profile's own path too.
+ expect(
+ readFileSync(join(profile, "projects", "-repo-a", "s1.jsonl"), "utf-8"),
+ ).toBe("a");
+ });
+
+ it("leaves conflicting files behind in the pending dir and still links", () => {
+ const { profile, main } = makeDirs();
+ mkdirSync(join(profile, "projects", "-repo"), { recursive: true });
+ writeFileSync(join(profile, "projects", "-repo", "s.jsonl"), "profile");
+ mkdirSync(join(main, "projects", "-repo"), { recursive: true });
+ writeFileSync(join(main, "projects", "-repo", "s.jsonl"), "main");
+ shareClaudeSessionState(profile, main);
+ expect(isLinkTo(join(profile, "projects"), join(main, "projects"))).toBe(
+ true,
+ );
+ expect(
+ readFileSync(join(main, "projects", "-repo", "s.jsonl"), "utf-8"),
+ ).toBe("main");
+ expect(
+ readFileSync(
+ join(profile, "projects.superset-merge", "-repo", "s.jsonl"),
+ "utf-8",
+ ),
+ ).toBe("profile");
+ });
+
+ it("flushes a pending dir left by an interrupted merge", () => {
+ const { profile, main } = makeDirs();
+ mkdirSync(join(profile, "projects.superset-merge", "-repo"), {
+ recursive: true,
+ });
+ writeFileSync(
+ join(profile, "projects.superset-merge", "-repo", "s.jsonl"),
+ "leftover",
+ );
+ symlinkSync(join(main, "projects"), join(profile, "projects"));
+ mkdirSync(join(main, "projects"));
+ shareClaudeSessionState(profile, main);
+ expect(
+ readFileSync(join(main, "projects", "-repo", "s.jsonl"), "utf-8"),
+ ).toBe("leftover");
+ expect(readdirSync(profile)).not.toContain("projects.superset-merge");
+ });
+
+ it("separates records when main's history lacks a trailing newline", () => {
+ const { profile, main } = makeDirs();
+ writeFileSync(join(main, "history.jsonl"), '{"m":1}'); // crash-truncated
+ writeFileSync(join(profile, "history.jsonl"), '{"p":1}\n');
+ shareClaudeSessionState(profile, main);
+ expect(readFileSync(join(main, "history.jsonl"), "utf-8")).toBe(
+ '{"m":1}\n{"p":1}\n',
+ );
+ });
+
+ it("refuses a profile dir that is a symlink alias of the main home", () => {
+ const { profile, main } = makeDirs();
+ const alias = join(profile, "..", "main-alias");
+ symlinkSync(main, alias);
+ expect(shareableProfileDir(alias, main)).toBeNull();
+ });
+
+ it("appends the profile's prompt history to main's", () => {
+ const { profile, main } = makeDirs();
+ writeFileSync(join(main, "history.jsonl"), '{"m":1}\n');
+ writeFileSync(join(profile, "history.jsonl"), '{"p":1}\n');
+ shareClaudeSessionState(profile, main);
+ expect(
+ isLinkTo(join(profile, "history.jsonl"), join(main, "history.jsonl")),
+ ).toBe(true);
+ expect(readFileSync(join(main, "history.jsonl"), "utf-8")).toBe(
+ '{"m":1}\n{"p":1}\n',
+ );
+ });
+
+ it("leaves config surfaces to agent-setup provisioning", () => {
+ const { profile, main } = makeDirs();
+ writeFileSync(join(main, "settings.json"), "{}");
+ writeFileSync(join(main, "CLAUDE.md"), "memory");
+ mkdirSync(join(main, "agents"));
+ shareClaudeSessionState(profile, main);
+ for (const name of ["settings.json", "CLAUDE.md", "agents", "skills"]) {
+ expect(lstatOrNull(join(profile, name))).toBeNull();
+ }
+ });
+
+ it("leaves existing symlinks alone, wherever they point", () => {
+ const { profile, main } = makeDirs();
+ const elsewhere = join(profile, "elsewhere");
+ mkdirSync(elsewhere);
+ symlinkSync(elsewhere, join(profile, "projects"));
+ shareClaudeSessionState(profile, main);
+ expect(readlinkSync(join(profile, "projects"))).toBe(elsewhere);
+ });
+
+ it("never touches identity or runtime entries", () => {
+ const { profile, main } = makeDirs();
+ writeFileSync(join(profile, ".claude.json"), '{"oauthAccount":{}}');
+ writeFileSync(join(profile, ".credentials.json"), "secret");
+ mkdirSync(join(profile, "daemon"));
+ shareClaudeSessionState(profile, main);
+ expect(lstatSync(join(profile, ".claude.json")).isSymbolicLink()).toBe(
+ false,
+ );
+ expect(lstatSync(join(profile, ".credentials.json")).isSymbolicLink()).toBe(
+ false,
+ );
+ expect(lstatSync(join(profile, "daemon")).isSymbolicLink()).toBe(false);
+ expect(readdirSync(main)).not.toContain(".claude.json");
+ expect(readdirSync(main)).not.toContain(".credentials.json");
+ });
+});
diff --git a/packages/host-service/src/trpc/router/usage/session-share.ts b/packages/host-service/src/trpc/router/usage/session-share.ts
new file mode 100644
index 00000000000..1502f02f55f
--- /dev/null
+++ b/packages/host-service/src/trpc/router/usage/session-share.ts
@@ -0,0 +1,243 @@
+/**
+ * Shares session state between a secondary Claude profile dir and the
+ * default `~/.claude` home via symlinks, so every login sees one
+ * conversation history — switching accounts stops meaning losing
+ * `--resume` and the prompt history.
+ *
+ * Only surfaces a symlink can survive are shared here: directories, and
+ * `history.jsonl`, which the CLI appends to in place. Config files like
+ * settings.json and `.claude.json` are written with write-tmp-then-rename —
+ * a rename would replace the symlink with a real file and silently fork the
+ * config — so those (plus skills, plugins, MCP servers) belong to
+ * agent-setup's ledger-based profile provisioning, not to this module.
+ * Identity (`.claude.json`, credential stores) and runtime dirs (daemon/,
+ * cache/, telemetry/, backups/) always stay per-profile.
+ *
+ * Existing real state is merged, not clobbered. Session trees are renamed
+ * aside, the symlink lands immediately, and files then move into `~/.claude`
+ * one by one: renames preserve inodes, so a live session's open transcripts
+ * stay valid and its paths resolve through the new link. The prompt history
+ * is appended. Anything that cannot merge safely is left where it is.
+ */
+
+import {
+ appendFileSync,
+ closeSync,
+ lstatSync,
+ mkdirSync,
+ openSync,
+ readdirSync,
+ readFileSync,
+ readSync,
+ realpathSync,
+ renameSync,
+ rmdirSync,
+ statSync,
+ symlinkSync,
+ unlinkSync,
+} from "node:fs";
+import { homedir, platform } from "node:os";
+import { join, resolve } from "node:path";
+
+/** Session-scoped state keyed by session UUID or cwd slug: safe to merge
+ * file-by-file, since two profiles' sessions collide no more than two
+ * concurrent sessions in one dir already do. */
+const SESSION_DIRS = [
+ "projects",
+ "sessions",
+ "session-env",
+ "file-history",
+ "shell-snapshots",
+ "todos",
+ "paste-cache",
+ "tasks",
+ "plans",
+ "transcripts",
+];
+
+const MERGE_SUFFIX = ".superset-merge";
+
+/** Real path when the dir exists (a symlink alias of a protected dir must
+ * compare equal to it), plain resolution otherwise. */
+function canonical(path: string): string {
+ try {
+ return realpathSync(path);
+ } catch {
+ return resolve(path);
+ }
+}
+
+/** Resolved profile dir, or null when it IS a default home (never share a
+ * default home into itself, and never operate on `~`). Compares real paths:
+ * a profile dir that is itself a symlink to `~/.claude` would otherwise pass
+ * and get linked into itself. */
+export function shareableProfileDir(
+ configDir: string,
+ mainHome: string,
+): string | null {
+ const resolved = canonical(configDir);
+ const home = homedir();
+ const excluded = new Set(
+ [
+ home,
+ join(home, ".claude"),
+ join(home, ".config"),
+ join(home, ".config", "claude"),
+ mainHome,
+ ].map(canonical),
+ );
+ return excluded.has(resolved) ? null : resolved;
+}
+
+function lstatOrNull(path: string) {
+ try {
+ return lstatSync(path);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Moves every file under `srcDir` into `dstDir`, creating dirs as needed.
+ * Conflicts (a path that already exists in `dstDir`) are left behind in
+ * `srcDir`; empty dirs are pruned as the move drains them.
+ */
+function moveTreeInto(srcDir: string, dstDir: string): void {
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
+ const from = join(srcDir, entry.name);
+ const to = join(dstDir, entry.name);
+ if (entry.isDirectory()) {
+ const toInfo = lstatOrNull(to);
+ if (!toInfo) {
+ renameSync(from, to);
+ continue;
+ }
+ if (!toInfo.isDirectory()) continue;
+ moveTreeInto(from, to);
+ } else if (!lstatOrNull(to)) {
+ renameSync(from, to);
+ }
+ }
+ try {
+ rmdirSync(srcDir);
+ } catch {
+ // Conflicts remain — the leftovers stay for the next run to retry.
+ }
+}
+
+function mergeAndLinkSessionDir(
+ profile: string,
+ main: string,
+ name: string,
+): void {
+ const src = join(profile, name);
+ const dst = join(main, name);
+ const pending = `${src}${MERGE_SUFFIX}`;
+ // Finish what an interrupted earlier merge left behind before anything
+ // else — src may already be a symlink by now.
+ if (lstatOrNull(pending)?.isDirectory()) {
+ mkdirSync(dst, { recursive: true });
+ moveTreeInto(pending, dst);
+ }
+ const info = lstatOrNull(src);
+ if (info?.isSymbolicLink()) return;
+ if (info && !info.isDirectory()) return;
+ mkdirSync(dst, { recursive: true });
+ if (!info) {
+ symlinkSync(dst, src);
+ return;
+ }
+ // Swap first, merge after: the path is only ever missing for the instant
+ // between rename and symlink, and open file handles ride the renames.
+ renameSync(src, pending);
+ try {
+ symlinkSync(dst, src);
+ } catch {
+ // A live CLI recreated the dir in the gap — restore and skip.
+ moveTreeInto(pending, src);
+ return;
+ }
+ moveTreeInto(pending, dst);
+}
+
+/** Appends line-delimited records, inserting a newline first when the
+ * target's last record lacks one (a crash-truncated write) — plain
+ * concatenation would fuse the boundary records into one unparsable line. */
+function appendHistoryRecords(dst: string, content: Buffer): void {
+ if (content.length === 0) return;
+ let needsSeparator = false;
+ try {
+ const size = statSync(dst).size;
+ if (size > 0) {
+ const tail = Buffer.alloc(1);
+ const fd = openSync(dst, "r");
+ try {
+ readSync(fd, tail, 0, 1, size - 1);
+ } finally {
+ closeSync(fd);
+ }
+ needsSeparator = tail[0] !== 0x0a;
+ }
+ } catch {
+ // Missing target — appendFileSync creates it; no separator needed.
+ }
+ appendFileSync(
+ dst,
+ needsSeparator ? Buffer.concat([Buffer.from("\n"), content]) : content,
+ );
+}
+
+function mergeAndLinkHistory(profile: string, main: string): void {
+ const src = join(profile, "history.jsonl");
+ const dst = join(main, "history.jsonl");
+ const pending = `${src}${MERGE_SUFFIX}`;
+ if (lstatOrNull(pending)?.isFile()) {
+ appendHistoryRecords(dst, readFileSync(pending));
+ unlinkSync(pending);
+ }
+ const info = lstatOrNull(src);
+ if (info?.isSymbolicLink()) return;
+ if (info && !info.isFile()) return;
+ if (!lstatOrNull(dst)) appendFileSync(dst, "");
+ if (!info) {
+ symlinkSync(dst, src);
+ return;
+ }
+ renameSync(src, pending);
+ try {
+ symlinkSync(dst, src);
+ } catch {
+ renameSync(pending, src);
+ return;
+ }
+ appendHistoryRecords(dst, readFileSync(pending));
+ unlinkSync(pending);
+}
+
+/**
+ * Best-effort per entry: one unmergeable path must not stop the rest, and a
+ * partially shared profile is strictly better than an unshared one.
+ */
+export function shareClaudeSessionState(
+ configDir: string,
+ mainHome: string = join(homedir(), ".claude"),
+): void {
+ // Windows symlinks need elevation; profiles are a macOS/Linux feature.
+ if (platform() === "win32") return;
+ const profile = shareableProfileDir(configDir, mainHome);
+ if (!profile) return;
+ const main = resolve(mainHome);
+ const steps: Array<() => void> = [
+ ...SESSION_DIRS.map(
+ (name) => () => mergeAndLinkSessionDir(profile, main, name),
+ ),
+ () => mergeAndLinkHistory(profile, main),
+ ];
+ for (const step of steps) {
+ try {
+ step();
+ } catch {
+ // Skipped entry; retried on the next switch to this profile.
+ }
+ }
+}
diff --git a/packages/host-service/src/trpc/router/usage/usage.ts b/packages/host-service/src/trpc/router/usage/usage.ts
index 9524b328e99..dd3e95ea2b3 100644
--- a/packages/host-service/src/trpc/router/usage/usage.ts
+++ b/packages/host-service/src/trpc/router/usage/usage.ts
@@ -1,16 +1,14 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { basename, join } from "node:path";
-import {
- ensureClaudeManagedHooksAt,
- ensureCodexManagedHooksAt,
-} from "@superset/agent-setup";
+import { provisionCodexProfile } from "@superset/agent-setup";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { projects, workspaces } from "../../../db/schema";
import { usageHistoryTask } from "../../../workers/tasks/usage";
import { protectedProcedure, queryProcedure, router } from "../../index";
import { offLoop } from "../../off-loop";
+import { provisionClaudeAccount } from "./account-provisioning";
import { fetchClaudeAccounts, readDefaultLoginEmail } from "./claude";
import { fetchCodexAccounts } from "./codex";
import {
@@ -18,7 +16,6 @@ import {
setDefaultAccountSelection,
} from "./default-account";
import { removeClaudeProfile, removeCodexHome } from "./profile-remove";
-import { seedClaudeProfileOnboarding } from "./profile-seed";
import { discoverClaudeProfiles, discoverCodexHomes } from "./profiles";
import type { UsageAccount } from "./types";
@@ -146,22 +143,22 @@ export const usageRouter = router({
}
}
setDefaultAccountSelection(ctx.db, input.provider, input.selection);
- // Secondary profiles read hooks from their own dir, so agents launched
- // there would otherwise lose lifecycle/status reporting; fresh Claude
- // profiles also need onboarding marked done or the first launch runs
- // the first-boot wizard. Best-effort: a failed merge must not undo
- // the switch.
+ // A profile dir is a whole config root, not just a login: without
+ // provisioning, agents launched there lose the user's skills,
+ // plugins, MCP servers and settings along with Superset's lifecycle
+ // hooks — and, for Claude, the shared session history. Best-effort —
+ // a failed share must not undo the switch, and provisioning retries
+ // on the next switch and at host boot.
if (input.selection !== null) {
try {
- if (input.provider === "claude") {
- ensureClaudeManagedHooksAt(input.selection);
- seedClaudeProfileOnboarding(input.selection);
- } else {
- ensureCodexManagedHooksAt(input.selection);
- }
- } catch {
- // Agents still run without hooks; provisioning retries on the
- // next switch.
+ await (input.provider === "claude"
+ ? provisionClaudeAccount(input.selection)
+ : provisionCodexProfile(input.selection));
+ } catch (error) {
+ console.warn(
+ `[host-service] provisioning ${input.provider} account ${input.selection} failed (continuing):`,
+ error,
+ );
}
}
return { success: true as const };
@@ -214,21 +211,33 @@ export const usageRouter = router({
}),
/**
- * One-time preparation for a freshly added Claude profile: mark onboarding
- * complete so the first agent launch doesn't open the first-boot wizard.
- * Only accepts discovered profile dirs.
+ * Preparation for a freshly added profile: share the default account's
+ * config into it (and, for Claude, mark onboarding complete), so its first
+ * agent launch opens the prompt with the user's usual setup instead of the
+ * first-boot wizard on an empty install. Only accepts discovered profile
+ * dirs.
*/
- prepareClaudeProfile: protectedProcedure
- .input(z.object({ configDir: z.string() }))
+ prepareAccount: protectedProcedure
+ .input(
+ z.object({
+ provider: z.enum(["claude", "codex"]),
+ selection: z.string(),
+ }),
+ )
.mutation(async ({ input }) => {
- const profiles = await discoverClaudeProfiles();
- if (!profiles.some((profile) => profile.configDir === input.configDir)) {
+ const discovered =
+ input.provider === "claude"
+ ? (await discoverClaudeProfiles()).map((profile) => profile.configDir)
+ : (await discoverCodexHomes()).map((home) => home.home);
+ if (!discovered.includes(input.selection)) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: `No Claude profile found at ${input.configDir}.`,
+ message: `No ${input.provider} profile found at ${input.selection}.`,
});
}
- seedClaudeProfileOnboarding(input.configDir);
+ await (input.provider === "claude"
+ ? provisionClaudeAccount(input.selection)
+ : provisionCodexProfile(input.selection));
return { success: true as const };
}),