@mariozechner/pi-coding-agent 0.36.0 → 0.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +27 -2
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +4 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +3 -1
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +25 -8
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/auth-storage.d.ts +9 -1
  12. package/dist/core/auth-storage.d.ts.map +1 -1
  13. package/dist/core/auth-storage.js +115 -18
  14. package/dist/core/auth-storage.js.map +1 -1
  15. package/dist/core/export-html/template.css +55 -0
  16. package/dist/core/export-html/template.js +112 -7
  17. package/dist/core/model-registry.d.ts.map +1 -1
  18. package/dist/core/model-registry.js +1 -1
  19. package/dist/core/model-registry.js.map +1 -1
  20. package/dist/core/sdk.d.ts +1 -1
  21. package/dist/core/sdk.d.ts.map +1 -1
  22. package/dist/core/sdk.js +9 -0
  23. package/dist/core/sdk.js.map +1 -1
  24. package/dist/main.d.ts.map +1 -1
  25. package/dist/main.js +4 -3
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrations.d.ts.map +1 -1
  28. package/dist/migrations.js +50 -3
  29. package/dist/migrations.js.map +1 -1
  30. package/dist/modes/interactive/components/login-dialog.d.ts +39 -0
  31. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -0
  32. package/dist/modes/interactive/components/login-dialog.js +135 -0
  33. package/dist/modes/interactive/components/login-dialog.js.map +1 -0
  34. package/dist/modes/interactive/interactive-mode.d.ts +5 -0
  35. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  36. package/dist/modes/interactive/interactive-mode.js +211 -84
  37. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  38. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  39. package/dist/modes/interactive/theme/theme.js +4 -2
  40. package/dist/modes/interactive/theme/theme.js.map +1 -1
  41. package/dist/utils/tools-manager.d.ts.map +1 -1
  42. package/dist/utils/tools-manager.js +2 -2
  43. package/dist/utils/tools-manager.js.map +1 -1
  44. package/docs/sdk.md +12 -4
  45. package/examples/extensions/claude-rules.ts +83 -0
  46. package/examples/extensions/with-deps/package-lock.json +2 -2
  47. package/examples/extensions/with-deps/package.json +7 -1
  48. package/examples/sdk/06-extensions.ts +5 -7
  49. package/examples/sdk/12-full-control.ts +2 -6
  50. package/package.json +12 -8
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Credential storage for API keys and OAuth tokens.
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
+ *
5
+ * Uses file locking to prevent race conditions when multiple pi instances
6
+ * try to refresh tokens simultaneously.
4
7
  */
5
8
  import { type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
6
9
  export type ApiKeyCredential = {
@@ -85,17 +88,22 @@ export declare class AuthStorage {
85
88
  placeholder?: string;
86
89
  }) => Promise<string>;
87
90
  onProgress?: (message: string) => void;
91
+ /** For providers with local callback servers (e.g., openai-codex), races with browser callback */
92
+ onManualCodeInput?: () => Promise<string>;
93
+ /** For cancellation support (e.g., github-copilot polling) */
94
+ signal?: AbortSignal;
88
95
  }): Promise<void>;
89
96
  /**
90
97
  * Logout from a provider.
91
98
  */
92
99
  logout(provider: string): void;
100
+ private refreshOAuthTokenWithLock;
93
101
  /**
94
102
  * Get API key for a provider.
95
103
  * Priority:
96
104
  * 1. Runtime override (CLI --api-key)
97
105
  * 2. API key from auth.json
98
- * 3. OAuth token from auth.json (auto-refreshed)
106
+ * 3. OAuth token from auth.json (auto-refreshed with locking)
99
107
  * 4. Environment variable
100
108
  * 5. Fallback resolver (models.json custom providers)
101
109
  */
@@ -1 +1 @@
1
- {"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["../../src/core/auth-storage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAQN,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,MAAM,qBAAqB,CAAC;AAI7B,MAAM,MAAM,gBAAgB,GAAG;IAC9B,IAAI,EAAE,SAAS,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC7B,IAAI,EAAE,OAAO,CAAC;CACd,GAAG,gBAAgB,CAAC;AAErB,MAAM,MAAM,cAAc,GAAG,gBAAgB,GAAG,eAAe,CAAC;AAEhE,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAE7D;;GAEG;AACH,qBAAa,WAAW;IAKX,OAAO,CAAC,QAAQ;IAJ5B,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,gBAAgB,CAAkC;IAC1D,OAAO,CAAC,gBAAgB,CAAC,CAA2C;IAEpE,YAAoB,QAAQ,EAAE,MAAM,EAEnC;IAED;;;OAGG;IACH,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAEvD;IAED;;OAEG;IACH,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE1C;IAED;;;OAGG;IACH,mBAAmB,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAAG,IAAI,CAE5E;IAED;;OAEG;IACH,MAAM,IAAI,IAAI,CAUb;IAED;;OAEG;IACH,OAAO,CAAC,IAAI;IASZ;;OAEG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAEhD;IAED;;OAEG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,cAAc,GAAG,IAAI,CAGtD;IAED;;OAEG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAG7B;IAED;;OAEG;IACH,IAAI,IAAI,MAAM,EAAE,CAEf;IAED;;OAEG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE7B;IAED;;;OAGG;IACH,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMjC;IAED;;OAEG;IACH,MAAM,IAAI,eAAe,CAExB;IAED;;OAEG;IACG,KAAK,CACV,QAAQ,EAAE,aAAa,EACvB,SAAS,EAAE;QACV,MAAM,EAAE,CAAC,IAAI,EAAE;YAAE,GAAG,EAAE,MAAM,CAAC;YAAC,YAAY,CAAC,EAAE,MAAM,CAAA;SAAE,KAAK,IAAI,CAAC;QAC/D,QAAQ,EAAE,CAAC,MAAM,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,WAAW,CAAC,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;QACjF,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;KACvC,GACC,OAAO,CAAC,IAAI,CAAC,CA+Bf;IAED;;OAEG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE7B;IAED;;;;;;;;OAQG;IACG,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAwC7D;CACD","sourcesContent":["/**\n * Credential storage for API keys and OAuth tokens.\n * Handles loading, saving, and refreshing credentials from auth.json.\n */\n\nimport {\n\tgetEnvApiKey,\n\tgetOAuthApiKey,\n\tloginAnthropic,\n\tloginAntigravity,\n\tloginGeminiCli,\n\tloginGitHubCopilot,\n\tloginOpenAICodex,\n\ttype OAuthCredentials,\n\ttype OAuthProvider,\n} from \"@mariozechner/pi-ai\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { dirname } from \"path\";\n\nexport type ApiKeyCredential = {\n\ttype: \"api_key\";\n\tkey: string;\n};\n\nexport type OAuthCredential = {\n\ttype: \"oauth\";\n} & OAuthCredentials;\n\nexport type AuthCredential = ApiKeyCredential | OAuthCredential;\n\nexport type AuthStorageData = Record<string, AuthCredential>;\n\n/**\n * Credential storage backed by a JSON file.\n */\nexport class AuthStorage {\n\tprivate data: AuthStorageData = {};\n\tprivate runtimeOverrides: Map<string, string> = new Map();\n\tprivate fallbackResolver?: (provider: string) => string | undefined;\n\n\tconstructor(private authPath: string) {\n\t\tthis.reload();\n\t}\n\n\t/**\n\t * Set a runtime API key override (not persisted to disk).\n\t * Used for CLI --api-key flag.\n\t */\n\tsetRuntimeApiKey(provider: string, apiKey: string): void {\n\t\tthis.runtimeOverrides.set(provider, apiKey);\n\t}\n\n\t/**\n\t * Remove a runtime API key override.\n\t */\n\tremoveRuntimeApiKey(provider: string): void {\n\t\tthis.runtimeOverrides.delete(provider);\n\t}\n\n\t/**\n\t * Set a fallback resolver for API keys not found in auth.json or env vars.\n\t * Used for custom provider keys from models.json.\n\t */\n\tsetFallbackResolver(resolver: (provider: string) => string | undefined): void {\n\t\tthis.fallbackResolver = resolver;\n\t}\n\n\t/**\n\t * Reload credentials from disk.\n\t */\n\treload(): void {\n\t\tif (!existsSync(this.authPath)) {\n\t\t\tthis.data = {};\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tthis.data = JSON.parse(readFileSync(this.authPath, \"utf-8\"));\n\t\t} catch {\n\t\t\tthis.data = {};\n\t\t}\n\t}\n\n\t/**\n\t * Save credentials to disk.\n\t */\n\tprivate save(): void {\n\t\tconst dir = dirname(this.authPath);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t}\n\t\twriteFileSync(this.authPath, JSON.stringify(this.data, null, 2), \"utf-8\");\n\t\tchmodSync(this.authPath, 0o600);\n\t}\n\n\t/**\n\t * Get credential for a provider.\n\t */\n\tget(provider: string): AuthCredential | undefined {\n\t\treturn this.data[provider] ?? undefined;\n\t}\n\n\t/**\n\t * Set credential for a provider.\n\t */\n\tset(provider: string, credential: AuthCredential): void {\n\t\tthis.data[provider] = credential;\n\t\tthis.save();\n\t}\n\n\t/**\n\t * Remove credential for a provider.\n\t */\n\tremove(provider: string): void {\n\t\tdelete this.data[provider];\n\t\tthis.save();\n\t}\n\n\t/**\n\t * List all providers with credentials.\n\t */\n\tlist(): string[] {\n\t\treturn Object.keys(this.data);\n\t}\n\n\t/**\n\t * Check if credentials exist for a provider in auth.json.\n\t */\n\thas(provider: string): boolean {\n\t\treturn provider in this.data;\n\t}\n\n\t/**\n\t * Check if any form of auth is configured for a provider.\n\t * Unlike getApiKey(), this doesn't refresh OAuth tokens.\n\t */\n\thasAuth(provider: string): boolean {\n\t\tif (this.runtimeOverrides.has(provider)) return true;\n\t\tif (this.data[provider]) return true;\n\t\tif (getEnvApiKey(provider)) return true;\n\t\tif (this.fallbackResolver?.(provider)) return true;\n\t\treturn false;\n\t}\n\n\t/**\n\t * Get all credentials (for passing to getOAuthApiKey).\n\t */\n\tgetAll(): AuthStorageData {\n\t\treturn { ...this.data };\n\t}\n\n\t/**\n\t * Login to an OAuth provider.\n\t */\n\tasync login(\n\t\tprovider: OAuthProvider,\n\t\tcallbacks: {\n\t\t\tonAuth: (info: { url: string; instructions?: string }) => void;\n\t\t\tonPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;\n\t\t\tonProgress?: (message: string) => void;\n\t\t},\n\t): Promise<void> {\n\t\tlet credentials: OAuthCredentials;\n\n\t\tswitch (provider) {\n\t\t\tcase \"anthropic\":\n\t\t\t\tcredentials = await loginAnthropic(\n\t\t\t\t\t(url) => callbacks.onAuth({ url }),\n\t\t\t\t\t() => callbacks.onPrompt({ message: \"Paste the authorization code:\" }),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"github-copilot\":\n\t\t\t\tcredentials = await loginGitHubCopilot({\n\t\t\t\t\tonAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),\n\t\t\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\t\t\tonProgress: callbacks.onProgress,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"google-gemini-cli\":\n\t\t\t\tcredentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);\n\t\t\t\tbreak;\n\t\t\tcase \"google-antigravity\":\n\t\t\t\tcredentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);\n\t\t\t\tbreak;\n\t\t\tcase \"openai-codex\":\n\t\t\t\tcredentials = await loginOpenAICodex(callbacks);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown OAuth provider: ${provider}`);\n\t\t}\n\n\t\tthis.set(provider, { type: \"oauth\", ...credentials });\n\t}\n\n\t/**\n\t * Logout from a provider.\n\t */\n\tlogout(provider: string): void {\n\t\tthis.remove(provider);\n\t}\n\n\t/**\n\t * Get API key for a provider.\n\t * Priority:\n\t * 1. Runtime override (CLI --api-key)\n\t * 2. API key from auth.json\n\t * 3. OAuth token from auth.json (auto-refreshed)\n\t * 4. Environment variable\n\t * 5. Fallback resolver (models.json custom providers)\n\t */\n\tasync getApiKey(provider: string): Promise<string | undefined> {\n\t\t// Runtime override takes highest priority\n\t\tconst runtimeKey = this.runtimeOverrides.get(provider);\n\t\tif (runtimeKey) {\n\t\t\treturn runtimeKey;\n\t\t}\n\n\t\tconst cred = this.data[provider];\n\n\t\tif (cred?.type === \"api_key\") {\n\t\t\treturn cred.key;\n\t\t}\n\n\t\tif (cred?.type === \"oauth\") {\n\t\t\t// Filter to only oauth credentials for getOAuthApiKey\n\t\t\tconst oauthCreds: Record<string, OAuthCredentials> = {};\n\t\t\tfor (const [key, value] of Object.entries(this.data)) {\n\t\t\t\tif (value.type === \"oauth\") {\n\t\t\t\t\toauthCreds[key] = value;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);\n\t\t\t\tif (result) {\n\t\t\t\t\tthis.data[provider] = { type: \"oauth\", ...result.newCredentials };\n\t\t\t\t\tthis.save();\n\t\t\t\t\treturn result.apiKey;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tthis.remove(provider);\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to environment variable\n\t\tconst envKey = getEnvApiKey(provider);\n\t\tif (envKey) return envKey;\n\n\t\t// Fall back to custom resolver (e.g., models.json custom providers)\n\t\treturn this.fallbackResolver?.(provider) ?? undefined;\n\t}\n}\n"]}
1
+ {"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["../../src/core/auth-storage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAQN,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,MAAM,qBAAqB,CAAC;AAK7B,MAAM,MAAM,gBAAgB,GAAG;IAC9B,IAAI,EAAE,SAAS,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC7B,IAAI,EAAE,OAAO,CAAC;CACd,GAAG,gBAAgB,CAAC;AAErB,MAAM,MAAM,cAAc,GAAG,gBAAgB,GAAG,eAAe,CAAC;AAEhE,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAE7D;;GAEG;AACH,qBAAa,WAAW;IAKX,OAAO,CAAC,QAAQ;IAJ5B,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,gBAAgB,CAAkC;IAC1D,OAAO,CAAC,gBAAgB,CAAC,CAA2C;IAEpE,YAAoB,QAAQ,EAAE,MAAM,EAEnC;IAED;;;OAGG;IACH,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAEvD;IAED;;OAEG;IACH,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE1C;IAED;;;OAGG;IACH,mBAAmB,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAAG,IAAI,CAE5E;IAED;;OAEG;IACH,MAAM,IAAI,IAAI,CAUb;IAED;;OAEG;IACH,OAAO,CAAC,IAAI;IASZ;;OAEG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAEhD;IAED;;OAEG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,cAAc,GAAG,IAAI,CAGtD;IAED;;OAEG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAG7B;IAED;;OAEG;IACH,IAAI,IAAI,MAAM,EAAE,CAEf;IAED;;OAEG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE7B;IAED;;;OAGG;IACH,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMjC;IAED;;OAEG;IACH,MAAM,IAAI,eAAe,CAExB;IAED;;OAEG;IACG,KAAK,CACV,QAAQ,EAAE,aAAa,EACvB,SAAS,EAAE;QACV,MAAM,EAAE,CAAC,IAAI,EAAE;YAAE,GAAG,EAAE,MAAM,CAAC;YAAC,YAAY,CAAC,EAAE,MAAM,CAAA;SAAE,KAAK,IAAI,CAAC;QAC/D,QAAQ,EAAE,CAAC,MAAM,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,WAAW,CAAC,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;QACjF,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;QACvC,kGAAkG;QAClG,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;QAC1C,8DAA8D;QAC9D,MAAM,CAAC,EAAE,WAAW,CAAC;KACrB,GACC,OAAO,CAAC,IAAI,CAAC,CAqCf;IAED;;OAEG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE7B;YAOa,yBAAyB;IA4EvC;;;;;;;;OAQG;IACG,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA0D7D;CACD","sourcesContent":["/**\n * Credential storage for API keys and OAuth tokens.\n * Handles loading, saving, and refreshing credentials from auth.json.\n *\n * Uses file locking to prevent race conditions when multiple pi instances\n * try to refresh tokens simultaneously.\n */\n\nimport {\n\tgetEnvApiKey,\n\tgetOAuthApiKey,\n\tloginAnthropic,\n\tloginAntigravity,\n\tloginGeminiCli,\n\tloginGitHubCopilot,\n\tloginOpenAICodex,\n\ttype OAuthCredentials,\n\ttype OAuthProvider,\n} from \"@mariozechner/pi-ai\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { dirname } from \"path\";\nimport lockfile from \"proper-lockfile\";\n\nexport type ApiKeyCredential = {\n\ttype: \"api_key\";\n\tkey: string;\n};\n\nexport type OAuthCredential = {\n\ttype: \"oauth\";\n} & OAuthCredentials;\n\nexport type AuthCredential = ApiKeyCredential | OAuthCredential;\n\nexport type AuthStorageData = Record<string, AuthCredential>;\n\n/**\n * Credential storage backed by a JSON file.\n */\nexport class AuthStorage {\n\tprivate data: AuthStorageData = {};\n\tprivate runtimeOverrides: Map<string, string> = new Map();\n\tprivate fallbackResolver?: (provider: string) => string | undefined;\n\n\tconstructor(private authPath: string) {\n\t\tthis.reload();\n\t}\n\n\t/**\n\t * Set a runtime API key override (not persisted to disk).\n\t * Used for CLI --api-key flag.\n\t */\n\tsetRuntimeApiKey(provider: string, apiKey: string): void {\n\t\tthis.runtimeOverrides.set(provider, apiKey);\n\t}\n\n\t/**\n\t * Remove a runtime API key override.\n\t */\n\tremoveRuntimeApiKey(provider: string): void {\n\t\tthis.runtimeOverrides.delete(provider);\n\t}\n\n\t/**\n\t * Set a fallback resolver for API keys not found in auth.json or env vars.\n\t * Used for custom provider keys from models.json.\n\t */\n\tsetFallbackResolver(resolver: (provider: string) => string | undefined): void {\n\t\tthis.fallbackResolver = resolver;\n\t}\n\n\t/**\n\t * Reload credentials from disk.\n\t */\n\treload(): void {\n\t\tif (!existsSync(this.authPath)) {\n\t\t\tthis.data = {};\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tthis.data = JSON.parse(readFileSync(this.authPath, \"utf-8\"));\n\t\t} catch {\n\t\t\tthis.data = {};\n\t\t}\n\t}\n\n\t/**\n\t * Save credentials to disk.\n\t */\n\tprivate save(): void {\n\t\tconst dir = dirname(this.authPath);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t}\n\t\twriteFileSync(this.authPath, JSON.stringify(this.data, null, 2), \"utf-8\");\n\t\tchmodSync(this.authPath, 0o600);\n\t}\n\n\t/**\n\t * Get credential for a provider.\n\t */\n\tget(provider: string): AuthCredential | undefined {\n\t\treturn this.data[provider] ?? undefined;\n\t}\n\n\t/**\n\t * Set credential for a provider.\n\t */\n\tset(provider: string, credential: AuthCredential): void {\n\t\tthis.data[provider] = credential;\n\t\tthis.save();\n\t}\n\n\t/**\n\t * Remove credential for a provider.\n\t */\n\tremove(provider: string): void {\n\t\tdelete this.data[provider];\n\t\tthis.save();\n\t}\n\n\t/**\n\t * List all providers with credentials.\n\t */\n\tlist(): string[] {\n\t\treturn Object.keys(this.data);\n\t}\n\n\t/**\n\t * Check if credentials exist for a provider in auth.json.\n\t */\n\thas(provider: string): boolean {\n\t\treturn provider in this.data;\n\t}\n\n\t/**\n\t * Check if any form of auth is configured for a provider.\n\t * Unlike getApiKey(), this doesn't refresh OAuth tokens.\n\t */\n\thasAuth(provider: string): boolean {\n\t\tif (this.runtimeOverrides.has(provider)) return true;\n\t\tif (this.data[provider]) return true;\n\t\tif (getEnvApiKey(provider)) return true;\n\t\tif (this.fallbackResolver?.(provider)) return true;\n\t\treturn false;\n\t}\n\n\t/**\n\t * Get all credentials (for passing to getOAuthApiKey).\n\t */\n\tgetAll(): AuthStorageData {\n\t\treturn { ...this.data };\n\t}\n\n\t/**\n\t * Login to an OAuth provider.\n\t */\n\tasync login(\n\t\tprovider: OAuthProvider,\n\t\tcallbacks: {\n\t\t\tonAuth: (info: { url: string; instructions?: string }) => void;\n\t\t\tonPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;\n\t\t\tonProgress?: (message: string) => void;\n\t\t\t/** For providers with local callback servers (e.g., openai-codex), races with browser callback */\n\t\t\tonManualCodeInput?: () => Promise<string>;\n\t\t\t/** For cancellation support (e.g., github-copilot polling) */\n\t\t\tsignal?: AbortSignal;\n\t\t},\n\t): Promise<void> {\n\t\tlet credentials: OAuthCredentials;\n\n\t\tswitch (provider) {\n\t\t\tcase \"anthropic\":\n\t\t\t\tcredentials = await loginAnthropic(\n\t\t\t\t\t(url) => callbacks.onAuth({ url }),\n\t\t\t\t\t() => callbacks.onPrompt({ message: \"Paste the authorization code:\" }),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"github-copilot\":\n\t\t\t\tcredentials = await loginGitHubCopilot({\n\t\t\t\t\tonAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),\n\t\t\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\t\t\tonProgress: callbacks.onProgress,\n\t\t\t\t\tsignal: callbacks.signal,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"google-gemini-cli\":\n\t\t\t\tcredentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);\n\t\t\t\tbreak;\n\t\t\tcase \"google-antigravity\":\n\t\t\t\tcredentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);\n\t\t\t\tbreak;\n\t\t\tcase \"openai-codex\":\n\t\t\t\tcredentials = await loginOpenAICodex({\n\t\t\t\t\tonAuth: callbacks.onAuth,\n\t\t\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\t\t\tonProgress: callbacks.onProgress,\n\t\t\t\t\tonManualCodeInput: callbacks.onManualCodeInput,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown OAuth provider: ${provider}`);\n\t\t}\n\n\t\tthis.set(provider, { type: \"oauth\", ...credentials });\n\t}\n\n\t/**\n\t * Logout from a provider.\n\t */\n\tlogout(provider: string): void {\n\t\tthis.remove(provider);\n\t}\n\n\t/**\n\t * Refresh OAuth token with file locking to prevent race conditions.\n\t * Multiple pi instances may try to refresh simultaneously when tokens expire.\n\t * This ensures only one instance refreshes while others wait and use the result.\n\t */\n\tprivate async refreshOAuthTokenWithLock(\n\t\tprovider: OAuthProvider,\n\t): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {\n\t\t// Ensure auth file exists for locking\n\t\tif (!existsSync(this.authPath)) {\n\t\t\tconst dir = dirname(this.authPath);\n\t\t\tif (!existsSync(dir)) {\n\t\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t\t}\n\t\t\twriteFileSync(this.authPath, \"{}\", \"utf-8\");\n\t\t\tchmodSync(this.authPath, 0o600);\n\t\t}\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\n\t\ttry {\n\t\t\t// Acquire exclusive lock with retry and timeout\n\t\t\t// Use generous retry window to handle slow token endpoints\n\t\t\trelease = await lockfile.lock(this.authPath, {\n\t\t\t\tretries: {\n\t\t\t\t\tretries: 10,\n\t\t\t\t\tfactor: 2,\n\t\t\t\t\tminTimeout: 100,\n\t\t\t\t\tmaxTimeout: 10000,\n\t\t\t\t\trandomize: true,\n\t\t\t\t},\n\t\t\t\tstale: 30000, // Consider lock stale after 30 seconds\n\t\t\t});\n\n\t\t\t// Re-read file after acquiring lock - another instance may have refreshed\n\t\t\tthis.reload();\n\n\t\t\tconst cred = this.data[provider];\n\t\t\tif (cred?.type !== \"oauth\") {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t// Check if token is still expired after re-reading\n\t\t\t// (another instance may have already refreshed it)\n\t\t\tif (Date.now() < cred.expires) {\n\t\t\t\t// Token is now valid - another instance refreshed it\n\t\t\t\tconst needsProjectId = provider === \"google-gemini-cli\" || provider === \"google-antigravity\";\n\t\t\t\tconst apiKey = needsProjectId\n\t\t\t\t\t? JSON.stringify({ token: cred.access, projectId: cred.projectId })\n\t\t\t\t\t: cred.access;\n\t\t\t\treturn { apiKey, newCredentials: cred };\n\t\t\t}\n\n\t\t\t// Token still expired, we need to refresh\n\t\t\tconst oauthCreds: Record<string, OAuthCredentials> = {};\n\t\t\tfor (const [key, value] of Object.entries(this.data)) {\n\t\t\t\tif (value.type === \"oauth\") {\n\t\t\t\t\toauthCreds[key] = value;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst result = await getOAuthApiKey(provider, oauthCreds);\n\t\t\tif (result) {\n\t\t\t\tthis.data[provider] = { type: \"oauth\", ...result.newCredentials };\n\t\t\t\tthis.save();\n\t\t\t\treturn result;\n\t\t\t}\n\n\t\t\treturn null;\n\t\t} finally {\n\t\t\t// Always release the lock\n\t\t\tif (release) {\n\t\t\t\ttry {\n\t\t\t\t\tawait release();\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore unlock errors (lock may have been compromised)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Get API key for a provider.\n\t * Priority:\n\t * 1. Runtime override (CLI --api-key)\n\t * 2. API key from auth.json\n\t * 3. OAuth token from auth.json (auto-refreshed with locking)\n\t * 4. Environment variable\n\t * 5. Fallback resolver (models.json custom providers)\n\t */\n\tasync getApiKey(provider: string): Promise<string | undefined> {\n\t\t// Runtime override takes highest priority\n\t\tconst runtimeKey = this.runtimeOverrides.get(provider);\n\t\tif (runtimeKey) {\n\t\t\treturn runtimeKey;\n\t\t}\n\n\t\tconst cred = this.data[provider];\n\n\t\tif (cred?.type === \"api_key\") {\n\t\t\treturn cred.key;\n\t\t}\n\n\t\tif (cred?.type === \"oauth\") {\n\t\t\t// Check if token needs refresh\n\t\t\tconst needsRefresh = Date.now() >= cred.expires;\n\n\t\t\tif (needsRefresh) {\n\t\t\t\t// Use locked refresh to prevent race conditions\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await this.refreshOAuthTokenWithLock(provider as OAuthProvider);\n\t\t\t\t\tif (result) {\n\t\t\t\t\t\treturn result.apiKey;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// Refresh failed - re-read file to check if another instance succeeded\n\t\t\t\t\tthis.reload();\n\t\t\t\t\tconst updatedCred = this.data[provider];\n\n\t\t\t\t\tif (updatedCred?.type === \"oauth\" && Date.now() < updatedCred.expires) {\n\t\t\t\t\t\t// Another instance refreshed successfully, use those credentials\n\t\t\t\t\t\tconst needsProjectId = provider === \"google-gemini-cli\" || provider === \"google-antigravity\";\n\t\t\t\t\t\treturn needsProjectId\n\t\t\t\t\t\t\t? JSON.stringify({ token: updatedCred.access, projectId: updatedCred.projectId })\n\t\t\t\t\t\t\t: updatedCred.access;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Refresh truly failed - DO NOT remove credentials\n\t\t\t\t\t// User can retry or re-authenticate manually\n\t\t\t\t\tconst errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`OAuth token refresh failed for ${provider}: ${errorMessage}. ` +\n\t\t\t\t\t\t\t`Please try again or re-authenticate with /login.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Token not expired, use current access token\n\t\t\t\tconst needsProjectId = provider === \"google-gemini-cli\" || provider === \"google-antigravity\";\n\t\t\t\treturn needsProjectId ? JSON.stringify({ token: cred.access, projectId: cred.projectId }) : cred.access;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to environment variable\n\t\tconst envKey = getEnvApiKey(provider);\n\t\tif (envKey) return envKey;\n\n\t\t// Fall back to custom resolver (e.g., models.json custom providers)\n\t\treturn this.fallbackResolver?.(provider) ?? undefined;\n\t}\n}\n"]}
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Credential storage for API keys and OAuth tokens.
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
+ *
5
+ * Uses file locking to prevent race conditions when multiple pi instances
6
+ * try to refresh tokens simultaneously.
4
7
  */
5
8
  import { getEnvApiKey, getOAuthApiKey, loginAnthropic, loginAntigravity, loginGeminiCli, loginGitHubCopilot, loginOpenAICodex, } from "@mariozechner/pi-ai";
6
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
10
  import { dirname } from "path";
11
+ import lockfile from "proper-lockfile";
8
12
  /**
9
13
  * Credential storage backed by a JSON file.
10
14
  */
@@ -130,16 +134,22 @@ export class AuthStorage {
130
134
  onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
131
135
  onPrompt: callbacks.onPrompt,
132
136
  onProgress: callbacks.onProgress,
137
+ signal: callbacks.signal,
133
138
  });
134
139
  break;
135
140
  case "google-gemini-cli":
136
- credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
141
+ credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
137
142
  break;
138
143
  case "google-antigravity":
139
- credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
144
+ credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
140
145
  break;
141
146
  case "openai-codex":
142
- credentials = await loginOpenAICodex(callbacks);
147
+ credentials = await loginOpenAICodex({
148
+ onAuth: callbacks.onAuth,
149
+ onPrompt: callbacks.onPrompt,
150
+ onProgress: callbacks.onProgress,
151
+ onManualCodeInput: callbacks.onManualCodeInput,
152
+ });
143
153
  break;
144
154
  default:
145
155
  throw new Error(`Unknown OAuth provider: ${provider}`);
@@ -152,12 +162,84 @@ export class AuthStorage {
152
162
  logout(provider) {
153
163
  this.remove(provider);
154
164
  }
165
+ /**
166
+ * Refresh OAuth token with file locking to prevent race conditions.
167
+ * Multiple pi instances may try to refresh simultaneously when tokens expire.
168
+ * This ensures only one instance refreshes while others wait and use the result.
169
+ */
170
+ async refreshOAuthTokenWithLock(provider) {
171
+ // Ensure auth file exists for locking
172
+ if (!existsSync(this.authPath)) {
173
+ const dir = dirname(this.authPath);
174
+ if (!existsSync(dir)) {
175
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
176
+ }
177
+ writeFileSync(this.authPath, "{}", "utf-8");
178
+ chmodSync(this.authPath, 0o600);
179
+ }
180
+ let release;
181
+ try {
182
+ // Acquire exclusive lock with retry and timeout
183
+ // Use generous retry window to handle slow token endpoints
184
+ release = await lockfile.lock(this.authPath, {
185
+ retries: {
186
+ retries: 10,
187
+ factor: 2,
188
+ minTimeout: 100,
189
+ maxTimeout: 10000,
190
+ randomize: true,
191
+ },
192
+ stale: 30000, // Consider lock stale after 30 seconds
193
+ });
194
+ // Re-read file after acquiring lock - another instance may have refreshed
195
+ this.reload();
196
+ const cred = this.data[provider];
197
+ if (cred?.type !== "oauth") {
198
+ return null;
199
+ }
200
+ // Check if token is still expired after re-reading
201
+ // (another instance may have already refreshed it)
202
+ if (Date.now() < cred.expires) {
203
+ // Token is now valid - another instance refreshed it
204
+ const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
205
+ const apiKey = needsProjectId
206
+ ? JSON.stringify({ token: cred.access, projectId: cred.projectId })
207
+ : cred.access;
208
+ return { apiKey, newCredentials: cred };
209
+ }
210
+ // Token still expired, we need to refresh
211
+ const oauthCreds = {};
212
+ for (const [key, value] of Object.entries(this.data)) {
213
+ if (value.type === "oauth") {
214
+ oauthCreds[key] = value;
215
+ }
216
+ }
217
+ const result = await getOAuthApiKey(provider, oauthCreds);
218
+ if (result) {
219
+ this.data[provider] = { type: "oauth", ...result.newCredentials };
220
+ this.save();
221
+ return result;
222
+ }
223
+ return null;
224
+ }
225
+ finally {
226
+ // Always release the lock
227
+ if (release) {
228
+ try {
229
+ await release();
230
+ }
231
+ catch {
232
+ // Ignore unlock errors (lock may have been compromised)
233
+ }
234
+ }
235
+ }
236
+ }
155
237
  /**
156
238
  * Get API key for a provider.
157
239
  * Priority:
158
240
  * 1. Runtime override (CLI --api-key)
159
241
  * 2. API key from auth.json
160
- * 3. OAuth token from auth.json (auto-refreshed)
242
+ * 3. OAuth token from auth.json (auto-refreshed with locking)
161
243
  * 4. Environment variable
162
244
  * 5. Fallback resolver (models.json custom providers)
163
245
  */
@@ -172,23 +254,38 @@ export class AuthStorage {
172
254
  return cred.key;
173
255
  }
174
256
  if (cred?.type === "oauth") {
175
- // Filter to only oauth credentials for getOAuthApiKey
176
- const oauthCreds = {};
177
- for (const [key, value] of Object.entries(this.data)) {
178
- if (value.type === "oauth") {
179
- oauthCreds[key] = value;
257
+ // Check if token needs refresh
258
+ const needsRefresh = Date.now() >= cred.expires;
259
+ if (needsRefresh) {
260
+ // Use locked refresh to prevent race conditions
261
+ try {
262
+ const result = await this.refreshOAuthTokenWithLock(provider);
263
+ if (result) {
264
+ return result.apiKey;
265
+ }
180
266
  }
181
- }
182
- try {
183
- const result = await getOAuthApiKey(provider, oauthCreds);
184
- if (result) {
185
- this.data[provider] = { type: "oauth", ...result.newCredentials };
186
- this.save();
187
- return result.apiKey;
267
+ catch (err) {
268
+ // Refresh failed - re-read file to check if another instance succeeded
269
+ this.reload();
270
+ const updatedCred = this.data[provider];
271
+ if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
272
+ // Another instance refreshed successfully, use those credentials
273
+ const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
274
+ return needsProjectId
275
+ ? JSON.stringify({ token: updatedCred.access, projectId: updatedCred.projectId })
276
+ : updatedCred.access;
277
+ }
278
+ // Refresh truly failed - DO NOT remove credentials
279
+ // User can retry or re-authenticate manually
280
+ const errorMessage = err instanceof Error ? err.message : String(err);
281
+ throw new Error(`OAuth token refresh failed for ${provider}: ${errorMessage}. ` +
282
+ `Please try again or re-authenticate with /login.`);
188
283
  }
189
284
  }
190
- catch {
191
- this.remove(provider);
285
+ else {
286
+ // Token not expired, use current access token
287
+ const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
288
+ return needsProjectId ? JSON.stringify({ token: cred.access, projectId: cred.projectId }) : cred.access;
192
289
  }
193
290
  }
194
291
  // Fall back to environment variable
@@ -1 +1 @@
1
- {"version":3,"file":"auth-storage.js","sourceRoot":"","sources":["../../src/core/auth-storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Credential storage for API keys and OAuth tokens.\n * Handles loading, saving, and refreshing credentials from auth.json.\n */\n\nimport {\n\tgetEnvApiKey,\n\tgetOAuthApiKey,\n\tloginAnthropic,\n\tloginAntigravity,\n\tloginGeminiCli,\n\tloginGitHubCopilot,\n\tloginOpenAICodex,\n\ttype OAuthCredentials,\n\ttype OAuthProvider,\n} from \"@mariozechner/pi-ai\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { dirname } from \"path\";\n\nexport type ApiKeyCredential = {\n\ttype: \"api_key\";\n\tkey: string;\n};\n\nexport type OAuthCredential = {\n\ttype: \"oauth\";\n} & OAuthCredentials;\n\nexport type AuthCredential = ApiKeyCredential | OAuthCredential;\n\nexport type AuthStorageData = Record<string, AuthCredential>;\n\n/**\n * Credential storage backed by a JSON file.\n */\nexport class AuthStorage {\n\tprivate data: AuthStorageData = {};\n\tprivate runtimeOverrides: Map<string, string> = new Map();\n\tprivate fallbackResolver?: (provider: string) => string | undefined;\n\n\tconstructor(private authPath: string) {\n\t\tthis.reload();\n\t}\n\n\t/**\n\t * Set a runtime API key override (not persisted to disk).\n\t * Used for CLI --api-key flag.\n\t */\n\tsetRuntimeApiKey(provider: string, apiKey: string): void {\n\t\tthis.runtimeOverrides.set(provider, apiKey);\n\t}\n\n\t/**\n\t * Remove a runtime API key override.\n\t */\n\tremoveRuntimeApiKey(provider: string): void {\n\t\tthis.runtimeOverrides.delete(provider);\n\t}\n\n\t/**\n\t * Set a fallback resolver for API keys not found in auth.json or env vars.\n\t * Used for custom provider keys from models.json.\n\t */\n\tsetFallbackResolver(resolver: (provider: string) => string | undefined): void {\n\t\tthis.fallbackResolver = resolver;\n\t}\n\n\t/**\n\t * Reload credentials from disk.\n\t */\n\treload(): void {\n\t\tif (!existsSync(this.authPath)) {\n\t\t\tthis.data = {};\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tthis.data = JSON.parse(readFileSync(this.authPath, \"utf-8\"));\n\t\t} catch {\n\t\t\tthis.data = {};\n\t\t}\n\t}\n\n\t/**\n\t * Save credentials to disk.\n\t */\n\tprivate save(): void {\n\t\tconst dir = dirname(this.authPath);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t}\n\t\twriteFileSync(this.authPath, JSON.stringify(this.data, null, 2), \"utf-8\");\n\t\tchmodSync(this.authPath, 0o600);\n\t}\n\n\t/**\n\t * Get credential for a provider.\n\t */\n\tget(provider: string): AuthCredential | undefined {\n\t\treturn this.data[provider] ?? undefined;\n\t}\n\n\t/**\n\t * Set credential for a provider.\n\t */\n\tset(provider: string, credential: AuthCredential): void {\n\t\tthis.data[provider] = credential;\n\t\tthis.save();\n\t}\n\n\t/**\n\t * Remove credential for a provider.\n\t */\n\tremove(provider: string): void {\n\t\tdelete this.data[provider];\n\t\tthis.save();\n\t}\n\n\t/**\n\t * List all providers with credentials.\n\t */\n\tlist(): string[] {\n\t\treturn Object.keys(this.data);\n\t}\n\n\t/**\n\t * Check if credentials exist for a provider in auth.json.\n\t */\n\thas(provider: string): boolean {\n\t\treturn provider in this.data;\n\t}\n\n\t/**\n\t * Check if any form of auth is configured for a provider.\n\t * Unlike getApiKey(), this doesn't refresh OAuth tokens.\n\t */\n\thasAuth(provider: string): boolean {\n\t\tif (this.runtimeOverrides.has(provider)) return true;\n\t\tif (this.data[provider]) return true;\n\t\tif (getEnvApiKey(provider)) return true;\n\t\tif (this.fallbackResolver?.(provider)) return true;\n\t\treturn false;\n\t}\n\n\t/**\n\t * Get all credentials (for passing to getOAuthApiKey).\n\t */\n\tgetAll(): AuthStorageData {\n\t\treturn { ...this.data };\n\t}\n\n\t/**\n\t * Login to an OAuth provider.\n\t */\n\tasync login(\n\t\tprovider: OAuthProvider,\n\t\tcallbacks: {\n\t\t\tonAuth: (info: { url: string; instructions?: string }) => void;\n\t\t\tonPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;\n\t\t\tonProgress?: (message: string) => void;\n\t\t},\n\t): Promise<void> {\n\t\tlet credentials: OAuthCredentials;\n\n\t\tswitch (provider) {\n\t\t\tcase \"anthropic\":\n\t\t\t\tcredentials = await loginAnthropic(\n\t\t\t\t\t(url) => callbacks.onAuth({ url }),\n\t\t\t\t\t() => callbacks.onPrompt({ message: \"Paste the authorization code:\" }),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"github-copilot\":\n\t\t\t\tcredentials = await loginGitHubCopilot({\n\t\t\t\t\tonAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),\n\t\t\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\t\t\tonProgress: callbacks.onProgress,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"google-gemini-cli\":\n\t\t\t\tcredentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);\n\t\t\t\tbreak;\n\t\t\tcase \"google-antigravity\":\n\t\t\t\tcredentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);\n\t\t\t\tbreak;\n\t\t\tcase \"openai-codex\":\n\t\t\t\tcredentials = await loginOpenAICodex(callbacks);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown OAuth provider: ${provider}`);\n\t\t}\n\n\t\tthis.set(provider, { type: \"oauth\", ...credentials });\n\t}\n\n\t/**\n\t * Logout from a provider.\n\t */\n\tlogout(provider: string): void {\n\t\tthis.remove(provider);\n\t}\n\n\t/**\n\t * Get API key for a provider.\n\t * Priority:\n\t * 1. Runtime override (CLI --api-key)\n\t * 2. API key from auth.json\n\t * 3. OAuth token from auth.json (auto-refreshed)\n\t * 4. Environment variable\n\t * 5. Fallback resolver (models.json custom providers)\n\t */\n\tasync getApiKey(provider: string): Promise<string | undefined> {\n\t\t// Runtime override takes highest priority\n\t\tconst runtimeKey = this.runtimeOverrides.get(provider);\n\t\tif (runtimeKey) {\n\t\t\treturn runtimeKey;\n\t\t}\n\n\t\tconst cred = this.data[provider];\n\n\t\tif (cred?.type === \"api_key\") {\n\t\t\treturn cred.key;\n\t\t}\n\n\t\tif (cred?.type === \"oauth\") {\n\t\t\t// Filter to only oauth credentials for getOAuthApiKey\n\t\t\tconst oauthCreds: Record<string, OAuthCredentials> = {};\n\t\t\tfor (const [key, value] of Object.entries(this.data)) {\n\t\t\t\tif (value.type === \"oauth\") {\n\t\t\t\t\toauthCreds[key] = value;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);\n\t\t\t\tif (result) {\n\t\t\t\t\tthis.data[provider] = { type: \"oauth\", ...result.newCredentials };\n\t\t\t\t\tthis.save();\n\t\t\t\t\treturn result.apiKey;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tthis.remove(provider);\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to environment variable\n\t\tconst envKey = getEnvApiKey(provider);\n\t\tif (envKey) return envKey;\n\n\t\t// Fall back to custom resolver (e.g., models.json custom providers)\n\t\treturn this.fallbackResolver?.(provider) ?? undefined;\n\t}\n}\n"]}
1
+ {"version":3,"file":"auth-storage.js","sourceRoot":"","sources":["../../src/core/auth-storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Credential storage for API keys and OAuth tokens.\n * Handles loading, saving, and refreshing credentials from auth.json.\n *\n * Uses file locking to prevent race conditions when multiple pi instances\n * try to refresh tokens simultaneously.\n */\n\nimport {\n\tgetEnvApiKey,\n\tgetOAuthApiKey,\n\tloginAnthropic,\n\tloginAntigravity,\n\tloginGeminiCli,\n\tloginGitHubCopilot,\n\tloginOpenAICodex,\n\ttype OAuthCredentials,\n\ttype OAuthProvider,\n} from \"@mariozechner/pi-ai\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { dirname } from \"path\";\nimport lockfile from \"proper-lockfile\";\n\nexport type ApiKeyCredential = {\n\ttype: \"api_key\";\n\tkey: string;\n};\n\nexport type OAuthCredential = {\n\ttype: \"oauth\";\n} & OAuthCredentials;\n\nexport type AuthCredential = ApiKeyCredential | OAuthCredential;\n\nexport type AuthStorageData = Record<string, AuthCredential>;\n\n/**\n * Credential storage backed by a JSON file.\n */\nexport class AuthStorage {\n\tprivate data: AuthStorageData = {};\n\tprivate runtimeOverrides: Map<string, string> = new Map();\n\tprivate fallbackResolver?: (provider: string) => string | undefined;\n\n\tconstructor(private authPath: string) {\n\t\tthis.reload();\n\t}\n\n\t/**\n\t * Set a runtime API key override (not persisted to disk).\n\t * Used for CLI --api-key flag.\n\t */\n\tsetRuntimeApiKey(provider: string, apiKey: string): void {\n\t\tthis.runtimeOverrides.set(provider, apiKey);\n\t}\n\n\t/**\n\t * Remove a runtime API key override.\n\t */\n\tremoveRuntimeApiKey(provider: string): void {\n\t\tthis.runtimeOverrides.delete(provider);\n\t}\n\n\t/**\n\t * Set a fallback resolver for API keys not found in auth.json or env vars.\n\t * Used for custom provider keys from models.json.\n\t */\n\tsetFallbackResolver(resolver: (provider: string) => string | undefined): void {\n\t\tthis.fallbackResolver = resolver;\n\t}\n\n\t/**\n\t * Reload credentials from disk.\n\t */\n\treload(): void {\n\t\tif (!existsSync(this.authPath)) {\n\t\t\tthis.data = {};\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tthis.data = JSON.parse(readFileSync(this.authPath, \"utf-8\"));\n\t\t} catch {\n\t\t\tthis.data = {};\n\t\t}\n\t}\n\n\t/**\n\t * Save credentials to disk.\n\t */\n\tprivate save(): void {\n\t\tconst dir = dirname(this.authPath);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t}\n\t\twriteFileSync(this.authPath, JSON.stringify(this.data, null, 2), \"utf-8\");\n\t\tchmodSync(this.authPath, 0o600);\n\t}\n\n\t/**\n\t * Get credential for a provider.\n\t */\n\tget(provider: string): AuthCredential | undefined {\n\t\treturn this.data[provider] ?? undefined;\n\t}\n\n\t/**\n\t * Set credential for a provider.\n\t */\n\tset(provider: string, credential: AuthCredential): void {\n\t\tthis.data[provider] = credential;\n\t\tthis.save();\n\t}\n\n\t/**\n\t * Remove credential for a provider.\n\t */\n\tremove(provider: string): void {\n\t\tdelete this.data[provider];\n\t\tthis.save();\n\t}\n\n\t/**\n\t * List all providers with credentials.\n\t */\n\tlist(): string[] {\n\t\treturn Object.keys(this.data);\n\t}\n\n\t/**\n\t * Check if credentials exist for a provider in auth.json.\n\t */\n\thas(provider: string): boolean {\n\t\treturn provider in this.data;\n\t}\n\n\t/**\n\t * Check if any form of auth is configured for a provider.\n\t * Unlike getApiKey(), this doesn't refresh OAuth tokens.\n\t */\n\thasAuth(provider: string): boolean {\n\t\tif (this.runtimeOverrides.has(provider)) return true;\n\t\tif (this.data[provider]) return true;\n\t\tif (getEnvApiKey(provider)) return true;\n\t\tif (this.fallbackResolver?.(provider)) return true;\n\t\treturn false;\n\t}\n\n\t/**\n\t * Get all credentials (for passing to getOAuthApiKey).\n\t */\n\tgetAll(): AuthStorageData {\n\t\treturn { ...this.data };\n\t}\n\n\t/**\n\t * Login to an OAuth provider.\n\t */\n\tasync login(\n\t\tprovider: OAuthProvider,\n\t\tcallbacks: {\n\t\t\tonAuth: (info: { url: string; instructions?: string }) => void;\n\t\t\tonPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;\n\t\t\tonProgress?: (message: string) => void;\n\t\t\t/** For providers with local callback servers (e.g., openai-codex), races with browser callback */\n\t\t\tonManualCodeInput?: () => Promise<string>;\n\t\t\t/** For cancellation support (e.g., github-copilot polling) */\n\t\t\tsignal?: AbortSignal;\n\t\t},\n\t): Promise<void> {\n\t\tlet credentials: OAuthCredentials;\n\n\t\tswitch (provider) {\n\t\t\tcase \"anthropic\":\n\t\t\t\tcredentials = await loginAnthropic(\n\t\t\t\t\t(url) => callbacks.onAuth({ url }),\n\t\t\t\t\t() => callbacks.onPrompt({ message: \"Paste the authorization code:\" }),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"github-copilot\":\n\t\t\t\tcredentials = await loginGitHubCopilot({\n\t\t\t\t\tonAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),\n\t\t\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\t\t\tonProgress: callbacks.onProgress,\n\t\t\t\t\tsignal: callbacks.signal,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"google-gemini-cli\":\n\t\t\t\tcredentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);\n\t\t\t\tbreak;\n\t\t\tcase \"google-antigravity\":\n\t\t\t\tcredentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);\n\t\t\t\tbreak;\n\t\t\tcase \"openai-codex\":\n\t\t\t\tcredentials = await loginOpenAICodex({\n\t\t\t\t\tonAuth: callbacks.onAuth,\n\t\t\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\t\t\tonProgress: callbacks.onProgress,\n\t\t\t\t\tonManualCodeInput: callbacks.onManualCodeInput,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown OAuth provider: ${provider}`);\n\t\t}\n\n\t\tthis.set(provider, { type: \"oauth\", ...credentials });\n\t}\n\n\t/**\n\t * Logout from a provider.\n\t */\n\tlogout(provider: string): void {\n\t\tthis.remove(provider);\n\t}\n\n\t/**\n\t * Refresh OAuth token with file locking to prevent race conditions.\n\t * Multiple pi instances may try to refresh simultaneously when tokens expire.\n\t * This ensures only one instance refreshes while others wait and use the result.\n\t */\n\tprivate async refreshOAuthTokenWithLock(\n\t\tprovider: OAuthProvider,\n\t): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {\n\t\t// Ensure auth file exists for locking\n\t\tif (!existsSync(this.authPath)) {\n\t\t\tconst dir = dirname(this.authPath);\n\t\t\tif (!existsSync(dir)) {\n\t\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t\t}\n\t\t\twriteFileSync(this.authPath, \"{}\", \"utf-8\");\n\t\t\tchmodSync(this.authPath, 0o600);\n\t\t}\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\n\t\ttry {\n\t\t\t// Acquire exclusive lock with retry and timeout\n\t\t\t// Use generous retry window to handle slow token endpoints\n\t\t\trelease = await lockfile.lock(this.authPath, {\n\t\t\t\tretries: {\n\t\t\t\t\tretries: 10,\n\t\t\t\t\tfactor: 2,\n\t\t\t\t\tminTimeout: 100,\n\t\t\t\t\tmaxTimeout: 10000,\n\t\t\t\t\trandomize: true,\n\t\t\t\t},\n\t\t\t\tstale: 30000, // Consider lock stale after 30 seconds\n\t\t\t});\n\n\t\t\t// Re-read file after acquiring lock - another instance may have refreshed\n\t\t\tthis.reload();\n\n\t\t\tconst cred = this.data[provider];\n\t\t\tif (cred?.type !== \"oauth\") {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t// Check if token is still expired after re-reading\n\t\t\t// (another instance may have already refreshed it)\n\t\t\tif (Date.now() < cred.expires) {\n\t\t\t\t// Token is now valid - another instance refreshed it\n\t\t\t\tconst needsProjectId = provider === \"google-gemini-cli\" || provider === \"google-antigravity\";\n\t\t\t\tconst apiKey = needsProjectId\n\t\t\t\t\t? JSON.stringify({ token: cred.access, projectId: cred.projectId })\n\t\t\t\t\t: cred.access;\n\t\t\t\treturn { apiKey, newCredentials: cred };\n\t\t\t}\n\n\t\t\t// Token still expired, we need to refresh\n\t\t\tconst oauthCreds: Record<string, OAuthCredentials> = {};\n\t\t\tfor (const [key, value] of Object.entries(this.data)) {\n\t\t\t\tif (value.type === \"oauth\") {\n\t\t\t\t\toauthCreds[key] = value;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst result = await getOAuthApiKey(provider, oauthCreds);\n\t\t\tif (result) {\n\t\t\t\tthis.data[provider] = { type: \"oauth\", ...result.newCredentials };\n\t\t\t\tthis.save();\n\t\t\t\treturn result;\n\t\t\t}\n\n\t\t\treturn null;\n\t\t} finally {\n\t\t\t// Always release the lock\n\t\t\tif (release) {\n\t\t\t\ttry {\n\t\t\t\t\tawait release();\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore unlock errors (lock may have been compromised)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Get API key for a provider.\n\t * Priority:\n\t * 1. Runtime override (CLI --api-key)\n\t * 2. API key from auth.json\n\t * 3. OAuth token from auth.json (auto-refreshed with locking)\n\t * 4. Environment variable\n\t * 5. Fallback resolver (models.json custom providers)\n\t */\n\tasync getApiKey(provider: string): Promise<string | undefined> {\n\t\t// Runtime override takes highest priority\n\t\tconst runtimeKey = this.runtimeOverrides.get(provider);\n\t\tif (runtimeKey) {\n\t\t\treturn runtimeKey;\n\t\t}\n\n\t\tconst cred = this.data[provider];\n\n\t\tif (cred?.type === \"api_key\") {\n\t\t\treturn cred.key;\n\t\t}\n\n\t\tif (cred?.type === \"oauth\") {\n\t\t\t// Check if token needs refresh\n\t\t\tconst needsRefresh = Date.now() >= cred.expires;\n\n\t\t\tif (needsRefresh) {\n\t\t\t\t// Use locked refresh to prevent race conditions\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await this.refreshOAuthTokenWithLock(provider as OAuthProvider);\n\t\t\t\t\tif (result) {\n\t\t\t\t\t\treturn result.apiKey;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// Refresh failed - re-read file to check if another instance succeeded\n\t\t\t\t\tthis.reload();\n\t\t\t\t\tconst updatedCred = this.data[provider];\n\n\t\t\t\t\tif (updatedCred?.type === \"oauth\" && Date.now() < updatedCred.expires) {\n\t\t\t\t\t\t// Another instance refreshed successfully, use those credentials\n\t\t\t\t\t\tconst needsProjectId = provider === \"google-gemini-cli\" || provider === \"google-antigravity\";\n\t\t\t\t\t\treturn needsProjectId\n\t\t\t\t\t\t\t? JSON.stringify({ token: updatedCred.access, projectId: updatedCred.projectId })\n\t\t\t\t\t\t\t: updatedCred.access;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Refresh truly failed - DO NOT remove credentials\n\t\t\t\t\t// User can retry or re-authenticate manually\n\t\t\t\t\tconst errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`OAuth token refresh failed for ${provider}: ${errorMessage}. ` +\n\t\t\t\t\t\t\t`Please try again or re-authenticate with /login.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Token not expired, use current access token\n\t\t\t\tconst needsProjectId = provider === \"google-gemini-cli\" || provider === \"google-antigravity\";\n\t\t\t\treturn needsProjectId ? JSON.stringify({ token: cred.access, projectId: cred.projectId }) : cred.access;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to environment variable\n\t\tconst envKey = getEnvApiKey(provider);\n\t\tif (envKey) return envKey;\n\n\t\t// Fall back to custom resolver (e.g., models.json custom providers)\n\t\treturn this.fallbackResolver?.(provider) ?? undefined;\n\t}\n}\n"]}
@@ -280,10 +280,65 @@
280
280
  color: var(--userMessageText);
281
281
  padding: var(--line-height);
282
282
  border-radius: 4px;
283
+ position: relative;
283
284
  }
284
285
 
285
286
  .assistant-message {
286
287
  padding: 0;
288
+ position: relative;
289
+ }
290
+
291
+ /* Copy link button - appears on hover */
292
+ .copy-link-btn {
293
+ position: absolute;
294
+ top: 8px;
295
+ right: 8px;
296
+ width: 28px;
297
+ height: 28px;
298
+ padding: 6px;
299
+ background: var(--container-bg);
300
+ border: 1px solid var(--dim);
301
+ border-radius: 4px;
302
+ color: var(--muted);
303
+ cursor: pointer;
304
+ opacity: 0;
305
+ transition: opacity 0.15s, background 0.15s, color 0.15s;
306
+ display: flex;
307
+ align-items: center;
308
+ justify-content: center;
309
+ z-index: 10;
310
+ }
311
+
312
+ .user-message:hover .copy-link-btn,
313
+ .assistant-message:hover .copy-link-btn {
314
+ opacity: 1;
315
+ }
316
+
317
+ .copy-link-btn:hover {
318
+ background: var(--accent);
319
+ color: var(--body-bg);
320
+ border-color: var(--accent);
321
+ }
322
+
323
+ .copy-link-btn.copied {
324
+ background: var(--success, #22c55e);
325
+ color: white;
326
+ border-color: var(--success, #22c55e);
327
+ }
328
+
329
+ /* Highlight effect for deep-linked messages */
330
+ .user-message.highlight,
331
+ .assistant-message.highlight {
332
+ animation: highlight-pulse 2s ease-out;
333
+ }
334
+
335
+ @keyframes highlight-pulse {
336
+ 0% {
337
+ box-shadow: 0 0 0 3px var(--accent);
338
+ }
339
+ 100% {
340
+ box-shadow: 0 0 0 0 transparent;
341
+ }
287
342
  }
288
343
 
289
344
  .assistant-message > .message-timestamp {
@@ -12,7 +12,18 @@
12
12
  bytes[i] = binary.charCodeAt(i);
13
13
  }
14
14
  const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));
15
- const { header, entries, leafId, systemPrompt, tools } = data;
15
+ const { header, entries, leafId: defaultLeafId, systemPrompt, tools } = data;
16
+
17
+ // ============================================================
18
+ // URL PARAMETER HANDLING
19
+ // ============================================================
20
+
21
+ // Parse URL parameters for deep linking: leafId and targetId
22
+ const urlParams = new URLSearchParams(window.location.search);
23
+ const urlLeafId = urlParams.get('leafId');
24
+ const urlTargetId = urlParams.get('targetId');
25
+ // Use URL leafId if provided, otherwise fall back to session default
26
+ const leafId = urlLeafId || defaultLeafId;
16
27
 
17
28
  // ============================================================
18
29
  // DATA STRUCTURES
@@ -777,16 +788,87 @@
777
788
  return html;
778
789
  }
779
790
 
791
+ /**
792
+ * Build a shareable URL for a specific message.
793
+ * URL format: base?gistId&leafId=<leafId>&targetId=<entryId>
794
+ */
795
+ function buildShareUrl(entryId) {
796
+ const url = new URL(window.location.href);
797
+ // Find the gist ID (first query param without value, e.g., ?abc123)
798
+ const gistId = Array.from(url.searchParams.keys()).find(k => !url.searchParams.get(k));
799
+ // Build search string manually to preserve ?gistId format (not ?gistId=)
800
+ const params = new URLSearchParams();
801
+ params.set('leafId', currentLeafId);
802
+ params.set('targetId', entryId);
803
+ url.search = gistId ? `?${gistId}&${params.toString()}` : `?${params.toString()}`;
804
+ return url.toString();
805
+ }
806
+
807
+ /**
808
+ * Copy text to clipboard with visual feedback.
809
+ * Uses navigator.clipboard with fallback to execCommand for HTTP contexts.
810
+ */
811
+ async function copyToClipboard(text, button) {
812
+ let success = false;
813
+ try {
814
+ if (navigator.clipboard && navigator.clipboard.writeText) {
815
+ await navigator.clipboard.writeText(text);
816
+ success = true;
817
+ }
818
+ } catch (err) {
819
+ // Clipboard API failed, try fallback
820
+ }
821
+
822
+ // Fallback for HTTP or when Clipboard API is unavailable
823
+ if (!success) {
824
+ try {
825
+ const textarea = document.createElement('textarea');
826
+ textarea.value = text;
827
+ textarea.style.position = 'fixed';
828
+ textarea.style.opacity = '0';
829
+ document.body.appendChild(textarea);
830
+ textarea.select();
831
+ success = document.execCommand('copy');
832
+ document.body.removeChild(textarea);
833
+ } catch (err) {
834
+ console.error('Failed to copy:', err);
835
+ }
836
+ }
837
+
838
+ if (success && button) {
839
+ const originalHtml = button.innerHTML;
840
+ button.innerHTML = '✓';
841
+ button.classList.add('copied');
842
+ setTimeout(() => {
843
+ button.innerHTML = originalHtml;
844
+ button.classList.remove('copied');
845
+ }, 1500);
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Render the copy-link button HTML for a message.
851
+ */
852
+ function renderCopyLinkButton(entryId) {
853
+ return `<button class="copy-link-btn" data-entry-id="${entryId}" title="Copy link to this message">
854
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
855
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
856
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
857
+ </svg>
858
+ </button>`;
859
+ }
860
+
780
861
  function renderEntry(entry) {
781
862
  const ts = formatTimestamp(entry.timestamp);
782
863
  const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : '';
783
864
  const entryId = `entry-${entry.id}`;
865
+ const copyBtnHtml = renderCopyLinkButton(entry.id);
784
866
 
785
867
  if (entry.type === 'message') {
786
868
  const msg = entry.message;
787
869
 
788
870
  if (msg.role === 'user') {
789
- let html = `<div class="user-message" id="${entryId}">${tsHtml}`;
871
+ let html = `<div class="user-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
790
872
  const content = msg.content;
791
873
 
792
874
  if (Array.isArray(content)) {
@@ -810,7 +892,7 @@
810
892
  }
811
893
 
812
894
  if (msg.role === 'assistant') {
813
- let html = `<div class="assistant-message" id="${entryId}">${tsHtml}`;
895
+ let html = `<div class="assistant-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
814
896
 
815
897
  for (const block of msg.content) {
816
898
  if (block.type === 'text' && block.text.trim()) {
@@ -1023,7 +1105,7 @@
1023
1105
  return node;
1024
1106
  }
1025
1107
 
1026
- function navigateTo(targetId, scrollMode = 'target') {
1108
+ function navigateTo(targetId, scrollMode = 'target', scrollToEntryId = null) {
1027
1109
  currentLeafId = targetId;
1028
1110
  const path = getPath(targetId);
1029
1111
 
@@ -1045,15 +1127,32 @@
1045
1127
  messagesEl.innerHTML = '';
1046
1128
  messagesEl.appendChild(fragment);
1047
1129
 
1130
+ // Attach click handlers for copy-link buttons
1131
+ messagesEl.querySelectorAll('.copy-link-btn').forEach(btn => {
1132
+ btn.addEventListener('click', (e) => {
1133
+ e.stopPropagation();
1134
+ const entryId = btn.dataset.entryId;
1135
+ const shareUrl = buildShareUrl(entryId);
1136
+ copyToClipboard(shareUrl, btn);
1137
+ });
1138
+ });
1139
+
1048
1140
  // Use setTimeout(0) to ensure DOM is fully laid out before scrolling
1049
1141
  setTimeout(() => {
1050
1142
  const content = document.getElementById('content');
1051
1143
  if (scrollMode === 'bottom') {
1052
1144
  content.scrollTop = content.scrollHeight;
1053
1145
  } else if (scrollMode === 'target') {
1054
- const targetEl = document.getElementById(`entry-${targetId}`);
1146
+ // If scrollToEntryId is provided, scroll to that specific entry
1147
+ const scrollTargetId = scrollToEntryId || targetId;
1148
+ const targetEl = document.getElementById(`entry-${scrollTargetId}`);
1055
1149
  if (targetEl) {
1056
1150
  targetEl.scrollIntoView({ block: 'center' });
1151
+ // Briefly highlight the target message
1152
+ if (scrollToEntryId) {
1153
+ targetEl.classList.add('highlight');
1154
+ setTimeout(() => targetEl.classList.remove('highlight'), 2000);
1155
+ }
1057
1156
  }
1058
1157
  }
1059
1158
  }, 0);
@@ -1188,9 +1287,15 @@
1188
1287
  }
1189
1288
  });
1190
1289
 
1191
- // Initial render - don't scroll, stay at top
1290
+ // Initial render
1291
+ // If URL has targetId, scroll to that specific message; otherwise stay at top
1192
1292
  if (leafId) {
1193
- navigateTo(leafId, 'none');
1293
+ if (urlTargetId && byId.has(urlTargetId)) {
1294
+ // Deep link: navigate to leaf and scroll to target message
1295
+ navigateTo(leafId, 'target', urlTargetId);
1296
+ } else {
1297
+ navigateTo(leafId, 'none');
1298
+ }
1194
1299
  } else if (entries.length > 0) {
1195
1300
  // Fallback: use last entry if no leafId
1196
1301
  navigateTo(entries[entries.length - 1].id, 'none');