@hieplp/pi-account-switcher 0.2.3 → 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.
@@ -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
+ });
@@ -4,6 +4,7 @@ import { accountUtil, providerUtil, uiUtil } from "@/utils";
4
4
 
5
5
  export interface AccountService {
6
6
  load(): Promise<void>;
7
+ setSessionKey(sessionKey: string): void;
7
8
  getAccounts(): AccountConfig[];
8
9
  findAccountsByProvider(provider: string, providers: ProviderConfig[]): AccountConfig[];
9
10
  getActiveAccount(): AccountConfig | undefined;
@@ -13,6 +14,8 @@ export interface AccountService {
13
14
  activateAccount(account: AccountConfig, ctx: AccountSwitcherContext, authProvider?: string): Promise<string>;
14
15
  getActiveModelState(): { id: string; provider: string } | undefined;
15
16
  saveActiveModel(id: string, provider: string): Promise<void>;
17
+ setDefaultAccountId(id: string): Promise<void>;
18
+ getDefaultAccountId(): Promise<string | undefined>;
16
19
  }
17
20
 
18
21
  export function useAccountService(accountsPath: string, statePath?: string): AccountService {
@@ -28,18 +31,51 @@ class AccountServiceImpl implements AccountService {
28
31
  private activeAccountId: string | undefined;
29
32
  private activeModelId: string | undefined;
30
33
  private activeModelProvider: string | undefined;
34
+ private sessionKey: string | undefined;
31
35
 
32
36
  constructor(
33
37
  private readonly store: AccountStore,
34
38
  private readonly stateStore: StateStore,
35
39
  ) {}
36
40
 
41
+ setSessionKey(sessionKey: string): void {
42
+ this.sessionKey = sessionKey;
43
+ }
44
+
37
45
  async load(): Promise<void> {
46
+ // Apply config TTL to state store before loading
47
+ const config = await this.store.loadConfig();
48
+ this.stateStore.setCleanupDays(config.stateCleanupDays ?? 30);
49
+
38
50
  this.accounts = await this.store.load();
39
- const state = await this.stateStore.load();
40
- this.activeAccountId = state.activeAccountId;
41
- this.activeModelId = state.activeModelId;
42
- this.activeModelProvider = state.activeModelProvider;
51
+ const key = this.sessionKey ?? "default";
52
+ const state = await this.stateStore.loadSession(key);
53
+
54
+ // Session key state only — no fallback to defaultAccountId here.
55
+ // The full cascade (session → dirs → defaultAccountId) is in runtime.init()
56
+ // so dir-based matching runs before we fall back to the default.
57
+ if (state.activeAccountId) {
58
+ this.activeAccountId = state.activeAccountId;
59
+ this.activeModelId = state.activeModelId;
60
+ this.activeModelProvider = state.activeModelProvider;
61
+ }
62
+
63
+ // Legacy cleanup (always runs): if sessions.default still exists from
64
+ // old format migration, promote its account to config-level default
65
+ // (if not set yet) and clear the key. This is dead data — sessions
66
+ // should only have per-session keys once migration completes.
67
+ if (await this.stateStore.sessionExists("default")) {
68
+ const legacyDefault = await this.stateStore.loadSession("default");
69
+ if (legacyDefault.activeAccountId && !(await this.getDefaultAccountId())) {
70
+ await this.setDefaultAccountId(legacyDefault.activeAccountId);
71
+ }
72
+ await this.stateStore.deleteSession("default");
73
+ if (!this.activeAccountId && legacyDefault.activeAccountId) {
74
+ this.activeAccountId = legacyDefault.activeAccountId;
75
+ this.activeModelId = legacyDefault.activeModelId;
76
+ this.activeModelProvider = legacyDefault.activeModelProvider;
77
+ }
78
+ }
43
79
  }
44
80
 
45
81
  getAccounts(): AccountConfig[] {
@@ -100,17 +136,28 @@ class AccountServiceImpl implements AccountService {
100
136
  applied = accountUtil.applyResolvedAccountEnv(account, resolved, ctx.modelRegistry, authProvider);
101
137
  }
102
138
  this.activeAccountId = account.id;
139
+ // Persist active account ID for subagent (cross-process) inheritance
140
+ process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID = account.id;
103
141
  await this.flushState();
104
142
  uiUtil.setAccountStatus(ctx.ui, account.label);
105
143
  if (account.piAuth) return "via OAuth";
106
144
  return applied.length > 0 ? applied.join(", ") : "";
107
145
  }
108
146
 
147
+ async setDefaultAccountId(id: string): Promise<void> {
148
+ await this.store.setDefaultAccountId(id);
149
+ }
150
+
151
+ async getDefaultAccountId(): Promise<string | undefined> {
152
+ const config = await this.store.loadConfig();
153
+ return config.defaultAccountId;
154
+ }
155
+
109
156
  private async flushState(): Promise<void> {
110
- await this.stateStore.save({
157
+ await this.stateStore.saveSession(this.sessionKey ?? "default", {
111
158
  activeAccountId: this.activeAccountId,
112
159
  activeModelId: this.activeModelId,
113
160
  activeModelProvider: this.activeModelProvider,
114
161
  });
115
162
  }
116
- }
163
+ }
@@ -5,9 +5,11 @@ import { errorUtil, fileUtil } from "@/utils";
5
5
 
6
6
  export interface AccountStore {
7
7
  load(): Promise<AccountConfig[]>;
8
+ loadConfig(): Promise<AccountSwitcherConfig>;
8
9
  addAccount(account: AccountConfig): Promise<AccountConfig[]>;
9
10
  replaceAccount(originalId: string, account: AccountConfig): Promise<AccountConfig[]>;
10
11
  removeAccount(id: string): Promise<AccountConfig[]>;
12
+ setDefaultAccountId(id: string): Promise<void>;
11
13
  }
12
14
 
13
15
  export function useAccountStore(path: string) {
@@ -22,18 +24,20 @@ class AccountStoreImpl implements AccountStore {
22
24
  constructor(private readonly path: string) {}
23
25
 
24
26
  async load(): Promise<AccountConfig[]> {
27
+ const config = await this.loadConfig();
28
+ return config.accounts;
29
+ }
30
+
31
+ async loadConfig(): Promise<AccountSwitcherConfig> {
25
32
  try {
26
33
  const raw = await readFile(this.path, "utf8");
27
34
  const parsed = configSchema.parse(JSON.parse(raw));
28
-
29
- const { accounts } = parsed;
30
- accountValidator.assertNoDuplicateAccounts(accounts);
31
- accountValidator.assertAccountsHaveCredentials(accounts);
32
-
33
- return accounts;
35
+ accountValidator.assertNoDuplicateAccounts(parsed.accounts);
36
+ accountValidator.assertAccountsHaveCredentials(parsed.accounts);
37
+ return parsed;
34
38
  } catch (error) {
35
39
  if (fileUtil.isMissingFileError(error)) {
36
- return [];
40
+ return { accounts: [] };
37
41
  }
38
42
  throw new Error(`Failed to load accounts at ${this.path}: ${errorUtil.format(error)}`);
39
43
  }
@@ -67,9 +71,23 @@ class AccountStoreImpl implements AccountStore {
67
71
  }
68
72
 
69
73
  private async save(accounts: AccountConfig[]): Promise<void> {
70
- const config: AccountSwitcherConfig = { accounts };
74
+ const { defaultAccountId, stateCleanupDays } = await this.loadConfig();
75
+ const config: AccountSwitcherConfig = {
76
+ accounts,
77
+ ...(defaultAccountId ? { defaultAccountId } : {}),
78
+ ...(stateCleanupDays !== undefined ? { stateCleanupDays } : {}),
79
+ };
71
80
  await fileUtil.writePrivateJson(this.path, config);
72
81
  }
82
+
83
+ async setDefaultAccountId(id: string): Promise<void> {
84
+ const config = await this.loadConfig();
85
+ if (!config.accounts.some((a) => a.id === id)) {
86
+ throw new Error(`Account not found: ${id}`);
87
+ }
88
+ const updated: AccountSwitcherConfig = { ...config, defaultAccountId: id };
89
+ await fileUtil.writePrivateJson(this.path, updated);
90
+ }
73
91
  }
74
92
 
75
93
  // ===============================================================================================
@@ -106,4 +124,4 @@ const accountValidator = {
106
124
  throw new Error(`Accounts missing credentials: ${missing.sort().join(", ")}`);
107
125
  }
108
126
  },
109
- };
127
+ };
@@ -3,36 +3,77 @@ import z from "zod";
3
3
  import { STATE_PATH } from "@/constants";
4
4
  import { fileUtil } from "@/utils";
5
5
 
6
- const appStateSchema = z.object({
6
+ const sessionStateSchema = z.object({
7
7
  activeAccountId: z.string().optional(),
8
8
  activeModelId: z.string().optional(),
9
9
  activeModelProvider: z.string().optional(),
10
+ lastActive: z.string().optional(),
10
11
  });
11
12
 
12
- export interface AppState {
13
+ const appStateSchema = z.preprocess(
14
+ (raw) => {
15
+ // Migrate legacy flat format: { activeAccountId, ... } → { sessions: { default: { ... } } }
16
+ if (
17
+ raw &&
18
+ typeof raw === "object" &&
19
+ !("sessions" in raw) &&
20
+ ("activeAccountId" in raw || "activeModelId" in raw)
21
+ ) {
22
+ return { sessions: { default: raw } };
23
+ }
24
+ return raw;
25
+ },
26
+ z.object({
27
+ sessions: z.record(z.string(), sessionStateSchema).default({}),
28
+ }),
29
+ );
30
+
31
+ export interface SessionState {
13
32
  activeAccountId?: string;
14
33
  activeModelId?: string;
15
34
  activeModelProvider?: string;
35
+ lastActive?: string;
36
+ }
37
+
38
+ export interface AppState {
39
+ sessions: Record<string, SessionState>;
16
40
  }
17
41
 
18
42
  export interface StateStore {
19
43
  load(): Promise<AppState>;
20
44
  save(state: AppState): Promise<void>;
45
+ loadSession(sessionKey: string): Promise<SessionState>;
46
+ saveSession(sessionKey: string, state: SessionState): Promise<void>;
47
+ deleteSession(sessionKey: string): Promise<void>;
48
+ sessionExists(sessionKey: string): Promise<boolean>;
49
+ setCleanupDays(days: number): void;
21
50
  }
22
51
 
23
52
  export function useStateStore(path = STATE_PATH): StateStore {
24
53
  return new StateStoreImpl(path);
25
54
  }
26
55
 
56
+ // ===============================================================================================
57
+ // State Store Implementation
58
+ // ===============================================================================================
59
+
60
+ const HARD_CAP = 500;
61
+
27
62
  class StateStoreImpl implements StateStore {
63
+ private cleanupDays = 30;
64
+
28
65
  constructor(private readonly path: string) {}
29
66
 
67
+ setCleanupDays(days: number): void {
68
+ this.cleanupDays = days;
69
+ }
70
+
30
71
  async load(): Promise<AppState> {
31
72
  try {
32
73
  const raw = await readFile(this.path, "utf8");
33
74
  return appStateSchema.parse(JSON.parse(raw));
34
75
  } catch (error) {
35
- if (fileUtil.isMissingFileError(error)) return {};
76
+ if (fileUtil.isMissingFileError(error)) return { sessions: {} };
36
77
  throw error;
37
78
  }
38
79
  }
@@ -40,4 +81,81 @@ class StateStoreImpl implements StateStore {
40
81
  async save(state: AppState): Promise<void> {
41
82
  await fileUtil.writePrivateJson(this.path, state);
42
83
  }
43
- }
84
+
85
+ async loadSession(sessionKey: string): Promise<SessionState> {
86
+ const appState = await this.load();
87
+ return appState.sessions[sessionKey] ?? {};
88
+ }
89
+
90
+ async saveSession(sessionKey: string, state: SessionState): Promise<void> {
91
+ const appState = await this.load();
92
+
93
+ // Migration: timestamp any pre-upgrade entries so they become TTL-eligible
94
+ for (const [key, entry] of Object.entries(appState.sessions)) {
95
+ if (!entry.lastActive) {
96
+ appState.sessions[key] = { ...entry, lastActive: new Date().toISOString() };
97
+ }
98
+ }
99
+
100
+ // Stamp lastActive on the session being written
101
+ const stampedState: SessionState = { ...state, lastActive: new Date().toISOString() };
102
+ appState.sessions[sessionKey] = stampedState;
103
+
104
+ // Prune before saving
105
+ const pruned = this.pruneSessions(appState.sessions);
106
+ appState.sessions = pruned;
107
+
108
+ await this.save(appState);
109
+ }
110
+
111
+ async deleteSession(sessionKey: string): Promise<void> {
112
+ const appState = await this.load();
113
+ delete appState.sessions[sessionKey];
114
+ await this.save(appState);
115
+ }
116
+
117
+ async sessionExists(sessionKey: string): Promise<boolean> {
118
+ const appState = await this.load();
119
+ return sessionKey in appState.sessions;
120
+ }
121
+
122
+ /**
123
+ * Prune sessions in two passes:
124
+ * 1. TTL eviction: remove entries where lastActive exists AND entry is older than cleanupDays
125
+ * 2. Hard cap: if entries > 500, keep only the 500 most recent
126
+ *
127
+ * Entries without lastActive are preserved (pre-upgrade safety).
128
+ * Entries with invalid lastActive are treated as "recent enough" (not evicted).
129
+ */
130
+ private pruneSessions(sessions: Record<string, SessionState>): Record<string, SessionState> {
131
+ const entries = Object.entries(sessions);
132
+ const now = Date.now();
133
+ const ttlMs = this.cleanupDays * 86400000;
134
+
135
+ // TTL pass — keep entries that are either:
136
+ // - without lastActive (pre-upgrade safety)
137
+ // - with valid lastActive that is within TTL
138
+ const ttlFiltered = entries.filter(([, state]) => {
139
+ if (!state.lastActive) return true; // Preserve pre-upgrade entries
140
+ const ts = new Date(state.lastActive).getTime();
141
+ if (isNaN(ts)) return true; // Preserve invalid timestamps
142
+ return now - ts <= ttlMs;
143
+ });
144
+
145
+ // Hard cap pass — keep only the 500 most recent
146
+ if (ttlFiltered.length <= HARD_CAP) {
147
+ return Object.fromEntries(ttlFiltered);
148
+ }
149
+
150
+ // Sort by lastActive ascending (oldest first), treating undefined as 0
151
+ const sorted = [...ttlFiltered].sort(([, a], [, b]) => {
152
+ const aTs = a.lastActive ? new Date(a.lastActive).getTime() : 0;
153
+ const bTs = b.lastActive ? new Date(b.lastActive).getTime() : 0;
154
+ return aTs - bTs;
155
+ });
156
+
157
+ // Keep the last 500 (most recent)
158
+ const trimmed = sorted.slice(-HARD_CAP);
159
+ return Object.fromEntries(trimmed);
160
+ }
161
+ }
@@ -29,6 +29,8 @@ export interface AccountConfig {
29
29
  usesProviderApiKey?: boolean;
30
30
  /** Optional model to switch to when this account is activated. */
31
31
  model?: string;
32
+ /** Directories where this account should be the default (CWD-based auto-select). */
33
+ dirs?: string[];
32
34
  /** Captured Pi /login credentials for built-in OAuth/subscription providers. */
33
35
  piAuth?: {
34
36
  provider: ProviderId;
@@ -3,4 +3,8 @@ import { AccountConfig } from "./accounts";
3
3
  export interface AccountSwitcherConfig {
4
4
  accounts: AccountConfig[];
5
5
  switchMode?: "env";
6
- }
6
+ /** Config-level fallback account when no session state or dirs match. */
7
+ defaultAccountId?: string;
8
+ /** Number of days after which inactive sessions are eligible for GC. Default: 30. */
9
+ stateCleanupDays?: number;
10
+ }
@@ -1,4 +1,3 @@
1
- import * as piAi from "@earendil-works/pi-ai";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
4
3
  import type { AccountConfig, SecretSource } from "@/types";
@@ -8,6 +7,8 @@ import { providerUtil } from "./providers";
8
7
 
9
8
  export const accountUtil = {
10
9
  clearAccountEnv: async (account: AccountConfig, modelRegistry?: ModelRegistry): Promise<void> => {
10
+ // Clear the cross-process inheritance env var
11
+ delete process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID;
11
12
  const authProvider = account.piAuth?.provider ?? providerUtil.normalizeProvider(account.provider);
12
13
  if (!account.piAuth && account.env) {
13
14
  for (const envName of Object.keys(account.env)) {
@@ -89,11 +90,69 @@ export const accountUtil = {
89
90
  },
90
91
  };
91
92
 
93
+ function normalizeDir(dir: string): string {
94
+ return dir.replace(/\/$/, "");
95
+ }
96
+
97
+ /**
98
+ * Check if an account has a specific directory.
99
+ * Normalizes trailing slashes before comparison.
100
+ */
101
+ export function hasDir<T extends { dirs?: string[] }>(account: T, dir: string): boolean {
102
+ const dirs = account.dirs;
103
+ if (!dirs || dirs.length === 0) return false;
104
+ const normalized = normalizeDir(dir);
105
+ return dirs.some((d) => normalizeDir(d) === normalized);
106
+ }
107
+
108
+ /**
109
+ * Add a directory to an account.
110
+ * Returns a new AccountConfig with the dir added, or null if the dir already exists.
111
+ * Dirs are kept sorted for consistent display.
112
+ */
113
+ export function addDirToAccount<T extends { id: string; label: string; provider: string; dirs?: string[] }>(
114
+ account: T,
115
+ dir: string,
116
+ ): T | null {
117
+ if (hasDir(account, dir)) return null;
118
+
119
+ const existing = account.dirs ?? [];
120
+ const newDirs = [...existing, dir].sort();
121
+ return { ...account, dirs: newDirs };
122
+ }
123
+
124
+ /**
125
+ * Remove a directory from an account.
126
+ * Returns a new AccountConfig with the dir removed, or null if the dir does not exist.
127
+ */
128
+ export function removeDirFromAccount<T extends { id: string; label: string; provider: string; dirs?: string[] }>(
129
+ account: T,
130
+ dir: string,
131
+ ): T | null {
132
+ const dirs = account.dirs;
133
+ if (!dirs || dirs.length === 0) return null;
134
+
135
+ const normalized = normalizeDir(dir);
136
+ const filtered = dirs.filter((d) => normalizeDir(d) !== normalized);
137
+
138
+ if (filtered.length === dirs.length) return null;
139
+ if (filtered.length === 0) return { ...account, dirs: undefined };
140
+ return { ...account, dirs: filtered };
141
+ }
142
+
92
143
  function closeCachedSessions(): void {
93
- const helpers = piAi as {
94
- cleanupSessionResources?: () => void;
95
- closeOpenAICodexWebSocketSessions?: () => void;
96
- };
97
- helpers.cleanupSessionResources?.();
98
- helpers.closeOpenAICodexWebSocketSessions?.();
144
+ // Dynamic import so the module is not required at load time — @earendil-works/pi-ai
145
+ // is a peerDependency provided by the pi agent host, not bundled with this package.
146
+ import("@earendil-works/pi-ai")
147
+ .then((piAi) => {
148
+ const helpers = piAi as {
149
+ cleanupSessionResources?: () => void;
150
+ closeOpenAICodexWebSocketSessions?: () => void;
151
+ };
152
+ helpers.cleanupSessionResources?.();
153
+ helpers.closeOpenAICodexWebSocketSessions?.();
154
+ })
155
+ .catch(() => {
156
+ // pi-ai not available in this environment — skip session cleanup
157
+ });
99
158
  }