jeo-code 0.7.28 → 0.7.30

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,27 @@ 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.30] - 2026-07-02
10
+ _GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs._
11
+
12
+ ### Fixed
13
+ - **GPT/Codex OAuth no longer sends `max_output_tokens` to the ChatGPT/Codex backend.** `0.7.28` moved the computed output cap onto the shared Responses payload, but the OAuth Codex endpoint rejects that public-API-only parameter with `HTTP 400 {"detail":"Unsupported parameter: max_output_tokens"}`. The cap is now applied only on the public `/v1/responses` API-key path; OAuth requests keep using the Codex backend shape that `jeo doctor` probes.
14
+ - **`JEO_TURN_MAX_MS` is now a stall budget, not an absolute kill switch.** The old implementation measured 30 minutes from turn start and could terminate a long autonomous run even while it was still executing tools and making progress. The clock now resets on every executed tool step and on mid-turn steering, so it only stops no-progress loops such as refusal backoff spins, endless provider retries, or bounce loops. The existing step hard cap and repeat/cycle/failure guards still bound genuinely long runs.
15
+
16
+ ### Verified
17
+ - `bun test` green — 2147 pass / 0 fail across 244 files; `bun run typecheck` clean.
18
+ - Live GPT/Codex OAuth smoke tests through the installed `jeo` symlink: `jeo chat "Say exactly: hello" --model gpt-5.5` and `jeo chat "Reply with one word: pong" --model gpt-5.5` both returned the requested text. Added/updated regression coverage in `test/codex-responses.test.ts` and `test/cycle-and-turn-budget.test.ts`.
19
+
20
+ ## [0.7.29] - 2026-07-02
21
+ _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._
22
+
23
+ ### Fixed
24
+ - **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.
25
+ - **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.
26
+
27
+ ### Verified
28
+ - `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.
29
+
9
30
  ## [0.7.28] - 2026-07-02
10
31
  _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)._
11
32
 
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.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
209
+ - **[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.
208
210
  - **[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).
209
211
  - **[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.
210
212
  - **[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.
211
- - **[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.
212
- - **[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.
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.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
209
+ - **[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.
208
210
  - **[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).
209
211
  - **[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.
210
212
  - **[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.
211
- - **[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.
212
- - **[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.
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.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
236
+ - **[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.
235
237
  - **[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).
236
238
  - **[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.
237
239
  - **[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.
238
- - **[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.
239
- - **[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.
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.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
209
+ - **[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.
208
210
  - **[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).
209
211
  - **[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.
210
212
  - **[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.
211
- - **[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.
212
- - **[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.
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.28",
3
+ "version": "0.7.30",
4
4
 
5
5
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
6
6
  "type": "module",
@@ -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";
@@ -286,10 +286,12 @@ export interface AgentLoopResult {
286
286
  }
287
287
 
288
288
 
289
- /** Wall-clock budget for ONE agent turn (ms). JEO_TURN_MAX_MS overrides; 0 disables.
290
- * Default 30 minutes: long autonomous runs stay alive, while a turn that spins in
291
- * "thinking" (huge contexts, endless extensions) is guaranteed to terminate into
292
- * the consolidation wrap-up instead of running for hours. */
289
+ /** Wall-clock STALL budget for ONE agent turn (ms): the maximum time a turn may run
290
+ * WITHOUT PROGRESS (no executed tool step). JEO_TURN_MAX_MS overrides; 0 disables.
291
+ * Default 30 minutes: long autonomous runs that keep executing tools stay alive
292
+ * indefinitely (the step budget's hard cap bounds them), while a turn that spins in
293
+ * "thinking" (refusal backoff, endless provider retries) is guaranteed to terminate
294
+ * into the consolidation wrap-up instead of running for hours. */
293
295
  export function turnMaxMs(env: Record<string, string | undefined> = process.env): number {
294
296
  const raw = jeoEnv("TURN_MAX_MS", env);
295
297
  if (raw !== undefined && raw !== "") {
@@ -364,12 +366,18 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
364
366
  const ev = opts.events ?? {};
365
367
  const maxHistoryTokens = Math.max(10_000, opts.maxHistoryTokens ?? 80_000);
366
368
 
367
- // Wall-clock turn budget — the definitive "never sits in thinking forever"
368
- // guarantee. Step budgets bound the COUNT of model calls; this bounds their total
369
- // TIME: a turn that crosses it stops at the next loop boundary and consolidates a
370
- // wrap-up instead of spinning for hours under a generous dynamic step cap.
369
+ // Wall-clock STALL budget — the definitive "never sits in thinking forever"
370
+ // guarantee. Step budgets bound the COUNT of model calls; this bounds the time a
371
+ // turn may spend WITHOUT PROGRESS (no executed tool step): refusal-backoff spins,
372
+ // endless provider retries, and bounce loops terminate into the consolidation
373
+ // wrap-up, while a long-running turn that keeps executing tools stays alive —
374
+ // its termination is owned by the step budget's hard cap and the spin guards.
375
+ // Field regression this replaces: an ABSOLUTE 30m budget measured from turn start
376
+ // killed genuinely progressing autonomous runs mid-work.
371
377
  const turnStartedAt = Date.now();
372
378
  const turnBudgetMs = turnMaxMs();
379
+ // Reset on every executed tool step and on mid-turn steering.
380
+ let lastProgressAt = turnStartedAt;
373
381
  // "steps" | "time" — drives honest wording in the consolidation message.
374
382
  let stopKind: "steps" | "time" = "steps";
375
383
  let step = 1;
@@ -443,11 +451,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
443
451
  // Round-6 #4: ONE reactive recovery when the PROVIDER reports context overflow
444
452
  // (authoritative where the local estimate drifted — images, tokenizer mismatch).
445
453
  let contextOverflowRetryUsed = false;
446
- // Refusal recovery budget: a safety refusal (HTTP 200, no content) on routine
447
- // coding work is usually a transient false-positive. Retry the SAME step once
448
- // as-is, then once more with an explicit re-grounding note; only a third
449
- // refusal in the turn surfaces the (friendly) error. Bounded per turn so a
450
- // genuinely refused request can never burn billed calls in a loop.
454
+ // Refusal recovery: a safety refusal (HTTP 200, no content) on routine coding
455
+ // work is usually a transient false-positive. The first rungs MUTATE the context
456
+ // (plain resend → context reset + re-grounding note → guidance strip); after the
457
+ // ladder is exhausted the loop keeps resending with capped exponential backoff
458
+ // instead of surfacing a terminal error (gjc parity: a refusal is retried until
459
+ // it clears or the user cancels — Esc aborts the backoff wait via opts.signal,
460
+ // and the stall budget bounds the spin: refusals execute no tools, so the
461
+ // no-progress clock keeps ticking and terminates the turn at JEO_TURN_MAX_MS).
451
462
  const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
452
463
  let refusalRetries = 0;
453
464
  let lastSig = "";
@@ -473,9 +484,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
473
484
  // schemas ONCE instead of rebuilding/reallocating the list on every step.
474
485
  const turnToolSchemas = nativeToolSchemasFor(Object.keys(tools));
475
486
  while (true) {
476
- if (turnBudgetMs > 0 && Date.now() - turnStartedAt > turnBudgetMs) {
487
+ if (turnBudgetMs > 0 && Date.now() - lastProgressAt > turnBudgetMs) {
477
488
  stopKind = "time";
478
- budgetStopReason = `turn wall-clock budget of ${Math.round(turnBudgetMs / 60_000)}m exceeded (JEO_TURN_MAX_MS) without done`;
489
+ budgetStopReason = `no tool progress for ${Math.round(turnBudgetMs / 60_000)}m — turn stall budget (JEO_TURN_MAX_MS) exceeded without done`;
479
490
  break;
480
491
  }
481
492
  if (step > budget.limit()) {
@@ -513,6 +524,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
513
524
  consecutiveFailures = 0;
514
525
  recentStepSigs.length = 0;
515
526
  budget.noteSteer?.();
527
+ lastProgressAt = Date.now(); // fresh instruction = fresh progress window
516
528
  }
517
529
  }
518
530
 
@@ -610,7 +622,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
610
622
  // routine). Strip that block for the rest of the turn and retry once;
611
623
  // core instructions stay intact. Field case: `$gjc init` inside a
612
624
  // repo whose guidance files refuse-trip the OAuth classifier.
613
- if (isRefusalError(err) && refusalRetries < MAX_REFUSAL_RETRIES) {
625
+ // 4) unbounded backoff — the classifier verdict is TIME-sensitive (rolling
626
+ // classifier state, per-request user-id rotation), so once every
627
+ // context mutation is spent, keep resending with capped exponential
628
+ // backoff instead of surfacing a terminal error (gjc parity: gjc's
629
+ // session auto-retry classifies a refusal as retryable and never ends
630
+ // the turn on it). Esc/cancel aborts the wait via opts.signal and the
631
+ // loop-top check turns it into "Cancelled."; the JEO_TURN_MAX_MS stall
632
+ // budget still bounds the spin (a refusal loop executes no tools, so
633
+ // the no-progress clock never resets).
634
+ if (isRefusalError(err)) {
614
635
  refusalRetries++;
615
636
  if (refusalRetries === 1) {
616
637
  ev.onNotice?.("provider refused the last call (no content) — retrying the same step");
@@ -642,15 +663,24 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
642
663
  step++;
643
664
  continue;
644
665
  }
645
- const sys = history[0];
646
- if (sys?.role === "system" && sys.content.includes("<project_context>")) {
647
- const stripped = sys.content.replace(/\n*<project_context>[\s\S]*?<\/project_context>/, "").trimEnd();
648
- history[0] = { ...sys, content: stripped }; // replace, never mutate (identity caches)
649
- ev.onNotice?.("provider refused a third time — removed project-context guidance from the system prompt and retrying once more");
650
- continue; // same step, reduced system prompt
666
+ if (refusalRetries === 3) {
667
+ const sys = history[0];
668
+ if (sys?.role === "system" && sys.content.includes("<project_context>")) {
669
+ const stripped = sys.content.replace(/\n*<project_context>[\s\S]*?<\/project_context>/, "").trimEnd();
670
+ history[0] = { ...sys, content: stripped }; // replace, never mutate (identity caches)
671
+ ev.onNotice?.("provider refused a third time — removed project-context guidance from the system prompt and retrying once more");
672
+ continue; // same step, reduced system prompt
673
+ }
674
+ // Nothing left to strip — fall through to the backoff rung below.
651
675
  }
652
- // Nothing left to strip — fall through to the friendly terminal error
653
- // instead of burning an identical billed call.
676
+ // Rung 4: every context mutation is spent. Backoff-resend forever (abortable).
677
+ const attempt = Math.max(1, refusalRetries - MAX_REFUSAL_RETRIES);
678
+ const baseRaw = Number(jeoEnv("REFUSAL_BACKOFF_BASE_MS") ?? NaN);
679
+ const baseMs = Number.isFinite(baseRaw) && baseRaw >= 0 ? baseRaw : GUARD_LIMITS.REFUSAL_BACKOFF_BASE_MS;
680
+ const delayMs = Math.min(baseMs * 2 ** (attempt - 1), GUARD_LIMITS.REFUSAL_BACKOFF_MAX_MS);
681
+ ev.onNotice?.(`provider refused again — auto-retry #${attempt} in ${Math.max(1, Math.round(delayMs / 1000))}s (Esc to cancel)`);
682
+ await waitAbortable(delayMs, opts.signal);
683
+ continue; // loop top surfaces "Cancelled." when the wait was aborted
654
684
  }
655
685
  const message = friendlyProviderError(err);
656
686
  // The error IS the turn's doneReason and every caller displays that — emitting a
@@ -1114,6 +1144,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1114
1144
  // repeated call's ACTUAL last outcome (A). A skipped/bounced step never reaches
1115
1145
  // here, so this always holds the last REAL execution's results.
1116
1146
  lastResults = results;
1147
+ // Executed tool step = progress: the stall budget clocks time WITHOUT this.
1148
+ if (results.some(r => r.executed)) lastProgressAt = Date.now();
1117
1149
  step++;
1118
1150
  }
1119
1151
 
@@ -1124,7 +1156,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1124
1156
  const extInfo = budget.extensionsUsed() > 0 ? ` after ${budget.extensionsUsed()} extension(s)` : "";
1125
1157
  const stopInfo = budgetStopReason ? `; ${budgetStopReason}` : "";
1126
1158
  const budgetLabel = stopKind === "time"
1127
- ? `turn time budget of ${Math.round(turnBudgetMs / 60_000)}m reached`
1159
+ ? `turn time budget of ${Math.round(turnBudgetMs / 60_000)}m reached (no tool progress)`
1128
1160
  : `step budget of ${budget.limit()} reached`;
1129
1161
  try {
1130
1162
  if (!opts.signal?.aborted) {
@@ -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. */
@@ -126,13 +126,14 @@ export function codexResponsesRequest(
126
126
  // frame can show the model's thinking instead of a frozen "calling model (Ns)…".
127
127
  payload.reasoning = { effort: options.reasoningEffort, summary: "auto" };
128
128
  }
129
- // Cap the response length (gjc parity): the computed per-call budget must reach the wire,
130
- // otherwise the backend free-runs to its own default.
131
- if (options.maxTokens) payload.max_output_tokens = options.maxTokens;
129
+ // Cap the response length (gjc parity) on the PUBLIC Responses API only. The ChatGPT/Codex
130
+ // OAuth backend 400s on this parameter ({"detail":"Unsupported parameter: max_output_tokens"}),
131
+ // so the cap is applied inside the api_key branch below.
132
132
  // OAuth → the undocumented ChatGPT/Codex backend (codex headers + account-id).
133
133
  // API key → the public OpenAI Responses API (`/v1/responses`) with a plain Bearer.
134
134
  // Both speak the same Responses schema (the body above), so only url+headers differ.
135
135
  if (credential.kind === "api_key") {
136
+ if (options.maxTokens) payload.max_output_tokens = options.maxTokens;
136
137
  const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
137
138
  // Stateless reasoning replay (public Responses API): ask for encrypted reasoning content
138
139
  // so it can be captured and threaded back into a later `input` (store stays false).
@@ -61,7 +61,7 @@ export function friendlyProviderError(err: unknown): string {
61
61
  return `${provider} rejected the request: the conversation no longer fits the model's context window. Run /compact, drop large attachments, or start a fresh session.`;
62
62
  }
63
63
  if (isRefusalError(err)) {
64
- return `${provider} declined to answer (safety refusal — no content returned) even after automatic retries with a context reset. Usually a content classifier tripped on recently read file/search content: /retry once more, /compact or /new to drop the triggering context, or switch model with /model. If this persists on a Claude subscription (OAuth) login, Anthropic restricts third-party OAuth clients — set ANTHROPIC_API_KEY or use another provider.`;
64
+ return `${provider} declined to answer (safety refusal — no content returned). Usually a content classifier tripped on recently read file/search content: /retry, /compact or /new to drop the triggering context, or switch model with /model. If this persists on a Claude subscription (OAuth) login, Anthropic restricts third-party OAuth clients — set ANTHROPIC_API_KEY or use another provider.`;
65
65
  }
66
66
  if (status === 404) {
67
67
  return `${provider} does not recognize the requested model (HTTP 404). The id may be retired, gated, or mistyped — pick another with /model.`;
package/src/util/retry.ts CHANGED
@@ -208,6 +208,26 @@ function abortableSleep(sleep: (ms: number) => Promise<void>, ms: number, signal
208
208
  return promise;
209
209
  }
210
210
 
211
+ /** Abortable wait for the ENGINE's post-ladder refusal backoff: resolves EARLY (never
212
+ * rejects) when `signal` aborts, so the agent loop's top-of-loop cancellation check
213
+ * owns the exit path. Distinct from the rejecting `abortableSleep` inside `withRetry`,
214
+ * whose callers need the abort reason propagated as an error. */
215
+ export function waitAbortable(ms: number, signal?: AbortSignal): Promise<void> {
216
+ const { promise, resolve } = Promise.withResolvers<void>();
217
+ if (signal?.aborted) {
218
+ resolve();
219
+ return promise;
220
+ }
221
+ const finish = () => {
222
+ clearTimeout(timer);
223
+ signal?.removeEventListener("abort", finish);
224
+ resolve();
225
+ };
226
+ const timer = setTimeout(finish, ms);
227
+ signal?.addEventListener("abort", finish, { once: true });
228
+ return promise;
229
+ }
230
+
211
231
  // Extract a non-negative `retryAfterMs` from an error, if it carries one (e.g. ProviderHttpError).
212
232
  function retryAfterOf(err: unknown): number | undefined {
213
233
  if (typeof err === "object" && err !== null) {
@@ -258,8 +278,9 @@ export function isUsageLimitError(err: unknown): boolean {
258
278
  * block reasons). Lives here (not provider-error.ts) so `defaultRetryable` can
259
279
  * fail fast on it: a refusal is DETERMINISTIC for the same conversation content —
260
280
  * transport-level resends of an identical payload just burn billed calls. The
261
- * engine's bounded refusal ladder (resend → context reset → guidance strip) is
262
- * the correct recovery layer because it MUTATES the context between attempts. */
281
+ * engine's refusal ladder (resend → context reset → guidance strip, then unbounded
282
+ * capped backoff — gjc parity: a refusal is never terminal) is the correct recovery
283
+ * layer because it MUTATES the context between attempts. */
263
284
  export function isRefusalError(err: unknown): boolean {
264
285
  return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(errorMessageOf(err));
265
286
  }