jeo-code 0.8.21 → 0.8.23

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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `jeo routine init` — generate a GitHub Actions workflow that runs
3
+ * `jeo "<prompt>" -p` headlessly on a schedule or repo event.
4
+ *
5
+ * This is the SCOPED-SAFE take on "Routines": jeo-code itself never runs a
6
+ * scheduler, listens on a port, or accepts a webhook — it's a pure template
7
+ * generator. GitHub's own hosted runners do the actual triggering/running,
8
+ * which achieves "runs without your laptop" with zero new attack surface
9
+ * inside jeo-code's own process. See ../util/routine-template.ts for the
10
+ * pure YAML-building logic this command wraps with flag parsing + I/O.
11
+ */
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { renderRoutineWorkflow, validateCron, type RoutineConfig, type RoutineTrigger } from "../util/routine-template";
15
+ import { slugify } from "../agent/memory-okf";
16
+
17
+ const TRIGGERS: readonly RoutineTrigger[] = ["schedule", "issues", "pull_request"];
18
+ function isTrigger(val: string | undefined): val is RoutineTrigger {
19
+ return val !== undefined && (TRIGGERS as readonly string[]).includes(val);
20
+ }
21
+
22
+ const VALUE_FLAGS: Record<string, true> = { "--name": true, "--trigger": true, "--cron": true, "--prompt": true, "--api-key-env": true, "--out": true };
23
+
24
+ interface ParsedInit {
25
+ name: string;
26
+ triggerRaw?: string;
27
+ cron?: string;
28
+ prompt?: string;
29
+ apiKeyEnvVar: string;
30
+ openPr: boolean;
31
+ out?: string;
32
+ force: boolean;
33
+ dryRun: boolean;
34
+ json: boolean;
35
+ help: boolean;
36
+ /** Parse-level problems: unknown flags, or a value-flag given with no value. */
37
+ errors: string[];
38
+ }
39
+
40
+ /** `--flag value` or `--flag=value`, matching launch/flags.ts's takeValue shape
41
+ * (a bare value-flag followed by another `-`-prefixed token is treated as
42
+ * missing, not as accidentally swallowing the next flag). */
43
+ function takeValue(args: string[], index: number, flag: string): { value?: string; nextIndex: number } {
44
+ const current = args[index]!;
45
+ const inline = `${flag}=`;
46
+ if (current.startsWith(inline)) return { value: current.slice(inline.length), nextIndex: index };
47
+ const next = args[index + 1];
48
+ if (next !== undefined && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
49
+ return { nextIndex: index };
50
+ }
51
+
52
+ function parseInitArgs(args: string[]): ParsedInit {
53
+ const parsed: ParsedInit = {
54
+ name: "jeo routine",
55
+ apiKeyEnvVar: "ANTHROPIC_API_KEY",
56
+ openPr: true,
57
+ force: false,
58
+ dryRun: false,
59
+ json: false,
60
+ help: false,
61
+ errors: [],
62
+ };
63
+
64
+ for (let i = 0; i < args.length; i++) {
65
+ const a = args[i]!;
66
+ const flag = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
67
+
68
+ if (flag === "-h" || flag === "--help") { parsed.help = true; continue; }
69
+ if (flag === "--no-pr") { parsed.openPr = false; continue; }
70
+ if (flag === "--force") { parsed.force = true; continue; }
71
+ if (flag === "--dry-run") { parsed.dryRun = true; continue; }
72
+ if (flag === "--json") { parsed.json = true; continue; }
73
+
74
+ if (VALUE_FLAGS[flag]) {
75
+ const { value, nextIndex } = takeValue(args, i, flag);
76
+ i = nextIndex;
77
+ if (value === undefined) {
78
+ parsed.errors.push(`${flag} requires a value`);
79
+ continue;
80
+ }
81
+ switch (flag) {
82
+ case "--name": parsed.name = value; break;
83
+ case "--trigger": parsed.triggerRaw = value; break;
84
+ case "--cron": parsed.cron = value; break;
85
+ case "--prompt": parsed.prompt = value; break;
86
+ case "--api-key-env": parsed.apiKeyEnvVar = value; break;
87
+ case "--out": parsed.out = value; break;
88
+ }
89
+ continue;
90
+ }
91
+
92
+ parsed.errors.push(`Unknown flag: ${a}`);
93
+ }
94
+
95
+ return parsed;
96
+ }
97
+
98
+ function printUsage(): void {
99
+ console.log("Usage: jeo routine init [options]");
100
+ console.log("");
101
+ console.log('Generate a GitHub Actions workflow that runs `jeo "<prompt>" -p` headlessly');
102
+ console.log("on a schedule or repo event. GitHub's own hosted runners do the triggering —");
103
+ console.log("jeo-code never runs a scheduler or listens on a port for this.");
104
+ console.log("");
105
+ console.log("Options:");
106
+ console.log(" --trigger <schedule|issues|pull_request> Required. What fires the routine.");
107
+ console.log(" --prompt <text> Required. The task/goal text passed to `jeo -p`.");
108
+ console.log(' --cron <expr> Required when --trigger schedule (e.g. "0 7 * * *").');
109
+ console.log(' --name <name> Workflow display name. Default: "jeo routine".');
110
+ console.log(" --api-key-env <VAR_NAME> Secret env var name. Default: ANTHROPIC_API_KEY.");
111
+ console.log(" --no-pr Commit directly to the branch instead of opening a PR.");
112
+ console.log(" --out <path> Output path. Default: .github/workflows/jeo-routine-<slug>.yml");
113
+ console.log(" --force Overwrite an existing file at --out.");
114
+ console.log(" --dry-run Print the rendered YAML instead of writing it.");
115
+ console.log(" --json Machine-readable output.");
116
+ console.log(" -h, --help Show this help message.");
117
+ }
118
+
119
+ async function runInit(args: string[]): Promise<void> {
120
+ const parsed = parseInitArgs(args);
121
+
122
+ if (parsed.errors.length > 0) {
123
+ for (const e of parsed.errors) console.log(`Error: ${e}`);
124
+ printUsage();
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+
129
+ if (parsed.help) {
130
+ printUsage();
131
+ return;
132
+ }
133
+
134
+ const problems: string[] = [];
135
+ if (!isTrigger(parsed.triggerRaw)) {
136
+ problems.push(
137
+ parsed.triggerRaw
138
+ ? `--trigger must be one of: schedule, issues, pull_request (got '${parsed.triggerRaw}')`
139
+ : "--trigger is required (one of: schedule, issues, pull_request)",
140
+ );
141
+ }
142
+ if (!parsed.prompt) {
143
+ problems.push("--prompt is required");
144
+ }
145
+ const trigger = isTrigger(parsed.triggerRaw) ? parsed.triggerRaw : undefined;
146
+ if (trigger === "schedule") {
147
+ if (!parsed.cron) {
148
+ problems.push("--cron is required when --trigger schedule");
149
+ } else if (!validateCron(parsed.cron)) {
150
+ problems.push(`--cron '${parsed.cron}' does not look like a valid 5-field cron expression`);
151
+ }
152
+ }
153
+
154
+ if (problems.length > 0) {
155
+ for (const p of problems) console.log(`Error: ${p}`);
156
+ printUsage();
157
+ process.exitCode = 1;
158
+ return;
159
+ }
160
+
161
+ const config: RoutineConfig = {
162
+ name: parsed.name,
163
+ trigger: trigger!,
164
+ cron: parsed.cron,
165
+ prompt: parsed.prompt!,
166
+ apiKeyEnvVar: parsed.apiKeyEnvVar,
167
+ openPr: parsed.openPr,
168
+ };
169
+
170
+ let yaml: string;
171
+ try {
172
+ yaml = renderRoutineWorkflow(config);
173
+ } catch (err) {
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ if (parsed.json) console.log(JSON.stringify({ error: msg }));
176
+ else console.log(`Error: ${msg}`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+
181
+ const outPath = parsed.out ?? path.join(".github", "workflows", `jeo-routine-${slugify(parsed.name)}.yml`);
182
+ const resolvedOut = path.resolve(process.cwd(), outPath);
183
+
184
+ if (parsed.dryRun) {
185
+ if (parsed.json) console.log(JSON.stringify({ dryRun: true, out: resolvedOut, yaml }, null, 2));
186
+ else console.log(yaml);
187
+ return;
188
+ }
189
+
190
+ let exists = true;
191
+ try {
192
+ await fs.access(resolvedOut);
193
+ } catch {
194
+ exists = false;
195
+ }
196
+ if (exists && !parsed.force) {
197
+ const msg = `${resolvedOut} already exists. Use --force to overwrite.`;
198
+ if (parsed.json) console.log(JSON.stringify({ error: msg }));
199
+ else console.log(`Error: ${msg}`);
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+
204
+ await fs.mkdir(path.dirname(resolvedOut), { recursive: true });
205
+ // Atomic write: build the file fully off to the side, then rename into
206
+ // place, so a crash mid-write never leaves a truncated workflow file.
207
+ const tmpPath = `${resolvedOut}.tmp-${process.pid}`;
208
+ await fs.writeFile(tmpPath, yaml, "utf-8");
209
+ await fs.rename(tmpPath, resolvedOut);
210
+
211
+ if (parsed.json) {
212
+ console.log(JSON.stringify({ wrote: resolvedOut, trigger, apiKeyEnvVar: parsed.apiKeyEnvVar, openPr: parsed.openPr }, null, 2));
213
+ return;
214
+ }
215
+
216
+ console.log(`Wrote ${resolvedOut}`);
217
+ console.log("");
218
+ console.log("Next steps:");
219
+ console.log(` - Add '${parsed.apiKeyEnvVar}' as a repo secret: Settings -> Secrets and variables -> Actions.`);
220
+ if (trigger === "schedule") {
221
+ console.log(" - Scheduled workflows only run on the repo's default branch, and GitHub may");
222
+ console.log(" delay or skip scheduled runs on low-activity repos — this is documented");
223
+ console.log(" GitHub Actions behavior, not a bug in the generated workflow.");
224
+ }
225
+ console.log(` - Commit and push ${resolvedOut} to enable it.`);
226
+ }
227
+
228
+ export async function runRoutineCommand(args: string[]): Promise<void> {
229
+ const sub = args[0];
230
+
231
+ if (sub === "-h" || sub === "--help") {
232
+ printUsage();
233
+ return;
234
+ }
235
+
236
+ if (sub === undefined) {
237
+ printUsage();
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+
242
+ if (sub !== "init") {
243
+ console.log(`Unknown 'jeo routine' subcommand '${sub}'. Usage: jeo routine init [options]`);
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+
248
+ await runInit(args.slice(1));
249
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
4
4
  import { getLocalJeoDir } from "../agent/state";
5
+ import { appendSkillLesson, evalSkillLessons, type SkillLesson } from "../agent/skill-lessons";
5
6
 
6
7
  export { userSkillsDir };
7
8
 
@@ -147,6 +148,62 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
147
148
  return;
148
149
  }
149
150
 
151
+ // `jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"` — manual
152
+ // lesson entry into the project-level skill file (the same compounding write the
153
+ // launch.ts stall-guard fires automatically; lets an agent or human record one directly).
154
+ if (cleanArgs[0] === "lesson") {
155
+ const [, skillName, kindArg, title, detail] = cleanArgs;
156
+ if (!skillName || !kindArg || !title || !detail) {
157
+ console.log('Usage: jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"');
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ const kind: SkillLesson["kind"] | null =
162
+ kindArg === "failure" ? "failure-mode" : kindArg === "anti-pattern" ? "anti-pattern" : null;
163
+ if (!kind) {
164
+ console.log(`Error: kind must be 'failure' or 'anti-pattern' (got '${kindArg}').`);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+ const result = await appendSkillLesson(cwd, { skill: skillName, kind, title, detail });
169
+ if (isJson) {
170
+ console.log(JSON.stringify(result, null, 2));
171
+ } else if (result.appended) {
172
+ console.log("Appended.");
173
+ } else if (result.reason === "duplicate") {
174
+ console.log("Already recorded (duplicate).");
175
+ } else {
176
+ console.log(`Error: ${result.reason ?? "unknown error"}`);
177
+ process.exitCode = 1;
178
+ }
179
+ return;
180
+ }
181
+
182
+ // `jeo skills eval <skill>` — self-eval half: judge whether recorded lessons are
183
+ // still covered by the skill's current guidance, or have gone stale.
184
+ if (cleanArgs[0] === "eval") {
185
+ const skillName = cleanArgs[1];
186
+ if (!skillName) {
187
+ console.log("Error: Missing skill name for 'eval' command.");
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ const result = await evalSkillLessons(cwd, skillName);
192
+ if (result === null) {
193
+ console.log(`No recorded lessons for '${skillName}' yet.`);
194
+ return;
195
+ }
196
+ if (isJson) {
197
+ console.log(JSON.stringify(result, null, 2));
198
+ } else {
199
+ console.log(`${result.covered}/${result.total} lessons still covered by current guidance.`);
200
+ for (const s of result.stale) {
201
+ console.log(` STALE ${s.title}: ${s.reason}`);
202
+ }
203
+ }
204
+ return;
205
+ }
206
+
150
207
  // List/lookup over the MERGED set (bundled + ~/.jeo/skills + ~/.agents/skills + project dirs), matching the REPL /skill.
151
208
  const skills = await loadSkills(cwd);
152
209
  const command = cleanArgs[0];
package/src/tui/app.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { Renderer } from "./renderer";
13
13
  import { readWorkflowStateStrict } from "../agent/state";
14
- import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen } from "./terminal";
14
+ import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen, watchResize } from "./terminal";
15
+
15
16
  import { Spinner } from "./components/spinner";
16
17
  import { ToolList } from "./components/tool-list";
17
18
  import { StreamRegion } from "./components/stream";
@@ -251,6 +252,10 @@ export class LaunchTui {
251
252
  private lastResizeAt = 0;
252
253
  private lastCols = -1;
253
254
  private lastRows = -1;
255
+ // Stops the poll-based resize safety net (terminal.ts's watchResize) started in
256
+ // begin(); cleared in finish() alongside the 'resize'/SIGCONT listeners.
257
+ private stopResizeWatch: (() => void) | undefined;
258
+
254
259
  private pendingIndex: number | null = null;
255
260
  private pendingTitle: string | null = null;
256
261
  private pendingForge: ForgeSummary | null = null;
@@ -1172,7 +1177,15 @@ export class LaunchTui {
1172
1177
  if (processMax > 0 && resumeCount + 1 >= processMax) process.setMaxListeners(resumeCount + 2);
1173
1178
  process.on("SIGCONT", this.onResume);
1174
1179
  }
1180
+ // Poll-based safety net (terminal.ts's watchResize): catches a resize the TTY
1181
+ // 'resize' event missed (e.g. a tmux pane switch while this pane wasn't the
1182
+ // foreground one — tmux only forwards SIGWINCH to the active pane). Cheap
1183
+ // (300ms comparison poll), fires resizeRepaint() only on an actual geometry
1184
+ // change, and is a no-op alongside a real 'resize' event (whichever notices
1185
+ // first wins; lastCols/lastRows dedupe the other).
1186
+ this.stopResizeWatch = watchResize(() => this.resizeRepaint());
1175
1187
  }
1188
+
1176
1189
  // Animate the spinner + elapsed clock while the model is thinking.
1177
1190
  this.timer = setInterval(() => {
1178
1191
  try {
@@ -1269,7 +1282,12 @@ export class LaunchTui {
1269
1282
  if (process.platform !== "win32") {
1270
1283
  process.removeListener("SIGCONT", this.onResume);
1271
1284
  }
1285
+ if (this.stopResizeWatch) {
1286
+ this.stopResizeWatch();
1287
+ this.stopResizeWatch = undefined;
1288
+ }
1272
1289
  }
1290
+
1273
1291
  if (this.usedAltScreen) {
1274
1292
  // Leave the alt screen (restores the main buffer + scrollback), then print the
1275
1293
  // static summary below the prior output so the turn leaves exactly one record.
@@ -84,6 +84,75 @@ export function size(): { cols: number; rows: number } {
84
84
  rows: process.stdout.rows || 24,
85
85
  };
86
86
  }
87
+ /** True live geometry via `getWindowSize()` — a real `TIOCGWINSZ` ioctl through libuv,
88
+ * bypassing `process.stdout.columns`/`.rows` entirely. Node/Bun only refresh THOSE
89
+ * cached fields when a `'resize'` (SIGWINCH) event fires, which is exactly the signal
90
+ * {@link watchResize} exists to work around (tmux pane/window switch while jeo's pane
91
+ * isn't foregrounded, a SIGCONT race after Ctrl-Z, an SSH/multiplexer layer that only
92
+ * propagates the ioctl update on the next write) — in every one of those, the cached
93
+ * fields go stale and NOTHING re-freshes them without a real event. Returns `null` when
94
+ * unavailable (older runtimes, non-TTY streams — Bun/Node only expose `getWindowSize`
95
+ * on a real `tty.WriteStream`), it throws (handle torn down), or it reports `0,0` (no
96
+ * controlling terminal ever set a winsize, e.g. a bare pty as `bun test` sees). */
97
+ function liveWindowSize(): { cols: number; rows: number } | null {
98
+ const stream = process.stdout as NodeJS.WriteStream & { getWindowSize?: () => [number, number] };
99
+ if (typeof stream.getWindowSize !== "function") return null;
100
+ try {
101
+ const [cols, rows] = stream.getWindowSize();
102
+ return cols > 0 && rows > 0 ? { cols, rows } : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Poll-based resize watcher — a SAFETY NET alongside the TTY `'resize'` event.
109
+ * `'resize'` (SIGWINCH) is missed in several real-world cases jeo actually hits: a
110
+ * tmux pane/window switch while jeo's pane isn't the foreground one (tmux only
111
+ * forwards SIGWINCH to the active pane), a SIGCONT race after Ctrl-Z where the resize
112
+ * happened entirely during the stop, and some SSH/multiplexer layers that only
113
+ * propagate the ioctl update on the next write rather than emitting a fresh event.
114
+ * In every one of these, `process.stdout.columns`/`.rows` (and therefore `size()`)
115
+ * go stale and NOTHING short of a real `'resize'` event ever refreshes them — so a
116
+ * naive poll comparing `size()` against itself can never observe a change that
117
+ * already happened, it would just keep comparing one stale cache to itself.
118
+ *
119
+ * Each tick instead asks the OS DIRECTLY via {@link liveWindowSize} (a cheap,
120
+ * allocation-free ioctl — safe every `intervalMs`). When that live read disagrees
121
+ * with the last-seen geometry, this SELF-HEALS the stale cache — writing the
122
+ * corrected values onto `process.stdout.columns`/`.rows` exactly like Node's own
123
+ * SIGWINCH handler would — before firing `onChange`, so every other reader of
124
+ * `size()` (the live-frame `draw()`, `resizeRepaint()`, `idleResizeHandler()`, a
125
+ * picker's `repaint()`) sees the corrected geometry too, not just this callback.
126
+ * When `getWindowSize` is unavailable, falls back to comparing `size()` against
127
+ * itself (the pre-existing behavior) — still catches a genuine cache update that
128
+ * arrived via some OTHER path (e.g. a real `'resize'` event firing between polls).
129
+ * Never fires spuriously — only on an actual geometry change — so it composes
130
+ * safely alongside an existing `'resize'` listener (whichever notices first wins,
131
+ * the other becomes a no-op once both routes converge on the same geometry).
132
+ * Returns a `stop()` to clear the interval; the timer is `unref()`d so it can never
133
+ * keep the process alive on its own. */
134
+ export function watchResize(onChange: (cols: number, rows: number) => void, intervalMs = 300): () => void {
135
+ let last = size();
136
+ const timer = setInterval(() => {
137
+ const live = liveWindowSize();
138
+ const cur = live ?? size();
139
+ if (cur.cols !== last.cols || cur.rows !== last.rows) {
140
+ last = cur;
141
+ if (live) {
142
+ try {
143
+ process.stdout.columns = live.cols;
144
+ process.stdout.rows = live.rows;
145
+ } catch { /* non-writable in this environment — onChange still fires below */ }
146
+ }
147
+ onChange(cur.cols, cur.rows);
148
+ }
149
+ }, intervalMs);
150
+ if (typeof (timer as unknown as { unref?: () => void }).unref === "function") {
151
+ (timer as unknown as { unref: () => void }).unref();
152
+ }
153
+ return () => clearInterval(timer);
154
+ }
155
+
87
156
 
88
157
  export function isTTY(): boolean {
89
158
  return !!process.stdout.isTTY;
@@ -137,3 +206,23 @@ export function enableKittyKeyboard(): string {
137
206
  export function disableKittyKeyboard(): string {
138
207
  return `${ESC}<u`;
139
208
  }
209
+ /** Set the terminal's TEXT CURSOR color via OSC 12 (`\x1b]12;#rrggbb\x07`) — widely
210
+ * supported (xterm, iTerm2, kitty, wezterm, alacritty, ghostty, foot, most tmux/VTE
211
+ * terminals). jeo's input box parks the REAL terminal cursor at the caret (see
212
+ * `input-box.ts`'s `cursorRow`/`cursorCol`) rather than drawing a fake one, so the
213
+ * cursor's on-screen color is entirely up to the terminal's default — usually a
214
+ * solid white/light block. On every theme except `mono` (whose panels/prompt already
215
+ * render colorless), that default cursor color visually MATCHES the light card
216
+ * backgrounds used elsewhere in the UI (e.g. `cardFillPaint`), making the caret
217
+ * nearly impossible to spot against typed text. Setting it to the theme's accent hue
218
+ * gives the caret a color that's always distinct from body text and panel fills. */
219
+ export function setCursorColor(hex: string): string {
220
+ return `\x1b]12;${hex}\x07`;
221
+ }
222
+
223
+ /** Restore the terminal's default cursor color (OSC 112, no color id) — the
224
+ * counterpart to {@link setCursorColor}, sent on exit so a shell/editor started
225
+ * afterward isn't left with jeo's accent-colored cursor. */
226
+ export function resetCursorColor(): string {
227
+ return `\x1b]112\x07`;
228
+ }
@@ -117,6 +117,21 @@ export function friendlyProviderError(err: unknown): string {
117
117
  if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
118
118
  return `${provider} rejected the credential (HTTP ${status ?? "401/403"}). Run 'jeo auth status', re-login with /provider login <name>, and for Antigravity prefer '/provider login antigravity' (gemini login only works when the Cloud Code Assist backend authorizes that token).`;
119
119
  }
120
+ // A persistent 5xx (server-side fault on the provider's backend) — `ProviderHttpError`
121
+ // already embeds the numeric status directly in its message ("… failed (HTTP 500) …",
122
+ // caught here too, redundantly but harmlessly), but `ProviderStreamError` (the in-band
123
+ // SSE/stream error shape — antigravity.ts/gemini.ts's `google.rpc.Status` and
124
+ // openai-responses.ts's `response.failed`/`error` events) composes its `.message` from
125
+ // a STRING status code only ("Antigravity stream failed (INTERNAL): internal error"),
126
+ // never the numeric one, even though `.status` carries it correctly (explicitStatus
127
+ // for antigravity/gemini's google.rpc.Status `error.code`; the 500 default for
128
+ // openai-responses's non-rate_limit_exceeded codes). Without a digit anywhere in the
129
+ // text, launch.ts's `routeFailureReason` `/\b(?:500|502|503|504|529)\b/` text regex
130
+ // never matched, so the equivalent-pool fallback never fired for a stream-shaped 5xx
131
+ // even with a credentialed same-tier fallback sitting right there.
132
+ if (status !== undefined && status >= 500 && status < 600) {
133
+ return `${provider} hit a persistent server-side error (HTTP ${status}) — ${msg}. The provider's backend itself is failing; switch model with /model or wait and retry.`;
134
+ }
120
135
  if ((err instanceof Error && err.name === "TimeoutError") || /the operation timed out/i.test(msg)) {
121
136
  return `${provider} did not complete the request within the call timeout (default 30min). This is expected for a HIGH/XHIGH-reasoning-effort completion that legitimately runs long on a hard problem — raise JEO_CALL_TIMEOUT_MS (ms) if this recurs, or lower the thinking level with /model.`;
122
137
  }
package/src/util/retry.ts CHANGED
@@ -5,8 +5,16 @@ export interface RetryOptions {
5
5
  isRetryable?: (err: unknown, attempt: number) => boolean;
6
6
  sleep?: (ms: number) => Promise<void>; // injectable for tests (default real setTimeout)
7
7
  random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
8
- /** Notified before each backoff wait; `delayMs` is the wait actually applied. */
9
- onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
8
+ /** Notified before each backoff wait; `delayMs` is the wait actually applied.
9
+ * Returning `false` (explicitly) aborts the retry ladder immediately — the
10
+ * pending error is thrown right away instead of sleeping and re-attempting.
11
+ * Lets a caller with a same-tier FALLBACK model available bail out of a
12
+ * same-model rate-limit retry loop early rather than riding the full
13
+ * `rateLimitRetries` budget (up to ~90s of escalating backoff) before the
14
+ * turn-level equivalent-pool fallback ever gets a chance to switch models.
15
+ * Any other return value (including `undefined`, the common case) continues
16
+ * the retry ladder unchanged. */
17
+ onRetry?: (attempt: number, err: unknown, delayMs: number) => void | false;
10
18
  /** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
11
19
  * aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
12
20
  * for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
@@ -200,8 +208,11 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
200
208
  : 0;
201
209
  const delay = Math.max(base, floor);
202
210
 
203
- if (onRetry) {
204
- onRetry(attempt, err, delay);
211
+ // A `false` return from onRetry means the caller wants to bail NOW instead of
212
+ // sleeping and re-attempting (see RetryOptions.onRetry's doc comment) — throw
213
+ // the pending error immediately rather than entering the backoff wait.
214
+ if (onRetry && onRetry(attempt, err, delay) === false) {
215
+ throw err;
205
216
  }
206
217
 
207
218
  await abortableSleep(sleep, delay, signal);