jeo-code 0.7.26 → 0.7.28

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.ja.md +2 -2
  3. package/README.ko.md +2 -2
  4. package/README.md +2 -2
  5. package/README.zh.md +2 -2
  6. package/package.json +1 -1
  7. package/src/agent/compaction.ts +24 -0
  8. package/src/agent/config-schema.ts +1 -0
  9. package/src/agent/engine.ts +20 -5
  10. package/src/agent/loop.ts +3 -0
  11. package/src/agent/state.ts +84 -5
  12. package/src/agent/task-tool.ts +5 -2
  13. package/src/ai/model-catalog.ts +23 -0
  14. package/src/ai/model-discovery.ts +6 -2
  15. package/src/ai/model-manager.ts +60 -13
  16. package/src/ai/providers/anthropic.ts +62 -18
  17. package/src/ai/providers/antigravity.ts +34 -22
  18. package/src/ai/providers/gemini.ts +81 -27
  19. package/src/ai/providers/kimi.ts +44 -5
  20. package/src/ai/providers/openai-responses.ts +36 -2
  21. package/src/ai/types.ts +10 -0
  22. package/src/auth/callback-server.ts +19 -5
  23. package/src/auth/flows/AGENTS.md +7 -6
  24. package/src/auth/flows/antigravity.ts +3 -0
  25. package/src/auth/flows/google-project.ts +8 -2
  26. package/src/auth/flows/google.ts +15 -1
  27. package/src/auth/flows/index.ts +10 -0
  28. package/src/auth/flows/kimi.ts +263 -0
  29. package/src/auth/flows/openai.ts +103 -3
  30. package/src/auth/oauth.ts +9 -0
  31. package/src/auth/refresh.ts +6 -1
  32. package/src/auth/storage.ts +5 -5
  33. package/src/commands/launch/input.ts +244 -9
  34. package/src/commands/launch/slash-views.ts +5 -2
  35. package/src/commands/launch.ts +64 -28
  36. package/src/commands/team.ts +3 -0
  37. package/src/tui/clipboard.ts +49 -0
  38. package/src/tui/components/welcome.ts +98 -39
  39. package/src/tui/terminal.ts +5 -3
  40. package/src/util/provider-error.ts +4 -12
  41. package/src/util/retry.ts +88 -31
@@ -1,7 +1,9 @@
1
1
  import chalk from "chalk";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
2
4
  import { renderForgeMark, FORGE_MARK_ART_GRAND } from "./ascii-art";
3
5
  import { truncate, isTTY } from "../terminal";
4
- import { detectColorLevel, ColorLevel } from "./color";
6
+ import { detectColorLevel, ColorLevel, visibleWidth } from "./color";
5
7
 
6
8
  export interface WelcomeData {
7
9
  version: string;
@@ -23,12 +25,8 @@ export interface WelcomeData {
23
25
  color?: boolean; // default true
24
26
  }
25
27
 
26
- function getVisibleWidth(s: string): number {
27
- return s.replace(/\x1b\[[0-9;]*m/g, "").length;
28
- }
29
-
30
28
  function padLine(line: string, width: number, align: "left" | "center" | "right" = "left"): string {
31
- const vis = getVisibleWidth(line);
29
+ const vis = visibleWidth(line);
32
30
  if (width <= 0 || vis >= width) return line;
33
31
  const total = width - vis;
34
32
  if (align === "right") return " ".repeat(total) + line;
@@ -39,12 +37,60 @@ function padLine(line: string, width: number, align: "left" | "center" | "right"
39
37
  return line + " ".repeat(total);
40
38
  }
41
39
 
40
+ function shortenPath(p: string | undefined, maxWidth: number, unicode: boolean): string {
41
+ if (!p) return "-";
42
+ let s = p;
43
+ const home = os.homedir();
44
+ if (home && (s === home || s.startsWith(home + path.sep))) s = "~" + s.slice(home.length);
45
+ if (visibleWidth(s) <= maxWidth) return s;
46
+ const ell = unicode ? "…" : "...";
47
+ return truncate(`${ell}${s.slice(-Math.max(1, maxWidth - visibleWidth(ell)))}`, maxWidth);
48
+ }
49
+
50
+ function metaRows(d: WelcomeData, width: number, unicode: boolean, useColor: boolean): string[] {
51
+ const labelWidth = 8;
52
+ const valueWidth = Math.max(1, width - labelWidth - 3);
53
+ const key = useColor ? chalk.dim : (s: string) => s;
54
+ const rows: string[] = [];
55
+ const push = (label: string, value: string): void => {
56
+ const lhs = key(padLine(label, labelWidth, "right"));
57
+ const rhs = truncate(value || "-", valueWidth);
58
+ rows.push(padLine(`${lhs} │ ${rhs}`, width));
59
+ };
60
+ push("version", `v${d.version}`);
61
+ push("folder", shortenPath(d.cwd, valueWidth, unicode));
62
+ push("model", d.model);
63
+ if (d.provider) push("provider", d.provider);
64
+ if (d.thinking) push("thinking", d.thinking);
65
+ if (d.sessionId) push("session", d.sessionId.slice(0, 8));
66
+
67
+ const files = d.contextFiles ?? [];
68
+ if (files.length > 0 && width >= 28) {
69
+ const shown = files.slice(0, width >= 34 ? 3 : 2).map(f => path.basename(f));
70
+ push("files", shown.join(", ") + (files.length > shown.length ? ` +${files.length - shown.length}` : ""));
71
+ }
72
+ return rows;
73
+ }
74
+
75
+ function centerColumn(lines: string[], width: number): string[] {
76
+ return lines.map(raw => padLine(truncate(raw, width), width, "center"));
77
+ }
78
+
79
+ function zipColumns(left: string[], right: string[], leftWidth: number, gap: string, rightWidth: number): string[] {
80
+ const n = Math.max(left.length, right.length);
81
+ const out: string[] = [];
82
+ for (let i = 0; i < n; i++) {
83
+ out.push(`${padLine(left[i] ?? "", leftWidth)}${gap}${padLine(right[i] ?? "", rightWidth)}`);
84
+ }
85
+ return out;
86
+ }
87
+
42
88
  /**
43
89
  * The gjc-style hero welcome box ("JEO forge"): one outer box with the version
44
- * embedded in the top border and a SINGLE CENTERED column inside — brand line,
45
- * tagline, the grand jeo forge mark (flowing gradient on capable terminals),
46
- * and the model/provider pills. Workspace details and key hints intentionally
47
- * live elsewhere (footer/status bar), matching the gjc forge banner.
90
+ * embedded in the top border. Narrow terminals keep a single centered forge
91
+ * column; wide terminals split into a left hero mark and a right workspace table
92
+ * (folder, version, model, context files) so the launch capture uses the extra
93
+ * resolution instead of leaving a dead right side.
48
94
  */
49
95
  export function renderWelcome(d: WelcomeData): string[] {
50
96
  const cols = d.cols ?? 80;
@@ -80,11 +126,11 @@ export function renderWelcome(d: WelcomeData): string[] {
80
126
  const dashStr = g.h.repeat(titleDashes);
81
127
  const titleHead = `${dashStr}${titleLabel}`;
82
128
  let topBorderLine: string;
83
- if (titleHead.length + 2 > inner) {
84
- const clipped = titleHead.slice(0, inner);
85
- topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - clipped.length)) + g.tr);
129
+ if (visibleWidth(titleHead) + 2 > inner) {
130
+ const clipped = truncate(titleHead, inner);
131
+ topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - visibleWidth(clipped))) + g.tr);
86
132
  } else {
87
- const fill = g.h.repeat(inner - titleHead.length);
133
+ const fill = g.h.repeat(inner - visibleWidth(titleHead));
88
134
  topBorderLine = useColor
89
135
  ? lit(g.tl + dashStr) + chalk.bold(lit(titleLabel)) + lit(fill) + lit(g.tr)
90
136
  : g.tl + titleHead + fill + g.tr;
@@ -93,44 +139,57 @@ export function renderWelcome(d: WelcomeData): string[] {
93
139
  const bottomBorderPlain = g.bl + g.h.repeat(inner) + g.br;
94
140
  const bottomBorderLine = shadow(bottomBorderPlain);
95
141
 
96
- // Grand symbol when the box is wide enough; compact forge mark otherwise.
142
+ // Grand symbol when the box is wide enough; compact forge mark otherwise. In a
143
+ // split layout, art is constrained to the left pane so the right table remains stable.
97
144
  const colorLevel = useColor ? detectColorLevel(process.env, isTTY()) : ColorLevel.None;
98
- const grand = inner >= grandWidth;
145
+ const wideMeta = inner >= 86;
146
+ const gap = wideMeta ? " " : "";
147
+ const rightWidth = wideMeta ? Math.min(38, Math.max(28, Math.floor(inner * 0.34))) : 0;
148
+ const leftWidth = wideMeta ? Math.max(32, inner - rightWidth - visibleWidth(gap)) : inner;
149
+ const grand = leftWidth >= grandWidth;
99
150
  const artLines = renderForgeMark({
100
151
  color: useColor,
101
152
  phase: d.phase ?? 0,
102
153
  unicode,
103
154
  colorLevel,
104
155
  grand,
105
- cols: inner,
156
+ cols: leftWidth,
106
157
  });
107
158
 
108
- // Single centered hero column (gjc forge layout): breathing room, brand,
109
- // tagline, the symbol, then the model/provider pills.
110
- const content: string[] = [];
111
- content.push("");
112
- content.push(useColor ? chalk.bold.cyan("Jeo forge") : "Jeo forge");
113
- content.push(useColor ? chalk.dim("evolve · act · prove") : "evolve · act · prove");
114
- content.push("");
115
- for (const line of artLines) content.push(line);
116
- content.push("");
117
-
118
- const modelIcon = unicode ? "◆" : "*";
119
- const modelPill = truncate(`[ ${modelIcon} ${d.model} ]`, inner);
120
- content.push(useColor ? chalk.cyan(modelPill) : modelPill);
121
- if (d.provider) {
122
- const providerIcon = unicode ? "◇" : "o";
123
- const providerPill = truncate(`[ ${providerIcon} ${d.provider} ]`, inner);
124
- content.push(useColor ? chalk.blue(providerPill) : providerPill);
159
+ const pill = (icon: string, text: string, paint: (s: string) => string): string => {
160
+ const p = truncate(`[ ${icon} ${text} ]`, leftWidth);
161
+ return useColor ? paint(p) : p;
162
+ };
163
+ const leftContent: string[] = [];
164
+ leftContent.push("");
165
+ leftContent.push(useColor ? chalk.bold.cyan("Jeo forge") : "Jeo forge");
166
+ leftContent.push(useColor ? chalk.dim("evolve · act · prove") : "evolve · act · prove");
167
+ leftContent.push("");
168
+ leftContent.push(...artLines);
169
+ leftContent.push("");
170
+ leftContent.push(pill(unicode ? "◆" : "*", d.model, chalk.cyan));
171
+ if (d.provider) leftContent.push(pill(unicode ? "◇" : "o", d.provider, chalk.blue));
172
+ leftContent.push("");
173
+
174
+ let content: string[];
175
+ if (wideMeta) {
176
+ const tableTitle = useColor ? chalk.bold("workspace") : "workspace";
177
+ const rightContent = [
178
+ "",
179
+ padLine(tableTitle, rightWidth, "center"),
180
+ (useColor ? chalk.dim : (s: string) => s)(padLine("forge context", rightWidth, "center")),
181
+ "",
182
+ ...metaRows(d, rightWidth, unicode, useColor),
183
+ "",
184
+ ];
185
+ content = zipColumns(centerColumn(leftContent, leftWidth), rightContent, leftWidth, gap, rightWidth);
186
+ } else {
187
+ content = centerColumn(leftContent, inner);
125
188
  }
126
- content.push("");
127
189
 
128
190
  const leftBorder = lit(g.v);
129
191
  const rightBorder = shadow(g.v);
130
- const finalContentLines = content.map(raw => {
131
- const line = padLine(truncate(raw, inner), inner, "center");
132
- return leftBorder + line + rightBorder;
133
- });
192
+ const finalContentLines = content.map(raw => leftBorder + padLine(truncate(raw, inner), inner) + rightBorder);
134
193
 
135
194
  return [topBorderLine, ...finalContentLines, bottomBorderLine];
136
195
  }
@@ -130,8 +130,10 @@ export function enableKittyKeyboard(): string {
130
130
  return `${ESC}>1u`;
131
131
  }
132
132
 
133
- /** Reset the kitty keyboard protocol flags to 0 (disabled) on exit, the counterpart to
134
- * {@link enableKittyKeyboard}. */
133
+ /** Undo {@link enableKittyKeyboard} on exit. `CSI > 1 u` PUSHES an entry onto the
134
+ * terminal's keyboard-mode stack, so the counterpart is a POP (`CSI < u`) — pushing
135
+ * `>0u` instead would leak our entry and leave flags-0 stacked on top of whatever
136
+ * the shell/tmux had. Terminals without kitty-protocol support ignore both. */
135
137
  export function disableKittyKeyboard(): string {
136
- return `${ESC}>0u`;
138
+ return `${ESC}<u`;
137
139
  }
@@ -4,7 +4,10 @@
4
4
  * (carrying `.status` and a body), and the agent loop surfaces failures both as a
5
5
  * thrown error and as a `doneReason`, so this lives in a shared util used by both.
6
6
  */
7
- import { isUsageLimitError } from "./retry";
7
+ import { isUsageLimitError, isRefusalError } from "./retry";
8
+ // Re-export: engine.ts and callers import the refusal predicate from here; the
9
+ // definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
10
+ export { isRefusalError } from "./retry";
8
11
 
9
12
  /** Provider-authoritative context-overflow signal (HTTP 400/413 family). The
10
13
  * local token estimate can drift under the real count (images, tokenizer
@@ -18,17 +21,6 @@ export function isContextOverflowError(err: unknown): boolean {
18
21
  return status === 413; // payload-too-large is always an overflow signal
19
22
  }
20
23
 
21
- /** Provider safety-refusal signal: an HTTP-200 completion that returned NO
22
- * content because the model/provider declined (Anthropic `stop_reason=refusal`,
23
- * OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
24
- * block reasons). On routine coding work these are usually transient
25
- * false-positives — the engine retries the step (bounded) instead of killing
26
- * the turn with a dead "Error: … returned no content". */
27
- export function isRefusalError(err: unknown): boolean {
28
- const msg = (err as Error)?.message ?? String(err);
29
- return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(msg);
30
- }
31
-
32
24
  function formatDuration(ms: number): string {
33
25
  const totalSeconds = Math.ceil(ms / 1000);
34
26
  if (totalSeconds < 60) return `${totalSeconds}s`;
package/src/util/retry.ts CHANGED
@@ -7,8 +7,14 @@ export interface RetryOptions {
7
7
  random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
8
8
  /** Notified before each backoff wait; `delayMs` is the wait actually applied. */
9
9
  onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
10
+ /** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
11
+ * aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
12
+ * for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
13
+ * longer capped by default). Does NOT cancel the in-flight `fn()` call itself; thread the
14
+ * same signal into the request (e.g. `fetch`) for that. */
15
+ signal?: AbortSignal;
10
16
  /** Minimum backoff (ms) applied specifically to rate-limit (429) errors. The floor
11
- * ESCALATES per attempt (floor × 2^(attempt-1), capped at RETRY_AFTER_CAP_MS) because a
17
+ * ESCALATES per attempt (floor × 2^(attempt-1), capped at RATE_LIMIT_FLOOR_CAP_MS) because a
12
18
  * rate-limit window rarely clears in <1s and often needs tens of seconds — a flat
13
19
  * sub-second retry cadence just burns the budget; default 0 (no floor) preserves
14
20
  * generic behavior. */
@@ -17,9 +23,13 @@ export interface RetryOptions {
17
23
  * When higher than `retries`, rate-limit errors get extra attempts so a transient
18
24
  * per-minute window can reset; non-rate-limit errors still use `retries`. */
19
25
  rateLimitRetries?: number;
20
- /** If a 429 carries a server-directed retry delay above this budget, fail fast
21
- * instead of capping the wait and replaying a request the server already said
22
- * cannot succeed soon. */
26
+ /** Opt-in ceiling on a 429's server-directed retry delay: set it and a delay beyond it fails
27
+ * fast with the original error instead of sleeping. Unset (the default) honors ANY
28
+ * server-directed delay in full, however long — gjc parity: a generic rate limit is retried
29
+ * forever, not treated as fatal past some arbitrary budget. Usage/quota-limit errors
30
+ * (`isUsageLimitError`) are unaffected either way: `defaultRetryable` already classifies
31
+ * them non-retryable before this wait is ever computed. Pair with `signal` so an unbounded
32
+ * wait stays user-cancellable. */
23
33
  rateLimitMaxServerDelayMs?: number;
24
34
  }
25
35
 
@@ -38,6 +48,14 @@ export function defaultRetryable(err: unknown): boolean {
38
48
  if (isUsageLimitError(err)) {
39
49
  return false;
40
50
  }
51
+ // Safety refusals are DETERMINISTIC for identical conversation content: the
52
+ // classifier trips on what's IN the context, so a transport-level resend of the
53
+ // same payload re-refuses every time (observed: each engine ladder rung burned
54
+ // 2 extra billed calls before this check). Fail fast — the engine's refusal
55
+ // ladder owns recovery because it mutates the context between attempts.
56
+ if (isRefusalError(err)) {
57
+ return false;
58
+ }
41
59
 
42
60
  let message = "";
43
61
  if (err instanceof Error) {
@@ -94,37 +112,45 @@ export function defaultRetryable(err: unknown): boolean {
94
112
  return false;
95
113
  }
96
114
 
97
- // Server-directed `Retry-After` is honored but capped so a CLI never hangs on a hostile header.
98
- const RETRY_AFTER_CAP_MS = 30_000;
99
- // Long account/subscription windows should be reported, not silently compressed to 30s.
115
+ // Synthetic per-minute floor for a 429 that carries NO server `Retry-After` (see
116
+ // `rateLimitMinDelayMs`) — caps the escalating floor so a SILENT rate limit still waits a
117
+ // realistic window instead of ~8s. Does NOT cap a genuine server-directed Retry-After: a
118
+ // provider that names its own wait is authoritative and is honored in full (gjc parity);
119
+ // `rateLimitMaxServerDelayMs` is the opt-in ceiling for a caller that truly cannot wait that long.
120
+ const RATE_LIMIT_FLOOR_CAP_MS = 30_000;
100
121
 
101
122
  // Run fn; on a retryable error, back off and retry up to `retries` attempts. Backoff is
102
- // exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands in
103
- // [0.5x, 1x] of that cap), unless the error carries a `retryAfterMs` (server `Retry-After`), which
104
- // takes precedence (capped at RETRY_AFTER_CAP_MS). A 429 with a server delay beyond
105
- // `rateLimitMaxServerDelayMs` is not retried because the provider has identified a
106
- // long account-wide window; callers can surface the reset time instead of hanging.
123
+ // exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands
124
+ // in [0.5x, 1x] of that cap). A server-directed `retryAfterMs` (Retry-After) wins over the
125
+ // jitter and is honored IN FULL — no cap — unless `rateLimitMaxServerDelayMs` is explicitly set
126
+ // and exceeded, in which case the original error is re-thrown immediately instead of sleeping.
127
+ // `opts.signal` can abort an in-progress wait, so an unbounded honored delay stays cancellable.
107
128
  export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T> {
108
129
  const retries = opts?.retries ?? 3;
109
130
  const baseDelayMs = opts?.baseDelayMs ?? 250;
110
131
  const maxDelayMs = opts?.maxDelayMs ?? 4000;
111
132
  const isRetryable = opts?.isRetryable ?? defaultRetryable;
112
- const sleep = opts?.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
133
+ const sleep = opts?.sleep ?? ((ms: number) => {
134
+ const { promise, resolve } = Promise.withResolvers<void>();
135
+ setTimeout(resolve, ms);
136
+ return promise;
137
+ });
113
138
  const random = opts?.random ?? Math.random;
114
139
  const onRetry = opts?.onRetry;
115
140
  const rateLimitMinDelayMs = opts?.rateLimitMinDelayMs ?? 0;
116
141
  const rateLimitRetries = opts?.rateLimitRetries;
117
142
  const rateLimitMaxServerDelayMs = opts?.rateLimitMaxServerDelayMs;
143
+ const signal = opts?.signal;
118
144
 
119
145
  let attempt = 1;
120
146
  while (true) {
121
147
  try {
122
148
  return await fn();
123
149
  } catch (err) {
124
- // Rate-limit (429) errors may use a higher attempt cap so a transient
125
- // per-minute window can reset before we surface the failure. If the server
126
- // says the window is much longer than our retry budget, fail fast with the
127
- // original error so the UI can show the real reset delay.
150
+ // Rate-limit (429) errors may use a higher attempt cap so a transient per-minute window
151
+ // can reset before we surface the failure. `rateLimitMaxServerDelayMs` is opt-in
152
+ // (unset by default): only when explicitly configured and the server's own wait exceeds
153
+ // it do we fail fast instead of sleeping through a window the caller declared too long.
128
154
  const rateLimited = isRateLimitError(err);
129
155
  const serverDelay = retryAfterOf(err);
130
156
  if (
@@ -143,15 +169,15 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
143
169
  const capped = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
144
170
  // Equal jitter: half fixed + half random → [0.5x, 1x] of the capped backoff.
145
171
  const jittered = capped / 2 + random() * (capped / 2);
146
- // Server `Retry-After` wins (capped); else jitter. For rate limits, apply the
147
- // floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
148
- // burn the 429 budget back-to-back with no real pause. The floor escalates per
149
- // attempt (×2 each retry, capped) so the total wait across the 429 budget spans
150
- // a realistic rate-limit window (~a minute) instead of ~8s.
151
- const cappedServer = serverDelay !== undefined ? Math.min(serverDelay, RETRY_AFTER_CAP_MS) : undefined;
152
- const base = cappedServer !== undefined ? cappedServer : jittered;
172
+ // Server `Retry-After` wins, honored IN FULL (no cap — gjc parity: even a multi-hour
173
+ // provider-directed window is retried, never silently compressed). For rate limits,
174
+ // apply the floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
175
+ // burn the 429 budget back-to-back with no real pause. The floor escalates per attempt
176
+ // (×2 each retry, capped at RATE_LIMIT_FLOOR_CAP_MS) so a SYNTHETIC (server-silent) wait
177
+ // spans a realistic rate-limit window (~a minute) instead of ~8s.
178
+ const base = serverDelay !== undefined ? serverDelay : jittered;
153
179
  const floor = rateLimited
154
- ? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1), RETRY_AFTER_CAP_MS)
180
+ ? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1), RATE_LIMIT_FLOOR_CAP_MS)
155
181
  : 0;
156
182
  const delay = Math.max(base, floor);
157
183
 
@@ -159,12 +185,29 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
159
185
  onRetry(attempt, err, delay);
160
186
  }
161
187
 
162
- await sleep(delay);
188
+ await abortableSleep(sleep, delay, signal);
163
189
  attempt++;
164
190
  }
165
191
  }
166
192
  }
167
193
 
194
+ /** Race `sleep(ms)` against `signal`; an already-aborted or mid-wait-aborted signal rejects
195
+ * immediately with its abort reason instead of completing the wait. Keeps a long, honored
196
+ * server-directed retry delay (see `withRetry`) user-cancellable even though it is no longer
197
+ * capped. A no-op passthrough when no signal is supplied (existing callers/tests unaffected). */
198
+ function abortableSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
199
+ if (!signal) return sleep(ms);
200
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
201
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
202
+ const onAbort = () => reject(signal.reason ?? new Error("aborted"));
203
+ signal.addEventListener("abort", onAbort, { once: true });
204
+ sleep(ms).then(
205
+ value => { signal.removeEventListener("abort", onAbort); resolve(value); },
206
+ err => { signal.removeEventListener("abort", onAbort); reject(err); },
207
+ );
208
+ return promise;
209
+ }
210
+
168
211
  // Extract a non-negative `retryAfterMs` from an error, if it carries one (e.g. ProviderHttpError).
169
212
  function retryAfterOf(err: unknown): number | undefined {
170
213
  if (typeof err === "object" && err !== null) {
@@ -197,12 +240,26 @@ function errorMessageOf(err: unknown): string {
197
240
 
198
241
  /**
199
242
  * Persistent usage/quota-limit detection (gjc parity: `isUsageLimitError`). These are
200
- * subscription/window limits ("usage limit reached", "quota exceeded") that need a model
201
- * or credential switch — retrying within seconds is pure waste, unlike a per-minute 429.
202
- * Deliberately excludes the ambiguous "resource exhausted" (Gemini uses it for windows
203
- * that often DO clear within a retry budget).
243
+ * subscription/window limits ("usage limit reached", "quota exceeded", model/message
244
+ * window limits, credit exhaustion, Anthropic ACCOUNT-level exhaustion) that need a
245
+ * model or credential switch — retrying within seconds is pure waste, unlike a
246
+ * per-minute 429. Bare "resource exhausted" (gRPC capacity wording) stays transient;
247
+ * only the qualified "Resource has been exhausted (… quota/limit)" form is persistent
248
+ * (gjc fixture parity: rate-limit-utils.test.ts).
204
249
  */
205
- const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|exceeded your/i;
250
+ const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|model.?limit|message.?limit|limit for this model|quota.?exceeded|out_of_credits|request would exceed your account.?s rate limit|resource has been exhausted[^\n]*(?:quota|limit)|exceeded your/i;
206
251
  export function isUsageLimitError(err: unknown): boolean {
207
252
  return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
208
253
  }
254
+
255
+ /** Provider safety-refusal signal: an HTTP-200 completion that returned NO
256
+ * content because the model/provider declined (Anthropic `stop_reason=refusal`,
257
+ * OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
258
+ * block reasons). Lives here (not provider-error.ts) so `defaultRetryable` can
259
+ * fail fast on it: a refusal is DETERMINISTIC for the same conversation content —
260
+ * 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. */
263
+ export function isRefusalError(err: unknown): boolean {
264
+ return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(errorMessageOf(err));
265
+ }