@oh-my-pi/pi-coding-agent 3.35.0 → 3.37.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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.37.0] - 2026-01-10
6
+ ### Changed
7
+
8
+ - Improved bash command display to show relative paths for working directories within the current directory, and hide redundant `cd` prefix when working directory matches current directory
9
+
10
+ ## [3.36.0] - 2026-01-10
11
+ ### Added
12
+
13
+ - Added `calc` tool for basic mathematical calculations with support for arithmetic operators, parentheses, and hex/binary/octal literals
14
+ - Added support for multiple API credentials per provider with round-robin distribution across sessions
15
+ - Added file locking for auth.json to prevent concurrent write corruption
16
+ - Added clickable OAuth login URL display in terminal
17
+ - Added `workdir` parameter to bash tool to execute commands in a specific directory without requiring `cd` commands
18
+
19
+ ### Changed
20
+
21
+ - Updated bash tool rendering to display working directory context when `workdir` parameter is used
22
+
23
+ ### Fixed
24
+
25
+ - Fixed completion notification to only send when interactive mode is in foreground
26
+ - Improved completion notification message to include session title when available
27
+
5
28
  ## [3.35.0] - 2026-01-09
6
29
  ### Added
7
30
 
package/README.md CHANGED
@@ -114,10 +114,15 @@ Add API keys to `~/.omp/agent/auth.json`:
114
114
 
115
115
  ```json
116
116
  {
117
- "anthropic": { "type": "api_key", "key": "sk-ant-..." },
117
+ "anthropic": [
118
+ { "type": "api_key", "key": "sk-ant-..." },
119
+ { "type": "api_key", "key": "sk-ant-..." }
120
+ ],
118
121
  "openai": { "type": "api_key", "key": "sk-..." },
119
122
  "google": { "type": "api_key", "key": "..." }
120
123
  }
124
+
125
+ If a provider has multiple credentials, new sessions round robin across them and stay sticky per session.
121
126
  ```
122
127
 
123
128
  **Option 2: Environment variables**
@@ -152,7 +157,7 @@ omp
152
157
  /login # Select provider, authorize in browser
153
158
  ```
154
159
 
155
- **Note:** `/login` replaces any existing API key for that provider with OAuth credentials in `auth.json`.
160
+ **Note:** `/login` replaces any existing API keys for that provider with OAuth credentials in `auth.json`. If OAuth credentials already exist, `/login` appends another entry.
156
161
 
157
162
  **GitHub Copilot notes:**
158
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.35.0",
3
+ "version": "3.37.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "3.35.0",
43
- "@oh-my-pi/pi-agent-core": "3.35.0",
44
- "@oh-my-pi/pi-git-tool": "3.35.0",
45
- "@oh-my-pi/pi-tui": "3.35.0",
42
+ "@oh-my-pi/pi-ai": "3.37.0",
43
+ "@oh-my-pi/pi-agent-core": "3.37.0",
44
+ "@oh-my-pi/pi-git-tool": "3.37.0",
45
+ "@oh-my-pi/pi-tui": "3.37.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -745,7 +745,7 @@ export class AgentSession {
745
745
  }
746
746
 
747
747
  // Validate API key
748
- const apiKey = await this._modelRegistry.getApiKey(this.model);
748
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
749
749
  if (!apiKey) {
750
750
  throw new Error(
751
751
  `No API key found for ${this.model.provider}.\n\n` +
@@ -1142,7 +1142,7 @@ export class AgentSession {
1142
1142
  * @throws Error if no API key available for the model
1143
1143
  */
1144
1144
  async setModel(model: Model<any>, role: string = "default"): Promise<void> {
1145
- const apiKey = await this._modelRegistry.getApiKey(model);
1145
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
1146
1146
  if (!apiKey) {
1147
1147
  throw new Error(`No API key for ${model.provider}/${model.id}`);
1148
1148
  }
@@ -1161,7 +1161,7 @@ export class AgentSession {
1161
1161
  * @throws Error if no API key available for the model
1162
1162
  */
1163
1163
  async setModelTemporary(model: Model<any>): Promise<void> {
1164
- const apiKey = await this._modelRegistry.getApiKey(model);
1164
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
1165
1165
  if (!apiKey) {
1166
1166
  throw new Error(`No API key for ${model.provider}/${model.id}`);
1167
1167
  }
@@ -1255,7 +1255,7 @@ export class AgentSession {
1255
1255
  const next = this._scopedModels[nextIndex];
1256
1256
 
1257
1257
  // Validate API key
1258
- const apiKey = await this._modelRegistry.getApiKey(next.model);
1258
+ const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId);
1259
1259
  if (!apiKey) {
1260
1260
  throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
1261
1261
  }
@@ -1283,7 +1283,7 @@ export class AgentSession {
1283
1283
  const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1284
1284
  const nextModel = availableModels[nextIndex];
1285
1285
 
1286
- const apiKey = await this._modelRegistry.getApiKey(nextModel);
1286
+ const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId);
1287
1287
  if (!apiKey) {
1288
1288
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
1289
1289
  }
@@ -1413,7 +1413,7 @@ export class AgentSession {
1413
1413
  throw new Error("No model selected");
1414
1414
  }
1415
1415
 
1416
- const apiKey = await this._modelRegistry.getApiKey(this.model);
1416
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
1417
1417
  if (!apiKey) {
1418
1418
  throw new Error(`No API key for ${this.model.provider}`);
1419
1419
  }
@@ -1698,7 +1698,7 @@ export class AgentSession {
1698
1698
  let lastError: unknown;
1699
1699
 
1700
1700
  for (const candidate of candidates) {
1701
- const apiKey = await this._modelRegistry.getApiKey(candidate);
1701
+ const apiKey = await this._modelRegistry.getApiKey(candidate, this.sessionId);
1702
1702
  if (!apiKey) continue;
1703
1703
 
1704
1704
  let attempt = 0;
@@ -2344,7 +2344,7 @@ export class AgentSession {
2344
2344
  let summaryDetails: unknown;
2345
2345
  if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
2346
2346
  const model = this.model!;
2347
- const apiKey = await this._modelRegistry.getApiKey(model);
2347
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
2348
2348
  if (!apiKey) {
2349
2349
  throw new Error(`No API key for ${model.provider}`);
2350
2350
  }
@@ -3,7 +3,17 @@
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
4
  */
5
5
 
6
- import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import {
7
+ chmodSync,
8
+ closeSync,
9
+ existsSync,
10
+ openSync,
11
+ readFileSync,
12
+ renameSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
7
17
  import { dirname } from "node:path";
8
18
  import {
9
19
  getEnvApiKey,
@@ -29,15 +39,26 @@ export type OAuthCredential = {
29
39
 
30
40
  export type AuthCredential = ApiKeyCredential | OAuthCredential;
31
41
 
32
- export type AuthStorageData = Record<string, AuthCredential>;
42
+ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
43
+
44
+ export type AuthStorageData = Record<string, AuthCredentialEntry>;
33
45
 
34
46
  /**
35
47
  * Credential storage backed by a JSON file.
36
48
  * Reads from multiple fallback paths, writes to primary path.
37
49
  */
38
50
  export class AuthStorage {
51
+ // File locking configuration for concurrent access protection
52
+ private static readonly lockRetryDelayMs = 50; // Polling interval when waiting for lock
53
+ private static readonly lockTimeoutMs = 5000; // Max wait time before failing
54
+ private static readonly lockStaleMs = 30000; // Age threshold for auto-removing orphaned locks
55
+
39
56
  private data: AuthStorageData = {};
40
57
  private runtimeOverrides: Map<string, string> = new Map();
58
+ /** Tracks next credential index per provider:type key for round-robin distribution */
59
+ private providerRoundRobinIndex: Map<string, number> = new Map();
60
+ /** Maps provider:type -> sessionId -> credentialIndex for session-sticky credential assignment */
61
+ private sessionCredentialIndexes: Map<string, Map<string, number>> = new Map();
41
62
  private fallbackResolver?: (provider: string) => string | undefined;
42
63
 
43
64
  /**
@@ -105,24 +126,244 @@ export class AuthStorage {
105
126
  * Save credentials to disk.
106
127
  */
107
128
  private async save(): Promise<void> {
108
- writeFileSync(this.authPath, JSON.stringify(this.data, null, 2));
109
- chmodSync(this.authPath, 0o600);
110
- const dir = dirname(this.authPath);
111
- chmodSync(dir, 0o700);
129
+ const lockFd = await this.acquireLock();
130
+ const tempPath = this.getTempPath();
131
+
132
+ try {
133
+ writeFileSync(tempPath, JSON.stringify(this.data, null, 2), { mode: 0o600 });
134
+ renameSync(tempPath, this.authPath);
135
+ chmodSync(this.authPath, 0o600);
136
+ const dir = dirname(this.authPath);
137
+ chmodSync(dir, 0o700);
138
+ } finally {
139
+ this.safeUnlink(tempPath);
140
+ this.releaseLock(lockFd);
141
+ }
142
+ }
143
+
144
+ /** Returns the lock file path (auth.json.lock) */
145
+ private getLockPath(): string {
146
+ return `${this.authPath}.lock`;
147
+ }
148
+
149
+ /** Returns a unique temp file path using pid and timestamp to avoid collisions */
150
+ private getTempPath(): string {
151
+ return `${this.authPath}.tmp-${process.pid}-${Date.now()}`;
152
+ }
153
+
154
+ /** Checks if lock file is older than lockStaleMs (orphaned by crashed process) */
155
+ private isLockStale(lockPath: string): boolean {
156
+ try {
157
+ const stats = statSync(lockPath);
158
+ return Date.now() - stats.mtimeMs > AuthStorage.lockStaleMs;
159
+ } catch {
160
+ return false;
161
+ }
112
162
  }
113
163
 
114
164
  /**
115
- * Get credential for a provider.
165
+ * Acquires exclusive file lock using O_EXCL atomic create.
166
+ * Polls with exponential backoff, removes stale locks from crashed processes.
167
+ * @returns File descriptor for the lock (must be passed to releaseLock)
168
+ */
169
+ private async acquireLock(): Promise<number> {
170
+ const lockPath = this.getLockPath();
171
+ const start = Date.now();
172
+ const timeoutMs = AuthStorage.lockTimeoutMs;
173
+ const retryDelayMs = AuthStorage.lockRetryDelayMs;
174
+
175
+ while (true) {
176
+ try {
177
+ // O_EXCL fails if file exists, providing atomic lock acquisition
178
+ return openSync(lockPath, "wx", 0o600);
179
+ } catch (error) {
180
+ const err = error as NodeJS.ErrnoException;
181
+ if (err.code !== "EEXIST") {
182
+ throw err;
183
+ }
184
+ if (this.isLockStale(lockPath)) {
185
+ this.safeUnlink(lockPath);
186
+ logger.warn("AuthStorage lock was stale, removing", { path: lockPath });
187
+ continue;
188
+ }
189
+ if (Date.now() - start > timeoutMs) {
190
+ throw new Error(`Timed out waiting for auth lock: ${lockPath}`);
191
+ }
192
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
193
+ }
194
+ }
195
+ }
196
+
197
+ /** Releases file lock by closing fd and removing lock file */
198
+ private releaseLock(lockFd: number): void {
199
+ const lockPath = this.getLockPath();
200
+ try {
201
+ closeSync(lockFd);
202
+ } catch (error) {
203
+ logger.warn("AuthStorage failed to close lock file", { error: String(error) });
204
+ }
205
+ this.safeUnlink(lockPath);
206
+ }
207
+
208
+ /** Removes file if it exists, ignoring ENOENT errors */
209
+ private safeUnlink(path: string): void {
210
+ try {
211
+ unlinkSync(path);
212
+ } catch (error) {
213
+ const err = error as NodeJS.ErrnoException;
214
+ if (err.code !== "ENOENT") {
215
+ logger.warn("AuthStorage failed to remove file", { path, error: String(error) });
216
+ }
217
+ }
218
+ }
219
+
220
+ /** Normalizes credential storage format: single credential becomes array of one */
221
+ private normalizeCredentialEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] {
222
+ if (!entry) return [];
223
+ return Array.isArray(entry) ? entry : [entry];
224
+ }
225
+
226
+ /** Returns all credentials for a provider as an array */
227
+ private getCredentialsForProvider(provider: string): AuthCredential[] {
228
+ return this.normalizeCredentialEntry(this.data[provider]);
229
+ }
230
+
231
+ /** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */
232
+ private getProviderTypeKey(provider: string, type: AuthCredential["type"]): string {
233
+ return `${provider}:${type}`;
234
+ }
235
+
236
+ /**
237
+ * Returns next index in round-robin sequence for load distribution.
238
+ * Increments stored counter and wraps at total.
239
+ */
240
+ private getNextRoundRobinIndex(providerKey: string, total: number): number {
241
+ if (total <= 1) return 0;
242
+ const current = this.providerRoundRobinIndex.get(providerKey) ?? -1;
243
+ const next = (current + 1) % total;
244
+ this.providerRoundRobinIndex.set(providerKey, next);
245
+ return next;
246
+ }
247
+
248
+ /**
249
+ * Selects credential index with session affinity.
250
+ * Sessions reuse their assigned credential; new sessions get next round-robin index.
251
+ * This ensures a session always uses the same credential for consistency.
252
+ */
253
+ private selectCredentialIndex(providerKey: string, sessionId: string | undefined, total: number): number {
254
+ if (total <= 1) return 0;
255
+ if (!sessionId) return 0;
256
+
257
+ const sessionMap = this.sessionCredentialIndexes.get(providerKey);
258
+ const existing = sessionMap?.get(sessionId);
259
+ if (existing !== undefined && existing < total) {
260
+ return existing;
261
+ }
262
+
263
+ // New session: assign next round-robin credential and cache the assignment
264
+ const next = this.getNextRoundRobinIndex(providerKey, total);
265
+ const updatedSessionMap = sessionMap ?? new Map<string, number>();
266
+ updatedSessionMap.set(sessionId, next);
267
+ this.sessionCredentialIndexes.set(providerKey, updatedSessionMap);
268
+ return next;
269
+ }
270
+
271
+ /**
272
+ * Selects a credential of the specified type for a provider.
273
+ * Returns both the credential and its index in the original array (for updates/removal).
274
+ * Uses session-sticky selection when multiple credentials exist.
275
+ */
276
+ private selectCredentialByType<T extends AuthCredential["type"]>(
277
+ provider: string,
278
+ type: T,
279
+ sessionId?: string,
280
+ ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
281
+ const credentials = this.getCredentialsForProvider(provider)
282
+ .map((credential, index) => ({ credential, index }))
283
+ .filter(
284
+ (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
285
+ entry.credential.type === type,
286
+ );
287
+
288
+ if (credentials.length === 0) return undefined;
289
+ if (credentials.length === 1) return credentials[0];
290
+
291
+ const providerKey = this.getProviderTypeKey(provider, type);
292
+ const selectedIndex = this.selectCredentialIndex(providerKey, sessionId, credentials.length);
293
+ return credentials[selectedIndex];
294
+ }
295
+
296
+ /**
297
+ * Clears round-robin and session assignment state for a provider.
298
+ * Called when credentials are added/removed to prevent stale index references.
299
+ */
300
+ private resetProviderAssignments(provider: string): void {
301
+ for (const key of this.providerRoundRobinIndex.keys()) {
302
+ if (key.startsWith(`${provider}:`)) {
303
+ this.providerRoundRobinIndex.delete(key);
304
+ }
305
+ }
306
+ for (const key of this.sessionCredentialIndexes.keys()) {
307
+ if (key.startsWith(`${provider}:`)) {
308
+ this.sessionCredentialIndexes.delete(key);
309
+ }
310
+ }
311
+ }
312
+
313
+ /** Updates credential at index in-place (used for OAuth token refresh) */
314
+ private replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
315
+ const entry = this.data[provider];
316
+ if (!entry) return;
317
+
318
+ if (Array.isArray(entry)) {
319
+ if (index >= 0 && index < entry.length) {
320
+ const updated = [...entry];
321
+ updated[index] = credential;
322
+ this.data[provider] = updated;
323
+ }
324
+ return;
325
+ }
326
+
327
+ if (index === 0) {
328
+ this.data[provider] = credential;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Removes credential at index (used when OAuth refresh fails).
334
+ * Cleans up provider entry if last credential removed.
335
+ */
336
+ private removeCredentialAt(provider: string, index: number): void {
337
+ const entry = this.data[provider];
338
+ if (!entry) return;
339
+
340
+ if (Array.isArray(entry)) {
341
+ const updated = entry.filter((_value, idx) => idx !== index);
342
+ if (updated.length > 0) {
343
+ this.data[provider] = updated;
344
+ } else {
345
+ delete this.data[provider];
346
+ }
347
+ } else {
348
+ delete this.data[provider];
349
+ }
350
+
351
+ this.resetProviderAssignments(provider);
352
+ }
353
+
354
+ /**
355
+ * Get credential for a provider (first entry if multiple).
116
356
  */
117
357
  get(provider: string): AuthCredential | undefined {
118
- return this.data[provider] ?? undefined;
358
+ return this.getCredentialsForProvider(provider)[0];
119
359
  }
120
360
 
121
361
  /**
122
362
  * Set credential for a provider.
123
363
  */
124
- async set(provider: string, credential: AuthCredential): Promise<void> {
364
+ async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
125
365
  this.data[provider] = credential;
366
+ this.resetProviderAssignments(provider);
126
367
  await this.save();
127
368
  }
128
369
 
@@ -131,6 +372,7 @@ export class AuthStorage {
131
372
  */
132
373
  async remove(provider: string): Promise<void> {
133
374
  delete this.data[provider];
375
+ this.resetProviderAssignments(provider);
134
376
  await this.save();
135
377
  }
136
378
 
@@ -145,7 +387,7 @@ export class AuthStorage {
145
387
  * Check if credentials exist for a provider in auth.json.
146
388
  */
147
389
  has(provider: string): boolean {
148
- return provider in this.data;
390
+ return this.getCredentialsForProvider(provider).length > 0;
149
391
  }
150
392
 
151
393
  /**
@@ -154,14 +396,30 @@ export class AuthStorage {
154
396
  */
155
397
  hasAuth(provider: string): boolean {
156
398
  if (this.runtimeOverrides.has(provider)) return true;
157
- if (this.data[provider]) return true;
399
+ if (this.getCredentialsForProvider(provider).length > 0) return true;
158
400
  if (getEnvApiKey(provider)) return true;
159
401
  if (this.fallbackResolver?.(provider)) return true;
160
402
  return false;
161
403
  }
162
404
 
163
405
  /**
164
- * Get all credentials (for passing to getOAuthApiKey).
406
+ * Check if OAuth credentials are configured for a provider.
407
+ */
408
+ hasOAuth(provider: string): boolean {
409
+ return this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth");
410
+ }
411
+
412
+ /**
413
+ * Get OAuth credentials for a provider.
414
+ */
415
+ getOAuthCredential(provider: string): OAuthCredential | undefined {
416
+ return this.getCredentialsForProvider(provider).find(
417
+ (credential): credential is OAuthCredential => credential.type === "oauth",
418
+ );
419
+ }
420
+
421
+ /**
422
+ * Get all credentials.
165
423
  */
166
424
  getAll(): AuthStorageData {
167
425
  return { ...this.data };
@@ -207,7 +465,14 @@ export class AuthStorage {
207
465
  throw new Error(`Unknown OAuth provider: ${provider}`);
208
466
  }
209
467
 
210
- await this.set(provider, { type: "oauth", ...credentials });
468
+ const newCredential: OAuthCredential = { type: "oauth", ...credentials };
469
+ const existing = this.getCredentialsForProvider(provider);
470
+ if (existing.length === 0) {
471
+ await this.set(provider, newCredential);
472
+ return;
473
+ }
474
+
475
+ await this.set(provider, [...existing, newCredential]);
211
476
  }
212
477
 
213
478
  /**
@@ -226,37 +491,37 @@ export class AuthStorage {
226
491
  * 4. Environment variable
227
492
  * 5. Fallback resolver (models.json custom providers)
228
493
  */
229
- async getApiKey(provider: string): Promise<string | undefined> {
494
+ async getApiKey(provider: string, sessionId?: string): Promise<string | undefined> {
230
495
  // Runtime override takes highest priority
231
496
  const runtimeKey = this.runtimeOverrides.get(provider);
232
497
  if (runtimeKey) {
233
498
  return runtimeKey;
234
499
  }
235
500
 
236
- const cred = this.data[provider];
237
-
238
- if (cred?.type === "api_key") {
239
- return cred.key;
501
+ const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
502
+ if (apiKeySelection) {
503
+ return apiKeySelection.credential.key;
240
504
  }
241
505
 
242
- if (cred?.type === "oauth") {
243
- // Filter to only oauth credentials for getOAuthApiKey
244
- const oauthCreds: Record<string, OAuthCredentials> = {};
245
- for (const [key, value] of Object.entries(this.data)) {
246
- if (value.type === "oauth") {
247
- oauthCreds[key] = value;
248
- }
249
- }
506
+ const oauthSelection = this.selectCredentialByType(provider, "oauth", sessionId);
507
+ if (oauthSelection) {
508
+ const oauthCreds: Record<string, OAuthCredentials> = {
509
+ [provider]: oauthSelection.credential,
510
+ };
250
511
 
251
512
  try {
252
513
  const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
253
514
  if (result) {
254
- this.data[provider] = { type: "oauth", ...result.newCredentials };
515
+ this.replaceCredentialAt(provider, oauthSelection.index, { type: "oauth", ...result.newCredentials });
255
516
  await this.save();
256
517
  return result.apiKey;
257
518
  }
258
519
  } catch {
259
- await this.remove(provider);
520
+ this.removeCredentialAt(provider, oauthSelection.index);
521
+ await this.save();
522
+ if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
523
+ return this.getApiKey(provider, sessionId);
524
+ }
260
525
  }
261
526
  }
262
527
 
@@ -187,8 +187,8 @@ export class ModelRegistry {
187
187
  const combined = [...builtInModels, ...customModels];
188
188
 
189
189
  // Update github-copilot base URL based on OAuth credentials
190
- const copilotCred = this.authStorage.get("github-copilot");
191
- if (copilotCred?.type === "oauth") {
190
+ const copilotCred = this.authStorage.getOAuthCredential("github-copilot");
191
+ if (copilotCred) {
192
192
  const domain = copilotCred.enterpriseUrl
193
193
  ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined)
194
194
  : undefined;
@@ -390,22 +390,21 @@ export class ModelRegistry {
390
390
  /**
391
391
  * Get API key for a model.
392
392
  */
393
- async getApiKey(model: Model<Api>): Promise<string | undefined> {
394
- return this.authStorage.getApiKey(model.provider);
393
+ async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
394
+ return this.authStorage.getApiKey(model.provider, sessionId);
395
395
  }
396
396
 
397
397
  /**
398
398
  * Get API key for a provider (e.g., "openai").
399
399
  */
400
- async getApiKeyForProvider(provider: string): Promise<string | undefined> {
401
- return this.authStorage.getApiKey(provider);
400
+ async getApiKeyForProvider(provider: string, sessionId?: string): Promise<string | undefined> {
401
+ return this.authStorage.getApiKey(provider, sessionId);
402
402
  }
403
403
 
404
404
  /**
405
405
  * Check if a model is using OAuth credentials (subscription).
406
406
  */
407
407
  isUsingOAuth(model: Model<Api>): boolean {
408
- const cred = this.authStorage.get(model.provider);
409
- return cred?.type === "oauth";
408
+ return this.authStorage.hasOAuth(model.provider);
410
409
  }
411
410
  }
package/src/core/sdk.ts CHANGED
@@ -538,6 +538,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
538
538
 
539
539
  const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
540
540
  time("sessionManager");
541
+ const sessionId = sessionManager.getSessionId();
541
542
 
542
543
  // Check if session has existing data to restore
543
544
  const existingSession = sessionManager.buildSessionContext();
@@ -554,7 +555,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
554
555
  const parsedModel = parseModelString(defaultModelStr);
555
556
  if (parsedModel) {
556
557
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
557
- if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
558
+ if (restoredModel && (await modelRegistry.getApiKey(restoredModel, sessionId))) {
558
559
  model = restoredModel;
559
560
  }
560
561
  }
@@ -570,7 +571,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
570
571
  const parsedModel = parseModelString(settingsDefaultModel);
571
572
  if (parsedModel) {
572
573
  const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
573
- if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) {
574
+ if (settingsModel && (await modelRegistry.getApiKey(settingsModel, sessionId))) {
574
575
  model = settingsModel;
575
576
  }
576
577
  }
@@ -580,7 +581,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
580
581
  // Fall back to first available model with a valid API key
581
582
  if (!model) {
582
583
  for (const m of modelRegistry.getAll()) {
583
- if (await modelRegistry.getApiKey(m)) {
584
+ if (await modelRegistry.getApiKey(m, sessionId)) {
584
585
  model = m;
585
586
  break;
586
587
  }
@@ -921,7 +922,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
921
922
  if (!currentModel) {
922
923
  throw new Error("No model selected");
923
924
  }
924
- const key = await modelRegistry.getApiKey(currentModel);
925
+ const key = await modelRegistry.getApiKey(currentModel, sessionId);
925
926
  if (!key) {
926
927
  throw new Error(`No API key found for provider "${currentModel.provider}"`);
927
928
  }
@@ -72,6 +72,7 @@ const toolDescriptions: Record<ToolName, string> = {
72
72
  ask: "Ask user for input or clarification",
73
73
  read: "Read file contents",
74
74
  bash: "Execute bash commands (npm, docker, etc.)",
75
+ calc: "{ calculations: array of { expression: string, prefix: string, suffix: string } } Basic calculations.",
75
76
  ssh: "Execute commands on remote hosts via SSH",
76
77
  edit: "Make surgical edits to files (find exact text and replace)",
77
78
  write: "Create or overwrite files",
@@ -68,11 +68,13 @@ export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: s
68
68
  * @param firstMessage The first user message
69
69
  * @param registry Model registry
70
70
  * @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
71
+ * @param sessionId Optional session id for sticky API key selection
71
72
  */
72
73
  export async function generateSessionTitle(
73
74
  firstMessage: string,
74
75
  registry: ModelRegistry,
75
76
  savedSmolModel?: string,
77
+ sessionId?: string,
76
78
  ): Promise<string | null> {
77
79
  const candidates = getTitleModelCandidates(registry, savedSmolModel);
78
80
  if (candidates.length === 0) {
@@ -86,7 +88,7 @@ export async function generateSessionTitle(
86
88
  const userMessage = `<user-message>\n${truncatedMessage}\n</user-message>`;
87
89
 
88
90
  for (const model of candidates) {
89
- const apiKey = await registry.getApiKey(model);
91
+ const apiKey = await registry.getApiKey(model, sessionId);
90
92
  if (!apiKey) {
91
93
  logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
92
94
  continue;