@hieplp/pi-account-switcher 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountSwitcherContext } from "@/types";
4
+ import { errorUtil } from "@/utils";
5
+ import { AccountCommand } from "./shared";
6
+
7
+ export const useSubagentAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
8
+ new SubagentAccountCommand(pi, runtime).register();
9
+ };
10
+
11
+ class SubagentAccountCommand extends AccountCommand {
12
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
13
+ super(pi, runtime, {
14
+ name: "accounts:subagent",
15
+ description: "Set the account to use for the next spawned subagent (one-shot or persistent)",
16
+ });
17
+ }
18
+
19
+ async handler(ctx: AccountSwitcherContext, args?: string): Promise<void> {
20
+ try {
21
+ await this.runtime.load();
22
+ const accounts = this.runtime.getAccounts();
23
+
24
+ if (accounts.length === 0) {
25
+ ctx.ui.notify("No accounts configured.", "info");
26
+ return;
27
+ }
28
+
29
+ // Interactive: pick an account
30
+ const account = await this.pickGroupedAccount(ctx, accounts, "Account for subagent");
31
+ if (!account) return;
32
+
33
+ // Ask about oneshot (default: yes)
34
+ const oneshot = await ctx.ui.confirm(
35
+ "Apply to next subagent only?",
36
+ "Yes = one-shot (next subagent only). No = persistent (all subagents until changed).",
37
+ );
38
+
39
+ const varName = oneshot !== false ? "PI_ACCOUNT_SWITCHER_NEXT_ID" : "PI_ACCOUNT_SWITCHER_ACTIVE_ID";
40
+ process.env[varName] = account.id;
41
+
42
+ ctx.ui.notify(
43
+ `Subagent account set to: ${account.label} (${oneshot !== false ? "next subagent only" : "persistent"}).`,
44
+ "info",
45
+ );
46
+ } catch (e) {
47
+ ctx.ui.notify(`Failed to set subagent account: ${errorUtil.format(e)}`, "error");
48
+ }
49
+ }
50
+ }
@@ -2,7 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import type { AccountSwitcherContext } from "@/types";
4
4
  import { COMMANDS } from "@/constants";
5
- import { commandUtil, errorUtil, providerUtil } from "@/utils";
5
+ import { errorUtil } from "@/utils";
6
6
  import { AccountCommand } from "./shared";
7
7
 
8
8
  export const useSwitchAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
@@ -14,38 +14,30 @@ class SwitchAccountCommand extends AccountCommand {
14
14
  super(pi, runtime, COMMANDS.accounts.switch);
15
15
  }
16
16
 
17
- async handler(ctx: AccountSwitcherContext): Promise<void> {
17
+ async handler(ctx: AccountSwitcherContext, args?: string): Promise<void> {
18
18
  try {
19
19
  await this.runtime.load();
20
20
 
21
- const active = this.runtime.getActiveAccount();
22
- if (!active) {
23
- ctx.ui.notify(
24
- `No active account. Use ${commandUtil.name(COMMANDS.accounts.list.name)} to activate one first.`,
25
- "info",
26
- );
21
+ // With args: activate by ID directly (agent-facing, any provider)
22
+ if (args) {
23
+ const account = this.runtime.getAccounts().find((a) => a.id === args.trim());
24
+ if (!account) {
25
+ ctx.ui.notify(`Account not found: "${args.trim()}". Use the list_accounts tool to see available accounts.`, "error");
26
+ return;
27
+ }
28
+ const applied = await this.runtime.activateAccount(account, ctx);
29
+ ctx.ui.notify(`Switched to ${account.label} (${applied}).`, "info");
27
30
  return;
28
31
  }
29
32
 
30
- const providers = this.runtime.getProviders();
31
- const normalizedActive = providerUtil.normalizeProviderWithCustom(
32
- active.piAuth?.provider ?? active.provider,
33
- providers,
34
- );
35
- const peers = this.runtime
36
- .getAccounts()
37
- .filter(
38
- (a) =>
39
- providerUtil.normalizeProviderWithCustom(a.piAuth?.provider ?? a.provider, providers) ===
40
- normalizedActive && a.id !== active.id,
41
- );
42
-
43
- if (peers.length === 0) {
44
- ctx.ui.notify(`No other accounts for provider "${active.provider}".`, "info");
33
+ // Without args: interactive picker from all accounts
34
+ const accounts = this.runtime.getAccounts();
35
+ if (accounts.length === 0) {
36
+ ctx.ui.notify("No accounts configured. Use accounts:add to create one.", "info");
45
37
  return;
46
38
  }
47
39
 
48
- const account = await this.pickGroupedAccount(ctx, peers, `Switch account (${active.provider})`);
40
+ const account = await this.pickGroupedAccount(ctx, accounts, "Pick account to activate");
49
41
  if (!account) return;
50
42
 
51
43
  const applied = await this.runtime.activateAccount(account, ctx);
@@ -0,0 +1,176 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import type { AccountConfig, AccountSwitcherContext } from "@/types";
4
+ import { COMMANDS } from "@/constants";
5
+ import { BaseCommand } from "../base";
6
+ import { buildGroupedItems } from "../accounts/shared/select";
7
+ import { homedir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+ import type { Dirent } from "node:fs";
10
+
11
+ export const useDirsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
12
+ new DirsCommand(pi, runtime).register();
13
+ };
14
+
15
+ class DirsCommand extends BaseCommand {
16
+ constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
17
+ super(pi, runtime, COMMANDS.accounts.dirs);
18
+ }
19
+
20
+ async handler(ctx: AccountSwitcherContext): Promise<void> {
21
+ await this.runtime.load();
22
+ const accounts = this.runtime.getAccounts();
23
+ if (accounts.length === 0) {
24
+ ctx.ui.notify("No accounts configured.", "info");
25
+ return;
26
+ }
27
+
28
+ // Build entry options: auto-save if active account + CWD both exist
29
+ const activeAccount = this.runtime.getActiveAccount();
30
+ const canAutoSave = Boolean(ctx.cwd && activeAccount);
31
+ const entryOptions = canAutoSave
32
+ ? ["Auto-save current folder", "Select an account to configure"]
33
+ : ["Select an account to configure"];
34
+
35
+ const entry = await ctx.ui.select("Directory auto-select", entryOptions);
36
+ if (!entry) return;
37
+
38
+ if (entry === "Auto-save current folder") {
39
+ await this.autoSave(ctx, activeAccount!);
40
+ return;
41
+ }
42
+
43
+ // Manual: pick account → add/remove
44
+ await this.manualConfig(ctx, accounts);
45
+ }
46
+
47
+ private async autoSave(ctx: AccountSwitcherContext, account: AccountConfig): Promise<void> {
48
+ const cwd = ctx.cwd!;
49
+ const resolved = cwd;
50
+
51
+ // Check if already present
52
+ const current = this.runtime.getAccounts().find((a) => a.id === account.id) ?? account;
53
+ const dirs = current.dirs ?? [];
54
+
55
+ if (dirs.includes(resolved)) {
56
+ ctx.ui.notify(`Directory already configured for ${current.label}.`, "info");
57
+ return;
58
+ }
59
+
60
+ const updated: AccountConfig = { ...current, dirs: [...dirs, resolved] };
61
+ await this.runtime.editAccount(current, updated);
62
+ ctx.ui.notify(`Added dir: ${resolved}`, "info");
63
+ }
64
+
65
+ private async manualConfig(ctx: AccountSwitcherContext, accounts: AccountConfig[]): Promise<void> {
66
+ const account = await this.pickAccount(ctx, accounts);
67
+ if (!account) return;
68
+
69
+ await this.manageDirs(ctx, account);
70
+ }
71
+
72
+ private async pickAccount(
73
+ ctx: AccountSwitcherContext,
74
+ accounts: AccountConfig[],
75
+ ): Promise<AccountConfig | undefined> {
76
+ const items = buildGroupedItems(accounts, this.runtime.getProviders(), this.runtime.getActiveAccount()?.id);
77
+ const labels: string[] = [];
78
+ const values: Array<AccountConfig | null> = [];
79
+ for (const item of items) {
80
+ if (item.type === "header") {
81
+ labels.push(item.provider);
82
+ values.push(null);
83
+ continue;
84
+ }
85
+ labels.push(` ${this.isActiveAccount(item.account) ? `${item.account.label} (active)` : item.account.label}`);
86
+ values.push(item.account);
87
+ }
88
+ return this.pickGrouped(ctx, "Pick account to configure dirs", labels, values);
89
+ }
90
+
91
+ private async manageDirs(ctx: AccountSwitcherContext, account: AccountConfig): Promise<void> {
92
+ const current = this.runtime.getAccounts().find((a) => a.id === account.id) ?? account;
93
+ const dirs = current.dirs ?? [];
94
+ const dirsDisplay = dirs.length > 0 ? dirs.join(", ") : "(none)";
95
+ ctx.ui.notify(`Account: ${current.label} | Dirs: ${dirsDisplay}`, "info");
96
+
97
+ const options = ["Add directory", "Remove directory"];
98
+ const action = await ctx.ui.select("Directory auto-select", options);
99
+ if (!action) return;
100
+
101
+ if (action === "Add directory") {
102
+ const picked = await this.pickDirectory(ctx, homedir());
103
+ if (!picked) return;
104
+ const resolved = picked;
105
+ if (dirs.includes(resolved)) {
106
+ ctx.ui.notify(`Directory already configured for ${current.label}.`, "info");
107
+ return;
108
+ }
109
+ const updated: AccountConfig = { ...current, dirs: [...dirs, resolved] };
110
+ await this.runtime.editAccount(current, updated);
111
+ ctx.ui.notify(`Added dir: ${resolved}`, "info");
112
+ return;
113
+ }
114
+
115
+ if (action === "Remove directory") {
116
+ if (dirs.length === 0) {
117
+ ctx.ui.notify("No directories configured.", "info");
118
+ return;
119
+ }
120
+ const selected = await ctx.ui.select("Remove directory", dirs);
121
+ if (!selected) return;
122
+
123
+ const updatedDirs = dirs.filter((d) => d !== selected);
124
+ const updated: AccountConfig = { ...current, dirs: updatedDirs.length > 0 ? updatedDirs : undefined };
125
+ await this.runtime.editAccount(current, updated);
126
+ ctx.ui.notify(`Removed dir: ${selected}`, "info");
127
+ return;
128
+ }
129
+ }
130
+
131
+ private async pickDirectory(ctx: AccountSwitcherContext, startPath: string): Promise<string | undefined> {
132
+ let currentPath = startPath;
133
+ while (true) {
134
+ const entries = this.listDirectories(currentPath);
135
+ const displayPath = currentPath.replace(homedir(), "~");
136
+
137
+ const options: Array<{ label: string; value: string }> = [
138
+ { label: "✅ Select this directory", value: "__select__" },
139
+ { label: "⬆ ..", value: "__up__" },
140
+ ...entries.map((e) => ({ label: `${e}/`, value: e })),
141
+ ];
142
+
143
+ const labels = options.map((o) => o.label);
144
+ const selected = await ctx.ui.select(`Navigate: ${displayPath}`, labels);
145
+ if (!selected) return undefined;
146
+
147
+ const option = options.find((o) => o.label === selected);
148
+ if (!option) continue;
149
+
150
+ if (option.value === "__select__") return currentPath;
151
+ if (option.value === "__up__") {
152
+ const parent = dirname(currentPath);
153
+ if (parent === currentPath) continue; // at root, can't go up
154
+ currentPath = parent;
155
+ continue;
156
+ }
157
+ // Navigate into subdirectory
158
+ currentPath = join(currentPath, option.value);
159
+ }
160
+ }
161
+
162
+ private listDirectories(dirPath: string): string[] {
163
+ const result: string[] = [];
164
+ try {
165
+ const entries: Dirent[] = require("fs").readdirSync(dirPath, { withFileTypes: true });
166
+ for (const entry of entries) {
167
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
168
+ result.push(entry.name);
169
+ }
170
+ }
171
+ } catch {
172
+ /* permission denied, return what we have */
173
+ }
174
+ return result.sort((a, b) => a.localeCompare(b));
175
+ }
176
+ }
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AccountSwitcher } from "@/runtime";
3
+ import { useDirsCommand } from "./dirs";
4
+
5
+ const useDirsCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
6
+ useDirsCommand(pi, runtime);
7
+ };
8
+
9
+ export default useDirsCommands;
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcher } from "@/runtime";
3
3
  import useProviderCommands from "./providers";
4
4
  import useAccountCommands from "./accounts";
5
+ import useDirsCommands from "./dirs";
5
6
  import useModelCommands from "./models";
6
7
  import useSystemCommands from "./system";
7
8
 
@@ -10,6 +11,7 @@ export { BaseCommand } from "./base";
10
11
 
11
12
  export function registerAllCommands(pi: ExtensionAPI, runtime: AccountSwitcher) {
12
13
  useAccountCommands(pi, runtime);
14
+ useDirsCommands(pi, runtime);
13
15
  useProviderCommands(pi, runtime);
14
16
  useModelCommands(pi, runtime);
15
17
  useSystemCommands(pi, runtime);
@@ -6,7 +6,7 @@ export const COMMANDS = {
6
6
  },
7
7
  list: {
8
8
  name: "accounts:list",
9
- description: "List configured accounts and activate the selected account",
9
+ description: "(agent tool: use list_accounts tool instead for structured output",
10
10
  },
11
11
  edit: {
12
12
  name: "accounts:edit",
@@ -29,6 +29,10 @@ export const COMMANDS = {
29
29
  description:
30
30
  "Verify secrets for one or all accounts without activating them (pass 'all'; add 'ping' to send a test request)",
31
31
  },
32
+ dirs: {
33
+ name: "accounts:dirs",
34
+ description: "Manage working directories for CWD-based auto-select",
35
+ },
32
36
  },
33
37
  providers: {
34
38
  add: {
@@ -14,11 +14,41 @@ export const OAUTH_PROVIDER_IDS = [
14
14
  "anthropic",
15
15
  "openai-codex",
16
16
  "github-copilot",
17
- "google-antigravity",
18
- "custom",
19
17
  ] as const;
20
18
 
21
- export const BUILT_IN_PROVIDER_IDS = ["anthropic", "openai", "openai-codex", "google", "xai", "openrouter"] as const;
19
+ export const BUILT_IN_PROVIDER_IDS = [
20
+ "amazon-bedrock",
21
+ "anthropic",
22
+ "azure-openai-responses",
23
+ "cerebras",
24
+ "cloudflare-ai-gateway",
25
+ "cloudflare-workers-ai",
26
+ "deepseek",
27
+ "fireworks",
28
+ "github-copilot",
29
+ "google",
30
+ "google-vertex",
31
+ "groq",
32
+ "huggingface",
33
+ "kimi-coding",
34
+ "minimax",
35
+ "minimax-cn",
36
+ "mistral",
37
+ "moonshotai",
38
+ "moonshotai-cn",
39
+ "openai",
40
+ "openai-codex",
41
+ "opencode",
42
+ "opencode-go",
43
+ "openrouter",
44
+ "vercel-ai-gateway",
45
+ "xai",
46
+ "xiaomi",
47
+ "xiaomi-token-plan-ams",
48
+ "xiaomi-token-plan-cn",
49
+ "xiaomi-token-plan-sgp",
50
+ "zai",
51
+ ] as const;
22
52
 
23
53
  export const PROVIDER_ALIASES: Record<string, string> = {
24
54
  claude: "anthropic",
@@ -27,10 +57,35 @@ export const PROVIDER_ALIASES: Record<string, string> = {
27
57
  };
28
58
 
29
59
  export const PROVIDER_ENV_KEYS: Record<string, string[]> = {
30
- anthropic: ["ANTHROPIC_API_KEY"],
60
+ anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
31
61
  openai: ["OPENAI_API_KEY"],
32
62
  "openai-codex": ["OPENAI_API_KEY"],
33
- google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
63
+ google: ["GEMINI_API_KEY"],
64
+ "google-vertex": ["GOOGLE_CLOUD_API_KEY"],
65
+ "azure-openai-responses": ["AZURE_OPENAI_API_KEY"],
66
+ deepseek: ["DEEPSEEK_API_KEY"],
67
+ groq: ["GROQ_API_KEY"],
68
+ cerebras: ["CEREBRAS_API_KEY"],
34
69
  xai: ["XAI_API_KEY"],
35
70
  openrouter: ["OPENROUTER_API_KEY"],
71
+ "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
72
+ zai: ["ZAI_API_KEY"],
73
+ mistral: ["MISTRAL_API_KEY"],
74
+ minimax: ["MINIMAX_API_KEY"],
75
+ "minimax-cn": ["MINIMAX_CN_API_KEY"],
76
+ moonshotai: ["MOONSHOT_API_KEY"],
77
+ "moonshotai-cn": ["MOONSHOT_API_KEY"],
78
+ huggingface: ["HF_TOKEN"],
79
+ fireworks: ["FIREWORKS_API_KEY"],
80
+ opencode: ["OPENCODE_API_KEY"],
81
+ "opencode-go": ["OPENCODE_API_KEY"],
82
+ "kimi-coding": ["KIMI_API_KEY"],
83
+ "cloudflare-workers-ai": ["CLOUDFLARE_API_KEY"],
84
+ "cloudflare-ai-gateway": ["CLOUDFLARE_API_KEY"],
85
+ xiaomi: ["XIAOMI_API_KEY"],
86
+ "xiaomi-token-plan-cn": ["XIAOMI_TOKEN_PLAN_CN_API_KEY"],
87
+ "xiaomi-token-plan-ams": ["XIAOMI_TOKEN_PLAN_AMS_API_KEY"],
88
+ "xiaomi-token-plan-sgp": ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"],
89
+ "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
90
+ "amazon-bedrock": [],
36
91
  };
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AccountSwitcherContext } from "@/types";
3
- import { useAccountSwitcher, type AccountSwitcher } from "./runtime";
4
- import { registerAllCommands } from "./commands";
3
+ import { useAccountSwitcher, type AccountSwitcher } from "@/runtime";
4
+ import { registerAllCommands } from "@/commands";
5
5
 
6
6
  async function accountSwitcher(pi: ExtensionAPI) {
7
7
  const runtime: AccountSwitcher = useAccountSwitcher(pi);
@@ -10,6 +10,12 @@ async function accountSwitcher(pi: ExtensionAPI) {
10
10
  await runtime.init(ctx as AccountSwitcherContext);
11
11
  });
12
12
 
13
+ // Re-assert account status on agent/turn lifecycle so it persists
14
+ // across TUI redraws, reloads, and powerline updates.
15
+ pi.on("agent_start", async (_, ctx) => {
16
+ runtime.refreshStatus(ctx as AccountSwitcherContext);
17
+ });
18
+
13
19
  pi.on("model_select", async (event, ctx) => {
14
20
  await runtime.onModelSelect(event.model.provider, ctx as AccountSwitcherContext);
15
21
  });
@@ -1,11 +1,12 @@
1
- import AccountSwitcher from "./account-switcher";
1
+ import type AccountSwitcher from "./account-switcher";
2
+ import { createHash } from "node:crypto";
2
3
  import { ACCOUNTS_PATH, PROVIDERS_PATH, STATE_PATH } from "@/constants";
3
4
  import type { Api, Model } from "@earendil-works/pi-ai";
4
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
6
  import type { AccountConfig, AccountSwitcherContext, PiAuthEntry, ProviderConfig } from "@/types";
6
7
  import type { AccountService, ModelService, PiAuthService, ProviderService } from "@/services";
7
8
  import { useAccountService, useModelService, usePiAuthService, useProviderService } from "@/services";
8
- import { accountUtil, modelUtil, providerUtil, uiUtil } from "@/utils";
9
+ import { accountUtil, findLongestMatchingDir, modelUtil, providerUtil, uiUtil } from "@/utils";
9
10
 
10
11
  function resolveAuthProvider(account: AccountConfig, providers: ProviderConfig[]): string {
11
12
  if (account.piAuth?.provider) return account.piAuth.provider;
@@ -22,58 +23,117 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
22
23
  private modelService: ModelService;
23
24
  private piAuthService: PiAuthService;
24
25
  private providerService: ProviderService;
25
-
26
- constructor(private readonly pi: Pick<ExtensionAPI, "registerProvider" | "setModel">) {
27
- this.providerService = useProviderService(this.pi as ExtensionAPI, PROVIDERS_PATH);
28
- this.accountService = useAccountService(ACCOUNTS_PATH, STATE_PATH);
26
+ private lastStatusLabel: string | undefined;
27
+ private sessionKey: string | undefined;
28
+
29
+ constructor(
30
+ private readonly pi: Pick<ExtensionAPI, "registerProvider" | "setModel">,
31
+ private readonly paths?: { accounts: string; providers: string; state: string },
32
+ ) {
33
+ this.providerService = useProviderService(this.pi as ExtensionAPI, paths?.providers ?? PROVIDERS_PATH);
34
+ this.accountService = useAccountService(paths?.accounts ?? ACCOUNTS_PATH, paths?.state ?? STATE_PATH);
29
35
  this.modelService = useModelService(this.pi);
30
36
  this.piAuthService = usePiAuthService();
31
37
  }
32
38
 
39
+ /** Derive a stable session key from the session manager. */
40
+ private getSessionKey(ctx: AccountSwitcherContext): string {
41
+ try {
42
+ const sm = (ctx as unknown as Record<string, unknown>).sessionManager as Record<string, unknown> | undefined;
43
+ const sessionFile = typeof sm?.getSessionFile === "function" ? (sm.getSessionFile as () => string)() : undefined;
44
+ if (sessionFile) return createHash("sha256").update(sessionFile).digest("hex").slice(0, 12);
45
+ } catch {
46
+ /* fall through */
47
+ }
48
+ return "default";
49
+ }
50
+
51
+ /** Find the account whose dirs contain the longest prefix of cwd. */
52
+ private findAccountForCwd(cwd: string | undefined): AccountConfig | undefined {
53
+ if (!cwd) return undefined;
54
+ const id = findLongestMatchingDir(this.accountService.getAccounts(), cwd);
55
+ return id ? this.accountService.getAccounts().find((a) => a.id === id) : undefined;
56
+ }
57
+
33
58
  // ===============================================================================================
34
59
  // Core
35
60
  // ===============================================================================================
36
61
 
37
62
  async init(ctx: AccountSwitcherContext): Promise<void> {
63
+ this.sessionKey = this.getSessionKey(ctx);
64
+ this.accountService.setSessionKey(this.sessionKey);
38
65
  await this.load();
39
66
 
40
- const active = this.accountService.getActiveAccount();
41
- uiUtil.setAccountStatus(ctx.ui, active?.label);
42
-
43
- // Re-apply saved account credentials so env vars and OAuth auth storage are
44
- // populated on session start, not only after the first explicit switch.
45
- if (active) {
46
- const providers = this.providerService.getProviders();
47
- await this.applyProviderApiKey(active, providers);
48
- await accountUtil.applyAccountEnv(active, ctx.modelRegistry, resolveAuthProvider(active, providers));
67
+ // Cascade:
68
+ // 0. PI_ACCOUNT_SWITCHER_ACTIVE_ID env var (from parent process)
69
+ // 1. Session key state (handled inside accountService.load())
70
+ // 2. CWD-based auto-select via dirs
71
+ // 3. defaultAccountId from config
72
+ let selected: AccountConfig | undefined;
73
+
74
+ // Step 0: env var from parent process (for subagent inheritance)
75
+ // PI_ACCOUNT_SWITCHER_NEXT_ID is a one-shot override (consumed after first read)
76
+ // PI_ACCOUNT_SWITCHER_ACTIVE_ID is the persistent inheritance from the parent
77
+ const nextId = process.env.PI_ACCOUNT_SWITCHER_NEXT_ID;
78
+ if (nextId) {
79
+ delete process.env.PI_ACCOUNT_SWITCHER_NEXT_ID;
80
+ selected = this.accountService.getAccounts().find((a) => a.id === nextId);
81
+ }
82
+ if (!selected) {
83
+ const envId = process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID;
84
+ if (envId) {
85
+ selected = this.accountService.getAccounts().find((a) => a.id === envId);
86
+ }
49
87
  }
50
88
 
51
- // Restore the last active model. modelRegistry.find returns undefined if the
52
- // model is no longer available (e.g. provider was removed), in which case we
53
- // leave Pi's default model selection untouched.
54
- const modelState = this.accountService.getActiveModelState();
55
- if (modelState) {
56
- const providers = this.providerService.getProviders();
57
- const activeProvider = active ? resolveAccountProvider(active, providers) : undefined;
58
- const savedProvider = providerUtil.normalizeProviderWithCustom(modelState.provider, providers);
59
-
60
- // Only restore a saved model when it belongs to the active account's provider.
61
- // Otherwise Pi can start with credentials for one provider and a model from another.
62
- if (!activeProvider || savedProvider === activeProvider) {
63
- const model = ctx.modelRegistry.find(modelState.provider, modelState.id);
64
- if (model) await this.modelService.applyModel(model, ctx);
89
+ if (!selected) {
90
+ // Step 1: session key state (restored by accountService.load())
91
+ selected = this.accountService.getActiveAccount();
92
+ }
93
+ if (!selected) {
94
+ // Step 2: CWD-based auto-select via dirs
95
+ selected = this.findAccountForCwd(ctx.cwd);
96
+ }
97
+ if (!selected) {
98
+ // Step 3: defaultAccountId from config
99
+ const defaultId = await this.accountService.getDefaultAccountId();
100
+ if (defaultId) {
101
+ selected = this.accountService.getAccounts().find((a) => a.id === defaultId);
65
102
  }
66
103
  }
104
+ if (selected) {
105
+ const providers = this.providerService.getProviders();
106
+ await this.applyProviderApiKey(selected, providers);
107
+ await this.accountService.activateAccount(selected, ctx, resolveAuthProvider(selected, providers));
108
+ }
109
+
110
+ uiUtil.setAccountStatus(ctx.ui, selected?.label);
111
+ }
112
+
113
+ refreshStatus(ctx: AccountSwitcherContext): void {
114
+ const active = this.accountService.getActiveAccount();
115
+ const label = active?.label;
116
+ if (label === this.lastStatusLabel) return;
117
+ this.lastStatusLabel = label;
118
+ uiUtil.setAccountStatus(ctx.ui, label);
67
119
  }
68
120
 
69
121
  async load(): Promise<void> {
70
- await this.accountService.load();
71
122
  await this.providerService.load();
123
+ await this.accountService.load();
72
124
  }
73
125
 
74
126
  async onModelSelect(provider: string, ctx: AccountSwitcherContext): Promise<void> {
75
- const matchingAccount = this.findAccountsByProvider(provider)[0];
127
+ const providers = this.providerService.getProviders();
128
+ const normalizedProvider = providerUtil.normalizeProviderWithCustom(provider, providers);
76
129
  const activeAccount = this.accountService.getActiveAccount();
130
+
131
+ // If the active account already belongs to this provider, keep it.
132
+ if (activeAccount && resolveAccountProvider(activeAccount, providers) === normalizedProvider) {
133
+ return;
134
+ }
135
+
136
+ const matchingAccount = this.findAccountsByProvider(provider)[0];
77
137
  if (matchingAccount && matchingAccount.id !== activeAccount?.id) {
78
138
  await this.activateAccount(matchingAccount, ctx);
79
139
  }
@@ -130,6 +190,9 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
130
190
  const providerApiKey = await this.applyProviderApiKey(account, providers);
131
191
  const result = await this.accountService.activateAccount(account, ctx, resolveAuthProvider(account, providers));
132
192
 
193
+ // Persist the active account ID for subagent (cross-process) inheritance
194
+ process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID = account.id;
195
+
133
196
  // piAuth accounts authenticate via a separate provider (e.g. github-copilot),
134
197
  // so use that for model lookup rather than the account's own provider field.
135
198
  const accountProvider = resolveAccountProvider(account, providers);
@@ -141,6 +204,11 @@ export default class AccountSwitcherRuntime implements AccountSwitcher {
141
204
  if (accountProvider !== currentProvider) {
142
205
  const model = await modelUtil.pickModel(ctx, account, providers, accountProvider);
143
206
  if (model) await this.applyModel(model, ctx);
207
+ } else {
208
+ // Same provider — persist current model for full session tracking
209
+ if (ctx.model) {
210
+ await this.accountService.saveActiveModel(ctx.model.id, ctx.model.provider);
211
+ }
144
212
  }
145
213
 
146
214
  return providerApiKey ? `provider apiKey (${providerApiKey})` : result;
@@ -4,6 +4,7 @@ import type { AccountConfig, AccountSwitcherContext, PiAuthEntry, ProviderConfig
4
4
  export default interface AccountSwitcher {
5
5
  // Core
6
6
  init(ctx: AccountSwitcherContext): Promise<void>;
7
+ refreshStatus(ctx: AccountSwitcherContext): void;
7
8
  load(): Promise<void>;
8
9
  onModelSelect(provider: string, ctx: AccountSwitcherContext): Promise<void>;
9
10
 
@@ -27,7 +28,7 @@ export default interface AccountSwitcher {
27
28
  // Provider
28
29
  getProviders(): ProviderConfig[];
29
30
  registerProvider(provider: ProviderConfig): void;
30
- addProvider(config: ProviderConfig): Promise<void>;
31
+ addProvider(provider: ProviderConfig): Promise<void>;
31
32
  editProvider(original: ProviderConfig, updated: ProviderConfig): Promise<void>;
32
33
  removeProvider(provider: ProviderConfig): Promise<void>;
33
34
  }
@@ -30,6 +30,7 @@ export const accountSchema = z
30
30
  env: z.record(z.string().min(1), secretSourceSchema).optional(),
31
31
  providerApiKey: secretSourceSchema.optional(),
32
32
  usesProviderApiKey: z.boolean().optional(),
33
+ dirs: z.array(z.string()).optional(),
33
34
  piAuth: z
34
35
  .object({
35
36
  provider: z.string().min(1),
@@ -4,4 +4,6 @@ import { accountSchema } from "./accounts";
4
4
  export const configSchema = z.object({
5
5
  accounts: z.array(accountSchema).default([]),
6
6
  switchMode: z.literal("env").optional().default("env"),
7
- });
7
+ defaultAccountId: z.string().optional(),
8
+ stateCleanupDays: z.number().int().min(1).optional().default(30),
9
+ });