@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.
- package/README.md +38 -10
- package/USAGE.md +80 -40
- package/package.json +3 -2
- package/src/commands/accounts/add.ts +9 -1
- package/src/commands/accounts/edit.ts +15 -1
- package/src/commands/accounts/index.ts +8 -2
- package/src/commands/accounts/list.ts +43 -25
- package/src/commands/accounts/peers.ts +59 -0
- package/src/commands/accounts/set-subagent-account.ts +64 -0
- package/src/commands/accounts/shared/prompts.ts +35 -10
- package/src/commands/accounts/subagent.ts +50 -0
- package/src/commands/accounts/switch.ts +16 -24
- package/src/commands/accounts/verify.ts +2 -1
- package/src/commands/dirs/dirs.ts +176 -0
- package/src/commands/dirs/index.ts +9 -0
- package/src/commands/index.ts +2 -0
- package/src/constants/commands.ts +5 -1
- package/src/constants/providers.ts +60 -5
- package/src/extension.ts +37 -5
- package/src/index.ts +8 -2
- package/src/runtime/account-switcher-runtime.ts +99 -31
- package/src/runtime/account-switcher.ts +2 -1
- package/src/schemas/accounts.ts +1 -0
- package/src/schemas/config.ts +3 -1
- package/src/services/accounts.ts +53 -6
- package/src/storage/accounts.ts +27 -9
- package/src/storage/state.ts +122 -4
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +5 -1
- package/src/utils/accounts.ts +66 -7
- package/src/utils/common.ts +27 -0
- package/src/utils/filterable-selector.ts +10 -1
- package/src/utils/providers.ts +3 -2
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
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
|
|
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(
|
|
31
|
+
addProvider(provider: ProviderConfig): Promise<void>;
|
|
31
32
|
editProvider(original: ProviderConfig, updated: ProviderConfig): Promise<void>;
|
|
32
33
|
removeProvider(provider: ProviderConfig): Promise<void>;
|
|
33
34
|
}
|
package/src/schemas/accounts.ts
CHANGED
|
@@ -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),
|
package/src/schemas/config.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/services/accounts.ts
CHANGED
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/storage/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
+
};
|
package/src/storage/state.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/types/accounts.ts
CHANGED
|
@@ -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;
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|