@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.
@@ -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
+ }
@@ -7,6 +7,8 @@ import { providerUtil } from "./providers";
7
7
 
8
8
  export const accountUtil = {
9
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;
10
12
  const authProvider = account.piAuth?.provider ?? providerUtil.normalizeProvider(account.provider);
11
13
  if (!account.piAuth && account.env) {
12
14
  for (const envName of Object.keys(account.env)) {
@@ -88,6 +90,56 @@ export const accountUtil = {
88
90
  },
89
91
  };
90
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
+
91
143
  function closeCachedSessions(): void {
92
144
  // Dynamic import so the module is not required at load time — @earendil-works/pi-ai
93
145
  // is a peerDependency provided by the pi agent host, not bundled with this package.
@@ -1,4 +1,5 @@
1
1
  import { exec, execFile } from "node:child_process";
2
+ import { homedir } from "node:os";
2
3
  import { promisify } from "node:util";
3
4
 
4
5
  const execAsync = promisify(exec);
@@ -87,3 +88,29 @@ export const commonUtil = {
87
88
  return results;
88
89
  },
89
90
  };
91
+
92
+ /**
93
+ * Find the account whose dirs contain the longest prefix of cwd.
94
+ * Returns the account id, or undefined if no match.
95
+ * On tie (same dir length on two accounts), first-in-array wins.
96
+ */
97
+ export function findLongestMatchingDir<T extends { id: string; dirs?: string[] }>(
98
+ accounts: T[],
99
+ cwd: string,
100
+ ): string | undefined {
101
+ let bestId: string | undefined;
102
+ let bestLen = -1;
103
+ for (const account of accounts) {
104
+ const dirs = account.dirs;
105
+ if (!dirs || dirs.length === 0) continue;
106
+ for (const dir of dirs) {
107
+ const resolved = dir.startsWith("~") ? dir.replace("~", homedir()) : dir;
108
+ const normalized = resolved.replace(/\/+$/, "");
109
+ if ((cwd === normalized || cwd.startsWith(normalized + "/")) && normalized.length > bestLen) {
110
+ bestLen = normalized.length;
111
+ bestId = account.id;
112
+ }
113
+ }
114
+ }
115
+ return bestId;
116
+ }
@@ -1,4 +1,13 @@
1
- import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, type Focusable, type Component } from "@earendil-works/pi-tui";
1
+ import {
2
+ Container,
3
+ fuzzyFilter,
4
+ getKeybindings,
5
+ Input,
6
+ Spacer,
7
+ Text,
8
+ type Focusable,
9
+ type Component,
10
+ } from "@earendil-works/pi-tui";
2
11
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
12
 
4
13
  /**
@@ -22,9 +22,10 @@ export const providerUtil = {
22
22
  );
23
23
  },
24
24
 
25
- providerChoices: (customProviders: ProviderConfig[] = []): string[] => {
25
+ providerChoices: (customProviders: ProviderConfig[] = [], piProviderIds: string[] = []): string[] => {
26
26
  const customIds = customProviders.map((p) => providerUtil.normalizeProvider(p.id)).sort();
27
- return [...BUILT_IN_PROVIDER_IDS, ...customIds, "custom"];
27
+ const dynamicIds = [...new Set(piProviderIds.map(providerUtil.normalizeProvider))].sort();
28
+ return [...new Set([...BUILT_IN_PROVIDER_IDS, ...dynamicIds, ...customIds, "custom"])];
28
29
  },
29
30
 
30
31
  hasProvider: (provider: string, providers: ProviderConfig[]): boolean => {