Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { cn } from "@superset/ui/utils";
import { useState } from "react";
import {
LuCheck,
LuCircle,
LuCircleCheck,
LuCopy,
LuEllipsis,
LuPlus,
Expand Down Expand Up @@ -86,27 +88,58 @@ 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;
onSwitchSignIn: () => void;
/** 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 (
<div className="group rounded-lg border bg-card/40 p-2.5">
<div
className={cn(
"group rounded-lg border bg-card/40 p-2.5",
selectable &&
account.isDefault &&
"border-primary/60 bg-primary/[0.04] ring-1 ring-primary/40",
)}
>
<div className="flex items-baseline gap-1.5">
{selectable &&
(account.isDefault ? (
<span className="shrink-0 self-center" title={DEFAULT_TITLE}>
<LuCircleCheck className="size-3.5 text-primary" />
</span>
) : (
<button
type="button"
className="shrink-0 self-center text-muted-foreground/50 transition-colors hover:text-primary disabled:pointer-events-none"
disabled={isSwitching}
title="Make default — launch new terminals and agents on this account."
onClick={onMakeDefault}
>
<LuCircle className="size-3.5" />
</button>
))}
<span className="truncate text-xs font-medium">
{account.email ?? PROVIDER_LABELS[account.provider]}
</span>
Expand All @@ -124,38 +157,17 @@ function AccountCard({
: "Unavailable"}
</span>
)}
{account.isDefault ? (
<span
className="rounded bg-primary/15 px-1 text-[9px] font-medium uppercase tracking-wide text-primary"
title="New terminals and agents use this account. Existing ones keep theirs."
>
Default
</span>
) : (
<Button
variant="ghost"
size="sm"
className="h-4 rounded px-1 text-[9px] font-medium uppercase tracking-wide text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
disabled={isSwitching}
title="Launch new terminals and agents on this account. Existing ones keep theirs."
onClick={onMakeDefault}
>
Make default
</Button>
)}
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
{/* 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}
</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-4 shrink-0 self-center opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
className="size-4 shrink-0 self-center text-muted-foreground"
>
<LuEllipsis className="size-3" />
</Button>
Expand Down Expand Up @@ -205,6 +217,39 @@ function AccountCard({
{account.statusDetail ?? "Usage unavailable."}
</div>
)}
{/* 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) && (
<div className="mt-2 flex items-center gap-2 border-t pt-1.5">
{account.isDefault ? (
!selectable && (
<span
className="inline-flex items-center gap-1 text-[10px] font-medium text-primary"
title={DEFAULT_TITLE}
>
<LuCircleCheck className="size-3" />
Default for new agents
</span>
)
) : (
<Button
variant="outline"
size="sm"
className="h-5 rounded px-1.5 text-[10px]"
disabled={isSwitching}
title={DEFAULT_TITLE}
onClick={onMakeDefault}
>
Make default
</Button>
)}
{credits && (
<span className="ml-auto text-[10px] text-muted-foreground tabular-nums">
{credits}
</span>
)}
</div>
)}
</div>
);
}
Expand All @@ -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.",
},
);
},
Expand Down Expand Up @@ -333,6 +377,7 @@ export function UsageView({ hostUrl }: { hostUrl: string | null }) {
: () => setRemoveTarget(account)
}
isSwitching={setDefault.isPending}
selectable={providerAccounts.length > 1}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
};

Expand All @@ -174,17 +175,16 @@ 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;
}
const fresh = findNewLogin(provider, baselineRef.current, logins);
if (fresh) {
setFound(fresh);
onAccountAdded();
if (provider === "claude") seedProfile(fresh.selection);
provisionProfile(fresh.selection);
}
}, [
open,
Expand Down Expand Up @@ -225,7 +225,7 @@ export function AddAccountDialog({
</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>

Expand Down
11 changes: 8 additions & 3 deletions apps/docs/content/docs/usage.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:

![Two Claude Code accounts with quota meters, one marked as the default](/images/usage-accounts.png)

- **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.
Expand Down
Binary file added apps/docs/public/images/usage-accounts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions docs/agent-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<profile>/.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
Expand Down
24 changes: 16 additions & 8 deletions packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildDefaultAccountResolver,
buildWrapperScript,
createWrapper,
getManagedNotifyHookCommand,
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-setup/src/agent-wrappers-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
}
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-setup/src/agent-wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
Loading
Loading