@phi-code-admin/phi-code 0.76.7 → 0.76.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"api-key-store.d.ts","sourceRoot":"","sources":["../../src/core/api-key-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAK3C,MAAM,WAAW,uBAAuB;IACvC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,qBAAqB;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,kBAAkB;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,WAAY,SAAQ,YAAY;IAC5C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,MAAM,CAA4C;IAC1D,OAAO,CAAC,MAAM,CAAS;IAEvB,YAAY,OAAO,GAAE,kBAAuB,EAG3C;IAED;;OAEG;IACH,IAAI,IAAI,qBAAqB,CAgB5B;IAED;;OAEG;IACH,cAAc,IAAI,IAAI,CAGrB;IAED;;;OAGG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM9D;IAED;;OAEG;IACH,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAGnE;IAED;;OAEG;IACH,aAAa,IAAI,MAAM,EAAE,CAGxB;IAED;;;;OAIG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC,GAAG,IAAI,CAU/F;IAED;;;OAGG;IACH,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAQlC;IAED;;OAEG;IACH,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAM9C;IAED;;;;;;OAMG;IACH,OAAO,CAAC,OAAO;CAef;AAID,wBAAgB,cAAc,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,WAAW,CAKxE;AAED,wBAAgB,iBAAiB,IAAI,IAAI,CAExC","sourcesContent":["/**\n * API Key Store - Centralized storage and hot-reload for provider API keys.\n *\n * Per Q5 strategy C: explicit /keys command + file watcher both supported.\n * Per Q6 strategy A: keys stored in plain text in ~/.phi/agent/models.json\n * with chmod 0600 on Unix and clear warnings to users.\n *\n * Storage format: models.json providers.<id>.apiKey contains the key value\n * directly (not an env var reference). Resolution priority:\n * 1. Value in models.json (if non-empty)\n * 2. process.env[envVar] (legacy fallback)\n *\n * Events emitted via the EventEmitter:\n * - \"key_changed\" { provider, key } : when setKey() or external edit detected\n * - \"key_removed\" { provider } : when removeKey() called\n * - \"store_reloaded\" : when reloadFromDisk() succeeds\n */\n\nimport { EventEmitter } from \"node:events\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\nexport interface ProviderConfigPersisted {\n\tbaseUrl?: string;\n\tapi?: string;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n\tauthHeader?: boolean;\n\tmodels?: unknown[];\n\tmodelOverrides?: Record<string, unknown>;\n}\n\nexport interface ModelsConfigPersisted {\n\t$comment?: string;\n\tproviders: Record<string, ProviderConfigPersisted>;\n}\n\nexport interface ApiKeyStoreOptions {\n\tconfigPath?: string;\n}\n\nexport class ApiKeyStore extends EventEmitter {\n\treadonly configPath: string;\n\tprivate config: ModelsConfigPersisted = { providers: {} };\n\tprivate loaded = false;\n\n\tconstructor(options: ApiKeyStoreOptions = {}) {\n\t\tsuper();\n\t\tthis.configPath = options.configPath ?? join(homedir(), \".phi\", \"agent\", \"models.json\");\n\t}\n\n\t/**\n\t * Load config from disk. Creates an empty file if missing.\n\t */\n\tload(): ModelsConfigPersisted {\n\t\ttry {\n\t\t\tif (!existsSync(this.configPath)) {\n\t\t\t\treturn this.config;\n\t\t\t}\n\t\t\tconst raw = readFileSync(this.configPath, \"utf-8\");\n\t\t\tconst parsed = JSON.parse(raw) as ModelsConfigPersisted;\n\t\t\tif (!parsed.providers || typeof parsed.providers !== \"object\") {\n\t\t\t\tparsed.providers = {};\n\t\t\t}\n\t\t\tthis.config = parsed;\n\t\t\tthis.loaded = true;\n\t\t\treturn this.config;\n\t\t} catch (err) {\n\t\t\tthrow new Error(`Failed to load ${this.configPath}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Reload from disk and emit \"store_reloaded\".\n\t */\n\treloadFromDisk(): void {\n\t\tthis.load();\n\t\tthis.emit(\"store_reloaded\");\n\t}\n\n\t/**\n\t * Get the API key for a provider, with env-var fallback.\n\t * Returns undefined if neither source has a value.\n\t */\n\tgetKey(providerId: string, envVar?: string): string | undefined {\n\t\tif (!this.loaded) this.load();\n\t\tconst stored = this.config.providers[providerId]?.apiKey?.trim();\n\t\tif (stored && stored.length > 0 && !stored.startsWith(\"$\")) return stored;\n\t\tif (envVar) return process.env[envVar]?.trim() || undefined;\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Get the full provider config block for a provider.\n\t */\n\tgetProvider(providerId: string): ProviderConfigPersisted | undefined {\n\t\tif (!this.loaded) this.load();\n\t\treturn this.config.providers[providerId];\n\t}\n\n\t/**\n\t * List all configured provider IDs.\n\t */\n\tlistProviders(): string[] {\n\t\tif (!this.loaded) this.load();\n\t\treturn Object.keys(this.config.providers);\n\t}\n\n\t/**\n\t * Set the API key for a provider. Creates the provider entry if missing.\n\t * Performs atomic write (tmp + rename) and chmod 0600 on Unix.\n\t * Emits \"key_changed\" event.\n\t */\n\tsetKey(providerId: string, key: string, providerConfig?: Partial<ProviderConfigPersisted>): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId] ?? {};\n\t\tthis.config.providers[providerId] = {\n\t\t\t...existing,\n\t\t\t...providerConfig,\n\t\t\tapiKey: key,\n\t\t};\n\t\tthis.persist();\n\t\tthis.emit(\"key_changed\", { provider: providerId, key });\n\t}\n\n\t/**\n\t * Remove the API key for a provider (but keep the rest of the provider config).\n\t * Emits \"key_removed\" event.\n\t */\n\tremoveKey(providerId: string): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId];\n\t\tif (!existing) return;\n\t\tconst { apiKey: _removed, ...rest } = existing;\n\t\tthis.config.providers[providerId] = rest;\n\t\tthis.persist();\n\t\tthis.emit(\"key_removed\", { provider: providerId });\n\t}\n\n\t/**\n\t * Mask an API key for display (preserves length info without exposing the secret).\n\t */\n\tstatic maskKey(key: string | undefined): string {\n\t\tif (!key) return \"(not set)\";\n\t\tconst trimmed = key.trim();\n\t\tif (trimmed.length === 0) return \"(empty)\";\n\t\tif (trimmed.length <= 8) return \"********\";\n\t\treturn `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;\n\t}\n\n\t/**\n\t * Atomic write of the config to disk:\n\t * 1. Ensure parent dir exists\n\t * 2. Write to <path>.tmp\n\t * 3. Rename to <path> (atomic on POSIX)\n\t * 4. Apply chmod 0600 on Unix (silently skip on Windows)\n\t */\n\tprivate persist(): void {\n\t\tconst parent = dirname(this.configPath);\n\t\tmkdirSync(parent, { recursive: true });\n\t\tconst tmpPath = `${this.configPath}.tmp`;\n\t\tconst payload = `${JSON.stringify(this.config, null, 2)}\\n`;\n\t\twriteFileSync(tmpPath, payload, \"utf-8\");\n\t\trenameSync(tmpPath, this.configPath);\n\t\tif (process.platform !== \"win32\") {\n\t\t\ttry {\n\t\t\t\tchmodSync(this.configPath, 0o600);\n\t\t\t} catch {\n\t\t\t\t// chmod may fail on some filesystems (eg WSL); non-critical\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Module-level singleton for convenience. */\nlet _singleton: ApiKeyStore | null = null;\nexport function getApiKeyStore(options?: ApiKeyStoreOptions): ApiKeyStore {\n\tif (!_singleton || (options?.configPath && _singleton.configPath !== options.configPath)) {\n\t\t_singleton = new ApiKeyStore(options);\n\t}\n\treturn _singleton;\n}\n\nexport function _resetApiKeyStore(): void {\n\t_singleton = null;\n}\n"]}
1
+ {"version":3,"file":"api-key-store.d.ts","sourceRoot":"","sources":["../../src/core/api-key-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAK3C,MAAM,WAAW,uBAAuB;IACvC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,qBAAqB;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,kBAAkB;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,WAAY,SAAQ,YAAY;IAC5C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,MAAM,CAA4C;IAC1D,OAAO,CAAC,MAAM,CAAS;IAEvB,YAAY,OAAO,GAAE,kBAAuB,EAG3C;IAED;;OAEG;IACH,IAAI,IAAI,qBAAqB,CAgB5B;IAED;;OAEG;IACH,cAAc,IAAI,IAAI,CAGrB;IAED;;;OAGG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM9D;IAED;;OAEG;IACH,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAGnE;IAED;;OAEG;IACH,aAAa,IAAI,MAAM,EAAE,CAGxB;IAED;;;;OAIG;IACH,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC,GAAG,IAAI,CAU/F;IAED;;;OAGG;IACH,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAQlC;IAED;;OAEG;IACH,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAM9C;IAED;;;;;;OAMG;IACH,OAAO,CAAC,OAAO;CAmBf;AAID,wBAAgB,cAAc,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,WAAW,CAKxE;AAED,wBAAgB,iBAAiB,IAAI,IAAI,CAExC","sourcesContent":["/**\n * API Key Store - Centralized storage and hot-reload for provider API keys.\n *\n * Per Q5 strategy C: explicit /keys command + file watcher both supported.\n * Per Q6 strategy A: keys stored in plain text in ~/.phi/agent/models.json\n * with chmod 0600 on Unix and clear warnings to users.\n *\n * Storage format: models.json providers.<id>.apiKey contains the key value\n * directly (not an env var reference). Resolution priority:\n * 1. Value in models.json (if non-empty)\n * 2. process.env[envVar] (legacy fallback)\n *\n * Events emitted via the EventEmitter:\n * - \"key_changed\" { provider, key } : when setKey() or external edit detected\n * - \"key_removed\" { provider } : when removeKey() called\n * - \"store_reloaded\" : when reloadFromDisk() succeeds\n */\n\nimport { EventEmitter } from \"node:events\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { getModelsPath } from \"../config.js\";\n\nexport interface ProviderConfigPersisted {\n\tbaseUrl?: string;\n\tapi?: string;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n\tauthHeader?: boolean;\n\tmodels?: unknown[];\n\tmodelOverrides?: Record<string, unknown>;\n}\n\nexport interface ModelsConfigPersisted {\n\t$comment?: string;\n\tproviders: Record<string, ProviderConfigPersisted>;\n}\n\nexport interface ApiKeyStoreOptions {\n\tconfigPath?: string;\n}\n\nexport class ApiKeyStore extends EventEmitter {\n\treadonly configPath: string;\n\tprivate config: ModelsConfigPersisted = { providers: {} };\n\tprivate loaded = false;\n\n\tconstructor(options: ApiKeyStoreOptions = {}) {\n\t\tsuper();\n\t\tthis.configPath = options.configPath ?? getModelsPath();\n\t}\n\n\t/**\n\t * Load config from disk. Creates an empty file if missing.\n\t */\n\tload(): ModelsConfigPersisted {\n\t\ttry {\n\t\t\tif (!existsSync(this.configPath)) {\n\t\t\t\treturn this.config;\n\t\t\t}\n\t\t\tconst raw = readFileSync(this.configPath, \"utf-8\");\n\t\t\tconst parsed = JSON.parse(raw) as ModelsConfigPersisted;\n\t\t\tif (!parsed.providers || typeof parsed.providers !== \"object\") {\n\t\t\t\tparsed.providers = {};\n\t\t\t}\n\t\t\tthis.config = parsed;\n\t\t\tthis.loaded = true;\n\t\t\treturn this.config;\n\t\t} catch (err) {\n\t\t\tthrow new Error(`Failed to load ${this.configPath}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Reload from disk and emit \"store_reloaded\".\n\t */\n\treloadFromDisk(): void {\n\t\tthis.load();\n\t\tthis.emit(\"store_reloaded\");\n\t}\n\n\t/**\n\t * Get the API key for a provider, with env-var fallback.\n\t * Returns undefined if neither source has a value.\n\t */\n\tgetKey(providerId: string, envVar?: string): string | undefined {\n\t\tif (!this.loaded) this.load();\n\t\tconst stored = this.config.providers[providerId]?.apiKey?.trim();\n\t\tif (stored && stored.length > 0 && !stored.startsWith(\"$\")) return stored;\n\t\tif (envVar) return process.env[envVar]?.trim() || undefined;\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Get the full provider config block for a provider.\n\t */\n\tgetProvider(providerId: string): ProviderConfigPersisted | undefined {\n\t\tif (!this.loaded) this.load();\n\t\treturn this.config.providers[providerId];\n\t}\n\n\t/**\n\t * List all configured provider IDs.\n\t */\n\tlistProviders(): string[] {\n\t\tif (!this.loaded) this.load();\n\t\treturn Object.keys(this.config.providers);\n\t}\n\n\t/**\n\t * Set the API key for a provider. Creates the provider entry if missing.\n\t * Performs atomic write (tmp + rename) and chmod 0600 on Unix.\n\t * Emits \"key_changed\" event.\n\t */\n\tsetKey(providerId: string, key: string, providerConfig?: Partial<ProviderConfigPersisted>): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId] ?? {};\n\t\tthis.config.providers[providerId] = {\n\t\t\t...existing,\n\t\t\t...providerConfig,\n\t\t\tapiKey: key,\n\t\t};\n\t\tthis.persist();\n\t\tthis.emit(\"key_changed\", { provider: providerId, key });\n\t}\n\n\t/**\n\t * Remove the API key for a provider (but keep the rest of the provider config).\n\t * Emits \"key_removed\" event.\n\t */\n\tremoveKey(providerId: string): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId];\n\t\tif (!existing) return;\n\t\tconst { apiKey: _removed, ...rest } = existing;\n\t\tthis.config.providers[providerId] = rest;\n\t\tthis.persist();\n\t\tthis.emit(\"key_removed\", { provider: providerId });\n\t}\n\n\t/**\n\t * Mask an API key for display (preserves length info without exposing the secret).\n\t */\n\tstatic maskKey(key: string | undefined): string {\n\t\tif (!key) return \"(not set)\";\n\t\tconst trimmed = key.trim();\n\t\tif (trimmed.length === 0) return \"(empty)\";\n\t\tif (trimmed.length <= 8) return \"********\";\n\t\treturn `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;\n\t}\n\n\t/**\n\t * Atomic write of the config to disk:\n\t * 1. Ensure parent dir exists\n\t * 2. Write to <path>.tmp\n\t * 3. Rename to <path> (atomic on POSIX)\n\t * 4. Apply chmod 0600 on Unix (silently skip on Windows)\n\t */\n\tprivate persist(): void {\n\t\tconst isPosix = process.platform !== \"win32\";\n\t\tconst parent = dirname(this.configPath);\n\t\tmkdirSync(parent, isPosix ? { recursive: true, mode: 0o700 } : { recursive: true });\n\t\tconst tmpPath = `${this.configPath}.tmp`;\n\t\tconst payload = `${JSON.stringify(this.config, null, 2)}\\n`;\n\t\t// Write the tmp file with restrictive perms from the start so the plaintext\n\t\t// key is never world-readable on disk (mode is still subject to umask, hence\n\t\t// the belt-and-suspenders chmod below after the rename).\n\t\twriteFileSync(tmpPath, payload, isPosix ? { encoding: \"utf-8\", mode: 0o600 } : \"utf-8\");\n\t\trenameSync(tmpPath, this.configPath);\n\t\tif (isPosix) {\n\t\t\ttry {\n\t\t\t\tchmodSync(this.configPath, 0o600);\n\t\t\t} catch {\n\t\t\t\t// chmod may fail on some filesystems (eg WSL); non-critical\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Module-level singleton for convenience. */\nlet _singleton: ApiKeyStore | null = null;\nexport function getApiKeyStore(options?: ApiKeyStoreOptions): ApiKeyStore {\n\tif (!_singleton || (options?.configPath && _singleton.configPath !== options.configPath)) {\n\t\t_singleton = new ApiKeyStore(options);\n\t}\n\treturn _singleton;\n}\n\nexport function _resetApiKeyStore(): void {\n\t_singleton = null;\n}\n"]}
@@ -17,15 +17,15 @@
17
17
  */
18
18
  import { EventEmitter } from "node:events";
19
19
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
- import { homedir } from "node:os";
21
- import { dirname, join } from "node:path";
20
+ import { dirname } from "node:path";
21
+ import { getModelsPath } from "../config.js";
22
22
  export class ApiKeyStore extends EventEmitter {
23
23
  configPath;
24
24
  config = { providers: {} };
25
25
  loaded = false;
26
26
  constructor(options = {}) {
27
27
  super();
28
- this.configPath = options.configPath ?? join(homedir(), ".phi", "agent", "models.json");
28
+ this.configPath = options.configPath ?? getModelsPath();
29
29
  }
30
30
  /**
31
31
  * Load config from disk. Creates an empty file if missing.
@@ -138,13 +138,17 @@ export class ApiKeyStore extends EventEmitter {
138
138
  * 4. Apply chmod 0600 on Unix (silently skip on Windows)
139
139
  */
140
140
  persist() {
141
+ const isPosix = process.platform !== "win32";
141
142
  const parent = dirname(this.configPath);
142
- mkdirSync(parent, { recursive: true });
143
+ mkdirSync(parent, isPosix ? { recursive: true, mode: 0o700 } : { recursive: true });
143
144
  const tmpPath = `${this.configPath}.tmp`;
144
145
  const payload = `${JSON.stringify(this.config, null, 2)}\n`;
145
- writeFileSync(tmpPath, payload, "utf-8");
146
+ // Write the tmp file with restrictive perms from the start so the plaintext
147
+ // key is never world-readable on disk (mode is still subject to umask, hence
148
+ // the belt-and-suspenders chmod below after the rename).
149
+ writeFileSync(tmpPath, payload, isPosix ? { encoding: "utf-8", mode: 0o600 } : "utf-8");
146
150
  renameSync(tmpPath, this.configPath);
147
- if (process.platform !== "win32") {
151
+ if (isPosix) {
148
152
  try {
149
153
  chmodSync(this.configPath, 0o600);
150
154
  }
@@ -1 +1 @@
1
- {"version":3,"file":"api-key-store.js","sourceRoot":"","sources":["../../src/core/api-key-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * API Key Store - Centralized storage and hot-reload for provider API keys.\n *\n * Per Q5 strategy C: explicit /keys command + file watcher both supported.\n * Per Q6 strategy A: keys stored in plain text in ~/.phi/agent/models.json\n * with chmod 0600 on Unix and clear warnings to users.\n *\n * Storage format: models.json providers.<id>.apiKey contains the key value\n * directly (not an env var reference). Resolution priority:\n * 1. Value in models.json (if non-empty)\n * 2. process.env[envVar] (legacy fallback)\n *\n * Events emitted via the EventEmitter:\n * - \"key_changed\" { provider, key } : when setKey() or external edit detected\n * - \"key_removed\" { provider } : when removeKey() called\n * - \"store_reloaded\" : when reloadFromDisk() succeeds\n */\n\nimport { EventEmitter } from \"node:events\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\nexport interface ProviderConfigPersisted {\n\tbaseUrl?: string;\n\tapi?: string;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n\tauthHeader?: boolean;\n\tmodels?: unknown[];\n\tmodelOverrides?: Record<string, unknown>;\n}\n\nexport interface ModelsConfigPersisted {\n\t$comment?: string;\n\tproviders: Record<string, ProviderConfigPersisted>;\n}\n\nexport interface ApiKeyStoreOptions {\n\tconfigPath?: string;\n}\n\nexport class ApiKeyStore extends EventEmitter {\n\treadonly configPath: string;\n\tprivate config: ModelsConfigPersisted = { providers: {} };\n\tprivate loaded = false;\n\n\tconstructor(options: ApiKeyStoreOptions = {}) {\n\t\tsuper();\n\t\tthis.configPath = options.configPath ?? join(homedir(), \".phi\", \"agent\", \"models.json\");\n\t}\n\n\t/**\n\t * Load config from disk. Creates an empty file if missing.\n\t */\n\tload(): ModelsConfigPersisted {\n\t\ttry {\n\t\t\tif (!existsSync(this.configPath)) {\n\t\t\t\treturn this.config;\n\t\t\t}\n\t\t\tconst raw = readFileSync(this.configPath, \"utf-8\");\n\t\t\tconst parsed = JSON.parse(raw) as ModelsConfigPersisted;\n\t\t\tif (!parsed.providers || typeof parsed.providers !== \"object\") {\n\t\t\t\tparsed.providers = {};\n\t\t\t}\n\t\t\tthis.config = parsed;\n\t\t\tthis.loaded = true;\n\t\t\treturn this.config;\n\t\t} catch (err) {\n\t\t\tthrow new Error(`Failed to load ${this.configPath}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Reload from disk and emit \"store_reloaded\".\n\t */\n\treloadFromDisk(): void {\n\t\tthis.load();\n\t\tthis.emit(\"store_reloaded\");\n\t}\n\n\t/**\n\t * Get the API key for a provider, with env-var fallback.\n\t * Returns undefined if neither source has a value.\n\t */\n\tgetKey(providerId: string, envVar?: string): string | undefined {\n\t\tif (!this.loaded) this.load();\n\t\tconst stored = this.config.providers[providerId]?.apiKey?.trim();\n\t\tif (stored && stored.length > 0 && !stored.startsWith(\"$\")) return stored;\n\t\tif (envVar) return process.env[envVar]?.trim() || undefined;\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Get the full provider config block for a provider.\n\t */\n\tgetProvider(providerId: string): ProviderConfigPersisted | undefined {\n\t\tif (!this.loaded) this.load();\n\t\treturn this.config.providers[providerId];\n\t}\n\n\t/**\n\t * List all configured provider IDs.\n\t */\n\tlistProviders(): string[] {\n\t\tif (!this.loaded) this.load();\n\t\treturn Object.keys(this.config.providers);\n\t}\n\n\t/**\n\t * Set the API key for a provider. Creates the provider entry if missing.\n\t * Performs atomic write (tmp + rename) and chmod 0600 on Unix.\n\t * Emits \"key_changed\" event.\n\t */\n\tsetKey(providerId: string, key: string, providerConfig?: Partial<ProviderConfigPersisted>): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId] ?? {};\n\t\tthis.config.providers[providerId] = {\n\t\t\t...existing,\n\t\t\t...providerConfig,\n\t\t\tapiKey: key,\n\t\t};\n\t\tthis.persist();\n\t\tthis.emit(\"key_changed\", { provider: providerId, key });\n\t}\n\n\t/**\n\t * Remove the API key for a provider (but keep the rest of the provider config).\n\t * Emits \"key_removed\" event.\n\t */\n\tremoveKey(providerId: string): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId];\n\t\tif (!existing) return;\n\t\tconst { apiKey: _removed, ...rest } = existing;\n\t\tthis.config.providers[providerId] = rest;\n\t\tthis.persist();\n\t\tthis.emit(\"key_removed\", { provider: providerId });\n\t}\n\n\t/**\n\t * Mask an API key for display (preserves length info without exposing the secret).\n\t */\n\tstatic maskKey(key: string | undefined): string {\n\t\tif (!key) return \"(not set)\";\n\t\tconst trimmed = key.trim();\n\t\tif (trimmed.length === 0) return \"(empty)\";\n\t\tif (trimmed.length <= 8) return \"********\";\n\t\treturn `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;\n\t}\n\n\t/**\n\t * Atomic write of the config to disk:\n\t * 1. Ensure parent dir exists\n\t * 2. Write to <path>.tmp\n\t * 3. Rename to <path> (atomic on POSIX)\n\t * 4. Apply chmod 0600 on Unix (silently skip on Windows)\n\t */\n\tprivate persist(): void {\n\t\tconst parent = dirname(this.configPath);\n\t\tmkdirSync(parent, { recursive: true });\n\t\tconst tmpPath = `${this.configPath}.tmp`;\n\t\tconst payload = `${JSON.stringify(this.config, null, 2)}\\n`;\n\t\twriteFileSync(tmpPath, payload, \"utf-8\");\n\t\trenameSync(tmpPath, this.configPath);\n\t\tif (process.platform !== \"win32\") {\n\t\t\ttry {\n\t\t\t\tchmodSync(this.configPath, 0o600);\n\t\t\t} catch {\n\t\t\t\t// chmod may fail on some filesystems (eg WSL); non-critical\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Module-level singleton for convenience. */\nlet _singleton: ApiKeyStore | null = null;\nexport function getApiKeyStore(options?: ApiKeyStoreOptions): ApiKeyStore {\n\tif (!_singleton || (options?.configPath && _singleton.configPath !== options.configPath)) {\n\t\t_singleton = new ApiKeyStore(options);\n\t}\n\treturn _singleton;\n}\n\nexport function _resetApiKeyStore(): void {\n\t_singleton = null;\n}\n"]}
1
+ {"version":3,"file":"api-key-store.js","sourceRoot":"","sources":["../../src/core/api-key-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAqB7C,MAAM,OAAO,WAAY,SAAQ,YAAY;IACnC,UAAU,CAAS;IACpB,MAAM,GAA0B,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC;IAClD,MAAM,GAAG,KAAK,CAAC;IAEvB,YAAY,OAAO,GAAuB,EAAE,EAAE;QAC7C,KAAK,EAAE,CAAC;QACR,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,aAAa,EAAE,CAAC;IAAA,CACxD;IAED;;OAEG;IACH,IAAI,GAA0B;QAC7B,IAAI,CAAC;YACJ,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;gBAClC,OAAO,IAAI,CAAC,MAAM,CAAC;YACpB,CAAC;YACD,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YACnD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAA0B,CAAC;YACxD,IAAI,CAAC,MAAM,CAAC,SAAS,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;gBAC/D,MAAM,CAAC,SAAS,GAAG,EAAE,CAAC;YACvB,CAAC;YACD,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;YACrB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;YACnB,OAAO,IAAI,CAAC,MAAM,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,MAAM,IAAI,KAAK,CAAC,kBAAkB,IAAI,CAAC,UAAU,KAAK,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC3G,CAAC;IAAA,CACD;IAED;;OAEG;IACH,cAAc,GAAS;QACtB,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;IAAA,CAC5B;IAED;;;OAGG;IACH,MAAM,CAAC,UAAkB,EAAE,MAAe,EAAsB;QAC/D,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;QACjE,IAAI,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,MAAM,CAAC;QAC1E,IAAI,MAAM;YAAE,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;QAC5D,OAAO,SAAS,CAAC;IAAA,CACjB;IAED;;OAEG;IACH,WAAW,CAAC,UAAkB,EAAuC;QACpE,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;IAAA,CACzC;IAED;;OAEG;IACH,aAAa,GAAa;QACzB,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9B,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAAA,CAC1C;IAED;;;;OAIG;IACH,MAAM,CAAC,UAAkB,EAAE,GAAW,EAAE,cAAiD,EAAQ;QAChG,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QACzD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG;YACnC,GAAG,QAAQ;YACX,GAAG,cAAc;YACjB,MAAM,EAAE,GAAG;SACX,CAAC;QACF,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC,CAAC;IAAA,CACxD;IAED;;;OAGG;IACH,SAAS,CAAC,UAAkB,EAAQ;QACnC,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;QACnD,IAAI,CAAC,QAAQ;YAAE,OAAO;QACtB,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,QAAQ,CAAC;QAC/C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;QACzC,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,CAAC;IAAA,CACnD;IAED;;OAEG;IACH,MAAM,CAAC,OAAO,CAAC,GAAuB,EAAU;QAC/C,IAAI,CAAC,GAAG;YAAE,OAAO,WAAW,CAAC;QAC7B,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QAC3C,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC;YAAE,OAAO,UAAU,CAAC;QAC3C,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAAA,CACvD;IAED;;;;;;OAMG;IACK,OAAO,GAAS;QACvB,MAAM,OAAO,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC;QAC7C,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACxC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACpF,MAAM,OAAO,GAAG,GAAG,IAAI,CAAC,UAAU,MAAM,CAAC;QACzC,MAAM,OAAO,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;QAC5D,4EAA4E;QAC5E,6EAA6E;QAC7E,yDAAyD;QACzD,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;QACxF,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QACrC,IAAI,OAAO,EAAE,CAAC;YACb,IAAI,CAAC;gBACJ,SAAS,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;YACnC,CAAC;YAAC,MAAM,CAAC;gBACR,4DAA4D;YAC7D,CAAC;QACF,CAAC;IAAA,CACD;CACD;AAED,8CAA8C;AAC9C,IAAI,UAAU,GAAuB,IAAI,CAAC;AAC1C,MAAM,UAAU,cAAc,CAAC,OAA4B,EAAe;IACzE,IAAI,CAAC,UAAU,IAAI,CAAC,OAAO,EAAE,UAAU,IAAI,UAAU,CAAC,UAAU,KAAK,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;QAC1F,UAAU,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,UAAU,CAAC;AAAA,CAClB;AAED,MAAM,UAAU,iBAAiB,GAAS;IACzC,UAAU,GAAG,IAAI,CAAC;AAAA,CAClB","sourcesContent":["/**\n * API Key Store - Centralized storage and hot-reload for provider API keys.\n *\n * Per Q5 strategy C: explicit /keys command + file watcher both supported.\n * Per Q6 strategy A: keys stored in plain text in ~/.phi/agent/models.json\n * with chmod 0600 on Unix and clear warnings to users.\n *\n * Storage format: models.json providers.<id>.apiKey contains the key value\n * directly (not an env var reference). Resolution priority:\n * 1. Value in models.json (if non-empty)\n * 2. process.env[envVar] (legacy fallback)\n *\n * Events emitted via the EventEmitter:\n * - \"key_changed\" { provider, key } : when setKey() or external edit detected\n * - \"key_removed\" { provider } : when removeKey() called\n * - \"store_reloaded\" : when reloadFromDisk() succeeds\n */\n\nimport { EventEmitter } from \"node:events\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { getModelsPath } from \"../config.js\";\n\nexport interface ProviderConfigPersisted {\n\tbaseUrl?: string;\n\tapi?: string;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n\tauthHeader?: boolean;\n\tmodels?: unknown[];\n\tmodelOverrides?: Record<string, unknown>;\n}\n\nexport interface ModelsConfigPersisted {\n\t$comment?: string;\n\tproviders: Record<string, ProviderConfigPersisted>;\n}\n\nexport interface ApiKeyStoreOptions {\n\tconfigPath?: string;\n}\n\nexport class ApiKeyStore extends EventEmitter {\n\treadonly configPath: string;\n\tprivate config: ModelsConfigPersisted = { providers: {} };\n\tprivate loaded = false;\n\n\tconstructor(options: ApiKeyStoreOptions = {}) {\n\t\tsuper();\n\t\tthis.configPath = options.configPath ?? getModelsPath();\n\t}\n\n\t/**\n\t * Load config from disk. Creates an empty file if missing.\n\t */\n\tload(): ModelsConfigPersisted {\n\t\ttry {\n\t\t\tif (!existsSync(this.configPath)) {\n\t\t\t\treturn this.config;\n\t\t\t}\n\t\t\tconst raw = readFileSync(this.configPath, \"utf-8\");\n\t\t\tconst parsed = JSON.parse(raw) as ModelsConfigPersisted;\n\t\t\tif (!parsed.providers || typeof parsed.providers !== \"object\") {\n\t\t\t\tparsed.providers = {};\n\t\t\t}\n\t\t\tthis.config = parsed;\n\t\t\tthis.loaded = true;\n\t\t\treturn this.config;\n\t\t} catch (err) {\n\t\t\tthrow new Error(`Failed to load ${this.configPath}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Reload from disk and emit \"store_reloaded\".\n\t */\n\treloadFromDisk(): void {\n\t\tthis.load();\n\t\tthis.emit(\"store_reloaded\");\n\t}\n\n\t/**\n\t * Get the API key for a provider, with env-var fallback.\n\t * Returns undefined if neither source has a value.\n\t */\n\tgetKey(providerId: string, envVar?: string): string | undefined {\n\t\tif (!this.loaded) this.load();\n\t\tconst stored = this.config.providers[providerId]?.apiKey?.trim();\n\t\tif (stored && stored.length > 0 && !stored.startsWith(\"$\")) return stored;\n\t\tif (envVar) return process.env[envVar]?.trim() || undefined;\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Get the full provider config block for a provider.\n\t */\n\tgetProvider(providerId: string): ProviderConfigPersisted | undefined {\n\t\tif (!this.loaded) this.load();\n\t\treturn this.config.providers[providerId];\n\t}\n\n\t/**\n\t * List all configured provider IDs.\n\t */\n\tlistProviders(): string[] {\n\t\tif (!this.loaded) this.load();\n\t\treturn Object.keys(this.config.providers);\n\t}\n\n\t/**\n\t * Set the API key for a provider. Creates the provider entry if missing.\n\t * Performs atomic write (tmp + rename) and chmod 0600 on Unix.\n\t * Emits \"key_changed\" event.\n\t */\n\tsetKey(providerId: string, key: string, providerConfig?: Partial<ProviderConfigPersisted>): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId] ?? {};\n\t\tthis.config.providers[providerId] = {\n\t\t\t...existing,\n\t\t\t...providerConfig,\n\t\t\tapiKey: key,\n\t\t};\n\t\tthis.persist();\n\t\tthis.emit(\"key_changed\", { provider: providerId, key });\n\t}\n\n\t/**\n\t * Remove the API key for a provider (but keep the rest of the provider config).\n\t * Emits \"key_removed\" event.\n\t */\n\tremoveKey(providerId: string): void {\n\t\tif (!this.loaded) this.load();\n\t\tconst existing = this.config.providers[providerId];\n\t\tif (!existing) return;\n\t\tconst { apiKey: _removed, ...rest } = existing;\n\t\tthis.config.providers[providerId] = rest;\n\t\tthis.persist();\n\t\tthis.emit(\"key_removed\", { provider: providerId });\n\t}\n\n\t/**\n\t * Mask an API key for display (preserves length info without exposing the secret).\n\t */\n\tstatic maskKey(key: string | undefined): string {\n\t\tif (!key) return \"(not set)\";\n\t\tconst trimmed = key.trim();\n\t\tif (trimmed.length === 0) return \"(empty)\";\n\t\tif (trimmed.length <= 8) return \"********\";\n\t\treturn `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;\n\t}\n\n\t/**\n\t * Atomic write of the config to disk:\n\t * 1. Ensure parent dir exists\n\t * 2. Write to <path>.tmp\n\t * 3. Rename to <path> (atomic on POSIX)\n\t * 4. Apply chmod 0600 on Unix (silently skip on Windows)\n\t */\n\tprivate persist(): void {\n\t\tconst isPosix = process.platform !== \"win32\";\n\t\tconst parent = dirname(this.configPath);\n\t\tmkdirSync(parent, isPosix ? { recursive: true, mode: 0o700 } : { recursive: true });\n\t\tconst tmpPath = `${this.configPath}.tmp`;\n\t\tconst payload = `${JSON.stringify(this.config, null, 2)}\\n`;\n\t\t// Write the tmp file with restrictive perms from the start so the plaintext\n\t\t// key is never world-readable on disk (mode is still subject to umask, hence\n\t\t// the belt-and-suspenders chmod below after the rename).\n\t\twriteFileSync(tmpPath, payload, isPosix ? { encoding: \"utf-8\", mode: 0o600 } : \"utf-8\");\n\t\trenameSync(tmpPath, this.configPath);\n\t\tif (isPosix) {\n\t\t\ttry {\n\t\t\t\tchmodSync(this.configPath, 0o600);\n\t\t\t} catch {\n\t\t\t\t// chmod may fail on some filesystems (eg WSL); non-critical\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Module-level singleton for convenience. */\nlet _singleton: ApiKeyStore | null = null;\nexport function getApiKeyStore(options?: ApiKeyStoreOptions): ApiKeyStore {\n\tif (!_singleton || (options?.configPath && _singleton.configPath !== options.configPath)) {\n\t\t_singleton = new ApiKeyStore(options);\n\t}\n\treturn _singleton;\n}\n\nexport function _resetApiKeyStore(): void {\n\t_singleton = null;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/export-html/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAKjD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAChC,oFAAoF;IACpF,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC;IACpF,4GAA4G;IAC5G,YAAY,CACX,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EAChF,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,GACd;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACzD;AASD,MAAM,WAAW,aAAa;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAgMD;;;GAGG;AACH,wBAAsB,mBAAmB,CACxC,EAAE,EAAE,cAAc,EAClB,KAAK,CAAC,EAAE,UAAU,EAClB,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,GAC9B,OAAO,CAAC,MAAM,CAAC,CA0CjB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BzG","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { basename, join } from \"path\";\nimport type { AgentState } from \"phi-code-agent\";\nimport { APP_NAME, getExportTemplateDir } from \"../../config.js\";\nimport { getResolvedThemeColors, getThemeExportColors } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition } from \"../extensions/types.js\";\nimport type { SessionEntry } from \"../session-manager.js\";\nimport { SessionManager } from \"../session-manager.js\";\n\n/**\n * Interface for rendering custom tools to HTML.\n * Used by agent-session to pre-render extension tool output.\n */\nexport interface ToolHtmlRenderer {\n\t/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */\n\trenderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;\n\t/** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */\n\trenderResult(\n\t\ttoolCallId: string,\n\t\ttoolName: string,\n\t\tresult: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,\n\t\tdetails: unknown,\n\t\tisError: boolean,\n\t): { collapsed?: string; expanded?: string } | undefined;\n}\n\n/** Pre-rendered HTML for a custom tool call and result */\ninterface RenderedToolHtml {\n\tcallHtml?: string;\n\tresultHtmlCollapsed?: string;\n\tresultHtmlExpanded?: string;\n}\n\nexport interface ExportOptions {\n\toutputPath?: string;\n\tthemeName?: string;\n\t/** Optional tool renderer for custom tools */\n\ttoolRenderer?: ToolHtmlRenderer;\n}\n\n/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */\nfunction parseColor(color: string): { r: number; g: number; b: number } | undefined {\n\tconst hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);\n\tif (hexMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(hexMatch[1], 16),\n\t\t\tg: Number.parseInt(hexMatch[2], 16),\n\t\t\tb: Number.parseInt(hexMatch[3], 16),\n\t\t};\n\t}\n\tconst rgbMatch = color.match(/^rgb\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)$/);\n\tif (rgbMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(rgbMatch[1], 10),\n\t\t\tg: Number.parseInt(rgbMatch[2], 10),\n\t\t\tb: Number.parseInt(rgbMatch[3], 10),\n\t\t};\n\t}\n\treturn undefined;\n}\n\n/** Calculate relative luminance of a color (0-1, higher = lighter). */\nfunction getLuminance(r: number, g: number, b: number): number {\n\tconst toLinear = (c: number) => {\n\t\tconst s = c / 255;\n\t\treturn s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n\t};\n\treturn 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\n/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */\nfunction adjustBrightness(color: string, factor: number): string {\n\tconst parsed = parseColor(color);\n\tif (!parsed) return color;\n\tconst adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));\n\treturn `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;\n}\n\n/** Derive export background colors from a base color (e.g., userMessageBg). */\nfunction deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {\n\tconst parsed = parseColor(baseColor);\n\tif (!parsed) {\n\t\treturn {\n\t\t\tpageBg: \"rgb(24, 24, 30)\",\n\t\t\tcardBg: \"rgb(30, 30, 36)\",\n\t\t\tinfoBg: \"rgb(60, 55, 40)\",\n\t\t};\n\t}\n\n\tconst luminance = getLuminance(parsed.r, parsed.g, parsed.b);\n\tconst isLight = luminance > 0.5;\n\n\tif (isLight) {\n\t\treturn {\n\t\t\tpageBg: adjustBrightness(baseColor, 0.96),\n\t\t\tcardBg: baseColor,\n\t\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,\n\t\t};\n\t}\n\treturn {\n\t\tpageBg: adjustBrightness(baseColor, 0.7),\n\t\tcardBg: adjustBrightness(baseColor, 0.85),\n\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,\n\t};\n}\n\n/**\n * Generate CSS custom property declarations from theme colors.\n */\nfunction generateThemeVars(themeName?: string): string {\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst lines: string[] = [];\n\tfor (const [key, value] of Object.entries(colors)) {\n\t\tlines.push(`--${key}: ${value};`);\n\t}\n\n\t// Use explicit theme export colors if available, otherwise derive from userMessageBg\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst userMessageBg = colors.userMessageBg || \"#343541\";\n\tconst derivedColors = deriveExportColors(userMessageBg);\n\n\tlines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);\n\tlines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);\n\tlines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);\n\n\treturn lines.join(\"\\n \");\n}\n\ninterface SessionData {\n\theader: ReturnType<SessionManager[\"getHeader\"]>;\n\tentries: ReturnType<SessionManager[\"getEntries\"]>;\n\tleafId: string | null;\n\tsystemPrompt?: string;\n\ttools?: Array<Pick<ToolDefinition, \"name\" | \"description\" | \"parameters\">>;\n\t/** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */\n\trenderedTools?: Record<string, RenderedToolHtml>;\n}\n\n/**\n * Core HTML generation logic shared by both export functions.\n */\nfunction generateHtml(sessionData: SessionData, themeName?: string): string {\n\tconst templateDir = getExportTemplateDir();\n\tconst template = readFileSync(join(templateDir, \"template.html\"), \"utf-8\");\n\tconst templateCss = readFileSync(join(templateDir, \"template.css\"), \"utf-8\");\n\tconst templateJs = readFileSync(join(templateDir, \"template.js\"), \"utf-8\");\n\tconst markedJs = readFileSync(join(templateDir, \"vendor\", \"marked.min.js\"), \"utf-8\");\n\tconst hljsJs = readFileSync(join(templateDir, \"vendor\", \"highlight.min.js\"), \"utf-8\");\n\n\tconst themeVars = generateThemeVars(themeName);\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst derivedExportColors = deriveExportColors(colors.userMessageBg || \"#343541\");\n\tconst bodyBg = themeExport.pageBg ?? derivedExportColors.pageBg;\n\tconst containerBg = themeExport.cardBg ?? derivedExportColors.cardBg;\n\tconst infoBg = themeExport.infoBg ?? derivedExportColors.infoBg;\n\n\t// Base64 encode session data to avoid escaping issues\n\tconst sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString(\"base64\");\n\n\t// Build the CSS with theme variables injected\n\tconst css = templateCss\n\t\t.replace(\"{{THEME_VARS}}\", themeVars)\n\t\t.replace(\"{{BODY_BG}}\", bodyBg)\n\t\t.replace(\"{{CONTAINER_BG}}\", containerBg)\n\t\t.replace(\"{{INFO_BG}}\", infoBg);\n\n\treturn template\n\t\t.replace(\"{{CSS}}\", css)\n\t\t.replace(\"{{JS}}\", templateJs)\n\t\t.replace(\"{{SESSION_DATA}}\", sessionDataBase64)\n\t\t.replace(\"{{MARKED_JS}}\", markedJs)\n\t\t.replace(\"{{HIGHLIGHT_JS}}\", hljsJs);\n}\n\n/** Tools rendered directly by the HTML template (not pre-rendered via TUI→ANSI→HTML pipeline) */\nconst TEMPLATE_RENDERED_TOOLS = new Set([\"bash\", \"read\", \"write\", \"edit\", \"ls\"]);\n\n/**\n * Pre-render custom tools to HTML using their TUI renderers.\n */\nfunction preRenderCustomTools(\n\tentries: SessionEntry[],\n\ttoolRenderer: ToolHtmlRenderer,\n): Record<string, RenderedToolHtml> {\n\tconst renderedTools: Record<string, RenderedToolHtml> = {};\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst msg = entry.message;\n\n\t\t// Find tool calls in assistant messages\n\t\tif (msg.role === \"assistant\" && Array.isArray(msg.content)) {\n\t\t\tfor (const block of msg.content) {\n\t\t\t\tif (block.type === \"toolCall\" && !TEMPLATE_RENDERED_TOOLS.has(block.name)) {\n\t\t\t\t\tconst callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments);\n\t\t\t\t\tif (callHtml) {\n\t\t\t\t\t\trenderedTools[block.id] = { callHtml };\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Find tool results\n\t\tif (msg.role === \"toolResult\" && msg.toolCallId) {\n\t\t\tconst toolName = msg.toolName || \"\";\n\t\t\t// Only render if we have a pre-rendered call OR it's not template-rendered\n\t\t\tconst existing = renderedTools[msg.toolCallId];\n\t\t\tif (existing || !TEMPLATE_RENDERED_TOOLS.has(toolName)) {\n\t\t\t\tconst rendered = toolRenderer.renderResult(\n\t\t\t\t\tmsg.toolCallId,\n\t\t\t\t\ttoolName,\n\t\t\t\t\tmsg.content,\n\t\t\t\t\tmsg.details,\n\t\t\t\t\tmsg.isError || false,\n\t\t\t\t);\n\t\t\t\tif (rendered) {\n\t\t\t\t\trenderedTools[msg.toolCallId] = {\n\t\t\t\t\t\t...existing,\n\t\t\t\t\t\tresultHtmlCollapsed: rendered.collapsed,\n\t\t\t\t\t\tresultHtmlExpanded: rendered.expanded,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn renderedTools;\n}\n\n/**\n * Export session to HTML using SessionManager and AgentState.\n * Used by TUI's /export command.\n */\nexport async function exportSessionToHtml(\n\tsm: SessionManager,\n\tstate?: AgentState,\n\toptions?: ExportOptions | string,\n): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tconst sessionFile = sm.getSessionFile();\n\tif (!sessionFile) {\n\t\tthrow new Error(\"Cannot export in-memory session to HTML\");\n\t}\n\tif (!existsSync(sessionFile)) {\n\t\tthrow new Error(\"Nothing to export yet - start a conversation first\");\n\t}\n\n\tconst entries = sm.getEntries();\n\n\t// Pre-render custom tools if a tool renderer is provided\n\tlet renderedTools: Record<string, RenderedToolHtml> | undefined;\n\tif (opts.toolRenderer) {\n\t\trenderedTools = preRenderCustomTools(entries, opts.toolRenderer);\n\t\t// Only include if we actually rendered something\n\t\tif (Object.keys(renderedTools).length === 0) {\n\t\t\trenderedTools = undefined;\n\t\t}\n\t}\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries,\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: state?.systemPrompt,\n\t\ttools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })),\n\t\trenderedTools,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${sessionBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n\n/**\n * Export session file to HTML (standalone, without AgentState).\n * Used by CLI for exporting arbitrary session files.\n */\nexport async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst sm = SessionManager.open(inputPath);\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries: sm.getEntries(),\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: undefined,\n\t\ttools: undefined,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${inputBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/export-html/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAKjD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAChC,oFAAoF;IACpF,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC;IACpF,4GAA4G;IAC5G,YAAY,CACX,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EAChF,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,GACd;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACzD;AASD,MAAM,WAAW,aAAa;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAoMD;;;GAGG;AACH,wBAAsB,mBAAmB,CACxC,EAAE,EAAE,cAAc,EAClB,KAAK,CAAC,EAAE,UAAU,EAClB,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,GAC9B,OAAO,CAAC,MAAM,CAAC,CA0CjB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BzG","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { basename, join } from \"path\";\nimport type { AgentState } from \"phi-code-agent\";\nimport { APP_NAME, getExportTemplateDir } from \"../../config.js\";\nimport { getResolvedThemeColors, getThemeExportColors } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition } from \"../extensions/types.js\";\nimport type { SessionEntry } from \"../session-manager.js\";\nimport { SessionManager } from \"../session-manager.js\";\n\n/**\n * Interface for rendering custom tools to HTML.\n * Used by agent-session to pre-render extension tool output.\n */\nexport interface ToolHtmlRenderer {\n\t/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */\n\trenderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;\n\t/** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */\n\trenderResult(\n\t\ttoolCallId: string,\n\t\ttoolName: string,\n\t\tresult: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,\n\t\tdetails: unknown,\n\t\tisError: boolean,\n\t): { collapsed?: string; expanded?: string } | undefined;\n}\n\n/** Pre-rendered HTML for a custom tool call and result */\ninterface RenderedToolHtml {\n\tcallHtml?: string;\n\tresultHtmlCollapsed?: string;\n\tresultHtmlExpanded?: string;\n}\n\nexport interface ExportOptions {\n\toutputPath?: string;\n\tthemeName?: string;\n\t/** Optional tool renderer for custom tools */\n\ttoolRenderer?: ToolHtmlRenderer;\n}\n\n/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */\nfunction parseColor(color: string): { r: number; g: number; b: number } | undefined {\n\tconst hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);\n\tif (hexMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(hexMatch[1], 16),\n\t\t\tg: Number.parseInt(hexMatch[2], 16),\n\t\t\tb: Number.parseInt(hexMatch[3], 16),\n\t\t};\n\t}\n\tconst rgbMatch = color.match(/^rgb\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)$/);\n\tif (rgbMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(rgbMatch[1], 10),\n\t\t\tg: Number.parseInt(rgbMatch[2], 10),\n\t\t\tb: Number.parseInt(rgbMatch[3], 10),\n\t\t};\n\t}\n\treturn undefined;\n}\n\n/** Calculate relative luminance of a color (0-1, higher = lighter). */\nfunction getLuminance(r: number, g: number, b: number): number {\n\tconst toLinear = (c: number) => {\n\t\tconst s = c / 255;\n\t\treturn s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n\t};\n\treturn 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\n/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */\nfunction adjustBrightness(color: string, factor: number): string {\n\tconst parsed = parseColor(color);\n\tif (!parsed) return color;\n\tconst adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));\n\treturn `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;\n}\n\n/** Derive export background colors from a base color (e.g., userMessageBg). */\nfunction deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {\n\tconst parsed = parseColor(baseColor);\n\tif (!parsed) {\n\t\treturn {\n\t\t\tpageBg: \"rgb(24, 24, 30)\",\n\t\t\tcardBg: \"rgb(30, 30, 36)\",\n\t\t\tinfoBg: \"rgb(60, 55, 40)\",\n\t\t};\n\t}\n\n\tconst luminance = getLuminance(parsed.r, parsed.g, parsed.b);\n\tconst isLight = luminance > 0.5;\n\n\tif (isLight) {\n\t\treturn {\n\t\t\tpageBg: adjustBrightness(baseColor, 0.96),\n\t\t\tcardBg: baseColor,\n\t\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,\n\t\t};\n\t}\n\treturn {\n\t\tpageBg: adjustBrightness(baseColor, 0.7),\n\t\tcardBg: adjustBrightness(baseColor, 0.85),\n\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,\n\t};\n}\n\n/**\n * Generate CSS custom property declarations from theme colors.\n */\nfunction generateThemeVars(themeName?: string): string {\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst lines: string[] = [];\n\tfor (const [key, value] of Object.entries(colors)) {\n\t\tlines.push(`--${key}: ${value};`);\n\t}\n\n\t// Use explicit theme export colors if available, otherwise derive from userMessageBg\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst userMessageBg = colors.userMessageBg || \"#343541\";\n\tconst derivedColors = deriveExportColors(userMessageBg);\n\n\tlines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);\n\tlines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);\n\tlines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);\n\n\treturn lines.join(\"\\n \");\n}\n\ninterface SessionData {\n\theader: ReturnType<SessionManager[\"getHeader\"]>;\n\tentries: ReturnType<SessionManager[\"getEntries\"]>;\n\tleafId: string | null;\n\tsystemPrompt?: string;\n\ttools?: Array<Pick<ToolDefinition, \"name\" | \"description\" | \"parameters\">>;\n\t/** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */\n\trenderedTools?: Record<string, RenderedToolHtml>;\n}\n\n/**\n * Core HTML generation logic shared by both export functions.\n */\nfunction generateHtml(sessionData: SessionData, themeName?: string): string {\n\tconst templateDir = getExportTemplateDir();\n\tconst template = readFileSync(join(templateDir, \"template.html\"), \"utf-8\");\n\tconst templateCss = readFileSync(join(templateDir, \"template.css\"), \"utf-8\");\n\tconst templateJs = readFileSync(join(templateDir, \"template.js\"), \"utf-8\");\n\tconst markedJs = readFileSync(join(templateDir, \"vendor\", \"marked.min.js\"), \"utf-8\");\n\tconst hljsJs = readFileSync(join(templateDir, \"vendor\", \"highlight.min.js\"), \"utf-8\");\n\n\tconst themeVars = generateThemeVars(themeName);\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst derivedExportColors = deriveExportColors(colors.userMessageBg || \"#343541\");\n\tconst bodyBg = themeExport.pageBg ?? derivedExportColors.pageBg;\n\tconst containerBg = themeExport.cardBg ?? derivedExportColors.cardBg;\n\tconst infoBg = themeExport.infoBg ?? derivedExportColors.infoBg;\n\n\t// Base64 encode session data to avoid escaping issues\n\tconst sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString(\"base64\");\n\n\t// Build the CSS with theme variables injected. Function replacers are used so\n\t// the replacement strings are inserted verbatim: with a string second argument,\n\t// String.prototype.replace treats `$&`, `$$`, `` $` `` and `$'` as special\n\t// patterns, which would corrupt vendored JS/CSS that legitimately contains them\n\t// (e.g. highlight.min.js contains the literal token `$&`).\n\tconst css = templateCss\n\t\t.replace(\"{{THEME_VARS}}\", () => themeVars)\n\t\t.replace(\"{{BODY_BG}}\", () => bodyBg)\n\t\t.replace(\"{{CONTAINER_BG}}\", () => containerBg)\n\t\t.replace(\"{{INFO_BG}}\", () => infoBg);\n\n\treturn template\n\t\t.replace(\"{{CSS}}\", () => css)\n\t\t.replace(\"{{JS}}\", () => templateJs)\n\t\t.replace(\"{{SESSION_DATA}}\", () => sessionDataBase64)\n\t\t.replace(\"{{MARKED_JS}}\", () => markedJs)\n\t\t.replace(\"{{HIGHLIGHT_JS}}\", () => hljsJs);\n}\n\n/** Tools rendered directly by the HTML template (not pre-rendered via TUI→ANSI→HTML pipeline) */\nconst TEMPLATE_RENDERED_TOOLS = new Set([\"bash\", \"read\", \"write\", \"edit\", \"ls\"]);\n\n/**\n * Pre-render custom tools to HTML using their TUI renderers.\n */\nfunction preRenderCustomTools(\n\tentries: SessionEntry[],\n\ttoolRenderer: ToolHtmlRenderer,\n): Record<string, RenderedToolHtml> {\n\tconst renderedTools: Record<string, RenderedToolHtml> = {};\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst msg = entry.message;\n\n\t\t// Find tool calls in assistant messages\n\t\tif (msg.role === \"assistant\" && Array.isArray(msg.content)) {\n\t\t\tfor (const block of msg.content) {\n\t\t\t\tif (block.type === \"toolCall\" && !TEMPLATE_RENDERED_TOOLS.has(block.name)) {\n\t\t\t\t\tconst callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments);\n\t\t\t\t\tif (callHtml) {\n\t\t\t\t\t\trenderedTools[block.id] = { callHtml };\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Find tool results\n\t\tif (msg.role === \"toolResult\" && msg.toolCallId) {\n\t\t\tconst toolName = msg.toolName || \"\";\n\t\t\t// Only render if we have a pre-rendered call OR it's not template-rendered\n\t\t\tconst existing = renderedTools[msg.toolCallId];\n\t\t\tif (existing || !TEMPLATE_RENDERED_TOOLS.has(toolName)) {\n\t\t\t\tconst rendered = toolRenderer.renderResult(\n\t\t\t\t\tmsg.toolCallId,\n\t\t\t\t\ttoolName,\n\t\t\t\t\tmsg.content,\n\t\t\t\t\tmsg.details,\n\t\t\t\t\tmsg.isError || false,\n\t\t\t\t);\n\t\t\t\tif (rendered) {\n\t\t\t\t\trenderedTools[msg.toolCallId] = {\n\t\t\t\t\t\t...existing,\n\t\t\t\t\t\tresultHtmlCollapsed: rendered.collapsed,\n\t\t\t\t\t\tresultHtmlExpanded: rendered.expanded,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn renderedTools;\n}\n\n/**\n * Export session to HTML using SessionManager and AgentState.\n * Used by TUI's /export command.\n */\nexport async function exportSessionToHtml(\n\tsm: SessionManager,\n\tstate?: AgentState,\n\toptions?: ExportOptions | string,\n): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tconst sessionFile = sm.getSessionFile();\n\tif (!sessionFile) {\n\t\tthrow new Error(\"Cannot export in-memory session to HTML\");\n\t}\n\tif (!existsSync(sessionFile)) {\n\t\tthrow new Error(\"Nothing to export yet - start a conversation first\");\n\t}\n\n\tconst entries = sm.getEntries();\n\n\t// Pre-render custom tools if a tool renderer is provided\n\tlet renderedTools: Record<string, RenderedToolHtml> | undefined;\n\tif (opts.toolRenderer) {\n\t\trenderedTools = preRenderCustomTools(entries, opts.toolRenderer);\n\t\t// Only include if we actually rendered something\n\t\tif (Object.keys(renderedTools).length === 0) {\n\t\t\trenderedTools = undefined;\n\t\t}\n\t}\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries,\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: state?.systemPrompt,\n\t\ttools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })),\n\t\trenderedTools,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${sessionBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n\n/**\n * Export session file to HTML (standalone, without AgentState).\n * Used by CLI for exporting arbitrary session files.\n */\nexport async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst sm = SessionManager.open(inputPath);\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries: sm.getEntries(),\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: undefined,\n\t\ttools: undefined,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${inputBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n"]}
@@ -101,18 +101,22 @@ function generateHtml(sessionData, themeName) {
101
101
  const infoBg = themeExport.infoBg ?? derivedExportColors.infoBg;
102
102
  // Base64 encode session data to avoid escaping issues
103
103
  const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
104
- // Build the CSS with theme variables injected
104
+ // Build the CSS with theme variables injected. Function replacers are used so
105
+ // the replacement strings are inserted verbatim: with a string second argument,
106
+ // String.prototype.replace treats `$&`, `$$`, `` $` `` and `$'` as special
107
+ // patterns, which would corrupt vendored JS/CSS that legitimately contains them
108
+ // (e.g. highlight.min.js contains the literal token `$&`).
105
109
  const css = templateCss
106
- .replace("{{THEME_VARS}}", themeVars)
107
- .replace("{{BODY_BG}}", bodyBg)
108
- .replace("{{CONTAINER_BG}}", containerBg)
109
- .replace("{{INFO_BG}}", infoBg);
110
+ .replace("{{THEME_VARS}}", () => themeVars)
111
+ .replace("{{BODY_BG}}", () => bodyBg)
112
+ .replace("{{CONTAINER_BG}}", () => containerBg)
113
+ .replace("{{INFO_BG}}", () => infoBg);
110
114
  return template
111
- .replace("{{CSS}}", css)
112
- .replace("{{JS}}", templateJs)
113
- .replace("{{SESSION_DATA}}", sessionDataBase64)
114
- .replace("{{MARKED_JS}}", markedJs)
115
- .replace("{{HIGHLIGHT_JS}}", hljsJs);
115
+ .replace("{{CSS}}", () => css)
116
+ .replace("{{JS}}", () => templateJs)
117
+ .replace("{{SESSION_DATA}}", () => sessionDataBase64)
118
+ .replace("{{MARKED_JS}}", () => markedJs)
119
+ .replace("{{HIGHLIGHT_JS}}", () => hljsJs);
116
120
  }
117
121
  /** Tools rendered directly by the HTML template (not pre-rendered via TUI→ANSI→HTML pipeline) */
118
122
  const TEMPLATE_RENDERED_TOOLS = new Set(["bash", "read", "write", "edit", "ls"]);
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/core/export-html/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { basename, join } from \"path\";\nimport type { AgentState } from \"phi-code-agent\";\nimport { APP_NAME, getExportTemplateDir } from \"../../config.js\";\nimport { getResolvedThemeColors, getThemeExportColors } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition } from \"../extensions/types.js\";\nimport type { SessionEntry } from \"../session-manager.js\";\nimport { SessionManager } from \"../session-manager.js\";\n\n/**\n * Interface for rendering custom tools to HTML.\n * Used by agent-session to pre-render extension tool output.\n */\nexport interface ToolHtmlRenderer {\n\t/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */\n\trenderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;\n\t/** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */\n\trenderResult(\n\t\ttoolCallId: string,\n\t\ttoolName: string,\n\t\tresult: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,\n\t\tdetails: unknown,\n\t\tisError: boolean,\n\t): { collapsed?: string; expanded?: string } | undefined;\n}\n\n/** Pre-rendered HTML for a custom tool call and result */\ninterface RenderedToolHtml {\n\tcallHtml?: string;\n\tresultHtmlCollapsed?: string;\n\tresultHtmlExpanded?: string;\n}\n\nexport interface ExportOptions {\n\toutputPath?: string;\n\tthemeName?: string;\n\t/** Optional tool renderer for custom tools */\n\ttoolRenderer?: ToolHtmlRenderer;\n}\n\n/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */\nfunction parseColor(color: string): { r: number; g: number; b: number } | undefined {\n\tconst hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);\n\tif (hexMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(hexMatch[1], 16),\n\t\t\tg: Number.parseInt(hexMatch[2], 16),\n\t\t\tb: Number.parseInt(hexMatch[3], 16),\n\t\t};\n\t}\n\tconst rgbMatch = color.match(/^rgb\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)$/);\n\tif (rgbMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(rgbMatch[1], 10),\n\t\t\tg: Number.parseInt(rgbMatch[2], 10),\n\t\t\tb: Number.parseInt(rgbMatch[3], 10),\n\t\t};\n\t}\n\treturn undefined;\n}\n\n/** Calculate relative luminance of a color (0-1, higher = lighter). */\nfunction getLuminance(r: number, g: number, b: number): number {\n\tconst toLinear = (c: number) => {\n\t\tconst s = c / 255;\n\t\treturn s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n\t};\n\treturn 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\n/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */\nfunction adjustBrightness(color: string, factor: number): string {\n\tconst parsed = parseColor(color);\n\tif (!parsed) return color;\n\tconst adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));\n\treturn `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;\n}\n\n/** Derive export background colors from a base color (e.g., userMessageBg). */\nfunction deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {\n\tconst parsed = parseColor(baseColor);\n\tif (!parsed) {\n\t\treturn {\n\t\t\tpageBg: \"rgb(24, 24, 30)\",\n\t\t\tcardBg: \"rgb(30, 30, 36)\",\n\t\t\tinfoBg: \"rgb(60, 55, 40)\",\n\t\t};\n\t}\n\n\tconst luminance = getLuminance(parsed.r, parsed.g, parsed.b);\n\tconst isLight = luminance > 0.5;\n\n\tif (isLight) {\n\t\treturn {\n\t\t\tpageBg: adjustBrightness(baseColor, 0.96),\n\t\t\tcardBg: baseColor,\n\t\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,\n\t\t};\n\t}\n\treturn {\n\t\tpageBg: adjustBrightness(baseColor, 0.7),\n\t\tcardBg: adjustBrightness(baseColor, 0.85),\n\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,\n\t};\n}\n\n/**\n * Generate CSS custom property declarations from theme colors.\n */\nfunction generateThemeVars(themeName?: string): string {\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst lines: string[] = [];\n\tfor (const [key, value] of Object.entries(colors)) {\n\t\tlines.push(`--${key}: ${value};`);\n\t}\n\n\t// Use explicit theme export colors if available, otherwise derive from userMessageBg\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst userMessageBg = colors.userMessageBg || \"#343541\";\n\tconst derivedColors = deriveExportColors(userMessageBg);\n\n\tlines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);\n\tlines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);\n\tlines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);\n\n\treturn lines.join(\"\\n \");\n}\n\ninterface SessionData {\n\theader: ReturnType<SessionManager[\"getHeader\"]>;\n\tentries: ReturnType<SessionManager[\"getEntries\"]>;\n\tleafId: string | null;\n\tsystemPrompt?: string;\n\ttools?: Array<Pick<ToolDefinition, \"name\" | \"description\" | \"parameters\">>;\n\t/** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */\n\trenderedTools?: Record<string, RenderedToolHtml>;\n}\n\n/**\n * Core HTML generation logic shared by both export functions.\n */\nfunction generateHtml(sessionData: SessionData, themeName?: string): string {\n\tconst templateDir = getExportTemplateDir();\n\tconst template = readFileSync(join(templateDir, \"template.html\"), \"utf-8\");\n\tconst templateCss = readFileSync(join(templateDir, \"template.css\"), \"utf-8\");\n\tconst templateJs = readFileSync(join(templateDir, \"template.js\"), \"utf-8\");\n\tconst markedJs = readFileSync(join(templateDir, \"vendor\", \"marked.min.js\"), \"utf-8\");\n\tconst hljsJs = readFileSync(join(templateDir, \"vendor\", \"highlight.min.js\"), \"utf-8\");\n\n\tconst themeVars = generateThemeVars(themeName);\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst derivedExportColors = deriveExportColors(colors.userMessageBg || \"#343541\");\n\tconst bodyBg = themeExport.pageBg ?? derivedExportColors.pageBg;\n\tconst containerBg = themeExport.cardBg ?? derivedExportColors.cardBg;\n\tconst infoBg = themeExport.infoBg ?? derivedExportColors.infoBg;\n\n\t// Base64 encode session data to avoid escaping issues\n\tconst sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString(\"base64\");\n\n\t// Build the CSS with theme variables injected\n\tconst css = templateCss\n\t\t.replace(\"{{THEME_VARS}}\", themeVars)\n\t\t.replace(\"{{BODY_BG}}\", bodyBg)\n\t\t.replace(\"{{CONTAINER_BG}}\", containerBg)\n\t\t.replace(\"{{INFO_BG}}\", infoBg);\n\n\treturn template\n\t\t.replace(\"{{CSS}}\", css)\n\t\t.replace(\"{{JS}}\", templateJs)\n\t\t.replace(\"{{SESSION_DATA}}\", sessionDataBase64)\n\t\t.replace(\"{{MARKED_JS}}\", markedJs)\n\t\t.replace(\"{{HIGHLIGHT_JS}}\", hljsJs);\n}\n\n/** Tools rendered directly by the HTML template (not pre-rendered via TUI→ANSI→HTML pipeline) */\nconst TEMPLATE_RENDERED_TOOLS = new Set([\"bash\", \"read\", \"write\", \"edit\", \"ls\"]);\n\n/**\n * Pre-render custom tools to HTML using their TUI renderers.\n */\nfunction preRenderCustomTools(\n\tentries: SessionEntry[],\n\ttoolRenderer: ToolHtmlRenderer,\n): Record<string, RenderedToolHtml> {\n\tconst renderedTools: Record<string, RenderedToolHtml> = {};\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst msg = entry.message;\n\n\t\t// Find tool calls in assistant messages\n\t\tif (msg.role === \"assistant\" && Array.isArray(msg.content)) {\n\t\t\tfor (const block of msg.content) {\n\t\t\t\tif (block.type === \"toolCall\" && !TEMPLATE_RENDERED_TOOLS.has(block.name)) {\n\t\t\t\t\tconst callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments);\n\t\t\t\t\tif (callHtml) {\n\t\t\t\t\t\trenderedTools[block.id] = { callHtml };\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Find tool results\n\t\tif (msg.role === \"toolResult\" && msg.toolCallId) {\n\t\t\tconst toolName = msg.toolName || \"\";\n\t\t\t// Only render if we have a pre-rendered call OR it's not template-rendered\n\t\t\tconst existing = renderedTools[msg.toolCallId];\n\t\t\tif (existing || !TEMPLATE_RENDERED_TOOLS.has(toolName)) {\n\t\t\t\tconst rendered = toolRenderer.renderResult(\n\t\t\t\t\tmsg.toolCallId,\n\t\t\t\t\ttoolName,\n\t\t\t\t\tmsg.content,\n\t\t\t\t\tmsg.details,\n\t\t\t\t\tmsg.isError || false,\n\t\t\t\t);\n\t\t\t\tif (rendered) {\n\t\t\t\t\trenderedTools[msg.toolCallId] = {\n\t\t\t\t\t\t...existing,\n\t\t\t\t\t\tresultHtmlCollapsed: rendered.collapsed,\n\t\t\t\t\t\tresultHtmlExpanded: rendered.expanded,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn renderedTools;\n}\n\n/**\n * Export session to HTML using SessionManager and AgentState.\n * Used by TUI's /export command.\n */\nexport async function exportSessionToHtml(\n\tsm: SessionManager,\n\tstate?: AgentState,\n\toptions?: ExportOptions | string,\n): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tconst sessionFile = sm.getSessionFile();\n\tif (!sessionFile) {\n\t\tthrow new Error(\"Cannot export in-memory session to HTML\");\n\t}\n\tif (!existsSync(sessionFile)) {\n\t\tthrow new Error(\"Nothing to export yet - start a conversation first\");\n\t}\n\n\tconst entries = sm.getEntries();\n\n\t// Pre-render custom tools if a tool renderer is provided\n\tlet renderedTools: Record<string, RenderedToolHtml> | undefined;\n\tif (opts.toolRenderer) {\n\t\trenderedTools = preRenderCustomTools(entries, opts.toolRenderer);\n\t\t// Only include if we actually rendered something\n\t\tif (Object.keys(renderedTools).length === 0) {\n\t\t\trenderedTools = undefined;\n\t\t}\n\t}\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries,\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: state?.systemPrompt,\n\t\ttools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })),\n\t\trenderedTools,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${sessionBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n\n/**\n * Export session file to HTML (standalone, without AgentState).\n * Used by CLI for exporting arbitrary session files.\n */\nexport async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst sm = SessionManager.open(inputPath);\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries: sm.getEntries(),\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: undefined,\n\t\ttools: undefined,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${inputBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/core/export-html/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { basename, join } from \"path\";\nimport type { AgentState } from \"phi-code-agent\";\nimport { APP_NAME, getExportTemplateDir } from \"../../config.js\";\nimport { getResolvedThemeColors, getThemeExportColors } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition } from \"../extensions/types.js\";\nimport type { SessionEntry } from \"../session-manager.js\";\nimport { SessionManager } from \"../session-manager.js\";\n\n/**\n * Interface for rendering custom tools to HTML.\n * Used by agent-session to pre-render extension tool output.\n */\nexport interface ToolHtmlRenderer {\n\t/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */\n\trenderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;\n\t/** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */\n\trenderResult(\n\t\ttoolCallId: string,\n\t\ttoolName: string,\n\t\tresult: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,\n\t\tdetails: unknown,\n\t\tisError: boolean,\n\t): { collapsed?: string; expanded?: string } | undefined;\n}\n\n/** Pre-rendered HTML for a custom tool call and result */\ninterface RenderedToolHtml {\n\tcallHtml?: string;\n\tresultHtmlCollapsed?: string;\n\tresultHtmlExpanded?: string;\n}\n\nexport interface ExportOptions {\n\toutputPath?: string;\n\tthemeName?: string;\n\t/** Optional tool renderer for custom tools */\n\ttoolRenderer?: ToolHtmlRenderer;\n}\n\n/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */\nfunction parseColor(color: string): { r: number; g: number; b: number } | undefined {\n\tconst hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);\n\tif (hexMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(hexMatch[1], 16),\n\t\t\tg: Number.parseInt(hexMatch[2], 16),\n\t\t\tb: Number.parseInt(hexMatch[3], 16),\n\t\t};\n\t}\n\tconst rgbMatch = color.match(/^rgb\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)$/);\n\tif (rgbMatch) {\n\t\treturn {\n\t\t\tr: Number.parseInt(rgbMatch[1], 10),\n\t\t\tg: Number.parseInt(rgbMatch[2], 10),\n\t\t\tb: Number.parseInt(rgbMatch[3], 10),\n\t\t};\n\t}\n\treturn undefined;\n}\n\n/** Calculate relative luminance of a color (0-1, higher = lighter). */\nfunction getLuminance(r: number, g: number, b: number): number {\n\tconst toLinear = (c: number) => {\n\t\tconst s = c / 255;\n\t\treturn s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n\t};\n\treturn 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\n/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */\nfunction adjustBrightness(color: string, factor: number): string {\n\tconst parsed = parseColor(color);\n\tif (!parsed) return color;\n\tconst adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));\n\treturn `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;\n}\n\n/** Derive export background colors from a base color (e.g., userMessageBg). */\nfunction deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {\n\tconst parsed = parseColor(baseColor);\n\tif (!parsed) {\n\t\treturn {\n\t\t\tpageBg: \"rgb(24, 24, 30)\",\n\t\t\tcardBg: \"rgb(30, 30, 36)\",\n\t\t\tinfoBg: \"rgb(60, 55, 40)\",\n\t\t};\n\t}\n\n\tconst luminance = getLuminance(parsed.r, parsed.g, parsed.b);\n\tconst isLight = luminance > 0.5;\n\n\tif (isLight) {\n\t\treturn {\n\t\t\tpageBg: adjustBrightness(baseColor, 0.96),\n\t\t\tcardBg: baseColor,\n\t\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,\n\t\t};\n\t}\n\treturn {\n\t\tpageBg: adjustBrightness(baseColor, 0.7),\n\t\tcardBg: adjustBrightness(baseColor, 0.85),\n\t\tinfoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,\n\t};\n}\n\n/**\n * Generate CSS custom property declarations from theme colors.\n */\nfunction generateThemeVars(themeName?: string): string {\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst lines: string[] = [];\n\tfor (const [key, value] of Object.entries(colors)) {\n\t\tlines.push(`--${key}: ${value};`);\n\t}\n\n\t// Use explicit theme export colors if available, otherwise derive from userMessageBg\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst userMessageBg = colors.userMessageBg || \"#343541\";\n\tconst derivedColors = deriveExportColors(userMessageBg);\n\n\tlines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);\n\tlines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);\n\tlines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);\n\n\treturn lines.join(\"\\n \");\n}\n\ninterface SessionData {\n\theader: ReturnType<SessionManager[\"getHeader\"]>;\n\tentries: ReturnType<SessionManager[\"getEntries\"]>;\n\tleafId: string | null;\n\tsystemPrompt?: string;\n\ttools?: Array<Pick<ToolDefinition, \"name\" | \"description\" | \"parameters\">>;\n\t/** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */\n\trenderedTools?: Record<string, RenderedToolHtml>;\n}\n\n/**\n * Core HTML generation logic shared by both export functions.\n */\nfunction generateHtml(sessionData: SessionData, themeName?: string): string {\n\tconst templateDir = getExportTemplateDir();\n\tconst template = readFileSync(join(templateDir, \"template.html\"), \"utf-8\");\n\tconst templateCss = readFileSync(join(templateDir, \"template.css\"), \"utf-8\");\n\tconst templateJs = readFileSync(join(templateDir, \"template.js\"), \"utf-8\");\n\tconst markedJs = readFileSync(join(templateDir, \"vendor\", \"marked.min.js\"), \"utf-8\");\n\tconst hljsJs = readFileSync(join(templateDir, \"vendor\", \"highlight.min.js\"), \"utf-8\");\n\n\tconst themeVars = generateThemeVars(themeName);\n\tconst colors = getResolvedThemeColors(themeName);\n\tconst themeExport = getThemeExportColors(themeName);\n\tconst derivedExportColors = deriveExportColors(colors.userMessageBg || \"#343541\");\n\tconst bodyBg = themeExport.pageBg ?? derivedExportColors.pageBg;\n\tconst containerBg = themeExport.cardBg ?? derivedExportColors.cardBg;\n\tconst infoBg = themeExport.infoBg ?? derivedExportColors.infoBg;\n\n\t// Base64 encode session data to avoid escaping issues\n\tconst sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString(\"base64\");\n\n\t// Build the CSS with theme variables injected. Function replacers are used so\n\t// the replacement strings are inserted verbatim: with a string second argument,\n\t// String.prototype.replace treats `$&`, `$$`, `` $` `` and `$'` as special\n\t// patterns, which would corrupt vendored JS/CSS that legitimately contains them\n\t// (e.g. highlight.min.js contains the literal token `$&`).\n\tconst css = templateCss\n\t\t.replace(\"{{THEME_VARS}}\", () => themeVars)\n\t\t.replace(\"{{BODY_BG}}\", () => bodyBg)\n\t\t.replace(\"{{CONTAINER_BG}}\", () => containerBg)\n\t\t.replace(\"{{INFO_BG}}\", () => infoBg);\n\n\treturn template\n\t\t.replace(\"{{CSS}}\", () => css)\n\t\t.replace(\"{{JS}}\", () => templateJs)\n\t\t.replace(\"{{SESSION_DATA}}\", () => sessionDataBase64)\n\t\t.replace(\"{{MARKED_JS}}\", () => markedJs)\n\t\t.replace(\"{{HIGHLIGHT_JS}}\", () => hljsJs);\n}\n\n/** Tools rendered directly by the HTML template (not pre-rendered via TUI→ANSI→HTML pipeline) */\nconst TEMPLATE_RENDERED_TOOLS = new Set([\"bash\", \"read\", \"write\", \"edit\", \"ls\"]);\n\n/**\n * Pre-render custom tools to HTML using their TUI renderers.\n */\nfunction preRenderCustomTools(\n\tentries: SessionEntry[],\n\ttoolRenderer: ToolHtmlRenderer,\n): Record<string, RenderedToolHtml> {\n\tconst renderedTools: Record<string, RenderedToolHtml> = {};\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst msg = entry.message;\n\n\t\t// Find tool calls in assistant messages\n\t\tif (msg.role === \"assistant\" && Array.isArray(msg.content)) {\n\t\t\tfor (const block of msg.content) {\n\t\t\t\tif (block.type === \"toolCall\" && !TEMPLATE_RENDERED_TOOLS.has(block.name)) {\n\t\t\t\t\tconst callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments);\n\t\t\t\t\tif (callHtml) {\n\t\t\t\t\t\trenderedTools[block.id] = { callHtml };\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Find tool results\n\t\tif (msg.role === \"toolResult\" && msg.toolCallId) {\n\t\t\tconst toolName = msg.toolName || \"\";\n\t\t\t// Only render if we have a pre-rendered call OR it's not template-rendered\n\t\t\tconst existing = renderedTools[msg.toolCallId];\n\t\t\tif (existing || !TEMPLATE_RENDERED_TOOLS.has(toolName)) {\n\t\t\t\tconst rendered = toolRenderer.renderResult(\n\t\t\t\t\tmsg.toolCallId,\n\t\t\t\t\ttoolName,\n\t\t\t\t\tmsg.content,\n\t\t\t\t\tmsg.details,\n\t\t\t\t\tmsg.isError || false,\n\t\t\t\t);\n\t\t\t\tif (rendered) {\n\t\t\t\t\trenderedTools[msg.toolCallId] = {\n\t\t\t\t\t\t...existing,\n\t\t\t\t\t\tresultHtmlCollapsed: rendered.collapsed,\n\t\t\t\t\t\tresultHtmlExpanded: rendered.expanded,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn renderedTools;\n}\n\n/**\n * Export session to HTML using SessionManager and AgentState.\n * Used by TUI's /export command.\n */\nexport async function exportSessionToHtml(\n\tsm: SessionManager,\n\tstate?: AgentState,\n\toptions?: ExportOptions | string,\n): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tconst sessionFile = sm.getSessionFile();\n\tif (!sessionFile) {\n\t\tthrow new Error(\"Cannot export in-memory session to HTML\");\n\t}\n\tif (!existsSync(sessionFile)) {\n\t\tthrow new Error(\"Nothing to export yet - start a conversation first\");\n\t}\n\n\tconst entries = sm.getEntries();\n\n\t// Pre-render custom tools if a tool renderer is provided\n\tlet renderedTools: Record<string, RenderedToolHtml> | undefined;\n\tif (opts.toolRenderer) {\n\t\trenderedTools = preRenderCustomTools(entries, opts.toolRenderer);\n\t\t// Only include if we actually rendered something\n\t\tif (Object.keys(renderedTools).length === 0) {\n\t\t\trenderedTools = undefined;\n\t\t}\n\t}\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries,\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: state?.systemPrompt,\n\t\ttools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })),\n\t\trenderedTools,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${sessionBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n\n/**\n * Export session file to HTML (standalone, without AgentState).\n * Used by CLI for exporting arbitrary session files.\n */\nexport async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {\n\tconst opts: ExportOptions = typeof options === \"string\" ? { outputPath: options } : options || {};\n\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst sm = SessionManager.open(inputPath);\n\n\tconst sessionData: SessionData = {\n\t\theader: sm.getHeader(),\n\t\tentries: sm.getEntries(),\n\t\tleafId: sm.getLeafId(),\n\t\tsystemPrompt: undefined,\n\t\ttools: undefined,\n\t};\n\n\tconst html = generateHtml(sessionData, opts.themeName);\n\n\tlet outputPath = opts.outputPath;\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${inputBasename}.html`;\n\t}\n\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../../src/core/extensions/loader.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAuBH,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAIhE,OAAO,KAAK,EACX,SAAS,EAET,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EAKpB,MAAM,YAAY,CAAC;AAgHpB;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,gBAAgB,CA8CzD;AA+ND;;GAEG;AACH,wBAAsB,wBAAwB,CAC7C,OAAO,EAAE,gBAAgB,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,gBAAgB,EACzB,aAAa,SAAa,GACxB,OAAO,CAAC,SAAS,CAAC,CAKpB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAwBrH;AAkHD;;GAEG;AACH,wBAAsB,yBAAyB,CAC9C,eAAe,EAAE,MAAM,EAAE,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,EAChC,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,oBAAoB,CAAC,CA+C/B","sourcesContent":["/**\n * Extension loader - loads TypeScript extension modules using jiti.\n *\n */\n\nimport * as fs from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { createJiti } from \"@mariozechner/jiti\";\nimport * as _bundledPiAgentCore from \"phi-code-agent\";\nimport * as _bundledPiAi from \"phi-code-ai\";\nimport * as _bundledPiAiOauth from \"phi-code-ai/oauth\";\nimport type { KeyId } from \"phi-code-tui\";\nimport * as _bundledPiTui from \"phi-code-tui\";\n// Static imports of packages that extensions may use.\n// These MUST be static so Bun bundles them into the compiled binary.\n// The virtualModules option then makes them available to extensions.\nimport * as _bundledTypebox from \"typebox\";\nimport * as _bundledTypeboxCompile from \"typebox/compile\";\nimport * as _bundledTypeboxValue from \"typebox/value\";\nimport { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from \"../../config.js\";\n// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,\n// avoiding a circular dependency. Extensions can import from phi-code.\nimport * as _bundledPiCodingAgent from \"../../index.js\";\nimport { createEventBus, type EventBus } from \"../event-bus.js\";\nimport type { ExecOptions } from \"../exec.js\";\nimport { execCommand } from \"../exec.js\";\nimport { createSyntheticSourceInfo } from \"../source-info.js\";\nimport type {\n\tExtension,\n\tExtensionAPI,\n\tExtensionFactory,\n\tExtensionRuntime,\n\tLoadExtensionsResult,\n\tMessageRenderer,\n\tProviderConfig,\n\tRegisteredCommand,\n\tToolDefinition,\n} from \"./types.js\";\n\n/** Modules available to extensions via virtualModules (for compiled Bun binary) */\nconst VIRTUAL_MODULES: Record<string, unknown> = {\n\t// typebox (new upstream name)\n\ttypebox: _bundledTypebox,\n\t\"typebox/compile\": _bundledTypeboxCompile,\n\t\"typebox/value\": _bundledTypeboxValue,\n\t\"@sinclair/typebox\": _bundledTypebox,\n\t\"@sinclair/typebox/compile\": _bundledTypeboxCompile,\n\t\"@sinclair/typebox/value\": _bundledTypeboxValue,\n\t// phi-code packages\n\t\"phi-code\": _bundledPiCodingAgent,\n\t\"@phi-code-admin/phi-code\": _bundledPiCodingAgent,\n\t\"phi-code-agent\": _bundledPiAgentCore,\n\t\"phi-code-tui\": _bundledPiTui,\n\t\"phi-code-ai\": _bundledPiAi,\n\t\"phi-code-ai/oauth\": _bundledPiAiOauth,\n\t// Backwards compat aliases (upstream @mariozechner scope)\n\t\"@mariozechner/pi-coding-agent\": _bundledPiCodingAgent,\n\t\"@mariozechner/pi-agent-core\": _bundledPiAgentCore,\n\t\"@mariozechner/pi-tui\": _bundledPiTui,\n\t\"@mariozechner/pi-ai\": _bundledPiAi,\n\t\"@mariozechner/pi-ai/oauth\": _bundledPiAiOauth,\n};\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Get aliases for jiti (used in Node.js/development mode).\n * In Bun binary mode, virtualModules is used instead.\n */\nlet _aliases: Record<string, string> | null = null;\n\nfunction getAliases(): Record<string, string> {\n\tif (_aliases) return _aliases;\n\n\tconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\tconst packageIndex = path.resolve(__dirname, \"../..\", \"index.js\");\n\n\tconst typeboxEntry = require.resolve(\"typebox\");\n\tconst typeboxCompileEntry = require.resolve(\"typebox/compile\");\n\tconst typeboxValueEntry = require.resolve(\"typebox/value\");\n\n\tconst packagesRoot = path.resolve(__dirname, \"../../../../\");\n\tconst resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => {\n\t\tconst workspacePath = path.join(packagesRoot, workspaceRelativePath);\n\t\tif (fs.existsSync(workspacePath)) {\n\t\t\treturn workspacePath;\n\t\t}\n\t\treturn fileURLToPath(import.meta.resolve(specifier));\n\t};\n\n\tconst piCodingAgentEntry = packageIndex;\n\tconst piAgentCoreEntry = resolveWorkspaceOrImport(\"agent/dist/index.js\", \"phi-code-agent\");\n\tconst piTuiEntry = resolveWorkspaceOrImport(\"tui/dist/index.js\", \"phi-code-tui\");\n\tconst piAiEntry = resolveWorkspaceOrImport(\"ai/dist/index.js\", \"phi-code-ai\");\n\tconst piAiOauthEntry = resolveWorkspaceOrImport(\"ai/dist/oauth.js\", \"phi-code-ai/oauth\");\n\n\t_aliases = {\n\t\t// phi-code packages\n\t\t\"phi-code\": piCodingAgentEntry,\n\t\t\"@phi-code-admin/phi-code\": piCodingAgentEntry,\n\t\t\"phi-code-agent\": piAgentCoreEntry,\n\t\t\"phi-code-tui\": piTuiEntry,\n\t\t\"phi-code-ai\": piAiEntry,\n\t\t\"phi-code-ai/oauth\": piAiOauthEntry,\n\t\t// Backwards compat aliases (upstream @mariozechner scope)\n\t\t\"@mariozechner/pi-coding-agent\": piCodingAgentEntry,\n\t\t\"@mariozechner/pi-agent-core\": piAgentCoreEntry,\n\t\t\"@mariozechner/pi-tui\": piTuiEntry,\n\t\t\"@mariozechner/pi-ai\": piAiEntry,\n\t\t\"@mariozechner/pi-ai/oauth\": piAiOauthEntry,\n\t\t// typebox (new upstream name)\n\t\ttypebox: typeboxEntry,\n\t\t\"typebox/compile\": typeboxCompileEntry,\n\t\t\"typebox/value\": typeboxValueEntry,\n\t\t\"@sinclair/typebox\": typeboxEntry,\n\t\t\"@sinclair/typebox/compile\": typeboxCompileEntry,\n\t\t\"@sinclair/typebox/value\": typeboxValueEntry,\n\t};\n\n\treturn _aliases;\n}\n\nconst UNICODE_SPACES = /[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g;\n\nfunction normalizeUnicodeSpaces(str: string): string {\n\treturn str.replace(UNICODE_SPACES, \" \");\n}\n\nfunction expandPath(p: string): string {\n\tconst normalized = normalizeUnicodeSpaces(p);\n\tif (normalized.startsWith(\"~/\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(2));\n\t}\n\tif (normalized.startsWith(\"~\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(1));\n\t}\n\treturn normalized;\n}\n\nfunction resolvePath(extPath: string, cwd: string): string {\n\tconst expanded = expandPath(extPath);\n\tif (path.isAbsolute(expanded)) {\n\t\treturn expanded;\n\t}\n\treturn path.resolve(cwd, expanded);\n}\n\ntype HandlerFn = (...args: unknown[]) => Promise<unknown>;\n\n/**\n * Create a runtime with throwing stubs for action methods.\n * Runner.bindCore() replaces these with real implementations.\n */\nexport function createExtensionRuntime(): ExtensionRuntime {\n\tconst notInitialized = () => {\n\t\tthrow new Error(\"Extension runtime not initialized. Action methods cannot be called during extension loading.\");\n\t};\n\tconst state: { staleMessage?: string } = {};\n\tconst assertActive = () => {\n\t\tif (state.staleMessage) {\n\t\t\tthrow new Error(state.staleMessage);\n\t\t}\n\t};\n\n\tconst runtime: ExtensionRuntime = {\n\t\tsendMessage: notInitialized,\n\t\tsendUserMessage: notInitialized,\n\t\tappendEntry: notInitialized,\n\t\tsetSessionName: notInitialized,\n\t\tgetSessionName: notInitialized,\n\t\tsetLabel: notInitialized,\n\t\tgetActiveTools: notInitialized,\n\t\tgetAllTools: notInitialized,\n\t\tsetActiveTools: notInitialized,\n\t\t// registerTool() is valid during extension load; refresh is only needed post-bind.\n\t\trefreshTools: () => {},\n\t\tgetCommands: notInitialized,\n\t\tsetModel: () => Promise.reject(new Error(\"Extension runtime not initialized\")),\n\t\tgetThinkingLevel: notInitialized,\n\t\tsetThinkingLevel: notInitialized,\n\t\tflagValues: new Map(),\n\t\tpendingProviderRegistrations: [],\n\t\tassertActive,\n\t\tinvalidate: (message) => {\n\t\t\tstate.staleMessage ??=\n\t\t\t\tmessage ??\n\t\t\t\t\"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().\";\n\t\t},\n\t\t// Pre-bind: queue registrations so bindCore() can flush them once the\n\t\t// model registry is available. bindCore() replaces both with direct calls.\n\t\tregisterProvider: (name, config, extensionPath = \"<unknown>\") => {\n\t\t\truntime.pendingProviderRegistrations.push({ name, config, extensionPath });\n\t\t},\n\t\tunregisterProvider: (name) => {\n\t\t\truntime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name);\n\t\t},\n\t};\n\n\treturn runtime;\n}\n\n/**\n * Create the ExtensionAPI for an extension.\n * Registration methods write to the extension object.\n * Action methods delegate to the shared runtime.\n */\nfunction createExtensionAPI(\n\textension: Extension,\n\truntime: ExtensionRuntime,\n\tcwd: string,\n\teventBus: EventBus,\n): ExtensionAPI {\n\tconst api = {\n\t\t// Registration methods - write to extension\n\t\ton(event: string, handler: HandlerFn): void {\n\t\t\truntime.assertActive();\n\t\t\tconst list = extension.handlers.get(event) ?? [];\n\t\t\tlist.push(handler);\n\t\t\textension.handlers.set(event, list);\n\t\t},\n\n\t\tregisterTool(tool: ToolDefinition): void {\n\t\t\truntime.assertActive();\n\t\t\textension.tools.set(tool.name, {\n\t\t\t\tdefinition: tool,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t});\n\t\t\truntime.refreshTools();\n\t\t},\n\n\t\tregisterCommand(name: string, options: Omit<RegisteredCommand, \"name\" | \"sourceInfo\">): void {\n\t\t\truntime.assertActive();\n\t\t\textension.commands.set(name, {\n\t\t\t\tname,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t\t...options,\n\t\t\t});\n\t\t},\n\n\t\tregisterShortcut(\n\t\t\tshortcut: KeyId,\n\t\t\toptions: {\n\t\t\t\tdescription?: string;\n\t\t\t\thandler: (ctx: import(\"./types.js\").ExtensionContext) => Promise<void> | void;\n\t\t\t},\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options });\n\t\t},\n\n\t\tregisterFlag(\n\t\t\tname: string,\n\t\t\toptions: { description?: string; type: \"boolean\" | \"string\"; default?: boolean | string },\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.flags.set(name, { name, extensionPath: extension.path, ...options });\n\t\t\tif (options.default !== undefined && !runtime.flagValues.has(name)) {\n\t\t\t\truntime.flagValues.set(name, options.default);\n\t\t\t}\n\t\t},\n\n\t\tregisterMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {\n\t\t\truntime.assertActive();\n\t\t\textension.messageRenderers.set(customType, renderer as MessageRenderer);\n\t\t},\n\n\t\t// Flag access - checks extension registered it, reads from runtime\n\t\tgetFlag(name: string): boolean | string | undefined {\n\t\t\truntime.assertActive();\n\t\t\tif (!extension.flags.has(name)) return undefined;\n\t\t\treturn runtime.flagValues.get(name);\n\t\t},\n\n\t\t// Action methods - delegate to shared runtime\n\t\tsendMessage(message, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendMessage(message, options);\n\t\t},\n\n\t\tsendUserMessage(content, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendUserMessage(content, options);\n\t\t},\n\n\t\tappendEntry(customType: string, data?: unknown): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.appendEntry(customType, data);\n\t\t},\n\n\t\tsetSessionName(name: string): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setSessionName(name);\n\t\t},\n\n\t\tgetSessionName(): string | undefined {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getSessionName();\n\t\t},\n\n\t\tsetLabel(entryId: string, label: string | undefined): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setLabel(entryId, label);\n\t\t},\n\n\t\texec(command: string, args: string[], options?: ExecOptions) {\n\t\t\truntime.assertActive();\n\t\t\treturn execCommand(command, args, options?.cwd ?? cwd, options);\n\t\t},\n\n\t\tgetActiveTools(): string[] {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getActiveTools();\n\t\t},\n\n\t\tgetAllTools() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getAllTools();\n\t\t},\n\n\t\tsetActiveTools(toolNames: string[]): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setActiveTools(toolNames);\n\t\t},\n\n\t\tgetCommands() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getCommands();\n\t\t},\n\n\t\tsetModel(model) {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.setModel(model);\n\t\t},\n\n\t\tgetThinkingLevel() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getThinkingLevel();\n\t\t},\n\n\t\tsetThinkingLevel(level) {\n\t\t\truntime.assertActive();\n\t\t\truntime.setThinkingLevel(level);\n\t\t},\n\n\t\tregisterProvider(name: string, config: ProviderConfig) {\n\t\t\truntime.assertActive();\n\t\t\truntime.registerProvider(name, config, extension.path);\n\t\t},\n\n\t\tunregisterProvider(name: string) {\n\t\t\truntime.assertActive();\n\t\t\truntime.unregisterProvider(name, extension.path);\n\t\t},\n\n\t\tevents: eventBus,\n\t} as ExtensionAPI;\n\n\treturn api;\n}\n\nasync function loadExtensionModule(extensionPath: string) {\n\tconst jiti = createJiti(import.meta.url, {\n\t\tmoduleCache: false,\n\t\t// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)\n\t\t// Also disable tryNative so jiti handles ALL imports (not just the entry point)\n\t\t// In Node.js/dev: use aliases to resolve to node_modules paths\n\t\t...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }),\n\t});\n\n\tconst module = await jiti.import(extensionPath, { default: true });\n\tconst factory = module as ExtensionFactory;\n\treturn typeof factory !== \"function\" ? undefined : factory;\n}\n\n/**\n * Create an Extension object with empty collections.\n */\nfunction createExtension(extensionPath: string, resolvedPath: string): Extension {\n\tconst source =\n\t\textensionPath.startsWith(\"<\") && extensionPath.endsWith(\">\")\n\t\t\t? extensionPath.slice(1, -1).split(\":\")[0] || \"temporary\"\n\t\t\t: \"local\";\n\tconst baseDir = extensionPath.startsWith(\"<\") ? undefined : path.dirname(resolvedPath);\n\n\treturn {\n\t\tpath: extensionPath,\n\t\tresolvedPath,\n\t\tsourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }),\n\t\thandlers: new Map(),\n\t\ttools: new Map(),\n\t\tmessageRenderers: new Map(),\n\t\tcommands: new Map(),\n\t\tflags: new Map(),\n\t\tshortcuts: new Map(),\n\t};\n}\n\nasync function loadExtension(\n\textensionPath: string,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n): Promise<{ extension: Extension | null; error: string | null }> {\n\tconst resolvedPath = resolvePath(extensionPath, cwd);\n\n\ttry {\n\t\tconst factory = await loadExtensionModule(resolvedPath);\n\t\tif (!factory) {\n\t\t\treturn { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };\n\t\t}\n\n\t\tconst extension = createExtension(extensionPath, resolvedPath);\n\t\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\t\tawait factory(api);\n\n\t\treturn { extension, error: null };\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\treturn { extension: null, error: `Failed to load extension: ${message}` };\n\t}\n}\n\n/**\n * Create an Extension from an inline factory function.\n */\nexport async function loadExtensionFromFactory(\n\tfactory: ExtensionFactory,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n\textensionPath = \"<inline>\",\n): Promise<Extension> {\n\tconst extension = createExtension(extensionPath, extensionPath);\n\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\tawait factory(api);\n\treturn extension;\n}\n\n/**\n * Load extensions from paths.\n */\nexport async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {\n\tconst extensions: Extension[] = [];\n\tconst errors: Array<{ path: string; error: string }> = [];\n\tconst resolvedEventBus = eventBus ?? createEventBus();\n\tconst runtime = createExtensionRuntime();\n\n\tfor (const extPath of paths) {\n\t\tconst { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);\n\n\t\tif (error) {\n\t\t\terrors.push({ path: extPath, error });\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (extension) {\n\t\t\textensions.push(extension);\n\t\t}\n\t}\n\n\treturn {\n\t\textensions,\n\t\terrors,\n\t\truntime,\n\t};\n}\n\ninterface PiManifest {\n\textensions?: string[];\n\tthemes?: string[];\n\tskills?: string[];\n\tprompts?: string[];\n}\n\nfunction readPiManifest(packageJsonPath: string): PiManifest | null {\n\ttry {\n\t\tconst content = fs.readFileSync(packageJsonPath, \"utf-8\");\n\t\tconst pkg = JSON.parse(content);\n\t\tif (pkg.phi && typeof pkg.phi === \"object\") {\n\t\t\treturn pkg.phi as PiManifest;\n\t\t}\n\t\tif (pkg.pi && typeof pkg.pi === \"object\") {\n\t\t\treturn pkg.pi as PiManifest;\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction isExtensionFile(name: string): boolean {\n\treturn name.endsWith(\".ts\") || name.endsWith(\".js\");\n}\n\n/**\n * Resolve extension entry points from a directory.\n *\n * Checks for:\n * 1. package.json with \"pi.extensions\" field -> returns declared paths\n * 2. index.ts or index.js -> returns the index file\n *\n * Returns resolved paths or null if no entry points found.\n */\nfunction resolveExtensionEntries(dir: string): string[] | null {\n\t// Check for package.json with \"pi\" field first\n\tconst packageJsonPath = path.join(dir, \"package.json\");\n\tif (fs.existsSync(packageJsonPath)) {\n\t\tconst manifest = readPiManifest(packageJsonPath);\n\t\tif (manifest?.extensions?.length) {\n\t\t\tconst entries: string[] = [];\n\t\t\tfor (const extPath of manifest.extensions) {\n\t\t\t\tconst resolvedExtPath = path.resolve(dir, extPath);\n\t\t\t\tif (fs.existsSync(resolvedExtPath)) {\n\t\t\t\t\tentries.push(resolvedExtPath);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (entries.length > 0) {\n\t\t\t\treturn entries;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check for index.ts or index.js\n\tconst indexTs = path.join(dir, \"index.ts\");\n\tconst indexJs = path.join(dir, \"index.js\");\n\tif (fs.existsSync(indexTs)) {\n\t\treturn [indexTs];\n\t}\n\tif (fs.existsSync(indexJs)) {\n\t\treturn [indexJs];\n\t}\n\n\treturn null;\n}\n\n/**\n * Discover extensions in a directory.\n *\n * Discovery rules:\n * 1. Direct files: `extensions/*.ts` or `*.js` → load\n * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load\n * 3. Subdirectory with package.json: `extensions/* /package.json` with \"pi\" field → load what it declares\n *\n * No recursion beyond one level. Complex packages must use package.json manifest.\n */\nfunction discoverExtensionsInDir(dir: string): string[] {\n\tif (!fs.existsSync(dir)) {\n\t\treturn [];\n\t}\n\n\tconst discovered: string[] = [];\n\n\ttry {\n\t\tconst entries = fs.readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tconst entryPath = path.join(dir, entry.name);\n\n\t\t\t// 1. Direct files: *.ts or *.js\n\t\t\tif ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {\n\t\t\t\tdiscovered.push(entryPath);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 2 & 3. Subdirectories\n\t\t\tif (entry.isDirectory() || entry.isSymbolicLink()) {\n\t\t\t\tconst entries = resolveExtensionEntries(entryPath);\n\t\t\t\tif (entries) {\n\t\t\t\t\tdiscovered.push(...entries);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\treturn [];\n\t}\n\n\treturn discovered;\n}\n\n/**\n * Discover and load extensions from standard locations.\n */\nexport async function discoverAndLoadExtensions(\n\tconfiguredPaths: string[],\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n\teventBus?: EventBus,\n): Promise<LoadExtensionsResult> {\n\tconst allPaths: string[] = [];\n\tconst seen = new Set<string>();\n\n\tconst addPaths = (paths: string[]) => {\n\t\tfor (const p of paths) {\n\t\t\tconst resolved = path.resolve(p);\n\t\t\tif (!seen.has(resolved)) {\n\t\t\t\tseen.add(resolved);\n\t\t\t\tallPaths.push(p);\n\t\t\t}\n\t\t}\n\t};\n\n\t// 1. Project-local extensions: cwd/.phi/extensions/ (or cwd/.pi/extensions/ for Pi compat)\n\tconst localExtDir = path.join(cwd, CONFIG_DIR_NAME, \"extensions\");\n\taddPaths(discoverExtensionsInDir(localExtDir));\n\n\t// 2. Global extensions: agentDir/extensions/\n\tconst globalExtDir = path.join(agentDir, \"extensions\");\n\taddPaths(discoverExtensionsInDir(globalExtDir));\n\n\t// 2b. Bundled Phi Code extensions (shipped with the package)\n\tconst bundledExtDir = path.resolve(path.join(__dirname, \"..\", \"..\", \"..\", \"extensions\", \"phi\"));\n\tif (fs.existsSync(bundledExtDir)) {\n\t\taddPaths(discoverExtensionsInDir(bundledExtDir));\n\t}\n\n\t// 3. Explicitly configured paths\n\tfor (const p of configuredPaths) {\n\t\tconst resolved = resolvePath(p, cwd);\n\t\tif (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\n\t\t\t// Check for package.json with pi manifest or index.ts\n\t\t\tconst entries = resolveExtensionEntries(resolved);\n\t\t\tif (entries) {\n\t\t\t\taddPaths(entries);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No explicit entries - discover individual files in directory\n\t\t\taddPaths(discoverExtensionsInDir(resolved));\n\t\t\tcontinue;\n\t\t}\n\n\t\taddPaths([resolved]);\n\t}\n\n\treturn loadExtensions(allPaths, cwd, eventBus);\n}\n"]}
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../../src/core/extensions/loader.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAuBH,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAIhE,OAAO,KAAK,EACX,SAAS,EAET,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EAKpB,MAAM,YAAY,CAAC;AAgHpB;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,gBAAgB,CA8CzD;AA+ND;;GAEG;AACH,wBAAsB,wBAAwB,CAC7C,OAAO,EAAE,gBAAgB,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,gBAAgB,EACzB,aAAa,SAAa,GACxB,OAAO,CAAC,SAAS,CAAC,CAKpB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAwBrH;AAoJD;;GAEG;AACH,wBAAsB,yBAAyB,CAC9C,eAAe,EAAE,MAAM,EAAE,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,EAChC,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,oBAAoB,CAAC,CAuD/B","sourcesContent":["/**\n * Extension loader - loads TypeScript extension modules using jiti.\n *\n */\n\nimport * as fs from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { createJiti } from \"@mariozechner/jiti\";\nimport * as _bundledPiAgentCore from \"phi-code-agent\";\nimport * as _bundledPiAi from \"phi-code-ai\";\nimport * as _bundledPiAiOauth from \"phi-code-ai/oauth\";\nimport type { KeyId } from \"phi-code-tui\";\nimport * as _bundledPiTui from \"phi-code-tui\";\n// Static imports of packages that extensions may use.\n// These MUST be static so Bun bundles them into the compiled binary.\n// The virtualModules option then makes them available to extensions.\nimport * as _bundledTypebox from \"typebox\";\nimport * as _bundledTypeboxCompile from \"typebox/compile\";\nimport * as _bundledTypeboxValue from \"typebox/value\";\nimport { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from \"../../config.js\";\n// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,\n// avoiding a circular dependency. Extensions can import from phi-code.\nimport * as _bundledPiCodingAgent from \"../../index.js\";\nimport { createEventBus, type EventBus } from \"../event-bus.js\";\nimport type { ExecOptions } from \"../exec.js\";\nimport { execCommand } from \"../exec.js\";\nimport { createSyntheticSourceInfo } from \"../source-info.js\";\nimport type {\n\tExtension,\n\tExtensionAPI,\n\tExtensionFactory,\n\tExtensionRuntime,\n\tLoadExtensionsResult,\n\tMessageRenderer,\n\tProviderConfig,\n\tRegisteredCommand,\n\tToolDefinition,\n} from \"./types.js\";\n\n/** Modules available to extensions via virtualModules (for compiled Bun binary) */\nconst VIRTUAL_MODULES: Record<string, unknown> = {\n\t// typebox (new upstream name)\n\ttypebox: _bundledTypebox,\n\t\"typebox/compile\": _bundledTypeboxCompile,\n\t\"typebox/value\": _bundledTypeboxValue,\n\t\"@sinclair/typebox\": _bundledTypebox,\n\t\"@sinclair/typebox/compile\": _bundledTypeboxCompile,\n\t\"@sinclair/typebox/value\": _bundledTypeboxValue,\n\t// phi-code packages\n\t\"phi-code\": _bundledPiCodingAgent,\n\t\"@phi-code-admin/phi-code\": _bundledPiCodingAgent,\n\t\"phi-code-agent\": _bundledPiAgentCore,\n\t\"phi-code-tui\": _bundledPiTui,\n\t\"phi-code-ai\": _bundledPiAi,\n\t\"phi-code-ai/oauth\": _bundledPiAiOauth,\n\t// Backwards compat aliases (upstream @mariozechner scope)\n\t\"@mariozechner/pi-coding-agent\": _bundledPiCodingAgent,\n\t\"@mariozechner/pi-agent-core\": _bundledPiAgentCore,\n\t\"@mariozechner/pi-tui\": _bundledPiTui,\n\t\"@mariozechner/pi-ai\": _bundledPiAi,\n\t\"@mariozechner/pi-ai/oauth\": _bundledPiAiOauth,\n};\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Get aliases for jiti (used in Node.js/development mode).\n * In Bun binary mode, virtualModules is used instead.\n */\nlet _aliases: Record<string, string> | null = null;\n\nfunction getAliases(): Record<string, string> {\n\tif (_aliases) return _aliases;\n\n\tconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\tconst packageIndex = path.resolve(__dirname, \"../..\", \"index.js\");\n\n\tconst typeboxEntry = require.resolve(\"typebox\");\n\tconst typeboxCompileEntry = require.resolve(\"typebox/compile\");\n\tconst typeboxValueEntry = require.resolve(\"typebox/value\");\n\n\tconst packagesRoot = path.resolve(__dirname, \"../../../../\");\n\tconst resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => {\n\t\tconst workspacePath = path.join(packagesRoot, workspaceRelativePath);\n\t\tif (fs.existsSync(workspacePath)) {\n\t\t\treturn workspacePath;\n\t\t}\n\t\treturn fileURLToPath(import.meta.resolve(specifier));\n\t};\n\n\tconst piCodingAgentEntry = packageIndex;\n\tconst piAgentCoreEntry = resolveWorkspaceOrImport(\"agent/dist/index.js\", \"phi-code-agent\");\n\tconst piTuiEntry = resolveWorkspaceOrImport(\"tui/dist/index.js\", \"phi-code-tui\");\n\tconst piAiEntry = resolveWorkspaceOrImport(\"ai/dist/index.js\", \"phi-code-ai\");\n\tconst piAiOauthEntry = resolveWorkspaceOrImport(\"ai/dist/oauth.js\", \"phi-code-ai/oauth\");\n\n\t_aliases = {\n\t\t// phi-code packages\n\t\t\"phi-code\": piCodingAgentEntry,\n\t\t\"@phi-code-admin/phi-code\": piCodingAgentEntry,\n\t\t\"phi-code-agent\": piAgentCoreEntry,\n\t\t\"phi-code-tui\": piTuiEntry,\n\t\t\"phi-code-ai\": piAiEntry,\n\t\t\"phi-code-ai/oauth\": piAiOauthEntry,\n\t\t// Backwards compat aliases (upstream @mariozechner scope)\n\t\t\"@mariozechner/pi-coding-agent\": piCodingAgentEntry,\n\t\t\"@mariozechner/pi-agent-core\": piAgentCoreEntry,\n\t\t\"@mariozechner/pi-tui\": piTuiEntry,\n\t\t\"@mariozechner/pi-ai\": piAiEntry,\n\t\t\"@mariozechner/pi-ai/oauth\": piAiOauthEntry,\n\t\t// typebox (new upstream name)\n\t\ttypebox: typeboxEntry,\n\t\t\"typebox/compile\": typeboxCompileEntry,\n\t\t\"typebox/value\": typeboxValueEntry,\n\t\t\"@sinclair/typebox\": typeboxEntry,\n\t\t\"@sinclair/typebox/compile\": typeboxCompileEntry,\n\t\t\"@sinclair/typebox/value\": typeboxValueEntry,\n\t};\n\n\treturn _aliases;\n}\n\nconst UNICODE_SPACES = /[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g;\n\nfunction normalizeUnicodeSpaces(str: string): string {\n\treturn str.replace(UNICODE_SPACES, \" \");\n}\n\nfunction expandPath(p: string): string {\n\tconst normalized = normalizeUnicodeSpaces(p);\n\tif (normalized.startsWith(\"~/\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(2));\n\t}\n\tif (normalized.startsWith(\"~\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(1));\n\t}\n\treturn normalized;\n}\n\nfunction resolvePath(extPath: string, cwd: string): string {\n\tconst expanded = expandPath(extPath);\n\tif (path.isAbsolute(expanded)) {\n\t\treturn expanded;\n\t}\n\treturn path.resolve(cwd, expanded);\n}\n\ntype HandlerFn = (...args: unknown[]) => Promise<unknown>;\n\n/**\n * Create a runtime with throwing stubs for action methods.\n * Runner.bindCore() replaces these with real implementations.\n */\nexport function createExtensionRuntime(): ExtensionRuntime {\n\tconst notInitialized = () => {\n\t\tthrow new Error(\"Extension runtime not initialized. Action methods cannot be called during extension loading.\");\n\t};\n\tconst state: { staleMessage?: string } = {};\n\tconst assertActive = () => {\n\t\tif (state.staleMessage) {\n\t\t\tthrow new Error(state.staleMessage);\n\t\t}\n\t};\n\n\tconst runtime: ExtensionRuntime = {\n\t\tsendMessage: notInitialized,\n\t\tsendUserMessage: notInitialized,\n\t\tappendEntry: notInitialized,\n\t\tsetSessionName: notInitialized,\n\t\tgetSessionName: notInitialized,\n\t\tsetLabel: notInitialized,\n\t\tgetActiveTools: notInitialized,\n\t\tgetAllTools: notInitialized,\n\t\tsetActiveTools: notInitialized,\n\t\t// registerTool() is valid during extension load; refresh is only needed post-bind.\n\t\trefreshTools: () => {},\n\t\tgetCommands: notInitialized,\n\t\tsetModel: () => Promise.reject(new Error(\"Extension runtime not initialized\")),\n\t\tgetThinkingLevel: notInitialized,\n\t\tsetThinkingLevel: notInitialized,\n\t\tflagValues: new Map(),\n\t\tpendingProviderRegistrations: [],\n\t\tassertActive,\n\t\tinvalidate: (message) => {\n\t\t\tstate.staleMessage ??=\n\t\t\t\tmessage ??\n\t\t\t\t\"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().\";\n\t\t},\n\t\t// Pre-bind: queue registrations so bindCore() can flush them once the\n\t\t// model registry is available. bindCore() replaces both with direct calls.\n\t\tregisterProvider: (name, config, extensionPath = \"<unknown>\") => {\n\t\t\truntime.pendingProviderRegistrations.push({ name, config, extensionPath });\n\t\t},\n\t\tunregisterProvider: (name) => {\n\t\t\truntime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name);\n\t\t},\n\t};\n\n\treturn runtime;\n}\n\n/**\n * Create the ExtensionAPI for an extension.\n * Registration methods write to the extension object.\n * Action methods delegate to the shared runtime.\n */\nfunction createExtensionAPI(\n\textension: Extension,\n\truntime: ExtensionRuntime,\n\tcwd: string,\n\teventBus: EventBus,\n): ExtensionAPI {\n\tconst api = {\n\t\t// Registration methods - write to extension\n\t\ton(event: string, handler: HandlerFn): void {\n\t\t\truntime.assertActive();\n\t\t\tconst list = extension.handlers.get(event) ?? [];\n\t\t\tlist.push(handler);\n\t\t\textension.handlers.set(event, list);\n\t\t},\n\n\t\tregisterTool(tool: ToolDefinition): void {\n\t\t\truntime.assertActive();\n\t\t\textension.tools.set(tool.name, {\n\t\t\t\tdefinition: tool,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t});\n\t\t\truntime.refreshTools();\n\t\t},\n\n\t\tregisterCommand(name: string, options: Omit<RegisteredCommand, \"name\" | \"sourceInfo\">): void {\n\t\t\truntime.assertActive();\n\t\t\textension.commands.set(name, {\n\t\t\t\tname,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t\t...options,\n\t\t\t});\n\t\t},\n\n\t\tregisterShortcut(\n\t\t\tshortcut: KeyId,\n\t\t\toptions: {\n\t\t\t\tdescription?: string;\n\t\t\t\thandler: (ctx: import(\"./types.js\").ExtensionContext) => Promise<void> | void;\n\t\t\t},\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options });\n\t\t},\n\n\t\tregisterFlag(\n\t\t\tname: string,\n\t\t\toptions: { description?: string; type: \"boolean\" | \"string\"; default?: boolean | string },\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.flags.set(name, { name, extensionPath: extension.path, ...options });\n\t\t\tif (options.default !== undefined && !runtime.flagValues.has(name)) {\n\t\t\t\truntime.flagValues.set(name, options.default);\n\t\t\t}\n\t\t},\n\n\t\tregisterMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {\n\t\t\truntime.assertActive();\n\t\t\textension.messageRenderers.set(customType, renderer as MessageRenderer);\n\t\t},\n\n\t\t// Flag access - checks extension registered it, reads from runtime\n\t\tgetFlag(name: string): boolean | string | undefined {\n\t\t\truntime.assertActive();\n\t\t\tif (!extension.flags.has(name)) return undefined;\n\t\t\treturn runtime.flagValues.get(name);\n\t\t},\n\n\t\t// Action methods - delegate to shared runtime\n\t\tsendMessage(message, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendMessage(message, options);\n\t\t},\n\n\t\tsendUserMessage(content, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendUserMessage(content, options);\n\t\t},\n\n\t\tappendEntry(customType: string, data?: unknown): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.appendEntry(customType, data);\n\t\t},\n\n\t\tsetSessionName(name: string): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setSessionName(name);\n\t\t},\n\n\t\tgetSessionName(): string | undefined {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getSessionName();\n\t\t},\n\n\t\tsetLabel(entryId: string, label: string | undefined): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setLabel(entryId, label);\n\t\t},\n\n\t\texec(command: string, args: string[], options?: ExecOptions) {\n\t\t\truntime.assertActive();\n\t\t\treturn execCommand(command, args, options?.cwd ?? cwd, options);\n\t\t},\n\n\t\tgetActiveTools(): string[] {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getActiveTools();\n\t\t},\n\n\t\tgetAllTools() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getAllTools();\n\t\t},\n\n\t\tsetActiveTools(toolNames: string[]): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setActiveTools(toolNames);\n\t\t},\n\n\t\tgetCommands() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getCommands();\n\t\t},\n\n\t\tsetModel(model) {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.setModel(model);\n\t\t},\n\n\t\tgetThinkingLevel() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getThinkingLevel();\n\t\t},\n\n\t\tsetThinkingLevel(level) {\n\t\t\truntime.assertActive();\n\t\t\truntime.setThinkingLevel(level);\n\t\t},\n\n\t\tregisterProvider(name: string, config: ProviderConfig) {\n\t\t\truntime.assertActive();\n\t\t\truntime.registerProvider(name, config, extension.path);\n\t\t},\n\n\t\tunregisterProvider(name: string) {\n\t\t\truntime.assertActive();\n\t\t\truntime.unregisterProvider(name, extension.path);\n\t\t},\n\n\t\tevents: eventBus,\n\t} as ExtensionAPI;\n\n\treturn api;\n}\n\nasync function loadExtensionModule(extensionPath: string) {\n\tconst jiti = createJiti(import.meta.url, {\n\t\tmoduleCache: false,\n\t\t// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)\n\t\t// Also disable tryNative so jiti handles ALL imports (not just the entry point)\n\t\t// In Node.js/dev: use aliases to resolve to node_modules paths\n\t\t...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }),\n\t});\n\n\tconst module = await jiti.import(extensionPath, { default: true });\n\tconst factory = module as ExtensionFactory;\n\treturn typeof factory !== \"function\" ? undefined : factory;\n}\n\n/**\n * Create an Extension object with empty collections.\n */\nfunction createExtension(extensionPath: string, resolvedPath: string): Extension {\n\tconst source =\n\t\textensionPath.startsWith(\"<\") && extensionPath.endsWith(\">\")\n\t\t\t? extensionPath.slice(1, -1).split(\":\")[0] || \"temporary\"\n\t\t\t: \"local\";\n\tconst baseDir = extensionPath.startsWith(\"<\") ? undefined : path.dirname(resolvedPath);\n\n\treturn {\n\t\tpath: extensionPath,\n\t\tresolvedPath,\n\t\tsourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }),\n\t\thandlers: new Map(),\n\t\ttools: new Map(),\n\t\tmessageRenderers: new Map(),\n\t\tcommands: new Map(),\n\t\tflags: new Map(),\n\t\tshortcuts: new Map(),\n\t};\n}\n\nasync function loadExtension(\n\textensionPath: string,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n): Promise<{ extension: Extension | null; error: string | null }> {\n\tconst resolvedPath = resolvePath(extensionPath, cwd);\n\n\ttry {\n\t\tconst factory = await loadExtensionModule(resolvedPath);\n\t\tif (!factory) {\n\t\t\treturn { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };\n\t\t}\n\n\t\tconst extension = createExtension(extensionPath, resolvedPath);\n\t\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\t\tawait factory(api);\n\n\t\treturn { extension, error: null };\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\treturn { extension: null, error: `Failed to load extension: ${message}` };\n\t}\n}\n\n/**\n * Create an Extension from an inline factory function.\n */\nexport async function loadExtensionFromFactory(\n\tfactory: ExtensionFactory,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n\textensionPath = \"<inline>\",\n): Promise<Extension> {\n\tconst extension = createExtension(extensionPath, extensionPath);\n\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\tawait factory(api);\n\treturn extension;\n}\n\n/**\n * Load extensions from paths.\n */\nexport async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {\n\tconst extensions: Extension[] = [];\n\tconst errors: Array<{ path: string; error: string }> = [];\n\tconst resolvedEventBus = eventBus ?? createEventBus();\n\tconst runtime = createExtensionRuntime();\n\n\tfor (const extPath of paths) {\n\t\tconst { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);\n\n\t\tif (error) {\n\t\t\terrors.push({ path: extPath, error });\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (extension) {\n\t\t\textensions.push(extension);\n\t\t}\n\t}\n\n\treturn {\n\t\textensions,\n\t\terrors,\n\t\truntime,\n\t};\n}\n\ninterface PiManifest {\n\textensions?: string[];\n\tthemes?: string[];\n\tskills?: string[];\n\tprompts?: string[];\n}\n\nfunction readPiManifest(packageJsonPath: string): PiManifest | null {\n\ttry {\n\t\tconst content = fs.readFileSync(packageJsonPath, \"utf-8\");\n\t\tconst pkg = JSON.parse(content);\n\t\tif (pkg.phi && typeof pkg.phi === \"object\") {\n\t\t\treturn pkg.phi as PiManifest;\n\t\t}\n\t\tif (pkg.pi && typeof pkg.pi === \"object\") {\n\t\t\treturn pkg.pi as PiManifest;\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction isExtensionFile(name: string): boolean {\n\treturn name.endsWith(\".ts\") || name.endsWith(\".js\");\n}\n\n/**\n * Guard against symlink-based escape/pivot during discovery.\n *\n * A discovered entry may be a symlink (or live under a symlinked path). We\n * resolve its real target and the discovery directory with fs.realpathSync,\n * then require the target to stay contained within the discovery directory.\n * A symlink pointing outside the extensions dir (e.g. to /etc, the user home,\n * or another project) is rejected so a cloned/untrusted workspace cannot use a\n * symlink to make the loader execute arbitrary out-of-tree code.\n *\n * Fails closed: if the target cannot be resolved (dangling/inaccessible), the\n * entry is rejected.\n */\nfunction isSymlinkEscaping(dir: string, entryPath: string): boolean {\n\ttry {\n\t\tconst realDir = fs.realpathSync(dir);\n\t\tconst realTarget = fs.realpathSync(entryPath);\n\t\tif (realTarget === realDir) {\n\t\t\treturn false;\n\t\t}\n\t\tconst containedPrefix = realDir.endsWith(path.sep) ? realDir : realDir + path.sep;\n\t\treturn !realTarget.startsWith(containedPrefix);\n\t} catch {\n\t\t// Dangling or inaccessible symlink target: reject (fail closed).\n\t\treturn true;\n\t}\n}\n\n/**\n * Resolve extension entry points from a directory.\n *\n * Checks for:\n * 1. package.json with \"pi.extensions\" field -> returns declared paths\n * 2. index.ts or index.js -> returns the index file\n *\n * Returns resolved paths or null if no entry points found.\n */\nfunction resolveExtensionEntries(dir: string): string[] | null {\n\t// Check for package.json with \"pi\" field first\n\tconst packageJsonPath = path.join(dir, \"package.json\");\n\tif (fs.existsSync(packageJsonPath)) {\n\t\tconst manifest = readPiManifest(packageJsonPath);\n\t\tif (manifest?.extensions?.length) {\n\t\t\tconst entries: string[] = [];\n\t\t\tfor (const extPath of manifest.extensions) {\n\t\t\t\tconst resolvedExtPath = path.resolve(dir, extPath);\n\t\t\t\tif (fs.existsSync(resolvedExtPath)) {\n\t\t\t\t\tentries.push(resolvedExtPath);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (entries.length > 0) {\n\t\t\t\treturn entries;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check for index.ts or index.js\n\tconst indexTs = path.join(dir, \"index.ts\");\n\tconst indexJs = path.join(dir, \"index.js\");\n\tif (fs.existsSync(indexTs)) {\n\t\treturn [indexTs];\n\t}\n\tif (fs.existsSync(indexJs)) {\n\t\treturn [indexJs];\n\t}\n\n\treturn null;\n}\n\n/**\n * Discover extensions in a directory.\n *\n * Discovery rules:\n * 1. Direct files: `extensions/*.ts` or `*.js` → load\n * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load\n * 3. Subdirectory with package.json: `extensions/* /package.json` with \"pi\" field → load what it declares\n *\n * No recursion beyond one level. Complex packages must use package.json manifest.\n */\nfunction discoverExtensionsInDir(dir: string): string[] {\n\tif (!fs.existsSync(dir)) {\n\t\treturn [];\n\t}\n\n\tconst discovered: string[] = [];\n\n\ttry {\n\t\tconst entries = fs.readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tconst entryPath = path.join(dir, entry.name);\n\n\t\t\t// Reject symlinks whose real target escapes the discovery directory\n\t\t\t// (anti symlink-escape: prevents loading arbitrary out-of-tree code).\n\t\t\tif (entry.isSymbolicLink() && isSymlinkEscaping(dir, entryPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 1. Direct files: *.ts or *.js\n\t\t\tif ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {\n\t\t\t\tdiscovered.push(entryPath);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 2 & 3. Subdirectories\n\t\t\tif (entry.isDirectory() || entry.isSymbolicLink()) {\n\t\t\t\tconst entries = resolveExtensionEntries(entryPath);\n\t\t\t\tif (entries) {\n\t\t\t\t\tdiscovered.push(...entries);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\treturn [];\n\t}\n\n\treturn discovered;\n}\n\n/**\n * Discover and load extensions from standard locations.\n */\nexport async function discoverAndLoadExtensions(\n\tconfiguredPaths: string[],\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n\teventBus?: EventBus,\n): Promise<LoadExtensionsResult> {\n\tconst allPaths: string[] = [];\n\tconst seen = new Set<string>();\n\n\tconst addPaths = (paths: string[]) => {\n\t\tfor (const p of paths) {\n\t\t\tconst resolved = path.resolve(p);\n\t\t\tif (!seen.has(resolved)) {\n\t\t\t\tseen.add(resolved);\n\t\t\t\tallPaths.push(p);\n\t\t\t}\n\t\t}\n\t};\n\n\t// 1. Project-local extensions: cwd/.phi/extensions/ (or cwd/.pi/extensions/ for Pi compat)\n\t// These run arbitrary code from the *current workspace*, so opening an\n\t// untrusted repo would auto-execute its extensions. Allow opting out via\n\t// PHI_DISABLE_PROJECT_EXTENSIONS=1 (global/bundled extensions still load).\n\tif (process.env.PHI_DISABLE_PROJECT_EXTENSIONS !== \"1\") {\n\t\tconst localExtDir = path.join(cwd, CONFIG_DIR_NAME, \"extensions\");\n\t\taddPaths(discoverExtensionsInDir(localExtDir));\n\t}\n\n\t// 2. Global extensions: agentDir/extensions/\n\tconst globalExtDir = path.join(agentDir, \"extensions\");\n\taddPaths(discoverExtensionsInDir(globalExtDir));\n\n\t// 2b. Bundled Phi Code extensions (shipped with the package)\n\t// __dirname n'existe pas en ESM : on le derive de import.meta.url (sinon\n\t// Reference: __dirname is not defined au chargement des extensions).\n\tconst moduleDir = path.dirname(fileURLToPath(import.meta.url));\n\tconst bundledExtDir = path.resolve(path.join(moduleDir, \"..\", \"..\", \"..\", \"extensions\", \"phi\"));\n\tif (fs.existsSync(bundledExtDir)) {\n\t\taddPaths(discoverExtensionsInDir(bundledExtDir));\n\t}\n\n\t// 3. Explicitly configured paths\n\tfor (const p of configuredPaths) {\n\t\tconst resolved = resolvePath(p, cwd);\n\t\tif (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\n\t\t\t// Check for package.json with pi manifest or index.ts\n\t\t\tconst entries = resolveExtensionEntries(resolved);\n\t\t\tif (entries) {\n\t\t\t\taddPaths(entries);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No explicit entries - discover individual files in directory\n\t\t\taddPaths(discoverExtensionsInDir(resolved));\n\t\t\tcontinue;\n\t\t}\n\n\t\taddPaths([resolved]);\n\t}\n\n\treturn loadExtensions(allPaths, cwd, eventBus);\n}\n"]}
@@ -391,6 +391,34 @@ function readPiManifest(packageJsonPath) {
391
391
  function isExtensionFile(name) {
392
392
  return name.endsWith(".ts") || name.endsWith(".js");
393
393
  }
394
+ /**
395
+ * Guard against symlink-based escape/pivot during discovery.
396
+ *
397
+ * A discovered entry may be a symlink (or live under a symlinked path). We
398
+ * resolve its real target and the discovery directory with fs.realpathSync,
399
+ * then require the target to stay contained within the discovery directory.
400
+ * A symlink pointing outside the extensions dir (e.g. to /etc, the user home,
401
+ * or another project) is rejected so a cloned/untrusted workspace cannot use a
402
+ * symlink to make the loader execute arbitrary out-of-tree code.
403
+ *
404
+ * Fails closed: if the target cannot be resolved (dangling/inaccessible), the
405
+ * entry is rejected.
406
+ */
407
+ function isSymlinkEscaping(dir, entryPath) {
408
+ try {
409
+ const realDir = fs.realpathSync(dir);
410
+ const realTarget = fs.realpathSync(entryPath);
411
+ if (realTarget === realDir) {
412
+ return false;
413
+ }
414
+ const containedPrefix = realDir.endsWith(path.sep) ? realDir : realDir + path.sep;
415
+ return !realTarget.startsWith(containedPrefix);
416
+ }
417
+ catch {
418
+ // Dangling or inaccessible symlink target: reject (fail closed).
419
+ return true;
420
+ }
421
+ }
394
422
  /**
395
423
  * Resolve extension entry points from a directory.
396
424
  *
@@ -448,6 +476,11 @@ function discoverExtensionsInDir(dir) {
448
476
  const entries = fs.readdirSync(dir, { withFileTypes: true });
449
477
  for (const entry of entries) {
450
478
  const entryPath = path.join(dir, entry.name);
479
+ // Reject symlinks whose real target escapes the discovery directory
480
+ // (anti symlink-escape: prevents loading arbitrary out-of-tree code).
481
+ if (entry.isSymbolicLink() && isSymlinkEscaping(dir, entryPath)) {
482
+ continue;
483
+ }
451
484
  // 1. Direct files: *.ts or *.js
452
485
  if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
453
486
  discovered.push(entryPath);
@@ -483,13 +516,21 @@ export async function discoverAndLoadExtensions(configuredPaths, cwd, agentDir =
483
516
  }
484
517
  };
485
518
  // 1. Project-local extensions: cwd/.phi/extensions/ (or cwd/.pi/extensions/ for Pi compat)
486
- const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions");
487
- addPaths(discoverExtensionsInDir(localExtDir));
519
+ // These run arbitrary code from the *current workspace*, so opening an
520
+ // untrusted repo would auto-execute its extensions. Allow opting out via
521
+ // PHI_DISABLE_PROJECT_EXTENSIONS=1 (global/bundled extensions still load).
522
+ if (process.env.PHI_DISABLE_PROJECT_EXTENSIONS !== "1") {
523
+ const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions");
524
+ addPaths(discoverExtensionsInDir(localExtDir));
525
+ }
488
526
  // 2. Global extensions: agentDir/extensions/
489
527
  const globalExtDir = path.join(agentDir, "extensions");
490
528
  addPaths(discoverExtensionsInDir(globalExtDir));
491
529
  // 2b. Bundled Phi Code extensions (shipped with the package)
492
- const bundledExtDir = path.resolve(path.join(__dirname, "..", "..", "..", "extensions", "phi"));
530
+ // __dirname n'existe pas en ESM : on le derive de import.meta.url (sinon
531
+ // Reference: __dirname is not defined au chargement des extensions).
532
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
533
+ const bundledExtDir = path.resolve(path.join(moduleDir, "..", "..", "..", "extensions", "phi"));
493
534
  if (fs.existsSync(bundledExtDir)) {
494
535
  addPaths(discoverExtensionsInDir(bundledExtDir));
495
536
  }