jeo-code 0.7.27 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.7.29] - 2026-07-02
10
+ _gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder._
11
+
12
+ ### Fixed
13
+ - **Safety refusals now keep retrying instead of surfacing a terminal provider error.** jeo already had the stronger context-mutating recovery ladder (plain resend → tool-result/thinking-replay reset → project-context guidance strip), but once those rungs were spent it still returned `Anthropic declined to answer (safety refusal — no content returned) even after automatic retries with a context reset`. gjc's session retry path treats that class as non-terminal and keeps the session alive. jeo now adds the same final rung: capped exponential backoff resends after all context mutations are spent, with an `Esc`/abort-cancellable wait and the turn wall-clock budget still bounding the whole run.
14
+ - **The stale fallback refusal copy no longer claims automatic retries were exhausted.** The friendly provider error text is now reserved for non-engine call sites and points users at `/retry`, `/compact`, `/new`, or `/model` without implying the agent already gave up.
15
+
16
+ ### Verified
17
+ - `bun run typecheck` clean; full suite green — 2146 pass / 0 fail across 244 files. Targeted coverage updated in `test/refusal-recovery.test.ts` for the new post-ladder backoff recovery and for Esc/Ctrl+C aborting a refusal backoff as `Cancelled.` instead of surfacing the refusal error.
18
+
19
+ ## [0.7.28] - 2026-07-02
20
+ _gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests)._
21
+
22
+ ### Fixed
23
+ - **Generic 429 rate limits with a long server `Retry-After` were treated as FATAL — gajae 0.7.10 (#1370) keeps them retryable, and now jeo does too.** The old behavior failed fast on any 429 whose server-directed delay exceeded a hard-coded 5-minute budget, and silently compressed every honored `Retry-After` to 30s (retrying into a still-closed window). gjc's contract (verified against its `agent-session-retry-cap` tests): a transient rate limit honors the provider's wait IN FULL — even a ~3.1h `retry-after-ms=11180000` — while usage/quota/model-limit errors keep failing fast via `isUsageLimitError` → `defaultRetryable`. `withRetry` (`src/util/retry.ts`) now honors server-directed delays uncapped (the 30s `RATE_LIMIT_FLOOR_CAP_MS` only caps the SYNTHETIC escalating floor for server-silent 429s), and `resolveRetryOptions` no longer injects a default `rateLimitMaxServerDelayMs` — it remains available as explicit opt-in config (`retry.rateLimitMaxServerDelayMs`, added to the Config type + zod schema).
24
+ - **A long honored rate-limit wait is now user-cancellable.** `RetryOptions.signal` (new) aborts an in-progress backoff sleep: `resolveCall` threads the caller's ORIGINAL abort signal (Ctrl-C / turn abort) into the retry layer — distinct from the per-attempt fetch timeout, so one attempt's timeout can never cut short the next attempt's backoff, while Esc/Ctrl-C interrupts a multi-hour wait immediately instead of after the sleep elapses.
25
+ - **`isUsageLimitError` missed gajae's usage-exhaustion phrasings.** Added `model.?limit` / `message.?limit` / `limit for this model` / `out_of_credits` / Anthropic's account-level `request would exceed your account's rate limit` and the qualified `Resource has been exhausted (… quota/limit)` form (bare gRPC `resource exhausted` stays transient — deliberate jeo choice for Gemini windows that clear quickly). Without these, an out-of-credits 429 was retried on a pointless backoff ladder instead of failing fast to the model/credential-switch hint.
26
+ - **Anthropic 429s now carry their rate-limit response headers in the error detail** (gjc `getSafeAnthropicHeaderEvidence` parity): `retry-after`, `retry-after-ms`, and every `anthropic-ratelimit-*` header — including the decisive `anthropic-ratelimit-unified-overage-disabled-reason=out_of_credits`, which often arrives ONLY as a header. Without it an out-of-credits 429 read like a generic per-minute rate limit.
27
+
28
+ ### Added
29
+ - **Kimi Code (Moonshot coding subscription) OAuth — completed end-to-end.** The device-code flow (`src/auth/flows/kimi.ts`, RFC 8628: `auth.kimi.com` device_authorization → poll with `authorization_pending`/`slow_down`/`expired_token`/`access_denied` semantics, X-Msh-* device headers, persisted `kimi-device-id`) now has: catalog entries for the Kimi Code models (`kimi-for-coding`, `kimi-k2.7-code`, `kimi/kimi-k2.5`, `kimi/kimi-k2-turbo-preview`, `kimi/kimi-k2` — 262k context, provider-qualified where canonicals collide with tencent's), a `KIMI_CODE_MODELS` export, OAuth gating (`oauthServesModel`: kimi OAuth serves ONLY the Kimi Code set; Moonshot API ids fall back to `KIMI_API_KEY` via `effectiveCredentialForProvider`, with an actionable error when no key exists), and an OAuth-only discovery fallback that lists exactly the Kimi Code models (`catalogOr`) instead of faking moonshot ids. The `kimiAdapter` dispatches on credential kind — OAuth → Anthropic-compatible `api.kimi.com/coding/v1/messages` with plain Bearer + X-Msh headers (no Claude-Code cloaking; gated by `shouldUseClaudeCodeOAuthShape`'s baseUrl check), api_key → the unchanged OpenAI-compatible `api.moonshot.ai/v1`.
30
+ - **Codex/Responses provider-side prompt caching (gjc parity — closes the `ponytail` note in `openai-responses.ts`).** New `CallOptions.sessionKey` threads a stable per-conversation key from `launch.ts` (the session id), `team.ts`, and `task-tool.ts` (per-run UUIDs) through the engine loop into every model call. `codexResponsesRequest` stamps it as `prompt_cache_key` in the body (both the OAuth Codex backend and the public `api.openai.com/v1/responses`) plus `session_id`/`conversation_id`/`x-client-request-id` correlation headers on the Codex backend (`normalizePromptCacheKey`: ≤64 chars verbatim, longer keys hashed `pc_*` — gjc `normalizeOpenAIResponsesPromptCacheKey` parity). An agent loop replays nearly-identical history each step, so cache hits cut both latency and billed input tokens.
31
+
32
+ ### Verified
33
+ - `bun run typecheck` clean; full suite green — 2142 pass / 0 fail across 244 files (before the codex/kimi test additions), then targeted suites re-run green: `retry` (26), `rate-limit-handling` (5), `provider-errors`, `round-b`, `kimi-provider` (7 new), `kimi-oauth` (7 new), `codex-responses` (+3 new). The corrected rate-limit contract is pinned by tests that mirror gjc's own fixtures (3.1h retry-after honored + recovered; model-limit fails fast; mid-wait abort interrupts the sleep).
34
+
9
35
  ## [0.7.27] - 2026-07-02
10
36
  _Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks._
11
37
 
package/README.ja.md CHANGED
@@ -205,11 +205,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
205
205
  ## 変更履歴 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
209
+ - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
208
210
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
209
211
  - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
210
212
  - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
211
- - **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
212
- - **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -205,11 +205,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
205
205
  ## 변경 이력 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
209
+ - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
208
210
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
209
211
  - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
210
212
  - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
211
- - **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
212
- - **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -232,11 +232,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
232
232
  ## Changelog
233
233
 
234
234
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
235
+ - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
236
+ - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
235
237
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
236
238
  - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
237
239
  - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
238
- - **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
239
- - **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
240
240
 
241
241
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
242
242
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -205,11 +205,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
205
205
  ## 更新日志 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
209
+ - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
208
210
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
209
211
  - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
210
212
  - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
211
- - **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
212
- - **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.27",
3
+ "version": "0.7.29",
4
4
 
5
5
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
6
6
  "type": "module",
@@ -105,6 +105,7 @@ export const ConfigSchema = z
105
105
  maxDelayMs: z.number().int().min(0).optional(),
106
106
  rateLimitRetries: z.number().int().min(0).optional(),
107
107
  rateLimitMinDelayMs: z.number().int().min(0).optional(),
108
+ rateLimitMaxServerDelayMs: z.number().int().min(0).optional(),
108
109
  })
109
110
  .optional(),
110
111
  /**
@@ -15,7 +15,7 @@ import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
15
15
  import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
16
16
  import { webSearchTool, setWebSearchActiveModel } from "./web-search";
17
17
  import { executeComputerAction } from "../commands/computer";
18
- import { isRateLimitError } from "../util/retry";
18
+ import { isRateLimitError, waitAbortable } from "../util/retry";
19
19
  import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
20
20
  import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
21
21
  import { truncateToolOutput, formatToolResultBody } from "./tool-output";
@@ -40,6 +40,7 @@ async function invokeCallLlm(history: Message[], options: {
40
40
  onReasoningStart?: () => void;
41
41
  onReasoningArtifact?: (artifact: import("../ai/types").ReasoningArtifact) => void;
42
42
  tools?: import("../ai/types").NativeToolSchema[];
43
+ sessionKey?: string;
43
44
  }): Promise<string> {
44
45
  const mod = await import("./loop");
45
46
  return mod.callLlm(history, options);
@@ -265,6 +266,11 @@ export interface AgentLoopOptions {
265
266
  * so an additional query typed while the turn runs steers the live turn instead of
266
267
  * waiting for the next prompt. Return [] when nothing is pending. */
267
268
  steer?: () => string[];
269
+ /** Stable per-conversation key (the session id): threaded to every model call for
270
+ * provider-side prompt caching (gjc parity — Codex prompt_cache_key/session_id).
271
+ * An agent loop replays nearly-identical history each step, so cache hits here cut
272
+ * both latency and billed input tokens. */
273
+ sessionKey?: string;
268
274
  }
269
275
 
270
276
  export interface AgentLoopResult {
@@ -388,7 +394,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
388
394
  "with what you have already found this turn, and state explicitly anything that is still uncertain.",
389
395
  },
390
396
  ],
391
- { jsonMode: false, model: opts.model, maxTokens: opts.maxTokens, signal: opts.signal },
397
+ { jsonMode: false, model: opts.model, maxTokens: opts.maxTokens, signal: opts.signal, sessionKey: opts.sessionKey },
392
398
  );
393
399
  const consolidated = wrapUp.trim();
394
400
  if (consolidated) {
@@ -437,11 +443,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
437
443
  // Round-6 #4: ONE reactive recovery when the PROVIDER reports context overflow
438
444
  // (authoritative where the local estimate drifted — images, tokenizer mismatch).
439
445
  let contextOverflowRetryUsed = false;
440
- // Refusal recovery budget: a safety refusal (HTTP 200, no content) on routine
441
- // coding work is usually a transient false-positive. Retry the SAME step once
442
- // as-is, then once more with an explicit re-grounding note; only a third
443
- // refusal in the turn surfaces the (friendly) error. Bounded per turn so a
444
- // genuinely refused request can never burn billed calls in a loop.
446
+ // Refusal recovery: a safety refusal (HTTP 200, no content) on routine coding
447
+ // work is usually a transient false-positive. The first rungs MUTATE the context
448
+ // (plain resend → context reset + re-grounding note → guidance strip); after the
449
+ // ladder is exhausted the loop keeps resending with capped exponential backoff
450
+ // instead of surfacing a terminal error (gjc parity: a refusal is retried until
451
+ // it clears or the user cancels — Esc aborts the backoff wait via opts.signal,
452
+ // and the turn wall-clock budget still bounds the whole turn).
445
453
  const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
446
454
  let refusalRetries = 0;
447
455
  let lastSig = "";
@@ -557,6 +565,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
557
565
  maxTokens: opts.maxTokens,
558
566
  reasoningEffort: opts.reasoningEffort,
559
567
  signal: opts.signal,
568
+ sessionKey: opts.sessionKey,
560
569
  onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
561
570
  onToken,
562
571
  onReasoning,
@@ -603,7 +612,15 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
603
612
  // routine). Strip that block for the rest of the turn and retry once;
604
613
  // core instructions stay intact. Field case: `$gjc init` inside a
605
614
  // repo whose guidance files refuse-trip the OAuth classifier.
606
- if (isRefusalError(err) && refusalRetries < MAX_REFUSAL_RETRIES) {
615
+ // 4) unbounded backoff — the classifier verdict is TIME-sensitive (rolling
616
+ // classifier state, per-request user-id rotation), so once every
617
+ // context mutation is spent, keep resending with capped exponential
618
+ // backoff instead of surfacing a terminal error (gjc parity: gjc's
619
+ // session auto-retry classifies a refusal as retryable and never ends
620
+ // the turn on it). Esc/cancel aborts the wait via opts.signal and the
621
+ // loop-top check turns it into "Cancelled."; JEO_TURN_MAX_MS still
622
+ // bounds the whole turn.
623
+ if (isRefusalError(err)) {
607
624
  refusalRetries++;
608
625
  if (refusalRetries === 1) {
609
626
  ev.onNotice?.("provider refused the last call (no content) — retrying the same step");
@@ -635,15 +652,24 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
635
652
  step++;
636
653
  continue;
637
654
  }
638
- const sys = history[0];
639
- if (sys?.role === "system" && sys.content.includes("<project_context>")) {
640
- const stripped = sys.content.replace(/\n*<project_context>[\s\S]*?<\/project_context>/, "").trimEnd();
641
- history[0] = { ...sys, content: stripped }; // replace, never mutate (identity caches)
642
- ev.onNotice?.("provider refused a third time — removed project-context guidance from the system prompt and retrying once more");
643
- continue; // same step, reduced system prompt
655
+ if (refusalRetries === 3) {
656
+ const sys = history[0];
657
+ if (sys?.role === "system" && sys.content.includes("<project_context>")) {
658
+ const stripped = sys.content.replace(/\n*<project_context>[\s\S]*?<\/project_context>/, "").trimEnd();
659
+ history[0] = { ...sys, content: stripped }; // replace, never mutate (identity caches)
660
+ ev.onNotice?.("provider refused a third time — removed project-context guidance from the system prompt and retrying once more");
661
+ continue; // same step, reduced system prompt
662
+ }
663
+ // Nothing left to strip — fall through to the backoff rung below.
644
664
  }
645
- // Nothing left to strip — fall through to the friendly terminal error
646
- // instead of burning an identical billed call.
665
+ // Rung 4: every context mutation is spent. Backoff-resend forever (abortable).
666
+ const attempt = Math.max(1, refusalRetries - MAX_REFUSAL_RETRIES);
667
+ const baseRaw = Number(jeoEnv("REFUSAL_BACKOFF_BASE_MS") ?? NaN);
668
+ const baseMs = Number.isFinite(baseRaw) && baseRaw >= 0 ? baseRaw : GUARD_LIMITS.REFUSAL_BACKOFF_BASE_MS;
669
+ const delayMs = Math.min(baseMs * 2 ** (attempt - 1), GUARD_LIMITS.REFUSAL_BACKOFF_MAX_MS);
670
+ ev.onNotice?.(`provider refused again — auto-retry #${attempt} in ${Math.max(1, Math.round(delayMs / 1000))}s (Esc to cancel)`);
671
+ await waitAbortable(delayMs, opts.signal);
672
+ continue; // loop top surfaces "Cancelled." when the wait was aborted
647
673
  }
648
674
  const message = friendlyProviderError(err);
649
675
  // The error IS the turn's doneReason and every caller displays that — emitting a
@@ -1132,7 +1158,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1132
1158
  "the key findings/changes so far, and what remains to be done next.",
1133
1159
  },
1134
1160
  ],
1135
- { jsonMode: false, model: opts.model, maxTokens: opts.maxTokens, signal: opts.signal },
1161
+ { jsonMode: false, model: opts.model, maxTokens: opts.maxTokens, signal: opts.signal, sessionKey: opts.sessionKey },
1136
1162
  );
1137
1163
  const consolidated = wrapUp.trim();
1138
1164
  if (consolidated) {
@@ -25,7 +25,7 @@ export type GuardState =
25
25
  | "invalid_tool_stop" // MAX_INVALID_CALLS replies with no usable tool field → stop
26
26
  | "parse_salvage" // repeated non-JSON prose → salvage the text as the final answer
27
27
  | "context_overflow_retry" // provider reported context overflow → ONE trim + retry
28
- | "refusal_retry" // transient safety refusal → bounded resend ladder
28
+ | "refusal_retry" // safety refusal → context-reset ladder, then unbounded backoff resends
29
29
  | "done_unverified" // mutated files, no verification signal → pushback on done
30
30
  | "done_stale_verification" // verified, then mutated again → re-verify before done
31
31
  | "done_hook_failing" // post-turn hook still failing → pushback on done
@@ -41,8 +41,13 @@ export const GUARD_LIMITS = Object.freeze({
41
41
  MAX_REPEAT: 4,
42
42
  /** Consecutive different-but-failing steps before the turn stops. */
43
43
  MAX_FAILURES: 5,
44
- /** Safety-refusal resends per turn before surfacing the friendly error. */
44
+ /** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
45
+ * recovery switches to unbounded backoff resends (gjc parity: never terminal). */
45
46
  MAX_REFUSAL_RETRIES: 3,
47
+ /** First post-ladder refusal backoff wait (ms); doubles per attempt (gjc retry.baseDelayMs). */
48
+ REFUSAL_BACKOFF_BASE_MS: 2_000,
49
+ /** Ceiling on the post-ladder refusal backoff wait (ms). */
50
+ REFUSAL_BACKOFF_MAX_MS: 30_000,
46
51
  /** Replies with no usable `tool`/`tools` field before the turn stops. */
47
52
  MAX_INVALID_CALLS: 3,
48
53
  /** Consecutive non-JSON parse failures before the prose is salvaged as the answer. */
package/src/agent/loop.ts CHANGED
@@ -31,6 +31,9 @@ export interface ChatOptions {
31
31
  onReasoningArtifact?: (artifact: import("../ai/types").ReasoningArtifact) => void;
32
32
  /** NATIVE tool-calling function declarations (forwarded to capable adapters). */
33
33
  tools?: import("../ai/types").NativeToolSchema[];
34
+ /** Stable per-conversation key (session id) for provider-side prompt caching
35
+ * (gjc parity: prompt_cache_key / session_id correlation on Codex/Responses). */
36
+ sessionKey?: string;
34
37
  }
35
38
 
36
39
  const manager = createModelManager();
@@ -78,6 +78,11 @@ export interface Config {
78
78
  rateLimitRetries?: number;
79
79
  /** Minimum backoff (ms) for a 429 when the server sends no Retry-After. */
80
80
  rateLimitMinDelayMs?: number;
81
+ /** Opt-in ceiling (ms) on a 429's server-directed Retry-After: a delay beyond it fails
82
+ * fast instead of sleeping. Unset (the default) honors any server-directed delay in
83
+ * full, however long — a generic rate limit is retried forever, not treated as fatal
84
+ * past some budget. Set this only if unbounded rate-limit waits are undesirable. */
85
+ rateLimitMaxServerDelayMs?: number;
81
86
  /** HTTP statuses to treat as NON-retryable even when defaultRetryable would
82
87
  * retry them (e.g. pin 503 to fail fast instead of riding the backoff ladder). */
83
88
  failFastStatuses?: number[];
@@ -385,14 +390,88 @@ export async function readRawGlobalConfig(): Promise<Config> {
385
390
  return salvageCredentials(clean, parsed);
386
391
  }
387
392
 
393
+ /**
394
+ * Global config write lock — HIGH-1 lost-update fix (gjc parity: auth-storage.ts
395
+ * SqliteAuthCredentialStore serializes credential writes inside SQLite). jeo keeps
396
+ * everything in one config.json, so EVERY read-modify-write must serialize through one
397
+ * lock file; otherwise two concurrent per-provider OAuth refreshes (e.g. doctor.ts
398
+ * Promise.all) read the same base config and the later write clobbers the earlier one's
399
+ * just-rotated refresh token → silent logout.
400
+ *
401
+ * Semantics mirror src/auth/storage.ts acquireLock (stale-lock detection + bounded wait
402
+ * + single steal at the deadline) but are implemented locally: storage.ts imports
403
+ * state.ts, so importing it back would create a cycle.
404
+ * // ponytail: extract a shared lockfile helper module if a third lockfile user appears.
405
+ */
406
+ const CONFIG_LOCK_STALE_MS = 5_000;
407
+
408
+ function configLockPath(): string {
409
+ return `${globalConfigPath()}.lock`;
410
+ }
411
+
412
+ async function acquireConfigLock(timeoutMs = CONFIG_LOCK_STALE_MS): Promise<void> {
413
+ const lockPath = configLockPath();
414
+ await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
415
+ // `timeoutMs` is the STALENESS threshold for a dead holder's lock file; the
416
+ // acquisition wait itself is bounded at 2× that (same policy as storage.ts).
417
+ const deadline = Date.now() + Math.max(timeoutMs * 2, 1_000);
418
+ while (true) {
419
+ try {
420
+ const handle = await fs.open(lockPath, "wx");
421
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }), "utf-8");
422
+ await handle.close();
423
+ return;
424
+ } catch (err: any) {
425
+ if (err.code !== "EEXIST") throw err;
426
+ try {
427
+ const info = JSON.parse(await fs.readFile(lockPath, "utf-8"));
428
+ if (typeof info.createdAt === "number" && info.createdAt + timeoutMs < Date.now()) {
429
+ await fs.unlink(lockPath).catch(() => {});
430
+ }
431
+ } catch {
432
+ try {
433
+ const stat = await fs.stat(lockPath);
434
+ if (stat.mtimeMs + timeoutMs < Date.now()) {
435
+ await fs.unlink(lockPath).catch(() => {});
436
+ }
437
+ } catch {}
438
+ }
439
+ }
440
+ if (Date.now() >= deadline) {
441
+ // Deadline reached: the holder is dead or wedged. Steal once — the lock guards
442
+ // a short config read-modify-write, so waiting longer only hangs the caller.
443
+ await fs.unlink(lockPath).catch(() => {});
444
+ const handle = await fs.open(lockPath, "wx").catch(() => null);
445
+ if (handle) {
446
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), stolen: true }), "utf-8");
447
+ await handle.close();
448
+ return;
449
+ }
450
+ throw new Error(`config lock could not be acquired within ${Math.max(timeoutMs * 2, 1_000)}ms (${lockPath})`);
451
+ }
452
+ await new Promise(resolve => setTimeout(resolve, 50));
453
+ }
454
+ }
455
+
456
+ async function releaseConfigLock(): Promise<void> {
457
+ await fs.unlink(configLockPath()).catch(() => {});
458
+ }
459
+
388
460
  /** Merge a patch onto the RAW on-disk config and persist. The `build` callback
389
461
  * receives the raw config so partial updates (subagents/roles maps) are derived
390
- * from on-disk state, never from the env-overlaid runtime config. */
462
+ * from on-disk state, never from the env-overlaid runtime config. The whole
463
+ * read→merge→write cycle holds the global config lock so concurrent patches
464
+ * (per-provider OAuth refreshes, /model save, doctor sweeps) never lose updates. */
391
465
  export async function saveConfigPatch(build: (raw: Config) => Partial<Config>): Promise<Config> {
392
- const raw = await readRawGlobalConfig();
393
- const next = { ...raw, ...build(raw) };
394
- await saveGlobalConfig(next);
395
- return next;
466
+ await acquireConfigLock();
467
+ try {
468
+ const raw = await readRawGlobalConfig();
469
+ const next = { ...raw, ...build(raw) };
470
+ await saveGlobalConfig(next);
471
+ return next;
472
+ } finally {
473
+ await releaseConfigLock();
474
+ }
396
475
  }
397
476
 
398
477
  export function getLocalJeoDir(cwd: string = process.cwd()): string {
@@ -220,6 +220,9 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
220
220
  model,
221
221
  maxSteps,
222
222
  maxTokens: resolveMaxOutputTokens(model, thinking),
223
+ // Per-run prompt-cache key: the subagent replays its own growing history each
224
+ // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
225
+ sessionKey: crypto.randomUUID(),
223
226
  // Bounded delegation: a subagent's step contract stays exact — the parent
224
227
  // owns any retry/extension decision, so the gjc retry flow is disabled here.
225
228
  budget: { maxExtensions: 0 },
@@ -93,6 +93,14 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
93
93
  { canonical: "kimi-thinking-preview", provider: "kimi", providerModel: "kimi-thinking-preview", contextTokens: 128_000, maxOutputTokens: 32_000, thinking: FULL, images: true },
94
94
  { canonical: "kimi-latest", provider: "kimi", providerModel: "kimi-latest", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
95
95
  { canonical: "moonshot-v1-128k", provider: "kimi", providerModel: "moonshot-v1-128k", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: false },
96
+ // Kimi Code (Moonshot coding subscription, device-code OAuth) — Anthropic-compatible
97
+ // at https://api.kimi.com/coding. Provider-qualified (`kimi/…`) to avoid canonical
98
+ // collisions (kimi-k2.5 also exists under tencent). gjc catalog parity: kimi-code.
99
+ { canonical: "kimi-for-coding", provider: "kimi", providerModel: "kimi/kimi-for-coding", contextTokens: 262_144, maxOutputTokens: 32_000, thinking: FULL, images: true, company: "Moonshot (Kimi Code)" },
100
+ { canonical: "kimi-k2.7-code", provider: "kimi", providerModel: "kimi/kimi-k2.7-code", contextTokens: 262_144, maxOutputTokens: 65_536, thinking: FULL, images: true, company: "Moonshot (Kimi Code)" },
101
+ { canonical: "kimi/kimi-k2.5", provider: "kimi", providerModel: "kimi/kimi-k2.5", contextTokens: 262_144, maxOutputTokens: 65_536, thinking: FULL, images: true, company: "Moonshot (Kimi Code)" },
102
+ { canonical: "kimi/kimi-k2-turbo-preview", provider: "kimi", providerModel: "kimi/kimi-k2-turbo-preview", contextTokens: 262_144, maxOutputTokens: 32_000, thinking: FULL, images: false, company: "Moonshot (Kimi Code)" },
103
+ { canonical: "kimi/kimi-k2", provider: "kimi", providerModel: "kimi/kimi-k2", contextTokens: 262_144, maxOutputTokens: 262_144, thinking: [], images: false, company: "Moonshot (Kimi Code)" },
96
104
  // Google
97
105
  { canonical: "gemini-1.5-pro", provider: "gemini", providerModel: "gemini-1.5-pro", contextTokens: 1_000_000, maxOutputTokens: 8_192, thinking: [], images: true },
98
106
  { canonical: "gemini-2.0-flash", provider: "gemini", providerModel: "gemini-2.0-flash", contextTokens: 1_000_000, maxOutputTokens: 8_192, thinking: [], images: true },
@@ -144,6 +152,21 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
144
152
  */
145
153
  export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4"];
146
154
 
155
+ /**
156
+ * Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
157
+ * format) actually serves. The subscription endpoint rejects Moonshot API-platform
158
+ * ids (kimi-latest, moonshot-v1-*, kimi-thinking-preview), so a Kimi OAuth login is
159
+ * limited to exactly these; an API key serves the api.moonshot.ai catalog instead.
160
+ * gjc parity: models.json `kimi-code` provider entries (wire ids, `kimi/` stripped).
161
+ */
162
+ export const KIMI_CODE_MODELS: readonly string[] = [
163
+ "kimi-for-coding",
164
+ "kimi-k2.7-code",
165
+ "kimi-k2.5",
166
+ "kimi-k2-turbo-preview",
167
+ "kimi-k2",
168
+ ];
169
+
147
170
  /** Format a token count compactly (1000 → 1K, 1_000_000 → 1M). */
148
171
  export function formatTokens(n: number): string {
149
172
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
@@ -11,7 +11,7 @@ import { readGlobalConfig, type Config } from "../agent/state";
11
11
  import { resolveCredential, type AuthProvider, type Credential } from "../auth";
12
12
  import type { ProviderName } from "./types";
13
13
  import { PROVIDER_NAMES } from "./provider-status";
14
- import { catalogByProvider, CODEX_MODELS } from "./model-catalog";
14
+ import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS } from "./model-catalog";
15
15
  import { extractChatgptAccountId } from "./providers/openai-responses";
16
16
  import { openaiCompatDef } from "./providers/openai-compatible-catalog";
17
17
 
@@ -291,7 +291,11 @@ export function catalogOr(result: ProviderModelsResult): ProviderModelsResult {
291
291
  if (!eligible) return result;
292
292
  const ids = result.provider === "openai" && result.source === "oauth"
293
293
  ? [...CODEX_MODELS]
294
- : catalogByProvider(result.provider).map(m => m.providerModel);
294
+ : result.provider === "kimi" && result.source === "oauth"
295
+ // Kimi OAuth (Kimi Code subscription) serves ONLY the Kimi Code catalog —
296
+ // moonshot API-platform ids would 404 against api.kimi.com/coding.
297
+ ? KIMI_CODE_MODELS.map(id => `kimi/${id}`)
298
+ : catalogByProvider(result.provider).map(m => m.providerModel);
295
299
  if (ids.length === 0) return result;
296
300
  return { ...result, models: ids, ok: true, fallback: true };
297
301
  }
@@ -6,7 +6,7 @@ import "./register-providers"; // side-effect: registers built-in adapters into
6
6
  import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
7
7
  import { expandAlias, resolveModelId, effectiveAliasesFor } from "./model-registry";
8
8
  import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
9
- import { toProviderModel, CODEX_MODELS, findCatalogModel } from "./model-catalog";
9
+ import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel } from "./model-catalog";
10
10
  import { xaiCredential } from "./providers/xai";
11
11
  import { OPENAI_COMPAT_NAMES, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
12
12
  import { withRetry, defaultRetryable, type RetryOptions } from "../util/retry";
@@ -194,19 +194,18 @@ const ALIAS_DEFAULTS = { fast: "ollama/qwen2.5:0.5b", local: "ollama/qwen2.5:0.5
194
194
  * requestMaxRetries + 1. When unset, the `withRetry` defaults apply (3 attempts),
195
195
  * but rate-limit (429) errors get a more generous budget + a backoff floor so a
196
196
  * transient per-minute window can clear instead of the very first 429 instantly
197
- * exhausting auto-retry. A server-directed retry delay above the five-minute
198
- * budget is surfaced immediately with its reset hint instead of being capped and
199
- * retried pointlessly. Explicit config (`requestMaxRetries`/`maxDelayMs`) always
200
- * wins and disables the matching rate-limit default.
201
- * `maxDelayMs` caps per-attempt backoff when provided.
197
+ * exhausting auto-retry. A server-directed retry delay is honored IN FULL — no
198
+ * default ceiling — because a generic 429 is retried forever, not treated as fatal
199
+ * past some arbitrary budget (gjc parity: even a multi-hour Anthropic rate-limit
200
+ * window is retried, not bailed on); `rateLimitMaxServerDelayMs` remains available
201
+ * as an explicit opt-in for a caller that truly cannot wait that long. Explicit
202
+ * config (`requestMaxRetries`/`maxDelayMs`) always wins and disables the matching
203
+ * rate-limit default. `maxDelayMs` caps per-attempt backoff when provided.
202
204
  */
203
205
  const DEFAULT_RATE_LIMIT_RETRIES = 6; // total attempts for 429 (initial + 5 retries)
204
206
  // 429 floor when the server sends no Retry-After. Escalates per attempt inside
205
207
  // withRetry (2s → 4s → 8s → 16s → 30s ≈ 60s total), spanning a per-minute window.
206
208
  const DEFAULT_RATE_LIMIT_MIN_DELAY_MS = 2000;
207
- // GJC parity for server-directed 429s: retry short windows, but do not hang a CLI
208
- // through long subscription/account resets.
209
- const DEFAULT_RATE_LIMIT_MAX_SERVER_DELAY_MS = 5 * 60 * 1000;
210
209
  export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "stream" = "request"): RetryOptions {
211
210
  const opts: RetryOptions = { isRetryable: defaultRetryable };
212
211
 
@@ -244,7 +243,12 @@ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "s
244
243
  // 429 backoff floor: explicit wins; else default UNLESS the user pinned maxDelayMs.
245
244
  if (typeof retry?.rateLimitMinDelayMs === "number") opts.rateLimitMinDelayMs = retry.rateLimitMinDelayMs;
246
245
  else if (typeof retry?.maxDelayMs !== "number") opts.rateLimitMinDelayMs = DEFAULT_RATE_LIMIT_MIN_DELAY_MS;
247
- opts.rateLimitMaxServerDelayMs = DEFAULT_RATE_LIMIT_MAX_SERVER_DELAY_MS;
246
+ // No default `rateLimitMaxServerDelayMs` ceiling: gjc parity retries a generic 429
247
+ // forever, honoring however long the server asks (see the doc comment above). Usage
248
+ // limits fail fast on their own via isUsageLimitError → defaultRetryable, well before a
249
+ // wait is ever computed, so this is safe. An explicit config value is still honored for
250
+ // a caller that truly cannot wait an unbounded server-directed delay.
251
+ if (typeof retry?.rateLimitMaxServerDelayMs === "number") opts.rateLimitMaxServerDelayMs = retry.rateLimitMaxServerDelayMs;
248
252
 
249
253
  // Config-driven fail-fast overrides: a status in `failFastStatuses` or a message
250
254
  // matching any `failFastPattern` is forced non-retryable, layered on top of the
@@ -281,12 +285,18 @@ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "s
281
285
  /**
282
286
  * Whether the bundled adapter can serve this provider+model over OAuth end-to-end.
283
287
  * OpenAI OAuth (ChatGPT/Codex) only serves Codex models, so any other OpenAI model
284
- * must use an API key. A provider whose OAuth backend is not verified end-to-end
288
+ * must use an API key. Kimi OAuth (Kimi Code subscription) only serves the Kimi Code
289
+ * catalog (api.kimi.com/coding) — Moonshot API-platform ids (kimi-latest, moonshot-v1-*)
290
+ * need a KIMI_API_KEY. A provider whose OAuth backend is not verified end-to-end
285
291
  * cannot serve any model over OAuth. Everything else (Anthropic Messages, Gemini /
286
292
  * Antigravity Cloud Code Assist) is served end-to-end.
287
293
  */
288
294
  function oauthServesModel(provider: AuthProvider, model: string): boolean {
289
295
  if (provider === "openai") return CODEX_MODELS.includes(model);
296
+ if (provider === "kimi") {
297
+ const wire = model.startsWith("kimi/") ? model.slice(5) : model;
298
+ return KIMI_CODE_MODELS.includes(wire);
299
+ }
290
300
  if (isOAuthProvider(provider) && OAUTH_FLOW_REGISTRY[provider].verifiedEndToEnd === false) return false;
291
301
  return true;
292
302
  }
@@ -375,6 +385,7 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
375
385
  onReasoning: options.onReasoning,
376
386
  onReasoningArtifact: options.onReasoningArtifact,
377
387
  tools: options.tools,
388
+ sessionKey: options.sessionKey,
378
389
  };
379
390
  // Caller-supplied retry sink rides on the config-derived retry budget so the
380
391
  // engine/TUI can surface "rate limited — retrying in Ns" instead of a silent wait.
@@ -383,7 +394,12 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
383
394
  // never replays after the first emitted chunk). Both fall back to `maxRetries`,
384
395
  // and an unset stream budget keeps the conservative withRetry default — the
385
396
  // generous gjc default of 100 only applies when the user configures it.
386
- const retry: RetryOptions = { ...resolveRetryOptions(config.retry, kind), ...(options.onRetry ? { onRetry: options.onRetry } : {}) };
397
+ // `retry.signal` is the caller's ORIGINAL signal (Ctrl-C / turn abort) — not the
398
+ // per-attempt fetch timeout composed below — so a long, honored server-directed
399
+ // retry wait (see resolveRetryOptions) can still be cancelled by the user without
400
+ // waiting out the full delay, while a single attempt's timeout never cuts short
401
+ // the NEXT attempt's backoff sleep.
402
+ const retry: RetryOptions = { ...resolveRetryOptions(config.retry, kind), ...(options.onRetry ? { onRetry: options.onRetry } : {}), ...(callOptions.signal ? { signal: callOptions.signal } : {}) };
387
403
 
388
404
  if (provider === "ollama" || provider === "lmstudio") {
389
405
  return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry };
@@ -416,6 +432,11 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
416
432
  "OpenAI OAuth 자격증명은 Codex 모델(gpt-5.5/gpt-5.4)만 지원. OPENAI_API_KEY를 설정하거나 모델을 변경하세요"
417
433
  );
418
434
  }
435
+ if (provider === "kimi" && effective.kind === "oauth" && !oauthServesModel("kimi", model)) {
436
+ throw new Error(
437
+ `Kimi OAuth (Kimi Code subscription) serves only the Kimi Code models (${KIMI_CODE_MODELS.join(", ")}). Set KIMI_API_KEY for Moonshot API models, or pick a Kimi Code model with /model.`,
438
+ );
439
+ }
419
440
  if (effective.kind === "none" && !isLocalOpenAi) {
420
441
  throw new Error(
421
442
  `No credential for provider '${provider}'. Run 'jeo setup', 'jeo auth login', or set ${provider.toUpperCase()}_API_KEY / ${provider.toUpperCase()}_OAUTH_TOKEN.`