claude-overnight 1.14.0 → 1.16.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/README.md CHANGED
@@ -4,7 +4,7 @@ Run 10, 100, or 1000 Claude agents overnight. Come back to shipped work.
4
4
 
5
5
  Describe what to build. Set a budget. The tool plans, explores your codebase, breaks the objective into tasks, launches parallel agents in isolated git worktrees, iterates toward quality, and handles rate limits automatically. You press Run once, then go to sleep.
6
6
 
7
- Built on the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). Works with Claude Opus, Sonnet, and Haiku.
7
+ Built on the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). Works with Claude Opus, Sonnet, and Haiku — or route executors to Qwen / OpenRouter / any Anthropic-compatible endpoint via the `Other…` picker.
8
8
 
9
9
  ## Install
10
10
 
@@ -38,14 +38,19 @@ claude-overnight
38
38
 
39
39
  ② Budget [10]: 200
40
40
 
41
- Worker model:
41
+ Planner model (thinking, steering — use your strongest):
42
+ ● Opus — Opus 4.6 · Most capable
43
+ ○ Sonnet — Sonnet 4.6 · Best for everyday tasks
44
+
45
+ ⑤ Executor model (what runs the tasks — Qwen/OpenRouter/etc via Other…):
42
46
  ● Sonnet — Sonnet 4.6 · Best for everyday tasks
43
47
  ○ Opus — Opus 4.6 · Most capable
48
+ ○ Other… · custom OpenAI/Anthropic-compatible endpoint
44
49
 
45
- Usage cap:
50
+ Usage cap:
46
51
  ● 90% · leave 10% for other work
47
52
 
48
- Allow extra usage (billed separately):
53
+ Allow extra usage (billed separately):
49
54
  ● No · stop when plan limits are reached
50
55
 
51
56
  ╭──────────────────────────────────────────────────╮
@@ -188,7 +193,7 @@ claude-overnight "fix auth bug in src/auth.ts" "add tests for user model"
188
193
  |---|---|---|
189
194
  | `--budget=N` | `10` | Total agent sessions |
190
195
  | `--concurrency=N` | `5` | Parallel agents |
191
- | `--model=NAME` | prompted | Worker model (planner uses best available) |
196
+ | `--model=NAME` | prompted | Worker model — interactive picks planner + executor separately; `Other…` adds Qwen / OpenRouter / any Anthropic-compat endpoint. In non-interactive mode, a saved provider's model id is auto-resolved to the provider. |
192
197
  | `--usage-cap=N` | unlimited | Stop at N% utilization |
193
198
  | `--allow-extra-usage` | off | Allow extra/overage usage (billed separately) |
194
199
  | `--extra-usage-budget=N` | — | Max $ for extra usage (implies --allow-extra-usage) |
@@ -210,6 +215,36 @@ claude-overnight "fix auth bug in src/auth.ts" "add tests for user model"
210
215
  | `mergeStrategy` | `"yolo" \| "branch"` | `"yolo"` | Merge into HEAD or new branch |
211
216
  | `usageCap` | `number (0-100)` | unlimited | Stop at N% utilization |
212
217
 
218
+ ## Custom providers (Qwen, OpenRouter, anything Anthropic-compatible)
219
+
220
+ Planner and executor are picked separately — pair Opus-on-Anthropic for the planner/thinker with a cheaper model on another provider for the bulk of execution.
221
+
222
+ From the interactive picker, choose `Other…` on the planner or executor step:
223
+
224
+ ```
225
+ ⑤ Executor model (what runs the tasks — Qwen/OpenRouter/etc via Other…):
226
+ ○ Sonnet
227
+ ○ Opus
228
+ ● Other…
229
+
230
+ Name: Qwen Coder
231
+ Base URL: https://dashscope-intl.aliyuncs.com/api/v2/apps/claude-code-proxy
232
+ Model id: qwen3-coder-plus
233
+ API key source:
234
+ ● Paste key now · stored plaintext in ~/.claude/claude-overnight/providers.json (0600)
235
+ ○ Read from env var · nothing written to disk
236
+ ```
237
+
238
+ Saved providers live user-level at `~/.claude/claude-overnight/providers.json` (mode 0600) and show up automatically in every repo. No per-project config.
239
+
240
+ **How routing works.** Each `query()` gets its own env override (`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`) — planner queries use the planner provider, executor queries use the executor provider. No global shell env, no proxy daemon, no `process.env` pollution between calls.
241
+
242
+ **Pre-flight.** Before the swarm starts, each custom provider is pinged with a 1-turn auth check. Bad keys fail fast with `✗ executor preflight failed: ...` instead of N scattered mid-run errors.
243
+
244
+ **Resume.** Provider ids are persisted in `run.json` and rehydrated on resume. If you deleted a provider between runs, resume refuses to start and tells you exactly which id is missing.
245
+
246
+ **Non-interactive / CI.** `claude-overnight --model=qwen3-coder-plus` auto-resolves the model id to a saved provider — no separate `--provider` flag.
247
+
213
248
  ## Usage controls
214
249
 
215
250
  ### Extra usage protection
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // Tiny launcher: prints a splash the instant node is ready, then dynamically
3
+ // imports the real entrypoint. Loading `@anthropic-ai/claude-agent-sdk` and the
4
+ // rest of the module graph takes several seconds on a cold cache — without
5
+ // this, the terminal sits black that whole time. index.ts stops the splash
6
+ // via `globalThis.__coStopSplash` as soon as its header is about to print.
7
+ const argv = process.argv.slice(2);
8
+ const quiet = argv.includes("-h") || argv.includes("--help") || argv.includes("-v") || argv.includes("--version");
9
+ if (!quiet && process.stdout.isTTY) {
10
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+ let i = 0;
12
+ const render = () => process.stdout.write(`\r\x1b[2K 🌙 \x1b[1mclaude-overnight\x1b[0m \x1b[2m${frames[i++ % frames.length]} starting…\x1b[0m`);
13
+ render();
14
+ const timer = setInterval(render, 120);
15
+ let stopped = false;
16
+ const stop = () => {
17
+ if (stopped)
18
+ return;
19
+ stopped = true;
20
+ clearInterval(timer);
21
+ process.stdout.write("\r\x1b[2K");
22
+ };
23
+ globalThis.__coStopSplash = stop;
24
+ process.once("exit", stop);
25
+ }
26
+ await import("./index.js");
27
+ export {};
package/dist/cli.js CHANGED
@@ -55,7 +55,7 @@ export async function fetchModels(timeoutMs = 10_000) {
55
55
  clearTimeout(timer);
56
56
  q?.close();
57
57
  if (err.message === "model_fetch_timeout") {
58
- console.warn(chalk.yellow("\n Model fetch timed out continuing with defaults"));
58
+ // Silent: callers fall back to a text prompt with the current value as default.
59
59
  }
60
60
  else if (isAuthError(err)) {
61
61
  console.error(chalk.red("\n Authentication failed — check your API key or run: claude auth\n"));
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@ import chalk from "chalk";
6
6
  import { query } from "@anthropic-ai/claude-agent-sdk";
7
7
  import { Swarm } from "./swarm.js";
8
8
  import { planTasks, refinePlan, identifyThemes, buildThinkingTasks, orchestrate, salvageFromFile } from "./planner.js";
9
- import { detectModelTier } from "./planner-query.js";
9
+ import { detectModelTier, setPlannerEnvResolver } from "./planner-query.js";
10
+ import { pickModel, loadProviders, preflightProvider, buildEnvResolver } from "./providers.js";
10
11
  import { RunDisplay } from "./ui.js";
11
12
  import { renderSummary } from "./render.js";
12
13
  import { executeRun } from "./run.js";
@@ -60,6 +61,8 @@ async function promptResumeOverrides(state, cliFlags, argv, noTTY, runDir) {
60
61
  catch { }
61
62
  return;
62
63
  }
64
+ // Kick off model fetch in the background so it's ready if the user picks Edit.
65
+ const modelsPromise = fetchModels(20_000).catch(() => []);
63
66
  // ── Interactive review ──
64
67
  const fmtSummary = () => {
65
68
  const tier = detectModelTier(state.workerModel);
@@ -88,16 +91,21 @@ async function promptResumeOverrides(state, cliFlags, argv, noTTY, runDir) {
88
91
  if (action === "r")
89
92
  return;
90
93
  // ── Edit walk ──
91
- const models = await fetchModels(5_000);
92
- if (models.length > 0) {
93
- const currentIdx = Math.max(0, models.findIndex(m => m.value === state.workerModel));
94
- state.workerModel = await select(`${chalk.cyan("①")} Worker model:`, models.map(m => ({ name: m.displayName, value: m.value, hint: m.description })), currentIdx);
94
+ let modelFrame = 0;
95
+ const modelSpinner = setInterval(() => {
96
+ process.stdout.write(`\x1B[2K\r ${chalk.cyan(BRAILLE[modelFrame++ % BRAILLE.length])} ${chalk.dim("loading models...")}`);
97
+ }, 120);
98
+ let models;
99
+ try {
100
+ models = await modelsPromise;
95
101
  }
96
- else {
97
- const ans = await ask(`\n ${chalk.cyan("①")} Worker model ${chalk.dim(`[${state.workerModel}]:`)} `);
98
- if (ans.trim())
99
- state.workerModel = ans.trim();
102
+ finally {
103
+ clearInterval(modelSpinner);
104
+ process.stdout.write(`\x1B[2K\r`);
100
105
  }
106
+ const pick = await pickModel(`${chalk.cyan("①")} Worker model:`, models, state.workerProviderId ?? state.workerModel);
107
+ state.workerModel = pick.model;
108
+ state.workerProviderId = pick.providerId;
101
109
  const remAns = await ask(`\n ${chalk.cyan("②")} Remaining sessions ${chalk.dim(`[${state.remaining}]:`)} `);
102
110
  const parsedRem = parseInt(remAns);
103
111
  if (!isNaN(parsedRem) && parsedRem > 0) {
@@ -172,7 +180,7 @@ async function main() {
172
180
  --dry-run Show planned tasks without running them
173
181
  --budget=N Target number of agent runs ${chalk.dim("(default: 10)")}
174
182
  --concurrency=N Max parallel agents ${chalk.dim("(default: 5)")}
175
- --model=NAME Worker model override ${chalk.dim("(planner always uses best available)")}
183
+ --model=NAME Worker model override ${chalk.dim("(interactive mode picks planner + executor separately — supports 'Other…' for Qwen / OpenRouter / etc.)")}
176
184
  --usage-cap=N Stop at N% utilization ${chalk.dim("(e.g. 90 to save 10% for other work)")}
177
185
  --allow-extra-usage Allow extra/overage usage ${chalk.dim("(default: stop when plan limits hit)")}
178
186
  --extra-usage-budget=N Max $ for extra usage ${chalk.dim("(implies --allow-extra-usage)")}
@@ -235,7 +243,9 @@ async function main() {
235
243
  }
236
244
  }
237
245
  // ── Mode detection ──
238
- console.log(`\n ${chalk.bold("🌙 claude-overnight")}`);
246
+ // Stop the bin.ts startup splash (if any) before printing our header.
247
+ globalThis.__coStopSplash?.();
248
+ console.log(` ${chalk.bold("🌙 claude-overnight")}`);
239
249
  console.log(chalk.dim(` ${"─".repeat(36)}`));
240
250
  const noTTY = !process.stdin.isTTY;
241
251
  const nonInteractive = noTTY || fileCfg !== undefined || tasks.length > 0;
@@ -443,6 +453,8 @@ async function main() {
443
453
  // ── Config resolution ──
444
454
  let workerModel;
445
455
  let plannerModel;
456
+ let workerProvider;
457
+ let plannerProvider;
446
458
  let budget;
447
459
  let concurrency;
448
460
  let objective = fileCfg?.objective;
@@ -455,6 +467,23 @@ async function main() {
455
467
  if (resuming) {
456
468
  workerModel = resumeState.workerModel;
457
469
  plannerModel = resumeState.plannerModel;
470
+ const saved = loadProviders();
471
+ if (resumeState.workerProviderId) {
472
+ workerProvider = saved.find(p => p.id === resumeState.workerProviderId);
473
+ if (!workerProvider) {
474
+ console.error(chalk.red(`\n Resume aborted: worker provider "${resumeState.workerProviderId}" is no longer in ~/.claude/claude-overnight/providers.json`));
475
+ console.error(chalk.dim(` Re-add it via a fresh run's "Other…" flow, or start Fresh instead.\n`));
476
+ process.exit(1);
477
+ }
478
+ }
479
+ if (resumeState.plannerProviderId) {
480
+ plannerProvider = saved.find(p => p.id === resumeState.plannerProviderId);
481
+ if (!plannerProvider) {
482
+ console.error(chalk.red(`\n Resume aborted: planner provider "${resumeState.plannerProviderId}" is no longer in ~/.claude/claude-overnight/providers.json`));
483
+ console.error(chalk.dim(` Re-add it via a fresh run's "Other…" flow, or start Fresh instead.\n`));
484
+ process.exit(1);
485
+ }
486
+ }
458
487
  budget = resumeState.budget;
459
488
  concurrency = resumeState.concurrency;
460
489
  objective = resumeState.objective;
@@ -506,21 +535,19 @@ async function main() {
506
535
  clearInterval(modelSpinner);
507
536
  process.stdout.write(`\x1B[2K\r`);
508
537
  }
509
- plannerModel = models[0]?.value || "claude-sonnet-4-6";
510
- if (models.length > 0) {
511
- workerModel = await select(`${chalk.cyan("④")} Worker model:`, models.map(m => ({ name: m.displayName, value: m.value, hint: m.description })));
512
- }
513
- else {
514
- const ans = await ask(` ${chalk.cyan("④")} ${chalk.dim("Worker model [claude-sonnet-4-6]:")} `);
515
- workerModel = ans || "claude-sonnet-4-6";
516
- }
517
- usageCap = await select(`${chalk.cyan("⑤")} Usage cap:`, [
538
+ const plannerPick = await pickModel(`${chalk.cyan("④")} Planner model ${chalk.dim("(thinking, steering — use your strongest)")}:`, models);
539
+ plannerModel = plannerPick.model;
540
+ plannerProvider = plannerPick.provider;
541
+ const workerPick = await pickModel(`${chalk.cyan("⑤")} Executor model ${chalk.dim("(what runs the tasks — Qwen/OpenRouter/etc via Other…)")}:`, models);
542
+ workerModel = workerPick.model;
543
+ workerProvider = workerPick.provider;
544
+ usageCap = await select(`${chalk.cyan("")} Usage cap:`, [
518
545
  { name: "Unlimited", value: undefined, hint: "full capacity, wait through rate limits" },
519
546
  { name: "90%", value: 0.9, hint: "leave 10% for other work" },
520
547
  { name: "75%", value: 0.75, hint: "conservative, plenty of headroom" },
521
548
  { name: "50%", value: 0.5, hint: "use half, keep the rest" },
522
549
  ]);
523
- const extraChoice = await select(`${chalk.cyan("")} Allow extra usage ${chalk.dim("(billed separately)")}:`, [
550
+ const extraChoice = await select(`${chalk.cyan("")} Allow extra usage ${chalk.dim("(billed separately)")}:`, [
524
551
  { name: "No", value: "no", hint: "stop when plan limits are reached" },
525
552
  { name: "Yes, with $ limit", value: "budget", hint: "set a spending cap" },
526
553
  { name: "Yes, unlimited", value: "unlimited", hint: "keep going no matter what" },
@@ -534,7 +561,7 @@ async function main() {
534
561
  }
535
562
  else if (extraChoice === "unlimited")
536
563
  allowExtraUsage = true;
537
- // Permission mode (skip if --yolo or --perm set)
564
+ // Permission mode (skip if --yolo or --perm set)
538
565
  const cliYolo = argv.includes("--yolo");
539
566
  if (cliFlags.perm) {
540
567
  permissionMode = cliFlags.perm;
@@ -543,13 +570,13 @@ async function main() {
543
570
  permissionMode = "bypassPermissions";
544
571
  }
545
572
  else {
546
- permissionMode = await select(`${chalk.cyan("")} Permissions:`, [
573
+ permissionMode = await select(`${chalk.cyan("")} Permissions:`, [
547
574
  { name: "Auto", value: "auto", hint: "accept low-risk, reject high-risk" },
548
575
  { name: "Bypass all", value: "bypassPermissions", hint: "agents can run anything (yolo)" },
549
576
  { name: "Prompt each", value: "default", hint: "ask for every dangerous op" },
550
577
  ]);
551
578
  }
552
- // Worktrees + merge (skip if --yolo, --worktrees, --no-worktrees, or --merge set)
579
+ // Worktrees + merge (skip if --yolo, --worktrees, --no-worktrees, or --merge set)
553
580
  const gitRepo = isGitRepo(cwd);
554
581
  if (cliYolo || argv.includes("--no-worktrees")) {
555
582
  useWorktrees = false;
@@ -560,7 +587,7 @@ async function main() {
560
587
  mergeStrategy = cliFlags.merge || "yolo";
561
588
  }
562
589
  else if (gitRepo) {
563
- const wtChoice = await select(`${chalk.cyan("")} Git isolation:`, [
590
+ const wtChoice = await select(`${chalk.cyan("")} Git isolation:`, [
564
591
  { name: "Worktrees + yolo merge", value: "wt-yolo", hint: "isolate agents, merge into current branch" },
565
592
  { name: "Worktrees + new branch", value: "wt-branch", hint: "isolate agents, merge into a new branch" },
566
593
  { name: "No worktrees", value: "no-wt", hint: "all agents share the working directory" },
@@ -603,6 +630,14 @@ async function main() {
603
630
  models = await fetchModels(5_000);
604
631
  workerModel = cliFlags.model ?? fileCfg?.model ?? (models[0]?.value || "claude-sonnet-4-6");
605
632
  plannerModel = models[0]?.value || workerModel;
633
+ // Auto-resolve a saved custom provider if --model matches its id or model id.
634
+ // Lets `claude-overnight --model=qwen3-coder-plus` route correctly without a separate flag.
635
+ const savedForCli = loadProviders();
636
+ const matched = savedForCli.find(p => p.id === workerModel || p.model === workerModel);
637
+ if (matched) {
638
+ workerProvider = matched;
639
+ workerModel = matched.model;
640
+ }
606
641
  concurrency = cliFlags.concurrency ? parseInt(cliFlags.concurrency) : (fileCfg?.concurrency ?? 5);
607
642
  budget = cliFlags.budget ? parseInt(cliFlags.budget) : undefined;
608
643
  if (budget != null && (isNaN(budget) || budget < 1)) {
@@ -655,6 +690,29 @@ async function main() {
655
690
  }
656
691
  if (useWorktrees)
657
692
  validateGitRepo(cwd);
693
+ // Custom-provider routing: build a model→env resolver so planner and worker
694
+ // queries hit the right endpoint without touching process.env globally.
695
+ const envForModel = buildEnvResolver({ plannerModel, plannerProvider, workerModel, workerProvider });
696
+ setPlannerEnvResolver(envForModel);
697
+ // Fail fast if a custom provider is misconfigured — one bad key would
698
+ // otherwise surface as N agent failures scattered across the run.
699
+ if (plannerProvider || workerProvider) {
700
+ const pending = [];
701
+ if (plannerProvider)
702
+ pending.push(["planner", plannerProvider]);
703
+ if (workerProvider && workerProvider.id !== plannerProvider?.id)
704
+ pending.push(["executor", workerProvider]);
705
+ for (const [role, p] of pending) {
706
+ process.stdout.write(` ${chalk.dim(`◆ Pinging ${role} (${p.displayName})...`)}`);
707
+ const r = await preflightProvider(p, cwd);
708
+ if (!r.ok) {
709
+ process.stdout.write(`\x1B[2K\r ${chalk.red(`✗ ${role} preflight failed:`)} ${chalk.dim(r.error)}\n`);
710
+ console.error(chalk.red(`\n Fix the provider at ~/.claude/claude-overnight/providers.json and retry.\n`));
711
+ process.exit(1);
712
+ }
713
+ process.stdout.write(`\x1B[2K\r ${chalk.green(`✓ ${role} ready`)} ${chalk.dim(`· ${p.displayName} · ${p.model}`)}\n`);
714
+ }
715
+ }
658
716
  if (nonInteractive) {
659
717
  const capStr = usageCap != null ? ` cap=${Math.round(usageCap * 100)}%` : "";
660
718
  const extraStr = allowExtraUsage ? (extraUsageBudget ? ` extra=$${extraUsageBudget}` : " extra=∞") : " extra=off";
@@ -681,7 +739,9 @@ async function main() {
681
739
  saveRunState(runDir, {
682
740
  id: runDir.split(/[/\\]/).pop() ?? "",
683
741
  objective, budget: budget ?? 10, remaining: budget ?? 10,
684
- workerModel, plannerModel, concurrency, permissionMode,
742
+ workerModel, plannerModel,
743
+ workerProviderId: workerProvider?.id, plannerProviderId: plannerProvider?.id,
744
+ concurrency, permissionMode,
685
745
  usageCap, allowExtraUsage, extraUsageBudget,
686
746
  flex, useWorktrees, mergeStrategy,
687
747
  waveNum: 0, currentTasks: [],
@@ -740,9 +800,10 @@ async function main() {
740
800
  process.stdout.write("\x1B[?25l");
741
801
  try {
742
802
  let answer = "";
803
+ const plannerEnv = envForModel(plannerModel);
743
804
  for await (const msg of query({
744
805
  prompt: `You're planning work for: "${objective}"\n\nThemes identified:\n${themes.map((t, i) => `${i + 1}. ${t}`).join("\n")}\n\nUser question: ${question}`,
745
- options: { cwd, model: plannerModel, permissionMode, persistSession: false },
806
+ options: { cwd, model: plannerModel, permissionMode, persistSession: false, ...(plannerEnv && { env: plannerEnv }) },
746
807
  })) {
747
808
  if (msg.type === "result" && msg.subtype === "success")
748
809
  answer = msg.result || "";
@@ -782,6 +843,7 @@ async function main() {
782
843
  const thinkingSwarm = new Swarm({
783
844
  tasks: thinkingTasks, concurrency, cwd, model: plannerModel, permissionMode,
784
845
  useWorktrees: false, mergeStrategy: "yolo", agentTimeoutMs, usageCap, allowExtraUsage, extraUsageBudget,
846
+ envForModel,
785
847
  });
786
848
  const thinkRunInfo = { accIn: 0, accOut: 0, accCost: 0, accCompleted: 0, accFailed: 0, sessionsBudget: budget ?? 10, waveNum: -1, remaining: budget ?? 10, model: plannerModel, startedAt: Date.now() };
787
849
  const thinkDisplay = new RunDisplay(thinkRunInfo, { remaining: 0, usageCap, concurrency, paused: false, dirty: false });
@@ -808,7 +870,9 @@ async function main() {
808
870
  saveRunState(runDir, {
809
871
  id: runDir.split(/[/\\]/).pop() ?? "",
810
872
  objective: objective, budget: budget ?? 10, remaining: (budget ?? 10) - thinkingUsed,
811
- workerModel, plannerModel, concurrency, permissionMode,
873
+ workerModel, plannerModel,
874
+ workerProviderId: workerProvider?.id, plannerProviderId: plannerProvider?.id,
875
+ concurrency, permissionMode,
812
876
  usageCap, allowExtraUsage, extraUsageBudget,
813
877
  flex, useWorktrees, mergeStrategy,
814
878
  waveNum: 0, currentTasks: [],
@@ -883,9 +947,10 @@ async function main() {
883
947
  process.stdout.write("\x1B[?25l");
884
948
  try {
885
949
  let answer = "";
950
+ const plannerEnv = envForModel(plannerModel);
886
951
  for await (const msg of query({
887
952
  prompt: `You planned these tasks for the objective "${objective}":\n${tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join("\n")}\n\nUser question: ${question}`,
888
- options: { cwd, model: plannerModel, permissionMode, persistSession: false },
953
+ options: { cwd, model: plannerModel, permissionMode, persistSession: false, ...(plannerEnv && { env: plannerEnv }) },
889
954
  })) {
890
955
  if (msg.type === "result" && msg.subtype === "success")
891
956
  answer = msg.result || "";
@@ -926,7 +991,8 @@ async function main() {
926
991
  }
927
992
  // ── Execute ──
928
993
  await executeRun({
929
- tasks, objective, budget: budget ?? tasks.length, workerModel, plannerModel, concurrency,
994
+ tasks, objective, budget: budget ?? tasks.length, workerModel, plannerModel,
995
+ workerProvider, plannerProvider, concurrency,
930
996
  permissionMode, useWorktrees, mergeStrategy, usageCap, allowExtraUsage, extraUsageBudget,
931
997
  flex, agentTimeoutMs, cwd, allowedTools, runDir, previousKnowledge,
932
998
  resuming, resumeState: resumeState ?? undefined,
@@ -24,6 +24,7 @@ export interface PlannerOpts {
24
24
  schema: Record<string, unknown>;
25
25
  };
26
26
  }
27
+ export declare function setPlannerEnvResolver(fn: ((model?: string) => Record<string, string> | undefined) | undefined): void;
27
28
  export type ModelTier = "opus" | "sonnet" | "haiku" | "unknown";
28
29
  export declare function detectModelTier(model: string): ModelTier;
29
30
  export declare function modelCapabilityBlock(model: string): string;
@@ -1,6 +1,15 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { readFileSync } from "fs";
3
3
  import { NudgeError } from "./types.js";
4
+ // ── Shared env resolver (set once at run start, used by every planner query) ──
5
+ //
6
+ // Swarm and planner calls share a model→env map so a custom provider configured
7
+ // as planner or worker routes its traffic without threading extra params
8
+ // through every planner.ts / steering.ts function.
9
+ let _envResolver;
10
+ export function setPlannerEnvResolver(fn) {
11
+ _envResolver = fn;
12
+ }
4
13
  export function detectModelTier(model) {
5
14
  const m = model.toLowerCase();
6
15
  if (m === "default" || m.includes("opus"))
@@ -77,6 +86,7 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
77
86
  let structuredOutput;
78
87
  const startedAt = Date.now();
79
88
  const isResume = !!opts.resumeSessionId;
89
+ const envOverride = _envResolver?.(opts.model);
80
90
  const pq = query({
81
91
  prompt,
82
92
  options: {
@@ -90,6 +100,7 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
90
100
  includePartialMessages: true,
91
101
  ...(isResume && { resume: opts.resumeSessionId }),
92
102
  ...(opts.outputFormat && { outputFormat: opts.outputFormat }),
103
+ ...(envOverride && { env: envOverride }),
93
104
  },
94
105
  });
95
106
  let lastLogText = "";
@@ -0,0 +1,61 @@
1
+ import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * A non-Anthropic model provider reachable via an Anthropic-compatible endpoint
4
+ * (e.g. DashScope for Qwen, OpenRouter, a local proxy). Stored user-level so a
5
+ * key configured once works across every repo.
6
+ */
7
+ export interface ProviderConfig {
8
+ id: string;
9
+ displayName: string;
10
+ baseURL: string;
11
+ model: string;
12
+ /** Env var name holding the key — preferred over inline `key` (nothing on disk). */
13
+ keyEnv?: string;
14
+ /** Inline API key. Stored plaintext in providers.json (mode 0600). */
15
+ key?: string;
16
+ }
17
+ export declare function getStorePath(): string;
18
+ export declare function loadProviders(): ProviderConfig[];
19
+ export declare function saveProvider(p: ProviderConfig): void;
20
+ export declare function deleteProvider(id: string): void;
21
+ export declare function resolveKey(p: ProviderConfig): string | null;
22
+ /**
23
+ * Build the env overrides for a custom provider. Returns a full merged env
24
+ * (including current process.env) because the SDK replaces, not merges, when
25
+ * you pass `options.env`.
26
+ */
27
+ export declare function envFor(p: ProviderConfig): Record<string, string>;
28
+ export interface ModelPick {
29
+ model: string;
30
+ providerId?: string;
31
+ provider?: ProviderConfig;
32
+ }
33
+ /**
34
+ * Show a unified picker: Anthropic models (from SDK), saved custom providers,
35
+ * and an "Other…" entry that walks the user through adding a new provider.
36
+ * Returns the selected model string and, if it's a custom provider, the id.
37
+ */
38
+ export declare function pickModel(label: string, anthropicModels: ModelInfo[], currentModelId?: string): Promise<ModelPick>;
39
+ /**
40
+ * Cheap auth check: spawn a 1-turn query against the provider and fail fast
41
+ * if the key is wrong or the endpoint is unreachable. Timeout is aggressive
42
+ * so misconfig doesn't delay the main run.
43
+ */
44
+ export declare function preflightProvider(p: ProviderConfig, cwd: string, timeoutMs?: number): Promise<{
45
+ ok: true;
46
+ } | {
47
+ ok: false;
48
+ error: string;
49
+ }>;
50
+ export type EnvResolver = (model?: string) => Record<string, string> | undefined;
51
+ /**
52
+ * Build a single resolver that swarm.ts and planner-query.ts share. Maps a
53
+ * model string to the env overrides that should be passed to `query()`.
54
+ * Returns undefined for Anthropic-native models (let the SDK use process.env).
55
+ */
56
+ export declare function buildEnvResolver(opts: {
57
+ plannerModel: string;
58
+ plannerProvider?: ProviderConfig;
59
+ workerModel: string;
60
+ workerProvider?: ProviderConfig;
61
+ }): EnvResolver;
@@ -0,0 +1,243 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import chalk from "chalk";
5
+ import { query } from "@anthropic-ai/claude-agent-sdk";
6
+ import { ask, select } from "./cli.js";
7
+ // ── Store ──
8
+ const STORE_PATH = join(homedir(), ".claude", "claude-overnight", "providers.json");
9
+ export function getStorePath() { return STORE_PATH; }
10
+ export function loadProviders() {
11
+ try {
12
+ const raw = readFileSync(STORE_PATH, "utf-8");
13
+ const parsed = JSON.parse(raw);
14
+ if (Array.isArray(parsed?.providers))
15
+ return parsed.providers.filter(isValidProvider);
16
+ }
17
+ catch { }
18
+ return [];
19
+ }
20
+ export function saveProvider(p) {
21
+ const all = loadProviders().filter(x => x.id !== p.id);
22
+ all.push(p);
23
+ mkdirSync(join(homedir(), ".claude", "claude-overnight"), { recursive: true });
24
+ writeFileSync(STORE_PATH, JSON.stringify({ providers: all }, null, 2), "utf-8");
25
+ try {
26
+ chmodSync(STORE_PATH, 0o600);
27
+ }
28
+ catch { }
29
+ }
30
+ export function deleteProvider(id) {
31
+ const all = loadProviders().filter(x => x.id !== id);
32
+ if (!existsSync(STORE_PATH))
33
+ return;
34
+ writeFileSync(STORE_PATH, JSON.stringify({ providers: all }, null, 2), "utf-8");
35
+ try {
36
+ chmodSync(STORE_PATH, 0o600);
37
+ }
38
+ catch { }
39
+ }
40
+ function isValidProvider(p) {
41
+ return p && typeof p.id === "string" && typeof p.baseURL === "string"
42
+ && typeof p.model === "string" && typeof p.displayName === "string";
43
+ }
44
+ // ── Key resolution ──
45
+ export function resolveKey(p) {
46
+ if (p.keyEnv) {
47
+ const v = process.env[p.keyEnv];
48
+ return v && v.trim() ? v : null;
49
+ }
50
+ return p.key && p.key.trim() ? p.key : null;
51
+ }
52
+ /**
53
+ * Build the env overrides for a custom provider. Returns a full merged env
54
+ * (including current process.env) because the SDK replaces, not merges, when
55
+ * you pass `options.env`.
56
+ */
57
+ 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
+ const base = {};
62
+ for (const [k, v] of Object.entries(process.env))
63
+ if (v !== undefined)
64
+ base[k] = v;
65
+ base.ANTHROPIC_BASE_URL = p.baseURL;
66
+ base.ANTHROPIC_AUTH_TOKEN = key;
67
+ delete base.ANTHROPIC_API_KEY;
68
+ return base;
69
+ }
70
+ /**
71
+ * Show a unified picker: Anthropic models (from SDK), saved custom providers,
72
+ * and an "Other…" entry that walks the user through adding a new provider.
73
+ * Returns the selected model string and, if it's a custom provider, the id.
74
+ */
75
+ export async function pickModel(label, anthropicModels, currentModelId) {
76
+ for (;;) {
77
+ const saved = loadProviders();
78
+ const items = [];
79
+ for (const m of anthropicModels) {
80
+ items.push({ name: m.displayName, value: { kind: "anthropic", model: m }, hint: m.description });
81
+ }
82
+ // Network-failed fallback: ensure the picker always has at least one Anthropic
83
+ // entry so the user isn't trapped if they cancel the Other… form.
84
+ if (anthropicModels.length === 0) {
85
+ items.push({
86
+ name: "claude-sonnet-4-6",
87
+ value: { kind: "anthropic", model: { value: "claude-sonnet-4-6", displayName: "claude-sonnet-4-6", description: "default (model list unavailable)" } },
88
+ hint: "default — Anthropic model list unavailable",
89
+ });
90
+ }
91
+ for (const p of saved) {
92
+ const keySrc = p.keyEnv ? `env ${p.keyEnv}` : "stored key";
93
+ items.push({ name: `${p.displayName}`, value: { kind: "provider", provider: p }, hint: `${p.model} · ${keySrc}` });
94
+ }
95
+ items.push({ name: chalk.cyan("Other…"), value: { kind: "other" }, hint: "custom OpenAI/Anthropic-compatible endpoint" });
96
+ let defaultIdx = 0;
97
+ if (currentModelId) {
98
+ const i = items.findIndex(it => {
99
+ if (it.value.kind === "anthropic")
100
+ return it.value.model.value === currentModelId;
101
+ if (it.value.kind === "provider")
102
+ return it.value.provider.id === currentModelId || it.value.provider.model === currentModelId;
103
+ return false;
104
+ });
105
+ if (i >= 0)
106
+ defaultIdx = i;
107
+ }
108
+ const picked = await select(label, items, defaultIdx);
109
+ if (picked.kind === "anthropic")
110
+ return { model: picked.model.value };
111
+ if (picked.kind === "provider") {
112
+ return { model: picked.provider.model, providerId: picked.provider.id, provider: picked.provider };
113
+ }
114
+ const added = await promptNewProvider();
115
+ if (added) {
116
+ saveProvider(added);
117
+ return { model: added.model, providerId: added.id, provider: added };
118
+ }
119
+ // user cancelled "Other…" — loop back to picker
120
+ }
121
+ }
122
+ async function promptNewProvider() {
123
+ console.log(chalk.dim("\n Add a custom provider (Anthropic-compatible endpoint)"));
124
+ console.log(chalk.dim(" Leave blank to cancel.\n"));
125
+ const displayName = await ask(` ${chalk.cyan("Name")} ${chalk.dim("(e.g. 'Qwen Coder'):")} `);
126
+ if (!displayName)
127
+ return null;
128
+ const id = slugify(displayName);
129
+ const baseURLRaw = await ask(`\n ${chalk.cyan("Base URL")} ${chalk.dim("(e.g. https://dashscope-intl.aliyuncs.com/api/v2/apps/claude-code-proxy):")} `);
130
+ if (!baseURLRaw)
131
+ return null;
132
+ const baseURL = normalizeBaseURL(baseURLRaw);
133
+ const model = await ask(`\n ${chalk.cyan("Model id")} ${chalk.dim("(e.g. qwen3-coder-plus):")} `);
134
+ if (!model)
135
+ return null;
136
+ const keyMode = await select(` ${chalk.cyan("API key source")}:`, [
137
+ { name: "Paste key now", value: "inline", hint: "stored plaintext in ~/.claude/claude-overnight/providers.json (0600)" },
138
+ { name: "Read from env var", value: "env", hint: "nothing written to disk" },
139
+ ]);
140
+ if (keyMode === "env") {
141
+ const envName = await ask(`\n ${chalk.cyan("Env var name")} ${chalk.dim(`(e.g. CO_KEY_${id.toUpperCase()}):`)} `);
142
+ if (!envName)
143
+ return null;
144
+ if (!process.env[envName]) {
145
+ console.log(chalk.yellow(`\n ⚠ ${envName} is not set in the current shell — you'll need to export it before running.`));
146
+ }
147
+ return { id, displayName, baseURL, model, keyEnv: envName };
148
+ }
149
+ const key = await ask(`\n ${chalk.cyan("API key")}: `);
150
+ if (!key)
151
+ return null;
152
+ return { id, displayName, baseURL, model, key };
153
+ }
154
+ function slugify(s) {
155
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "provider";
156
+ }
157
+ /** Strip trailing slashes and common endpoint suffixes users paste by mistake. */
158
+ function normalizeBaseURL(raw) {
159
+ let url = raw.trim().replace(/\/+$/, "");
160
+ url = url.replace(/\/v1\/messages$/i, "").replace(/\/messages$/i, "");
161
+ return url;
162
+ }
163
+ // ── Pre-flight validation ──
164
+ /**
165
+ * Cheap auth check: spawn a 1-turn query against the provider and fail fast
166
+ * if the key is wrong or the endpoint is unreachable. Timeout is aggressive
167
+ * so misconfig doesn't delay the main run.
168
+ */
169
+ export async function preflightProvider(p, cwd, timeoutMs = 20_000) {
170
+ let env;
171
+ try {
172
+ env = envFor(p);
173
+ }
174
+ catch (err) {
175
+ return { ok: false, error: err.message };
176
+ }
177
+ let pq;
178
+ try {
179
+ pq = query({
180
+ prompt: "Reply with exactly the word ok and nothing else.",
181
+ options: {
182
+ cwd,
183
+ model: p.model,
184
+ env,
185
+ permissionMode: "bypassPermissions",
186
+ allowDangerouslySkipPermissions: true,
187
+ maxTurns: 1,
188
+ persistSession: false,
189
+ },
190
+ });
191
+ const stream = pq;
192
+ const consume = (async () => {
193
+ for await (const msg of stream) {
194
+ if (msg.type === "result") {
195
+ if (msg.subtype !== "success") {
196
+ return { ok: false, error: String(msg.result || msg.subtype || "unknown error").slice(0, 200) };
197
+ }
198
+ return { ok: true };
199
+ }
200
+ }
201
+ return { ok: false, error: "no result received" };
202
+ })();
203
+ const timeout = new Promise((resolve) => {
204
+ setTimeout(() => {
205
+ try {
206
+ stream.interrupt().catch(() => stream.close());
207
+ }
208
+ catch { }
209
+ resolve({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s` });
210
+ }, timeoutMs);
211
+ });
212
+ return await Promise.race([consume, timeout]);
213
+ }
214
+ catch (err) {
215
+ return { ok: false, error: String(err?.message || err).slice(0, 200) };
216
+ }
217
+ finally {
218
+ try {
219
+ pq?.close();
220
+ }
221
+ catch { }
222
+ }
223
+ }
224
+ /**
225
+ * Build a single resolver that swarm.ts and planner-query.ts share. Maps a
226
+ * model string to the env overrides that should be passed to `query()`.
227
+ * Returns undefined for Anthropic-native models (let the SDK use process.env).
228
+ */
229
+ export function buildEnvResolver(opts) {
230
+ const byModel = new Map();
231
+ if (opts.plannerProvider)
232
+ byModel.set(opts.plannerModel, opts.plannerProvider);
233
+ if (opts.workerProvider)
234
+ byModel.set(opts.workerModel, opts.workerProvider);
235
+ if (byModel.size === 0)
236
+ return () => undefined;
237
+ return (model) => {
238
+ if (!model)
239
+ return undefined;
240
+ const p = byModel.get(model);
241
+ return p ? envFor(p) : undefined;
242
+ };
243
+ }
package/dist/render.js CHANGED
@@ -1,9 +1,6 @@
1
1
  import chalk from "chalk";
2
+ import { RATE_LIMIT_WINDOW_SHORT } from "./types.js";
2
3
  const SPINNER = ["|", "/", "-", "\\"];
3
- const WINDOW_SHORT_NAMES = {
4
- five_hour: "5h", seven_day: "7d", seven_day_opus: "7d op",
5
- seven_day_sonnet: "7d sn", overage: "extra",
6
- };
7
4
  // ── Shared helpers ──
8
5
  export function truncate(s, max) {
9
6
  return s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
@@ -90,13 +87,23 @@ function renderUsageBars(out, w, swarm) {
90
87
  ? chalk.red(`Budget $${swarm.extraUsageBudget} exhausted \u2014 finishing active`)
91
88
  : chalk.yellow(`Capped at ${capFrac != null ? Math.round(capFrac * 100) : 100}% \u2014 finishing active`);
92
89
  }
93
- else if (swarm.rateLimitPaused > 0) {
94
- label = chalk.yellow(`Cooling down \u2014 ${swarm.rateLimitPaused} worker(s) waiting`);
95
- }
96
- else if (swarm.rateLimitResetsAt && swarm.rateLimitResetsAt > Date.now()) {
97
- const waitSec = Math.ceil((swarm.rateLimitResetsAt - Date.now()) / 1000);
98
- const mm = Math.floor(waitSec / 60), ss = waitSec % 60;
99
- label = chalk.red(`Waiting for reset ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`);
90
+ else if (swarm.rateLimitPaused > 0 || (swarm.rateLimitResetsAt && swarm.rateLimitResetsAt > Date.now())) {
91
+ const mcw = swarm.mostConstrainedWindow();
92
+ const when = (mcw?.resetsAt && mcw.resetsAt > Date.now()) ? mcw.resetsAt
93
+ : (swarm.rateLimitResetsAt && swarm.rateLimitResetsAt > Date.now()) ? swarm.rateLimitResetsAt
94
+ : undefined;
95
+ const winName = mcw ? (RATE_LIMIT_WINDOW_SHORT[mcw.type] ?? mcw.type.replace(/_/g, " ")) : undefined;
96
+ let txt = winName
97
+ ? `Anthropic ${winName} limit hit`
98
+ : `Rate limited`;
99
+ if (when) {
100
+ const waitSec = Math.ceil((when - Date.now()) / 1000);
101
+ const mm = Math.floor(waitSec / 60), ss = waitSec % 60;
102
+ txt += ` \u2014 resets in ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`;
103
+ }
104
+ if (swarm.rateLimitPaused > 0)
105
+ txt += ` (${swarm.rateLimitPaused} waiting)`;
106
+ label = chalk.yellow(txt);
100
107
  }
101
108
  if (swarm.isUsingOverage && !swarm.cappedOut)
102
109
  label += chalk.red(" [OVERAGE]");
@@ -106,7 +113,7 @@ function renderUsageBars(out, w, swarm) {
106
113
  if (windows.length > 1) {
107
114
  const cycleIdx = Math.floor(Date.now() / 3000) % windows.length;
108
115
  const win = windows[cycleIdx];
109
- const shortName = WINDOW_SHORT_NAMES[win.type] ?? win.type.replace(/_/g, " ");
116
+ const shortName = RATE_LIMIT_WINDOW_SHORT[win.type] ?? win.type.replace(/_/g, " ");
110
117
  renderBar(win.utilization, shortName);
111
118
  const dots = windows.map((_, i) => i === cycleIdx ? "\u25CF" : "\u25CB").join("");
112
119
  out[out.length - 1] += chalk.dim(` ${dots}`);
@@ -195,10 +202,11 @@ export function renderFrame(swarm, showHotkeys, runInfo) {
195
202
  const pending = runInfo?.pendingSteer ?? 0;
196
203
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
197
204
  const fixChip = swarm.failed > 0 && swarm.active > 0 ? chalk.yellow(" [f] fix") : "";
205
+ const retryChip = swarm.rateLimitPaused > 0 ? chalk.yellow(" [r] retry-now") : "";
198
206
  const pauseLabel = swarm.paused ? "[p] resume" : "[p] pause";
199
- out.push(chalk.dim(` [b] budget [t] cap [c] conc [e] extra ${pauseLabel} [s] steer [?] ask [q] stop`) + fixChip + chip);
207
+ out.push(chalk.dim(` [b] budget [t] cap [c] conc [e] extra ${pauseLabel} [s] steer [?] ask [q] stop`) + fixChip + retryChip + chip);
200
208
  if (swarm.blocked > 0 && swarm.blocked === swarm.active) {
201
- out.push(chalk.yellow(` all workers rate-limited — press [c] to reduce concurrency, [p] to pause, [q] to quit`));
209
+ out.push(chalk.yellow(` all workers rate-limited — [r] retry-now, [c] reduce concurrency, [p] pause, [q] quit`));
202
210
  }
203
211
  }
204
212
  out.push("");
package/dist/run.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import type { Task, PermMode, MergeStrategy, RunState, WaveSummary } from "./types.js";
2
+ import type { ProviderConfig } from "./providers.js";
2
3
  export interface RunConfig {
3
4
  tasks: Task[];
4
5
  objective?: string;
5
6
  budget: number;
6
7
  workerModel: string;
7
8
  plannerModel: string;
9
+ /** Custom provider for worker tasks (optional — Anthropic default when undefined). */
10
+ workerProvider?: ProviderConfig;
11
+ /** Custom provider for planner/steering calls (optional). */
12
+ plannerProvider?: ProviderConfig;
8
13
  concurrency: number;
9
14
  permissionMode: PermMode;
10
15
  useWorktrees: boolean;
package/dist/run.js CHANGED
@@ -4,7 +4,8 @@ import { execSync } from "child_process";
4
4
  import chalk from "chalk";
5
5
  import { Swarm } from "./swarm.js";
6
6
  import { steerWave } from "./steering.js";
7
- import { getTotalPlannerCost, getPlannerRateLimitInfo, runPlannerQuery } from "./planner-query.js";
7
+ import { getTotalPlannerCost, getPlannerRateLimitInfo, runPlannerQuery, setPlannerEnvResolver } from "./planner-query.js";
8
+ import { buildEnvResolver } from "./providers.js";
8
9
  import { RunDisplay } from "./ui.js";
9
10
  import { renderSummary } from "./render.js";
10
11
  import { fmtTokens } from "./render.js";
@@ -16,6 +17,11 @@ export async function executeRun(cfg) {
16
17
  }
17
18
  catch { } };
18
19
  const { objective, cwd, workerModel, plannerModel, concurrency, permissionMode, allowedTools, runDir, previousKnowledge, } = cfg;
20
+ const envForModel = buildEnvResolver({
21
+ plannerModel, plannerProvider: cfg.plannerProvider,
22
+ workerModel, workerProvider: cfg.workerProvider,
23
+ });
24
+ setPlannerEnvResolver(envForModel);
19
25
  let { usageCap, flex } = cfg;
20
26
  const useWorktrees = cfg.useWorktrees;
21
27
  const mergeStrategy = cfg.mergeStrategy;
@@ -311,7 +317,7 @@ export async function executeRun(cfg) {
311
317
  tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
312
318
  useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs: cfg.agentTimeoutMs,
313
319
  usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
314
- baseCostUsd: accCost,
320
+ baseCostUsd: accCost, envForModel,
315
321
  });
316
322
  currentSwarm = swarm;
317
323
  display.setWave(swarm);
@@ -359,7 +365,9 @@ export async function executeRun(cfg) {
359
365
  const neverStarted = currentTasks.filter(t => !attemptedPrompts.has(t.prompt));
360
366
  saveRunState(runDir, {
361
367
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: cfg.budget,
362
- remaining, workerModel, plannerModel, concurrency, permissionMode,
368
+ remaining, workerModel, plannerModel,
369
+ workerProviderId: cfg.workerProvider?.id, plannerProviderId: cfg.plannerProvider?.id,
370
+ concurrency, permissionMode,
363
371
  usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
364
372
  flex, useWorktrees, mergeStrategy, waveNum, currentTasks: neverStarted,
365
373
  accCost, accCompleted, accFailed, accIn, accOut, accTools,
@@ -417,7 +425,9 @@ export async function executeRun(cfg) {
417
425
  const finalPhase = trulyDone ? "done" : wasCapped ? "capped" : remaining <= 0 ? "capped" : "stopped";
418
426
  saveRunState(runDir, {
419
427
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: cfg.budget,
420
- remaining, workerModel, plannerModel, concurrency, permissionMode,
428
+ remaining, workerModel, plannerModel,
429
+ workerProviderId: cfg.workerProvider?.id, plannerProviderId: cfg.plannerProvider?.id,
430
+ concurrency, permissionMode,
421
431
  usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
422
432
  flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
423
433
  accCost, accCompleted, accFailed, accIn, accOut, accTools,
package/dist/swarm.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface SwarmConfig {
15
15
  allowExtraUsage?: boolean;
16
16
  extraUsageBudget?: number;
17
17
  baseCostUsd?: number;
18
+ /** Per-task env overrides: given a model id, return the env to pass to `query()` (or undefined for Anthropic default). */
19
+ envForModel?: (model?: string) => Record<string, string> | undefined;
18
20
  }
19
21
  export declare class Swarm {
20
22
  readonly agents: AgentState[];
@@ -41,6 +43,8 @@ export declare class Swarm {
41
43
  rateLimitPaused: number;
42
44
  isUsingOverage: boolean;
43
45
  overageCostUsd: number;
46
+ private rateLimitExplained;
47
+ private rateLimitWakers;
44
48
  /** Live-adjustable concurrency target. Workers above this count exit on the next task boundary. */
45
49
  targetConcurrency: number;
46
50
  /** When true, dispatch is frozen — workers wait without starting new tasks. */
@@ -76,6 +80,13 @@ export declare class Swarm {
76
80
  setConcurrency(n: number): void;
77
81
  /** Freeze/resume dispatch without killing the run. Paused workers block at the top of their loop. */
78
82
  setPaused(b: boolean): void;
83
+ /** Returns the rate-limit window currently holding the swarm back — rejected first, then highest utilization. */
84
+ mostConstrainedWindow(): RateLimitWindow | undefined;
85
+ private windowTag;
86
+ /** Cancellable sleep used by rate-limit waits. `retryRateLimitNow()` wakes every pending sleeper. */
87
+ private rateLimitSleep;
88
+ /** Force-wake every rate-limit sleeper and clear the reset timestamp so the next attempt fires immediately. */
89
+ retryRateLimitNow(): void;
79
90
  /** Live-adjust the overage spend cap. `undefined` = unlimited. If already over the new cap, stop dispatch. */
80
91
  setExtraUsageBudget(n: number | undefined): void;
81
92
  run(): Promise<void>;
package/dist/swarm.js CHANGED
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { tmpdir } from "os";
4
4
  import { query } from "@anthropic-ai/claude-agent-sdk";
5
- import { NudgeError } from "./types.js";
5
+ import { NudgeError, RATE_LIMIT_WINDOW_SHORT } from "./types.js";
6
6
  import { gitExec, autoCommit, mergeAllBranches, warnDirtyTree, cleanStaleWorktrees, writeSwarmLog } from "./merge.js";
7
7
  const SIMPLIFY_PROMPT = `You just finished your task. Now review and simplify your changes.
8
8
 
@@ -34,6 +34,8 @@ export class Swarm {
34
34
  rateLimitPaused = 0;
35
35
  isUsingOverage = false;
36
36
  overageCostUsd = 0;
37
+ rateLimitExplained = false;
38
+ rateLimitWakers = [];
37
39
  /** Live-adjustable concurrency target. Workers above this count exit on the next task boundary. */
38
40
  targetConcurrency;
39
41
  /** When true, dispatch is frozen — workers wait without starting new tasks. */
@@ -109,6 +111,56 @@ export class Swarm {
109
111
  this.paused = b;
110
112
  this.log(-1, b ? "Dispatch paused" : "Dispatch resumed");
111
113
  }
114
+ /** Returns the rate-limit window currently holding the swarm back — rejected first, then highest utilization. */
115
+ mostConstrainedWindow() {
116
+ const windows = Array.from(this.rateLimitWindows.values());
117
+ if (windows.length === 0)
118
+ return undefined;
119
+ const rejected = windows.find(w => w.status === "rejected" && (!w.resetsAt || w.resetsAt > Date.now()));
120
+ if (rejected)
121
+ return rejected;
122
+ return windows.reduce((a, b) => (a.utilization >= b.utilization ? a : b));
123
+ }
124
+ windowTag() {
125
+ const w = this.mostConstrainedWindow();
126
+ if (!w)
127
+ return "";
128
+ const name = RATE_LIMIT_WINDOW_SHORT[w.type] ?? w.type.replace(/_/g, " ");
129
+ return ` (${name} window)`;
130
+ }
131
+ /** Cancellable sleep used by rate-limit waits. `retryRateLimitNow()` wakes every pending sleeper. */
132
+ rateLimitSleep(ms) {
133
+ return new Promise(resolve => {
134
+ let done = false;
135
+ const finish = () => {
136
+ if (done)
137
+ return;
138
+ done = true;
139
+ clearTimeout(timer);
140
+ const i = this.rateLimitWakers.indexOf(finish);
141
+ if (i >= 0)
142
+ this.rateLimitWakers.splice(i, 1);
143
+ resolve();
144
+ };
145
+ const timer = setTimeout(finish, ms);
146
+ this.rateLimitWakers.push(finish);
147
+ });
148
+ }
149
+ /** Force-wake every rate-limit sleeper and clear the reset timestamp so the next attempt fires immediately. */
150
+ retryRateLimitNow() {
151
+ const n = this.rateLimitWakers.length;
152
+ if (n === 0) {
153
+ this.log(-1, "Retry-now: no workers waiting on rate limit");
154
+ return;
155
+ }
156
+ this.rateLimitResetsAt = undefined;
157
+ this.rateLimitUtilization = 0;
158
+ const wakers = this.rateLimitWakers.slice();
159
+ this.rateLimitWakers.length = 0;
160
+ for (const w of wakers)
161
+ w();
162
+ this.log(-1, `Retry-now: woke ${n} worker(s) — hitting API immediately (may be rejected again)`);
163
+ }
112
164
  /** Live-adjust the overage spend cap. `undefined` = unlimited. If already over the new cap, stop dispatch. */
113
165
  setExtraUsageBudget(n) {
114
166
  if (this.extraUsageBudget === n)
@@ -319,10 +371,10 @@ export class Swarm {
319
371
  : fallbackMs;
320
372
  const reason = capExceeded
321
373
  ? `Usage at ${Math.round(this.rateLimitUtilization * 100)}% (cap ${Math.round(cap * 100)}%)`
322
- : "Rate limited";
323
- this.log(-1, `${reason} — waiting ${Math.ceil(waitMs / 1000)}s then retrying`);
374
+ : `Rate limited${this.windowTag()}`;
375
+ this.log(-1, `${reason} — waiting ${Math.ceil(waitMs / 1000)}s then retrying ([r] to retry now)`);
324
376
  this.rateLimitPaused++;
325
- await sleep(waitMs);
377
+ await this.rateLimitSleep(waitMs);
326
378
  this.rateLimitPaused--;
327
379
  this.rateLimitUtilization = 0;
328
380
  this.rateLimitResetsAt = undefined;
@@ -402,13 +454,16 @@ export class Swarm {
402
454
  : this.config.useWorktrees && !task.noWorktree
403
455
  ? `You are working in an isolated git worktree. Focus only on this task. Do NOT commit your changes — the framework handles that.\n\n${preamble}${task.prompt}`
404
456
  : `${preamble}${task.prompt}`;
457
+ const effectiveModel = task.model || this.config.model;
458
+ const envOverride = this.config.envForModel?.(effectiveModel);
405
459
  const agentQuery = query({
406
460
  prompt: agentPrompt,
407
461
  options: {
408
- cwd: agentCwd, model: task.model || this.config.model, permissionMode: perm,
462
+ cwd: agentCwd, model: effectiveModel, permissionMode: perm,
409
463
  ...(perm === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
410
464
  allowedTools: this.config.allowedTools, includePartialMessages: true, persistSession: true,
411
465
  ...(isResume && resumeSessionId && { resume: resumeSessionId }),
466
+ ...(envOverride && { env: envOverride }),
412
467
  },
413
468
  });
414
469
  const timeoutMs = isResume ? inactivityMs * 2 : inactivityMs;
@@ -503,10 +558,10 @@ export class Swarm {
503
558
  // we eventually surrender instead of looping forever.
504
559
  const globallyStalled = Date.now() - this.lastProgressAt > 15 * 60_000;
505
560
  const freebie = !globallyStalled;
506
- this.log(id, `Rate limited — waiting ${Math.ceil(waitMs / 1000)}s${freebie ? " (attempt not counted)" : " (counted — swarm stalled)"}`);
561
+ this.log(id, `Rate limited${this.windowTag()} — waiting ${Math.ceil(waitMs / 1000)}s${freebie ? " (attempt not counted)" : " (counted — swarm stalled)"} ([r] to retry now)`);
507
562
  agent.blockedAt = Date.now();
508
563
  this.rateLimitPaused++;
509
- await sleep(waitMs);
564
+ await this.rateLimitSleep(waitMs);
510
565
  this.rateLimitPaused--;
511
566
  agent.blockedAt = undefined;
512
567
  this.isUsingOverage = false;
@@ -660,6 +715,12 @@ export class Swarm {
660
715
  if (!this.rateLimitResetsAt || this.rateLimitResetsAt <= Date.now()) {
661
716
  this.rateLimitResetsAt = Date.now() + 60_000;
662
717
  }
718
+ if (!this.rateLimitExplained) {
719
+ this.rateLimitExplained = true;
720
+ const name = windowType ? (RATE_LIMIT_WINDOW_SHORT[windowType] ?? windowType.replace(/_/g, " ")) : "Anthropic";
721
+ const overageNote = this.isUsingOverage ? " even on overage" : "";
722
+ this.log(-1, `${name} window is full${overageNote} — plan-level Anthropic limit, not a claude-overnight cap. Press [r] to retry now, [c] to lower concurrency, or wait for reset.`);
723
+ }
663
724
  throw new Error("rate limit rejected — retrying");
664
725
  }
665
726
  break;
package/dist/types.d.ts CHANGED
@@ -116,6 +116,7 @@ export interface RateLimitWindow {
116
116
  status: string;
117
117
  resetsAt?: number;
118
118
  }
119
+ export declare const RATE_LIMIT_WINDOW_SHORT: Record<string, string>;
119
120
  /** Thrown when a query goes silent — carries session ID for interrupt+resume. */
120
121
  export declare class NudgeError extends Error {
121
122
  sessionId: string | undefined;
@@ -160,6 +161,10 @@ export interface RunState {
160
161
  remaining: number;
161
162
  workerModel: string;
162
163
  plannerModel: string;
164
+ /** Optional id of a custom provider (from ~/.claude/claude-overnight/providers.json) used for worker tasks. */
165
+ workerProviderId?: string;
166
+ /** Optional id of a custom provider used for planner/steering calls. */
167
+ plannerProviderId?: string;
163
168
  concurrency: number;
164
169
  permissionMode: PermMode;
165
170
  usageCap?: number;
package/dist/types.js CHANGED
@@ -1,3 +1,7 @@
1
+ export const RATE_LIMIT_WINDOW_SHORT = {
2
+ five_hour: "5h", seven_day: "7d", seven_day_opus: "7d opus",
3
+ seven_day_sonnet: "7d sonnet", overage: "extra",
4
+ };
1
5
  /** Thrown when a query goes silent — carries session ID for interrupt+resume. */
2
6
  export class NudgeError extends Error {
3
7
  sessionId;
package/dist/ui.js CHANGED
@@ -387,6 +387,10 @@ export class RunDisplay {
387
387
  this.swarm.requeueFailed();
388
388
  return false;
389
389
  }
390
+ if ((s === "r" || s === "R") && this.swarm && this.swarm.rateLimitPaused > 0) {
391
+ this.swarm.retryRateLimitNow();
392
+ return true;
393
+ }
390
394
  if ((s === "s" || s === "S") && this.onSteer) {
391
395
  this.inputMode = "steer";
392
396
  this.inputSegs = [];
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.14.0",
3
+ "version": "1.16.1",
4
4
  "description": "Run 10, 100, or 1000 Claude agents overnight. Parallel autonomous AI coding with thinking waves, iterative quality steering, crash recovery, and rate limit handling. Built on the Claude Agent SDK.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "claude-overnight": "dist/index.js"
7
+ "claude-overnight": "dist/bin.js"
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc",
11
11
  "dev": "tsc --watch",
12
- "start": "node dist/index.js",
12
+ "start": "node dist/bin.js",
13
13
  "prepublishOnly": "node scripts/sync-plugin-version.js"
14
14
  },
15
15
  "dependencies": {