jeo-code 0.7.28 → 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,16 @@ 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
+
9
19
  ## [0.7.28] - 2026-07-02
10
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)._
11
21
 
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.
208
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).
209
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.
210
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.
211
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.
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.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
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).
209
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.
210
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.
211
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.
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.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
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).
236
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.
237
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.
238
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.
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.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
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).
209
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.
210
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.
211
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.
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.29",
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";
@@ -443,11 +443,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
443
443
  // Round-6 #4: ONE reactive recovery when the PROVIDER reports context overflow
444
444
  // (authoritative where the local estimate drifted — images, tokenizer mismatch).
445
445
  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.
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).
451
453
  const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
452
454
  let refusalRetries = 0;
453
455
  let lastSig = "";
@@ -610,7 +612,15 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
610
612
  // routine). Strip that block for the rest of the turn and retry once;
611
613
  // core instructions stay intact. Field case: `$gjc init` inside a
612
614
  // repo whose guidance files refuse-trip the OAuth classifier.
613
- 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)) {
614
624
  refusalRetries++;
615
625
  if (refusalRetries === 1) {
616
626
  ev.onNotice?.("provider refused the last call (no content) — retrying the same step");
@@ -642,15 +652,24 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
642
652
  step++;
643
653
  continue;
644
654
  }
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
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.
651
664
  }
652
- // Nothing left to strip — fall through to the friendly terminal error
653
- // 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
654
673
  }
655
674
  const message = friendlyProviderError(err);
656
675
  // The error IS the turn's doneReason and every caller displays that — emitting a
@@ -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. */
@@ -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
  }