claude-overnight 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export interface JWTPayload {
2
+ sub: string;
3
+ model: string;
4
+ bearer: string;
5
+ aud: string;
6
+ iat: number;
7
+ exp: number;
8
+ }
9
+ export interface TokenRecord {
10
+ signedToken: string;
11
+ payload: JWTPayload;
12
+ }
13
+ export declare function loadSecret(): Buffer;
14
+ export declare function signToken(providerId: string, model: string, bearer: string, baseURL: string): TokenRecord;
15
+ export declare function verifyToken(token: string, providerId: string): JWTPayload | null;
16
+ export declare function refreshToken(oldToken: string, providerId: string): TokenRecord | null;
17
+ export declare function getBearerToken(providerId: string, model: string, bearer: string, baseURL: string): string;
18
+ export declare function clearTokenCache(): void;
19
+ export declare function isJWTAuthError(err: unknown): boolean;
package/dist/auth.js ADDED
@@ -0,0 +1,82 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
4
+ const SECRET_PATH = join(homedir(), ".claude", "claude-overnight", "jwt-secret.key");
5
+ export function loadSecret() {
6
+ try {
7
+ const raw = readFileSync(SECRET_PATH);
8
+ if (raw.length >= 32)
9
+ return raw;
10
+ }
11
+ catch { }
12
+ const secret = cryptoRandomBytes(32);
13
+ mkdirSync(join(homedir(), ".claude", "claude-overnight"), { recursive: true });
14
+ writeFileSync(SECRET_PATH, secret);
15
+ try {
16
+ chmodSync(SECRET_PATH, 0o600);
17
+ }
18
+ catch { }
19
+ return secret;
20
+ }
21
+ function deriveKey(secret, providerId) {
22
+ const crypto = require("crypto");
23
+ return crypto.createHmac("sha256", secret).update(providerId).digest();
24
+ }
25
+ const DEFAULT_TTL_SEC = 300; // 5 minutes
26
+ export function signToken(providerId, model, bearer, baseURL) {
27
+ const jwt = require("jsonwebtoken");
28
+ const secret = loadSecret();
29
+ const key = deriveKey(secret, providerId);
30
+ const now = Math.floor(Date.now() / 1000);
31
+ const payload = { sub: providerId, model, bearer, aud: baseURL, iat: now, exp: now + DEFAULT_TTL_SEC };
32
+ const signedToken = jwt.sign(payload, key, { algorithm: "HS256" });
33
+ return { signedToken, payload };
34
+ }
35
+ export function verifyToken(token, providerId) {
36
+ const jwt = require("jsonwebtoken");
37
+ const secret = loadSecret();
38
+ const key = deriveKey(secret, providerId);
39
+ try {
40
+ return jwt.verify(token, key, { algorithms: ["HS256"] });
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ export function refreshToken(oldToken, providerId) {
47
+ const payload = verifyToken(oldToken, providerId);
48
+ if (!payload)
49
+ return null;
50
+ const now = Math.floor(Date.now() / 1000);
51
+ if (payload.exp - now > 60)
52
+ return null;
53
+ return signToken(payload.sub, payload.model, payload.bearer, payload.aud);
54
+ }
55
+ const tokenCache = new Map();
56
+ export function getBearerToken(providerId, model, bearer, baseURL) {
57
+ const cached = tokenCache.get(providerId);
58
+ if (cached) {
59
+ const payload = verifyToken(cached.signedToken, providerId);
60
+ if (payload && payload.exp > Math.floor(Date.now() / 1000) + 30) {
61
+ return cached.signedToken;
62
+ }
63
+ }
64
+ const fresh = refreshToken(cached?.signedToken ?? "", providerId) ?? signToken(providerId, model, bearer, baseURL);
65
+ tokenCache.set(providerId, fresh);
66
+ return fresh.signedToken;
67
+ }
68
+ export function clearTokenCache() {
69
+ tokenCache.clear();
70
+ }
71
+ function cryptoRandomBytes(length) {
72
+ const crypto = require("crypto");
73
+ return crypto.randomBytes(length);
74
+ }
75
+ export function isJWTAuthError(err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ const lower = msg.toLowerCase();
78
+ return lower.includes("token expired") || lower.includes("invalid_token")
79
+ || lower.includes("jwt") || lower.includes("signature")
80
+ || lower.includes("unauthorized") || lower.includes("forbidden")
81
+ || lower.includes("invalid_api_key") || lower.includes("authentication");
82
+ }
package/dist/cli.d.ts CHANGED
@@ -4,7 +4,10 @@ export declare function parseCliFlags(argv: string[]): {
4
4
  flags: Record<string, string>;
5
5
  positional: string[];
6
6
  };
7
- export declare function isAuthError(err: unknown): boolean;
7
+ import { isJWTAuthError } from "./auth.js";
8
+ /** @deprecated Use isJWTAuthError from auth.ts instead. */
9
+ export declare const isAuthError: typeof isJWTAuthError;
10
+ export { isJWTAuthError };
8
11
  export declare function fetchModels(timeoutMs?: number): Promise<ModelInfo[]>;
9
12
  export declare const PASTE_START = "\u001B[200~";
10
13
  export declare const PASTE_END = "\u001B[201~";
package/dist/cli.js CHANGED
@@ -29,12 +29,11 @@ export function parseCliFlags(argv) {
29
29
  }
30
30
  return { flags, positional };
31
31
  }
32
- // ── Auth error detection ──
33
- const AUTH_PATTERNS = ["unauthorized", "forbidden", "invalid_api_key", "authentication"];
34
- export function isAuthError(err) {
35
- const msg = err instanceof Error ? err.message : String(err);
36
- return AUTH_PATTERNS.some((p) => msg.toLowerCase().includes(p));
37
- }
32
+ // ── Auth error detection (re-exported from auth module for backward compatibility) ──
33
+ import { isJWTAuthError } from "./auth.js";
34
+ /** @deprecated Use isJWTAuthError from auth.ts instead. */
35
+ export const isAuthError = isJWTAuthError;
36
+ export { isJWTAuthError };
38
37
  // ── Fetch models via SDK ──
39
38
  export async function fetchModels(timeoutMs = 10_000) {
40
39
  let q;
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
10
10
  import { Swarm } from "./swarm.js";
11
11
  import { planTasks, refinePlan, identifyThemes, buildThinkingTasks, orchestrate, salvageFromFile } from "./planner.js";
12
12
  import { detectModelTier, setPlannerEnvResolver } from "./planner-query.js";
13
- import { pickModel, loadProviders, preflightProvider, buildEnvResolver } from "./providers.js";
13
+ import { pickModel, loadProviders, preflightProvider, buildEnvResolver, healthCheckCursorProxy, PROXY_DEFAULT_URL, isCursorProxyProvider } from "./providers.js";
14
14
  import { RunDisplay } from "./ui.js";
15
15
  import { renderSummary } from "./render.js";
16
16
  import { executeRun } from "./run.js";
@@ -217,6 +217,16 @@ async function main() {
217
217
  process.exit(1);
218
218
  }
219
219
  }
220
+ // ── Pre-check: warn if saved Cursor providers exist but proxy is down ──
221
+ const savedCursorProviders = loadProviders().filter(isCursorProxyProvider);
222
+ if (savedCursorProviders.length > 0 && !dryRun) {
223
+ const proxyUp = await healthCheckCursorProxy();
224
+ if (!proxyUp) {
225
+ console.warn(chalk.yellow(`\n ⚠ ${savedCursorProviders.length} Cursor provider(s) saved but proxy is not running at ${PROXY_DEFAULT_URL}`));
226
+ console.warn(chalk.yellow(` Start it: npx cursor-api-proxy`));
227
+ console.warn(chalk.dim(` (Continuing — you can still use Anthropic models)\n`));
228
+ }
229
+ }
220
230
  // ── Load tasks ──
221
231
  let tasks = [];
222
232
  let fileCfg;
@@ -18,6 +18,11 @@ export function detectModelTier(model) {
18
18
  return "sonnet";
19
19
  if (m.includes("haiku"))
20
20
  return "haiku";
21
+ // Cursor API Proxy models
22
+ if (m === "auto")
23
+ return "unknown";
24
+ if (m.startsWith("composer"))
25
+ return "sonnet";
21
26
  return "unknown";
22
27
  }
23
28
  export function modelCapabilityBlock(model) {
@@ -29,6 +34,10 @@ export function modelCapabilityBlock(model) {
29
34
  case "haiku":
30
35
  return `Each agent runs Claude Haiku -- fast and efficient, best for focused, well-specified tasks. Be explicit about files, functions, and expected changes. Keep each task scoped to a clear, concrete deliverable.`;
31
36
  default:
37
+ // Cursor API Proxy or unknown model — generic but mention Cursor context
38
+ if (model.toLowerCase().startsWith("composer") || model.toLowerCase() === "auto") {
39
+ return `Each agent runs a Cursor model with full codebase access. Capable of focused implementation work. Be explicit about files, functions, and expected changes.`;
40
+ }
32
41
  return `Each agent has full codebase access and can work autonomously.`;
33
42
  }
34
43
  }
@@ -44,6 +53,47 @@ let _plannerRateLimitInfo = {
44
53
  utilization: 0, status: "", isUsingOverage: false, windows: new Map(), costUsd: 0,
45
54
  };
46
55
  export function getPlannerRateLimitInfo() { return _plannerRateLimitInfo; }
56
+ // ── Proactive throttle: wait before making API calls when utilization is high ──
57
+ /**
58
+ * Proactive rate-limit gate. Called before each planner/steering query to
59
+ * prevent hammering the API when we're already near a limit.
60
+ *
61
+ * Levels:
62
+ * - rejected -> wait until resetsAt (or 60s fallback)
63
+ * - utilization >= 90% -> wait 30s with exponential backoff
64
+ * - utilization >= 75% -> brief 5s cooldown
65
+ * - utilization < 75% -> pass through immediately
66
+ */
67
+ async function throttlePlanner(onLog, aborted) {
68
+ const MAX_BACKOFF = 3;
69
+ for (let backoff = 0; backoff <= MAX_BACKOFF; backoff++) {
70
+ if (aborted())
71
+ return;
72
+ const rl = _plannerRateLimitInfo;
73
+ const rejected = rl.resetsAt && rl.resetsAt > Date.now();
74
+ const highUtil = rl.utilization >= 0.9;
75
+ const elevatedUtil = rl.utilization >= 0.75;
76
+ if (!rejected && !highUtil && !elevatedUtil)
77
+ return;
78
+ const waitMs = rejected
79
+ ? Math.max(5000, rl.resetsAt - Date.now())
80
+ : highUtil
81
+ ? 30_000 * (1 + backoff)
82
+ : 5000;
83
+ const reason = rejected ? "Rate limited" : `Utilization ${Math.round(rl.utilization * 100)}%`;
84
+ onLog(`${reason} -- waiting ${Math.ceil(waitMs / 1000)}s before query${backoff > 0 ? ` (backoff ${backoff})` : ""}`, "event");
85
+ await new Promise((r) => setTimeout(r, waitMs));
86
+ if (aborted())
87
+ return;
88
+ // After a wait, clear the rejected flag so we don't loop forever if
89
+ // the SDK stopped sending updates.
90
+ if (rejected && rl.resetsAt && rl.resetsAt <= Date.now()) {
91
+ rl.resetsAt = undefined;
92
+ rl.utilization = 0;
93
+ }
94
+ }
95
+ // Exhausted backoffs — proceed anyway, the retry loop will catch a rejection.
96
+ }
47
97
  // ── Query execution ──
48
98
  const NUDGE_MS = 15 * 60 * 1000;
49
99
  const HARD_TIMEOUT_MS = 30 * 60 * 1000;
@@ -53,8 +103,11 @@ export async function runPlannerQuery(prompt, opts, onLog) {
53
103
  const BACKOFF = [30_000, 60_000, 120_000];
54
104
  let currentPrompt = prompt;
55
105
  let currentOpts = opts;
106
+ let aborted = false;
56
107
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
57
108
  try {
109
+ // Proactive throttle: wait if utilization is already high
110
+ await throttlePlanner(onLog, () => aborted);
58
111
  return await runPlannerQueryOnce(currentPrompt, currentOpts, onLog);
59
112
  }
60
113
  catch (err) {
@@ -78,6 +131,7 @@ export async function runPlannerQuery(prompt, opts, onLog) {
78
131
  throw err;
79
132
  }
80
133
  }
134
+ aborted = true;
81
135
  throw new Error("Planner query failed after retries");
82
136
  }
83
137
  async function runPlannerQueryOnce(prompt, opts, onLog) {
@@ -13,6 +13,10 @@ export interface ProviderConfig {
13
13
  keyEnv?: string;
14
14
  /** Inline API key. Stored plaintext in providers.json (mode 0600). */
15
15
  key?: string;
16
+ /** When true, use JWT token auth instead of raw API keys. The bearer token is embedded in a short-lived JWT. */
17
+ useJWT?: boolean;
18
+ /** When true, this provider routes through cursor-api-proxy (special env/health-check handling). */
19
+ cursorProxy?: boolean;
16
20
  }
17
21
  export declare function getStorePath(): string;
18
22
  export declare function loadProviders(): ProviderConfig[];
@@ -47,6 +51,24 @@ export declare function preflightProvider(p: ProviderConfig, cwd: string, timeou
47
51
  ok: false;
48
52
  error: string;
49
53
  }>;
54
+ export declare const PROXY_DEFAULT_URL = "http://127.0.0.1:8765";
55
+ /** Check if a provider routes through cursor-api-proxy. */
56
+ export declare function isCursorProxyProvider(p: ProviderConfig): boolean;
57
+ /**
58
+ * Health check: GET /health on the proxy. Returns true if proxy is reachable.
59
+ */
60
+ export declare function healthCheckCursorProxy(baseUrl?: string): Promise<boolean>;
61
+ /**
62
+ * Fetch available Cursor models via GET /v1/models on the proxy.
63
+ * Returns model IDs like ["auto", "composer", "composer-2", "opus-4.6", ...].
64
+ */
65
+ export declare function fetchCursorModels(baseUrl?: string): Promise<string[]>;
66
+ /**
67
+ * Interactive setup guide for cursor-api-proxy.
68
+ * Walks through CLI install, login, and proxy start.
69
+ * Returns true when proxy is running and healthy.
70
+ */
71
+ export declare function setupCursorProxy(): Promise<boolean>;
50
72
  export type EnvResolver = (model?: string) => Record<string, string> | undefined;
51
73
  /**
52
74
  * Build a single resolver that swarm.ts and planner-query.ts share. Maps a
package/dist/providers.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
+ import { execSync } from "child_process";
4
5
  import chalk from "chalk";
5
6
  import { query } from "@anthropic-ai/claude-agent-sdk";
6
- import { ask, select } from "./cli.js";
7
+ import { ask, select, selectKey } from "./cli.js";
8
+ import { getBearerToken, clearTokenCache } from "./auth.js";
7
9
  // ── Store ──
8
10
  const STORE_PATH = join(homedir(), ".claude", "claude-overnight", "providers.json");
9
11
  export function getStorePath() { return STORE_PATH; }
@@ -26,6 +28,7 @@ export function saveProvider(p) {
26
28
  chmodSync(STORE_PATH, 0o600);
27
29
  }
28
30
  catch { }
31
+ clearTokenCache();
29
32
  }
30
33
  export function deleteProvider(id) {
31
34
  const all = loadProviders().filter(x => x.id !== id);
@@ -36,6 +39,7 @@ export function deleteProvider(id) {
36
39
  chmodSync(STORE_PATH, 0o600);
37
40
  }
38
41
  catch { }
42
+ clearTokenCache();
39
43
  }
40
44
  function isValidProvider(p) {
41
45
  return p && typeof p.id === "string" && typeof p.baseURL === "string"
@@ -55,15 +59,27 @@ export function resolveKey(p) {
55
59
  * you pass `options.env`.
56
60
  */
57
61
  export function envFor(p) {
58
- const key = resolveKey(p);
59
- if (!key)
60
- throw new Error(`Provider "${p.id}" has no API key (${p.keyEnv ? `env ${p.keyEnv} is empty` : "inline key missing"})`);
61
62
  const base = {};
62
63
  for (const [k, v] of Object.entries(process.env))
63
64
  if (v !== undefined)
64
65
  base[k] = v;
66
+ if (p.cursorProxy) {
67
+ // cursor-api-proxy: routes through local proxy, no real API key needed
68
+ base.ANTHROPIC_BASE_URL = p.baseURL;
69
+ base.ANTHROPIC_AUTH_TOKEN = process.env.CURSOR_BRIDGE_API_KEY || "unused";
70
+ delete base.ANTHROPIC_API_KEY;
71
+ return base;
72
+ }
73
+ const key = resolveKey(p);
74
+ if (!key)
75
+ throw new Error(`Provider "${p.id}" has no API key (${p.keyEnv ? `env ${p.keyEnv} is empty` : "inline key missing"})`);
65
76
  base.ANTHROPIC_BASE_URL = p.baseURL;
66
- base.ANTHROPIC_AUTH_TOKEN = key;
77
+ if (p.useJWT) {
78
+ base.ANTHROPIC_AUTH_TOKEN = getBearerToken(p.id, p.model, key, p.baseURL);
79
+ }
80
+ else {
81
+ base.ANTHROPIC_AUTH_TOKEN = key;
82
+ }
67
83
  delete base.ANTHROPIC_API_KEY;
68
84
  return base;
69
85
  }
@@ -90,8 +106,10 @@ export async function pickModel(label, anthropicModels, currentModelId) {
90
106
  }
91
107
  for (const p of saved) {
92
108
  const keySrc = p.keyEnv ? `env ${p.keyEnv}` : "stored key";
93
- items.push({ name: `${p.displayName}`, value: { kind: "provider", provider: p }, hint: `${p.model} · ${keySrc}` });
109
+ const cursorTag = p.cursorProxy ? chalk.dim(" · cursor") : "";
110
+ items.push({ name: `${p.displayName}${cursorTag}`, value: { kind: "provider", provider: p }, hint: `${p.model} · ${keySrc}` });
94
111
  }
112
+ items.push({ name: chalk.green("Cursor…"), value: { kind: "cursor" }, hint: "Cursor API Proxy — composer, composer-2, auto, etc." });
95
113
  items.push({ name: chalk.cyan("Other…"), value: { kind: "other" }, hint: "Qwen 3.6 Plus, OpenRouter, or any Anthropic-compatible endpoint" });
96
114
  let defaultIdx = 0;
97
115
  if (currentModelId) {
@@ -111,6 +129,13 @@ export async function pickModel(label, anthropicModels, currentModelId) {
111
129
  if (picked.kind === "provider") {
112
130
  return { model: picked.provider.model, providerId: picked.provider.id, provider: picked.provider };
113
131
  }
132
+ if (picked.kind === "cursor") {
133
+ const cursorPick = await pickCursorModel();
134
+ if (cursorPick)
135
+ return cursorPick;
136
+ // user cancelled cursor picker — loop back
137
+ continue;
138
+ }
114
139
  const added = await promptNewProvider();
115
140
  if (added) {
116
141
  saveProvider(added);
@@ -144,12 +169,20 @@ async function promptNewProvider() {
144
169
  if (!process.env[envName]) {
145
170
  console.log(chalk.yellow(`\n ⚠ ${envName} is not set in the current shell -- you'll need to export it before running.`));
146
171
  }
147
- return { id, displayName, baseURL, model, keyEnv: envName };
172
+ const useJWT = await select(` ${chalk.cyan("Auth method")}:`, [
173
+ { name: "JWT tokens", value: "jwt", hint: "short-lived tokens, raw keys never passed to agents" },
174
+ { name: "Raw API key", value: "raw", hint: "key sent directly with every request" },
175
+ ]);
176
+ return { id, displayName, baseURL, model, keyEnv: envName, useJWT: useJWT === "jwt" };
148
177
  }
149
178
  const key = await ask(`\n ${chalk.cyan("API key")}: `);
150
179
  if (!key)
151
180
  return null;
152
- return { id, displayName, baseURL, model, key };
181
+ const useJWT = await select(` ${chalk.cyan("Auth method")}:`, [
182
+ { name: "JWT tokens", value: "jwt", hint: "short-lived tokens, raw keys never passed to agents" },
183
+ { name: "Raw API key", value: "raw", hint: "key sent directly with every request" },
184
+ ]);
185
+ return { id, displayName, baseURL, model, key, useJWT: useJWT === "jwt" };
153
186
  }
154
187
  function slugify(s) {
155
188
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "provider";
@@ -221,6 +254,264 @@ export async function preflightProvider(p, cwd, timeoutMs = 20_000) {
221
254
  catch { }
222
255
  }
223
256
  }
257
+ // ── Cursor API Proxy ──
258
+ export const PROXY_DEFAULT_URL = "http://127.0.0.1:8765";
259
+ /** Check if a provider routes through cursor-api-proxy. */
260
+ export function isCursorProxyProvider(p) {
261
+ return p.cursorProxy === true || p.baseURL === PROXY_DEFAULT_URL;
262
+ }
263
+ /**
264
+ * Health check: GET /health on the proxy. Returns true if proxy is reachable.
265
+ */
266
+ export async function healthCheckCursorProxy(baseUrl = PROXY_DEFAULT_URL) {
267
+ const url = `${baseUrl.replace(/\/$/, "")}/health`;
268
+ try {
269
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(3_000) });
270
+ return res.ok;
271
+ }
272
+ catch {
273
+ return false;
274
+ }
275
+ }
276
+ /**
277
+ * Fetch available Cursor models via GET /v1/models on the proxy.
278
+ * Returns model IDs like ["auto", "composer", "composer-2", "opus-4.6", ...].
279
+ */
280
+ export async function fetchCursorModels(baseUrl = PROXY_DEFAULT_URL) {
281
+ const url = `${baseUrl.replace(/\/$/, "")}/v1/models`;
282
+ try {
283
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(5_000) });
284
+ if (!res.ok)
285
+ return [];
286
+ const json = await res.json();
287
+ return (json.data || []).map(m => m.id).filter(Boolean);
288
+ }
289
+ catch {
290
+ return [];
291
+ }
292
+ }
293
+ /**
294
+ * Known Cursor model recommendations — short hints to guide users.
295
+ */
296
+ const CURSOR_MODEL_HINTS = {
297
+ "auto": "fast — delegates to best available model",
298
+ "composer": "Cursor Composer — good for focused tasks",
299
+ "composer-2": "Cursor Composer 2 — latest, strongest Cursor model",
300
+ };
301
+ function cursorModelHint(modelId) {
302
+ const m = modelId.toLowerCase();
303
+ if (CURSOR_MODEL_HINTS[m])
304
+ return CURSOR_MODEL_HINTS[m];
305
+ if (m.includes("opus"))
306
+ return "Opus-tier Cursor model";
307
+ if (m.includes("sonnet"))
308
+ return "Sonnet-tier Cursor model";
309
+ if (m.includes("haiku"))
310
+ return "Haiku-tier Cursor model (fast)";
311
+ return "Cursor model";
312
+ }
313
+ function setupSteps() {
314
+ return [
315
+ {
316
+ label: "Cursor agent CLI",
317
+ check: () => {
318
+ try {
319
+ execSync("which agent", { stdio: "pipe" });
320
+ return true;
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ },
326
+ autoCmd: "curl https://cursor.com/install -fsS | bash",
327
+ manualCmd: "curl https://cursor.com/install -fsS | bash",
328
+ successMsg: "Cursor CLI found",
329
+ },
330
+ {
331
+ label: "Cursor authentication",
332
+ check: () => {
333
+ try {
334
+ const out = execSync("agent --list-models", { stdio: "pipe", timeout: 10_000 });
335
+ return out.toString().trim().length > 0;
336
+ }
337
+ catch {
338
+ return false;
339
+ }
340
+ },
341
+ autoCmd: "agent login",
342
+ manualCmd: "agent login",
343
+ successMsg: "Cursor authenticated",
344
+ },
345
+ {
346
+ label: "cursor-api-proxy server",
347
+ check: () => {
348
+ try {
349
+ execSync("npx cursor-api-proxy --help", { stdio: "pipe", timeout: 10_000 });
350
+ return true;
351
+ }
352
+ catch {
353
+ return false;
354
+ }
355
+ },
356
+ autoCmd: "npx cursor-api-proxy",
357
+ manualCmd: "npx cursor-api-proxy",
358
+ successMsg: "cursor-api-proxy available",
359
+ },
360
+ ];
361
+ }
362
+ /**
363
+ * Interactive setup guide for cursor-api-proxy.
364
+ * Walks through CLI install, login, and proxy start.
365
+ * Returns true when proxy is running and healthy.
366
+ */
367
+ export async function setupCursorProxy() {
368
+ console.log(chalk.dim("\n Cursor API Proxy Setup"));
369
+ console.log(chalk.dim(" " + "─".repeat(40)));
370
+ console.log(chalk.dim(" We need three things: Cursor CLI, authentication, and the proxy server.\n"));
371
+ const steps = setupSteps();
372
+ for (const step of steps) {
373
+ if (step.check()) {
374
+ console.log(chalk.green(` ✓ ${step.successMsg}`));
375
+ continue;
376
+ }
377
+ console.log(chalk.yellow(`\n ${step.label} not found`));
378
+ const choice = await selectKey(` Set up ${step.label}:`, [
379
+ { key: "a", desc: "uto (run command)" },
380
+ { key: "m", desc: "anual (show command)" },
381
+ { key: "s", desc: "kip (I'll handle it)" },
382
+ ]);
383
+ if (choice === "a") {
384
+ if (step.label === "Cursor authentication") {
385
+ // agent login needs interactive browser — run it directly
386
+ console.log(chalk.dim(` Running: ${step.autoCmd}`));
387
+ console.log(chalk.dim(" (A browser window will open for login)\n"));
388
+ try {
389
+ execSync(step.autoCmd, { stdio: "inherit", timeout: 120_000 });
390
+ console.log(chalk.green(` ✓ ${step.successMsg}`));
391
+ }
392
+ catch {
393
+ console.log(chalk.yellow(" Login failed — try manual mode"));
394
+ // Fall through to manual display
395
+ }
396
+ }
397
+ else if (step.label === "cursor-api-proxy server") {
398
+ // Don't auto-start the proxy server here — it blocks. Just verify it's installable.
399
+ console.log(chalk.dim(` Install check: ${step.autoCmd} --help`));
400
+ try {
401
+ execSync("npx cursor-api-proxy --help", { stdio: "pipe", timeout: 30_000 });
402
+ console.log(chalk.green(` ✓ cursor-api-proxy installed`));
403
+ console.log(chalk.yellow(` → Start it in another terminal: ${chalk.bold("npx cursor-api-proxy")}`));
404
+ const ready = await selectKey(` Is the proxy running now?`, [
405
+ { key: "y", desc: "es" },
406
+ { key: "n", desc: "ot yet" },
407
+ ]);
408
+ if (ready === "y") {
409
+ if (await healthCheckCursorProxy()) {
410
+ console.log(chalk.green(` ✓ Proxy connected`));
411
+ return true;
412
+ }
413
+ }
414
+ }
415
+ catch {
416
+ console.log(chalk.red(" cursor-api-proxy not installed. Install with: npm install -g cursor-api-proxy"));
417
+ }
418
+ }
419
+ else {
420
+ console.log(chalk.dim(` Running: ${step.autoCmd}`));
421
+ try {
422
+ execSync(step.autoCmd, { stdio: "inherit", timeout: 60_000 });
423
+ console.log(chalk.green(` ✓ ${step.successMsg}`));
424
+ }
425
+ catch {
426
+ console.log(chalk.yellow(" Command failed — try manual mode"));
427
+ }
428
+ }
429
+ }
430
+ else if (choice === "m") {
431
+ console.log(chalk.cyan(`\n Run this command:`));
432
+ console.log(chalk.white(` ${step.manualCmd}`));
433
+ if (step.label === "cursor-api-proxy server") {
434
+ console.log(chalk.yellow(` Then start the proxy: ${chalk.bold("npx cursor-api-proxy")}`));
435
+ }
436
+ console.log();
437
+ const done = await selectKey(` Done?`, [
438
+ { key: "y", desc: "es" },
439
+ { key: "n", desc: "ot yet" },
440
+ ]);
441
+ if (done === "y" && step.label === "cursor-api-proxy server") {
442
+ if (await healthCheckCursorProxy()) {
443
+ console.log(chalk.green(` ✓ Proxy connected`));
444
+ return true;
445
+ }
446
+ }
447
+ }
448
+ else {
449
+ console.log(chalk.dim(` Skipped: ${step.label}`));
450
+ }
451
+ }
452
+ // Final health check
453
+ if (await healthCheckCursorProxy()) {
454
+ console.log(chalk.green("\n ✓ Proxy is running and healthy"));
455
+ return true;
456
+ }
457
+ console.log(chalk.yellow("\n Proxy not reachable yet. You can start it later and add it via 'Cursor' in the model picker."));
458
+ return false;
459
+ }
460
+ // ── Cursor model picker sub-flow ──
461
+ async function pickCursorModel() {
462
+ console.log(chalk.dim("\n Cursor API Proxy Models"));
463
+ console.log(chalk.dim(" " + "─".repeat(40)));
464
+ // Quick health check with spinner
465
+ let frame = 0;
466
+ const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
467
+ const spinner = setInterval(() => {
468
+ process.stdout.write(`\x1B[2K\r ${chalk.cyan(BRAILLE[frame++ % BRAILLE.length])} ${chalk.dim("checking proxy...")}`);
469
+ }, 120);
470
+ const healthy = await healthCheckCursorProxy();
471
+ clearInterval(spinner);
472
+ process.stdout.write("\x1B[2K\r");
473
+ if (!healthy) {
474
+ console.log(chalk.yellow(" Proxy is not running at " + PROXY_DEFAULT_URL));
475
+ const choice = await selectKey(` What next?`, [
476
+ { key: "s", desc: "etup guide" },
477
+ { key: "r", desc: "etry" },
478
+ { key: "c", desc: "ancel" },
479
+ ]);
480
+ if (choice === "s") {
481
+ const ok = await setupCursorProxy();
482
+ if (!ok)
483
+ return null;
484
+ }
485
+ else if (choice === "r") {
486
+ return pickCursorModel();
487
+ }
488
+ else {
489
+ return null;
490
+ }
491
+ }
492
+ // Fetch live models
493
+ const modelIds = await fetchCursorModels();
494
+ if (modelIds.length === 0) {
495
+ console.log(chalk.yellow(" No models returned from proxy"));
496
+ return null;
497
+ }
498
+ const picked = await select(" Select a Cursor model:", modelIds.map(id => ({
499
+ name: id,
500
+ value: id,
501
+ hint: cursorModelHint(id),
502
+ })), 0);
503
+ // Save as a cursor proxy provider
504
+ const provider = {
505
+ id: `cursor-${picked}`,
506
+ displayName: `Cursor: ${picked}`,
507
+ baseURL: PROXY_DEFAULT_URL,
508
+ model: picked,
509
+ cursorProxy: true,
510
+ };
511
+ saveProvider(provider);
512
+ console.log(chalk.green(` ✓ Saved as provider: ${provider.displayName}`));
513
+ return { model: picked, providerId: provider.id, provider };
514
+ }
224
515
  /**
225
516
  * Build a single resolver that swarm.ts and planner-query.ts share. Maps a
226
517
  * model string to the env overrides that should be passed to `query()`.
package/dist/run.js CHANGED
@@ -9,7 +9,8 @@ import { buildEnvResolver } from "./providers.js";
9
9
  import { RunDisplay } from "./ui.js";
10
10
  import { renderSummary } from "./render.js";
11
11
  import { fmtTokens } from "./render.js";
12
- import { isAuthError, selectKey, ask } from "./cli.js";
12
+ import { isJWTAuthError } from "./auth.js";
13
+ import { selectKey, ask } from "./cli.js";
13
14
  import { readRunMemory, writeStatus, writeGoalUpdate, saveRunState, saveWaveSession, loadWaveHistory, recordBranches, archiveMilestone, writeSteerInbox, consumeSteerInbox, countSteerInbox, appendOvernightLogStart, updateOvernightLogEnd, } from "./state.js";
14
15
  export async function executeRun(cfg) {
15
16
  const restore = () => { try {
@@ -353,6 +354,11 @@ export async function executeRun(cfg) {
353
354
  currentTasks = currentTasks.slice(0, remaining);
354
355
  syncRunInfo();
355
356
  saveRunState(runDir, buildRunState({ remaining, phase: "steering", currentTasks }));
357
+ // Pre-wave rate limit gate: don't spawn a new wave if the API is already
358
+ // near a limit. This prevents wasting sessions on instant rejections.
359
+ await throttleBeforeWave(() => getPlannerRateLimitInfo(), (text) => display.appendSteeringEvent(text), () => stopping);
360
+ if (stopping)
361
+ break;
356
362
  const swarm = new Swarm({
357
363
  tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
358
364
  useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs: cfg.agentTimeoutMs,
@@ -366,7 +372,7 @@ export async function executeRun(cfg) {
366
372
  await swarm.run();
367
373
  }
368
374
  catch (err) {
369
- if (isAuthError(err)) {
375
+ if (isJWTAuthError(err)) {
370
376
  display.stop();
371
377
  restore();
372
378
  console.error(chalk.red(`\n Authentication failed -- check your API key or run: claude auth\n`));
@@ -751,3 +757,51 @@ function checkProjectHealth(cwd) {
751
757
  };
752
758
  }
753
759
  }
760
+ // ── Pre-wave rate limit gate ──
761
+ function sleep(ms) {
762
+ return new Promise(r => setTimeout(r, ms));
763
+ }
764
+ /**
765
+ * Proactive rate-limit gate called before spawning a new wave. Prevents
766
+ * starting a batch of agents when the API is already near or at a limit,
767
+ * which would waste sessions on instant rejections.
768
+ *
769
+ * Thresholds:
770
+ * - any window rejected → wait until resetsAt (or 60s fallback)
771
+ * - utilization >= 90% → wait 60s
772
+ * - utilization >= 75% → wait 15s
773
+ */
774
+ async function throttleBeforeWave(getRL, log, shouldStop) {
775
+ const MAX_ATTEMPTS = 4;
776
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
777
+ if (shouldStop())
778
+ return;
779
+ const rl = getRL();
780
+ // Check for rejected windows
781
+ let rejectedReset;
782
+ for (const w of rl.windows.values()) {
783
+ if (w.status === "rejected" && w.resetsAt && w.resetsAt > Date.now()) {
784
+ if (!rejectedReset || w.resetsAt < rejectedReset)
785
+ rejectedReset = w.resetsAt;
786
+ }
787
+ }
788
+ const highUtil = rl.utilization >= 0.9;
789
+ const elevatedUtil = rl.utilization >= 0.75;
790
+ const explicitRejected = rl.resetsAt && rl.resetsAt > Date.now();
791
+ if (!rejectedReset && !explicitRejected && !highUtil && !elevatedUtil)
792
+ return;
793
+ const waitMs = rejectedReset
794
+ ? Math.max(10_000, rejectedReset - Date.now())
795
+ : explicitRejected
796
+ ? Math.max(10_000, rl.resetsAt - Date.now())
797
+ : highUtil
798
+ ? 60_000 * (1 + attempt)
799
+ : 15_000;
800
+ const reason = rejectedReset ? `Rate limit window blocked`
801
+ : explicitRejected ? "Rate limited"
802
+ : `Utilization ${Math.round(rl.utilization * 100)}%`;
803
+ log(`${reason} -- waiting ${Math.ceil(waitMs / 1000)}s before wave${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`);
804
+ await sleep(waitMs);
805
+ }
806
+ // Exhausted attempts — proceed anyway, swarm's internal retry will handle rejections.
807
+ }
package/dist/swarm.d.ts CHANGED
@@ -110,6 +110,8 @@ export declare class Swarm {
110
110
  private checkStall;
111
111
  private capForOverage;
112
112
  private throttle;
113
+ /** Returns the nearest future resetsAt from any rejected window, or undefined. */
114
+ private windowRejectedReset;
113
115
  private runAgent;
114
116
  private agentSummary;
115
117
  private handleMsg;
package/dist/swarm.js CHANGED
@@ -377,15 +377,23 @@ export class Swarm {
377
377
  const cap = this.usageCap;
378
378
  const capExceeded = cap != null && cap < 1 && this.rateLimitUtilization >= cap;
379
379
  const rejected = this.rateLimitResetsAt && this.rateLimitResetsAt > Date.now();
380
- if (!capExceeded && !rejected)
380
+ // Proactive: check per-window rejections even when rateLimitResetsAt isn't set
381
+ const windowRejected = this.windowRejectedReset();
382
+ // Proactive: near-critical utilization (no cap set but API is clearly strained)
383
+ const nearCritical = cap == null && this.rateLimitUtilization >= 0.95;
384
+ if (!capExceeded && !rejected && !windowRejected && !nearCritical)
381
385
  break;
382
386
  const fallbackMs = Math.min(300_000, 60_000 * (1 + consecutiveWaits * 2));
383
- const waitMs = this.rateLimitResetsAt && this.rateLimitResetsAt > Date.now()
384
- ? Math.max(5000, this.rateLimitResetsAt - Date.now())
385
- : fallbackMs;
387
+ const waitMs = (rejected || windowRejected)
388
+ ? Math.max(5000, (windowRejected ?? this.rateLimitResetsAt) - Date.now())
389
+ : nearCritical
390
+ ? 30_000 * (1 + consecutiveWaits)
391
+ : fallbackMs;
386
392
  const reason = capExceeded
387
393
  ? `Usage at ${Math.round(this.rateLimitUtilization * 100)}% (cap ${Math.round(cap * 100)}%)`
388
- : `Rate limited${this.windowTag()}`;
394
+ : nearCritical
395
+ ? `Near-critical utilization ${Math.round(this.rateLimitUtilization * 100)}%`
396
+ : `Rate limited${this.windowTag()}`;
389
397
  this.log(-1, `${reason} -- waiting ${Math.ceil(waitMs / 1000)}s then retrying ([r] to retry now)`);
390
398
  this.rateLimitPaused++;
391
399
  await this.rateLimitSleep(waitMs);
@@ -398,6 +406,17 @@ export class Swarm {
398
406
  return;
399
407
  }
400
408
  }
409
+ /** Returns the nearest future resetsAt from any rejected window, or undefined. */
410
+ windowRejectedReset() {
411
+ let nearest;
412
+ for (const w of this.rateLimitWindows.values()) {
413
+ if (w.status === "rejected" && w.resetsAt && w.resetsAt > Date.now()) {
414
+ if (!nearest || w.resetsAt < nearest)
415
+ nearest = w.resetsAt;
416
+ }
417
+ }
418
+ return nearest;
419
+ }
401
420
  // ── Agent execution ──
402
421
  async runAgent(task) {
403
422
  const id = this.nextId++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "description": "Background lane for your Claude Max plan. Parallel Claude Agent SDK sessions in git worktrees with a usage cap that reserves headroom for your interactive Claude Code. Crash-safe resume. Opus/Sonnet/Haiku + Qwen/OpenRouter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,13 +15,15 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@anthropic-ai/claude-agent-sdk": "^0.2.92",
18
- "chalk": "^5.4.1"
18
+ "chalk": "^5.4.1",
19
+ "jsonwebtoken": "^9.0.2"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/node": "^22.0.0",
22
23
  "node-pty": "^1.1.0",
23
24
  "strip-ansi": "^7.1.0",
24
- "typescript": "^5.7.0"
25
+ "typescript": "^5.7.0",
26
+ "@types/jsonwebtoken": "^9.0.7"
25
27
  },
26
28
  "license": "MIT",
27
29
  "author": "Francesco Fornace",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "description": "Claude Code skill for understanding, installing, and inspecting claude-overnight runs -- parallel Claude agents in git worktrees with thinking waves, multi-wave steering, and crash-safe resume.",
5
5
  "author": {
6
6
  "name": "Francesco Fornace"