jeo-code 0.7.27 → 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.
@@ -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
  }
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
 
@@ -102,37 +112,45 @@ export function defaultRetryable(err: unknown): boolean {
102
112
  return false;
103
113
  }
104
114
 
105
- // Server-directed `Retry-After` is honored but capped so a CLI never hangs on a hostile header.
106
- const RETRY_AFTER_CAP_MS = 30_000;
107
- // 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;
108
121
 
109
122
  // Run fn; on a retryable error, back off and retry up to `retries` attempts. Backoff is
110
- // exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands in
111
- // [0.5x, 1x] of that cap), unless the error carries a `retryAfterMs` (server `Retry-After`), which
112
- // takes precedence (capped at RETRY_AFTER_CAP_MS). A 429 with a server delay beyond
113
- // `rateLimitMaxServerDelayMs` is not retried because the provider has identified a
114
- // 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.
115
128
  export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T> {
116
129
  const retries = opts?.retries ?? 3;
117
130
  const baseDelayMs = opts?.baseDelayMs ?? 250;
118
131
  const maxDelayMs = opts?.maxDelayMs ?? 4000;
119
132
  const isRetryable = opts?.isRetryable ?? defaultRetryable;
120
- 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
+ });
121
138
  const random = opts?.random ?? Math.random;
122
139
  const onRetry = opts?.onRetry;
123
140
  const rateLimitMinDelayMs = opts?.rateLimitMinDelayMs ?? 0;
124
141
  const rateLimitRetries = opts?.rateLimitRetries;
125
142
  const rateLimitMaxServerDelayMs = opts?.rateLimitMaxServerDelayMs;
143
+ const signal = opts?.signal;
126
144
 
127
145
  let attempt = 1;
128
146
  while (true) {
129
147
  try {
130
148
  return await fn();
131
149
  } catch (err) {
132
- // Rate-limit (429) errors may use a higher attempt cap so a transient
133
- // per-minute window can reset before we surface the failure. If the server
134
- // says the window is much longer than our retry budget, fail fast with the
135
- // 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.
136
154
  const rateLimited = isRateLimitError(err);
137
155
  const serverDelay = retryAfterOf(err);
138
156
  if (
@@ -151,15 +169,15 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
151
169
  const capped = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
152
170
  // Equal jitter: half fixed + half random → [0.5x, 1x] of the capped backoff.
153
171
  const jittered = capped / 2 + random() * (capped / 2);
154
- // Server `Retry-After` wins (capped); else jitter. For rate limits, apply the
155
- // floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
156
- // burn the 429 budget back-to-back with no real pause. The floor escalates per
157
- // attempt (×2 each retry, capped) so the total wait across the 429 budget spans
158
- // a realistic rate-limit window (~a minute) instead of ~8s.
159
- const cappedServer = serverDelay !== undefined ? Math.min(serverDelay, RETRY_AFTER_CAP_MS) : undefined;
160
- 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;
161
179
  const floor = rateLimited
162
- ? 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)
163
181
  : 0;
164
182
  const delay = Math.max(base, floor);
165
183
 
@@ -167,12 +185,29 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
167
185
  onRetry(attempt, err, delay);
168
186
  }
169
187
 
170
- await sleep(delay);
188
+ await abortableSleep(sleep, delay, signal);
171
189
  attempt++;
172
190
  }
173
191
  }
174
192
  }
175
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
+
176
211
  // Extract a non-negative `retryAfterMs` from an error, if it carries one (e.g. ProviderHttpError).
177
212
  function retryAfterOf(err: unknown): number | undefined {
178
213
  if (typeof err === "object" && err !== null) {
@@ -205,12 +240,14 @@ function errorMessageOf(err: unknown): string {
205
240
 
206
241
  /**
207
242
  * Persistent usage/quota-limit detection (gjc parity: `isUsageLimitError`). These are
208
- * subscription/window limits ("usage limit reached", "quota exceeded") that need a model
209
- * or credential switch — retrying within seconds is pure waste, unlike a per-minute 429.
210
- * Deliberately excludes the ambiguous "resource exhausted" (Gemini uses it for windows
211
- * 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).
212
249
  */
213
- 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;
214
251
  export function isUsageLimitError(err: unknown): boolean {
215
252
  return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
216
253
  }