@kenkaiiii/gg-core 5.96.2 → 5.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-J7W5HS6X.js → chunk-2ZTKYSSY.js} +38 -16
- package/dist/{chunk-J7W5HS6X.js.map → chunk-2ZTKYSSY.js.map} +1 -1
- package/dist/index.cjs +39 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/model-registry.cjs +37 -15
- package/dist/model-registry.cjs.map +1 -1
- package/dist/model-registry.d.cts +1 -1
- package/dist/model-registry.d.ts +1 -1
- package/dist/model-registry.js +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/model-registry.ts","../src/kimi-region.ts","../src/auth-storage.ts","../src/paths.ts","../src/claude-code/runtime.ts","../src/claude-code/tar.ts","../src/oauth/openai.ts","../src/file-lock.ts","../src/logger.ts","../src/hidden-providers.ts"],"sourcesContent":["import type { Provider, ThinkingLevel } from \"@kenkaiiii/gg-ai\";\nimport { isKimiCodingEndpoint } from \"./kimi-region.js\";\nimport { GEMINI_API_KEY, KIMI_CODE_KEY, XIAOMI_CREDITS_KEY } from \"./auth-storage.js\";\nimport { isHiddenProvider } from \"./hidden-providers.js\";\n\nexport { HIDDEN_PROVIDERS, isHiddenProvider } from \"./hidden-providers.js\";\n\nexport interface ModelInfo {\n id: string;\n name: string;\n provider: Provider;\n contextWindow: number;\n /**\n * The ChatGPT plan route uses product-specific windows that can differ from\n * the public API model window. ChatGPT plan requests carry an accountId;\n * API-key requests do not.\n */\n codexContextWindow?: number;\n maxOutputTokens: number;\n supportsThinking: boolean;\n /**\n * Vendor-declared default reasoning level (Codex models.json\n * `default_reasoning_level`). When present, fresh sessions start here rather\n * than at the ceiling: the deep-reasoning models (Astra and GPT-6.1 Sol ship\n * \"low\", GPT-6 Luna \"medium\") think dramatically longer per rung, so defaulting to\n * `maxThinkingLevel` made new sessions pathologically slow.\n */\n defaultThinkingLevel?: ThinkingLevel;\n supportsImages: boolean;\n supportsVideo: boolean;\n /**\n * Max video payload (bytes) this model's transport accepts, used to decide\n * when an attached/read video must be compressed before sending. Differs by\n * provider delivery mechanism:\n * - Moonshot/Kimi: 100 MB (file-service upload cap)\n * - MiniMax: 50 MB (Anthropic-compatible base64 inline cap)\n * - Gemini: 20 MB (inlineData per-request cap)\n * - Xiaomi (MiMo): ~36 MB raw — the API caps the base64 STRING at 50 MB,\n * and base64 inflates bytes by ~4/3, so 36 MB raw ≈ 48 MB encoded.\n * Only meaningful when `supportsVideo` is true.\n */\n maxVideoBytes?: number;\n costTier: \"low\" | \"medium\" | \"high\";\n /**\n * The top reasoning tier this model genuinely uses. Used when thinking is\n * enabled to pick the strongest setting per model:\n * - OpenAI GPT-6 Astra / GPT-6.1 Sol: `ultra` (Codex orchestration preset above `max`)\n * - OpenAI GPT-6 Luna: `max`\n * - OpenAI Pro/Codex/old: clamped to what the model accepts\n * - Claude Fable 5.1 / Fable 5 / Mythos 5, Opus 5.5, Sonnet 5.5 and\n * Haiku 5.5: `max` (the Fable / Mythos line uses always-on adaptive\n * thinking, low→max)\n * - Kimi K3: `max` (always-on reasoning; currently the only API effort)\n * - xAI Grok 4.6: `xhigh` (new top rung; 4.5 caps at `high`)\n * - GLM / Kimi K2.x / Xiaomi / MiniMax / Qwen: `high` — binary-thinking\n * providers ignore the level on the wire, so the value is cosmetic\n * - DeepSeek V4: `xhigh` (DeepSeek maps `xhigh` → its internal `max`)\n */\n maxThinkingLevel: ThinkingLevel;\n /**\n * Effort rungs the endpoint itself declared (a synced gateway catalog's\n * `reasoning.supported_efforts`). When set, the picker offers exactly these\n * and the transport sends the chosen one unchanged. Absent on static models.\n */\n thinkingLevels?: ThinkingLevel[];\n /**\n * Ordered preference of auth-storage keys this model resolves credentials\n * from, for providers that split auth across multiple distinct\n * endpoints/keys (a plan key vs. a pay-per-token key). The first key with stored credentials wins, so a\n * model can both prefer one endpoint AND fall back to another the user has\n * configured instead:\n * - `mimo-v2.6-pro` / `mimo-v2.6-flash`: `[\"xiaomi\", XIAOMI_CREDITS_KEY]` —\n * prefer the Token Plan, fall back to API Credits (API Credits serves\n * every MiMo model, so a Credits-only user still reaches these).\n * - `mimo-v2.6-pro-ultraspeed`: `[XIAOMI_CREDITS_KEY]` only — not served\n * over the Token Plan endpoint, so there's no fallback to it.\n * - Kimi: `[KIMI_CODE_KEY, \"moonshot\"]` — the Kimi Code plan key first;\n * K2.8 Preview is `[KIMI_CODE_KEY]` only (the plan endpoint serves it).\n * - every Gemini model: `[GEMINI_API_KEY]` — the AI Studio key.\n * Falls back to `[provider]` — the normal single-credential case — when\n * unset. Read via `getAuthStorageKeys()` / `getAuthStorageKey()`.\n */\n authStorageKeys?: string[];\n /**\n * How this model bills beyond the normal plan/API rate, shown wherever the\n * model is picked or active. Only set where picking the model changes what\n * the user pays: `paid` marks real money (Claude fast mode bills usage\n * credits, never included plan usage), unset `paid` a plan-usage multiplier.\n */\n billing?: ModelBilling;\n}\n\nexport interface ModelBilling {\n /** Short picker label, e.g. \"bills usage credits\". */\n label: string;\n /** One-sentence explanation for tooltips and notes. */\n detail: string;\n /** True when choosing the model spends real money, not just plan allowance. */\n paid: boolean;\n /**\n * The same-provider model to run instead wherever a paid tier must not be\n * carried silently (saved defaults, restored sessions, Ken, sub-agents, goal\n * workers). Required when `paid` is true; registry tests enforce that it is\n * registered, same-provider and itself not paid.\n */\n standardModel?: string;\n}\n\n/** Claude fast mode: billed to usage credits from the first token. */\nconst CLAUDE_FAST_BILLING: ModelBilling = {\n label: \"bills usage credits\",\n detail:\n \"Fast mode bills your Claude usage credits ($8 in / $40 out per million tokens) from the first token, never your plan's included usage.\",\n paid: true,\n standardModel: \"claude-opus-5-5\",\n};\n\n/** MiMo UltraSpeed: API Credits only, billed per token, never the Token Plan. */\nconst XIAOMI_ULTRASPEED_BILLING: ModelBilling = {\n label: \"bills API Credits\",\n detail:\n \"UltraSpeed runs only on your Xiaomi API Credits, billed per token at a higher rate than MiMo-V2.6-Pro, never on the Token Plan.\",\n paid: true,\n standardModel: \"mimo-v2.6-pro\",\n};\n\n/** Codex Fast mode: plan limits at 2.5x (purchased credits at 2x). */\nconst CODEX_FAST_BILLING: ModelBilling = {\n label: \"2.5x plan usage\",\n detail:\n \"Codex Fast mode uses your ChatGPT plan limits at 2.5x the standard rate (purchased credits at 2x).\",\n paid: false,\n};\n\n// Provider display order — mirrors `PROVIDERS` in ui/login.tsx so the\n// /model selector and login selector sort models identically.\n/** Gemini connects with an AI Studio API key only. */\nconst GEMINI_AUTH_KEYS = [GEMINI_API_KEY];\n\n/** Kimi credential order: a Kimi Code plan key first, then a Moonshot key. */\nconst KIMI_AUTH_KEYS = [KIMI_CODE_KEY, \"moonshot\"];\n\nexport const MODELS: ModelInfo[] = [\n // ── Anthropic ──────────────────────────────────────────\n // NOTE: Claude Mythos 5.1 (`claude-mythos-5-1`, 2026-09-01) is kept commented\n // out — it's a Project Glasswing model, still \"Active (invite only)\" in\n // Anthropic's docs and unavailable to most users. Re-enable once it's\n // generally available.\n {\n // Released 2026-09-01 — replaces Fable 5 at the same $10/$50 MTok (cache\n // reads drop to $0.25). Always-on adaptive thinking steered by effort;\n // forced tool use is rejected with a 400, which gg-ai never sends on the\n // Anthropic path. Fable 5 is retired here — a session that still has it\n // saved falls back to the provider default on next start.\n id: \"claude-fable-5-1\",\n name: \"Claude Fable 5.1\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"max\",\n },\n // {\n // // Mythos-class model offered through Project Glasswing (limited\n // // availability, invitation-only). Same underlying model as Fable 5.1 with\n // // some safeguards lifted; kept here so approved accounts can select it.\n // id: \"claude-mythos-5-1\",\n // name: \"Claude Mythos 5.1\",\n // provider: \"anthropic\",\n // contextWindow: 1_000_000,\n // maxOutputTokens: 128_000,\n // supportsThinking: true,\n // supportsImages: true,\n // supportsVideo: false,\n // costTier: \"high\",\n // maxThinkingLevel: \"max\",\n // },\n {\n // Released 2026-09-22 — \"built for long-running agentic coding and knowledge\n // work\". Fable-5.1-class output at $4/$20 MTok (40% below Opus 5), 1M\n // context, 128K output, image input. Same always-on adaptive thinking with\n // the full effort ladder (low→max, xhigh included; the API-side default\n // effort is `medium`) and the same Fable 5.1 constraints gg-ai already\n // handles (no forced tool use, thinking can't be disabled). Fast mode is\n // the separate `claude-opus-5-5-fast` entry below. Opus 5 is retired here — a session that\n // still has it saved falls back to the provider default on next start.\n id: \"claude-opus-5-5\",\n name: \"Claude Opus 5.5\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n // Anthropic's API and Claude Code both start the 5.5 family at medium; at a\n // given level 5.5 thinks more than 5, so the ladder ceiling (max) made fresh\n // sessions slow and expensive. Users can still opt up.\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n },\n {\n // Opus 5.5 in fast mode (research preview): the same model at up to 2.5x\n // output tokens/sec, $8/$40 MTok. gg-ai's `stream()` sends it as\n // `claude-opus-5-5` + `speed: \"fast\"` + the fast-mode beta. On a Claude\n // subscription it bills usage credits only — never included plan usage —\n // and Anthropic 429s \"Usage credits are required for fast mode\" until they\n // are turned on. Its prompt cache is separate from standard-speed Opus.\n id: \"claude-opus-5-5-fast\",\n name: \"Claude Opus 5.5 Fast\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n billing: CLAUDE_FAST_BILLING,\n },\n {\n // Released 2026-09-28 — replaces Sonnet 5 at $2/$10 MTok, with the same\n // 1M context / 128K output and adaptive thinking, now including xhigh.\n id: \"claude-sonnet-5-5\",\n name: \"Claude Sonnet 5.5\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n // We start Sonnet 5.5 at medium like the rest of the 5.5 family (the API's\n // own default for Sonnet 5.5 is high). At a given level 5.5 thinks more\n // than 5, so the ladder ceiling (max) made fresh sessions slow and\n // expensive. Users can still opt up.\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n },\n {\n // Released 2026-10-07 — replaces Haiku 4.5 at $0.10/$0.50 MTok (prompts\n // up to 100K tokens; $0.50/$2.50 above). 1M context, 128K output, image\n // input. Adaptive thinking with the full effort ladder (low→max, xhigh\n // included; API default `medium`) — manual budget_tokens, non-default\n // sampling params and assistant prefill all 400. No server-side refusal\n // fallback: `fallbacks: \"default\"` is accepted but a refusal stays a\n // refusal. Haiku 4.5 is retired here — a session that still has it saved\n // falls back to the provider default on next start.\n id: \"claude-haiku-5-5\",\n name: \"Claude Haiku 5.5\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n // Anthropic's API and Claude Code both start the 5.5 family at medium; at a\n // given level 5.5 thinks more than 5, so the ladder ceiling (max) made fresh\n // sessions slow and expensive. Users can still opt up.\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n },\n // ── OpenAI (Codex) ─────────────────────────────────────\n {\n // GPT-6 Astra — \"Our most capable model for complex, demanding work.\"\n // (Codex catalog priority 2, listed for every ChatGPT plan, requires a\n // Codex client >= 0.153.0). Same split as 5.6:\n // 1.05M on the public Responses API, 272K on the ChatGPT plan route\n // (openai/codex models.json, `gpt-6-astra`). Reasoning ladder low → medium\n // → high → xhigh → max → ultra; `ultra` is the Codex orchestration preset\n // (multi_agent v2) and is Codex-only — the public API tops out at `max`.\n // OpenAI requires the Responses API for tool calling on Astra, so both\n // the ChatGPT plan and API keys ride gg-ai's Responses transport.\n id: \"gpt-6-astra\",\n name: \"GPT-6 Astra\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"ultra\",\n },\n {\n // Codex Fast mode for Astra (catalog `service_tiers: priority`, \"2x speed,\n // increased usage\"). gg-ai's `stream()` sends it as `gpt-6-astra` +\n // `service_tier: \"priority\"`, exactly what the Codex CLI sends. On a\n // ChatGPT plan it draws included limits at 2.5x the standard rate.\n id: \"gpt-6-astra-fast\",\n name: \"GPT-6 Astra Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"ultra\",\n billing: CODEX_FAST_BILLING,\n },\n // GPT-6 Sol + Luna — released 2026-09-22 below Astra, replacing the whole\n // GPT-5.6 family (Sol/Terra/Luna; there was no GPT-6 Terra — OpenAI's Codex\n // catalog upgrades 5.6 Terra to 6 Sol). GPT-6.1 Sol (2026-09-29) then replaced\n // GPT-6 Sol, and GPT-6.1 Terra (2026-10-26) sits between Sol and Luna. Same window split as Astra: 1.05M on the public Responses API,\n // 272K on the Codex route; 128K output, text+image input, freeform\n // apply_patch, responses-lite transport. The GPT-5.6 ids and `gpt-6-sol` are\n // retired — a saved session on one falls back to the provider default on\n // next start.\n {\n // GPT-6.1 Sol — \"Latest workhorse model for coding and everyday work.\"\n // (Codex priority 1, default low). $2/$10 MTok, cached input $0.10. Ladder low →\n // medium → high → xhigh → max → ultra; ultra is the Codex orchestration\n // preset (xhigh effort on the wire + proactive local subagent delegation).\n id: \"gpt-6.1-sol\",\n name: \"GPT-6.1 Sol\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n },\n {\n // Codex Fast mode for 6.1 Sol (\"2x speed, increased usage\") — see\n // `gpt-6-astra-fast` for the wire mapping and billing.\n id: \"gpt-6.1-sol-fast\",\n name: \"GPT-6.1 Sol Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n billing: CODEX_FAST_BILLING,\n },\n {\n // GPT-6.1 Terra (Codex priority 7, default low, client >= 0.153.0). $1/$5\n // MTok, cached input $0.05. Unlike Sol/Astra the Codex route gets a 500K\n // window (catalog `context_window`). Same ladder and Ultra preset as Sol.\n id: \"gpt-6.1-terra\",\n name: \"GPT-6.1 Terra\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 500_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n },\n {\n // Codex Fast mode for 6.1 Terra (\"1.5x speed\") — see `gpt-6-astra-fast`\n // for the wire mapping and billing.\n id: \"gpt-6.1-terra-fast\",\n name: \"GPT-6.1 Terra Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 500_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n billing: CODEX_FAST_BILLING,\n },\n {\n // Luna — \"Fast and affordable model for easier tasks.\" (Codex priority 4,\n // default medium, needs a Codex client >= 0.155.0). $0.10/$0.50 MTok.\n // Reasoning tops out at `max`.\n id: \"gpt-6-luna\",\n name: \"GPT-6 Luna\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"medium\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n {\n // Codex Fast mode for Luna (\"1.5x speed\") — see `gpt-6-astra-fast` for the\n // wire mapping and billing. Listed after Luna so compaction summaries\n // (first `low` OpenAI model) stay on standard-speed Luna.\n id: \"gpt-6-luna-fast\",\n name: \"GPT-6 Luna Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"medium\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n billing: CODEX_FAST_BILLING,\n },\n // ── Sakana (Fugu) ──────────────────────────────────────\n // Sakana Fugu is a multi-agent system surfaced as a standard LLM via the\n // OpenAI-compatible Sakana API (https://api.sakana.ai/v1). All three take\n // text + image input (verified against the live /models list, 2026-09-28).\n // `fugu` balances latency and quality; `fugu-max` (v1.0, 2026-09-11) is the\n // cost tier over the largest open-weight pool ($2/$6 per 1M); `fugu-ultra`\n // is the heavier quality tier (may need larger client timeouts). Plain Fugu\n // and Fugu Max stop at xhigh — Sakana documents max as the same effort there.\n {\n id: \"fugu\",\n name: \"Fugu\",\n provider: \"sakana\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"xhigh\",\n },\n {\n id: \"fugu-max\",\n name: \"Fugu Max\",\n provider: \"sakana\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"xhigh\",\n },\n {\n id: \"fugu-ultra\",\n name: \"Fugu Ultra\",\n provider: \"sakana\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n // The rolling alias now serves v2.0 (2026-09-11), which keeps max effort.\n maxThinkingLevel: \"max\",\n },\n // ── xAI (Grok) ─────────────────────────────────────────\n // Grok 4.7 (released 2026-09-21) — xAI's flagship for coding, agentic tasks,\n // and knowledge work: a new, larger base model with a longer RL run weighted\n // toward hours-long tasks, plus stronger self-verification and long-context\n // management. 500K context, text+image input, and a `reasoning_effort`\n // ladder of low/medium/high default/xhigh (reasoning still can't be fully\n // disabled). $2/$6 per MTok under 200K prompt tokens ($4/$12 at or above),\n // and it's the default model of the Grok Build coding agent. xAI advertises\n // \"no fixed text output limit\"; we keep the 131K practical cap for budget\n // predictability and input headroom. (A faster \"Grok 4.7 Fast\" variant\n // exists but is Cursor/Grok Build-only — not on the public API — so it isn't\n // registered.) Only the newest Grok ships — 4.6/4.5 are superseded and\n // retired; saved sessions on them fall back to this default.\n {\n id: \"grok-4.7\",\n name: \"Grok 4.7\",\n provider: \"xai\",\n contextWindow: 500_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"xhigh\",\n },\n // ── Gemini ─────────────────────────────────────────\n {\n id: \"gemini-3.1-flash-lite\",\n name: \"Gemini 3.1 Flash Lite\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n // Keep 3.1 Flash Lite first for the default and fast-model routing.\n // Gemini 3.7\n // Flash is retired: Google deprecated it on 2026-10-08 and routes every\n // `gemini-3.7-flash` request to 3.8 Flash (ai.google.dev/gemini-api/docs/\n // changelog), so a saved session on it falls back to the provider default.\n {\n id: \"gemini-3.8-flash\",\n name: \"Gemini 3.8 Flash\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n {\n id: \"gemini-3.5-flash-lite\",\n name: \"Gemini 3.5 Flash Lite\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n {\n // Wire name `gemini-3-flash`, the id Google serves 3.5 Flash under.\n id: \"gemini-3-flash\",\n name: \"Gemini 3.5 Flash\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n {\n // Gemini 3.1 Pro is public preview.\n id: \"gemini-3.1-pro-preview\",\n name: \"Gemini 3.1 Pro (Preview)\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"high\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n // ── Moonshot (Kimi) ────────────────────────────────────\n // K3 is Kimi's 2.8T-parameter flagship for long-horizon coding, knowledge\n // work, and deep reasoning. Its effort ladder is server-declared as\n // low/high/max on both the public API (default max) and the Kimi Code plan\n // endpoint (default high); thinking can also be fully disabled.\n {\n id: \"kimi-k3\",\n name: \"Kimi K3\",\n provider: \"moonshot\",\n contextWindow: 1_048_576,\n // The API can be raised as high as the full context window, but 131K is the\n // documented default and keeps room for input in AgentSession's fixed cap.\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"high\",\n maxThinkingLevel: \"max\",\n authStorageKeys: KIMI_AUTH_KEYS,\n },\n // K2.8 Preview (2026-09-11) is served only on the Kimi Code plan endpoint,\n // under its rolling `kimi-for-coding` id (live /models, 2026-09-28:\n // display_name \"K2.8 Preview\", 1M context, image + video input, efforts\n // low/high/max default max). The public Moonshot API does not serve it, so\n // it resolves from a Kimi Code plan key only.\n {\n id: \"kimi-for-coding\",\n name: \"Kimi K2.8 Preview\",\n provider: \"moonshot\",\n contextWindow: 1_048_576,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"max\",\n authStorageKeys: [KIMI_CODE_KEY],\n },\n // K2.7 Code is requested by its pinned id (not the `kimi-for-coding` alias\n // that moved to K2.8), so it stays the real K2.7 on both endpoints.\n {\n id: \"kimi-k2.7-code\",\n name: \"Kimi K2.7 Code\",\n provider: \"moonshot\",\n contextWindow: 262_144,\n maxOutputTokens: 262_144,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n authStorageKeys: KIMI_AUTH_KEYS,\n },\n // K2.7 Code HighSpeed — the same model as K2.7 Code served at ~180 tok/s\n // (up to ~260 on short contexts), 256K context (platform.kimi.ai model list,\n // 2026-10-08). The `kimi-k2.7-code` prefix gives it K2.7's always-thinking\n // wire handling in gg-ai. The Kimi For Coding endpoint lists this model as\n // `kimi-for-coding-highspeed`; like `kimi-k2.7-code` and `kimi-k3`, we send\n // the public id there too.\n {\n id: \"kimi-k2.7-code-highspeed\",\n name: \"Kimi K2.7 Code HighSpeed\",\n provider: \"moonshot\",\n contextWindow: 262_144,\n maxOutputTokens: 262_144,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n authStorageKeys: KIMI_AUTH_KEYS,\n },\n // ── Z.AI (GLM) ─────────────────────────────────────────\n // Two GLM entries, both live on the coding endpoint (verified against its\n // /models list). The pre-5.3 ids stay retired: they routed to strictly worse\n // coding for the same plan quota, and the endpoint already answers `glm-5.2`\n // requests as glm-5.3.\n // `max` is both the ceiling and Z.AI's own default — the rungs below it live\n // in thinking-level.ts.\n {\n id: \"glm-5.3\",\n name: \"GLM-5.3\",\n provider: \"glm\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"max\",\n },\n // GLM-5.3-Flash (released 2026-08-26): 320B-A18B natively multimodal sibling\n // at ~1/20th of 5.3's API price with 3× the coding-plan quota, so it is the\n // provider's `low` tier — scout sub-agents and compaction summaries route\n // here instead of paying 5.3 rates.\n // Images are native on the coding endpoint (verified: base64 data URL in an\n // `image_url` block answers correctly), which also means GLM image\n // attachments go inline for this model rather than through the zai_vision MCP\n // detour that `supportsImages: false` triggers.\n // Video/file input is documented but unverified on this transport, so it\n // stays off until measured. Thinking cannot be disabled server-side (Z.AI\n // maps a `disabled` toggle to the `low` rung and answers 200), and unlike\n // 5.3 it accepts any reasoning_effort string without a 400.\n {\n id: \"glm-5.3-flash\",\n name: \"GLM-5.3-Flash\",\n provider: \"glm\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n // ── MiniMax ────────────────────────────────────────────\n {\n // The only text model M Plan / Token Plan keys cover; MiniMax-M3 on those\n // keys fails with \"insufficient balance (1008)\". Pay-as-you-go serves both.\n // Always thinks (`thinking: disabled` 400s; omitted = on) with a real\n // low→max effort ladder on output_config.effort, API default max.\n id: \"MiniMax-M3.1-Flash\",\n name: \"MiniMax M3.1 Flash\",\n provider: \"minimax\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 50 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n {\n id: \"MiniMax-M3\",\n name: \"MiniMax M3\",\n provider: \"minimax\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 50 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n },\n // ── Xiaomi (MiMo) ──────────────────────────────────────\n // MiMo-V2.6 series (released 2026-09-22, open-weight: Pro 1.02T/42B-A,\n // Flash 309B/15B-A, plus a 9B Qwen distill not served over the API). Every\n // V2.6 text model is natively full-modal (image + audio + video\n // understanding), so the whole series takes image/video input over the same\n // OpenAI-compatible base64 transport the old omni model used. API prices are\n // unchanged from V2.5. The V2.5 ids (`mimo-v2.5-pro`/`mimo-v2.5`) deprecate\n // on the platform 2026-10-21 and are retired here — saved sessions fall back\n // to the provider default on next start.\n {\n // Coding/agentic flagship — highest open-weight score on Artificial\n // Analysis at launch (46, tied with Grok 4.7).\n id: \"mimo-v2.6-pro\",\n name: \"MiMo-V2.6-Pro\",\n provider: \"xiaomi\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 36 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n authStorageKeys: [\"xiaomi\", XIAOMI_CREDITS_KEY],\n },\n {\n // Low-cost full-modal sibling (~10% of Pro's price class) — the provider's\n // `low` tier, so scout sub-agents and fast routing land here.\n id: \"mimo-v2.6-flash\",\n name: \"MiMo-V2.6-Flash\",\n provider: \"xiaomi\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 36 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: [\"xiaomi\", XIAOMI_CREDITS_KEY],\n },\n // UltraSpeed: lower-latency sibling of the Pro flagship, same full-modal\n // capability surface, premium-priced for the throughput gain. API-only —\n // not served over the Token Plan endpoint, so credentials resolve from the\n // distinct API Credits key only (see authStorageKeys doc).\n {\n id: \"mimo-v2.6-pro-ultraspeed\",\n name: \"MiMo-V2.6-Pro-UltraSpeed\",\n provider: \"xiaomi\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 36 * 1024 * 1024,\n costTier: \"high\",\n maxThinkingLevel: \"high\",\n authStorageKeys: [XIAOMI_CREDITS_KEY],\n billing: XIAOMI_ULTRASPEED_BILLING,\n },\n // ── DeepSeek ───────────────────────────────────────────\n // Window 1,000,000 tokens (decimal \"1M\"), as DeepSeek's own agent sizes it\n // (deepseek-ai/deepseek-harness DEFAULT_CONTEXT_WINDOW = 1_000_000; models.dev\n // via earendil-works/pi agrees) — not 1,048,576.\n // The live /models list (2026-09-28) serves exactly `deepseek-flash` and\n // `deepseek-v4-pro`. V4 Flash and V4 Flash Vision Exp are retired; their old\n // ids only temporarily route to V4.1 Flash, so they are retired here too.\n {\n // `deepseek-v4-pro` now serves DeepSeek-V4-Pro-0813 (released 2026-08-13,\n // first STABLE V4 Pro — supersedes the April preview; calling name\n // unchanged, same 1.6T/49B MoE). 1M context, text-only, low/high/max effort.\n // DeepSeek reversed its planned 2026-09-14 retirement, so it stays served.\n // Docs abbreviate output as 384K; use the same conservative 384,000-token\n // application cap across V4 models rather than mixing decimal/binary units.\n id: \"deepseek-v4-pro\",\n name: \"DeepSeek V4 Pro\",\n provider: \"deepseek\",\n contextWindow: 1_000_000,\n maxOutputTokens: 384_000,\n supportsThinking: true,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"max\",\n },\n {\n // `deepseek-flash` is the rolling alias for the latest Flash — currently\n // V4.1 Flash (2026-09-10): native image input, 1M context, 384K output.\n id: \"deepseek-flash\",\n name: \"DeepSeek V4.1 Flash\",\n provider: \"deepseek\",\n contextWindow: 1_000_000,\n maxOutputTokens: 384_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n // ── OpenRouter ─────────────────────────────────────────\n {\n // Qwen3.8 Max — Alibaba's flagship (live /endpoints, 2026-09-28): 1M\n // context, 131,072 output, text + image + video input, reasoning on.\n id: \"qwen/qwen3.8-max\",\n name: \"Qwen3.8 Max\",\n provider: \"openrouter\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n // Practical inline-payload cap, not an asserted provider maximum.\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n },\n // ── Hugging Face (Inference Providers router) ────────\n // One HF token (hf.co/settings/tokens, \"Make calls to Inference Providers\"\n // permission) routes to whichever hosted backend serves each open model;\n // billing follows each backend's rates on the HF account (small free tier).\n // Model ids are Hub repo paths, so they intentionally contain a slash — the\n // same shape local/ vLLM ids already use (`local/vllm/Qwen/Qwen3-32B`).\n // Live GET /v1/models (2026-10-08) is the source for hosts, context and\n // modalities. Qwen3 Coder 480B is retired here: only one tool-calling host\n // (Novita) still served it. We keep bare repo ids (no :auto suffix) so the\n // picker matches what GET /v1/models reports.\n {\n // Moonshot's open coding model. Served with tool calling by Novita and\n // DeepInfra at 262K context, with image input (video is a Moonshot-API-only\n // upload path). It always thinks, but the router has no documented effort\n // control for it, so we register it as non-thinking: we never send a\n // reasoning_effort that a host might reject. Output is capped at half the\n // window so the input keeps room.\n id: \"moonshotai/Kimi-K2.7-Code\",\n name: \"Kimi K2.7 Code\",\n provider: \"huggingface\",\n contextWindow: 262_144,\n maxOutputTokens: 131_072,\n supportsThinking: false,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"low\",\n },\n {\n // DeepSeek V4.1 Flash open weights: 1M context and image input on four\n // tool-calling hosts (Novita, Fireworks, Baseten, DeepInfra; $0.20–0.30 in /\n // $0.60–1.20 out per MTok). It is listed before gpt-oss so it becomes the\n // low-tier summary model, because its 1M window can take a full long\n // session. Thinking control over the router is unverified, so it is\n // registered as non-thinking. Output is a conservative 131K: hosts don't\n // publish the 384K first-party cap.\n id: \"deepseek-ai/DeepSeek-V4.1-Flash\",\n name: \"DeepSeek V4.1 Flash\",\n provider: \"huggingface\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: false,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"low\",\n },\n {\n // OpenAI's open-weight 120B MoE (5.1B active) — general-purpose, tool-calling\n // native, adjustable reasoning effort (low/medium/high, default medium) over\n // the router's Chat Completions API. Cheap enough to be the low-tier sibling\n // for summaries and fast sub-agents.\n id: \"openai/gpt-oss-120b\",\n name: \"GPT-OSS 120B\",\n provider: \"huggingface\",\n contextWindow: 131_072,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n },\n];\n\n/**\n * Models discovered at runtime rather than shipped in `MODELS` — today only\n * locally hosted ones (Ollama/LM Studio/llama.cpp/vLLM), whose ids and context\n * windows depend on what the user has installed. Kept in a separate map so\n * `MODELS` stays a static, reviewable table.\n */\nconst runtimeModels = new Map<string, ModelInfo>();\n\n/** Add (or replace) runtime-discovered models. Later registrations win by id. */\nexport function registerRuntimeModels(models: readonly ModelInfo[]): void {\n for (const model of models) runtimeModels.set(model.id, model);\n}\n\n/**\n * Remove runtime models matching `predicate` (all of them when omitted) — e.g.\n * every model from an endpoint the user just deleted.\n */\nexport function clearRuntimeModels(predicate?: (model: ModelInfo) => boolean): void {\n if (!predicate) {\n runtimeModels.clear();\n return;\n }\n for (const [id, model] of runtimeModels) {\n if (predicate(model)) runtimeModels.delete(id);\n }\n}\n\n/**\n * A provider's model list synced from its own endpoint (OpenRouter today).\n * While set it REPLACES that provider's static rows in listings; the static\n * rows still resolve by id so a session saved on one keeps working.\n */\nconst providerCatalogs = new Map<Provider, Map<string, ModelInfo>>();\n\n/**\n * Replace (or, with undefined, restore) a provider's listed catalog. Rows whose\n * id is already a static model of ANOTHER provider are dropped: getModel()\n * resolves by bare id, so a collision would route to the wrong provider.\n * Returns the rows actually installed.\n */\nexport function setProviderCatalog(\n provider: Provider,\n models: readonly ModelInfo[] | undefined,\n): ModelInfo[] {\n if (!models) {\n providerCatalogs.delete(provider);\n return [];\n }\n const installed = new Map<string, ModelInfo>();\n for (const model of models) {\n if (model.provider !== provider) continue;\n if (MODELS.some((m) => m.id === model.id && m.provider !== provider)) continue;\n installed.set(model.id, model);\n }\n providerCatalogs.set(provider, installed);\n return [...installed.values()];\n}\n\nexport function getProviderCatalog(provider: Provider): ModelInfo[] | undefined {\n const catalog = providerCatalogs.get(provider);\n return catalog ? [...catalog.values()] : undefined;\n}\n\n/** Static table plus everything discovered at runtime. */\nexport function getAllModels(): ModelInfo[] {\n const synced = [...providerCatalogs.values()].flatMap((c) => [...c.values()]);\n return [\n ...MODELS.filter((m) => !providerCatalogs.has(m.provider)),\n ...synced,\n ...runtimeModels.values(),\n ];\n}\n\nexport function getModel(id: string): ModelInfo | undefined {\n const fixed = MODELS.find((m) => m.id === id);\n if (fixed && !providerCatalogs.has(fixed.provider)) return fixed;\n return findSynced(id) ?? fixed ?? runtimeModels.get(id);\n}\n\nfunction findSynced(id: string): ModelInfo | undefined {\n for (const catalog of providerCatalogs.values()) {\n const hit = catalog.get(id);\n if (hit) return hit;\n }\n return undefined;\n}\n\nexport function getModelsForProvider(provider: Provider): ModelInfo[] {\n return getAllModels().filter((m) => m.provider === provider);\n}\n\n/**\n * Models offered in lists that don't filter by login (Telegram `/m`, the\n * agent's model list): everything except hidden providers' models.\n */\nexport function getListedModels(): ModelInfo[] {\n return getAllModels().filter((m) => !isHiddenProvider(m.provider));\n}\n\n/** True when running `modelId` spends real money (Claude fast mode, MiMo UltraSpeed). */\nexport function isPaidModel(modelId: string): boolean {\n return getModel(modelId)?.billing?.paid === true;\n}\n\n/**\n * The model to run instead of a paid tier: its `billing.standardModel`\n * (`claude-opus-5-5-fast` → `claude-opus-5-5`, `mimo-v2.6-pro-ultraspeed` →\n * `mimo-v2.6-pro`); every unpaid id is returned unchanged. A paid tier must be\n * picked on purpose for the one session the user is watching, so everything\n * that would otherwise carry it silently (saved defaults, restored sessions,\n * Ken following GG, sub-agents, goal workers) runs through this instead.\n *\n * Fails closed: when the standard model is missing, unregistered or itself\n * paid, it falls back to the provider's default, then the global default —\n * never to the paid id.\n */\nexport function withoutPaidTier(modelId: string): string {\n const model = getModel(modelId);\n if (model?.billing?.paid !== true) return modelId;\n const usable = (id: string | undefined): id is string =>\n id !== undefined && getModel(id) !== undefined && !isPaidModel(id);\n const standard = model.billing.standardModel;\n if (usable(standard)) return standard;\n const providerDefault = DEFAULT_MODEL_IDS[model.provider];\n if (usable(providerDefault)) return providerDefault;\n return FALLBACK_DEFAULT_MODEL_ID;\n}\n\n/**\n * Ordered auth-storage keys to try resolving credentials from for\n * `(provider, model)`, first match wins. Almost every model just uses its\n * provider id (one credential per provider). Models with `authStorageKeys`\n * set (currently only Xiaomi) can prefer one endpoint and fall back to\n * another — e.g. `mimo-v2.6-pro` prefers the Token Plan but falls back to API\n * Credits, while the API-only `mimo-v2.6-pro-ultraspeed` has no fallback.\n */\nexport function getAuthStorageKeys(provider: Provider, modelId: string): string[] {\n const model = getAllModels().find((m) => m.id === modelId && m.provider === provider);\n return model?.authStorageKeys ?? [provider];\n}\n\n/** The preferred (first) auth-storage key for `(provider, model)` — see `getAuthStorageKeys()`. */\nexport function getAuthStorageKey(provider: Provider, modelId: string): string {\n return getAuthStorageKeys(provider, modelId)[0]!;\n}\n\n/** Default video payload cap (bytes) when a video model doesn't declare one. */\nexport const DEFAULT_MAX_VIDEO_BYTES = 20 * 1024 * 1024;\n\n/**\n * Max video payload (bytes) the given model's transport accepts before the clip\n * must be compressed. Returns `undefined` for models without video support, so\n * callers can skip the native-video path entirely.\n */\nexport function getVideoByteLimit(modelId: string): number | undefined {\n const model = getModel(modelId);\n if (!model?.supportsVideo) return undefined;\n return model.maxVideoBytes ?? DEFAULT_MAX_VIDEO_BYTES;\n}\n\n/** Default model per hosted provider. Providers not listed default to Anthropic's. */\nconst DEFAULT_MODEL_IDS: Partial<Record<Provider, string>> = {\n xiaomi: \"mimo-v2.6-pro\",\n openai: \"gpt-6.1-sol\",\n gemini: \"gemini-3.1-flash-lite\",\n glm: \"glm-5.3\",\n moonshot: \"kimi-k3\",\n minimax: \"MiniMax-M3.1-Flash\",\n deepseek: \"deepseek-v4-pro\",\n huggingface: \"moonshotai/Kimi-K2.7-Code\",\n openrouter: \"qwen/qwen3.8-max\",\n sakana: \"fugu\",\n xai: \"grok-4.7\",\n};\nconst FALLBACK_DEFAULT_MODEL_ID = \"claude-sonnet-5-5\";\n\nfunction registeredModel(id: string): ModelInfo {\n const model = MODELS.find((m) => m.id === id);\n // A default that isn't registered is a bug in this file, caught by the tests.\n if (!model) throw new Error(`Default model \"${id}\" is not in the model registry`);\n return model;\n}\n\nexport function getDefaultModel(provider: Provider): ModelInfo {\n // Local models only exist once discovery has run, and there's no \"the\" local\n // model. Never throw here (callers rely on a ModelInfo): fall back to a\n // placeholder that carries the conservative defaults, so a caller asking\n // before a scan gets a coherent object instead of a crash.\n if (provider === \"local\") {\n return getModelsForProvider(\"local\")[0] ?? PLACEHOLDER_LOCAL_MODEL;\n }\n return registeredModel(DEFAULT_MODEL_IDS[provider] ?? FALLBACK_DEFAULT_MODEL_ID);\n}\n\n/**\n * Stand-in returned by `getDefaultModel(\"local\")` before any local model has\n * been discovered. Not registered, never selectable in the UI — it exists only\n * so the non-null contract of `getDefaultModel` holds.\n */\nconst PLACEHOLDER_LOCAL_MODEL: ModelInfo = {\n id: \"local/none/none\",\n name: \"No local model discovered\",\n provider: \"local\",\n contextWindow: 8192,\n maxOutputTokens: 2048,\n supportsThinking: false,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n};\n\nexport interface ContextWindowOptions {\n provider?: Provider;\n accountId?: string;\n}\n\nexport function usesOpenAICodexTransport(options?: ContextWindowOptions): boolean {\n return options?.provider === \"openai\" && Boolean(options.accountId);\n}\n\n/**\n * Codex applies a 10K-token history cap to every tool/function output. GG's\n * generic 30%-of-context allowance is far larger on 272K/372K Codex windows\n * and can turn a few reads into 100K+ fresh input tokens. Four characters per\n * token matches Codex's byte approximation and keeps this provider policy in\n * the shared model registry instead of an app-specific copy.\n */\nexport function getToolResultCharLimit(\n _modelId: string,\n options?: ContextWindowOptions,\n): number | undefined {\n return usesOpenAICodexTransport(options) ? 10_000 * 4 : undefined;\n}\n\nexport function getContextWindow(modelId: string, options?: ContextWindowOptions): number {\n const model = getModel(modelId);\n if (!model) return 200_000;\n if (usesOpenAICodexTransport(options) && model.codexContextWindow) {\n return model.codexContextWindow;\n }\n return model.contextWindow;\n}\n\n/**\n * The strongest thinking level the given model genuinely uses. Falls back to\n * `\"high\"` for unknown models since every provider we ship accepts it.\n */\nexport function getMaxThinkingLevel(modelId: string): ThinkingLevel {\n return getModel(modelId)?.maxThinkingLevel ?? \"high\";\n}\n\n/**\n * The thinking level a fresh session starts at. Identical to\n * {@link getMaxThinkingLevel} except where the provider declares a lower\n * default effort server-side — Kimi K3's Kimi Code plan endpoint declares\n * `default_effort: \"high\"` in its /models think_efforts (the public Moonshot\n * API declares \"max\").\n * Pass the active credential's baseUrl so the endpoint-aware default resolves;\n * matching it keeps plan-usage burn identical to the official CLI (users can\n * still toggle up to max).\n */\nexport function getDefaultThinkingLevel(\n modelId: string,\n options?: { baseUrl?: string },\n): ThinkingLevel {\n const model = getModel(modelId);\n if (model?.id === \"kimi-k3\" && isKimiCodingEndpoint(options?.baseUrl)) return \"high\";\n return model?.defaultThinkingLevel ?? model?.maxThinkingLevel ?? \"high\";\n}\n\n/**\n * The cost tier each provider's compaction summaries run on. Chosen by TAG,\n * never by model id, so adding, renaming or removing models needs no change\n * here: the first registered model of that provider carrying the tag wins.\n * Providers not listed summarize on the active model.\n * - Anthropic: \"medium\" (the Sonnet line) — Haiku's shallower summaries\n * cost more in lost memory than they save.\n * - OpenAI, GLM, DeepSeek, Hugging Face: \"low\" (Luna and the Flash models).\n */\nconst SUMMARY_COST_TIER: Readonly<Partial<Record<Provider, ModelInfo[\"costTier\"]>>> = {\n anthropic: \"medium\",\n openai: \"low\",\n glm: \"low\",\n deepseek: \"low\",\n huggingface: \"low\",\n};\n\n/**\n * Get the model to use for compaction summarization — the ONLY place a\n * cheaper model is used. Everything else (sub-agents, reviewers, judges)\n * runs on the parent's model.\n *\n * Picks the provider's first model tagged with its summary tier\n * ({@link SUMMARY_COST_TIER}). When none is registered — the tier was removed,\n * or the provider has no cheap sibling — it summarizes on the current model.\n * Never throws. The caller still retries on the current model when the\n * provider rejects the chosen one at runtime (retired upstream, or not on the\n * user's plan), since the registry cannot know that.\n */\nexport function getSummaryModel(provider: Provider, currentModelId: string): ModelInfo {\n const tier = SUMMARY_COST_TIER[provider];\n // Models with billing (fast modes, UltraSpeed) cost more than the standard\n // rate — never pick one for summaries.\n const tagged = tier\n ? getModelsForProvider(provider).find((m) => m.costTier === tier && m.billing === undefined)\n : undefined;\n return tagged ?? getModel(currentModelId) ?? getDefaultModel(provider);\n}\n","import { GG_CODER_USER_AGENT } from \"@kenkaiiii/gg-ai\";\n\n/**\n * Kimi Code plan keys come from the Kimi Code Console and work on the host\n * that issued them: `api.kimi.com` (kimi.com accounts) or `api.kimi.ai`\n * (kimi.ai accounts). The other host rejects the key as invalid.\n */\nexport const KIMI_CODE_DEFAULT_BASE_URL = \"https://api.kimi.com/coding/v1\";\nexport const KIMI_CODE_OVERSEAS_BASE_URL = \"https://api.kimi.ai/coding/v1\";\n\nconst KIMI_CODE_BASE_URLS = [KIMI_CODE_DEFAULT_BASE_URL, KIMI_CODE_OVERSEAS_BASE_URL] as const;\n\ntype ProbeResult = \"authorized\" | \"unauthorized\" | \"inconclusive\";\n\nasync function probeHost(\n baseUrl: string,\n apiKey: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<ProbeResult> {\n try {\n const res = await fetchImpl(`${baseUrl}/models`, {\n headers: { Authorization: `Bearer ${apiKey}`, \"User-Agent\": GG_CODER_USER_AGENT },\n signal: AbortSignal.timeout(timeoutMs),\n });\n if (res.ok) return \"authorized\";\n if (res.status === 401 || res.status === 403) return \"unauthorized\";\n return \"inconclusive\";\n } catch {\n return \"inconclusive\";\n }\n}\n\n/**\n * The Kimi Code base URL for the host that issued `apiKey`, found by asking\n * both. Undefined when neither gives a clear answer (offline, timeouts);\n * callers then use {@link KIMI_CODE_DEFAULT_BASE_URL} and may ask again later.\n */\nexport async function detectKimiCodeBaseUrl(\n apiKey: string,\n options: { fetch?: typeof fetch; timeoutMs?: number } = {},\n): Promise<string | undefined> {\n const fetchImpl = options.fetch ?? fetch;\n const timeoutMs = options.timeoutMs ?? 5000;\n const results = await Promise.all(\n KIMI_CODE_BASE_URLS.map((url) => probeHost(url, apiKey, fetchImpl, timeoutMs)),\n );\n const index = results.indexOf(\"authorized\");\n return index === -1 ? undefined : KIMI_CODE_BASE_URLS[index];\n}\n\n/** True if `baseUrl` is a Kimi Code plan endpoint (either host). */\nexport function isKimiCodingEndpoint(baseUrl: string | undefined): boolean {\n if (typeof baseUrl !== \"string\" || baseUrl.length === 0) return false;\n return /^https:\\/\\/api\\.kimi\\.(com|ai)\\/coding(\\/|$)/i.test(baseUrl);\n}\n","import fs from \"node:fs/promises\";\nimport { readFileSync } from \"node:fs\";\nimport crypto from \"node:crypto\";\nimport { getAppPaths } from \"./paths.js\";\nimport type { OAuthCredentials } from \"./oauth/types.js\";\nimport {\n CLAUDE_CODE_AUTH_KEY,\n CLAUDE_CODE_AUTH_MARKER,\n isClaudeCodeAuthMarker,\n isLegacyClaudeOAuth,\n} from \"./claude-code/runtime.js\";\nimport { refreshChatGPTToken } from \"./oauth/openai.js\";\nimport { withFileLock } from \"./file-lock.js\";\nimport { log } from \"./logger.js\";\nimport { isHiddenProvider } from \"./hidden-providers.js\";\nimport { LOCAL_NO_API_KEY } from \"@kenkaiiii/gg-ai\";\nimport { detectMiniMaxBaseUrl } from \"./minimax-region.js\";\nimport { detectXiaomiTokenPlanBaseUrl } from \"./xiaomi-region.js\";\nimport { KIMI_CODE_DEFAULT_BASE_URL, detectKimiCodeBaseUrl } from \"./kimi-region.js\";\n\ntype AuthData = Record<string, OAuthCredentials>;\n\n/**\n * Storage key for the ChatGPT plan sign-in (Continue with ChatGPT). Kept\n * distinct from the `openai` entry, which holds an OpenAI API key, so a user can\n * hold both: the plan serves first and the key covers while its usage is out.\n */\nexport const OPENAI_CHATGPT_KEY = \"openai-chatgpt\";\n\n/**\n * Storage key for a Kimi Code plan key (Kimi Code Console), served by\n * `api.kimi.com/coding/v1` or `api.kimi.ai/coding/v1`. Kept distinct from the\n * `moonshot` entry, which holds a pay-per-token Moonshot platform key.\n */\nexport const KIMI_CODE_KEY = \"kimi-code\";\n\n/**\n * A sign-in GG Coder no longer uses, because it posed as another vendor's app\n * (Codex CLI, Kimi Code CLI, Grok CLI, Gemini CLI) or its token stopped\n * working. The entry is never served; the user is asked once to reconnect.\n */\nexport interface RetiredSignIn {\n /** Logical provider the credential belonged to. */\n provider: string;\n /** auth.json key holding the retired credential. */\n storageKey: string;\n /** What to tell the user, without CLI/app-specific instructions. */\n message: string;\n}\n\nexport const RETIRED_SIGN_INS: readonly RetiredSignIn[] = [\n {\n provider: \"anthropic\",\n storageKey: \"anthropic\",\n message:\n \"Your old Claude sign-in no longer works. Sign in with Claude again to keep using your subscription.\",\n },\n {\n provider: \"openai\",\n storageKey: \"openai\",\n message:\n \"Your old ChatGPT sign-in no longer works. Continue with ChatGPT again, or add an OpenAI API key.\",\n },\n {\n provider: \"moonshot\",\n storageKey: \"moonshot-oauth\",\n message:\n \"Kimi sign-in was removed. Paste a Kimi Code plan key from the Kimi Code Console, or a Moonshot API key.\",\n },\n {\n provider: \"xai\",\n storageKey: \"xai-oauth\",\n message:\n \"Grok sign-in was removed: xAI offers SuperGrok and X Premium usage only in apps it partners with. Add an xAI API key instead.\",\n },\n {\n provider: \"gemini\",\n storageKey: \"gemini\",\n message: \"Google sign-in was removed. Add a Gemini API key from Google AI Studio instead.\",\n },\n];\n\n/** True when `value`, stored under `retired.storageKey`, is that retired sign-in. */\nfunction isRetiredEntry(retired: RetiredSignIn, value: OAuthCredentials | undefined): boolean {\n if (!value) return false;\n switch (retired.storageKey) {\n case \"anthropic\":\n return isLegacyClaudeOAuth(value);\n // These slots now hold API keys; only a refreshable sign-in token is retired.\n case \"openai\":\n case \"gemini\":\n return Boolean(value.refreshToken) || Boolean(value.accountId) || Boolean(value.projectId);\n default:\n return true;\n }\n}\n\nfunction isRetiredStorageKey(data: AuthData, storageKey: string): boolean {\n return RETIRED_SIGN_INS.some(\n (r) => r.storageKey === storageKey && isRetiredEntry(r, data[storageKey]),\n );\n}\n\n/** The usable credential stored under `storageKey`; a retired sign-in reads as absent. */\nfunction usableEntry(data: AuthData, storageKey: string): OAuthCredentials | undefined {\n return isRetiredStorageKey(data, storageKey) ? undefined : data[storageKey];\n}\n\n/** Logical provider for a storage key (`kimi-code` → `moonshot`). */\nexport function storageKeyProvider(storageKey: string): string {\n if (storageKey === OPENAI_CHATGPT_KEY) return \"openai\";\n if (storageKey === KIMI_CODE_KEY) return \"moonshot\";\n if (storageKey === XIAOMI_CREDITS_KEY) return \"xiaomi\";\n if (storageKey === GEMINI_API_KEY) return \"gemini\";\n return storageKey;\n}\n\n/**\n * API-key storage keys for a provider whose keys split into a plan key and a\n * pay-per-token key, plan first. Single-key providers yield just their own key.\n */\nexport function apiKeyStorageKeys(provider: string): string[] {\n if (provider === \"moonshot\") return [KIMI_CODE_KEY, \"moonshot\"];\n if (provider === \"xiaomi\") return [\"xiaomi\", XIAOMI_CREDITS_KEY];\n if (provider === \"gemini\") return [GEMINI_API_KEY];\n return [provider];\n}\n\n/**\n * A provider that can hold two credentials at once: a refreshable plan sign-in\n * and a static API key. One policy governs all of them — see\n * {@link DUAL_AUTH_PROVIDERS} — so adding a provider here is enough to give it\n * OAuth-first resolution, usage-exhaustion fallback, per-method logout and the\n * matching UI affordances.\n */\nexport interface DualAuthProvider {\n /** Logical provider id, which is also the API-key storage key. */\n provider: string;\n /** Storage key holding the OAuth credential. */\n oauthKey: string;\n /** Human label for the OAuth credential (log/UI wording). */\n oauthLabel: string;\n /** Human label for the API-key credential (log/UI wording). */\n apiKeyLabel: string;\n /** What the user should do to restore OAuth after it went invalid. */\n restoreHint: string;\n}\n\n/**\n * Providers offering subscription OAuth *and* an API key. The resolution policy\n * is identical for every entry:\n *\n * 1. OAuth wins whenever it is configured — it is the cheaper, subscription\n * credential, and the one the user opted into most recently.\n * 2. If OAuth's usage window is exhausted (marked by the agent loop when the\n * subscription endpoint rejects with a usage/quota stop) the API key serves\n * until the mark lapses — but only when a key is actually configured, so\n * OAuth-only users still see the real usage-limit error and its reset time.\n * 3. If OAuth's refresh token is dead, fall back to the API key and log loudly:\n * it is a billing switch (subscription → metered), not a detail.\n */\nconst DUAL_AUTH_PROVIDERS: readonly DualAuthProvider[] = [\n {\n provider: \"openai\",\n oauthKey: OPENAI_CHATGPT_KEY,\n oauthLabel: \"ChatGPT plan\",\n apiKeyLabel: \"OpenAI API key\",\n restoreHint:\n 'Run \"ggcoder login\" and choose Continue with ChatGPT to use your ChatGPT plan again.',\n },\n];\n\n/** Dual-auth policy for a logical provider, or undefined if it has just one method. */\nexport function dualAuthProvider(provider: string): DualAuthProvider | undefined {\n return DUAL_AUTH_PROVIDERS.find((entry) => entry.provider === provider);\n}\n\n/** Dual-auth policy keyed by the OAuth storage key (the reverse lookup). */\nexport function dualAuthProviderByOAuthKey(storageKey: string): DualAuthProvider | undefined {\n return DUAL_AUTH_PROVIDERS.find((entry) => entry.oauthKey === storageKey);\n}\n\n/** The OAuth storage key for a dual-auth provider, if it has one. */\nexport function oauthStorageKey(provider: string): string | undefined {\n return dualAuthProvider(provider)?.oauthKey;\n}\n\n/**\n * Both storage keys a dual-auth provider may hold, in resolution order\n * (OAuth first). Single-method providers yield just their own key.\n */\nexport function providerStorageKeys(provider: string): string[] {\n const dual = dualAuthProvider(provider);\n return dual ? [dual.oauthKey, dual.provider] : [provider];\n}\n\n/**\n * Storage key for the Xiaomi API Credits credential (`https://api.xiaomimimo.com/v1`).\n * Kept distinct from the `xiaomi` Token Plan entry (`token-plan-sgp.xiaomimimo.com`)\n * so a user can configure BOTH — `mimo-v2.6-pro-ultraspeed` is API Credits-only,\n * while `mimo-v2.6-pro`/`mimo-v2.6-flash` prefer the Token Plan but fall back to API\n * Credits when only that's configured. Which key(s) a model tries, and in what\n * order, is decided per-model via `getAuthStorageKeys()` in model-registry.ts.\n */\nexport const XIAOMI_CREDITS_KEY = \"xiaomi-credits\";\n\n/**\n * Storage key for a Gemini API key (Google AI Studio, billed per token), served\n * by the public Gemini API at {@link GEMINI_API_KEY_BASE_URL}. Kept distinct from\n * the `gemini` entry, which held the retired Google sign-in.\n */\nexport const GEMINI_API_KEY = \"gemini-api\";\n\n/** Public Gemini API (AI Studio key). The transport routes on this base URL. */\nexport const GEMINI_API_KEY_BASE_URL = \"https://generativelanguage.googleapis.com/v1beta\";\n\n/**\n * Prefix for local-endpoint credentials (`local:ollama`, `local:lmstudio`, …).\n * One entry per endpoint, each carrying that endpoint's `baseUrl`, so the\n * existing `resolveCredentials({ storageKeys })` override resolves a local model\n * with no new code path. Kept in sync with `localAuthStorageKey()` in\n * local-models.ts.\n */\nexport const LOCAL_AUTH_KEY_PREFIX = \"local:\";\n\n/** A century — local endpoints have no token lifetime, so never expire them. */\nconst LOCAL_CREDENTIAL_LIFETIME_MS = 100 * 365 * 24 * 60 * 60 * 1000;\n\n/**\n * The credential entry whose baseUrl applies right now. For a dual-auth provider\n * this mirrors resolveCredentials' preference: the OAuth entry, sidelined to the\n * API key only while its usage window is exhausted and a key is configured.\n * Shared by {@link AuthStorage.getStoredBaseUrl} and {@link readStoredBaseUrlSync}\n * so both paths agree on the active endpoint.\n */\nfunction activeBaseUrlEntry(data: AuthData, provider: string): OAuthCredentials | undefined {\n const dual = dualAuthProvider(provider);\n if (dual) {\n const oauth = data[dual.oauthKey];\n const apiKey = usableEntry(data, dual.provider);\n if (oauth) {\n const exhaustedUntil = oauth.usageExhaustedUntil ?? 0;\n if (Date.now() < exhaustedUntil && apiKey) return apiKey;\n return oauth;\n }\n return apiKey;\n }\n const key = apiKeyStorageKeys(provider).find((k) => usableEntry(data, k));\n const entry = key ? data[key] : undefined;\n // A Kimi Code key not yet probed still goes to the plan endpoint.\n if (key === KIMI_CODE_KEY && entry && !entry.baseUrl) {\n return { ...entry, baseUrl: KIMI_CODE_DEFAULT_BASE_URL };\n }\n return entry;\n}\n\n// Shared across AuthStorage instances (one per app window). An inconclusive\n// answer (offline) is retried after a while instead of on every request.\nconst MINIMAX_REGION_RETRY_MS = 5 * 60 * 1000;\nconst miniMaxRegionProbes = new Map<string, { at: number; result: Promise<string | undefined> }>();\n\nfunction cachedMiniMaxRegion(apiKey: string): Promise<string | undefined> {\n const cached = miniMaxRegionProbes.get(apiKey);\n if (cached && Date.now() - cached.at < MINIMAX_REGION_RETRY_MS) return cached.result;\n const result = detectMiniMaxBaseUrl(apiKey).catch(() => undefined);\n miniMaxRegionProbes.set(apiKey, { at: Date.now(), result });\n return result;\n}\n\nconst kimiRegionProbes = new Map<string, { at: number; result: Promise<string | undefined> }>();\n\nfunction cachedKimiRegion(apiKey: string): Promise<string | undefined> {\n const cached = kimiRegionProbes.get(apiKey);\n if (cached && Date.now() - cached.at < MINIMAX_REGION_RETRY_MS) return cached.result;\n const result = detectKimiCodeBaseUrl(apiKey).catch(() => undefined);\n kimiRegionProbes.set(apiKey, { at: Date.now(), result });\n return result;\n}\n\n/**\n * Synchronous baseUrl read straight from the auth file, for boot paths that\n * need the active endpoint before an AuthStorage instance exists (e.g. the\n * CLI's sync main()). Missing/corrupt files yield undefined — callers treat\n * that as the provider's public endpoint. Read-only: safe without the file\n * lock (a torn mid-write read just falls back to undefined).\n */\nexport function readStoredBaseUrlSync(authFile: string, provider: string): string | undefined {\n try {\n const data = JSON.parse(readFileSync(authFile, \"utf-8\")) as AuthData;\n return activeBaseUrlEntry(data, provider)?.baseUrl;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Proactive-refresh threshold: refresh when the token is within\n * `max(MIN_REFRESH_THRESHOLD_MS, lifetime * REFRESH_THRESHOLD_RATIO)` of expiry.\n * A flat skew rides short-lived tokens to the boundary and 401s; scaling by\n * lifetime refreshes a 1-hour ChatGPT token at its halfway point.\n */\nconst MIN_REFRESH_THRESHOLD_MS = 300_000;\nconst REFRESH_THRESHOLD_RATIO = 0.5;\n\nfunction refreshThresholdMs(creds: OAuthCredentials): number {\n const lifetimeMs = (creds.expiresIn ?? 0) * 1000;\n return Math.max(MIN_REFRESH_THRESHOLD_MS, lifetimeMs * REFRESH_THRESHOLD_RATIO);\n}\n\n/**\n * How long a usage-exhausted mark holds when the provider gave no reset time.\n * Short on purpose: after it lapses we try the preferred (OAuth) credential\n * again — if the window is still out, the caller re-marks and falls back again,\n * costing one rejected request per window instead of sticking to the fallback\n * key forever.\n */\nconst USAGE_EXHAUSTED_DEFAULT_MS = 15 * 60 * 1000;\n\n/**\n * Storage keys whose credential refreshes. Everything else is a static API key:\n * Claude subscriptions run through Claude Code, and every other plan is a key.\n */\nconst REFRESHABLE_STORAGE_KEYS = new Set([OPENAI_CHATGPT_KEY]);\n\nexport class AuthStorage {\n private data: AuthData = {};\n private filePath: string;\n private loaded = false;\n /**\n * inode+mtime+size of the cached file (`size: -1` = no file). The inode\n * detects atomic replacements with equal size within one filesystem clock tick.\n * auth.json is shared: the desktop app writes API keys and disconnects\n * NATIVELY (so they work with no daemon running), and every window/process has\n * its own AuthStorage. A load-once cache therefore goes stale — the sidecar\n * would keep listing models for a provider just disconnected, and hide the\n * ones just connected, until the daemon restarted.\n */\n private snapshotMtimeMs = 0;\n private snapshotSize = -1;\n private snapshotIno = 0;\n /** Per-provider lock to serialize concurrent refresh calls. */\n private refreshLocks = new Map<string, Promise<OAuthCredentials>>();\n\n private readonly detectMiniMaxRegion: (apiKey: string) => Promise<string | undefined>;\n private readonly detectXiaomiCluster: (apiKey: string) => Promise<string | undefined>;\n private readonly detectKimiRegion: (apiKey: string) => Promise<string | undefined>;\n\n constructor(\n filePath?: string,\n options?: {\n detectMiniMaxBaseUrl?: (apiKey: string) => Promise<string | undefined>;\n detectXiaomiTokenPlanBaseUrl?: (apiKey: string) => Promise<string | undefined>;\n detectKimiCodeBaseUrl?: (apiKey: string) => Promise<string | undefined>;\n },\n ) {\n this.filePath = filePath ?? getAppPaths().authFile;\n this.detectMiniMaxRegion = options?.detectMiniMaxBaseUrl ?? cachedMiniMaxRegion;\n this.detectXiaomiCluster =\n options?.detectXiaomiTokenPlanBaseUrl ?? detectXiaomiTokenPlanBaseUrl;\n this.detectKimiRegion = options?.detectKimiCodeBaseUrl ?? cachedKimiRegion;\n }\n\n /** Path to the on-disk auth file. Useful for status output. */\n get path(): string {\n return this.filePath;\n }\n\n /** List provider keys with stored credentials. */\n async listProviders(): Promise<string[]> {\n await this.ensureFresh();\n return Object.keys(this.data);\n }\n\n /** True if credentials exist for `provider`. */\n async hasCredentials(provider: string): Promise<boolean> {\n await this.ensureFresh();\n return Boolean(this.data[provider]);\n }\n\n /**\n * First key in `keys` (in order) that has stored credentials, or `undefined`\n * if none do. Mirrors the first-match logic `resolveCredentials({ storageKeys })`\n * uses internally — callers that need to know WHICH credential will actually\n * be used (e.g. to clear the right one after a 401) call this directly\n * instead of re-deriving the same order.\n */\n async pickStorageKey(keys: string[]): Promise<string | undefined> {\n await this.ensureFresh();\n return keys.find((key) => Boolean(this.data[key]));\n }\n\n /**\n * True if the user has any usable auth for the logical provider: the plan\n * sign-in or any of its API keys. A retired sign-in doesn't count.\n */\n async hasProviderAuth(provider: string): Promise<boolean> {\n // A hidden provider counts as not connected, so pickers and startup skip\n // it even when old credentials are still on disk.\n if (isHiddenProvider(provider)) return false;\n await this.ensureFresh();\n const dual = dualAuthProvider(provider);\n if (dual) {\n return Boolean(this.data[dual.oauthKey] || usableEntry(this.data, dual.provider));\n }\n if (provider === \"anthropic\") {\n return (\n (Boolean(this.data.anthropic) && !isLegacyClaudeOAuth(this.data.anthropic)) ||\n isClaudeCodeAuthMarker(this.data[CLAUDE_CODE_AUTH_KEY])\n );\n }\n // `local` has no single credential — any configured endpoint counts.\n if (provider === \"local\") {\n return Object.keys(this.data).some((key) => key.startsWith(LOCAL_AUTH_KEY_PREFIX));\n }\n return apiKeyStorageKeys(provider).some((key) => Boolean(usableEntry(this.data, key)));\n }\n\n /** True if any of `provider`'s API keys is stored. A retired sign-in in a key slot doesn't count. */\n async hasApiKey(provider: string): Promise<boolean> {\n await this.ensureFresh();\n if (provider === \"anthropic\" && isLegacyClaudeOAuth(this.data.anthropic)) return false;\n return apiKeyStorageKeys(provider).some((key) => Boolean(usableEntry(this.data, key)));\n }\n\n /**\n * `provider`'s first stored API key, never its plan sign-in. For calls that\n * only an API key may make (OpenAI's Images API isn't on the ChatGPT plan).\n */\n async resolveApiKey(provider: string): Promise<OAuthCredentials> {\n await this.ensureFresh();\n for (const key of apiKeyStorageKeys(provider)) {\n const creds = usableEntry(this.data, key);\n if (creds && !(provider === \"anthropic\" && isLegacyClaudeOAuth(creds))) return creds;\n }\n throw new NotLoggedInError(provider);\n }\n\n /** True when the user signed in with Claude through Claude Code (marker only, no token). */\n async hasClaudeCodeLogin(): Promise<boolean> {\n await this.ensureFresh();\n return isClaudeCodeAuthMarker(this.data[CLAUDE_CODE_AUTH_KEY]);\n }\n\n /** Record a Claude Code sign-in. Claude Code keeps the token; auth.json gets only the marker. */\n async setClaudeCodeLogin(): Promise<void> {\n await this.mutateLatest((latest) => {\n latest[CLAUDE_CODE_AUTH_KEY] = { ...CLAUDE_CODE_AUTH_MARKER } as unknown as OAuthCredentials;\n if (isLegacyClaudeOAuth(latest.anthropic)) delete latest.anthropic;\n });\n }\n\n /** Retired sign-ins still on disk, so the UI can ask the user to reconnect. */\n async legacySignIns(): Promise<RetiredSignIn[]> {\n await this.ensureFresh();\n return RETIRED_SIGN_INS.filter((r) => isRetiredEntry(r, this.data[r.storageKey]));\n }\n\n /**\n * Remove `provider`'s retired sign-in, if any. True when one was removed, so\n * the caller can tell the user once to reconnect.\n */\n async takeLegacySignIn(provider: string): Promise<boolean> {\n const retired = RETIRED_SIGN_INS.find((r) => r.provider === provider);\n if (!retired) return false;\n await this.ensureFresh();\n if (!isRetiredEntry(retired, this.data[retired.storageKey])) return false;\n let removed = false;\n await this.mutateLatest((latest) => {\n if (!isRetiredEntry(retired, latest[retired.storageKey])) return;\n delete latest[retired.storageKey];\n removed = true;\n });\n return removed;\n }\n\n async clearClaudeCodeLogin(): Promise<void> {\n await this.clearCredentials(CLAUDE_CODE_AUTH_KEY);\n }\n\n /** Endpoint ids that currently have a `local:<id>` credential stored. */\n async listLocalEndpointIds(): Promise<string[]> {\n await this.ensureLoaded();\n return Object.keys(this.data)\n .filter((key) => key.startsWith(LOCAL_AUTH_KEY_PREFIX))\n .map((key) => key.slice(LOCAL_AUTH_KEY_PREFIX.length));\n }\n\n /**\n * Write (or refresh) the credential for one local endpoint. The `baseUrl` is\n * what `effectiveBaseUrl` later picks up, and `accessToken` is the endpoint's\n * key — a placeholder for the servers that ignore it.\n */\n async setLocalEndpoint(endpointId: string, baseUrl: string, apiKey?: string): Promise<void> {\n await this.setCredentials(`${LOCAL_AUTH_KEY_PREFIX}${endpointId}`, {\n accessToken: apiKey && apiKey.length > 0 ? apiKey : LOCAL_NO_API_KEY,\n refreshToken: \"\",\n expiresAt: Date.now() + LOCAL_CREDENTIAL_LIFETIME_MS,\n baseUrl,\n });\n }\n\n /** Remove one local endpoint's credential. No-op when it isn't stored. */\n async removeLocalEndpoint(endpointId: string): Promise<void> {\n await this.clearCredentials(`${LOCAL_AUTH_KEY_PREFIX}${endpointId}`);\n }\n\n /**\n * True if the active credential for `provider` is a static API key with no\n * refresh mechanism. For a dual-auth provider this is only true when its OAuth\n * credential is absent or sidelined (a live OAuth credential is refreshable).\n */\n async isStaticApiKey(provider: string): Promise<boolean> {\n await this.ensureFresh();\n const dual = dualAuthProvider(provider);\n const oauthCreds = dual ? this.data[dual.oauthKey] : undefined;\n if (dual && oauthCreds) {\n // A usage-exhausted plan sign-in with an API key configured means the\n // API key is what actually resolves right now — treat it as the static\n // key it is (so a 401 clears the key instead of pointlessly\n // force-refreshing the sidelined token).\n const exhaustedUntil = oauthCreds.usageExhaustedUntil ?? 0;\n const apiKeyActive =\n Date.now() < exhaustedUntil && Boolean(usableEntry(this.data, dual.provider));\n if (!apiKeyActive) return false;\n }\n return !REFRESHABLE_STORAGE_KEYS.has(provider);\n }\n\n /**\n * The base URL on the credential that is active right now, if any.\n * Synchronous — call only after load()/resolveCredentials() populated the\n * snapshot. For a dual-auth provider this is the plan endpoint whenever the\n * sign-in is the one resolveCredentials would serve (i.e. not currently\n * usage-exhausted with an API key configured); for a plan/pay-per-token key\n * split it is the plan key's endpoint when one is stored.\n */\n getStoredBaseUrl(provider: string): string | undefined {\n return activeBaseUrlEntry(this.data, provider)?.baseUrl;\n }\n\n async load(): Promise<void> {\n // A reload (the file changed under us) is routine — log it once at first\n // load so ~/.gg/debug.log doesn't fill with identical lines on every read.\n const first = !this.loaded;\n await withFileLock(this.filePath, async () => {\n try {\n const content = await fs.readFile(this.filePath, \"utf-8\");\n this.data = JSON.parse(content) as AuthData;\n if (first) {\n log(\"INFO\", \"auth\", `Loaded credentials from ${this.filePath}`, {\n providers: Object.keys(this.data).join(\",\") || \"(none)\",\n });\n }\n } catch (err) {\n this.data = {};\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOENT\") {\n if (first) log(\"INFO\", \"auth\", `No auth file found at ${this.filePath} (first run)`);\n } else {\n log(\n \"ERROR\",\n \"auth\",\n `Failed to load auth file: ${err instanceof Error ? err.message : String(err)}`,\n { path: this.filePath, code: code ?? \"unknown\" },\n );\n }\n }\n });\n this.loaded = true;\n await this.rememberSnapshot();\n }\n\n private async ensureLoaded(): Promise<void> {\n if (!this.loaded) await this.load();\n }\n\n /**\n * Like {@link ensureLoaded}, but re-reads when the file changed since this\n * snapshot — a cheap stat, not a re-parse. Used by the \"what is connected?\"\n * readers, which must reflect writes made by another window, the CLI, or the\n * desktop app's native (daemon-free) API-key and disconnect paths.\n *\n * Deliberately NOT used by {@link resolveCredentials}: that path compares the\n * caller's snapshot against the latest file to detect a concurrent re-login,\n * and silently refreshing this instance's view first would destroy the\n * evidence that the token it just had rejected has already been replaced.\n */\n private async ensureFresh(): Promise<void> {\n if (!this.loaded) {\n await this.load();\n return;\n }\n let changed: boolean;\n try {\n const stat = await fs.stat(this.filePath);\n changed =\n stat.ino !== this.snapshotIno ||\n stat.mtimeMs !== this.snapshotMtimeMs ||\n stat.size !== this.snapshotSize;\n } catch {\n // File is gone (logged out everywhere) — reload only if we still hold one.\n changed = this.snapshotSize !== -1;\n }\n if (changed) await this.load();\n }\n\n /**\n * Record the file identity behind the current snapshot, so {@link ensureLoaded}\n * can tell \"someone else wrote\" from \"this is our own write\".\n */\n private async rememberSnapshot(): Promise<void> {\n try {\n const stat = await fs.stat(this.filePath);\n this.snapshotMtimeMs = stat.mtimeMs;\n this.snapshotSize = stat.size;\n this.snapshotIno = stat.ino;\n } catch {\n this.snapshotMtimeMs = 0;\n this.snapshotSize = -1;\n this.snapshotIno = 0;\n }\n }\n\n /**\n * Apply one provider-scoped mutation to the latest on-disk snapshot.\n * AuthStorage instances live in every app session/process, so writing this\n * instance's cached snapshot can erase credentials another instance just\n * added. The file lock only serializes writers; the re-read prevents stale\n * full-file overwrites.\n */\n private async mutateLatest(mutator: (data: AuthData) => void): Promise<void> {\n await this.ensureLoaded();\n await withFileLock(this.filePath, async () => {\n const latest = await readAuthData(this.filePath);\n mutator(latest);\n await atomicWriteFile(this.filePath, JSON.stringify(latest, null, 2));\n this.data = latest;\n });\n await this.rememberSnapshot();\n }\n\n private async reloadLatest(): Promise<void> {\n await withFileLock(this.filePath, async () => {\n this.data = await readAuthData(this.filePath);\n });\n await this.rememberSnapshot();\n }\n\n async getCredentials(provider: string): Promise<OAuthCredentials | undefined> {\n await this.ensureFresh();\n return this.data[provider];\n }\n\n /**\n * A MiniMax key saved without an endpoint (every key, until its first use)\n * gets the endpoint of the region that issued it, saved so the check runs\n * once. Without this a Mainland China key went to the Global API, got a 401,\n * and was deleted as invalid.\n */\n private async withMiniMaxRegion(creds: OAuthCredentials): Promise<OAuthCredentials> {\n const baseUrl = await this.detectMiniMaxRegion(creds.accessToken);\n if (!baseUrl) return creds;\n log(\"INFO\", \"auth\", `MiniMax key belongs to ${baseUrl} — saved for future requests`);\n await this.mutateLatest((latest) => {\n const current = latest[\"minimax\"];\n // Only annotate the key that was probed; a key replaced meanwhile is re-checked.\n if (current?.accessToken === creds.accessToken && !current.baseUrl) {\n current.baseUrl = baseUrl;\n }\n });\n return { ...creds, baseUrl };\n }\n\n /**\n * A Kimi Code key saved without an endpoint gets the host that issued it\n * (`api.kimi.com` or `api.kimi.ai`), saved so the check runs once.\n */\n private async withKimiRegion(creds: OAuthCredentials): Promise<OAuthCredentials> {\n const baseUrl = await this.detectKimiRegion(creds.accessToken);\n if (!baseUrl) return { ...creds, baseUrl: KIMI_CODE_DEFAULT_BASE_URL };\n log(\"INFO\", \"auth\", `Kimi Code key belongs to ${baseUrl} — saved for future requests`);\n await this.mutateLatest((latest) => {\n const current = latest[KIMI_CODE_KEY];\n if (current?.accessToken === creds.accessToken && !current.baseUrl) {\n current.baseUrl = baseUrl;\n }\n });\n return { ...creds, baseUrl };\n }\n\n /**\n * Store `creds` under `storageKey`. Connecting a provider again also removes\n * its retired sign-in, which is what the reconnect notice asked for.\n */\n async setCredentials(storageKey: string, creds: OAuthCredentials): Promise<void> {\n const provider = storageKeyProvider(storageKey);\n await this.mutateLatest((latest) => {\n for (const retired of RETIRED_SIGN_INS) {\n if (retired.provider !== provider || retired.storageKey === storageKey) continue;\n if (isRetiredEntry(retired, latest[retired.storageKey])) delete latest[retired.storageKey];\n }\n latest[storageKey] = creds;\n });\n }\n\n /**\n * After a 401, move a regional key to the cluster that issued it instead of\n * treating it as invalid. Only the Xiaomi Token Plan is regional: every key\n * used to go to Singapore, so Europe and China keys were rejected and deleted.\n * Returns the new base URL, or undefined when no other cluster accepts the key.\n */\n async relocateRegionalApiKey(storageKeys: string[]): Promise<string | undefined> {\n const key = await this.pickStorageKey(storageKeys);\n const creds = key === \"xiaomi\" ? this.data[key] : undefined;\n if (!creds) return undefined;\n const baseUrl = await this.detectXiaomiCluster(creds.accessToken);\n if (!baseUrl || baseUrl === creds.baseUrl) return undefined;\n await this.mutateLatest((latest) => {\n const current = latest[\"xiaomi\"];\n if (current?.accessToken === creds.accessToken) current.baseUrl = baseUrl;\n });\n log(\"INFO\", \"auth\", `Xiaomi Token Plan key belongs to ${baseUrl} — saved for future requests`);\n return baseUrl;\n }\n\n // Keeps local-endpoint keys: they're the user's own config, and a 401 there is often a busy gateway.\n async clearRejectedApiKey(storageKey: string): Promise<boolean> {\n if (storageKey.startsWith(LOCAL_AUTH_KEY_PREFIX)) return false;\n await this.clearCredentials(storageKey);\n return true;\n }\n\n async clearCredentials(provider: string): Promise<void> {\n await this.mutateLatest((latest) => {\n delete latest[provider];\n });\n }\n\n /**\n * Mark the credential stored under `storageKey` as usage-exhausted until\n * `resetsAt` (unix SECONDS, from the provider's rate-limit response) or a\n * 15-minute default when no reset time is known. While the mark is in the\n * future, `resolveCredentials(\"openai\")` serves the OpenAI API key instead of\n * the ChatGPT plan sign-in (when both are configured) — the plan stays the\n * preferred credential and is retried automatically once the mark\n * lapses. Persisted to auth.json so a restart (or another gg-app window)\n * doesn't burn a request rediscovering the same exhausted window. No-op if\n * nothing is stored under `storageKey`.\n */\n async markUsageExhausted(storageKey: string, resetsAt?: number): Promise<void> {\n const until =\n resetsAt !== undefined && resetsAt * 1000 > Date.now()\n ? resetsAt * 1000\n : Date.now() + USAGE_EXHAUSTED_DEFAULT_MS;\n let marked = false;\n await this.mutateLatest((latest) => {\n const creds = latest[storageKey];\n if (!creds) return;\n creds.usageExhaustedUntil = until;\n marked = true;\n });\n if (!marked) return;\n log(\n \"WARN\",\n \"auth\",\n `Marked ${storageKey} usage-exhausted until ${new Date(until).toISOString()}`,\n );\n }\n\n async clearAll(): Promise<void> {\n await this.ensureLoaded();\n await withFileLock(this.filePath, async () => {\n this.data = {};\n await atomicWriteFile(this.filePath, JSON.stringify(this.data, null, 2));\n });\n }\n\n /**\n * Returns valid credentials, auto-refreshing if expired.\n *\n * If `forceRefresh` is true, refreshes even if the token hasn't expired\n * (useful when the provider rejects a token with 401 before its stored\n * expiry). Callers recovering from a rejection should also pass\n * `rejectedToken` — see the stampede guard below.\n *\n * Throws if not logged in.\n */\n async resolveCredentials(\n provider: string,\n opts?: {\n forceRefresh?: boolean;\n storageKeys?: string[];\n /**\n * The access token the provider just rejected. Refreshing an OAuth grant\n * invalidates the previous access token, so N processes sharing auth.json\n * (app windows, CLI sessions, the usage poller) can otherwise revoke each\n * other in a loop: each one force-refreshes on 401, and every refresh kills\n * the token the others still hold. Naming the rejected token lets the\n * refresh path tell \"this credential is genuinely dead\" from \"someone else\n * already rotated it\" and simply adopt the newer on-disk credential.\n */\n rejectedToken?: string;\n },\n ): Promise<OAuthCredentials> {\n // Pick up a rotation performed by another process before serving a cached\n // token. Long-lived processes (the desktop sidecar runs for days) would\n // otherwise keep handing out an access token that a sibling's refresh\n // already invalidated — the \"it logged me out again\" failure.\n //\n // Skipped for a forced refresh on purpose: that path compares the caller's\n // snapshot against the latest file to detect a concurrent re-login, and\n // refreshing this instance's view first would destroy that evidence.\n if (opts?.forceRefresh) {\n await this.ensureLoaded();\n } else {\n await this.ensureFresh();\n }\n\n // A failed refresh removes the credential from this session's cache. If\n // the user then re-logs in through another app session, recover that new\n // on-disk credential instead of remaining \"not logged in\" until restart.\n const directStorageKeys =\n opts?.storageKeys && !(opts.storageKeys.length === 1 && opts.storageKeys[0] === provider)\n ? opts.storageKeys\n : providerStorageKeys(provider);\n if (\n !directStorageKeys.some((key) => Boolean(usableEntry(this.data, key))) &&\n !(provider === \"anthropic\" && this.data[CLAUDE_CODE_AUTH_KEY])\n ) {\n await this.reloadLatest();\n }\n\n // Explicit ordered storage-key override (e.g. Kimi: prefer the Kimi Code\n // plan key, fall back to the Moonshot key if only that's configured).\n // Bypasses the provider-name resolution below entirely when given. A\n // single-entry list equal to `[provider]` falls through to normal\n // resolution below.\n if (opts?.storageKeys && !(opts.storageKeys.length === 1 && opts.storageKeys[0] === provider)) {\n for (const key of opts.storageKeys) {\n const creds = usableEntry(this.data, key);\n if (!creds) continue;\n // A plan sign-in storage key must still refresh, so resolve it as its\n // own provider instead of returning a possibly expired token.\n if (key !== provider && dualAuthProviderByOAuthKey(key)) {\n return await this.resolveCredentials(key, {\n ...(opts.forceRefresh ? { forceRefresh: true } : {}),\n ...(opts.rejectedToken !== undefined ? { rejectedToken: opts.rejectedToken } : {}),\n });\n }\n // The provider's own key in an ordered list resolves through the normal\n // path, so a dual-auth provider still prefers its plan sign-in.\n if (key === provider) {\n return await this.resolveCredentials(provider, {\n ...(opts.forceRefresh ? { forceRefresh: true } : {}),\n ...(opts.rejectedToken !== undefined ? { rejectedToken: opts.rejectedToken } : {}),\n });\n }\n if (key === KIMI_CODE_KEY && !creds.baseUrl) return await this.withKimiRegion(creds);\n return creds;\n }\n throw new NotLoggedInError(provider);\n }\n\n // Signed in with Claude through Claude Code: there is no token to hand\n // out (Claude Code holds it). Callers on that route never use this one.\n if (\n provider === \"anthropic\" &&\n (!this.data.anthropic || isLegacyClaudeOAuth(this.data.anthropic))\n ) {\n if (isClaudeCodeAuthMarker(this.data[CLAUDE_CODE_AUTH_KEY])) {\n return { accessToken: \"\", refreshToken: \"\", expiresAt: Number.MAX_SAFE_INTEGER };\n }\n // The old GG Claude sign-in token can't be used or refreshed any more.\n if (this.data.anthropic) throw new NotLoggedInError(provider);\n }\n\n // Prefer the plan sign-in over the API key for a dual-auth provider\n // (OpenAI). When a sign-in exists, resolve (and refresh) that instead.\n const dual = dualAuthProvider(provider);\n const dualOAuthCreds = dual ? this.data[dual.oauthKey] : undefined;\n const dualApiKey = dual ? usableEntry(this.data, dual.provider) : undefined;\n if (dual && dualOAuthCreds) {\n // OAuth plan usage window exhausted (marked by the agent loop when the\n // subscription endpoint rejected with a usage/quota stop). Serve the API\n // key while the window recovers — but ONLY when one is configured; with no\n // API key the OAuth credential still resolves so the real usage-limit\n // error (with its reset time) surfaces to the user instead of a\n // misleading \"not logged in\".\n const exhaustedUntil = dualOAuthCreds.usageExhaustedUntil ?? 0;\n if (Date.now() < exhaustedUntil && dualApiKey) {\n log(\n \"WARN\",\n \"auth\",\n `${dual.oauthLabel} usage window is exhausted — using the ${dual.apiKeyLabel} until ` +\n `${new Date(exhaustedUntil).toISOString()} (OAuth resumes automatically).`,\n );\n return dualApiKey;\n }\n try {\n // Do NOT forward `storageKeys` here: the caller's keys (e.g.\n // AgentSession's [\"openai\"]) don't match the recursive provider\n // (\"openai-chatgpt\"), so forwarding them would trip the storage-key\n // override branch and silently return the raw API key.\n return await this.resolveCredentials(dual.oauthKey, {\n ...(opts?.forceRefresh ? { forceRefresh: true } : {}),\n ...(opts?.rejectedToken !== undefined ? { rejectedToken: opts.rejectedToken } : {}),\n });\n } catch (err) {\n // OAuth refresh token is dead and was wiped. Fall back to the API key if\n // the user also configured one. This is a billing switch (subscription →\n // metered key), so make it loud in the debug log rather than silent —\n // the user expects OAuth to stay active and should know a re-login is\n // needed to restore it.\n if (err instanceof NotLoggedInError && dualApiKey) {\n log(\n \"WARN\",\n \"auth\",\n `${dual.oauthLabel} credential is no longer valid — falling back to the ` +\n `${dual.apiKeyLabel}. ${dual.restoreHint}`,\n );\n return dualApiKey;\n }\n throw err;\n }\n }\n\n // A retired sign-in (old Codex token in the `openai` key slot, …) is never\n // served — above all never as an API key.\n const creds = usableEntry(this.data, provider);\n if (!creds) {\n throw new NotLoggedInError(provider);\n }\n\n // Static API keys have no refresh mechanism; only the ChatGPT plan\n // sign-in refreshes below.\n if (!REFRESHABLE_STORAGE_KEYS.has(provider)) {\n if (provider === \"minimax\" && !creds.baseUrl) return await this.withMiniMaxRegion(creds);\n if (provider === KIMI_CODE_KEY && !creds.baseUrl) return await this.withKimiRegion(creds);\n return creds;\n }\n\n // Return if not expired (with a safety skew) and not force-refreshing\n if (!opts?.forceRefresh && Date.now() < creds.expiresAt - refreshThresholdMs(creds)) {\n return creds;\n }\n\n // Serialize concurrent refresh calls per provider to avoid races\n const existing = this.refreshLocks.get(provider);\n if (existing) return existing;\n\n const refreshPromise = withFileLock(this.filePath, async () => {\n // Always refresh against the latest complete file. A different app\n // session may have re-logged in this provider or changed another one\n // since this instance loaded its cached snapshot.\n const latest = await readAuthData(this.filePath);\n const latestCreds = latest[provider];\n if (!latestCreds) {\n this.data = latest;\n throw new NotLoggedInError(provider);\n }\n\n const credentialWasReplaced =\n latestCreds.accessToken !== creds.accessToken ||\n latestCreds.refreshToken !== creds.refreshToken ||\n latestCreds.expiresAt !== creds.expiresAt;\n // The caller named the token the provider rejected, and disk now holds a\n // different one: a sibling process already rotated this grant. Minting\n // another token here would invalidate the one that sibling is using and\n // keep the mutual-revocation loop going.\n const rotatedBySibling =\n opts?.rejectedToken !== undefined && latestCreds.accessToken !== opts.rejectedToken;\n if (\n credentialWasReplaced ||\n rotatedBySibling ||\n (!opts?.forceRefresh &&\n Date.now() < latestCreds.expiresAt - refreshThresholdMs(latestCreds))\n ) {\n // Another process refreshed or re-logged in while this session still\n // held the rejected token. Trust that replacement even for a forced\n // refresh; retrying the revoked OLD refresh token would delete the new\n // login that just landed on disk.\n if (rotatedBySibling && !credentialWasReplaced) {\n log(\n \"INFO\",\n \"auth\",\n `${provider} token was rotated by another gg process — adopting it instead of refreshing again`,\n );\n }\n this.data = latest;\n return latestCreds;\n }\n\n let refreshed: OAuthCredentials;\n try {\n refreshed = await refreshChatGPTToken(latestCreds);\n } catch (err) {\n // Refresh token revoked / expired / invalid → the stored creds are\n // unusable. Delete only this provider from the latest snapshot so a\n // failed refresh can never erase another provider's concurrent login.\n const msg = err instanceof Error ? err.message : String(err);\n const isAuthFailure =\n /\\((401|400)\\)/.test(msg) ||\n /invalid_grant|invalid_token|invalid.*refresh|token_expired|refresh_token_(?:expired|invalidated|reused)/i.test(\n msg,\n ) ||\n /unauthorized/i.test(msg);\n if (isAuthFailure) {\n // Re-read before deleting. The refresh ran over the network while\n // the file lock can be broken by a waiter (5s wait vs 30s request);\n // a sibling may have refreshed with this grant or re-logged in\n // meanwhile, so the rejection only proves OUR token is dead.\n // Writing the pre-request snapshot back would erase their fresh\n // login and sign every window out.\n const current = await readAuthData(this.filePath);\n const currentCreds = current[provider];\n if (currentCreds && currentCreds.refreshToken !== latestCreds.refreshToken) {\n log(\n \"WARN\",\n \"auth\",\n `${provider} token refresh was rejected, but another gg process already saved a newer credential — adopting it instead of signing out`,\n );\n this.data = current;\n return currentCreds;\n }\n delete current[provider];\n this.data = current;\n await atomicWriteFile(this.filePath, JSON.stringify(current, null, 2));\n throw new NotLoggedInError(provider);\n }\n throw err;\n }\n // The refresh response carries tokens only; keep the identity and\n // routing fields saved at sign-in.\n refreshed = {\n ...pick(latestCreds, [\"accountId\", \"baseUrl\", \"clientId\", \"idToken\", \"scopes\"]),\n ...refreshed,\n };\n latest[provider] = refreshed;\n this.data = latest;\n // Write atomically (we already hold the file lock).\n await atomicWriteFile(this.filePath, JSON.stringify(latest, null, 2));\n return refreshed;\n });\n\n this.refreshLocks.set(provider, refreshPromise);\n try {\n return await refreshPromise;\n } finally {\n this.refreshLocks.delete(provider);\n // The refresh path rewrites auth.json and updates `this.data` in step, so\n // re-stamp the snapshot: otherwise the next ensureFresh() sees its own\n // write as a foreign change and re-reads the file on every resolve.\n await this.rememberSnapshot();\n }\n }\n\n /**\n * Returns a valid access token, auto-refreshing if expired.\n * Throws if not logged in.\n */\n async resolveToken(provider: string): Promise<string> {\n const creds = await this.resolveCredentials(provider);\n return creds.accessToken;\n }\n}\n\nfunction pick<T extends object, K extends keyof T>(value: T, keys: K[]): Partial<Pick<T, K>> {\n const out: Partial<Pick<T, K>> = {};\n for (const key of keys) if (value[key] !== undefined) out[key] = value[key];\n return out;\n}\n\n/** Read the latest complete auth snapshot while a caller holds the file lock. */\nasync function readAuthData(filePath: string): Promise<AuthData> {\n try {\n const content = await fs.readFile(filePath, \"utf-8\");\n return JSON.parse(content) as AuthData;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return {};\n throw error;\n }\n}\n\n/**\n * Atomic file write using temp file + rename pattern.\n * Prevents partial/corrupt data if the process crashes mid-write.\n */\nasync function atomicWriteFile(filePath: string, content: string): Promise<void> {\n const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID().slice(0, 8)}.tmp`;\n try {\n await fs.writeFile(tmpPath, content, { encoding: \"utf-8\", mode: 0o600 });\n await fs.rename(tmpPath, filePath);\n } catch (err) {\n await fs.unlink(tmpPath).catch(() => {});\n throw err;\n }\n}\n\nexport class NotLoggedInError extends Error {\n provider: string;\n constructor(provider: string) {\n super(`Not logged in to ${provider}. Run \"ggcoder login\" to authenticate.`);\n this.name = \"NotLoggedInError\";\n this.provider = provider;\n }\n}\n","import path from \"node:path\";\nimport os from \"node:os\";\n\nexport interface AppPaths {\n agentDir: string;\n sessionsDir: string;\n subagentSessionsDir: string;\n subagentsDir: string;\n settingsFile: string;\n authFile: string;\n telegramFile: string;\n agentHomeFile: string;\n mcpFile: string;\n mcpAuthFile: string;\n /** Cached MCP tool catalog, so a cold start can answer tool_search honestly. */\n mcpCatalogFile: string;\n logFile: string;\n skillsDir: string;\n extensionsDir: string;\n agentsDir: string;\n progressFile: string;\n progressBackupFile: string;\n /** Earned trophies and the counters that unlock them. */\n trophiesFile: string;\n /** On-demand Claude Code binaries for \"Sign in with Claude\", one dir per version. */\n claudeCodeDir: string;\n /** GG Coder's own Claude Code login (CLAUDE_CONFIG_DIR), separate from the user's. */\n claudeCodeConfigDir: string;\n}\n\nexport function getAppPaths(): AppPaths {\n const agentDir = path.join(os.homedir(), \".gg\");\n return {\n agentDir,\n sessionsDir: path.join(agentDir, \"sessions\"),\n subagentSessionsDir: path.join(agentDir, \"subagent-sessions\"),\n subagentsDir: path.join(agentDir, \"subagents\"),\n settingsFile: path.join(agentDir, \"settings.json\"),\n authFile: path.join(agentDir, \"auth.json\"),\n telegramFile: path.join(agentDir, \"telegram.json\"),\n agentHomeFile: path.join(agentDir, \"agent-home.json\"),\n mcpFile: path.join(agentDir, \"mcp.json\"),\n mcpAuthFile: path.join(agentDir, \"mcp-auth.json\"),\n mcpCatalogFile: path.join(agentDir, \"mcp-catalog.json\"),\n logFile: path.join(agentDir, \"debug.log\"),\n skillsDir: path.join(agentDir, \"skills\"),\n extensionsDir: path.join(agentDir, \"extensions\"),\n agentsDir: path.join(agentDir, \"agents\"),\n progressFile: path.join(agentDir, \"progress.json\"),\n progressBackupFile: path.join(agentDir, \"progress.backup.json\"),\n trophiesFile: path.join(agentDir, \"trophies.json\"),\n claudeCodeDir: path.join(agentDir, \"claude-code\"),\n claudeCodeConfigDir: path.join(agentDir, \"claude-code\", \"config\"),\n };\n}\n","import { spawn } from \"node:child_process\";\nimport crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { getAppPaths } from \"../paths.js\";\nimport type { OAuthLoginCallbacks } from \"../oauth/types.js\";\nimport { extractFileFromTarGz } from \"./tar.js\";\nimport manifestJson from \"./manifest.json\" with { type: \"json\" };\n\nexport interface ClaudeCodePlatformAsset {\n url: string;\n /** npm `dist.integrity`: \"sha512-<base64>\". */\n integrity: string;\n bin: string;\n}\n\nexport interface ClaudeCodeManifest {\n sdkVersion: string;\n claudeCodeVersion: string;\n platforms: Record<string, ClaudeCodePlatformAsset>;\n}\n\nexport const CLAUDE_CODE_MANIFEST: ClaudeCodeManifest = manifestJson;\n\n/** auth.json key for \"signed in with Claude through Claude Code\". Holds only the marker. */\nexport const CLAUDE_CODE_AUTH_KEY = \"anthropic-claude-code\";\nexport const CLAUDE_CODE_AUTH_MARKER = { via: \"claude-code\" } as const;\n\nexport function isClaudeCodeAuthMarker(value: unknown): boolean {\n return (value as { via?: unknown } | null | undefined)?.via === CLAUDE_CODE_AUTH_MARKER.via;\n}\n\n/** An old GG Claude sign-in token (`sk-ant-oat…`). It no longer works; the user must sign in again. */\nexport function isLegacyClaudeOAuth(value: unknown): boolean {\n const token = (value as { accessToken?: unknown } | null | undefined)?.accessToken;\n return typeof token === \"string\" && token.startsWith(\"sk-ant-oat\");\n}\n\nexport type ClaudeCodeInstallProgress =\n | { phase: \"download\"; receivedBytes: number; totalBytes?: number }\n | { phase: \"verify\" }\n | { phase: \"extract\" };\n\nexport interface ClaudeCodeRuntimeOptions {\n /** Defaults to `~/.gg/claude-code`. */\n home?: string;\n manifest?: ClaudeCodeManifest;\n /** Platform key in the manifest; detected from the running process by default. */\n platformKey?: string;\n}\n\nexport interface EnsureClaudeCodeOptions extends ClaudeCodeRuntimeOptions {\n onProgress?: (progress: ClaudeCodeInstallProgress) => void;\n signal?: AbortSignal;\n fetch?: typeof fetch;\n}\n\nexport interface ClaudeCodeRunOptions extends ClaudeCodeRuntimeOptions {\n /** Executable to run instead of the installed binary (tests). */\n binary?: string;\n /** Arguments placed before the `auth …` arguments (tests run a script via node). */\n binaryArgs?: string[];\n signal?: AbortSignal;\n /** How long sign-in waits for the browser URL before falling back to the paste-code page. */\n browserWaitMs?: number;\n}\n\nexport interface ClaudeCodeAuthStatus {\n loggedIn: boolean;\n /** \"claude.ai\" for a Claude subscription. */\n authMethod?: string;\n email?: string;\n subscriptionType?: string;\n}\n\ninterface PlatformInfo {\n platform: NodeJS.Platform;\n arch: string;\n musl: boolean;\n}\n\nfunction isMusl(): boolean {\n if (process.platform !== \"linux\") return false;\n const report =\n typeof process.report?.getReport === \"function\"\n ? (process.report.getReport() as { header?: { glibcVersionRuntime?: string } })\n : null;\n return report != null && report.header?.glibcVersionRuntime === undefined;\n}\n\n/** Manifest key for a platform, or null where Claude Code has no build. */\nexport function claudeCodePlatformKey(\n info: PlatformInfo = { platform: process.platform, arch: process.arch, musl: isMusl() },\n): string | null {\n const arch = info.arch === \"arm64\" || info.arch === \"x64\" ? info.arch : null;\n if (!arch) return null;\n if (info.platform === \"darwin\" || info.platform === \"win32\") return `${info.platform}-${arch}`;\n if (info.platform === \"linux\") return `linux-${arch}${info.musl ? \"-musl\" : \"\"}`;\n return null;\n}\n\nfunction resolveAsset(opts: ClaudeCodeRuntimeOptions): {\n manifest: ClaudeCodeManifest;\n asset: ClaudeCodePlatformAsset;\n} {\n const manifest = opts.manifest ?? CLAUDE_CODE_MANIFEST;\n const key = opts.platformKey ?? claudeCodePlatformKey();\n const asset = key ? manifest.platforms[key] : undefined;\n if (!asset) {\n throw new Error(\n `Claude Code isn't available for ${process.platform}-${process.arch}. Use an Anthropic API key instead.`,\n );\n }\n return { manifest, asset };\n}\n\nexport function claudeCodeHome(opts: ClaudeCodeRuntimeOptions = {}): string {\n return opts.home ?? getAppPaths().claudeCodeDir;\n}\n\nexport function claudeCodeConfigDir(opts: ClaudeCodeRuntimeOptions = {}): string {\n return opts.home ? path.join(opts.home, \"config\") : getAppPaths().claudeCodeConfigDir;\n}\n\n/** Where the pinned binary lives once installed (it may not exist yet). */\nexport function claudeCodeBinaryPath(opts: ClaudeCodeRuntimeOptions = {}): string {\n const { manifest, asset } = resolveAsset(opts);\n return path.join(claudeCodeHome(opts), manifest.claudeCodeVersion, asset.bin);\n}\n\n/** The installed binary for the pinned version, or null when it still needs downloading. */\nexport function resolveClaudeCodeBinary(opts: ClaudeCodeRuntimeOptions = {}): string | null {\n let binary: string;\n try {\n binary = claudeCodeBinaryPath(opts);\n } catch {\n return null;\n }\n try {\n return fs.statSync(binary).isFile() ? binary : null;\n } catch {\n return null;\n }\n}\n\nconst installs = new Map<string, Promise<string>>();\n\n/**\n * Download, verify and unpack the pinned Claude Code binary if it isn't\n * installed yet. Concurrent callers in this process share one download;\n * other processes race safely because each unpacks into its own staging dir\n * and only one rename wins.\n */\nexport async function ensureClaudeCodeBinary(opts: EnsureClaudeCodeOptions = {}): Promise<string> {\n const existing = resolveClaudeCodeBinary(opts);\n if (existing) return existing;\n const target = claudeCodeBinaryPath(opts);\n let install = installs.get(target);\n if (!install) {\n install = installClaudeCode(opts).finally(() => installs.delete(target));\n installs.set(target, install);\n }\n return install;\n}\n\nasync function installClaudeCode(opts: EnsureClaudeCodeOptions): Promise<string> {\n const { manifest, asset } = resolveAsset(opts);\n const home = claudeCodeHome(opts);\n const versionDir = path.join(home, manifest.claudeCodeVersion);\n const binary = path.join(versionDir, asset.bin);\n await fsp.mkdir(home, { recursive: true });\n const tag = `${process.pid}-${crypto.randomBytes(4).toString(\"hex\")}`;\n const tarball = path.join(home, `.download-${tag}.tgz`);\n const staging = path.join(home, `.staging-${tag}`);\n try {\n await download(asset, tarball, opts);\n opts.onProgress?.({ phase: \"extract\" });\n await fsp.mkdir(staging, { recursive: true });\n const found = await extractFileFromTarGz(\n tarball,\n `package/${asset.bin}`,\n path.join(staging, asset.bin),\n );\n if (!found) throw new Error(`The Claude Code download has no ${asset.bin} binary.`);\n await fsp.chmod(path.join(staging, asset.bin), 0o755);\n try {\n await fsp.rename(staging, versionDir);\n } catch (err) {\n // Another process installed the same version first; theirs is identical.\n if (!fs.existsSync(binary)) throw err;\n }\n await removeOldVersions(home, manifest.claudeCodeVersion);\n return binary;\n } finally {\n await fsp.rm(tarball, { force: true }).catch(() => {});\n await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});\n }\n}\n\nasync function download(\n asset: ClaudeCodePlatformAsset,\n dest: string,\n opts: EnsureClaudeCodeOptions,\n): Promise<void> {\n const doFetch = opts.fetch ?? fetch;\n const res = await doFetch(asset.url, { signal: opts.signal });\n if (!res.ok || !res.body) {\n throw new Error(`Claude Code download failed (HTTP ${res.status}).`);\n }\n const totalBytes = Number(res.headers.get(\"content-length\")) || undefined;\n const hash = crypto.createHash(\"sha512\");\n const out = await fsp.open(dest, \"w\");\n let receivedBytes = 0;\n let lastReported = -1;\n try {\n opts.onProgress?.({ phase: \"download\", receivedBytes, totalBytes });\n for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {\n if (opts.signal?.aborted) throw opts.signal.reason ?? new Error(\"Download cancelled.\");\n hash.update(chunk);\n await out.write(chunk);\n receivedBytes += chunk.byteLength;\n // Report each whole percent (or every 4 MB without a length), not every chunk.\n const step = totalBytes\n ? Math.floor((receivedBytes / totalBytes) * 100)\n : Math.floor(receivedBytes / 4_194_304);\n if (step !== lastReported) {\n lastReported = step;\n opts.onProgress?.({ phase: \"download\", receivedBytes, totalBytes });\n }\n }\n } finally {\n await out.close();\n }\n opts.onProgress?.({ phase: \"verify\" });\n const expected = asset.integrity.replace(/^sha512-/, \"\");\n if (hash.digest(\"base64\") !== expected) {\n throw new Error(\n \"The Claude Code download didn't match its pinned checksum, so it was discarded.\",\n );\n }\n}\n\nasync function removeOldVersions(home: string, keep: string): Promise<void> {\n let entries: fs.Dirent[];\n try {\n entries = await fsp.readdir(home, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name === keep || entry.name === \"config\") continue;\n if (!/^\\d+\\.\\d+\\.\\d+/.test(entry.name)) continue;\n // A window may still run the old binary; Windows refuses (EBUSY) until it exits.\n await fsp.rm(path.join(home, entry.name), { recursive: true, force: true }).catch(() => {});\n }\n}\n\n/** Plain-language status line for an install progress event. */\nexport function formatClaudeCodeInstallProgress(progress: ClaudeCodeInstallProgress): string {\n if (progress.phase === \"verify\") return \"Checking the Claude Code download…\";\n if (progress.phase === \"extract\") return \"Unpacking Claude Code…\";\n const mb = (bytes: number) => Math.round(bytes / 1_048_576);\n if (!progress.totalBytes) return `Downloading Claude Code… ${mb(progress.receivedBytes)} MB`;\n const pct = Math.floor((progress.receivedBytes / progress.totalBytes) * 100);\n return `Downloading Claude Code… ${pct}% (${mb(progress.receivedBytes)} of ${mb(progress.totalBytes)} MB)`;\n}\n\n// Credentials Claude Code would prefer over the subscription login.\nconst STRIPPED_ENV = [\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_BASE_URL\",\n \"CLAUDE_CODE_OAUTH_TOKEN\",\n \"CLAUDE_CODE_OAUTH_REFRESH_TOKEN\",\n \"CLAUDE_CODE_USE_BEDROCK\",\n \"CLAUDE_CODE_USE_VERTEX\",\n \"CLAUDE_CODE_USE_FOUNDRY\",\n \"NODE_OPTIONS\",\n];\n\n/** Environment for every Claude Code process GG Coder starts: our own login, no stray keys. */\nexport function claudeCodeEnv(\n opts: ClaudeCodeRuntimeOptions = {},\n base: NodeJS.ProcessEnv = process.env,\n): Record<string, string> {\n const env: Record<string, string> = {};\n for (const [key, value] of Object.entries(base)) {\n if (value !== undefined && !STRIPPED_ENV.includes(key)) env[key] = value;\n }\n env.CLAUDE_CONFIG_DIR = claudeCodeConfigDir(opts);\n // The binary is pinned and checksummed; it must not replace itself.\n env.DISABLE_AUTOUPDATER = \"1\";\n return env;\n}\n\nfunction runCommand(opts: ClaudeCodeRunOptions): { command: string; prefix: string[] } {\n if (opts.binary) return { command: opts.binary, prefix: opts.binaryArgs ?? [] };\n const binary = resolveClaudeCodeBinary(opts);\n if (!binary) throw new Error(\"Claude Code isn't installed yet. Sign in with Claude first.\");\n return { command: binary, prefix: [] };\n}\n\nfunction runClaude(\n args: string[],\n opts: ClaudeCodeRunOptions,\n): Promise<{ code: number | null; stdout: string; stderr: string }> {\n const { command, prefix } = runCommand(opts);\n return new Promise((resolve, reject) => {\n const child = spawn(command, [...prefix, ...args], {\n env: claudeCodeEnv(opts),\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n windowsHide: true,\n signal: opts.signal,\n });\n let stdout = \"\";\n let stderr = \"\";\n child.stdout.on(\"data\", (d: Buffer) => (stdout += d.toString()));\n child.stderr.on(\"data\", (d: Buffer) => (stderr += d.toString()));\n child.on(\"error\", reject);\n child.on(\"close\", (code) => resolve({ code, stdout, stderr }));\n });\n}\n\n/** `claude auth status` for GG Coder's own login. Logged out when the binary is missing. */\nexport async function claudeCodeAuthStatus(\n opts: ClaudeCodeRunOptions = {},\n): Promise<ClaudeCodeAuthStatus> {\n if (!opts.binary && !resolveClaudeCodeBinary(opts)) return { loggedIn: false };\n const { stdout } = await runClaude([\"auth\", \"status\", \"--json\"], opts);\n // Exits 1 when logged out but still prints the JSON.\n try {\n const parsed = JSON.parse(stdout) as Record<string, unknown>;\n return {\n loggedIn: parsed.loggedIn === true,\n ...(typeof parsed.authMethod === \"string\" ? { authMethod: parsed.authMethod } : {}),\n ...(typeof parsed.email === \"string\" ? { email: parsed.email } : {}),\n ...(typeof parsed.subscriptionType === \"string\"\n ? { subscriptionType: parsed.subscriptionType }\n : {}),\n };\n } catch {\n return { loggedIn: false };\n }\n}\n\nexport async function logoutClaudeCode(opts: ClaudeCodeRunOptions = {}): Promise<void> {\n if (!opts.binary && !resolveClaudeCodeBinary(opts)) return;\n const { code, stderr } = await runClaude([\"auth\", \"logout\"], opts);\n if (code !== 0) throw new Error(stderr.trim() || `claude auth logout exited with ${code}`);\n}\n\n// Claude Code runs $BROWSER with the sign-in URL as its only argument. Pointing\n// it at Node with this preload hands us the URL instead of opening a tab, so the\n// app opens it (once). Node resolves argv[1] like a script path, so only the\n// callback port is read back from it.\nconst OPEN_URL_HOOK = `const arg = String(process.argv[1] || \"\");\nconst file = process.env.GG_CLAUDE_CODE_URL_FILE;\nif (file && /https?:/.test(arg)) {\n require(\"node:fs\").appendFileSync(file, arg + \"\\\\n\");\n process.exit(0);\n}\n`;\n\nconst CAPTURE_WAIT_MS = 3_000;\n\nfunction stripAnsi(text: string): string {\n // eslint-disable-next-line no-control-regex\n return text.replace(/\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)|\\x1b\\[[0-9;?]*[A-Za-z]/g, \"\");\n}\n\n/** The browser sign-in URL with Claude Code's localhost callback instead of the paste-code page. */\nexport function claudeCodeCallbackUrl(manualUrl: string, captured: string): string | null {\n const port = /localhost(?::|%3A)(\\d{2,5})(?:\\/|%2F)callback/i.exec(captured)?.[1];\n if (!port) return null;\n try {\n const url = new URL(manualUrl);\n url.searchParams.set(\"redirect_uri\", `http://localhost:${port}/callback`);\n return url.toString();\n } catch {\n return null;\n }\n}\n\n/**\n * Run `claude auth login` for GG Coder's own Claude login. The browser URL goes\n * to `onOpenUrl`; Claude Code's localhost callback finishes sign-in, and a\n * pasted code (from the fallback page) is accepted too.\n */\nexport async function loginClaudeCode(\n callbacks: OAuthLoginCallbacks,\n opts: ClaudeCodeRunOptions = {},\n): Promise<void> {\n const { command, prefix } = runCommand(opts);\n const home = claudeCodeHome(opts);\n await fsp.mkdir(claudeCodeConfigDir(opts), { recursive: true });\n const tag = `${process.pid}-${crypto.randomBytes(4).toString(\"hex\")}`;\n const hookFile = path.join(home, `.open-url-${tag}.cjs`);\n const urlFile = path.join(home, `.open-url-${tag}.txt`);\n await fsp.writeFile(hookFile, OPEN_URL_HOOK);\n const env = {\n ...claudeCodeEnv(opts),\n BROWSER: process.execPath,\n NODE_OPTIONS: `--require ${JSON.stringify(hookFile)}`,\n GG_CLAUDE_CODE_URL_FILE: urlFile,\n };\n const promptAbort = new AbortController();\n let timer: NodeJS.Timeout | undefined;\n try {\n await new Promise<void>((resolve, reject) => {\n const child = spawn(command, [...prefix, \"auth\", \"login\", \"--claudeai\"], {\n env,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n windowsHide: true,\n });\n let stdout = \"\";\n let stderr = \"\";\n let manualUrl: string | null = null;\n let opened = false;\n const onAbort = () => child.kill();\n opts.signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n const open = (url: string, needsCode: boolean) => {\n if (opened) return;\n opened = true;\n clearInterval(timer);\n callbacks.onOpenUrl(url);\n callbacks.onStatus(\"Finish signing in to Claude in your browser…\");\n if (!needsCode) return;\n // Fallback page shows a code to paste back (\"code#state\").\n void callbacks\n .onPromptCode(\"Paste the code shown after signing in to Claude:\", promptAbort.signal)\n .then((code) => {\n const trimmed = code.trim();\n if (trimmed && !child.stdin.destroyed) child.stdin.write(`${trimmed}\\n`);\n })\n .catch(() => {});\n };\n\n child.stdout.on(\"data\", (d: Buffer) => {\n stdout += d.toString();\n if (manualUrl) return;\n const match = /visit:\\s*(https:\\/\\/\\S+)/.exec(stripAnsi(stdout));\n if (!match) return;\n manualUrl = match[1]!;\n const started = Date.now();\n timer = setInterval(() => {\n let captured = \"\";\n try {\n captured = fs.readFileSync(urlFile, \"utf8\");\n } catch {\n // Not written yet.\n }\n const callbackUrl = captured ? claudeCodeCallbackUrl(manualUrl!, captured) : null;\n if (callbackUrl) open(callbackUrl, false);\n else if (Date.now() - started > (opts.browserWaitMs ?? CAPTURE_WAIT_MS))\n open(manualUrl!, true);\n }, 100);\n });\n child.stderr.on(\"data\", (d: Buffer) => (stderr += d.toString()));\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n opts.signal?.removeEventListener(\"abort\", onAbort);\n if (opts.signal?.aborted) reject(new Error(\"Sign-in cancelled.\"));\n else if (code === 0) resolve();\n else {\n const detail = stripAnsi(stderr).trim().split(\"\\n\").pop()?.trim();\n reject(new Error(detail || `Claude sign-in failed (exit ${code}).`));\n }\n });\n });\n } finally {\n clearInterval(timer);\n promptAbort.abort();\n await fsp.rm(hookFile, { force: true }).catch(() => {});\n await fsp.rm(urlFile, { force: true }).catch(() => {});\n }\n}\n","import fs from \"node:fs\";\nimport fsp from \"node:fs/promises\";\nimport zlib from \"node:zlib\";\n\nconst BLOCK = 512;\n\nfunction readString(buf: Buffer, offset: number, length: number): string {\n const end = buf.indexOf(0, offset);\n return buf.toString(\"utf8\", offset, end === -1 || end > offset + length ? offset + length : end);\n}\n\nfunction readSize(buf: Buffer, offset: number): number {\n // GNU base-256 encoding (high bit set) for sizes past the octal limit.\n if (buf[offset]! & 0x80) {\n let value = buf[offset]! & 0x7f;\n for (let i = 1; i < 12; i++) value = value * 256 + buf[offset + i]!;\n return value;\n }\n const text = readString(buf, offset, 12).trim();\n return text ? parseInt(text, 8) : 0;\n}\n\nfunction paxPath(data: Buffer): string | undefined {\n let pos = 0;\n while (pos < data.length) {\n const space = data.indexOf(0x20, pos);\n if (space === -1) break;\n const len = parseInt(data.toString(\"utf8\", pos, space), 10);\n if (!Number.isFinite(len) || len <= 0) break;\n const record = data.toString(\"utf8\", space + 1, pos + len - 1);\n const eq = record.indexOf(\"=\");\n if (eq !== -1 && record.slice(0, eq) === \"path\") return record.slice(eq + 1);\n pos += len;\n }\n return undefined;\n}\n\n/**\n * Stream one regular file out of a .tgz into `destPath` without unpacking the\n * rest (or holding the ~300 MB binary in memory). Resolves false when the\n * archive has no such entry.\n */\nexport async function extractFileFromTarGz(\n tgzPath: string,\n entryName: string,\n destPath: string,\n): Promise<boolean> {\n const input = fs.createReadStream(tgzPath).pipe(zlib.createGunzip());\n let pending: Buffer = Buffer.alloc(0);\n let remaining = 0; // data bytes left in the current entry\n let padding = 0; // zero bytes after the data, up to the block boundary\n let kind: \"skip\" | \"pax\" | \"longname\" | \"target\" = \"skip\";\n let meta: Buffer[] = [];\n let overridePath: string | undefined;\n let out: fsp.FileHandle | undefined;\n let found = false;\n\n try {\n for await (const chunk of input as AsyncIterable<Buffer>) {\n pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;\n let pos = 0;\n while (pos < pending.length) {\n if (remaining > 0) {\n const take = Math.min(remaining, pending.length - pos);\n const slice = pending.subarray(pos, pos + take);\n if (kind === \"target\") await out!.write(slice);\n else if (kind === \"pax\" || kind === \"longname\") meta.push(Buffer.from(slice));\n pos += take;\n remaining -= take;\n if (remaining === 0) {\n if (kind === \"target\") {\n await out!.close();\n out = undefined;\n found = true;\n } else if (kind === \"pax\") {\n overridePath = paxPath(Buffer.concat(meta)) ?? overridePath;\n } else if (kind === \"longname\") {\n overridePath = readString(Buffer.concat(meta), 0, Number.MAX_SAFE_INTEGER);\n }\n }\n continue;\n }\n if (padding > 0) {\n const skip = Math.min(padding, pending.length - pos);\n pos += skip;\n padding -= skip;\n continue;\n }\n if (found) break;\n if (pending.length - pos < BLOCK) break;\n const header = pending.subarray(pos, pos + BLOCK);\n pos += BLOCK;\n if (header.every((b) => b === 0)) continue; // end-of-archive blocks\n const size = readSize(header, 124);\n const type = String.fromCharCode(header[156] || 0x30);\n let name = readString(header, 0, 100);\n if (readString(header, 257, 6) === \"ustar\") {\n const prefix = readString(header, 345, 155);\n if (prefix) name = `${prefix}/${name}`;\n }\n remaining = size;\n padding = (BLOCK - (size % BLOCK)) % BLOCK;\n meta = [];\n if (type === \"x\") kind = \"pax\";\n else if (type === \"L\") kind = \"longname\";\n else {\n const path = overridePath ?? name;\n overridePath = undefined;\n if ((type === \"0\" || type === \"\\0\" || type === \"7\") && path === entryName) {\n kind = \"target\";\n out = await fsp.open(destPath, \"w\", 0o755);\n if (size === 0) {\n await out.close();\n out = undefined;\n found = true;\n }\n } else {\n kind = \"skip\";\n }\n }\n }\n pending = pos < pending.length ? Buffer.from(pending.subarray(pos)) : Buffer.alloc(0);\n if (found) break;\n }\n if (!found && out) throw new Error(`Archive ended inside ${entryName}`);\n return found;\n } finally {\n if (out) await out.close().catch(() => {});\n input.destroy();\n }\n}\n","import http from \"node:http\";\nimport type { AddressInfo } from \"node:net\";\nimport crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { getAppPaths } from \"../paths.js\";\nimport { generatePKCE } from \"./pkce.js\";\nimport { oauthRequestSignal } from \"./request-timeout.js\";\nimport type { OAuthCredentials, OAuthLoginCallbacks } from \"./types.js\";\n\n// Sign in with ChatGPT for open-source local apps:\n// https://developers.openai.com/siwc/token-sharing-open-source/sign-in\nconst ISSUER = \"https://auth.openai.com\";\nconst AUTHORIZE_URL = `${ISSUER}/api/accounts/authorize`;\nconst TOKEN_URL = `${ISSUER}/api/accounts/oauth/token`;\nconst REVOKE_URL = `${ISSUER}/api/accounts/oauth/revoke`;\nconst JWKS_URL = `${ISSUER}/.well-known/jwks.json`;\n/** First-time registration entrypoint; never saved or used for token exchange. */\nconst REGISTRATION_CLIENT_ID = \"dynamic_agent_client\";\nconst AGENT_NAME = \"GG Coder\";\nconst RESOURCE = \"https://api.openai.com/v1\";\n/** The scope that lets GG Coder use the user's ChatGPT plan. */\nexport const CHATGPT_PLAN_SCOPE = \"chatgpt.tokens.use.direct\";\nconst SCOPES = `openid profile email offline_access resource.invoke ${CHATGPT_PLAN_SCOPE}`;\n/** Responses API base for ChatGPT plan inference. */\nexport const CHATGPT_PLAN_BASE_URL = \"https://api.openai.com/v1\";\n/** Where users review and limit what GG Coder may use of their plan. */\nexport const CHATGPT_USAGE_URL = \"https://chatgpt.com/settings/usage\";\nconst CALLBACK_PATH = \"/auth/callback\";\nconst PREFERRED_PORT = 1455;\n/** How long the loopback listener stays up before giving the paste route the floor. */\nconst CALLBACK_TIMEOUT_MS = 120_000;\n/** Retries allowed on a mistyped/truncated paste before that route gives up. */\nconst MAX_PASTE_ATTEMPTS = 3;\nconst ID_TOKEN_CLOCK_SKEW_S = 60;\n\nexport const CHATGPT_PLAN_NOT_ENABLED_MESSAGE =\n \"ChatGPT plan use isn't enabled for GG Coder. Allow Use your ChatGPT plan, or add an OpenAI API key.\";\n\n/** A sign-in that worked but can't be used to bill the user's ChatGPT plan. */\nexport class ChatGPTPlanNotEnabledError extends Error {\n constructor() {\n super(CHATGPT_PLAN_NOT_ENABLED_MESSAGE);\n this.name = \"ChatGPTPlanNotEnabledError\";\n }\n}\n\ninterface ChatGPTRegistration {\n clientId: string;\n subject: string;\n}\n\nexport interface ChatGPTLoginOptions {\n /** Defaults to `~/.gg/openai-host-id`. */\n hostIdFile?: string;\n /** Defaults to `~/.gg/openai-chatgpt-client.json`. */\n registrationFile?: string;\n /** The current sign-in, if any: its ID token skips the account picker. */\n previous?: Pick<OAuthCredentials, \"idToken\">;\n}\n\nfunction defaultHostIdFile(): string {\n return path.join(getAppPaths().agentDir, \"openai-host-id\");\n}\n\nfunction defaultRegistrationFile(): string {\n return path.join(getAppPaths().agentDir, \"openai-chatgpt-client.json\");\n}\n\n/** Owner-only write via temp file + rename, so a crash never leaves a torn file. */\nasync function writePrivateFile(file: string, content: string): Promise<void> {\n await fs.mkdir(path.dirname(file), { recursive: true });\n const tmp = `${file}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;\n try {\n await fs.writeFile(tmp, content, { encoding: \"utf-8\", mode: 0o600 });\n await fs.rename(tmp, file);\n } catch (err) {\n await fs.unlink(tmp).catch(() => {});\n throw err;\n }\n}\n\nconst HOST_ID_PATTERN = /^urn:uuid:[0-9a-f-]{36}$/i;\n\n/** This machine's stable `ext_agent_host_id`, created on first use. */\nexport async function getChatGPTHostId(file = defaultHostIdFile()): Promise<string> {\n const existing = await fs.readFile(file, \"utf-8\").catch(() => \"\");\n if (HOST_ID_PATTERN.test(existing.trim())) return existing.trim();\n await writePrivateFile(file, `urn:uuid:${crypto.randomUUID()}`);\n // Another process may have won the race; everyone uses what is on disk.\n return (await fs.readFile(file, \"utf-8\")).trim();\n}\n\nasync function readRegistration(file: string): Promise<ChatGPTRegistration | undefined> {\n try {\n const value = JSON.parse(await fs.readFile(file, \"utf-8\")) as Partial<ChatGPTRegistration>;\n if (typeof value.clientId === \"string\" && typeof value.subject === \"string\") {\n return { clientId: value.clientId, subject: value.subject };\n }\n } catch {\n // Missing or unreadable: register again.\n }\n return undefined;\n}\n\n/**\n * Continue with ChatGPT. Registers GG Coder for this ChatGPT account on first\n * use (the user names and approves it in the browser), then reuses the issued\n * client ID. Throws {@link ChatGPTPlanNotEnabledError} when the user signed in\n * but didn't allow plan use.\n */\nexport async function loginChatGPT(\n callbacks: OAuthLoginCallbacks,\n options: ChatGPTLoginOptions = {},\n): Promise<OAuthCredentials> {\n const hostId = await getChatGPTHostId(options.hostIdFile);\n const registrationFile = options.registrationFile ?? defaultRegistrationFile();\n const registration = await readRegistration(registrationFile);\n const { verifier, challenge } = await generatePKCE();\n const state = crypto.randomBytes(16).toString(\"hex\");\n const nonce = crypto.randomBytes(16).toString(\"hex\");\n\n const listener = await startCallbackListener(state, Boolean(registration));\n try {\n const redirectUri = `http://127.0.0.1:${listener.port}${CALLBACK_PATH}`;\n const url = new URL(AUTHORIZE_URL);\n url.searchParams.set(\"client_id\", registration?.clientId ?? REGISTRATION_CLIENT_ID);\n if (!registration) url.searchParams.set(\"agent_name_hint\", AGENT_NAME);\n url.searchParams.set(\"ext_agent_host_id\", hostId);\n if (registration && options.previous?.idToken) {\n url.searchParams.set(\"id_token_hint\", options.previous.idToken);\n }\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"redirect_uri\", redirectUri);\n url.searchParams.set(\"scope\", SCOPES);\n url.searchParams.set(\"resource\", RESOURCE);\n url.searchParams.set(\"state\", state);\n url.searchParams.set(\"nonce\", nonce);\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n url.searchParams.set(\"code_challenge\", challenge);\n\n const callback = await acquireCallback(url.toString(), state, listener, callbacks);\n if (callback.get(\"error\")) {\n if (callback.get(\"error\") === \"access_denied\") throw new ChatGPTPlanNotEnabledError();\n throw new Error(\n `ChatGPT sign-in failed: ${callback.get(\"error_description\") ?? callback.get(\"error\")}`,\n );\n }\n const code = callback.get(\"code\");\n if (!code) throw new Error(\"ChatGPT sign-in returned no authorization code.\");\n const issued = callback.get(\"client_id\") ?? undefined;\n if (registration && issued && issued !== registration.clientId) {\n throw new Error(\"ChatGPT returned a different app registration. Start the sign-in again.\");\n }\n const clientId = registration?.clientId ?? issued;\n if (!clientId || clientId === REGISTRATION_CLIENT_ID) {\n throw new Error(\n \"ChatGPT didn't finish registering GG Coder. Start the sign-in again, and paste the full callback URL if you're on another machine.\",\n );\n }\n\n const tokens = await exchangeCode({ code, verifier, clientId, redirectUri });\n const claims = await verifyIdToken(tokens.idToken, { clientId, nonce });\n const subject = String(claims.sub ?? \"\");\n if (!subject) throw new Error(\"ChatGPT ID token has no subject.\");\n if (registration && registration.subject !== subject) {\n // The saved registration belongs to another account; register afresh next time.\n await fs.unlink(registrationFile).catch(() => {});\n await revokeChatGPTSignIn({ refreshToken: tokens.refreshToken, clientId });\n throw new Error(\n \"That was a different ChatGPT account. Continue with ChatGPT again to connect it.\",\n );\n }\n if (!tokens.scopes.includes(CHATGPT_PLAN_SCOPE)) {\n await revokeChatGPTSignIn({ refreshToken: tokens.refreshToken, clientId });\n throw new ChatGPTPlanNotEnabledError();\n }\n if (!registration) {\n await writePrivateFile(registrationFile, JSON.stringify({ clientId, subject }));\n }\n\n return {\n accessToken: tokens.accessToken,\n refreshToken: tokens.refreshToken,\n expiresAt: Date.now() + tokens.expiresIn * 1000,\n expiresIn: tokens.expiresIn,\n accountId: subject,\n clientId,\n idToken: tokens.idToken,\n scopes: tokens.scopes,\n baseUrl: CHATGPT_PLAN_BASE_URL,\n };\n } finally {\n listener.close();\n }\n}\n\n/**\n * Model ids the signed-in ChatGPT account may use on its plan, from the same\n * `/v1/models` catalog OpenAI tells apps to build their picker from. Hidden\n * entries (`visibility` other than \"list\") are left out.\n */\nexport async function fetchChatGPTPlanModels(\n accessToken: string,\n fetchFn: typeof fetch = fetch,\n): Promise<string[]> {\n const response = await fetchFn(`${CHATGPT_PLAN_BASE_URL}/models`, {\n headers: { Authorization: `Bearer ${accessToken}` },\n signal: oauthRequestSignal(),\n });\n if (!response.ok) throw new Error(`ChatGPT model list failed (${response.status})`);\n const data = (await response.json()) as {\n models?: Array<{ slug?: unknown; visibility?: unknown }>;\n };\n return (data.models ?? [])\n .filter((m) => typeof m.slug === \"string\" && (m.visibility ?? \"list\") === \"list\")\n .map((m) => m.slug as string);\n}\n\n// ── Callback ───────────────────────────────────────────────\n\ninterface CallbackListener {\n port: number;\n /** Resolves with the callback query once a request with our state arrives. */\n result: Promise<URLSearchParams>;\n close: () => void;\n}\n\n/**\n * Bind the loopback callback. The first registration uses the documented port;\n * a returning sign-in may take any free one (only the port may vary). When no\n * port binds, the paste route still works with the preferred URI.\n */\nasync function startCallbackListener(\n expectedState: string,\n anyPort: boolean,\n): Promise<CallbackListener> {\n let settle: { resolve: (p: URLSearchParams) => void; reject: (e: Error) => void } | undefined;\n const result = new Promise<URLSearchParams>((resolve, reject) => {\n settle = { resolve, reject };\n });\n result.catch(() => {});\n const server = http.createServer((req, res) => {\n const url = new URL(req.url || \"\", \"http://127.0.0.1\");\n if (url.pathname !== CALLBACK_PATH) {\n res.statusCode = 404;\n res.end(\"Not found\");\n return;\n }\n if (url.searchParams.get(\"state\") !== expectedState) {\n res.statusCode = 400;\n res.end(\"State mismatch\");\n return;\n }\n res.writeHead(200, { \"Content-Type\": \"text/html\" });\n res.end(\n \"<html><body><h1>You're signed in to GG Coder.</h1><p>You can close this tab.</p></body></html>\",\n );\n settle?.resolve(url.searchParams);\n server.close();\n });\n\n const listen = (port: number) =>\n new Promise<number>((resolve, reject) => {\n const onError = (err: Error) => reject(err);\n server.once(\"error\", onError);\n server.listen(port, \"127.0.0.1\", () => {\n server.off(\"error\", onError);\n resolve((server.address() as AddressInfo).port);\n });\n });\n\n let port: number | undefined;\n for (const candidate of anyPort ? [PREFERRED_PORT, 0] : [PREFERRED_PORT]) {\n try {\n port = await listen(candidate);\n break;\n } catch {\n // Port in use: try the next one, or fall back to paste only.\n }\n }\n if (port === undefined) {\n settle?.reject(new Error(\"Could not start the local sign-in listener.\"));\n return { port: PREFERRED_PORT, result, close: () => {} };\n }\n\n // Backstop so this route always terminates.\n const timeout = setTimeout(() => server.close(), CALLBACK_TIMEOUT_MS);\n timeout.unref();\n server.on(\"close\", () => {\n clearTimeout(timeout);\n settle?.reject(new Error(\"Sign-in listener closed without a callback.\"));\n });\n return { port, result, close: () => server.close() };\n}\n\n/**\n * Wait for the browser callback and the paste route at once, taking whichever\n * arrives first. Over SSH the listener binds but never hears from the browser,\n * so the user pastes the callback URL instead (#20).\n */\nasync function acquireCallback(\n authUrl: string,\n expectedState: string,\n listener: CallbackListener,\n callbacks: OAuthLoginCallbacks,\n): Promise<URLSearchParams> {\n callbacks.onOpenUrl(authUrl);\n callbacks.onStatus(\"Waiting for ChatGPT sign-in in your browser...\");\n const done = new AbortController();\n try {\n return await new Promise<URLSearchParams>((resolve, reject) => {\n let listenerFailed = false;\n let pasteError: unknown;\n const failIfExhausted = (): void => {\n if (!listenerFailed || pasteError === undefined) return;\n reject(\n pasteError instanceof Error ? pasteError : new Error(\"Could not complete the sign-in.\"),\n );\n };\n listener.result.then(resolve, () => {\n listenerFailed = true;\n failIfExhausted();\n });\n promptForCallback(expectedState, callbacks, done.signal).then(resolve, (err: unknown) => {\n if (err instanceof FatalLoginError) {\n reject(err);\n return;\n }\n pasteError = err;\n failIfExhausted();\n });\n });\n } finally {\n done.abort();\n listener.close();\n }\n}\n\n/** A failure that should end the whole login rather than defer to the other route. */\nclass FatalLoginError extends Error {}\n\n/** The callback query from a pasted URL or query string, or undefined for anything else. */\nexport function parseCallbackInput(input: string): URLSearchParams | undefined {\n const value = input.trim();\n if (!value) return undefined;\n try {\n return new URL(value).searchParams;\n } catch {\n // Not a full URL.\n }\n if (/[?&]?(code|error)=/.test(value)) {\n return new URLSearchParams(value.replace(/^.*\\?/, \"\"));\n }\n return undefined;\n}\n\nasync function promptForCallback(\n expectedState: string,\n callbacks: OAuthLoginCallbacks,\n signal: AbortSignal,\n): Promise<URLSearchParams> {\n let message =\n \"If the browser is on another machine (SSH/headless), paste the full callback URL here:\";\n // Re-ask on a fumbled paste, but bounded: a caller that can't collect input\n // would otherwise spin here and this route could never fail.\n for (let attempt = 0; attempt < MAX_PASTE_ATTEMPTS; attempt++) {\n if (signal.aborted) throw new Error(\"Callback already received.\");\n const raw = await callbacks.onPromptCode(message, signal);\n if (signal.aborted) throw new Error(\"Callback already received.\");\n const params = parseCallbackInput(raw);\n if (params && (params.get(\"code\") || params.get(\"error\"))) {\n if (params.get(\"state\") !== expectedState) {\n throw new FatalLoginError(\"Sign-in state mismatch — start the sign-in again.\");\n }\n return params;\n }\n message = \"That wasn't a callback URL. Paste the full URL from the browser's address bar:\";\n }\n throw new Error(\"No callback URL found in input.\");\n}\n\n// ── Tokens ─────────────────────────────────────────────────\n\ninterface TokenSet {\n accessToken: string;\n refreshToken: string;\n idToken: string;\n expiresIn: number;\n scopes: string[];\n}\n\ninterface TokenResponse {\n access_token?: string;\n refresh_token?: string;\n id_token?: string;\n expires_in?: number;\n scope?: string;\n}\n\nasync function postForm(url: string, form: Record<string, string>): Promise<Response> {\n return fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(form),\n signal: oauthRequestSignal(),\n });\n}\n\nasync function exchangeCode(input: {\n code: string;\n verifier: string;\n clientId: string;\n redirectUri: string;\n}): Promise<TokenSet> {\n const response = await postForm(TOKEN_URL, {\n grant_type: \"authorization_code\",\n client_id: input.clientId,\n code: input.code,\n code_verifier: input.verifier,\n redirect_uri: input.redirectUri,\n resource: RESOURCE,\n });\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new Error(`ChatGPT token exchange failed (${response.status}): ${text.slice(0, 300)}`);\n }\n const data = (await response.json()) as TokenResponse;\n if (!data.access_token || !data.refresh_token || !data.id_token) {\n throw new Error(\"ChatGPT token response is missing tokens.\");\n }\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n idToken: data.id_token,\n expiresIn: data.expires_in ?? 3600,\n scopes: (data.scope ?? \"\").split(/\\s+/).filter(Boolean),\n };\n}\n\n/**\n * Renew the plan token with the issued client ID. The response replaces the\n * access token, its expiry, the granted scopes and the rotating refresh token.\n */\nexport async function refreshChatGPTToken(\n creds: Pick<OAuthCredentials, \"refreshToken\" | \"clientId\">,\n): Promise<OAuthCredentials> {\n if (!creds.clientId) {\n throw new Error(\"ChatGPT token refresh failed (401): no issued client ID saved\");\n }\n const response = await postForm(TOKEN_URL, {\n grant_type: \"refresh_token\",\n client_id: creds.clientId,\n refresh_token: creds.refreshToken,\n resource: RESOURCE,\n });\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new Error(`ChatGPT token refresh failed (${response.status}): ${text.slice(0, 300)}`);\n }\n const data = (await response.json()) as TokenResponse;\n if (!data.access_token || !data.refresh_token) {\n throw new Error(\"ChatGPT token refresh returned no tokens.\");\n }\n const expiresIn = data.expires_in ?? 3600;\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: Date.now() + expiresIn * 1000,\n expiresIn,\n ...(data.id_token ? { idToken: data.id_token } : {}),\n ...(data.scope ? { scopes: data.scope.split(/\\s+/).filter(Boolean) } : {}),\n };\n}\n\n/**\n * End the renewable session on sign-out. True when OpenAI confirmed it; callers\n * clear local tokens either way and say so when it wasn't confirmed.\n */\nexport async function revokeChatGPTSignIn(\n creds: Pick<OAuthCredentials, \"refreshToken\" | \"clientId\">,\n options: { attempts?: number; backoffMs?: number } = {},\n): Promise<boolean> {\n if (!creds.clientId || !creds.refreshToken) return false;\n const attempts = options.attempts ?? 3;\n for (let attempt = 0; attempt < attempts; attempt++) {\n try {\n const response = await postForm(REVOKE_URL, {\n token: creds.refreshToken,\n token_type_hint: \"refresh_token\",\n client_id: creds.clientId,\n });\n if (response.ok) return true;\n if (response.status < 500) return false;\n } catch {\n // Network failure: retry while the token is still available.\n }\n if (attempt < attempts - 1) {\n await new Promise((r) => setTimeout(r, (options.backoffMs ?? 500) * 2 ** attempt));\n }\n }\n return false;\n}\n\n// ── ID token ───────────────────────────────────────────────\n\nfunction decodeSegment(segment: string): Record<string, unknown> {\n return JSON.parse(Buffer.from(segment, \"base64url\").toString(\"utf-8\")) as Record<string, unknown>;\n}\n\n/** Check the ID token's RS256 signature against OpenAI's JWKS, then iss, aud, exp and nonce. */\nexport async function verifyIdToken(\n idToken: string,\n expected: { clientId: string; nonce: string; now?: number },\n): Promise<Record<string, unknown>> {\n const [headerSeg, payloadSeg, signatureSeg] = idToken.split(\".\");\n if (!headerSeg || !payloadSeg || !signatureSeg) throw new Error(\"Malformed ChatGPT ID token.\");\n const header = decodeSegment(headerSeg);\n if (header.alg !== \"RS256\") throw new Error(\"Unexpected ChatGPT ID token algorithm.\");\n\n const response = await fetch(JWKS_URL, { signal: oauthRequestSignal() });\n if (!response.ok) throw new Error(`Could not load OpenAI signing keys (${response.status}).`);\n const { keys = [] } = (await response.json()) as {\n keys?: Array<crypto.webcrypto.JsonWebKey & { kid?: string }>;\n };\n const jwk = keys.find((k) => k.kid === header.kid);\n if (!jwk) throw new Error(\"ChatGPT ID token is signed with an unknown key.\");\n const valid = crypto.verify(\n \"RSA-SHA256\",\n Buffer.from(`${headerSeg}.${payloadSeg}`),\n crypto.createPublicKey({ key: jwk, format: \"jwk\" }),\n Buffer.from(signatureSeg, \"base64url\"),\n );\n if (!valid) throw new Error(\"ChatGPT ID token signature is invalid.\");\n\n const claims = decodeSegment(payloadSeg);\n const now = Math.floor((expected.now ?? Date.now()) / 1000);\n const aud = claims.aud;\n const audiences = Array.isArray(aud) ? aud : [aud];\n if (claims.iss !== ISSUER) throw new Error(\"ChatGPT ID token has the wrong issuer.\");\n if (!audiences.includes(expected.clientId)) {\n throw new Error(\"ChatGPT ID token was issued to another app.\");\n }\n if (typeof claims.exp !== \"number\" || claims.exp + ID_TOKEN_CLOCK_SKEW_S < now) {\n throw new Error(\"ChatGPT ID token has expired.\");\n }\n if (claims.nonce !== expected.nonce) throw new Error(\"ChatGPT ID token nonce mismatch.\");\n return claims;\n}\n","import fs from \"node:fs/promises\";\nimport { setTimeout } from \"node:timers/promises\";\n\nconst STALE_TIMEOUT_MS = 10_000; // Lock is stale after 10s\nconst RETRY_INTERVAL_MS = 50; // Retry every 50ms\nconst MAX_WAIT_MS = 5_000; // Give up after 5s\n// Windows refuses `wx` with one of these, not EEXIST, while a released lock is pending delete.\nconst PENDING_DELETE_CODES = new Set([\"EACCES\", \"EBUSY\", \"EPERM\"]);\n\ninterface LockInfo {\n pid: number;\n timestamp: number;\n}\n\n/**\n * Simple file-based lock with PID tracking and stale detection.\n * Uses atomic file creation (wx flag) to prevent races.\n */\nexport async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {\n const lockPath = filePath + \".lock\";\n await acquireLock(lockPath);\n try {\n return await fn();\n } finally {\n await releaseLock(lockPath);\n }\n}\n\nasync function acquireLock(lockPath: string): Promise<void> {\n const startTime = Date.now();\n let refusedWithoutLock = 0;\n\n while (true) {\n try {\n // O_EXCL: fail if file exists — atomic lock acquisition\n const info: LockInfo = { pid: process.pid, timestamp: Date.now() };\n await fs.writeFile(lockPath, JSON.stringify(info), { flag: \"wx\" });\n return; // Lock acquired\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code !== \"EEXIST\") {\n if (process.platform !== \"win32\" || !code || !PENDING_DELETE_CODES.has(code)) throw err;\n // A refusal that persists without a lock file, or past the wait budget, is a real fault.\n const lockPresent = await lockFileExists(lockPath);\n if (!lockPresent && ++refusedWithoutLock > 3) throw err;\n if (Date.now() - startTime > MAX_WAIT_MS) throw err;\n await setTimeout(RETRY_INTERVAL_MS);\n continue;\n }\n\n // Lock file exists — check if it's stale\n try {\n const content = await fs.readFile(lockPath, \"utf-8\");\n const info = JSON.parse(content) as LockInfo;\n\n // Check if the holding process is still alive\n const isProcessAlive = isAlive(info.pid);\n const isStale = Date.now() - info.timestamp > STALE_TIMEOUT_MS;\n\n if (!isProcessAlive || isStale) {\n // Stale lock — remove and retry\n await fs.unlink(lockPath).catch(() => {});\n continue;\n }\n } catch {\n // Unreadable: released meanwhile, just created and not yet written (a\n // live holder — deleting it let two writers in), or truly corrupt.\n const stat = await fs.stat(lockPath).catch(() => null);\n if (!stat) continue;\n if (Date.now() - stat.mtimeMs > STALE_TIMEOUT_MS) {\n await fs.unlink(lockPath).catch(() => {});\n continue;\n }\n }\n\n // Lock is held by a live process — wait and retry\n if (Date.now() - startTime > MAX_WAIT_MS) {\n // Timeout — force break the lock (better than deadlocking)\n await fs.unlink(lockPath).catch(() => {});\n continue;\n }\n\n await setTimeout(RETRY_INTERVAL_MS);\n }\n }\n}\n\nasync function lockFileExists(lockPath: string): Promise<boolean> {\n try {\n await fs.stat(lockPath);\n return true;\n } catch (err) {\n // A refused stat means the file is still there, mid-delete.\n return (err as NodeJS.ErrnoException).code !== \"ENOENT\";\n }\n}\n\nasync function releaseLock(lockPath: string): Promise<void> {\n await fs.unlink(lockPath).catch(() => {});\n}\n\nfunction isAlive(pid: number): boolean {\n try {\n process.kill(pid, 0); // Signal 0 = check existence without killing\n return true;\n } catch (err) {\n // EPERM = process exists but belongs to a different user — still alive\n if ((err as NodeJS.ErrnoException).code === \"EPERM\") return true;\n // ESRCH = no such process — it's dead\n return false;\n }\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\nimport { environmentSecrets, redactText, redactValue } from \"@kenkaiiii/gg-ai\";\n\nexport type LogLevel = \"INFO\" | \"ERROR\" | \"WARN\" | \"DEBUG\";\n\n// Cross-session log retention: the log is appended across launches so you can\n// grep back through prior sessions. Rotated at MAX_BYTES to keep it bounded; we\n// keep one generation (debug.log.1) — enough to survive one rotation's worth of\n// scrollback while bounding disk usage.\nconst MAX_BYTES = 10 * 1024 * 1024; // 10 MB\n\n/**\n * Rotate when fewer than this many bytes remain, instead of only at/over the cap.\n *\n * A process that hits the cap mid-run stops writing at whatever size it reached,\n * which can be a hair UNDER MAX_BYTES. Rotating only at `size >= MAX_BYTES` then\n * wedges the log permanently: every later launch reopens the same near-full file,\n * has no room for a single line, and disables its own logging immediately — so the\n * file can never grow enough to qualify for rotation. That silently cost the app\n * sidecar ~2 weeks of diagnostics (the file sat 4 bytes short of the cap, gaining\n * one newline per launch). Requiring real headroom guarantees forward progress.\n */\nconst MIN_HEADROOM_BYTES = 64 * 1024; // 64 KB\n\nlet fd: number | null = null;\nlet logPath: string | null = null;\n/** Current size of the open log file, including earlier launches' lines. Drives rotation. */\nlet fileBytes = 0;\n/**\n * Bytes THIS process has written. Drives the hard per-process cap. Kept apart\n * from `fileBytes` because the file is shared across launches: a long-lived\n * daemon that started next to a nearly full file used to inherit that size as\n * its own budget, hit the cap within minutes, and log nothing for days.\n */\nlet processBytes = 0;\n/** Set when a mid-run rotation could not shrink the file; stop retrying it per line. */\nlet rotationFailed = false;\nlet capped = false;\nlet sessionId = \"\";\nlet appName = \"app\";\nlet cleanups: (() => void)[] = [];\nlet exactSecrets: string[] = [];\n\nfunction rotateIfNeeded(filePath: string, force = false): void {\n try {\n const st = fs.statSync(filePath);\n if (!force && st.size <= MAX_BYTES - MIN_HEADROOM_BYTES) return;\n const rotated = `${filePath}.1`;\n // Replace prior rotation (fs.renameSync overwrites on POSIX; on Windows it\n // fails if dest exists, so unlink first defensively).\n try {\n fs.unlinkSync(rotated);\n } catch {\n // No prior rotation\n }\n fs.renameSync(filePath, rotated);\n } catch {\n // Log file doesn't exist yet or stat failed — nothing to rotate\n }\n}\n\n/**\n * The shared file filled up while this process still has write budget: move it\n * to `.1` and continue in a fresh file. Returns false if logging had to stop.\n */\nfunction rotateOpenLog(): boolean {\n if (fd === null || logPath === null) return false;\n const sizeBefore = fileBytes;\n try {\n fs.closeSync(fd);\n } catch {\n // Closing a descriptor we are about to replace; nothing to recover.\n }\n fd = null;\n rotateIfNeeded(logPath, true);\n try {\n fd = fs.openSync(logPath, \"a\");\n fileBytes = fs.fstatSync(fd).size;\n } catch {\n if (fd !== null) {\n try {\n fs.closeSync(fd);\n } catch {\n // Ignore cleanup failure\n }\n }\n fd = null;\n capped = true;\n return false;\n }\n // Rename can fail (e.g. Windows sharing violations); the per-process cap still\n // bounds writes, so keep logging into the same file instead of retrying a\n // rename on every line.\n if (fileBytes >= sizeBefore) rotationFailed = true;\n return true;\n}\n\n/**\n * Open the debug log in append mode, tagging this process with a session id and\n * remembering `name` for the shutdown line. Idempotent — re-calling while open\n * is a no-op. Returns true only when it *newly* opened the file (so callers can\n * write a one-time startup line); returns false if already open or if the file\n * could not be opened.\n */\nexport function openLog(filePath: string, name: string): boolean {\n if (fd !== null || capped) return false;\n appName = name;\n exactSecrets = environmentSecrets(process.env);\n try {\n fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });\n } catch {\n // Directory may already exist or be uncreatable — fall through to open\n }\n rotateIfNeeded(filePath);\n try {\n fd = fs.openSync(filePath, \"a\");\n } catch {\n // Can't open log file — silently disable logging\n fd = null;\n fileBytes = 0;\n return false;\n }\n try {\n fileBytes = fs.fstatSync(fd).size;\n } catch {\n try {\n fs.closeSync(fd);\n } catch {\n // Ignore cleanup failure\n }\n fd = null;\n fileBytes = 0;\n return false;\n }\n logPath = filePath;\n processBytes = 0;\n rotationFailed = false;\n sessionId = randomBytes(4).toString(\"hex\");\n // Visible separator between sessions when back-reading the log.\n try {\n if (fileBytes < MAX_BYTES) {\n const written = fs.writeSync(fd, \"\\n\");\n fileBytes += written;\n processBytes += written;\n }\n } catch {\n // Write failed — proceed without the separator\n }\n return true;\n}\n\n/** Session identifier included on every log line as `sid=<id>`. */\nexport function getSessionId(): string {\n return sessionId;\n}\n\n/** True if the logger has an open file descriptor. */\nexport function isLoggerOpen(): boolean {\n return fd !== null;\n}\n\n/** Write a timestamped log line. No-op if the logger is not open. */\nexport function log(\n level: LogLevel,\n category: string,\n message: string,\n data?: Record<string, unknown>,\n): void {\n if (fd === null) return;\n const ts = new Date().toISOString();\n const safeMessage = redactText(message, { secrets: exactSecrets });\n let line = `[${ts}] [sid=${sessionId}] [${level}] [${category}] ${safeMessage}`;\n if (data) {\n const safeData = redactValue(data, { secrets: exactSecrets });\n const pairs = Object.entries(safeData)\n .map(([k, v]) => {\n if (typeof v === \"string\") return `${k}=${v}`;\n if (typeof v === \"bigint\") return `${k}=${String(v)}`;\n return `${k}=${JSON.stringify(v)}`;\n })\n .join(\" \");\n if (pairs) line += ` ${pairs}`;\n }\n line += \"\\n\";\n\n const lineBytes = Buffer.byteLength(line);\n if (processBytes + lineBytes > MAX_BYTES) {\n // A noisy production path must not turn one long-lived process into an\n // unbounded SSD writer. Stop file logging for this process once IT has\n // written MAX_BYTES; later launches get a fresh budget.\n const capLine = `[${ts}] [sid=${sessionId}] [WARN] [logger] Log cap reached; file logging disabled until restart\\n`;\n try {\n if (fileBytes + Buffer.byteLength(capLine) <= MAX_BYTES) {\n fileBytes += fs.writeSync(fd, capLine);\n }\n fs.closeSync(fd);\n } catch {\n // Write/close failure still disables logging below.\n }\n fd = null;\n capped = true;\n return;\n }\n\n // The shared file is full but this process still has budget: rotate and\n // keep logging rather than going dark for the rest of a long-lived run.\n if (fileBytes + lineBytes > MAX_BYTES && !rotationFailed && !rotateOpenLog()) return;\n if (fd === null) return;\n\n try {\n const written = fs.writeSync(fd, line);\n fileBytes += written;\n processBytes += written;\n } catch {\n // Write failed — don't crash\n }\n}\n\n/**\n * Register a cleanup callback (e.g. an EventBus unsubscriber) to run when the\n * logger closes. Lets app-side bridges hook into the shared lifecycle without\n * the core needing to know about app types.\n */\nexport function registerLogCleanup(fn: () => void): void {\n cleanups.push(fn);\n}\n\n/**\n * Write a shutdown line (unless suppressed), close the file descriptor, and run\n * any registered cleanups.\n */\nexport function closeLogger(opts?: { shutdownLine?: boolean }): void {\n if (fd !== null) {\n if (opts?.shutdownLine !== false) log(\"INFO\", \"shutdown\", `${appName} shutting down`);\n try {\n if (fd !== null) fs.closeSync(fd);\n } catch {\n // Ignore close errors\n }\n }\n fd = null;\n logPath = null;\n fileBytes = 0;\n processBytes = 0;\n rotationFailed = false;\n capped = false;\n exactSecrets = [];\n for (const unsub of cleanups) unsub();\n cleanups = [];\n}\n","/**\n * Providers temporarily hidden from every user-facing surface: the login lists\n * (CLI + app), the model pickers, and startup provider resolution. Their models,\n * transports and stored credentials stay in place, so un-hiding is a one-line\n * change here (plus `HIDDEN_PROVIDERS` in gg-app/src-tauri/src/auth.rs).\n *\n * None are hidden today: Gemini came back with an AI Studio API key.\n *\n * Hiding works through `AuthStorage.hasProviderAuth()`, which reports a hidden\n * provider as not connected: every picker and the startup resolver filter on\n * it, so a user with old Gemini credentials falls back to another provider.\n */\nexport const HIDDEN_PROVIDERS: readonly string[] = [];\n\n/** True when `provider` is hidden from login lists, pickers and startup. */\nexport function isHiddenProvider(provider: string): boolean {\n return HIDDEN_PROVIDERS.includes(provider);\n}\n"],"mappings":"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","names":["import_promises","import_node_fs","import_node_crypto","import_node_fs","import_promises","import_node_path","import_node_crypto","import_promises","import_node_path","import_promises","import_node_fs","import_node_path","import_node_crypto","import_gg_ai","import_gg_ai"]}
|
|
1
|
+
{"version":3,"sources":["../src/model-registry.ts","../src/kimi-region.ts","../src/auth-storage.ts","../src/paths.ts","../src/claude-code/runtime.ts","../src/claude-code/tar.ts","../src/oauth/openai.ts","../src/file-lock.ts","../src/logger.ts","../src/hidden-providers.ts"],"sourcesContent":["import type { Provider, ThinkingLevel } from \"@kenkaiiii/gg-ai\";\nimport { isKimiCodingEndpoint } from \"./kimi-region.js\";\nimport { GEMINI_API_KEY, KIMI_CODE_KEY, XIAOMI_CREDITS_KEY } from \"./auth-storage.js\";\nimport { isHiddenProvider } from \"./hidden-providers.js\";\n\nexport { HIDDEN_PROVIDERS, isHiddenProvider } from \"./hidden-providers.js\";\n\nexport interface ModelInfo {\n id: string;\n name: string;\n provider: Provider;\n contextWindow: number;\n /**\n * The ChatGPT plan route uses product-specific windows that can differ from\n * the public API model window. ChatGPT plan requests carry an accountId;\n * API-key requests do not.\n */\n codexContextWindow?: number;\n maxOutputTokens: number;\n supportsThinking: boolean;\n /**\n * Vendor-declared default reasoning level (Codex models.json\n * `default_reasoning_level`). When present, fresh sessions start here rather\n * than at the ceiling: the deep-reasoning models (Astra and GPT-6.1 Sol ship\n * \"low\", GPT-6 Luna \"medium\") think dramatically longer per rung, so defaulting to\n * `maxThinkingLevel` made new sessions pathologically slow.\n */\n defaultThinkingLevel?: ThinkingLevel;\n supportsImages: boolean;\n supportsVideo: boolean;\n /**\n * Max video payload (bytes) this model's transport accepts, used to decide\n * when an attached/read video must be compressed before sending. Differs by\n * provider delivery mechanism:\n * - Moonshot/Kimi: 100 MB (file-service upload cap)\n * - MiniMax: 50 MB (Anthropic-compatible base64 inline cap)\n * - Gemini: 20 MB (inlineData per-request cap)\n * - Xiaomi (MiMo): ~36 MB raw — the API caps the base64 STRING at 50 MB,\n * and base64 inflates bytes by ~4/3, so 36 MB raw ≈ 48 MB encoded.\n * Only meaningful when `supportsVideo` is true.\n */\n maxVideoBytes?: number;\n costTier: \"low\" | \"medium\" | \"high\";\n /**\n * The top reasoning tier this model genuinely uses. Used when thinking is\n * enabled to pick the strongest setting per model:\n * - OpenAI GPT-6 Astra / GPT-6.1 Sol: `ultra` (Codex orchestration preset above `max`)\n * - OpenAI GPT-6 Luna: `max`\n * - OpenAI Pro/Codex/old: clamped to what the model accepts\n * - Claude Fable 5.2 / Fable 5.1 / Mythos 5, Opus 5.5, Sonnet 5.5 and\n * Haiku 5.5: `max` (the Fable / Mythos line uses always-on adaptive\n * thinking, low→max)\n * - Kimi K3: `max` (always-on reasoning; currently the only API effort)\n * - xAI Grok 4.6: `xhigh` (new top rung; 4.5 caps at `high`)\n * - GLM / Kimi K2.x / Xiaomi / MiniMax / Qwen: `high` — binary-thinking\n * providers ignore the level on the wire, so the value is cosmetic\n * - DeepSeek V4: `xhigh` (DeepSeek maps `xhigh` → its internal `max`)\n */\n maxThinkingLevel: ThinkingLevel;\n /**\n * Effort rungs the endpoint itself declared (a synced gateway catalog's\n * `reasoning.supported_efforts`). When set, the picker offers exactly these\n * and the transport sends the chosen one unchanged. Absent on static models.\n */\n thinkingLevels?: ThinkingLevel[];\n /**\n * Ordered preference of auth-storage keys this model resolves credentials\n * from, for providers that split auth across multiple distinct\n * endpoints/keys (a plan key vs. a pay-per-token key). The first key with stored credentials wins, so a\n * model can both prefer one endpoint AND fall back to another the user has\n * configured instead:\n * - `mimo-v2.6-pro` / `mimo-v2.6-flash`: `[\"xiaomi\", XIAOMI_CREDITS_KEY]` —\n * prefer the Token Plan, fall back to API Credits (API Credits serves\n * every MiMo model, so a Credits-only user still reaches these).\n * - `mimo-v2.6-pro-ultraspeed`: `[XIAOMI_CREDITS_KEY]` only — not served\n * over the Token Plan endpoint, so there's no fallback to it.\n * - Kimi: `[KIMI_CODE_KEY, \"moonshot\"]` — the Kimi Code plan key first;\n * K2.8 Preview is `[KIMI_CODE_KEY]` only (the plan endpoint serves it).\n * - every Gemini model: `[GEMINI_API_KEY]` — the AI Studio key.\n * Falls back to `[provider]` — the normal single-credential case — when\n * unset. Read via `getAuthStorageKeys()` / `getAuthStorageKey()`.\n */\n authStorageKeys?: string[];\n /**\n * How this model bills beyond the normal plan/API rate, shown wherever the\n * model is picked or active. Only set where picking the model changes what\n * the user pays: `paid` marks real money (Claude fast mode bills usage\n * credits, never included plan usage), unset `paid` a plan-usage multiplier.\n */\n billing?: ModelBilling;\n}\n\nexport interface ModelBilling {\n /** Short picker label, e.g. \"bills usage credits\". */\n label: string;\n /** One-sentence explanation for tooltips and notes. */\n detail: string;\n /** True when choosing the model spends real money, not just plan allowance. */\n paid: boolean;\n /**\n * The same-provider model to run instead wherever a paid tier must not be\n * carried silently (saved defaults, restored sessions, Ken, sub-agents, goal\n * workers). Required when `paid` is true; registry tests enforce that it is\n * registered, same-provider and itself not paid.\n */\n standardModel?: string;\n}\n\n/** Claude fast mode: billed to usage credits from the first token. */\nconst CLAUDE_FAST_BILLING: ModelBilling = {\n label: \"bills usage credits\",\n detail:\n \"Fast mode bills your Claude usage credits ($8 in / $40 out per million tokens) from the first token, never your plan's included usage.\",\n paid: true,\n standardModel: \"claude-opus-5-5\",\n};\n\n/** Claude Fable 5.2 fast mode: usage credits at double Fable's standard rate. */\nconst CLAUDE_FABLE_FAST_BILLING: ModelBilling = {\n label: \"bills usage credits\",\n detail:\n \"Fast mode bills your Claude usage credits ($16 in / $80 out per million tokens) from the first token, never your plan's included usage.\",\n paid: true,\n standardModel: \"claude-fable-5-2\",\n};\n\n/** MiMo UltraSpeed: API Credits only, billed per token, never the Token Plan. */\nconst XIAOMI_ULTRASPEED_BILLING: ModelBilling = {\n label: \"bills API Credits\",\n detail:\n \"UltraSpeed runs only on your Xiaomi API Credits, billed per token at a higher rate than MiMo-V2.6-Pro, never on the Token Plan.\",\n paid: true,\n standardModel: \"mimo-v2.6-pro\",\n};\n\n/** Codex Fast mode: plan limits at 2.5x (purchased credits at 2x). */\nconst CODEX_FAST_BILLING: ModelBilling = {\n label: \"2.5x plan usage\",\n detail:\n \"Codex Fast mode uses your ChatGPT plan limits at 2.5x the standard rate (purchased credits at 2x).\",\n paid: false,\n};\n\n// Provider display order — mirrors `PROVIDERS` in ui/login.tsx so the\n// /model selector and login selector sort models identically.\n/** Gemini connects with an AI Studio API key only. */\nconst GEMINI_AUTH_KEYS = [GEMINI_API_KEY];\n\n/** Kimi credential order: a Kimi Code plan key first, then a Moonshot key. */\nconst KIMI_AUTH_KEYS = [KIMI_CODE_KEY, \"moonshot\"];\n\nexport const MODELS: ModelInfo[] = [\n // ── Anthropic ──────────────────────────────────────────\n // NOTE: Claude Mythos 5.2 (`claude-mythos-5-2`, 2026-11-09) is kept commented\n // out — it's only for organizations verified through Anthropic's verification\n // programs and unavailable to most users. Re-enable once it's generally\n // available.\n {\n // Released 2026-11-09 — replaces Fable 5.1 at $8/$40 MTok (down from\n // $10/$50; cache reads $0.20). Same API surface as 5.1: always-on adaptive\n // thinking steered by effort, forced tool use rejected with a 400 (gg-ai\n // never sends it on the Anthropic path). Fable 5.1 is retired here — a\n // session that still has it saved falls back to the provider default.\n id: \"claude-fable-5-2\",\n name: \"Claude Fable 5.2\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"max\",\n },\n {\n // Fable 5.2 in fast mode: the same model at up to 2.5x output tokens/sec,\n // $16/$80 MTok. Sent as `claude-fable-5-2` + `speed: \"fast\"`; on a Claude\n // subscription it bills usage credits only, like Opus 5.5 Fast.\n id: \"claude-fable-5-2-fast\",\n name: \"Claude Fable 5.2 Fast\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"max\",\n billing: CLAUDE_FABLE_FAST_BILLING,\n },\n // {\n // // Mythos-class model offered through Project Glasswing (limited\n // // availability, verified organizations only). Same specs and pricing as\n // // Fable 5.2; kept here so approved accounts can select it.\n // id: \"claude-mythos-5-2\",\n // name: \"Claude Mythos 5.2\",\n // provider: \"anthropic\",\n // contextWindow: 1_000_000,\n // maxOutputTokens: 128_000,\n // supportsThinking: true,\n // supportsImages: true,\n // supportsVideo: false,\n // costTier: \"high\",\n // maxThinkingLevel: \"max\",\n // },\n {\n // Released 2026-09-22 — \"built for long-running agentic coding and knowledge\n // work\". Fable-5.1-class output at $4/$20 MTok (40% below Opus 5), 1M\n // context, 128K output, image input. Same always-on adaptive thinking with\n // the full effort ladder (low→max, xhigh included; the API-side default\n // effort is `medium`) and the same Fable 5.1 constraints gg-ai already\n // handles (no forced tool use, thinking can't be disabled). Fast mode is\n // the separate `claude-opus-5-5-fast` entry below. Opus 5 is retired here — a session that\n // still has it saved falls back to the provider default on next start.\n id: \"claude-opus-5-5\",\n name: \"Claude Opus 5.5\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n // Anthropic's API and Claude Code both start the 5.5 family at medium; at a\n // given level 5.5 thinks more than 5, so the ladder ceiling (max) made fresh\n // sessions slow and expensive. Users can still opt up.\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n },\n {\n // Opus 5.5 in fast mode (research preview): the same model at up to 2.5x\n // output tokens/sec, $8/$40 MTok. gg-ai's `stream()` sends it as\n // `claude-opus-5-5` + `speed: \"fast\"` + the fast-mode beta. On a Claude\n // subscription it bills usage credits only — never included plan usage —\n // and Anthropic 429s \"Usage credits are required for fast mode\" until they\n // are turned on. Its prompt cache is separate from standard-speed Opus.\n id: \"claude-opus-5-5-fast\",\n name: \"Claude Opus 5.5 Fast\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n billing: CLAUDE_FAST_BILLING,\n },\n {\n // Released 2026-09-28 — replaces Sonnet 5 at $2/$10 MTok, with the same\n // 1M context / 128K output and adaptive thinking, now including xhigh.\n id: \"claude-sonnet-5-5\",\n name: \"Claude Sonnet 5.5\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n // We start Sonnet 5.5 at medium like the rest of the 5.5 family (the API's\n // own default for Sonnet 5.5 is high). At a given level 5.5 thinks more\n // than 5, so the ladder ceiling (max) made fresh sessions slow and\n // expensive. Users can still opt up.\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n },\n {\n // Released 2026-10-07 — replaces Haiku 4.5 at $0.10/$0.50 MTok (prompts\n // up to 100K tokens; $0.50/$2.50 above). 1M context, 128K output, image\n // input. Adaptive thinking with the full effort ladder (low→max, xhigh\n // included; API default `medium`) — manual budget_tokens, non-default\n // sampling params and assistant prefill all 400. No server-side refusal\n // fallback: `fallbacks: \"default\"` is accepted but a refusal stays a\n // refusal. Haiku 4.5 is retired here — a session that still has it saved\n // falls back to the provider default on next start.\n id: \"claude-haiku-5-5\",\n name: \"Claude Haiku 5.5\",\n provider: \"anthropic\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n // Anthropic's API and Claude Code both start the 5.5 family at medium; at a\n // given level 5.5 thinks more than 5, so the ladder ceiling (max) made fresh\n // sessions slow and expensive. Users can still opt up.\n defaultThinkingLevel: \"medium\",\n maxThinkingLevel: \"max\",\n },\n // ── OpenAI (Codex) ─────────────────────────────────────\n {\n // GPT-6 Astra — \"Our most capable model for complex, demanding work.\"\n // (Codex catalog priority 2, listed for every ChatGPT plan, requires a\n // Codex client >= 0.153.0). Same split as 5.6:\n // 1.05M on the public Responses API, 272K on the ChatGPT plan route\n // (openai/codex models.json, `gpt-6-astra`). Reasoning ladder low → medium\n // → high → xhigh → max → ultra; `ultra` is the Codex orchestration preset\n // (multi_agent v2) and is Codex-only — the public API tops out at `max`.\n // OpenAI requires the Responses API for tool calling on Astra, so both\n // the ChatGPT plan and API keys ride gg-ai's Responses transport.\n id: \"gpt-6-astra\",\n name: \"GPT-6 Astra\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"ultra\",\n },\n {\n // Codex Fast mode for Astra (catalog `service_tiers: priority`, \"2x speed,\n // increased usage\"). gg-ai's `stream()` sends it as `gpt-6-astra` +\n // `service_tier: \"priority\"`, exactly what the Codex CLI sends. On a\n // ChatGPT plan it draws included limits at 2.5x the standard rate.\n id: \"gpt-6-astra-fast\",\n name: \"GPT-6 Astra Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n maxThinkingLevel: \"ultra\",\n billing: CODEX_FAST_BILLING,\n },\n // GPT-6 Sol + Luna — released 2026-09-22 below Astra, replacing the whole\n // GPT-5.6 family (Sol/Terra/Luna; there was no GPT-6 Terra — OpenAI's Codex\n // catalog upgrades 5.6 Terra to 6 Sol). GPT-6.1 Sol (2026-09-29) then replaced\n // GPT-6 Sol, and GPT-6.1 Terra (2026-10-26) sits between Sol and Luna. Same window split as Astra: 1.05M on the public Responses API,\n // 272K on the Codex route; 128K output, text+image input, freeform\n // apply_patch, responses-lite transport. The GPT-5.6 ids and `gpt-6-sol` are\n // retired — a saved session on one falls back to the provider default on\n // next start.\n {\n // GPT-6.1 Sol — \"Latest workhorse model for coding and everyday work.\"\n // (Codex priority 1, default low). $2/$10 MTok, cached input $0.10. Ladder low →\n // medium → high → xhigh → max → ultra; ultra is the Codex orchestration\n // preset (xhigh effort on the wire + proactive local subagent delegation).\n id: \"gpt-6.1-sol\",\n name: \"GPT-6.1 Sol\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n },\n {\n // Codex Fast mode for 6.1 Sol (\"2x speed, increased usage\") — see\n // `gpt-6-astra-fast` for the wire mapping and billing.\n id: \"gpt-6.1-sol-fast\",\n name: \"GPT-6.1 Sol Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n billing: CODEX_FAST_BILLING,\n },\n {\n // GPT-6.1 Terra (Codex priority 7, default low, client >= 0.153.0). $1/$5\n // MTok, cached input $0.05. Unlike Sol/Astra the Codex route gets a 500K\n // window (catalog `context_window`). Same ladder and Ultra preset as Sol.\n id: \"gpt-6.1-terra\",\n name: \"GPT-6.1 Terra\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 500_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n },\n {\n // Codex Fast mode for 6.1 Terra (\"1.5x speed\") — see `gpt-6-astra-fast`\n // for the wire mapping and billing.\n id: \"gpt-6.1-terra-fast\",\n name: \"GPT-6.1 Terra Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 500_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"low\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"ultra\",\n billing: CODEX_FAST_BILLING,\n },\n {\n // Luna — \"Fast and affordable model for easier tasks.\" (Codex priority 4,\n // default medium, needs a Codex client >= 0.155.0). $0.10/$0.50 MTok.\n // Reasoning tops out at `max`.\n id: \"gpt-6-luna\",\n name: \"GPT-6 Luna\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"medium\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n {\n // Codex Fast mode for Luna (\"1.5x speed\") — see `gpt-6-astra-fast` for the\n // wire mapping and billing. Listed after Luna so compaction summaries\n // (first `low` OpenAI model) stay on standard-speed Luna.\n id: \"gpt-6-luna-fast\",\n name: \"GPT-6 Luna Fast\",\n provider: \"openai\",\n contextWindow: 1_050_000,\n codexContextWindow: 272_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n defaultThinkingLevel: \"medium\",\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n billing: CODEX_FAST_BILLING,\n },\n // ── Sakana (Fugu) ──────────────────────────────────────\n // Sakana Fugu is a multi-agent system surfaced as a standard LLM via the\n // OpenAI-compatible Sakana API (https://api.sakana.ai/v1). All three take\n // text + image input (verified against the live /models list, 2026-09-28).\n // `fugu` balances latency and quality; `fugu-max` (v1.0, 2026-09-11) is the\n // cost tier over the largest open-weight pool ($2/$6 per 1M); `fugu-ultra`\n // is the heavier quality tier (may need larger client timeouts). Plain Fugu\n // and Fugu Max stop at xhigh — Sakana documents max as the same effort there.\n {\n id: \"fugu\",\n name: \"Fugu\",\n provider: \"sakana\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"xhigh\",\n },\n {\n id: \"fugu-max\",\n name: \"Fugu Max\",\n provider: \"sakana\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"xhigh\",\n },\n {\n id: \"fugu-ultra\",\n name: \"Fugu Ultra\",\n provider: \"sakana\",\n contextWindow: 1_000_000,\n maxOutputTokens: 128_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"high\",\n // The rolling alias now serves v2.0 (2026-09-11), which keeps max effort.\n maxThinkingLevel: \"max\",\n },\n // ── xAI (Grok) ─────────────────────────────────────────\n // Grok 4.7 (released 2026-09-21) — xAI's flagship for coding, agentic tasks,\n // and knowledge work: a new, larger base model with a longer RL run weighted\n // toward hours-long tasks, plus stronger self-verification and long-context\n // management. 500K context, text+image input, and a `reasoning_effort`\n // ladder of low/medium/high default/xhigh (reasoning still can't be fully\n // disabled). $2/$6 per MTok under 200K prompt tokens ($4/$12 at or above),\n // and it's the default model of the Grok Build coding agent. xAI advertises\n // \"no fixed text output limit\"; we keep the 131K practical cap for budget\n // predictability and input headroom. (A faster \"Grok 4.7 Fast\" variant\n // exists but is Cursor/Grok Build-only — not on the public API — so it isn't\n // registered.) Only the newest Grok ships — 4.6/4.5 are superseded and\n // retired; saved sessions on them fall back to this default.\n {\n id: \"grok-4.7\",\n name: \"Grok 4.7\",\n provider: \"xai\",\n contextWindow: 500_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"xhigh\",\n },\n // ── Gemini ─────────────────────────────────────────\n {\n id: \"gemini-3.1-flash-lite\",\n name: \"Gemini 3.1 Flash Lite\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n // Keep 3.1 Flash Lite first for the default and fast-model routing.\n // Gemini 3.7\n // Flash is retired: Google deprecated it on 2026-10-08 and routes every\n // `gemini-3.7-flash` request to 3.8 Flash (ai.google.dev/gemini-api/docs/\n // changelog), so a saved session on it falls back to the provider default.\n {\n id: \"gemini-3.8-flash\",\n name: \"Gemini 3.8 Flash\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n {\n id: \"gemini-3.5-flash-lite\",\n name: \"Gemini 3.5 Flash Lite\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n {\n // Wire name `gemini-3-flash`, the id Google serves 3.5 Flash under.\n id: \"gemini-3-flash\",\n name: \"Gemini 3.5 Flash\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n {\n // Gemini 3.1 Pro is public preview.\n id: \"gemini-3.1-pro-preview\",\n name: \"Gemini 3.1 Pro (Preview)\",\n provider: \"gemini\",\n contextWindow: 1_048_576,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"high\",\n maxThinkingLevel: \"high\",\n authStorageKeys: GEMINI_AUTH_KEYS,\n },\n // ── Moonshot (Kimi) ────────────────────────────────────\n // K3 is Kimi's 2.8T-parameter flagship for long-horizon coding, knowledge\n // work, and deep reasoning. Its effort ladder is server-declared as\n // low/high/max on both the public API (default max) and the Kimi Code plan\n // endpoint (default high); thinking can also be fully disabled.\n {\n id: \"kimi-k3\",\n name: \"Kimi K3\",\n provider: \"moonshot\",\n contextWindow: 1_048_576,\n // The API can be raised as high as the full context window, but 131K is the\n // documented default and keeps room for input in AgentSession's fixed cap.\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"high\",\n maxThinkingLevel: \"max\",\n authStorageKeys: KIMI_AUTH_KEYS,\n },\n // K2.8 Preview (2026-09-11) is served only on the Kimi Code plan endpoint,\n // under its rolling `kimi-for-coding` id (live /models, 2026-09-28:\n // display_name \"K2.8 Preview\", 1M context, image + video input, efforts\n // low/high/max default max). The public Moonshot API does not serve it, so\n // it resolves from a Kimi Code plan key only.\n {\n id: \"kimi-for-coding\",\n name: \"Kimi K2.8 Preview\",\n provider: \"moonshot\",\n contextWindow: 1_048_576,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"max\",\n authStorageKeys: [KIMI_CODE_KEY],\n },\n // K2.7 Code is requested by its pinned id (not the `kimi-for-coding` alias\n // that moved to K2.8), so it stays the real K2.7 on both endpoints.\n {\n id: \"kimi-k2.7-code\",\n name: \"Kimi K2.7 Code\",\n provider: \"moonshot\",\n contextWindow: 262_144,\n maxOutputTokens: 262_144,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n authStorageKeys: KIMI_AUTH_KEYS,\n },\n // K2.7 Code HighSpeed — the same model as K2.7 Code served at ~180 tok/s\n // (up to ~260 on short contexts), 256K context (platform.kimi.ai model list,\n // 2026-10-08). The `kimi-k2.7-code` prefix gives it K2.7's always-thinking\n // wire handling in gg-ai. The Kimi For Coding endpoint lists this model as\n // `kimi-for-coding-highspeed`; like `kimi-k2.7-code` and `kimi-k3`, we send\n // the public id there too.\n {\n id: \"kimi-k2.7-code-highspeed\",\n name: \"Kimi K2.7 Code HighSpeed\",\n provider: \"moonshot\",\n contextWindow: 262_144,\n maxOutputTokens: 262_144,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 100 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n authStorageKeys: KIMI_AUTH_KEYS,\n },\n // ── Z.AI (GLM) ─────────────────────────────────────────\n // Two GLM entries, both live on the coding endpoint (verified against its\n // /models list). The pre-5.3 ids stay retired: they routed to strictly worse\n // coding for the same plan quota, and the endpoint already answers `glm-5.2`\n // requests as glm-5.3.\n // `max` is both the ceiling and Z.AI's own default — the rungs below it live\n // in thinking-level.ts.\n {\n id: \"glm-5.3\",\n name: \"GLM-5.3\",\n provider: \"glm\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"max\",\n },\n // GLM-5.3-Flash (released 2026-08-26): 320B-A18B natively multimodal sibling\n // at ~1/20th of 5.3's API price with 3× the coding-plan quota, so it is the\n // provider's `low` tier — scout sub-agents and compaction summaries route\n // here instead of paying 5.3 rates.\n // Images are native on the coding endpoint (verified: base64 data URL in an\n // `image_url` block answers correctly), which also means GLM image\n // attachments go inline for this model rather than through the zai_vision MCP\n // detour that `supportsImages: false` triggers.\n // Video/file input is documented but unverified on this transport, so it\n // stays off until measured. Thinking cannot be disabled server-side (Z.AI\n // maps a `disabled` toggle to the `low` rung and answers 200), and unlike\n // 5.3 it accepts any reasoning_effort string without a 400.\n {\n id: \"glm-5.3-flash\",\n name: \"GLM-5.3-Flash\",\n provider: \"glm\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n // ── MiniMax ────────────────────────────────────────────\n {\n // The only text model M Plan / Token Plan keys cover; MiniMax-M3 on those\n // keys fails with \"insufficient balance (1008)\". Pay-as-you-go serves both.\n // Always thinks (`thinking: disabled` 400s; omitted = on) with a real\n // low→max effort ladder on output_config.effort, API default max.\n id: \"MiniMax-M3.1-Flash\",\n name: \"MiniMax M3.1 Flash\",\n provider: \"minimax\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 50 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n {\n id: \"MiniMax-M3\",\n name: \"MiniMax M3\",\n provider: \"minimax\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 50 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n },\n // ── Xiaomi (MiMo) ──────────────────────────────────────\n // MiMo-V2.6 series (released 2026-09-22, open-weight: Pro 1.02T/42B-A,\n // Flash 309B/15B-A, plus a 9B Qwen distill not served over the API). Every\n // V2.6 text model is natively full-modal (image + audio + video\n // understanding), so the whole series takes image/video input over the same\n // OpenAI-compatible base64 transport the old omni model used. API prices are\n // unchanged from V2.5. The V2.5 ids (`mimo-v2.5-pro`/`mimo-v2.5`) deprecate\n // on the platform 2026-10-21 and are retired here — saved sessions fall back\n // to the provider default on next start.\n {\n // Coding/agentic flagship — highest open-weight score on Artificial\n // Analysis at launch (46, tied with Grok 4.7).\n id: \"mimo-v2.6-pro\",\n name: \"MiMo-V2.6-Pro\",\n provider: \"xiaomi\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 36 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n authStorageKeys: [\"xiaomi\", XIAOMI_CREDITS_KEY],\n },\n {\n // Low-cost full-modal sibling (~10% of Pro's price class) — the provider's\n // `low` tier, so scout sub-agents and fast routing land here.\n id: \"mimo-v2.6-flash\",\n name: \"MiMo-V2.6-Flash\",\n provider: \"xiaomi\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 36 * 1024 * 1024,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n authStorageKeys: [\"xiaomi\", XIAOMI_CREDITS_KEY],\n },\n // UltraSpeed: lower-latency sibling of the Pro flagship, same full-modal\n // capability surface, premium-priced for the throughput gain. API-only —\n // not served over the Token Plan endpoint, so credentials resolve from the\n // distinct API Credits key only (see authStorageKeys doc).\n {\n id: \"mimo-v2.6-pro-ultraspeed\",\n name: \"MiMo-V2.6-Pro-UltraSpeed\",\n provider: \"xiaomi\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n maxVideoBytes: 36 * 1024 * 1024,\n costTier: \"high\",\n maxThinkingLevel: \"high\",\n authStorageKeys: [XIAOMI_CREDITS_KEY],\n billing: XIAOMI_ULTRASPEED_BILLING,\n },\n // ── DeepSeek ───────────────────────────────────────────\n // Window 1,000,000 tokens (decimal \"1M\"), as DeepSeek's own agent sizes it\n // (deepseek-ai/deepseek-harness DEFAULT_CONTEXT_WINDOW = 1_000_000; models.dev\n // via earendil-works/pi agrees) — not 1,048,576.\n // The live /models list (2026-09-28) serves exactly `deepseek-flash` and\n // `deepseek-v4-pro`. V4 Flash and V4 Flash Vision Exp are retired; their old\n // ids only temporarily route to V4.1 Flash, so they are retired here too.\n {\n // `deepseek-v4-pro` now serves DeepSeek-V4-Pro-0813 (released 2026-08-13,\n // first STABLE V4 Pro — supersedes the April preview; calling name\n // unchanged, same 1.6T/49B MoE). 1M context, text-only, low/high/max effort.\n // DeepSeek reversed its planned 2026-09-14 retirement, so it stays served.\n // Docs abbreviate output as 384K; use the same conservative 384,000-token\n // application cap across V4 models rather than mixing decimal/binary units.\n id: \"deepseek-v4-pro\",\n name: \"DeepSeek V4 Pro\",\n provider: \"deepseek\",\n contextWindow: 1_000_000,\n maxOutputTokens: 384_000,\n supportsThinking: true,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"max\",\n },\n {\n // `deepseek-flash` is the rolling alias for the latest Flash — currently\n // V4.1 Flash (2026-09-10): native image input, 1M context, 384K output.\n id: \"deepseek-flash\",\n name: \"DeepSeek V4.1 Flash\",\n provider: \"deepseek\",\n contextWindow: 1_000_000,\n maxOutputTokens: 384_000,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"max\",\n },\n // ── OpenRouter ─────────────────────────────────────────\n {\n // Qwen3.8 Max — Alibaba's flagship (live /endpoints, 2026-09-28): 1M\n // context, 131,072 output, text + image + video input, reasoning on.\n id: \"qwen/qwen3.8-max\",\n name: \"Qwen3.8 Max\",\n provider: \"openrouter\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: true,\n supportsImages: true,\n supportsVideo: true,\n // Practical inline-payload cap, not an asserted provider maximum.\n maxVideoBytes: 20 * 1024 * 1024,\n costTier: \"medium\",\n maxThinkingLevel: \"high\",\n },\n // ── Hugging Face (Inference Providers router) ────────\n // One HF token (hf.co/settings/tokens, \"Make calls to Inference Providers\"\n // permission) routes to whichever hosted backend serves each open model;\n // billing follows each backend's rates on the HF account (small free tier).\n // Model ids are Hub repo paths, so they intentionally contain a slash — the\n // same shape local/ vLLM ids already use (`local/vllm/Qwen/Qwen3-32B`).\n // Live GET /v1/models (2026-10-08) is the source for hosts, context and\n // modalities. Qwen3 Coder 480B is retired here: only one tool-calling host\n // (Novita) still served it. We keep bare repo ids (no :auto suffix) so the\n // picker matches what GET /v1/models reports.\n {\n // Moonshot's open coding model. Served with tool calling by Novita and\n // DeepInfra at 262K context, with image input (video is a Moonshot-API-only\n // upload path). It always thinks, but the router has no documented effort\n // control for it, so we register it as non-thinking: we never send a\n // reasoning_effort that a host might reject. Output is capped at half the\n // window so the input keeps room.\n id: \"moonshotai/Kimi-K2.7-Code\",\n name: \"Kimi K2.7 Code\",\n provider: \"huggingface\",\n contextWindow: 262_144,\n maxOutputTokens: 131_072,\n supportsThinking: false,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"medium\",\n maxThinkingLevel: \"low\",\n },\n {\n // DeepSeek V4.1 Flash open weights: 1M context and image input on four\n // tool-calling hosts (Novita, Fireworks, Baseten, DeepInfra; $0.20–0.30 in /\n // $0.60–1.20 out per MTok). It is listed before gpt-oss so it becomes the\n // low-tier summary model, because its 1M window can take a full long\n // session. Thinking control over the router is unverified, so it is\n // registered as non-thinking. Output is a conservative 131K: hosts don't\n // publish the 384K first-party cap.\n id: \"deepseek-ai/DeepSeek-V4.1-Flash\",\n name: \"DeepSeek V4.1 Flash\",\n provider: \"huggingface\",\n contextWindow: 1_000_000,\n maxOutputTokens: 131_072,\n supportsThinking: false,\n supportsImages: true,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"low\",\n },\n {\n // OpenAI's open-weight 120B MoE (5.1B active) — general-purpose, tool-calling\n // native, adjustable reasoning effort (low/medium/high, default medium) over\n // the router's Chat Completions API. Cheap enough to be the low-tier sibling\n // for summaries and fast sub-agents.\n id: \"openai/gpt-oss-120b\",\n name: \"GPT-OSS 120B\",\n provider: \"huggingface\",\n contextWindow: 131_072,\n maxOutputTokens: 65_536,\n supportsThinking: true,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n },\n];\n\n/**\n * Models discovered at runtime rather than shipped in `MODELS` — today only\n * locally hosted ones (Ollama/LM Studio/llama.cpp/vLLM), whose ids and context\n * windows depend on what the user has installed. Kept in a separate map so\n * `MODELS` stays a static, reviewable table.\n */\nconst runtimeModels = new Map<string, ModelInfo>();\n\n/** Add (or replace) runtime-discovered models. Later registrations win by id. */\nexport function registerRuntimeModels(models: readonly ModelInfo[]): void {\n for (const model of models) runtimeModels.set(model.id, model);\n}\n\n/**\n * Remove runtime models matching `predicate` (all of them when omitted) — e.g.\n * every model from an endpoint the user just deleted.\n */\nexport function clearRuntimeModels(predicate?: (model: ModelInfo) => boolean): void {\n if (!predicate) {\n runtimeModels.clear();\n return;\n }\n for (const [id, model] of runtimeModels) {\n if (predicate(model)) runtimeModels.delete(id);\n }\n}\n\n/**\n * A provider's model list synced from its own endpoint (OpenRouter today).\n * While set it REPLACES that provider's static rows in listings; the static\n * rows still resolve by id so a session saved on one keeps working.\n */\nconst providerCatalogs = new Map<Provider, Map<string, ModelInfo>>();\n\n/**\n * Replace (or, with undefined, restore) a provider's listed catalog. Rows whose\n * id is already a static model of ANOTHER provider are dropped: getModel()\n * resolves by bare id, so a collision would route to the wrong provider.\n * Returns the rows actually installed.\n */\nexport function setProviderCatalog(\n provider: Provider,\n models: readonly ModelInfo[] | undefined,\n): ModelInfo[] {\n if (!models) {\n providerCatalogs.delete(provider);\n return [];\n }\n const installed = new Map<string, ModelInfo>();\n for (const model of models) {\n if (model.provider !== provider) continue;\n if (MODELS.some((m) => m.id === model.id && m.provider !== provider)) continue;\n installed.set(model.id, model);\n }\n providerCatalogs.set(provider, installed);\n return [...installed.values()];\n}\n\nexport function getProviderCatalog(provider: Provider): ModelInfo[] | undefined {\n const catalog = providerCatalogs.get(provider);\n return catalog ? [...catalog.values()] : undefined;\n}\n\n/** Static table plus everything discovered at runtime. */\nexport function getAllModels(): ModelInfo[] {\n const synced = [...providerCatalogs.values()].flatMap((c) => [...c.values()]);\n return [\n ...MODELS.filter((m) => !providerCatalogs.has(m.provider)),\n ...synced,\n ...runtimeModels.values(),\n ];\n}\n\nexport function getModel(id: string): ModelInfo | undefined {\n const fixed = MODELS.find((m) => m.id === id);\n if (fixed && !providerCatalogs.has(fixed.provider)) return fixed;\n return findSynced(id) ?? fixed ?? runtimeModels.get(id);\n}\n\nfunction findSynced(id: string): ModelInfo | undefined {\n for (const catalog of providerCatalogs.values()) {\n const hit = catalog.get(id);\n if (hit) return hit;\n }\n return undefined;\n}\n\nexport function getModelsForProvider(provider: Provider): ModelInfo[] {\n return getAllModels().filter((m) => m.provider === provider);\n}\n\n/**\n * Models offered in lists that don't filter by login (Telegram `/m`, the\n * agent's model list): everything except hidden providers' models.\n */\nexport function getListedModels(): ModelInfo[] {\n return getAllModels().filter((m) => !isHiddenProvider(m.provider));\n}\n\n/** True when running `modelId` spends real money (Claude fast mode, MiMo UltraSpeed). */\nexport function isPaidModel(modelId: string): boolean {\n return getModel(modelId)?.billing?.paid === true;\n}\n\n/**\n * The model to run instead of a paid tier: its `billing.standardModel`\n * (`claude-opus-5-5-fast` → `claude-opus-5-5`, `mimo-v2.6-pro-ultraspeed` →\n * `mimo-v2.6-pro`); every unpaid id is returned unchanged. A paid tier must be\n * picked on purpose for the one session the user is watching, so everything\n * that would otherwise carry it silently (saved defaults, restored sessions,\n * Ken following GG, sub-agents, goal workers) runs through this instead.\n *\n * Fails closed: when the standard model is missing, unregistered or itself\n * paid, it falls back to the provider's default, then the global default —\n * never to the paid id.\n */\nexport function withoutPaidTier(modelId: string): string {\n const model = getModel(modelId);\n if (model?.billing?.paid !== true) return modelId;\n const usable = (id: string | undefined): id is string =>\n id !== undefined && getModel(id) !== undefined && !isPaidModel(id);\n const standard = model.billing.standardModel;\n if (usable(standard)) return standard;\n const providerDefault = DEFAULT_MODEL_IDS[model.provider];\n if (usable(providerDefault)) return providerDefault;\n return FALLBACK_DEFAULT_MODEL_ID;\n}\n\n/**\n * Ordered auth-storage keys to try resolving credentials from for\n * `(provider, model)`, first match wins. Almost every model just uses its\n * provider id (one credential per provider). Models with `authStorageKeys`\n * set (currently only Xiaomi) can prefer one endpoint and fall back to\n * another — e.g. `mimo-v2.6-pro` prefers the Token Plan but falls back to API\n * Credits, while the API-only `mimo-v2.6-pro-ultraspeed` has no fallback.\n */\nexport function getAuthStorageKeys(provider: Provider, modelId: string): string[] {\n const model = getAllModels().find((m) => m.id === modelId && m.provider === provider);\n return model?.authStorageKeys ?? [provider];\n}\n\n/** The preferred (first) auth-storage key for `(provider, model)` — see `getAuthStorageKeys()`. */\nexport function getAuthStorageKey(provider: Provider, modelId: string): string {\n return getAuthStorageKeys(provider, modelId)[0]!;\n}\n\n/** Default video payload cap (bytes) when a video model doesn't declare one. */\nexport const DEFAULT_MAX_VIDEO_BYTES = 20 * 1024 * 1024;\n\n/**\n * Max video payload (bytes) the given model's transport accepts before the clip\n * must be compressed. Returns `undefined` for models without video support, so\n * callers can skip the native-video path entirely.\n */\nexport function getVideoByteLimit(modelId: string): number | undefined {\n const model = getModel(modelId);\n if (!model?.supportsVideo) return undefined;\n return model.maxVideoBytes ?? DEFAULT_MAX_VIDEO_BYTES;\n}\n\n/** Default model per hosted provider. Providers not listed default to Anthropic's. */\nconst DEFAULT_MODEL_IDS: Partial<Record<Provider, string>> = {\n xiaomi: \"mimo-v2.6-pro\",\n openai: \"gpt-6.1-sol\",\n gemini: \"gemini-3.1-flash-lite\",\n glm: \"glm-5.3\",\n moonshot: \"kimi-k3\",\n minimax: \"MiniMax-M3.1-Flash\",\n deepseek: \"deepseek-v4-pro\",\n huggingface: \"moonshotai/Kimi-K2.7-Code\",\n openrouter: \"qwen/qwen3.8-max\",\n sakana: \"fugu\",\n xai: \"grok-4.7\",\n};\nconst FALLBACK_DEFAULT_MODEL_ID = \"claude-sonnet-5-5\";\n\nfunction registeredModel(id: string): ModelInfo {\n const model = MODELS.find((m) => m.id === id);\n // A default that isn't registered is a bug in this file, caught by the tests.\n if (!model) throw new Error(`Default model \"${id}\" is not in the model registry`);\n return model;\n}\n\nexport function getDefaultModel(provider: Provider): ModelInfo {\n // Local models only exist once discovery has run, and there's no \"the\" local\n // model. Never throw here (callers rely on a ModelInfo): fall back to a\n // placeholder that carries the conservative defaults, so a caller asking\n // before a scan gets a coherent object instead of a crash.\n if (provider === \"local\") {\n return getModelsForProvider(\"local\")[0] ?? PLACEHOLDER_LOCAL_MODEL;\n }\n return registeredModel(DEFAULT_MODEL_IDS[provider] ?? FALLBACK_DEFAULT_MODEL_ID);\n}\n\n/**\n * Stand-in returned by `getDefaultModel(\"local\")` before any local model has\n * been discovered. Not registered, never selectable in the UI — it exists only\n * so the non-null contract of `getDefaultModel` holds.\n */\nconst PLACEHOLDER_LOCAL_MODEL: ModelInfo = {\n id: \"local/none/none\",\n name: \"No local model discovered\",\n provider: \"local\",\n contextWindow: 8192,\n maxOutputTokens: 2048,\n supportsThinking: false,\n supportsImages: false,\n supportsVideo: false,\n costTier: \"low\",\n maxThinkingLevel: \"high\",\n};\n\nexport interface ContextWindowOptions {\n provider?: Provider;\n accountId?: string;\n}\n\nexport function usesOpenAICodexTransport(options?: ContextWindowOptions): boolean {\n return options?.provider === \"openai\" && Boolean(options.accountId);\n}\n\n/**\n * Codex applies a 10K-token history cap to every tool/function output. GG's\n * generic 30%-of-context allowance is far larger on 272K/372K Codex windows\n * and can turn a few reads into 100K+ fresh input tokens. Four characters per\n * token matches Codex's byte approximation and keeps this provider policy in\n * the shared model registry instead of an app-specific copy.\n */\nexport function getToolResultCharLimit(\n _modelId: string,\n options?: ContextWindowOptions,\n): number | undefined {\n return usesOpenAICodexTransport(options) ? 10_000 * 4 : undefined;\n}\n\nexport function getContextWindow(modelId: string, options?: ContextWindowOptions): number {\n const model = getModel(modelId);\n if (!model) return 200_000;\n if (usesOpenAICodexTransport(options) && model.codexContextWindow) {\n return model.codexContextWindow;\n }\n return model.contextWindow;\n}\n\n/**\n * The strongest thinking level the given model genuinely uses. Falls back to\n * `\"high\"` for unknown models since every provider we ship accepts it.\n */\nexport function getMaxThinkingLevel(modelId: string): ThinkingLevel {\n return getModel(modelId)?.maxThinkingLevel ?? \"high\";\n}\n\n/**\n * The thinking level a fresh session starts at. Identical to\n * {@link getMaxThinkingLevel} except where the provider declares a lower\n * default effort server-side — Kimi K3's Kimi Code plan endpoint declares\n * `default_effort: \"high\"` in its /models think_efforts (the public Moonshot\n * API declares \"max\").\n * Pass the active credential's baseUrl so the endpoint-aware default resolves;\n * matching it keeps plan-usage burn identical to the official CLI (users can\n * still toggle up to max).\n */\nexport function getDefaultThinkingLevel(\n modelId: string,\n options?: { baseUrl?: string },\n): ThinkingLevel {\n const model = getModel(modelId);\n if (model?.id === \"kimi-k3\" && isKimiCodingEndpoint(options?.baseUrl)) return \"high\";\n return model?.defaultThinkingLevel ?? model?.maxThinkingLevel ?? \"high\";\n}\n\n/**\n * The cost tier each provider's compaction summaries run on. Chosen by TAG,\n * never by model id, so adding, renaming or removing models needs no change\n * here: the first registered model of that provider carrying the tag wins.\n * Providers not listed summarize on the active model.\n * - Anthropic: \"medium\" (the Sonnet line) — Haiku's shallower summaries\n * cost more in lost memory than they save.\n * - OpenAI, GLM, DeepSeek, Hugging Face: \"low\" (Luna and the Flash models).\n */\nconst SUMMARY_COST_TIER: Readonly<Partial<Record<Provider, ModelInfo[\"costTier\"]>>> = {\n anthropic: \"medium\",\n openai: \"low\",\n glm: \"low\",\n deepseek: \"low\",\n huggingface: \"low\",\n};\n\n/**\n * Get the model to use for compaction summarization — the ONLY place a\n * cheaper model is used. Everything else (sub-agents, reviewers, judges)\n * runs on the parent's model.\n *\n * Picks the provider's first model tagged with its summary tier\n * ({@link SUMMARY_COST_TIER}). When none is registered — the tier was removed,\n * or the provider has no cheap sibling — it summarizes on the current model.\n * Never throws. The caller still retries on the current model when the\n * provider rejects the chosen one at runtime (retired upstream, or not on the\n * user's plan), since the registry cannot know that.\n */\nexport function getSummaryModel(provider: Provider, currentModelId: string): ModelInfo {\n const tier = SUMMARY_COST_TIER[provider];\n // Models with billing (fast modes, UltraSpeed) cost more than the standard\n // rate — never pick one for summaries.\n const tagged = tier\n ? getModelsForProvider(provider).find((m) => m.costTier === tier && m.billing === undefined)\n : undefined;\n return tagged ?? getModel(currentModelId) ?? getDefaultModel(provider);\n}\n","import { GG_CODER_USER_AGENT } from \"@kenkaiiii/gg-ai\";\n\n/**\n * Kimi Code plan keys come from the Kimi Code Console and work on the host\n * that issued them: `api.kimi.com` (kimi.com accounts) or `api.kimi.ai`\n * (kimi.ai accounts). The other host rejects the key as invalid.\n */\nexport const KIMI_CODE_DEFAULT_BASE_URL = \"https://api.kimi.com/coding/v1\";\nexport const KIMI_CODE_OVERSEAS_BASE_URL = \"https://api.kimi.ai/coding/v1\";\n\nconst KIMI_CODE_BASE_URLS = [KIMI_CODE_DEFAULT_BASE_URL, KIMI_CODE_OVERSEAS_BASE_URL] as const;\n\ntype ProbeResult = \"authorized\" | \"unauthorized\" | \"inconclusive\";\n\nasync function probeHost(\n baseUrl: string,\n apiKey: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<ProbeResult> {\n try {\n const res = await fetchImpl(`${baseUrl}/models`, {\n headers: { Authorization: `Bearer ${apiKey}`, \"User-Agent\": GG_CODER_USER_AGENT },\n signal: AbortSignal.timeout(timeoutMs),\n });\n if (res.ok) return \"authorized\";\n if (res.status === 401 || res.status === 403) return \"unauthorized\";\n return \"inconclusive\";\n } catch {\n return \"inconclusive\";\n }\n}\n\n/**\n * The Kimi Code base URL for the host that issued `apiKey`, found by asking\n * both. Undefined when neither gives a clear answer (offline, timeouts);\n * callers then use {@link KIMI_CODE_DEFAULT_BASE_URL} and may ask again later.\n */\nexport async function detectKimiCodeBaseUrl(\n apiKey: string,\n options: { fetch?: typeof fetch; timeoutMs?: number } = {},\n): Promise<string | undefined> {\n const fetchImpl = options.fetch ?? fetch;\n const timeoutMs = options.timeoutMs ?? 5000;\n const results = await Promise.all(\n KIMI_CODE_BASE_URLS.map((url) => probeHost(url, apiKey, fetchImpl, timeoutMs)),\n );\n const index = results.indexOf(\"authorized\");\n return index === -1 ? undefined : KIMI_CODE_BASE_URLS[index];\n}\n\n/** True if `baseUrl` is a Kimi Code plan endpoint (either host). */\nexport function isKimiCodingEndpoint(baseUrl: string | undefined): boolean {\n if (typeof baseUrl !== \"string\" || baseUrl.length === 0) return false;\n return /^https:\\/\\/api\\.kimi\\.(com|ai)\\/coding(\\/|$)/i.test(baseUrl);\n}\n","import fs from \"node:fs/promises\";\nimport { readFileSync } from \"node:fs\";\nimport crypto from \"node:crypto\";\nimport { getAppPaths } from \"./paths.js\";\nimport type { OAuthCredentials } from \"./oauth/types.js\";\nimport {\n CLAUDE_CODE_AUTH_KEY,\n CLAUDE_CODE_AUTH_MARKER,\n isClaudeCodeAuthMarker,\n isLegacyClaudeOAuth,\n} from \"./claude-code/runtime.js\";\nimport { refreshChatGPTToken } from \"./oauth/openai.js\";\nimport { withFileLock } from \"./file-lock.js\";\nimport { log } from \"./logger.js\";\nimport { isHiddenProvider } from \"./hidden-providers.js\";\nimport { LOCAL_NO_API_KEY } from \"@kenkaiiii/gg-ai\";\nimport { detectMiniMaxBaseUrl } from \"./minimax-region.js\";\nimport { detectXiaomiTokenPlanBaseUrl } from \"./xiaomi-region.js\";\nimport { KIMI_CODE_DEFAULT_BASE_URL, detectKimiCodeBaseUrl } from \"./kimi-region.js\";\n\ntype AuthData = Record<string, OAuthCredentials>;\n\n/**\n * Storage key for the ChatGPT plan sign-in (Continue with ChatGPT). Kept\n * distinct from the `openai` entry, which holds an OpenAI API key, so a user can\n * hold both: the plan serves first and the key covers while its usage is out.\n */\nexport const OPENAI_CHATGPT_KEY = \"openai-chatgpt\";\n\n/**\n * Storage key for a Kimi Code plan key (Kimi Code Console), served by\n * `api.kimi.com/coding/v1` or `api.kimi.ai/coding/v1`. Kept distinct from the\n * `moonshot` entry, which holds a pay-per-token Moonshot platform key.\n */\nexport const KIMI_CODE_KEY = \"kimi-code\";\n\n/**\n * A sign-in GG Coder no longer uses, because it posed as another vendor's app\n * (Codex CLI, Kimi Code CLI, Grok CLI, Gemini CLI) or its token stopped\n * working. The entry is never served; the user is asked once to reconnect.\n */\nexport interface RetiredSignIn {\n /** Logical provider the credential belonged to. */\n provider: string;\n /** auth.json key holding the retired credential. */\n storageKey: string;\n /** What to tell the user, without CLI/app-specific instructions. */\n message: string;\n}\n\nexport const RETIRED_SIGN_INS: readonly RetiredSignIn[] = [\n {\n provider: \"anthropic\",\n storageKey: \"anthropic\",\n message:\n \"Your old Claude sign-in no longer works. Sign in with Claude again to keep using your subscription.\",\n },\n {\n provider: \"openai\",\n storageKey: \"openai\",\n message:\n \"Your old ChatGPT sign-in no longer works. Continue with ChatGPT again, or add an OpenAI API key.\",\n },\n {\n provider: \"moonshot\",\n storageKey: \"moonshot-oauth\",\n message:\n \"Kimi sign-in was removed. Paste a Kimi Code plan key from the Kimi Code Console, or a Moonshot API key.\",\n },\n {\n provider: \"xai\",\n storageKey: \"xai-oauth\",\n message:\n \"Grok sign-in was removed: xAI offers SuperGrok and X Premium usage only in apps it partners with. Add an xAI API key instead.\",\n },\n {\n provider: \"gemini\",\n storageKey: \"gemini\",\n message: \"Google sign-in was removed. Add a Gemini API key from Google AI Studio instead.\",\n },\n];\n\n/** True when `value`, stored under `retired.storageKey`, is that retired sign-in. */\nfunction isRetiredEntry(retired: RetiredSignIn, value: OAuthCredentials | undefined): boolean {\n if (!value) return false;\n switch (retired.storageKey) {\n case \"anthropic\":\n return isLegacyClaudeOAuth(value);\n // These slots now hold API keys; only a refreshable sign-in token is retired.\n case \"openai\":\n case \"gemini\":\n return Boolean(value.refreshToken) || Boolean(value.accountId) || Boolean(value.projectId);\n default:\n return true;\n }\n}\n\nfunction isRetiredStorageKey(data: AuthData, storageKey: string): boolean {\n return RETIRED_SIGN_INS.some(\n (r) => r.storageKey === storageKey && isRetiredEntry(r, data[storageKey]),\n );\n}\n\n/** The usable credential stored under `storageKey`; a retired sign-in reads as absent. */\nfunction usableEntry(data: AuthData, storageKey: string): OAuthCredentials | undefined {\n return isRetiredStorageKey(data, storageKey) ? undefined : data[storageKey];\n}\n\n/** Logical provider for a storage key (`kimi-code` → `moonshot`). */\nexport function storageKeyProvider(storageKey: string): string {\n if (storageKey === OPENAI_CHATGPT_KEY) return \"openai\";\n if (storageKey === KIMI_CODE_KEY) return \"moonshot\";\n if (storageKey === XIAOMI_CREDITS_KEY) return \"xiaomi\";\n if (storageKey === GEMINI_API_KEY) return \"gemini\";\n return storageKey;\n}\n\n/**\n * API-key storage keys for a provider whose keys split into a plan key and a\n * pay-per-token key, plan first. Single-key providers yield just their own key.\n */\nexport function apiKeyStorageKeys(provider: string): string[] {\n if (provider === \"moonshot\") return [KIMI_CODE_KEY, \"moonshot\"];\n if (provider === \"xiaomi\") return [\"xiaomi\", XIAOMI_CREDITS_KEY];\n if (provider === \"gemini\") return [GEMINI_API_KEY];\n return [provider];\n}\n\n/**\n * A provider that can hold two credentials at once: a refreshable plan sign-in\n * and a static API key. One policy governs all of them — see\n * {@link DUAL_AUTH_PROVIDERS} — so adding a provider here is enough to give it\n * OAuth-first resolution, usage-exhaustion fallback, per-method logout and the\n * matching UI affordances.\n */\nexport interface DualAuthProvider {\n /** Logical provider id, which is also the API-key storage key. */\n provider: string;\n /** Storage key holding the OAuth credential. */\n oauthKey: string;\n /** Human label for the OAuth credential (log/UI wording). */\n oauthLabel: string;\n /** Human label for the API-key credential (log/UI wording). */\n apiKeyLabel: string;\n /** What the user should do to restore OAuth after it went invalid. */\n restoreHint: string;\n}\n\n/**\n * Providers offering subscription OAuth *and* an API key. The resolution policy\n * is identical for every entry:\n *\n * 1. OAuth wins whenever it is configured — it is the cheaper, subscription\n * credential, and the one the user opted into most recently.\n * 2. If OAuth's usage window is exhausted (marked by the agent loop when the\n * subscription endpoint rejects with a usage/quota stop) the API key serves\n * until the mark lapses — but only when a key is actually configured, so\n * OAuth-only users still see the real usage-limit error and its reset time.\n * 3. If OAuth's refresh token is dead, fall back to the API key and log loudly:\n * it is a billing switch (subscription → metered), not a detail.\n */\nconst DUAL_AUTH_PROVIDERS: readonly DualAuthProvider[] = [\n {\n provider: \"openai\",\n oauthKey: OPENAI_CHATGPT_KEY,\n oauthLabel: \"ChatGPT plan\",\n apiKeyLabel: \"OpenAI API key\",\n restoreHint:\n 'Run \"ggcoder login\" and choose Continue with ChatGPT to use your ChatGPT plan again.',\n },\n];\n\n/** Dual-auth policy for a logical provider, or undefined if it has just one method. */\nexport function dualAuthProvider(provider: string): DualAuthProvider | undefined {\n return DUAL_AUTH_PROVIDERS.find((entry) => entry.provider === provider);\n}\n\n/** Dual-auth policy keyed by the OAuth storage key (the reverse lookup). */\nexport function dualAuthProviderByOAuthKey(storageKey: string): DualAuthProvider | undefined {\n return DUAL_AUTH_PROVIDERS.find((entry) => entry.oauthKey === storageKey);\n}\n\n/** The OAuth storage key for a dual-auth provider, if it has one. */\nexport function oauthStorageKey(provider: string): string | undefined {\n return dualAuthProvider(provider)?.oauthKey;\n}\n\n/**\n * Both storage keys a dual-auth provider may hold, in resolution order\n * (OAuth first). Single-method providers yield just their own key.\n */\nexport function providerStorageKeys(provider: string): string[] {\n const dual = dualAuthProvider(provider);\n return dual ? [dual.oauthKey, dual.provider] : [provider];\n}\n\n/**\n * Storage key for the Xiaomi API Credits credential (`https://api.xiaomimimo.com/v1`).\n * Kept distinct from the `xiaomi` Token Plan entry (`token-plan-sgp.xiaomimimo.com`)\n * so a user can configure BOTH — `mimo-v2.6-pro-ultraspeed` is API Credits-only,\n * while `mimo-v2.6-pro`/`mimo-v2.6-flash` prefer the Token Plan but fall back to API\n * Credits when only that's configured. Which key(s) a model tries, and in what\n * order, is decided per-model via `getAuthStorageKeys()` in model-registry.ts.\n */\nexport const XIAOMI_CREDITS_KEY = \"xiaomi-credits\";\n\n/**\n * Storage key for a Gemini API key (Google AI Studio, billed per token), served\n * by the public Gemini API at {@link GEMINI_API_KEY_BASE_URL}. Kept distinct from\n * the `gemini` entry, which held the retired Google sign-in.\n */\nexport const GEMINI_API_KEY = \"gemini-api\";\n\n/** Public Gemini API (AI Studio key). The transport routes on this base URL. */\nexport const GEMINI_API_KEY_BASE_URL = \"https://generativelanguage.googleapis.com/v1beta\";\n\n/**\n * Prefix for local-endpoint credentials (`local:ollama`, `local:lmstudio`, …).\n * One entry per endpoint, each carrying that endpoint's `baseUrl`, so the\n * existing `resolveCredentials({ storageKeys })` override resolves a local model\n * with no new code path. Kept in sync with `localAuthStorageKey()` in\n * local-models.ts.\n */\nexport const LOCAL_AUTH_KEY_PREFIX = \"local:\";\n\n/** A century — local endpoints have no token lifetime, so never expire them. */\nconst LOCAL_CREDENTIAL_LIFETIME_MS = 100 * 365 * 24 * 60 * 60 * 1000;\n\n/**\n * The credential entry whose baseUrl applies right now. For a dual-auth provider\n * this mirrors resolveCredentials' preference: the OAuth entry, sidelined to the\n * API key only while its usage window is exhausted and a key is configured.\n * Shared by {@link AuthStorage.getStoredBaseUrl} and {@link readStoredBaseUrlSync}\n * so both paths agree on the active endpoint.\n */\nfunction activeBaseUrlEntry(data: AuthData, provider: string): OAuthCredentials | undefined {\n const dual = dualAuthProvider(provider);\n if (dual) {\n const oauth = data[dual.oauthKey];\n const apiKey = usableEntry(data, dual.provider);\n if (oauth) {\n const exhaustedUntil = oauth.usageExhaustedUntil ?? 0;\n if (Date.now() < exhaustedUntil && apiKey) return apiKey;\n return oauth;\n }\n return apiKey;\n }\n const key = apiKeyStorageKeys(provider).find((k) => usableEntry(data, k));\n const entry = key ? data[key] : undefined;\n // A Kimi Code key not yet probed still goes to the plan endpoint.\n if (key === KIMI_CODE_KEY && entry && !entry.baseUrl) {\n return { ...entry, baseUrl: KIMI_CODE_DEFAULT_BASE_URL };\n }\n return entry;\n}\n\n// Shared across AuthStorage instances (one per app window). An inconclusive\n// answer (offline) is retried after a while instead of on every request.\nconst MINIMAX_REGION_RETRY_MS = 5 * 60 * 1000;\nconst miniMaxRegionProbes = new Map<string, { at: number; result: Promise<string | undefined> }>();\n\nfunction cachedMiniMaxRegion(apiKey: string): Promise<string | undefined> {\n const cached = miniMaxRegionProbes.get(apiKey);\n if (cached && Date.now() - cached.at < MINIMAX_REGION_RETRY_MS) return cached.result;\n const result = detectMiniMaxBaseUrl(apiKey).catch(() => undefined);\n miniMaxRegionProbes.set(apiKey, { at: Date.now(), result });\n return result;\n}\n\nconst kimiRegionProbes = new Map<string, { at: number; result: Promise<string | undefined> }>();\n\nfunction cachedKimiRegion(apiKey: string): Promise<string | undefined> {\n const cached = kimiRegionProbes.get(apiKey);\n if (cached && Date.now() - cached.at < MINIMAX_REGION_RETRY_MS) return cached.result;\n const result = detectKimiCodeBaseUrl(apiKey).catch(() => undefined);\n kimiRegionProbes.set(apiKey, { at: Date.now(), result });\n return result;\n}\n\n/**\n * Synchronous baseUrl read straight from the auth file, for boot paths that\n * need the active endpoint before an AuthStorage instance exists (e.g. the\n * CLI's sync main()). Missing/corrupt files yield undefined — callers treat\n * that as the provider's public endpoint. Read-only: safe without the file\n * lock (a torn mid-write read just falls back to undefined).\n */\nexport function readStoredBaseUrlSync(authFile: string, provider: string): string | undefined {\n try {\n const data = JSON.parse(readFileSync(authFile, \"utf-8\")) as AuthData;\n return activeBaseUrlEntry(data, provider)?.baseUrl;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Proactive-refresh threshold: refresh when the token is within\n * `max(MIN_REFRESH_THRESHOLD_MS, lifetime * REFRESH_THRESHOLD_RATIO)` of expiry.\n * A flat skew rides short-lived tokens to the boundary and 401s; scaling by\n * lifetime refreshes a 1-hour ChatGPT token at its halfway point.\n */\nconst MIN_REFRESH_THRESHOLD_MS = 300_000;\nconst REFRESH_THRESHOLD_RATIO = 0.5;\n\nfunction refreshThresholdMs(creds: OAuthCredentials): number {\n const lifetimeMs = (creds.expiresIn ?? 0) * 1000;\n return Math.max(MIN_REFRESH_THRESHOLD_MS, lifetimeMs * REFRESH_THRESHOLD_RATIO);\n}\n\n/**\n * How long a usage-exhausted mark holds when the provider gave no reset time.\n * Short on purpose: after it lapses we try the preferred (OAuth) credential\n * again — if the window is still out, the caller re-marks and falls back again,\n * costing one rejected request per window instead of sticking to the fallback\n * key forever.\n */\nconst USAGE_EXHAUSTED_DEFAULT_MS = 15 * 60 * 1000;\n\n/**\n * Storage keys whose credential refreshes. Everything else is a static API key:\n * Claude subscriptions run through Claude Code, and every other plan is a key.\n */\nconst REFRESHABLE_STORAGE_KEYS = new Set([OPENAI_CHATGPT_KEY]);\n\nexport class AuthStorage {\n private data: AuthData = {};\n private filePath: string;\n private loaded = false;\n /**\n * inode+mtime+size of the cached file (`size: -1` = no file). The inode\n * detects atomic replacements with equal size within one filesystem clock tick.\n * auth.json is shared: the desktop app writes API keys and disconnects\n * NATIVELY (so they work with no daemon running), and every window/process has\n * its own AuthStorage. A load-once cache therefore goes stale — the sidecar\n * would keep listing models for a provider just disconnected, and hide the\n * ones just connected, until the daemon restarted.\n */\n private snapshotMtimeMs = 0;\n private snapshotSize = -1;\n private snapshotIno = 0;\n /** Per-provider lock to serialize concurrent refresh calls. */\n private refreshLocks = new Map<string, Promise<OAuthCredentials>>();\n\n private readonly detectMiniMaxRegion: (apiKey: string) => Promise<string | undefined>;\n private readonly detectXiaomiCluster: (apiKey: string) => Promise<string | undefined>;\n private readonly detectKimiRegion: (apiKey: string) => Promise<string | undefined>;\n\n constructor(\n filePath?: string,\n options?: {\n detectMiniMaxBaseUrl?: (apiKey: string) => Promise<string | undefined>;\n detectXiaomiTokenPlanBaseUrl?: (apiKey: string) => Promise<string | undefined>;\n detectKimiCodeBaseUrl?: (apiKey: string) => Promise<string | undefined>;\n },\n ) {\n this.filePath = filePath ?? getAppPaths().authFile;\n this.detectMiniMaxRegion = options?.detectMiniMaxBaseUrl ?? cachedMiniMaxRegion;\n this.detectXiaomiCluster =\n options?.detectXiaomiTokenPlanBaseUrl ?? detectXiaomiTokenPlanBaseUrl;\n this.detectKimiRegion = options?.detectKimiCodeBaseUrl ?? cachedKimiRegion;\n }\n\n /** Path to the on-disk auth file. Useful for status output. */\n get path(): string {\n return this.filePath;\n }\n\n /** List provider keys with stored credentials. */\n async listProviders(): Promise<string[]> {\n await this.ensureFresh();\n return Object.keys(this.data);\n }\n\n /** True if credentials exist for `provider`. */\n async hasCredentials(provider: string): Promise<boolean> {\n await this.ensureFresh();\n return Boolean(this.data[provider]);\n }\n\n /**\n * First key in `keys` (in order) that has stored credentials, or `undefined`\n * if none do. Mirrors the first-match logic `resolveCredentials({ storageKeys })`\n * uses internally — callers that need to know WHICH credential will actually\n * be used (e.g. to clear the right one after a 401) call this directly\n * instead of re-deriving the same order.\n */\n async pickStorageKey(keys: string[]): Promise<string | undefined> {\n await this.ensureFresh();\n return keys.find((key) => Boolean(this.data[key]));\n }\n\n /**\n * True if the user has any usable auth for the logical provider: the plan\n * sign-in or any of its API keys. A retired sign-in doesn't count.\n */\n async hasProviderAuth(provider: string): Promise<boolean> {\n // A hidden provider counts as not connected, so pickers and startup skip\n // it even when old credentials are still on disk.\n if (isHiddenProvider(provider)) return false;\n await this.ensureFresh();\n const dual = dualAuthProvider(provider);\n if (dual) {\n return Boolean(this.data[dual.oauthKey] || usableEntry(this.data, dual.provider));\n }\n if (provider === \"anthropic\") {\n return (\n (Boolean(this.data.anthropic) && !isLegacyClaudeOAuth(this.data.anthropic)) ||\n isClaudeCodeAuthMarker(this.data[CLAUDE_CODE_AUTH_KEY])\n );\n }\n // `local` has no single credential — any configured endpoint counts.\n if (provider === \"local\") {\n return Object.keys(this.data).some((key) => key.startsWith(LOCAL_AUTH_KEY_PREFIX));\n }\n return apiKeyStorageKeys(provider).some((key) => Boolean(usableEntry(this.data, key)));\n }\n\n /** True if any of `provider`'s API keys is stored. A retired sign-in in a key slot doesn't count. */\n async hasApiKey(provider: string): Promise<boolean> {\n await this.ensureFresh();\n if (provider === \"anthropic\" && isLegacyClaudeOAuth(this.data.anthropic)) return false;\n return apiKeyStorageKeys(provider).some((key) => Boolean(usableEntry(this.data, key)));\n }\n\n /**\n * `provider`'s first stored API key, never its plan sign-in. For calls that\n * only an API key may make (OpenAI's Images API isn't on the ChatGPT plan).\n */\n async resolveApiKey(provider: string): Promise<OAuthCredentials> {\n await this.ensureFresh();\n for (const key of apiKeyStorageKeys(provider)) {\n const creds = usableEntry(this.data, key);\n if (creds && !(provider === \"anthropic\" && isLegacyClaudeOAuth(creds))) return creds;\n }\n throw new NotLoggedInError(provider);\n }\n\n /** True when the user signed in with Claude through Claude Code (marker only, no token). */\n async hasClaudeCodeLogin(): Promise<boolean> {\n await this.ensureFresh();\n return isClaudeCodeAuthMarker(this.data[CLAUDE_CODE_AUTH_KEY]);\n }\n\n /** Record a Claude Code sign-in. Claude Code keeps the token; auth.json gets only the marker. */\n async setClaudeCodeLogin(): Promise<void> {\n await this.mutateLatest((latest) => {\n latest[CLAUDE_CODE_AUTH_KEY] = { ...CLAUDE_CODE_AUTH_MARKER } as unknown as OAuthCredentials;\n if (isLegacyClaudeOAuth(latest.anthropic)) delete latest.anthropic;\n });\n }\n\n /** Retired sign-ins still on disk, so the UI can ask the user to reconnect. */\n async legacySignIns(): Promise<RetiredSignIn[]> {\n await this.ensureFresh();\n return RETIRED_SIGN_INS.filter((r) => isRetiredEntry(r, this.data[r.storageKey]));\n }\n\n /**\n * Remove `provider`'s retired sign-in, if any. True when one was removed, so\n * the caller can tell the user once to reconnect.\n */\n async takeLegacySignIn(provider: string): Promise<boolean> {\n const retired = RETIRED_SIGN_INS.find((r) => r.provider === provider);\n if (!retired) return false;\n await this.ensureFresh();\n if (!isRetiredEntry(retired, this.data[retired.storageKey])) return false;\n let removed = false;\n await this.mutateLatest((latest) => {\n if (!isRetiredEntry(retired, latest[retired.storageKey])) return;\n delete latest[retired.storageKey];\n removed = true;\n });\n return removed;\n }\n\n async clearClaudeCodeLogin(): Promise<void> {\n await this.clearCredentials(CLAUDE_CODE_AUTH_KEY);\n }\n\n /** Endpoint ids that currently have a `local:<id>` credential stored. */\n async listLocalEndpointIds(): Promise<string[]> {\n await this.ensureLoaded();\n return Object.keys(this.data)\n .filter((key) => key.startsWith(LOCAL_AUTH_KEY_PREFIX))\n .map((key) => key.slice(LOCAL_AUTH_KEY_PREFIX.length));\n }\n\n /**\n * Write (or refresh) the credential for one local endpoint. The `baseUrl` is\n * what `effectiveBaseUrl` later picks up, and `accessToken` is the endpoint's\n * key — a placeholder for the servers that ignore it.\n */\n async setLocalEndpoint(endpointId: string, baseUrl: string, apiKey?: string): Promise<void> {\n await this.setCredentials(`${LOCAL_AUTH_KEY_PREFIX}${endpointId}`, {\n accessToken: apiKey && apiKey.length > 0 ? apiKey : LOCAL_NO_API_KEY,\n refreshToken: \"\",\n expiresAt: Date.now() + LOCAL_CREDENTIAL_LIFETIME_MS,\n baseUrl,\n });\n }\n\n /** Remove one local endpoint's credential. No-op when it isn't stored. */\n async removeLocalEndpoint(endpointId: string): Promise<void> {\n await this.clearCredentials(`${LOCAL_AUTH_KEY_PREFIX}${endpointId}`);\n }\n\n /**\n * True if the active credential for `provider` is a static API key with no\n * refresh mechanism. For a dual-auth provider this is only true when its OAuth\n * credential is absent or sidelined (a live OAuth credential is refreshable).\n */\n async isStaticApiKey(provider: string): Promise<boolean> {\n await this.ensureFresh();\n const dual = dualAuthProvider(provider);\n const oauthCreds = dual ? this.data[dual.oauthKey] : undefined;\n if (dual && oauthCreds) {\n // A usage-exhausted plan sign-in with an API key configured means the\n // API key is what actually resolves right now — treat it as the static\n // key it is (so a 401 clears the key instead of pointlessly\n // force-refreshing the sidelined token).\n const exhaustedUntil = oauthCreds.usageExhaustedUntil ?? 0;\n const apiKeyActive =\n Date.now() < exhaustedUntil && Boolean(usableEntry(this.data, dual.provider));\n if (!apiKeyActive) return false;\n }\n return !REFRESHABLE_STORAGE_KEYS.has(provider);\n }\n\n /**\n * The base URL on the credential that is active right now, if any.\n * Synchronous — call only after load()/resolveCredentials() populated the\n * snapshot. For a dual-auth provider this is the plan endpoint whenever the\n * sign-in is the one resolveCredentials would serve (i.e. not currently\n * usage-exhausted with an API key configured); for a plan/pay-per-token key\n * split it is the plan key's endpoint when one is stored.\n */\n getStoredBaseUrl(provider: string): string | undefined {\n return activeBaseUrlEntry(this.data, provider)?.baseUrl;\n }\n\n async load(): Promise<void> {\n // A reload (the file changed under us) is routine — log it once at first\n // load so ~/.gg/debug.log doesn't fill with identical lines on every read.\n const first = !this.loaded;\n await withFileLock(this.filePath, async () => {\n try {\n const content = await fs.readFile(this.filePath, \"utf-8\");\n this.data = JSON.parse(content) as AuthData;\n if (first) {\n log(\"INFO\", \"auth\", `Loaded credentials from ${this.filePath}`, {\n providers: Object.keys(this.data).join(\",\") || \"(none)\",\n });\n }\n } catch (err) {\n this.data = {};\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOENT\") {\n if (first) log(\"INFO\", \"auth\", `No auth file found at ${this.filePath} (first run)`);\n } else {\n log(\n \"ERROR\",\n \"auth\",\n `Failed to load auth file: ${err instanceof Error ? err.message : String(err)}`,\n { path: this.filePath, code: code ?? \"unknown\" },\n );\n }\n }\n });\n this.loaded = true;\n await this.rememberSnapshot();\n }\n\n private async ensureLoaded(): Promise<void> {\n if (!this.loaded) await this.load();\n }\n\n /**\n * Like {@link ensureLoaded}, but re-reads when the file changed since this\n * snapshot — a cheap stat, not a re-parse. Used by the \"what is connected?\"\n * readers, which must reflect writes made by another window, the CLI, or the\n * desktop app's native (daemon-free) API-key and disconnect paths.\n *\n * Deliberately NOT used by {@link resolveCredentials}: that path compares the\n * caller's snapshot against the latest file to detect a concurrent re-login,\n * and silently refreshing this instance's view first would destroy the\n * evidence that the token it just had rejected has already been replaced.\n */\n private async ensureFresh(): Promise<void> {\n if (!this.loaded) {\n await this.load();\n return;\n }\n let changed: boolean;\n try {\n const stat = await fs.stat(this.filePath);\n changed =\n stat.ino !== this.snapshotIno ||\n stat.mtimeMs !== this.snapshotMtimeMs ||\n stat.size !== this.snapshotSize;\n } catch {\n // File is gone (logged out everywhere) — reload only if we still hold one.\n changed = this.snapshotSize !== -1;\n }\n if (changed) await this.load();\n }\n\n /**\n * Record the file identity behind the current snapshot, so {@link ensureLoaded}\n * can tell \"someone else wrote\" from \"this is our own write\".\n */\n private async rememberSnapshot(): Promise<void> {\n try {\n const stat = await fs.stat(this.filePath);\n this.snapshotMtimeMs = stat.mtimeMs;\n this.snapshotSize = stat.size;\n this.snapshotIno = stat.ino;\n } catch {\n this.snapshotMtimeMs = 0;\n this.snapshotSize = -1;\n this.snapshotIno = 0;\n }\n }\n\n /**\n * Apply one provider-scoped mutation to the latest on-disk snapshot.\n * AuthStorage instances live in every app session/process, so writing this\n * instance's cached snapshot can erase credentials another instance just\n * added. The file lock only serializes writers; the re-read prevents stale\n * full-file overwrites.\n */\n private async mutateLatest(mutator: (data: AuthData) => void): Promise<void> {\n await this.ensureLoaded();\n await withFileLock(this.filePath, async () => {\n const latest = await readAuthData(this.filePath);\n mutator(latest);\n await atomicWriteFile(this.filePath, JSON.stringify(latest, null, 2));\n this.data = latest;\n });\n await this.rememberSnapshot();\n }\n\n private async reloadLatest(): Promise<void> {\n await withFileLock(this.filePath, async () => {\n this.data = await readAuthData(this.filePath);\n });\n await this.rememberSnapshot();\n }\n\n async getCredentials(provider: string): Promise<OAuthCredentials | undefined> {\n await this.ensureFresh();\n return this.data[provider];\n }\n\n /**\n * A MiniMax key saved without an endpoint (every key, until its first use)\n * gets the endpoint of the region that issued it, saved so the check runs\n * once. Without this a Mainland China key went to the Global API, got a 401,\n * and was deleted as invalid.\n */\n private async withMiniMaxRegion(creds: OAuthCredentials): Promise<OAuthCredentials> {\n const baseUrl = await this.detectMiniMaxRegion(creds.accessToken);\n if (!baseUrl) return creds;\n log(\"INFO\", \"auth\", `MiniMax key belongs to ${baseUrl} — saved for future requests`);\n await this.mutateLatest((latest) => {\n const current = latest[\"minimax\"];\n // Only annotate the key that was probed; a key replaced meanwhile is re-checked.\n if (current?.accessToken === creds.accessToken && !current.baseUrl) {\n current.baseUrl = baseUrl;\n }\n });\n return { ...creds, baseUrl };\n }\n\n /**\n * A Kimi Code key saved without an endpoint gets the host that issued it\n * (`api.kimi.com` or `api.kimi.ai`), saved so the check runs once.\n */\n private async withKimiRegion(creds: OAuthCredentials): Promise<OAuthCredentials> {\n const baseUrl = await this.detectKimiRegion(creds.accessToken);\n if (!baseUrl) return { ...creds, baseUrl: KIMI_CODE_DEFAULT_BASE_URL };\n log(\"INFO\", \"auth\", `Kimi Code key belongs to ${baseUrl} — saved for future requests`);\n await this.mutateLatest((latest) => {\n const current = latest[KIMI_CODE_KEY];\n if (current?.accessToken === creds.accessToken && !current.baseUrl) {\n current.baseUrl = baseUrl;\n }\n });\n return { ...creds, baseUrl };\n }\n\n /**\n * Store `creds` under `storageKey`. Connecting a provider again also removes\n * its retired sign-in, which is what the reconnect notice asked for.\n */\n async setCredentials(storageKey: string, creds: OAuthCredentials): Promise<void> {\n const provider = storageKeyProvider(storageKey);\n await this.mutateLatest((latest) => {\n for (const retired of RETIRED_SIGN_INS) {\n if (retired.provider !== provider || retired.storageKey === storageKey) continue;\n if (isRetiredEntry(retired, latest[retired.storageKey])) delete latest[retired.storageKey];\n }\n latest[storageKey] = creds;\n });\n }\n\n /**\n * After a 401, move a regional key to the cluster that issued it instead of\n * treating it as invalid. Only the Xiaomi Token Plan is regional: every key\n * used to go to Singapore, so Europe and China keys were rejected and deleted.\n * Returns the new base URL, or undefined when no other cluster accepts the key.\n */\n async relocateRegionalApiKey(storageKeys: string[]): Promise<string | undefined> {\n const key = await this.pickStorageKey(storageKeys);\n const creds = key === \"xiaomi\" ? this.data[key] : undefined;\n if (!creds) return undefined;\n const baseUrl = await this.detectXiaomiCluster(creds.accessToken);\n if (!baseUrl || baseUrl === creds.baseUrl) return undefined;\n await this.mutateLatest((latest) => {\n const current = latest[\"xiaomi\"];\n if (current?.accessToken === creds.accessToken) current.baseUrl = baseUrl;\n });\n log(\"INFO\", \"auth\", `Xiaomi Token Plan key belongs to ${baseUrl} — saved for future requests`);\n return baseUrl;\n }\n\n // Keeps local-endpoint keys: they're the user's own config, and a 401 there is often a busy gateway.\n async clearRejectedApiKey(storageKey: string): Promise<boolean> {\n if (storageKey.startsWith(LOCAL_AUTH_KEY_PREFIX)) return false;\n await this.clearCredentials(storageKey);\n return true;\n }\n\n async clearCredentials(provider: string): Promise<void> {\n await this.mutateLatest((latest) => {\n delete latest[provider];\n });\n }\n\n /**\n * Mark the credential stored under `storageKey` as usage-exhausted until\n * `resetsAt` (unix SECONDS, from the provider's rate-limit response) or a\n * 15-minute default when no reset time is known. While the mark is in the\n * future, `resolveCredentials(\"openai\")` serves the OpenAI API key instead of\n * the ChatGPT plan sign-in (when both are configured) — the plan stays the\n * preferred credential and is retried automatically once the mark\n * lapses. Persisted to auth.json so a restart (or another gg-app window)\n * doesn't burn a request rediscovering the same exhausted window. No-op if\n * nothing is stored under `storageKey`.\n */\n async markUsageExhausted(storageKey: string, resetsAt?: number): Promise<void> {\n const until =\n resetsAt !== undefined && resetsAt * 1000 > Date.now()\n ? resetsAt * 1000\n : Date.now() + USAGE_EXHAUSTED_DEFAULT_MS;\n let marked = false;\n await this.mutateLatest((latest) => {\n const creds = latest[storageKey];\n if (!creds) return;\n creds.usageExhaustedUntil = until;\n marked = true;\n });\n if (!marked) return;\n log(\n \"WARN\",\n \"auth\",\n `Marked ${storageKey} usage-exhausted until ${new Date(until).toISOString()}`,\n );\n }\n\n async clearAll(): Promise<void> {\n await this.ensureLoaded();\n await withFileLock(this.filePath, async () => {\n this.data = {};\n await atomicWriteFile(this.filePath, JSON.stringify(this.data, null, 2));\n });\n }\n\n /**\n * Returns valid credentials, auto-refreshing if expired.\n *\n * If `forceRefresh` is true, refreshes even if the token hasn't expired\n * (useful when the provider rejects a token with 401 before its stored\n * expiry). Callers recovering from a rejection should also pass\n * `rejectedToken` — see the stampede guard below.\n *\n * Throws if not logged in.\n */\n async resolveCredentials(\n provider: string,\n opts?: {\n forceRefresh?: boolean;\n storageKeys?: string[];\n /**\n * The access token the provider just rejected. Refreshing an OAuth grant\n * invalidates the previous access token, so N processes sharing auth.json\n * (app windows, CLI sessions, the usage poller) can otherwise revoke each\n * other in a loop: each one force-refreshes on 401, and every refresh kills\n * the token the others still hold. Naming the rejected token lets the\n * refresh path tell \"this credential is genuinely dead\" from \"someone else\n * already rotated it\" and simply adopt the newer on-disk credential.\n */\n rejectedToken?: string;\n },\n ): Promise<OAuthCredentials> {\n // Pick up a rotation performed by another process before serving a cached\n // token. Long-lived processes (the desktop sidecar runs for days) would\n // otherwise keep handing out an access token that a sibling's refresh\n // already invalidated — the \"it logged me out again\" failure.\n //\n // Skipped for a forced refresh on purpose: that path compares the caller's\n // snapshot against the latest file to detect a concurrent re-login, and\n // refreshing this instance's view first would destroy that evidence.\n if (opts?.forceRefresh) {\n await this.ensureLoaded();\n } else {\n await this.ensureFresh();\n }\n\n // A failed refresh removes the credential from this session's cache. If\n // the user then re-logs in through another app session, recover that new\n // on-disk credential instead of remaining \"not logged in\" until restart.\n const directStorageKeys =\n opts?.storageKeys && !(opts.storageKeys.length === 1 && opts.storageKeys[0] === provider)\n ? opts.storageKeys\n : providerStorageKeys(provider);\n if (\n !directStorageKeys.some((key) => Boolean(usableEntry(this.data, key))) &&\n !(provider === \"anthropic\" && this.data[CLAUDE_CODE_AUTH_KEY])\n ) {\n await this.reloadLatest();\n }\n\n // Explicit ordered storage-key override (e.g. Kimi: prefer the Kimi Code\n // plan key, fall back to the Moonshot key if only that's configured).\n // Bypasses the provider-name resolution below entirely when given. A\n // single-entry list equal to `[provider]` falls through to normal\n // resolution below.\n if (opts?.storageKeys && !(opts.storageKeys.length === 1 && opts.storageKeys[0] === provider)) {\n for (const key of opts.storageKeys) {\n const creds = usableEntry(this.data, key);\n if (!creds) continue;\n // A plan sign-in storage key must still refresh, so resolve it as its\n // own provider instead of returning a possibly expired token.\n if (key !== provider && dualAuthProviderByOAuthKey(key)) {\n return await this.resolveCredentials(key, {\n ...(opts.forceRefresh ? { forceRefresh: true } : {}),\n ...(opts.rejectedToken !== undefined ? { rejectedToken: opts.rejectedToken } : {}),\n });\n }\n // The provider's own key in an ordered list resolves through the normal\n // path, so a dual-auth provider still prefers its plan sign-in.\n if (key === provider) {\n return await this.resolveCredentials(provider, {\n ...(opts.forceRefresh ? { forceRefresh: true } : {}),\n ...(opts.rejectedToken !== undefined ? { rejectedToken: opts.rejectedToken } : {}),\n });\n }\n if (key === KIMI_CODE_KEY && !creds.baseUrl) return await this.withKimiRegion(creds);\n return creds;\n }\n throw new NotLoggedInError(provider);\n }\n\n // Signed in with Claude through Claude Code: there is no token to hand\n // out (Claude Code holds it). Callers on that route never use this one.\n if (\n provider === \"anthropic\" &&\n (!this.data.anthropic || isLegacyClaudeOAuth(this.data.anthropic))\n ) {\n if (isClaudeCodeAuthMarker(this.data[CLAUDE_CODE_AUTH_KEY])) {\n return { accessToken: \"\", refreshToken: \"\", expiresAt: Number.MAX_SAFE_INTEGER };\n }\n // The old GG Claude sign-in token can't be used or refreshed any more.\n if (this.data.anthropic) throw new NotLoggedInError(provider);\n }\n\n // Prefer the plan sign-in over the API key for a dual-auth provider\n // (OpenAI). When a sign-in exists, resolve (and refresh) that instead.\n const dual = dualAuthProvider(provider);\n const dualOAuthCreds = dual ? this.data[dual.oauthKey] : undefined;\n const dualApiKey = dual ? usableEntry(this.data, dual.provider) : undefined;\n if (dual && dualOAuthCreds) {\n // OAuth plan usage window exhausted (marked by the agent loop when the\n // subscription endpoint rejected with a usage/quota stop). Serve the API\n // key while the window recovers — but ONLY when one is configured; with no\n // API key the OAuth credential still resolves so the real usage-limit\n // error (with its reset time) surfaces to the user instead of a\n // misleading \"not logged in\".\n const exhaustedUntil = dualOAuthCreds.usageExhaustedUntil ?? 0;\n if (Date.now() < exhaustedUntil && dualApiKey) {\n log(\n \"WARN\",\n \"auth\",\n `${dual.oauthLabel} usage window is exhausted — using the ${dual.apiKeyLabel} until ` +\n `${new Date(exhaustedUntil).toISOString()} (OAuth resumes automatically).`,\n );\n return dualApiKey;\n }\n try {\n // Do NOT forward `storageKeys` here: the caller's keys (e.g.\n // AgentSession's [\"openai\"]) don't match the recursive provider\n // (\"openai-chatgpt\"), so forwarding them would trip the storage-key\n // override branch and silently return the raw API key.\n return await this.resolveCredentials(dual.oauthKey, {\n ...(opts?.forceRefresh ? { forceRefresh: true } : {}),\n ...(opts?.rejectedToken !== undefined ? { rejectedToken: opts.rejectedToken } : {}),\n });\n } catch (err) {\n // OAuth refresh token is dead and was wiped. Fall back to the API key if\n // the user also configured one. This is a billing switch (subscription →\n // metered key), so make it loud in the debug log rather than silent —\n // the user expects OAuth to stay active and should know a re-login is\n // needed to restore it.\n if (err instanceof NotLoggedInError && dualApiKey) {\n log(\n \"WARN\",\n \"auth\",\n `${dual.oauthLabel} credential is no longer valid — falling back to the ` +\n `${dual.apiKeyLabel}. ${dual.restoreHint}`,\n );\n return dualApiKey;\n }\n throw err;\n }\n }\n\n // A retired sign-in (old Codex token in the `openai` key slot, …) is never\n // served — above all never as an API key.\n const creds = usableEntry(this.data, provider);\n if (!creds) {\n throw new NotLoggedInError(provider);\n }\n\n // Static API keys have no refresh mechanism; only the ChatGPT plan\n // sign-in refreshes below.\n if (!REFRESHABLE_STORAGE_KEYS.has(provider)) {\n if (provider === \"minimax\" && !creds.baseUrl) return await this.withMiniMaxRegion(creds);\n if (provider === KIMI_CODE_KEY && !creds.baseUrl) return await this.withKimiRegion(creds);\n return creds;\n }\n\n // Return if not expired (with a safety skew) and not force-refreshing\n if (!opts?.forceRefresh && Date.now() < creds.expiresAt - refreshThresholdMs(creds)) {\n return creds;\n }\n\n // Serialize concurrent refresh calls per provider to avoid races\n const existing = this.refreshLocks.get(provider);\n if (existing) return existing;\n\n const refreshPromise = withFileLock(this.filePath, async () => {\n // Always refresh against the latest complete file. A different app\n // session may have re-logged in this provider or changed another one\n // since this instance loaded its cached snapshot.\n const latest = await readAuthData(this.filePath);\n const latestCreds = latest[provider];\n if (!latestCreds) {\n this.data = latest;\n throw new NotLoggedInError(provider);\n }\n\n const credentialWasReplaced =\n latestCreds.accessToken !== creds.accessToken ||\n latestCreds.refreshToken !== creds.refreshToken ||\n latestCreds.expiresAt !== creds.expiresAt;\n // The caller named the token the provider rejected, and disk now holds a\n // different one: a sibling process already rotated this grant. Minting\n // another token here would invalidate the one that sibling is using and\n // keep the mutual-revocation loop going.\n const rotatedBySibling =\n opts?.rejectedToken !== undefined && latestCreds.accessToken !== opts.rejectedToken;\n if (\n credentialWasReplaced ||\n rotatedBySibling ||\n (!opts?.forceRefresh &&\n Date.now() < latestCreds.expiresAt - refreshThresholdMs(latestCreds))\n ) {\n // Another process refreshed or re-logged in while this session still\n // held the rejected token. Trust that replacement even for a forced\n // refresh; retrying the revoked OLD refresh token would delete the new\n // login that just landed on disk.\n if (rotatedBySibling && !credentialWasReplaced) {\n log(\n \"INFO\",\n \"auth\",\n `${provider} token was rotated by another gg process — adopting it instead of refreshing again`,\n );\n }\n this.data = latest;\n return latestCreds;\n }\n\n let refreshed: OAuthCredentials;\n try {\n refreshed = await refreshChatGPTToken(latestCreds);\n } catch (err) {\n // Refresh token revoked / expired / invalid → the stored creds are\n // unusable. Delete only this provider from the latest snapshot so a\n // failed refresh can never erase another provider's concurrent login.\n const msg = err instanceof Error ? err.message : String(err);\n const isAuthFailure =\n /\\((401|400)\\)/.test(msg) ||\n /invalid_grant|invalid_token|invalid.*refresh|token_expired|refresh_token_(?:expired|invalidated|reused)/i.test(\n msg,\n ) ||\n /unauthorized/i.test(msg);\n if (isAuthFailure) {\n // Re-read before deleting. The refresh ran over the network while\n // the file lock can be broken by a waiter (5s wait vs 30s request);\n // a sibling may have refreshed with this grant or re-logged in\n // meanwhile, so the rejection only proves OUR token is dead.\n // Writing the pre-request snapshot back would erase their fresh\n // login and sign every window out.\n const current = await readAuthData(this.filePath);\n const currentCreds = current[provider];\n if (currentCreds && currentCreds.refreshToken !== latestCreds.refreshToken) {\n log(\n \"WARN\",\n \"auth\",\n `${provider} token refresh was rejected, but another gg process already saved a newer credential — adopting it instead of signing out`,\n );\n this.data = current;\n return currentCreds;\n }\n delete current[provider];\n this.data = current;\n await atomicWriteFile(this.filePath, JSON.stringify(current, null, 2));\n throw new NotLoggedInError(provider);\n }\n throw err;\n }\n // The refresh response carries tokens only; keep the identity and\n // routing fields saved at sign-in.\n refreshed = {\n ...pick(latestCreds, [\"accountId\", \"baseUrl\", \"clientId\", \"idToken\", \"scopes\"]),\n ...refreshed,\n };\n latest[provider] = refreshed;\n this.data = latest;\n // Write atomically (we already hold the file lock).\n await atomicWriteFile(this.filePath, JSON.stringify(latest, null, 2));\n return refreshed;\n });\n\n this.refreshLocks.set(provider, refreshPromise);\n try {\n return await refreshPromise;\n } finally {\n this.refreshLocks.delete(provider);\n // The refresh path rewrites auth.json and updates `this.data` in step, so\n // re-stamp the snapshot: otherwise the next ensureFresh() sees its own\n // write as a foreign change and re-reads the file on every resolve.\n await this.rememberSnapshot();\n }\n }\n\n /**\n * Returns a valid access token, auto-refreshing if expired.\n * Throws if not logged in.\n */\n async resolveToken(provider: string): Promise<string> {\n const creds = await this.resolveCredentials(provider);\n return creds.accessToken;\n }\n}\n\nfunction pick<T extends object, K extends keyof T>(value: T, keys: K[]): Partial<Pick<T, K>> {\n const out: Partial<Pick<T, K>> = {};\n for (const key of keys) if (value[key] !== undefined) out[key] = value[key];\n return out;\n}\n\n/** Read the latest complete auth snapshot while a caller holds the file lock. */\nasync function readAuthData(filePath: string): Promise<AuthData> {\n try {\n const content = await fs.readFile(filePath, \"utf-8\");\n return JSON.parse(content) as AuthData;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return {};\n throw error;\n }\n}\n\n/**\n * Atomic file write using temp file + rename pattern.\n * Prevents partial/corrupt data if the process crashes mid-write.\n */\nasync function atomicWriteFile(filePath: string, content: string): Promise<void> {\n const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID().slice(0, 8)}.tmp`;\n try {\n await fs.writeFile(tmpPath, content, { encoding: \"utf-8\", mode: 0o600 });\n await fs.rename(tmpPath, filePath);\n } catch (err) {\n await fs.unlink(tmpPath).catch(() => {});\n throw err;\n }\n}\n\nexport class NotLoggedInError extends Error {\n provider: string;\n constructor(provider: string) {\n super(`Not logged in to ${provider}. Run \"ggcoder login\" to authenticate.`);\n this.name = \"NotLoggedInError\";\n this.provider = provider;\n }\n}\n","import path from \"node:path\";\nimport os from \"node:os\";\n\nexport interface AppPaths {\n agentDir: string;\n sessionsDir: string;\n subagentSessionsDir: string;\n subagentsDir: string;\n settingsFile: string;\n authFile: string;\n telegramFile: string;\n agentHomeFile: string;\n mcpFile: string;\n mcpAuthFile: string;\n /** Cached MCP tool catalog, so a cold start can answer tool_search honestly. */\n mcpCatalogFile: string;\n logFile: string;\n skillsDir: string;\n extensionsDir: string;\n agentsDir: string;\n progressFile: string;\n progressBackupFile: string;\n /** Earned trophies and the counters that unlock them. */\n trophiesFile: string;\n /** On-demand Claude Code binaries for \"Sign in with Claude\", one dir per version. */\n claudeCodeDir: string;\n /** GG Coder's own Claude Code login (CLAUDE_CONFIG_DIR), separate from the user's. */\n claudeCodeConfigDir: string;\n}\n\nexport function getAppPaths(): AppPaths {\n const agentDir = path.join(os.homedir(), \".gg\");\n return {\n agentDir,\n sessionsDir: path.join(agentDir, \"sessions\"),\n subagentSessionsDir: path.join(agentDir, \"subagent-sessions\"),\n subagentsDir: path.join(agentDir, \"subagents\"),\n settingsFile: path.join(agentDir, \"settings.json\"),\n authFile: path.join(agentDir, \"auth.json\"),\n telegramFile: path.join(agentDir, \"telegram.json\"),\n agentHomeFile: path.join(agentDir, \"agent-home.json\"),\n mcpFile: path.join(agentDir, \"mcp.json\"),\n mcpAuthFile: path.join(agentDir, \"mcp-auth.json\"),\n mcpCatalogFile: path.join(agentDir, \"mcp-catalog.json\"),\n logFile: path.join(agentDir, \"debug.log\"),\n skillsDir: path.join(agentDir, \"skills\"),\n extensionsDir: path.join(agentDir, \"extensions\"),\n agentsDir: path.join(agentDir, \"agents\"),\n progressFile: path.join(agentDir, \"progress.json\"),\n progressBackupFile: path.join(agentDir, \"progress.backup.json\"),\n trophiesFile: path.join(agentDir, \"trophies.json\"),\n claudeCodeDir: path.join(agentDir, \"claude-code\"),\n claudeCodeConfigDir: path.join(agentDir, \"claude-code\", \"config\"),\n };\n}\n","import { spawn } from \"node:child_process\";\nimport crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { getAppPaths } from \"../paths.js\";\nimport type { OAuthLoginCallbacks } from \"../oauth/types.js\";\nimport { extractFileFromTarGz } from \"./tar.js\";\nimport manifestJson from \"./manifest.json\" with { type: \"json\" };\n\nexport interface ClaudeCodePlatformAsset {\n url: string;\n /** npm `dist.integrity`: \"sha512-<base64>\". */\n integrity: string;\n bin: string;\n}\n\nexport interface ClaudeCodeManifest {\n sdkVersion: string;\n claudeCodeVersion: string;\n platforms: Record<string, ClaudeCodePlatformAsset>;\n}\n\nexport const CLAUDE_CODE_MANIFEST: ClaudeCodeManifest = manifestJson;\n\n/** auth.json key for \"signed in with Claude through Claude Code\". Holds only the marker. */\nexport const CLAUDE_CODE_AUTH_KEY = \"anthropic-claude-code\";\nexport const CLAUDE_CODE_AUTH_MARKER = { via: \"claude-code\" } as const;\n\nexport function isClaudeCodeAuthMarker(value: unknown): boolean {\n return (value as { via?: unknown } | null | undefined)?.via === CLAUDE_CODE_AUTH_MARKER.via;\n}\n\n/** An old GG Claude sign-in token (`sk-ant-oat…`). It no longer works; the user must sign in again. */\nexport function isLegacyClaudeOAuth(value: unknown): boolean {\n const token = (value as { accessToken?: unknown } | null | undefined)?.accessToken;\n return typeof token === \"string\" && token.startsWith(\"sk-ant-oat\");\n}\n\nexport type ClaudeCodeInstallProgress =\n | { phase: \"download\"; receivedBytes: number; totalBytes?: number }\n | { phase: \"verify\" }\n | { phase: \"extract\" };\n\nexport interface ClaudeCodeRuntimeOptions {\n /** Defaults to `~/.gg/claude-code`. */\n home?: string;\n manifest?: ClaudeCodeManifest;\n /** Platform key in the manifest; detected from the running process by default. */\n platformKey?: string;\n}\n\nexport interface EnsureClaudeCodeOptions extends ClaudeCodeRuntimeOptions {\n onProgress?: (progress: ClaudeCodeInstallProgress) => void;\n signal?: AbortSignal;\n fetch?: typeof fetch;\n}\n\nexport interface ClaudeCodeRunOptions extends ClaudeCodeRuntimeOptions {\n /** Executable to run instead of the installed binary (tests). */\n binary?: string;\n /** Arguments placed before the `auth …` arguments (tests run a script via node). */\n binaryArgs?: string[];\n signal?: AbortSignal;\n /** How long sign-in waits for the browser URL before falling back to the paste-code page. */\n browserWaitMs?: number;\n}\n\nexport interface ClaudeCodeAuthStatus {\n loggedIn: boolean;\n /** \"claude.ai\" for a Claude subscription. */\n authMethod?: string;\n email?: string;\n subscriptionType?: string;\n}\n\ninterface PlatformInfo {\n platform: NodeJS.Platform;\n arch: string;\n musl: boolean;\n}\n\nfunction isMusl(): boolean {\n if (process.platform !== \"linux\") return false;\n const report =\n typeof process.report?.getReport === \"function\"\n ? (process.report.getReport() as { header?: { glibcVersionRuntime?: string } })\n : null;\n return report != null && report.header?.glibcVersionRuntime === undefined;\n}\n\n/** Manifest key for a platform, or null where Claude Code has no build. */\nexport function claudeCodePlatformKey(\n info: PlatformInfo = { platform: process.platform, arch: process.arch, musl: isMusl() },\n): string | null {\n const arch = info.arch === \"arm64\" || info.arch === \"x64\" ? info.arch : null;\n if (!arch) return null;\n if (info.platform === \"darwin\" || info.platform === \"win32\") return `${info.platform}-${arch}`;\n if (info.platform === \"linux\") return `linux-${arch}${info.musl ? \"-musl\" : \"\"}`;\n return null;\n}\n\nfunction resolveAsset(opts: ClaudeCodeRuntimeOptions): {\n manifest: ClaudeCodeManifest;\n asset: ClaudeCodePlatformAsset;\n} {\n const manifest = opts.manifest ?? CLAUDE_CODE_MANIFEST;\n const key = opts.platformKey ?? claudeCodePlatformKey();\n const asset = key ? manifest.platforms[key] : undefined;\n if (!asset) {\n throw new Error(\n `Claude Code isn't available for ${process.platform}-${process.arch}. Use an Anthropic API key instead.`,\n );\n }\n return { manifest, asset };\n}\n\nexport function claudeCodeHome(opts: ClaudeCodeRuntimeOptions = {}): string {\n return opts.home ?? getAppPaths().claudeCodeDir;\n}\n\nexport function claudeCodeConfigDir(opts: ClaudeCodeRuntimeOptions = {}): string {\n return opts.home ? path.join(opts.home, \"config\") : getAppPaths().claudeCodeConfigDir;\n}\n\n/** Where the pinned binary lives once installed (it may not exist yet). */\nexport function claudeCodeBinaryPath(opts: ClaudeCodeRuntimeOptions = {}): string {\n const { manifest, asset } = resolveAsset(opts);\n return path.join(claudeCodeHome(opts), manifest.claudeCodeVersion, asset.bin);\n}\n\n/** The installed binary for the pinned version, or null when it still needs downloading. */\nexport function resolveClaudeCodeBinary(opts: ClaudeCodeRuntimeOptions = {}): string | null {\n let binary: string;\n try {\n binary = claudeCodeBinaryPath(opts);\n } catch {\n return null;\n }\n try {\n return fs.statSync(binary).isFile() ? binary : null;\n } catch {\n return null;\n }\n}\n\nconst installs = new Map<string, Promise<string>>();\n\n/**\n * Download, verify and unpack the pinned Claude Code binary if it isn't\n * installed yet. Concurrent callers in this process share one download;\n * other processes race safely because each unpacks into its own staging dir\n * and only one rename wins.\n */\nexport async function ensureClaudeCodeBinary(opts: EnsureClaudeCodeOptions = {}): Promise<string> {\n const existing = resolveClaudeCodeBinary(opts);\n if (existing) return existing;\n const target = claudeCodeBinaryPath(opts);\n let install = installs.get(target);\n if (!install) {\n install = installClaudeCode(opts).finally(() => installs.delete(target));\n installs.set(target, install);\n }\n return install;\n}\n\nasync function installClaudeCode(opts: EnsureClaudeCodeOptions): Promise<string> {\n const { manifest, asset } = resolveAsset(opts);\n const home = claudeCodeHome(opts);\n const versionDir = path.join(home, manifest.claudeCodeVersion);\n const binary = path.join(versionDir, asset.bin);\n await fsp.mkdir(home, { recursive: true });\n const tag = `${process.pid}-${crypto.randomBytes(4).toString(\"hex\")}`;\n const tarball = path.join(home, `.download-${tag}.tgz`);\n const staging = path.join(home, `.staging-${tag}`);\n try {\n await download(asset, tarball, opts);\n opts.onProgress?.({ phase: \"extract\" });\n await fsp.mkdir(staging, { recursive: true });\n const found = await extractFileFromTarGz(\n tarball,\n `package/${asset.bin}`,\n path.join(staging, asset.bin),\n );\n if (!found) throw new Error(`The Claude Code download has no ${asset.bin} binary.`);\n await fsp.chmod(path.join(staging, asset.bin), 0o755);\n try {\n await fsp.rename(staging, versionDir);\n } catch (err) {\n // Another process installed the same version first; theirs is identical.\n if (!fs.existsSync(binary)) throw err;\n }\n await removeOldVersions(home, manifest.claudeCodeVersion);\n return binary;\n } finally {\n await fsp.rm(tarball, { force: true }).catch(() => {});\n await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});\n }\n}\n\nasync function download(\n asset: ClaudeCodePlatformAsset,\n dest: string,\n opts: EnsureClaudeCodeOptions,\n): Promise<void> {\n const doFetch = opts.fetch ?? fetch;\n const res = await doFetch(asset.url, { signal: opts.signal });\n if (!res.ok || !res.body) {\n throw new Error(`Claude Code download failed (HTTP ${res.status}).`);\n }\n const totalBytes = Number(res.headers.get(\"content-length\")) || undefined;\n const hash = crypto.createHash(\"sha512\");\n const out = await fsp.open(dest, \"w\");\n let receivedBytes = 0;\n let lastReported = -1;\n try {\n opts.onProgress?.({ phase: \"download\", receivedBytes, totalBytes });\n for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {\n if (opts.signal?.aborted) throw opts.signal.reason ?? new Error(\"Download cancelled.\");\n hash.update(chunk);\n await out.write(chunk);\n receivedBytes += chunk.byteLength;\n // Report each whole percent (or every 4 MB without a length), not every chunk.\n const step = totalBytes\n ? Math.floor((receivedBytes / totalBytes) * 100)\n : Math.floor(receivedBytes / 4_194_304);\n if (step !== lastReported) {\n lastReported = step;\n opts.onProgress?.({ phase: \"download\", receivedBytes, totalBytes });\n }\n }\n } finally {\n await out.close();\n }\n opts.onProgress?.({ phase: \"verify\" });\n const expected = asset.integrity.replace(/^sha512-/, \"\");\n if (hash.digest(\"base64\") !== expected) {\n throw new Error(\n \"The Claude Code download didn't match its pinned checksum, so it was discarded.\",\n );\n }\n}\n\nasync function removeOldVersions(home: string, keep: string): Promise<void> {\n let entries: fs.Dirent[];\n try {\n entries = await fsp.readdir(home, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name === keep || entry.name === \"config\") continue;\n if (!/^\\d+\\.\\d+\\.\\d+/.test(entry.name)) continue;\n // A window may still run the old binary; Windows refuses (EBUSY) until it exits.\n await fsp.rm(path.join(home, entry.name), { recursive: true, force: true }).catch(() => {});\n }\n}\n\n/** Plain-language status line for an install progress event. */\nexport function formatClaudeCodeInstallProgress(progress: ClaudeCodeInstallProgress): string {\n if (progress.phase === \"verify\") return \"Checking the Claude Code download…\";\n if (progress.phase === \"extract\") return \"Unpacking Claude Code…\";\n const mb = (bytes: number) => Math.round(bytes / 1_048_576);\n if (!progress.totalBytes) return `Downloading Claude Code… ${mb(progress.receivedBytes)} MB`;\n const pct = Math.floor((progress.receivedBytes / progress.totalBytes) * 100);\n return `Downloading Claude Code… ${pct}% (${mb(progress.receivedBytes)} of ${mb(progress.totalBytes)} MB)`;\n}\n\n// Credentials Claude Code would prefer over the subscription login.\nconst STRIPPED_ENV = [\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_BASE_URL\",\n \"CLAUDE_CODE_OAUTH_TOKEN\",\n \"CLAUDE_CODE_OAUTH_REFRESH_TOKEN\",\n \"CLAUDE_CODE_USE_BEDROCK\",\n \"CLAUDE_CODE_USE_VERTEX\",\n \"CLAUDE_CODE_USE_FOUNDRY\",\n \"NODE_OPTIONS\",\n];\n\n/** Environment for every Claude Code process GG Coder starts: our own login, no stray keys. */\nexport function claudeCodeEnv(\n opts: ClaudeCodeRuntimeOptions = {},\n base: NodeJS.ProcessEnv = process.env,\n): Record<string, string> {\n const env: Record<string, string> = {};\n for (const [key, value] of Object.entries(base)) {\n if (value !== undefined && !STRIPPED_ENV.includes(key)) env[key] = value;\n }\n env.CLAUDE_CONFIG_DIR = claudeCodeConfigDir(opts);\n // The binary is pinned and checksummed; it must not replace itself.\n env.DISABLE_AUTOUPDATER = \"1\";\n return env;\n}\n\nfunction runCommand(opts: ClaudeCodeRunOptions): { command: string; prefix: string[] } {\n if (opts.binary) return { command: opts.binary, prefix: opts.binaryArgs ?? [] };\n const binary = resolveClaudeCodeBinary(opts);\n if (!binary) throw new Error(\"Claude Code isn't installed yet. Sign in with Claude first.\");\n return { command: binary, prefix: [] };\n}\n\nfunction runClaude(\n args: string[],\n opts: ClaudeCodeRunOptions,\n): Promise<{ code: number | null; stdout: string; stderr: string }> {\n const { command, prefix } = runCommand(opts);\n return new Promise((resolve, reject) => {\n const child = spawn(command, [...prefix, ...args], {\n env: claudeCodeEnv(opts),\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n windowsHide: true,\n signal: opts.signal,\n });\n let stdout = \"\";\n let stderr = \"\";\n child.stdout.on(\"data\", (d: Buffer) => (stdout += d.toString()));\n child.stderr.on(\"data\", (d: Buffer) => (stderr += d.toString()));\n child.on(\"error\", reject);\n child.on(\"close\", (code) => resolve({ code, stdout, stderr }));\n });\n}\n\n/** `claude auth status` for GG Coder's own login. Logged out when the binary is missing. */\nexport async function claudeCodeAuthStatus(\n opts: ClaudeCodeRunOptions = {},\n): Promise<ClaudeCodeAuthStatus> {\n if (!opts.binary && !resolveClaudeCodeBinary(opts)) return { loggedIn: false };\n const { stdout } = await runClaude([\"auth\", \"status\", \"--json\"], opts);\n // Exits 1 when logged out but still prints the JSON.\n try {\n const parsed = JSON.parse(stdout) as Record<string, unknown>;\n return {\n loggedIn: parsed.loggedIn === true,\n ...(typeof parsed.authMethod === \"string\" ? { authMethod: parsed.authMethod } : {}),\n ...(typeof parsed.email === \"string\" ? { email: parsed.email } : {}),\n ...(typeof parsed.subscriptionType === \"string\"\n ? { subscriptionType: parsed.subscriptionType }\n : {}),\n };\n } catch {\n return { loggedIn: false };\n }\n}\n\nexport async function logoutClaudeCode(opts: ClaudeCodeRunOptions = {}): Promise<void> {\n if (!opts.binary && !resolveClaudeCodeBinary(opts)) return;\n const { code, stderr } = await runClaude([\"auth\", \"logout\"], opts);\n if (code !== 0) throw new Error(stderr.trim() || `claude auth logout exited with ${code}`);\n}\n\n// Claude Code runs $BROWSER with the sign-in URL as its only argument. Pointing\n// it at Node with this preload hands us the URL instead of opening a tab, so the\n// app opens it (once). Node resolves argv[1] like a script path, so only the\n// callback port is read back from it.\nconst OPEN_URL_HOOK = `const arg = String(process.argv[1] || \"\");\nconst file = process.env.GG_CLAUDE_CODE_URL_FILE;\nif (file && /https?:/.test(arg)) {\n require(\"node:fs\").appendFileSync(file, arg + \"\\\\n\");\n process.exit(0);\n}\n`;\n\nconst CAPTURE_WAIT_MS = 3_000;\n\nfunction stripAnsi(text: string): string {\n // eslint-disable-next-line no-control-regex\n return text.replace(/\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)|\\x1b\\[[0-9;?]*[A-Za-z]/g, \"\");\n}\n\n/** The browser sign-in URL with Claude Code's localhost callback instead of the paste-code page. */\nexport function claudeCodeCallbackUrl(manualUrl: string, captured: string): string | null {\n const port = /localhost(?::|%3A)(\\d{2,5})(?:\\/|%2F)callback/i.exec(captured)?.[1];\n if (!port) return null;\n try {\n const url = new URL(manualUrl);\n url.searchParams.set(\"redirect_uri\", `http://localhost:${port}/callback`);\n return url.toString();\n } catch {\n return null;\n }\n}\n\n/**\n * Run `claude auth login` for GG Coder's own Claude login. The browser URL goes\n * to `onOpenUrl`; Claude Code's localhost callback finishes sign-in, and a\n * pasted code (from the fallback page) is accepted too.\n */\nexport async function loginClaudeCode(\n callbacks: OAuthLoginCallbacks,\n opts: ClaudeCodeRunOptions = {},\n): Promise<void> {\n const { command, prefix } = runCommand(opts);\n const home = claudeCodeHome(opts);\n await fsp.mkdir(claudeCodeConfigDir(opts), { recursive: true });\n const tag = `${process.pid}-${crypto.randomBytes(4).toString(\"hex\")}`;\n const hookFile = path.join(home, `.open-url-${tag}.cjs`);\n const urlFile = path.join(home, `.open-url-${tag}.txt`);\n await fsp.writeFile(hookFile, OPEN_URL_HOOK);\n const env = {\n ...claudeCodeEnv(opts),\n BROWSER: process.execPath,\n NODE_OPTIONS: `--require ${JSON.stringify(hookFile)}`,\n GG_CLAUDE_CODE_URL_FILE: urlFile,\n };\n const promptAbort = new AbortController();\n let timer: NodeJS.Timeout | undefined;\n try {\n await new Promise<void>((resolve, reject) => {\n const child = spawn(command, [...prefix, \"auth\", \"login\", \"--claudeai\"], {\n env,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n windowsHide: true,\n });\n let stdout = \"\";\n let stderr = \"\";\n let manualUrl: string | null = null;\n let opened = false;\n const onAbort = () => child.kill();\n opts.signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n const open = (url: string, needsCode: boolean) => {\n if (opened) return;\n opened = true;\n clearInterval(timer);\n callbacks.onOpenUrl(url);\n callbacks.onStatus(\"Finish signing in to Claude in your browser…\");\n if (!needsCode) return;\n // Fallback page shows a code to paste back (\"code#state\").\n void callbacks\n .onPromptCode(\"Paste the code shown after signing in to Claude:\", promptAbort.signal)\n .then((code) => {\n const trimmed = code.trim();\n if (trimmed && !child.stdin.destroyed) child.stdin.write(`${trimmed}\\n`);\n })\n .catch(() => {});\n };\n\n child.stdout.on(\"data\", (d: Buffer) => {\n stdout += d.toString();\n if (manualUrl) return;\n const match = /visit:\\s*(https:\\/\\/\\S+)/.exec(stripAnsi(stdout));\n if (!match) return;\n manualUrl = match[1]!;\n const started = Date.now();\n timer = setInterval(() => {\n let captured = \"\";\n try {\n captured = fs.readFileSync(urlFile, \"utf8\");\n } catch {\n // Not written yet.\n }\n const callbackUrl = captured ? claudeCodeCallbackUrl(manualUrl!, captured) : null;\n if (callbackUrl) open(callbackUrl, false);\n else if (Date.now() - started > (opts.browserWaitMs ?? CAPTURE_WAIT_MS))\n open(manualUrl!, true);\n }, 100);\n });\n child.stderr.on(\"data\", (d: Buffer) => (stderr += d.toString()));\n child.on(\"error\", reject);\n child.on(\"close\", (code) => {\n opts.signal?.removeEventListener(\"abort\", onAbort);\n if (opts.signal?.aborted) reject(new Error(\"Sign-in cancelled.\"));\n else if (code === 0) resolve();\n else {\n const detail = stripAnsi(stderr).trim().split(\"\\n\").pop()?.trim();\n reject(new Error(detail || `Claude sign-in failed (exit ${code}).`));\n }\n });\n });\n } finally {\n clearInterval(timer);\n promptAbort.abort();\n await fsp.rm(hookFile, { force: true }).catch(() => {});\n await fsp.rm(urlFile, { force: true }).catch(() => {});\n }\n}\n","import fs from \"node:fs\";\nimport fsp from \"node:fs/promises\";\nimport zlib from \"node:zlib\";\n\nconst BLOCK = 512;\n\nfunction readString(buf: Buffer, offset: number, length: number): string {\n const end = buf.indexOf(0, offset);\n return buf.toString(\"utf8\", offset, end === -1 || end > offset + length ? offset + length : end);\n}\n\nfunction readSize(buf: Buffer, offset: number): number {\n // GNU base-256 encoding (high bit set) for sizes past the octal limit.\n if (buf[offset]! & 0x80) {\n let value = buf[offset]! & 0x7f;\n for (let i = 1; i < 12; i++) value = value * 256 + buf[offset + i]!;\n return value;\n }\n const text = readString(buf, offset, 12).trim();\n return text ? parseInt(text, 8) : 0;\n}\n\nfunction paxPath(data: Buffer): string | undefined {\n let pos = 0;\n while (pos < data.length) {\n const space = data.indexOf(0x20, pos);\n if (space === -1) break;\n const len = parseInt(data.toString(\"utf8\", pos, space), 10);\n if (!Number.isFinite(len) || len <= 0) break;\n const record = data.toString(\"utf8\", space + 1, pos + len - 1);\n const eq = record.indexOf(\"=\");\n if (eq !== -1 && record.slice(0, eq) === \"path\") return record.slice(eq + 1);\n pos += len;\n }\n return undefined;\n}\n\n/**\n * Stream one regular file out of a .tgz into `destPath` without unpacking the\n * rest (or holding the ~300 MB binary in memory). Resolves false when the\n * archive has no such entry.\n */\nexport async function extractFileFromTarGz(\n tgzPath: string,\n entryName: string,\n destPath: string,\n): Promise<boolean> {\n const input = fs.createReadStream(tgzPath).pipe(zlib.createGunzip());\n let pending: Buffer = Buffer.alloc(0);\n let remaining = 0; // data bytes left in the current entry\n let padding = 0; // zero bytes after the data, up to the block boundary\n let kind: \"skip\" | \"pax\" | \"longname\" | \"target\" = \"skip\";\n let meta: Buffer[] = [];\n let overridePath: string | undefined;\n let out: fsp.FileHandle | undefined;\n let found = false;\n\n try {\n for await (const chunk of input as AsyncIterable<Buffer>) {\n pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;\n let pos = 0;\n while (pos < pending.length) {\n if (remaining > 0) {\n const take = Math.min(remaining, pending.length - pos);\n const slice = pending.subarray(pos, pos + take);\n if (kind === \"target\") await out!.write(slice);\n else if (kind === \"pax\" || kind === \"longname\") meta.push(Buffer.from(slice));\n pos += take;\n remaining -= take;\n if (remaining === 0) {\n if (kind === \"target\") {\n await out!.close();\n out = undefined;\n found = true;\n } else if (kind === \"pax\") {\n overridePath = paxPath(Buffer.concat(meta)) ?? overridePath;\n } else if (kind === \"longname\") {\n overridePath = readString(Buffer.concat(meta), 0, Number.MAX_SAFE_INTEGER);\n }\n }\n continue;\n }\n if (padding > 0) {\n const skip = Math.min(padding, pending.length - pos);\n pos += skip;\n padding -= skip;\n continue;\n }\n if (found) break;\n if (pending.length - pos < BLOCK) break;\n const header = pending.subarray(pos, pos + BLOCK);\n pos += BLOCK;\n if (header.every((b) => b === 0)) continue; // end-of-archive blocks\n const size = readSize(header, 124);\n const type = String.fromCharCode(header[156] || 0x30);\n let name = readString(header, 0, 100);\n if (readString(header, 257, 6) === \"ustar\") {\n const prefix = readString(header, 345, 155);\n if (prefix) name = `${prefix}/${name}`;\n }\n remaining = size;\n padding = (BLOCK - (size % BLOCK)) % BLOCK;\n meta = [];\n if (type === \"x\") kind = \"pax\";\n else if (type === \"L\") kind = \"longname\";\n else {\n const path = overridePath ?? name;\n overridePath = undefined;\n if ((type === \"0\" || type === \"\\0\" || type === \"7\") && path === entryName) {\n kind = \"target\";\n out = await fsp.open(destPath, \"w\", 0o755);\n if (size === 0) {\n await out.close();\n out = undefined;\n found = true;\n }\n } else {\n kind = \"skip\";\n }\n }\n }\n pending = pos < pending.length ? Buffer.from(pending.subarray(pos)) : Buffer.alloc(0);\n if (found) break;\n }\n if (!found && out) throw new Error(`Archive ended inside ${entryName}`);\n return found;\n } finally {\n if (out) await out.close().catch(() => {});\n input.destroy();\n }\n}\n","import http from \"node:http\";\nimport type { AddressInfo } from \"node:net\";\nimport crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { getAppPaths } from \"../paths.js\";\nimport { generatePKCE } from \"./pkce.js\";\nimport { oauthRequestSignal } from \"./request-timeout.js\";\nimport type { OAuthCredentials, OAuthLoginCallbacks } from \"./types.js\";\n\n// Sign in with ChatGPT for open-source local apps:\n// https://developers.openai.com/siwc/token-sharing-open-source/sign-in\nconst ISSUER = \"https://auth.openai.com\";\nconst AUTHORIZE_URL = `${ISSUER}/api/accounts/authorize`;\nconst TOKEN_URL = `${ISSUER}/api/accounts/oauth/token`;\nconst REVOKE_URL = `${ISSUER}/api/accounts/oauth/revoke`;\nconst JWKS_URL = `${ISSUER}/.well-known/jwks.json`;\n/** First-time registration entrypoint; never saved or used for token exchange. */\nconst REGISTRATION_CLIENT_ID = \"dynamic_agent_client\";\nconst AGENT_NAME = \"GG Coder\";\nconst RESOURCE = \"https://api.openai.com/v1\";\n/** The scope that lets GG Coder use the user's ChatGPT plan. */\nexport const CHATGPT_PLAN_SCOPE = \"chatgpt.tokens.use.direct\";\nconst SCOPES = `openid profile email offline_access resource.invoke ${CHATGPT_PLAN_SCOPE}`;\n/** Responses API base for ChatGPT plan inference. */\nexport const CHATGPT_PLAN_BASE_URL = \"https://api.openai.com/v1\";\n/** Where users review and limit what GG Coder may use of their plan. */\nexport const CHATGPT_USAGE_URL = \"https://chatgpt.com/settings/usage\";\nconst CALLBACK_PATH = \"/auth/callback\";\nconst PREFERRED_PORT = 1455;\n/** How long the loopback listener stays up before giving the paste route the floor. */\nconst CALLBACK_TIMEOUT_MS = 120_000;\n/** Retries allowed on a mistyped/truncated paste before that route gives up. */\nconst MAX_PASTE_ATTEMPTS = 3;\nconst ID_TOKEN_CLOCK_SKEW_S = 60;\n\nexport const CHATGPT_PLAN_NOT_ENABLED_MESSAGE =\n \"ChatGPT plan use isn't enabled for GG Coder. Allow Use your ChatGPT plan, or add an OpenAI API key.\";\n\n/** A sign-in that worked but can't be used to bill the user's ChatGPT plan. */\nexport class ChatGPTPlanNotEnabledError extends Error {\n constructor() {\n super(CHATGPT_PLAN_NOT_ENABLED_MESSAGE);\n this.name = \"ChatGPTPlanNotEnabledError\";\n }\n}\n\ninterface ChatGPTRegistration {\n clientId: string;\n subject: string;\n}\n\nexport interface ChatGPTLoginOptions {\n /** Defaults to `~/.gg/openai-host-id`. */\n hostIdFile?: string;\n /** Defaults to `~/.gg/openai-chatgpt-client.json`. */\n registrationFile?: string;\n /** The current sign-in, if any: its ID token skips the account picker. */\n previous?: Pick<OAuthCredentials, \"idToken\">;\n}\n\nfunction defaultHostIdFile(): string {\n return path.join(getAppPaths().agentDir, \"openai-host-id\");\n}\n\nfunction defaultRegistrationFile(): string {\n return path.join(getAppPaths().agentDir, \"openai-chatgpt-client.json\");\n}\n\n/** Owner-only write via temp file + rename, so a crash never leaves a torn file. */\nasync function writePrivateFile(file: string, content: string): Promise<void> {\n await fs.mkdir(path.dirname(file), { recursive: true });\n const tmp = `${file}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;\n try {\n await fs.writeFile(tmp, content, { encoding: \"utf-8\", mode: 0o600 });\n await fs.rename(tmp, file);\n } catch (err) {\n await fs.unlink(tmp).catch(() => {});\n throw err;\n }\n}\n\nconst HOST_ID_PATTERN = /^urn:uuid:[0-9a-f-]{36}$/i;\n\n/** This machine's stable `ext_agent_host_id`, created on first use. */\nexport async function getChatGPTHostId(file = defaultHostIdFile()): Promise<string> {\n const existing = await fs.readFile(file, \"utf-8\").catch(() => \"\");\n if (HOST_ID_PATTERN.test(existing.trim())) return existing.trim();\n await writePrivateFile(file, `urn:uuid:${crypto.randomUUID()}`);\n // Another process may have won the race; everyone uses what is on disk.\n return (await fs.readFile(file, \"utf-8\")).trim();\n}\n\nasync function readRegistration(file: string): Promise<ChatGPTRegistration | undefined> {\n try {\n const value = JSON.parse(await fs.readFile(file, \"utf-8\")) as Partial<ChatGPTRegistration>;\n if (typeof value.clientId === \"string\" && typeof value.subject === \"string\") {\n return { clientId: value.clientId, subject: value.subject };\n }\n } catch {\n // Missing or unreadable: register again.\n }\n return undefined;\n}\n\n/**\n * Continue with ChatGPT. Registers GG Coder for this ChatGPT account on first\n * use (the user names and approves it in the browser), then reuses the issued\n * client ID. Throws {@link ChatGPTPlanNotEnabledError} when the user signed in\n * but didn't allow plan use.\n */\nexport async function loginChatGPT(\n callbacks: OAuthLoginCallbacks,\n options: ChatGPTLoginOptions = {},\n): Promise<OAuthCredentials> {\n const hostId = await getChatGPTHostId(options.hostIdFile);\n const registrationFile = options.registrationFile ?? defaultRegistrationFile();\n const registration = await readRegistration(registrationFile);\n const { verifier, challenge } = await generatePKCE();\n const state = crypto.randomBytes(16).toString(\"hex\");\n const nonce = crypto.randomBytes(16).toString(\"hex\");\n\n const listener = await startCallbackListener(state, Boolean(registration));\n try {\n const redirectUri = `http://127.0.0.1:${listener.port}${CALLBACK_PATH}`;\n const url = new URL(AUTHORIZE_URL);\n url.searchParams.set(\"client_id\", registration?.clientId ?? REGISTRATION_CLIENT_ID);\n if (!registration) url.searchParams.set(\"agent_name_hint\", AGENT_NAME);\n url.searchParams.set(\"ext_agent_host_id\", hostId);\n if (registration && options.previous?.idToken) {\n url.searchParams.set(\"id_token_hint\", options.previous.idToken);\n }\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"redirect_uri\", redirectUri);\n url.searchParams.set(\"scope\", SCOPES);\n url.searchParams.set(\"resource\", RESOURCE);\n url.searchParams.set(\"state\", state);\n url.searchParams.set(\"nonce\", nonce);\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n url.searchParams.set(\"code_challenge\", challenge);\n\n const callback = await acquireCallback(url.toString(), state, listener, callbacks);\n if (callback.get(\"error\")) {\n if (callback.get(\"error\") === \"access_denied\") throw new ChatGPTPlanNotEnabledError();\n throw new Error(\n `ChatGPT sign-in failed: ${callback.get(\"error_description\") ?? callback.get(\"error\")}`,\n );\n }\n const code = callback.get(\"code\");\n if (!code) throw new Error(\"ChatGPT sign-in returned no authorization code.\");\n const issued = callback.get(\"client_id\") ?? undefined;\n if (registration && issued && issued !== registration.clientId) {\n throw new Error(\"ChatGPT returned a different app registration. Start the sign-in again.\");\n }\n const clientId = registration?.clientId ?? issued;\n if (!clientId || clientId === REGISTRATION_CLIENT_ID) {\n throw new Error(\n \"ChatGPT didn't finish registering GG Coder. Start the sign-in again, and paste the full callback URL if you're on another machine.\",\n );\n }\n\n const tokens = await exchangeCode({ code, verifier, clientId, redirectUri });\n const claims = await verifyIdToken(tokens.idToken, { clientId, nonce });\n const subject = String(claims.sub ?? \"\");\n if (!subject) throw new Error(\"ChatGPT ID token has no subject.\");\n if (registration && registration.subject !== subject) {\n // The saved registration belongs to another account; register afresh next time.\n await fs.unlink(registrationFile).catch(() => {});\n await revokeChatGPTSignIn({ refreshToken: tokens.refreshToken, clientId });\n throw new Error(\n \"That was a different ChatGPT account. Continue with ChatGPT again to connect it.\",\n );\n }\n if (!tokens.scopes.includes(CHATGPT_PLAN_SCOPE)) {\n await revokeChatGPTSignIn({ refreshToken: tokens.refreshToken, clientId });\n throw new ChatGPTPlanNotEnabledError();\n }\n if (!registration) {\n await writePrivateFile(registrationFile, JSON.stringify({ clientId, subject }));\n }\n\n return {\n accessToken: tokens.accessToken,\n refreshToken: tokens.refreshToken,\n expiresAt: Date.now() + tokens.expiresIn * 1000,\n expiresIn: tokens.expiresIn,\n accountId: subject,\n clientId,\n idToken: tokens.idToken,\n scopes: tokens.scopes,\n baseUrl: CHATGPT_PLAN_BASE_URL,\n };\n } finally {\n listener.close();\n }\n}\n\n/**\n * Model ids the signed-in ChatGPT account may use on its plan, from the same\n * `/v1/models` catalog OpenAI tells apps to build their picker from. Hidden\n * entries (`visibility` other than \"list\") are left out.\n */\nexport async function fetchChatGPTPlanModels(\n accessToken: string,\n fetchFn: typeof fetch = fetch,\n): Promise<string[]> {\n const response = await fetchFn(`${CHATGPT_PLAN_BASE_URL}/models`, {\n headers: { Authorization: `Bearer ${accessToken}` },\n signal: oauthRequestSignal(),\n });\n if (!response.ok) throw new Error(`ChatGPT model list failed (${response.status})`);\n const data = (await response.json()) as {\n models?: Array<{ slug?: unknown; visibility?: unknown }>;\n };\n return (data.models ?? [])\n .filter((m) => typeof m.slug === \"string\" && (m.visibility ?? \"list\") === \"list\")\n .map((m) => m.slug as string);\n}\n\n// ── Callback ───────────────────────────────────────────────\n\ninterface CallbackListener {\n port: number;\n /** Resolves with the callback query once a request with our state arrives. */\n result: Promise<URLSearchParams>;\n close: () => void;\n}\n\n/**\n * Bind the loopback callback. The first registration uses the documented port;\n * a returning sign-in may take any free one (only the port may vary). When no\n * port binds, the paste route still works with the preferred URI.\n */\nasync function startCallbackListener(\n expectedState: string,\n anyPort: boolean,\n): Promise<CallbackListener> {\n let settle: { resolve: (p: URLSearchParams) => void; reject: (e: Error) => void } | undefined;\n const result = new Promise<URLSearchParams>((resolve, reject) => {\n settle = { resolve, reject };\n });\n result.catch(() => {});\n const server = http.createServer((req, res) => {\n const url = new URL(req.url || \"\", \"http://127.0.0.1\");\n if (url.pathname !== CALLBACK_PATH) {\n res.statusCode = 404;\n res.end(\"Not found\");\n return;\n }\n if (url.searchParams.get(\"state\") !== expectedState) {\n res.statusCode = 400;\n res.end(\"State mismatch\");\n return;\n }\n res.writeHead(200, { \"Content-Type\": \"text/html\" });\n res.end(\n \"<html><body><h1>You're signed in to GG Coder.</h1><p>You can close this tab.</p></body></html>\",\n );\n settle?.resolve(url.searchParams);\n server.close();\n });\n\n const listen = (port: number) =>\n new Promise<number>((resolve, reject) => {\n const onError = (err: Error) => reject(err);\n server.once(\"error\", onError);\n server.listen(port, \"127.0.0.1\", () => {\n server.off(\"error\", onError);\n resolve((server.address() as AddressInfo).port);\n });\n });\n\n let port: number | undefined;\n for (const candidate of anyPort ? [PREFERRED_PORT, 0] : [PREFERRED_PORT]) {\n try {\n port = await listen(candidate);\n break;\n } catch {\n // Port in use: try the next one, or fall back to paste only.\n }\n }\n if (port === undefined) {\n settle?.reject(new Error(\"Could not start the local sign-in listener.\"));\n return { port: PREFERRED_PORT, result, close: () => {} };\n }\n\n // Backstop so this route always terminates.\n const timeout = setTimeout(() => server.close(), CALLBACK_TIMEOUT_MS);\n timeout.unref();\n server.on(\"close\", () => {\n clearTimeout(timeout);\n settle?.reject(new Error(\"Sign-in listener closed without a callback.\"));\n });\n return { port, result, close: () => server.close() };\n}\n\n/**\n * Wait for the browser callback and the paste route at once, taking whichever\n * arrives first. Over SSH the listener binds but never hears from the browser,\n * so the user pastes the callback URL instead (#20).\n */\nasync function acquireCallback(\n authUrl: string,\n expectedState: string,\n listener: CallbackListener,\n callbacks: OAuthLoginCallbacks,\n): Promise<URLSearchParams> {\n callbacks.onOpenUrl(authUrl);\n callbacks.onStatus(\"Waiting for ChatGPT sign-in in your browser...\");\n const done = new AbortController();\n try {\n return await new Promise<URLSearchParams>((resolve, reject) => {\n let listenerFailed = false;\n let pasteError: unknown;\n const failIfExhausted = (): void => {\n if (!listenerFailed || pasteError === undefined) return;\n reject(\n pasteError instanceof Error ? pasteError : new Error(\"Could not complete the sign-in.\"),\n );\n };\n listener.result.then(resolve, () => {\n listenerFailed = true;\n failIfExhausted();\n });\n promptForCallback(expectedState, callbacks, done.signal).then(resolve, (err: unknown) => {\n if (err instanceof FatalLoginError) {\n reject(err);\n return;\n }\n pasteError = err;\n failIfExhausted();\n });\n });\n } finally {\n done.abort();\n listener.close();\n }\n}\n\n/** A failure that should end the whole login rather than defer to the other route. */\nclass FatalLoginError extends Error {}\n\n/** The callback query from a pasted URL or query string, or undefined for anything else. */\nexport function parseCallbackInput(input: string): URLSearchParams | undefined {\n const value = input.trim();\n if (!value) return undefined;\n try {\n return new URL(value).searchParams;\n } catch {\n // Not a full URL.\n }\n if (/[?&]?(code|error)=/.test(value)) {\n return new URLSearchParams(value.replace(/^.*\\?/, \"\"));\n }\n return undefined;\n}\n\nasync function promptForCallback(\n expectedState: string,\n callbacks: OAuthLoginCallbacks,\n signal: AbortSignal,\n): Promise<URLSearchParams> {\n let message =\n \"If the browser is on another machine (SSH/headless), paste the full callback URL here:\";\n // Re-ask on a fumbled paste, but bounded: a caller that can't collect input\n // would otherwise spin here and this route could never fail.\n for (let attempt = 0; attempt < MAX_PASTE_ATTEMPTS; attempt++) {\n if (signal.aborted) throw new Error(\"Callback already received.\");\n const raw = await callbacks.onPromptCode(message, signal);\n if (signal.aborted) throw new Error(\"Callback already received.\");\n const params = parseCallbackInput(raw);\n if (params && (params.get(\"code\") || params.get(\"error\"))) {\n if (params.get(\"state\") !== expectedState) {\n throw new FatalLoginError(\"Sign-in state mismatch — start the sign-in again.\");\n }\n return params;\n }\n message = \"That wasn't a callback URL. Paste the full URL from the browser's address bar:\";\n }\n throw new Error(\"No callback URL found in input.\");\n}\n\n// ── Tokens ─────────────────────────────────────────────────\n\ninterface TokenSet {\n accessToken: string;\n refreshToken: string;\n idToken: string;\n expiresIn: number;\n scopes: string[];\n}\n\ninterface TokenResponse {\n access_token?: string;\n refresh_token?: string;\n id_token?: string;\n expires_in?: number;\n scope?: string;\n}\n\nasync function postForm(url: string, form: Record<string, string>): Promise<Response> {\n return fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(form),\n signal: oauthRequestSignal(),\n });\n}\n\nasync function exchangeCode(input: {\n code: string;\n verifier: string;\n clientId: string;\n redirectUri: string;\n}): Promise<TokenSet> {\n const response = await postForm(TOKEN_URL, {\n grant_type: \"authorization_code\",\n client_id: input.clientId,\n code: input.code,\n code_verifier: input.verifier,\n redirect_uri: input.redirectUri,\n resource: RESOURCE,\n });\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new Error(`ChatGPT token exchange failed (${response.status}): ${text.slice(0, 300)}`);\n }\n const data = (await response.json()) as TokenResponse;\n if (!data.access_token || !data.refresh_token || !data.id_token) {\n throw new Error(\"ChatGPT token response is missing tokens.\");\n }\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n idToken: data.id_token,\n expiresIn: data.expires_in ?? 3600,\n scopes: (data.scope ?? \"\").split(/\\s+/).filter(Boolean),\n };\n}\n\n/**\n * Renew the plan token with the issued client ID. The response replaces the\n * access token, its expiry, the granted scopes and the rotating refresh token.\n */\nexport async function refreshChatGPTToken(\n creds: Pick<OAuthCredentials, \"refreshToken\" | \"clientId\">,\n): Promise<OAuthCredentials> {\n if (!creds.clientId) {\n throw new Error(\"ChatGPT token refresh failed (401): no issued client ID saved\");\n }\n const response = await postForm(TOKEN_URL, {\n grant_type: \"refresh_token\",\n client_id: creds.clientId,\n refresh_token: creds.refreshToken,\n resource: RESOURCE,\n });\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new Error(`ChatGPT token refresh failed (${response.status}): ${text.slice(0, 300)}`);\n }\n const data = (await response.json()) as TokenResponse;\n if (!data.access_token || !data.refresh_token) {\n throw new Error(\"ChatGPT token refresh returned no tokens.\");\n }\n const expiresIn = data.expires_in ?? 3600;\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: Date.now() + expiresIn * 1000,\n expiresIn,\n ...(data.id_token ? { idToken: data.id_token } : {}),\n ...(data.scope ? { scopes: data.scope.split(/\\s+/).filter(Boolean) } : {}),\n };\n}\n\n/**\n * End the renewable session on sign-out. True when OpenAI confirmed it; callers\n * clear local tokens either way and say so when it wasn't confirmed.\n */\nexport async function revokeChatGPTSignIn(\n creds: Pick<OAuthCredentials, \"refreshToken\" | \"clientId\">,\n options: { attempts?: number; backoffMs?: number } = {},\n): Promise<boolean> {\n if (!creds.clientId || !creds.refreshToken) return false;\n const attempts = options.attempts ?? 3;\n for (let attempt = 0; attempt < attempts; attempt++) {\n try {\n const response = await postForm(REVOKE_URL, {\n token: creds.refreshToken,\n token_type_hint: \"refresh_token\",\n client_id: creds.clientId,\n });\n if (response.ok) return true;\n if (response.status < 500) return false;\n } catch {\n // Network failure: retry while the token is still available.\n }\n if (attempt < attempts - 1) {\n await new Promise((r) => setTimeout(r, (options.backoffMs ?? 500) * 2 ** attempt));\n }\n }\n return false;\n}\n\n// ── ID token ───────────────────────────────────────────────\n\nfunction decodeSegment(segment: string): Record<string, unknown> {\n return JSON.parse(Buffer.from(segment, \"base64url\").toString(\"utf-8\")) as Record<string, unknown>;\n}\n\n/** Check the ID token's RS256 signature against OpenAI's JWKS, then iss, aud, exp and nonce. */\nexport async function verifyIdToken(\n idToken: string,\n expected: { clientId: string; nonce: string; now?: number },\n): Promise<Record<string, unknown>> {\n const [headerSeg, payloadSeg, signatureSeg] = idToken.split(\".\");\n if (!headerSeg || !payloadSeg || !signatureSeg) throw new Error(\"Malformed ChatGPT ID token.\");\n const header = decodeSegment(headerSeg);\n if (header.alg !== \"RS256\") throw new Error(\"Unexpected ChatGPT ID token algorithm.\");\n\n const response = await fetch(JWKS_URL, { signal: oauthRequestSignal() });\n if (!response.ok) throw new Error(`Could not load OpenAI signing keys (${response.status}).`);\n const { keys = [] } = (await response.json()) as {\n keys?: Array<crypto.webcrypto.JsonWebKey & { kid?: string }>;\n };\n const jwk = keys.find((k) => k.kid === header.kid);\n if (!jwk) throw new Error(\"ChatGPT ID token is signed with an unknown key.\");\n const valid = crypto.verify(\n \"RSA-SHA256\",\n Buffer.from(`${headerSeg}.${payloadSeg}`),\n crypto.createPublicKey({ key: jwk, format: \"jwk\" }),\n Buffer.from(signatureSeg, \"base64url\"),\n );\n if (!valid) throw new Error(\"ChatGPT ID token signature is invalid.\");\n\n const claims = decodeSegment(payloadSeg);\n const now = Math.floor((expected.now ?? Date.now()) / 1000);\n const aud = claims.aud;\n const audiences = Array.isArray(aud) ? aud : [aud];\n if (claims.iss !== ISSUER) throw new Error(\"ChatGPT ID token has the wrong issuer.\");\n if (!audiences.includes(expected.clientId)) {\n throw new Error(\"ChatGPT ID token was issued to another app.\");\n }\n if (typeof claims.exp !== \"number\" || claims.exp + ID_TOKEN_CLOCK_SKEW_S < now) {\n throw new Error(\"ChatGPT ID token has expired.\");\n }\n if (claims.nonce !== expected.nonce) throw new Error(\"ChatGPT ID token nonce mismatch.\");\n return claims;\n}\n","import fs from \"node:fs/promises\";\nimport { setTimeout } from \"node:timers/promises\";\n\nconst STALE_TIMEOUT_MS = 10_000; // Lock is stale after 10s\nconst RETRY_INTERVAL_MS = 50; // Retry every 50ms\nconst MAX_WAIT_MS = 5_000; // Give up after 5s\n// Windows refuses `wx` with one of these, not EEXIST, while a released lock is pending delete.\nconst PENDING_DELETE_CODES = new Set([\"EACCES\", \"EBUSY\", \"EPERM\"]);\n\ninterface LockInfo {\n pid: number;\n timestamp: number;\n}\n\n/**\n * Simple file-based lock with PID tracking and stale detection.\n * Uses atomic file creation (wx flag) to prevent races.\n */\nexport async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {\n const lockPath = filePath + \".lock\";\n await acquireLock(lockPath);\n try {\n return await fn();\n } finally {\n await releaseLock(lockPath);\n }\n}\n\nasync function acquireLock(lockPath: string): Promise<void> {\n const startTime = Date.now();\n let refusedWithoutLock = 0;\n\n while (true) {\n try {\n // O_EXCL: fail if file exists — atomic lock acquisition\n const info: LockInfo = { pid: process.pid, timestamp: Date.now() };\n await fs.writeFile(lockPath, JSON.stringify(info), { flag: \"wx\" });\n return; // Lock acquired\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code !== \"EEXIST\") {\n if (process.platform !== \"win32\" || !code || !PENDING_DELETE_CODES.has(code)) throw err;\n // A refusal that persists without a lock file, or past the wait budget, is a real fault.\n const lockPresent = await lockFileExists(lockPath);\n if (!lockPresent && ++refusedWithoutLock > 3) throw err;\n if (Date.now() - startTime > MAX_WAIT_MS) throw err;\n await setTimeout(RETRY_INTERVAL_MS);\n continue;\n }\n\n // Lock file exists — check if it's stale\n try {\n const content = await fs.readFile(lockPath, \"utf-8\");\n const info = JSON.parse(content) as LockInfo;\n\n // Check if the holding process is still alive\n const isProcessAlive = isAlive(info.pid);\n const isStale = Date.now() - info.timestamp > STALE_TIMEOUT_MS;\n\n if (!isProcessAlive || isStale) {\n // Stale lock — remove and retry\n await fs.unlink(lockPath).catch(() => {});\n continue;\n }\n } catch {\n // Unreadable: released meanwhile, just created and not yet written (a\n // live holder — deleting it let two writers in), or truly corrupt.\n const stat = await fs.stat(lockPath).catch(() => null);\n if (!stat) continue;\n if (Date.now() - stat.mtimeMs > STALE_TIMEOUT_MS) {\n await fs.unlink(lockPath).catch(() => {});\n continue;\n }\n }\n\n // Lock is held by a live process — wait and retry\n if (Date.now() - startTime > MAX_WAIT_MS) {\n // Timeout — force break the lock (better than deadlocking)\n await fs.unlink(lockPath).catch(() => {});\n continue;\n }\n\n await setTimeout(RETRY_INTERVAL_MS);\n }\n }\n}\n\nasync function lockFileExists(lockPath: string): Promise<boolean> {\n try {\n await fs.stat(lockPath);\n return true;\n } catch (err) {\n // A refused stat means the file is still there, mid-delete.\n return (err as NodeJS.ErrnoException).code !== \"ENOENT\";\n }\n}\n\nasync function releaseLock(lockPath: string): Promise<void> {\n await fs.unlink(lockPath).catch(() => {});\n}\n\nfunction isAlive(pid: number): boolean {\n try {\n process.kill(pid, 0); // Signal 0 = check existence without killing\n return true;\n } catch (err) {\n // EPERM = process exists but belongs to a different user — still alive\n if ((err as NodeJS.ErrnoException).code === \"EPERM\") return true;\n // ESRCH = no such process — it's dead\n return false;\n }\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\nimport { environmentSecrets, redactText, redactValue } from \"@kenkaiiii/gg-ai\";\n\nexport type LogLevel = \"INFO\" | \"ERROR\" | \"WARN\" | \"DEBUG\";\n\n// Cross-session log retention: the log is appended across launches so you can\n// grep back through prior sessions. Rotated at MAX_BYTES to keep it bounded; we\n// keep one generation (debug.log.1) — enough to survive one rotation's worth of\n// scrollback while bounding disk usage.\nconst MAX_BYTES = 10 * 1024 * 1024; // 10 MB\n\n/**\n * Rotate when fewer than this many bytes remain, instead of only at/over the cap.\n *\n * A process that hits the cap mid-run stops writing at whatever size it reached,\n * which can be a hair UNDER MAX_BYTES. Rotating only at `size >= MAX_BYTES` then\n * wedges the log permanently: every later launch reopens the same near-full file,\n * has no room for a single line, and disables its own logging immediately — so the\n * file can never grow enough to qualify for rotation. That silently cost the app\n * sidecar ~2 weeks of diagnostics (the file sat 4 bytes short of the cap, gaining\n * one newline per launch). Requiring real headroom guarantees forward progress.\n */\nconst MIN_HEADROOM_BYTES = 64 * 1024; // 64 KB\n\nlet fd: number | null = null;\nlet logPath: string | null = null;\n/** Current size of the open log file, including earlier launches' lines. Drives rotation. */\nlet fileBytes = 0;\n/**\n * Bytes THIS process has written. Drives the hard per-process cap. Kept apart\n * from `fileBytes` because the file is shared across launches: a long-lived\n * daemon that started next to a nearly full file used to inherit that size as\n * its own budget, hit the cap within minutes, and log nothing for days.\n */\nlet processBytes = 0;\n/** Set when a mid-run rotation could not shrink the file; stop retrying it per line. */\nlet rotationFailed = false;\nlet capped = false;\nlet sessionId = \"\";\nlet appName = \"app\";\nlet cleanups: (() => void)[] = [];\nlet exactSecrets: string[] = [];\n\nfunction rotateIfNeeded(filePath: string, force = false): void {\n try {\n const st = fs.statSync(filePath);\n if (!force && st.size <= MAX_BYTES - MIN_HEADROOM_BYTES) return;\n const rotated = `${filePath}.1`;\n // Replace prior rotation (fs.renameSync overwrites on POSIX; on Windows it\n // fails if dest exists, so unlink first defensively).\n try {\n fs.unlinkSync(rotated);\n } catch {\n // No prior rotation\n }\n fs.renameSync(filePath, rotated);\n } catch {\n // Log file doesn't exist yet or stat failed — nothing to rotate\n }\n}\n\n/**\n * The shared file filled up while this process still has write budget: move it\n * to `.1` and continue in a fresh file. Returns false if logging had to stop.\n */\nfunction rotateOpenLog(): boolean {\n if (fd === null || logPath === null) return false;\n const sizeBefore = fileBytes;\n try {\n fs.closeSync(fd);\n } catch {\n // Closing a descriptor we are about to replace; nothing to recover.\n }\n fd = null;\n rotateIfNeeded(logPath, true);\n try {\n fd = fs.openSync(logPath, \"a\");\n fileBytes = fs.fstatSync(fd).size;\n } catch {\n if (fd !== null) {\n try {\n fs.closeSync(fd);\n } catch {\n // Ignore cleanup failure\n }\n }\n fd = null;\n capped = true;\n return false;\n }\n // Rename can fail (e.g. Windows sharing violations); the per-process cap still\n // bounds writes, so keep logging into the same file instead of retrying a\n // rename on every line.\n if (fileBytes >= sizeBefore) rotationFailed = true;\n return true;\n}\n\n/**\n * Open the debug log in append mode, tagging this process with a session id and\n * remembering `name` for the shutdown line. Idempotent — re-calling while open\n * is a no-op. Returns true only when it *newly* opened the file (so callers can\n * write a one-time startup line); returns false if already open or if the file\n * could not be opened.\n */\nexport function openLog(filePath: string, name: string): boolean {\n if (fd !== null || capped) return false;\n appName = name;\n exactSecrets = environmentSecrets(process.env);\n try {\n fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });\n } catch {\n // Directory may already exist or be uncreatable — fall through to open\n }\n rotateIfNeeded(filePath);\n try {\n fd = fs.openSync(filePath, \"a\");\n } catch {\n // Can't open log file — silently disable logging\n fd = null;\n fileBytes = 0;\n return false;\n }\n try {\n fileBytes = fs.fstatSync(fd).size;\n } catch {\n try {\n fs.closeSync(fd);\n } catch {\n // Ignore cleanup failure\n }\n fd = null;\n fileBytes = 0;\n return false;\n }\n logPath = filePath;\n processBytes = 0;\n rotationFailed = false;\n sessionId = randomBytes(4).toString(\"hex\");\n // Visible separator between sessions when back-reading the log.\n try {\n if (fileBytes < MAX_BYTES) {\n const written = fs.writeSync(fd, \"\\n\");\n fileBytes += written;\n processBytes += written;\n }\n } catch {\n // Write failed — proceed without the separator\n }\n return true;\n}\n\n/** Session identifier included on every log line as `sid=<id>`. */\nexport function getSessionId(): string {\n return sessionId;\n}\n\n/** True if the logger has an open file descriptor. */\nexport function isLoggerOpen(): boolean {\n return fd !== null;\n}\n\n/** Write a timestamped log line. No-op if the logger is not open. */\nexport function log(\n level: LogLevel,\n category: string,\n message: string,\n data?: Record<string, unknown>,\n): void {\n if (fd === null) return;\n const ts = new Date().toISOString();\n const safeMessage = redactText(message, { secrets: exactSecrets });\n let line = `[${ts}] [sid=${sessionId}] [${level}] [${category}] ${safeMessage}`;\n if (data) {\n const safeData = redactValue(data, { secrets: exactSecrets });\n const pairs = Object.entries(safeData)\n .map(([k, v]) => {\n if (typeof v === \"string\") return `${k}=${v}`;\n if (typeof v === \"bigint\") return `${k}=${String(v)}`;\n return `${k}=${JSON.stringify(v)}`;\n })\n .join(\" \");\n if (pairs) line += ` ${pairs}`;\n }\n line += \"\\n\";\n\n const lineBytes = Buffer.byteLength(line);\n if (processBytes + lineBytes > MAX_BYTES) {\n // A noisy production path must not turn one long-lived process into an\n // unbounded SSD writer. Stop file logging for this process once IT has\n // written MAX_BYTES; later launches get a fresh budget.\n const capLine = `[${ts}] [sid=${sessionId}] [WARN] [logger] Log cap reached; file logging disabled until restart\\n`;\n try {\n if (fileBytes + Buffer.byteLength(capLine) <= MAX_BYTES) {\n fileBytes += fs.writeSync(fd, capLine);\n }\n fs.closeSync(fd);\n } catch {\n // Write/close failure still disables logging below.\n }\n fd = null;\n capped = true;\n return;\n }\n\n // The shared file is full but this process still has budget: rotate and\n // keep logging rather than going dark for the rest of a long-lived run.\n if (fileBytes + lineBytes > MAX_BYTES && !rotationFailed && !rotateOpenLog()) return;\n if (fd === null) return;\n\n try {\n const written = fs.writeSync(fd, line);\n fileBytes += written;\n processBytes += written;\n } catch {\n // Write failed — don't crash\n }\n}\n\n/**\n * Register a cleanup callback (e.g. an EventBus unsubscriber) to run when the\n * logger closes. Lets app-side bridges hook into the shared lifecycle without\n * the core needing to know about app types.\n */\nexport function registerLogCleanup(fn: () => void): void {\n cleanups.push(fn);\n}\n\n/**\n * Write a shutdown line (unless suppressed), close the file descriptor, and run\n * any registered cleanups.\n */\nexport function closeLogger(opts?: { shutdownLine?: boolean }): void {\n if (fd !== null) {\n if (opts?.shutdownLine !== false) log(\"INFO\", \"shutdown\", `${appName} shutting down`);\n try {\n if (fd !== null) fs.closeSync(fd);\n } catch {\n // Ignore close errors\n }\n }\n fd = null;\n logPath = null;\n fileBytes = 0;\n processBytes = 0;\n rotationFailed = false;\n capped = false;\n exactSecrets = [];\n for (const unsub of cleanups) unsub();\n cleanups = [];\n}\n","/**\n * Providers temporarily hidden from every user-facing surface: the login lists\n * (CLI + app), the model pickers, and startup provider resolution. Their models,\n * transports and stored credentials stay in place, so un-hiding is a one-line\n * change here (plus `HIDDEN_PROVIDERS` in gg-app/src-tauri/src/auth.rs).\n *\n * None are hidden today: Gemini came back with an AI Studio API key.\n *\n * Hiding works through `AuthStorage.hasProviderAuth()`, which reports a hidden\n * provider as not connected: every picker and the startup resolver filter on\n * it, so a user with old Gemini credentials falls back to another provider.\n */\nexport const HIDDEN_PROVIDERS: readonly string[] = [];\n\n/** True when `provider` is hidden from login lists, pickers and startup. */\nexport function isHiddenProvider(provider: string): boolean {\n return HIDDEN_PROVIDERS.includes(provider);\n}\n"],"mappings":"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","names":["import_promises","import_node_fs","import_node_crypto","import_node_fs","import_promises","import_node_path","import_node_crypto","import_promises","import_node_path","import_promises","import_node_fs","import_node_path","import_node_crypto","import_gg_ai","import_gg_ai"]}
|