jeo-code 0.1.0 → 0.4.4

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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +804 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +562 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -0,0 +1,39 @@
1
+ <!-- Parent: ../AGENTS.md -->
2
+ <!-- Generated: 2026-06-11 | Updated: 2026-06-11 -->
3
+
4
+ # util
5
+
6
+ ## Purpose
7
+ General utilities, helper functions, and shared types used across the application.
8
+
9
+ ## Key Files
10
+ | File | Description |
11
+ |------|-------------|
12
+ | `update-check.ts` | Async check for newer npm versions |
13
+ | `retry.ts` | Rate-limit backoff and generic retry mechanisms |
14
+ | `provider-error.ts` | Error normalization helpers |
15
+
16
+ ## Subdirectories
17
+ *(None)*
18
+
19
+ ## For AI Agents
20
+
21
+ ### Working In This Directory
22
+ - Keep utilities pure and stateless where possible.
23
+ - Avoid circular dependencies.
24
+
25
+ ### Testing Requirements
26
+ - High unit test coverage expected.
27
+
28
+ ### Common Patterns
29
+ - Retry loops use exponential backoff respecting `Retry-After` headers.
30
+
31
+ ## Dependencies
32
+
33
+ ### Internal
34
+ - Used globally.
35
+
36
+ ### External
37
+ *(None)*
38
+
39
+ <!-- MANUAL: -->
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Cross-platform clipboard IMAGE reader for the REPL's Ctrl+V image paste.
3
+ *
4
+ * Terminals only deliver *text* paste through stdin, so pasting a copied
5
+ * image (screenshot, browser right-click copy, …) needs an explicit OS
6
+ * clipboard query. Strategy per platform, all via short-lived subprocesses:
7
+ * - macOS: `pngpaste -` when installed (fast), else an `osascript` fallback
8
+ * that writes the clipboard's «class PNGf» data to a temp file.
9
+ * - Linux: `wl-paste -t image/png` (Wayland), else `xclip -t image/png -o` (X11).
10
+ * - Windows: PowerShell `[Windows.Forms.Clipboard]::GetImage()` → temp PNG.
11
+ *
12
+ * Returns null when the clipboard holds no image (or no tool is available) —
13
+ * callers treat null as "not an image paste" and fall through silently.
14
+ */
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { unlink, readFile } from "node:fs/promises";
18
+ import type { ImageAttachment } from "../ai/types";
19
+
20
+ /** PNG magic bytes — every backend below produces PNG. */
21
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
22
+
23
+ export function looksLikePng(buf: Uint8Array): boolean {
24
+ return buf.length > 8 && Buffer.from(buf.slice(0, 4)).equals(PNG_MAGIC);
25
+ }
26
+
27
+ /** Build the attachment from raw image bytes; null when the bytes are not a PNG. */
28
+ export function attachmentFromBytes(bytes: Uint8Array): ImageAttachment | null {
29
+ if (!looksLikePng(bytes)) return null;
30
+ return { mediaType: "image/png", data: Buffer.from(bytes).toString("base64") };
31
+ }
32
+
33
+ async function runCapture(cmd: string[], timeoutMs = 4000): Promise<Uint8Array | null> {
34
+ try {
35
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
36
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
37
+ const bytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer());
38
+ const code = await proc.exited;
39
+ clearTimeout(timer);
40
+ return code === 0 && bytes.length > 0 ? bytes : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ async function macClipboardImage(): Promise<Uint8Array | null> {
47
+ // Fast path: pngpaste (brew install pngpaste) streams PNG to stdout.
48
+ if (Bun.which("pngpaste")) {
49
+ const bytes = await runCapture(["pngpaste", "-"]);
50
+ if (bytes) return bytes;
51
+ }
52
+ if (!Bun.which("osascript")) return null;
53
+ // Fallback: AppleScript writes «class PNGf» clipboard data to a temp file.
54
+ const tmp = path.join(os.tmpdir(), `jeo-paste-${Date.now()}-${process.pid}.png`);
55
+ const script =
56
+ `set pngData to the clipboard as «class PNGf»\n` +
57
+ `set f to open for access POSIX file ${JSON.stringify(tmp)} with write permission\n` +
58
+ `write pngData to f\nclose access f`;
59
+ try {
60
+ const proc = Bun.spawn(["osascript", "-e", script], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
61
+ const timer = setTimeout(() => proc.kill(), 4000);
62
+ const code = await proc.exited;
63
+ clearTimeout(timer);
64
+ if (code !== 0) return null;
65
+ return new Uint8Array(await readFile(tmp));
66
+ } catch {
67
+ return null;
68
+ } finally {
69
+ unlink(tmp).catch(() => {});
70
+ }
71
+ }
72
+
73
+ async function linuxClipboardImage(): Promise<Uint8Array | null> {
74
+ if (process.env.WAYLAND_DISPLAY && Bun.which("wl-paste")) {
75
+ const bytes = await runCapture(["wl-paste", "-t", "image/png"]);
76
+ if (bytes) return bytes;
77
+ }
78
+ if (Bun.which("xclip")) {
79
+ return runCapture(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]);
80
+ }
81
+ return null;
82
+ }
83
+
84
+ async function windowsClipboardImage(): Promise<Uint8Array | null> {
85
+ if (!Bun.which("powershell.exe") && !Bun.which("powershell")) return null;
86
+ const tmp = path.join(os.tmpdir(), `jeo-paste-${Date.now()}-${process.pid}.png`);
87
+ const ps =
88
+ `Add-Type -AssemblyName System.Windows.Forms; ` +
89
+ `$img = [System.Windows.Forms.Clipboard]::GetImage(); ` +
90
+ `if ($img -eq $null) { exit 1 }; ` +
91
+ `$img.Save('${tmp.replace(/\\/g, "\\\\").replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`;
92
+ try {
93
+ const exe = Bun.which("powershell.exe") ? "powershell.exe" : "powershell";
94
+ const proc = Bun.spawn([exe, "-NoProfile", "-STA", "-Command", ps], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
95
+ const timer = setTimeout(() => proc.kill(), 6000);
96
+ const code = await proc.exited;
97
+ clearTimeout(timer);
98
+ if (code !== 0) return null;
99
+ return new Uint8Array(await readFile(tmp));
100
+ } catch {
101
+ return null;
102
+ } finally {
103
+ unlink(tmp).catch(() => {});
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Read an image from the system clipboard, or null when none is present.
109
+ * Never throws; never blocks longer than a few seconds.
110
+ */
111
+ export async function readClipboardImage(): Promise<ImageAttachment | null> {
112
+ const bytes =
113
+ process.platform === "darwin" ? await macClipboardImage()
114
+ : process.platform === "win32" ? await windowsClipboardImage()
115
+ : await linuxClipboardImage();
116
+ if (!bytes) return null;
117
+ return attachmentFromBytes(bytes);
118
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Branded environment-variable lookup for jeo runtime flags.
3
+ *
4
+ * Runtime flags use the canonical `JEO_*` spelling only.
5
+ * `env` is injectable for tests.
6
+ */
7
+ export function jeoEnv(
8
+ suffix: string,
9
+ env: Record<string, string | undefined> = process.env,
10
+ ): string | undefined {
11
+ return env[`JEO_${suffix}`];
12
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Map a raw provider error (rate limit / auth / generic) to a concise, actionable
3
+ * one-line message for the user. Provider adapters throw `ProviderHttpError`
4
+ * (carrying `.status` and a body), and the agent loop surfaces failures both as a
5
+ * thrown error and as a `doneReason`, so this lives in a shared util used by both.
6
+ */
7
+ import { isUsageLimitError } from "./retry";
8
+
9
+ /** Provider-authoritative context-overflow signal (HTTP 400/413 family). The
10
+ * local token estimate can drift under the real count (images, tokenizer
11
+ * mismatch) — when the PROVIDER says the prompt doesn't fit, the loop can react
12
+ * (reactive trim + one retry) instead of dying on an opaque 400 (round-6 #4). */
13
+ export function isContextOverflowError(err: unknown): boolean {
14
+ const msg = (err as Error)?.message ?? String(err);
15
+ const status = (err as { status?: number })?.status;
16
+ const pattern = /context[ _-]?length|context window|prompt is too long|input is too long|too many tokens|maximum (input|context)|exceeds.{0,30}(context|token)/i;
17
+ if (pattern.test(msg)) return true;
18
+ return status === 413; // payload-too-large is always an overflow signal
19
+ }
20
+
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
+ function formatDuration(ms: number): string {
33
+ const totalSeconds = Math.ceil(ms / 1000);
34
+ if (totalSeconds < 60) return `${totalSeconds}s`;
35
+ const minutes = Math.ceil(totalSeconds / 60);
36
+ if (minutes < 60) return `~${minutes}m`;
37
+ const hours = Math.floor(minutes / 60);
38
+ const rem = minutes % 60;
39
+ return rem ? `~${hours}h ${rem}m` : `~${hours}h`;
40
+ }
41
+
42
+ export function friendlyProviderError(err: unknown): string {
43
+ const msg = (err as Error)?.message ?? String(err);
44
+ const status = (err as { status?: number })?.status;
45
+ const provider = /antigravity/i.test(msg)
46
+ ? "Antigravity"
47
+ : /anthropic/i.test(msg)
48
+ ? "Anthropic"
49
+ : /openai/i.test(msg)
50
+ ? "OpenAI"
51
+ : /gemini|google/i.test(msg)
52
+ ? "Gemini"
53
+ : "the provider";
54
+
55
+ if (isUsageLimitError(err)) {
56
+ return `${provider} usage/quota limit reached — this window will not clear in seconds, so auto-retry was skipped. Switch model with /model (e.g. a local ollama model), use another provider, or wait for the limit window to reset.`;
57
+ }
58
+ if (status === 429 || /\b429\b/.test(msg) || /rate[ _]?limit/i.test(msg)) {
59
+ const retryAfterMs = (err as { retryAfterMs?: number })?.retryAfterMs;
60
+ const retry = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0
61
+ ? ` Server requested retry after ${formatDuration(retryAfterMs)}.`
62
+ : "";
63
+ return `Rate limited by ${provider} (HTTP 429).${retry} Auto-retry cannot clear this window right now — slow your request rate, wait for the reset, or switch model with /model (a local ollama model never rate-limits).`;
64
+ }
65
+ if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
66
+ 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).`;
67
+ }
68
+ if (isContextOverflowError(err)) {
69
+ 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.`;
70
+ }
71
+ if (isRefusalError(err)) {
72
+ 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.`;
73
+ }
74
+ if (status === 404) {
75
+ return `${provider} does not recognize the requested model (HTTP 404). The id may be retired, gated, or mistyped — pick another with /model.`;
76
+ }
77
+ return msg;
78
+ }
package/src/util/retry.ts CHANGED
@@ -5,7 +5,22 @@ 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
- onRetry?: (attempt: number, err: unknown) => void;
8
+ /** Notified before each backoff wait; `delayMs` is the wait actually applied. */
9
+ onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
10
+ /** 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
12
+ * rate-limit window rarely clears in <1s and often needs tens of seconds — a flat
13
+ * sub-second retry cadence just burns the budget; default 0 (no floor) preserves
14
+ * generic behavior. */
15
+ rateLimitMinDelayMs?: number;
16
+ /** Total attempt cap used specifically when the current error is a rate limit (429).
17
+ * When higher than `retries`, rate-limit errors get extra attempts so a transient
18
+ * per-minute window can reset; non-rate-limit errors still use `retries`. */
19
+ 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. */
23
+ rateLimitMaxServerDelayMs?: number;
9
24
  }
10
25
 
11
26
  // Default retryable predicate: true for transient network errors, transient/overload
@@ -16,6 +31,13 @@ export function defaultRetryable(err: unknown): boolean {
16
31
  if (!err) {
17
32
  return false;
18
33
  }
34
+ // Persistent usage/quota limits (e.g. a subscription window exhausted) never clear
35
+ // within a retry budget — fail fast so the caller can switch model/provider instead
36
+ // of sitting through the whole backoff ladder (gjc parity: QUOTA_EXHAUSTED is not
37
+ // retried like a per-minute 429).
38
+ if (isUsageLimitError(err)) {
39
+ return false;
40
+ }
19
41
 
20
42
  let message = "";
21
43
  if (err instanceof Error) {
@@ -59,11 +81,14 @@ export function defaultRetryable(err: unknown): boolean {
59
81
 
60
82
  // Server-directed `Retry-After` is honored but capped so a CLI never hangs on a hostile header.
61
83
  const RETRY_AFTER_CAP_MS = 30_000;
84
+ // Long account/subscription windows should be reported, not silently compressed to 30s.
62
85
 
63
86
  // Run fn; on a retryable error, back off and retry up to `retries` attempts. Backoff is
64
87
  // exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands in
65
88
  // [0.5x, 1x] of that cap), unless the error carries a `retryAfterMs` (server `Retry-After`), which
66
- // takes precedence (capped at RETRY_AFTER_CAP_MS). Re-throws the last error when exhausted.
89
+ // takes precedence (capped at RETRY_AFTER_CAP_MS). A 429 with a server delay beyond
90
+ // `rateLimitMaxServerDelayMs` is not retried because the provider has identified a
91
+ // long account-wide window; callers can surface the reset time instead of hanging.
67
92
  export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T> {
68
93
  const retries = opts?.retries ?? 3;
69
94
  const baseDelayMs = opts?.baseDelayMs ?? 250;
@@ -72,24 +97,51 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
72
97
  const sleep = opts?.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
73
98
  const random = opts?.random ?? Math.random;
74
99
  const onRetry = opts?.onRetry;
100
+ const rateLimitMinDelayMs = opts?.rateLimitMinDelayMs ?? 0;
101
+ const rateLimitRetries = opts?.rateLimitRetries;
102
+ const rateLimitMaxServerDelayMs = opts?.rateLimitMaxServerDelayMs;
75
103
 
76
104
  let attempt = 1;
77
105
  while (true) {
78
106
  try {
79
107
  return await fn();
80
108
  } catch (err) {
81
- if (attempt >= retries || !isRetryable(err, attempt)) {
109
+ // Rate-limit (429) errors may use a higher attempt cap so a transient
110
+ // per-minute window can reset before we surface the failure. If the server
111
+ // says the window is much longer than our retry budget, fail fast with the
112
+ // original error so the UI can show the real reset delay.
113
+ const rateLimited = isRateLimitError(err);
114
+ const serverDelay = retryAfterOf(err);
115
+ if (
116
+ rateLimited &&
117
+ typeof rateLimitMaxServerDelayMs === "number" &&
118
+ serverDelay !== undefined &&
119
+ serverDelay > rateLimitMaxServerDelayMs
120
+ ) {
121
+ throw err;
122
+ }
123
+ const cap = rateLimited && typeof rateLimitRetries === "number" ? Math.max(retries, rateLimitRetries) : retries;
124
+ if (attempt >= cap || !isRetryable(err, attempt)) {
82
125
  throw err;
83
126
  }
84
127
 
85
128
  const capped = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
86
129
  // Equal jitter: half fixed + half random → [0.5x, 1x] of the capped backoff.
87
130
  const jittered = capped / 2 + random() * (capped / 2);
88
- const serverDelay = retryAfterOf(err);
89
- const delay = serverDelay !== undefined ? Math.min(serverDelay, RETRY_AFTER_CAP_MS) : jittered;
131
+ // Server `Retry-After` wins (capped); else jitter. For rate limits, apply the
132
+ // floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
133
+ // burn the 429 budget back-to-back with no real pause. The floor escalates per
134
+ // attempt (×2 each retry, capped) so the total wait across the 429 budget spans
135
+ // a realistic rate-limit window (~a minute) instead of ~8s.
136
+ const cappedServer = serverDelay !== undefined ? Math.min(serverDelay, RETRY_AFTER_CAP_MS) : undefined;
137
+ const base = cappedServer !== undefined ? cappedServer : jittered;
138
+ const floor = rateLimited
139
+ ? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1), RETRY_AFTER_CAP_MS)
140
+ : 0;
141
+ const delay = Math.max(base, floor);
90
142
 
91
143
  if (onRetry) {
92
- onRetry(attempt, err);
144
+ onRetry(attempt, err, delay);
93
145
  }
94
146
 
95
147
  await sleep(delay);
@@ -106,3 +158,36 @@ function retryAfterOf(err: unknown): number | undefined {
106
158
  }
107
159
  return undefined;
108
160
  }
161
+
162
+ // True when an error is an HTTP 429 / rate-limit (structured `.status` or message text).
163
+ export function isRateLimitError(err: unknown): boolean {
164
+ if (typeof err === "object" && err !== null) {
165
+ const status = (err as { status?: unknown }).status;
166
+ const n = typeof status === "number" ? status : typeof status === "string" ? Number(status) : NaN;
167
+ if (n === 429) return true;
168
+ }
169
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
170
+ return /\b429\b/.test(message) || /rate[ _]?limit/i.test(message);
171
+ }
172
+
173
+ /** Message text of an unknown error (Error / string / object with message). */
174
+ function errorMessageOf(err: unknown): string {
175
+ if (err instanceof Error) return err.message;
176
+ if (typeof err === "string") return err;
177
+ if (typeof err === "object" && err !== null && typeof (err as { message?: unknown }).message === "string") {
178
+ return (err as { message: string }).message;
179
+ }
180
+ return "";
181
+ }
182
+
183
+ /**
184
+ * Persistent usage/quota-limit detection (gjc parity: `isUsageLimitError`). These are
185
+ * subscription/window limits ("usage limit reached", "quota exceeded") that need a model
186
+ * or credential switch — retrying within seconds is pure waste, unlike a per-minute 429.
187
+ * Deliberately excludes the ambiguous "resource exhausted" (Gemini uses it for windows
188
+ * that often DO clear within a retry budget).
189
+ */
190
+ const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|exceeded your/i;
191
+ export function isUsageLimitError(err: unknown): boolean {
192
+ return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
193
+ }
@@ -0,0 +1,64 @@
1
+ import pkg from "../../package.json";
2
+ import { compareVersions } from "../commands/update";
3
+
4
+ export interface UpdateCheckResult {
5
+ current: string;
6
+ latest: string;
7
+ updateAvailable: boolean;
8
+ }
9
+
10
+ export interface UpdateCheckDeps {
11
+ fetchJson?: (url: string, opts?: { signal?: AbortSignal }) => Promise<any>;
12
+ localVersion?: () => string;
13
+ timeoutMs?: number;
14
+ }
15
+
16
+ export async function checkForUpdate(deps?: UpdateCheckDeps): Promise<UpdateCheckResult | null> {
17
+ try {
18
+ const fetchJson = deps?.fetchJson ?? (async (url, opts) => {
19
+ const res = await fetch(url, opts);
20
+ if (!res.ok) {
21
+ throw new Error(`HTTP error ${res.status}`);
22
+ }
23
+ return res.json();
24
+ });
25
+
26
+ const localVersion = deps?.localVersion ?? (() => pkg.version);
27
+ const timeoutMs = deps?.timeoutMs ?? 2500;
28
+
29
+ const controller = new AbortController();
30
+ const id = setTimeout(() => controller.abort(), timeoutMs);
31
+
32
+ try {
33
+ const current = localVersion();
34
+ if (typeof current !== "string" || !current) {
35
+ clearTimeout(id);
36
+ return null;
37
+ }
38
+
39
+ const data = await fetchJson("https://registry.npmjs.org/jeo-code/latest", {
40
+ signal: controller.signal,
41
+ });
42
+
43
+ clearTimeout(id);
44
+
45
+ if (!data || typeof data.version !== "string") {
46
+ return null;
47
+ }
48
+
49
+ const latest = data.version;
50
+ const updateAvailable = compareVersions(current, latest) < 0;
51
+
52
+ return {
53
+ current,
54
+ latest,
55
+ updateAvailable,
56
+ };
57
+ } catch (err) {
58
+ clearTimeout(id);
59
+ return null;
60
+ }
61
+ } catch (err) {
62
+ return null;
63
+ }
64
+ }
@@ -1,104 +0,0 @@
1
- import { readGlobalConfig } from "../agent/state";
2
- import { listAliases, resolveModelId } from "../ai/model-registry";
3
- import { resolveProvider, resolveRoleModel } from "../ai/model-manager";
4
- import { describeAllProviders } from "../ai/provider-status";
5
- import { discoverModels } from "../ai/model-discovery";
6
- import { formatLiveModels, formatCatalogTable, formatEnrichedModels } from "../tui/components/config-panel";
7
- import { MODEL_CATALOG, fuzzyMatchCatalog, type ThinkLevel } from "../ai/model-catalog";
8
- import { enrichAll, filterCapable, sortByCapability, knownCount } from "../ai/model-enrich";
9
-
10
- async function probeOllama(baseUrl: string): Promise<string[]> {
11
- try {
12
- const r = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, { signal: AbortSignal.timeout(2500) });
13
- if (!r.ok) return [];
14
- const data = (await r.json()) as { models?: { name: string }[] };
15
- return (data.models ?? []).map(m => `ollama/${m.name}`);
16
- } catch {
17
- return [];
18
- }
19
- }
20
-
21
- async function probeOpenAiCompat(baseUrl: string): Promise<string[]> {
22
- try {
23
- const r = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(2500) });
24
- if (!r.ok) return [];
25
- const data = (await r.json()) as { data?: { id: string }[] };
26
- return (data.data ?? []).map(m => `openai/${m.id}`);
27
- } catch {
28
- return [];
29
- }
30
- }
31
-
32
- export async function runModelsCommand(args: string[] = []): Promise<void> {
33
- const checkMode = args.includes("--check");
34
- const providerFilter = args.find(a => ["anthropic", "openai", "gemini", "ollama"].includes(a.toLowerCase()))?.toLowerCase();
35
- if (args.includes("--catalog")) {
36
- const query = args.find(a => !a.startsWith("--") && !["anthropic", "openai", "gemini", "ollama"].includes(a.toLowerCase()));
37
- const rows = query ? fuzzyMatchCatalog(query) : [...MODEL_CATALOG];
38
- console.log("\n=== joc models --catalog ===");
39
- console.log(`Known model capabilities${query ? ` matching '${query}'` : ""}:`);
40
- for (const line of formatCatalogTable(rows)) console.log(line);
41
- return;
42
- }
43
- if (args.includes("--caps")) {
44
- const cfg = await readGlobalConfig();
45
- const def = await resolveModelId(cfg.defaultModel);
46
- const thinkArg = args.find(a => a.startsWith("--thinking="))?.split("=")[1] as ThinkLevel | undefined;
47
- const filter = {
48
- thinking: thinkArg,
49
- images: args.includes("--images") ? true : undefined,
50
- minContext: args.includes("--long") ? 200_000 : undefined,
51
- };
52
- console.log("\n=== joc models --caps (live + capabilities) ===");
53
- const live = await discoverModels({ config: cfg, timeoutMs: 4000 });
54
- const enriched = sortByCapability(filterCapable(enrichAll(live), filter));
55
- const { known, unknown } = knownCount(enriched);
56
- for (const line of formatEnrichedModels(enriched, { current: def })) console.log(line);
57
- console.log(`\n${known} with known capabilities, ${unknown} unknown.`);
58
- return;
59
- }
60
- const config = await readGlobalConfig();
61
- console.log("\n=== joc models ===");
62
- const resolved = await resolveModelId(config.defaultModel);
63
- console.log(`Default model: ${config.defaultModel}${resolved !== config.defaultModel ? ` → ${resolved}` : ""} → ${resolveProvider(resolved)}`);
64
- console.log(`Role tiers: smol=${resolveRoleModel("smol", config)} · slow=${resolveRoleModel("slow", config)} · plan=${resolveRoleModel("plan", config)}`);
65
-
66
- const aliases = await listAliases();
67
- console.log("\nAliases (use as the model id; config overrides built-ins):");
68
- for (const [alias, target] of Object.entries(aliases)) {
69
- console.log(` ${alias.padEnd(10)} → ${target.padEnd(22)} (${resolveProvider(target)})`);
70
- }
71
-
72
- const ollamaBase = config.ollamaBaseUrl ?? "http://localhost:11434";
73
- const ollama = await probeOllama(ollamaBase);
74
- console.log(`\nLocal Ollama (${ollamaBase}):`);
75
- if (ollama.length) for (const m of ollama.slice(0, 30)) console.log(` ${m}`);
76
- else console.log(" (none reachable)");
77
-
78
- if (config.openaiBaseUrl) {
79
- const compat = await probeOpenAiCompat(config.openaiBaseUrl);
80
- console.log(`\nOpenAI-compatible (${config.openaiBaseUrl}):`);
81
- if (compat.length) for (const m of compat.slice(0, 30)) console.log(` ${m}`);
82
- else console.log(" (none reachable)");
83
- }
84
- console.log("\nProvider credentials:");
85
- for (const status of await describeAllProviders(config)) {
86
- const base = status.baseUrl ? ` [${status.baseUrl}]` : "";
87
- console.log(` ${status.name.padEnd(10)} ${status.ready ? "✓" : "·"} ${status.label}${base}`);
88
- }
89
-
90
- console.log("\nLive models (logged-in providers):");
91
- let live = await discoverModels({ config, timeoutMs: 4000 });
92
- if (providerFilter) live = live.filter(r => r.provider === providerFilter);
93
- if (checkMode) {
94
- for (const r of live) {
95
- const mark = r.ok ? "✓" : "✗";
96
- const detail = r.ok ? `${r.models.length} models (${r.source})` : `${r.error} (${r.source})`;
97
- console.log(` ${mark} ${r.provider.padEnd(10)} ${detail}`);
98
- }
99
- return;
100
- }
101
- for (const line of formatLiveModels(live, { current: resolved, perProvider: 20 })) console.log(line);
102
-
103
- console.log("\nSet a default with 'joc setup' or JOC_DEFAULT_MODEL=<id>.");
104
- }