claude-overnight 1.13.1 → 1.16.0

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";
@@ -21,6 +22,140 @@ function countTasksInFile(path) {
21
22
  return 0;
22
23
  }
23
24
  }
25
+ async function promptResumeOverrides(state, cliFlags, argv, noTTY, runDir) {
26
+ // ── Apply CLI flag overrides first ──
27
+ if (cliFlags.model)
28
+ state.workerModel = cliFlags.model;
29
+ if (cliFlags.concurrency) {
30
+ const n = parseInt(cliFlags.concurrency);
31
+ if (n >= 1)
32
+ state.concurrency = n;
33
+ }
34
+ if (cliFlags.budget) {
35
+ const n = parseInt(cliFlags.budget);
36
+ if (n > 0) {
37
+ state.remaining = n;
38
+ state.budget = state.accCompleted + state.accFailed + n;
39
+ }
40
+ }
41
+ if (cliFlags["usage-cap"] != null) {
42
+ const v = parseFloat(cliFlags["usage-cap"]);
43
+ if (!isNaN(v) && v >= 0 && v <= 100)
44
+ state.usageCap = v > 0 ? v / 100 : undefined;
45
+ }
46
+ if (cliFlags["extra-usage-budget"] != null) {
47
+ const v = parseFloat(cliFlags["extra-usage-budget"]);
48
+ if (!isNaN(v) && v > 0) {
49
+ state.extraUsageBudget = v;
50
+ state.allowExtraUsage = true;
51
+ }
52
+ }
53
+ if (argv.includes("--allow-extra-usage"))
54
+ state.allowExtraUsage = true;
55
+ if (cliFlags.perm)
56
+ state.permissionMode = cliFlags.perm;
57
+ if (noTTY) {
58
+ try {
59
+ saveRunState(runDir, state);
60
+ }
61
+ catch { }
62
+ return;
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(() => []);
66
+ // ── Interactive review ──
67
+ const fmtSummary = () => {
68
+ const tier = detectModelTier(state.workerModel);
69
+ const remaining = Math.max(1, state.remaining);
70
+ const capStr = state.usageCap != null ? `${Math.round(state.usageCap * 100)}%` : "unlimited";
71
+ const extraStr = state.allowExtraUsage
72
+ ? (state.extraUsageBudget ? `$${state.extraUsageBudget}` : "unlimited")
73
+ : "off";
74
+ console.log();
75
+ console.log(` ${chalk.dim("Resume settings")}`);
76
+ console.log(` ${chalk.dim("─".repeat(40))}`);
77
+ console.log(` ${chalk.dim("model ")}${chalk.white(state.workerModel)} ${chalk.dim(`(${tier})`)}`);
78
+ console.log(` ${chalk.dim("remaining ")}${chalk.white(String(remaining))} ${chalk.dim("sessions")}`);
79
+ console.log(` ${chalk.dim("concur ")}${chalk.white(String(state.concurrency))}`);
80
+ console.log(` ${chalk.dim("usage cap ")}${chalk.white(capStr)}`);
81
+ console.log(` ${chalk.dim("extra ")}${chalk.white(extraStr)}`);
82
+ };
83
+ fmtSummary();
84
+ const action = await selectKey("", [
85
+ { key: "r", desc: "esume" },
86
+ { key: "e", desc: "dit" },
87
+ { key: "q", desc: "uit" },
88
+ ]);
89
+ if (action === "q")
90
+ process.exit(0);
91
+ if (action === "r")
92
+ return;
93
+ // ── Edit walk ──
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;
101
+ }
102
+ finally {
103
+ clearInterval(modelSpinner);
104
+ process.stdout.write(`\x1B[2K\r`);
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;
109
+ const remAns = await ask(`\n ${chalk.cyan("②")} Remaining sessions ${chalk.dim(`[${state.remaining}]:`)} `);
110
+ const parsedRem = parseInt(remAns);
111
+ if (!isNaN(parsedRem) && parsedRem > 0) {
112
+ state.remaining = parsedRem;
113
+ state.budget = state.accCompleted + state.accFailed + parsedRem;
114
+ }
115
+ const concAns = await ask(`\n ${chalk.cyan("③")} Concurrency ${chalk.dim(`[${state.concurrency}]:`)} `);
116
+ const parsedConc = parseInt(concAns);
117
+ if (!isNaN(parsedConc) && parsedConc >= 1)
118
+ state.concurrency = parsedConc;
119
+ const currentCap = state.usageCap != null ? String(Math.round(state.usageCap * 100)) : "off";
120
+ const capAns = await ask(`\n ${chalk.cyan("④")} Usage cap % ${chalk.dim(`[${currentCap}]`)} ${chalk.dim("(0 = off):")} `);
121
+ if (capAns.trim()) {
122
+ const v = parseFloat(capAns);
123
+ if (!isNaN(v) && v >= 0 && v <= 100)
124
+ state.usageCap = v > 0 ? v / 100 : undefined;
125
+ }
126
+ const currentExtra = state.allowExtraUsage
127
+ ? (state.extraUsageBudget ? `$${state.extraUsageBudget}` : "unlimited")
128
+ : "off";
129
+ const extraChoice = await select(`${chalk.cyan("⑤")} Extra usage ${chalk.dim(`[current: ${currentExtra}]`)}:`, [
130
+ { name: "Keep current", value: "keep" },
131
+ { name: "Off", value: "off", hint: "stop at plan limit" },
132
+ { name: "With $ cap", value: "budget", hint: "set a spending cap" },
133
+ { name: "Unlimited", value: "unlimited", hint: "no cap, billed as overage" },
134
+ ]);
135
+ if (extraChoice === "off") {
136
+ state.allowExtraUsage = false;
137
+ state.extraUsageBudget = undefined;
138
+ }
139
+ else if (extraChoice === "budget") {
140
+ const bAns = await ask(` ${chalk.dim("Max extra $:")} `);
141
+ const bVal = parseFloat(bAns);
142
+ if (!isNaN(bVal) && bVal > 0) {
143
+ state.extraUsageBudget = bVal;
144
+ state.allowExtraUsage = true;
145
+ }
146
+ }
147
+ else if (extraChoice === "unlimited") {
148
+ state.allowExtraUsage = true;
149
+ state.extraUsageBudget = undefined;
150
+ }
151
+ try {
152
+ saveRunState(runDir, state);
153
+ }
154
+ catch { }
155
+ console.log(chalk.green("\n ✓ Settings updated"));
156
+ fmtSummary();
157
+ console.log();
158
+ }
24
159
  async function main() {
25
160
  const argv = process.argv.slice(2);
26
161
  if (argv.includes("-v") || argv.includes("--version")) {
@@ -45,7 +180,7 @@ async function main() {
45
180
  --dry-run Show planned tasks without running them
46
181
  --budget=N Target number of agent runs ${chalk.dim("(default: 10)")}
47
182
  --concurrency=N Max parallel agents ${chalk.dim("(default: 5)")}
48
- --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.)")}
49
184
  --usage-cap=N Stop at N% utilization ${chalk.dim("(e.g. 90 to save 10% for other work)")}
50
185
  --allow-extra-usage Allow extra/overage usage ${chalk.dim("(default: stop when plan limits hit)")}
51
186
  --extra-usage-budget=N Max $ for extra usage ${chalk.dim("(implies --allow-extra-usage)")}
@@ -108,7 +243,9 @@ async function main() {
108
243
  }
109
244
  }
110
245
  // ── Mode detection ──
111
- 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")}`);
112
249
  console.log(chalk.dim(` ${"─".repeat(36)}`));
113
250
  const noTTY = !process.stdin.isTTY;
114
251
  const nonInteractive = noTTY || fileCfg !== undefined || tasks.length > 0;
@@ -310,11 +447,14 @@ async function main() {
310
447
  }
311
448
  catch { }
312
449
  }
450
+ await promptResumeOverrides(resumeState, cliFlags, argv, noTTY, resumeRunDir);
313
451
  }
314
452
  }
315
453
  // ── Config resolution ──
316
454
  let workerModel;
317
455
  let plannerModel;
456
+ let workerProvider;
457
+ let plannerProvider;
318
458
  let budget;
319
459
  let concurrency;
320
460
  let objective = fileCfg?.objective;
@@ -327,6 +467,23 @@ async function main() {
327
467
  if (resuming) {
328
468
  workerModel = resumeState.workerModel;
329
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
+ }
330
487
  budget = resumeState.budget;
331
488
  concurrency = resumeState.concurrency;
332
489
  objective = resumeState.objective;
@@ -378,21 +535,19 @@ async function main() {
378
535
  clearInterval(modelSpinner);
379
536
  process.stdout.write(`\x1B[2K\r`);
380
537
  }
381
- plannerModel = models[0]?.value || "claude-sonnet-4-6";
382
- if (models.length > 0) {
383
- workerModel = await select(`${chalk.cyan("④")} Worker model:`, models.map(m => ({ name: m.displayName, value: m.value, hint: m.description })));
384
- }
385
- else {
386
- const ans = await ask(` ${chalk.cyan("④")} ${chalk.dim("Worker model [claude-sonnet-4-6]:")} `);
387
- workerModel = ans || "claude-sonnet-4-6";
388
- }
389
- 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:`, [
390
545
  { name: "Unlimited", value: undefined, hint: "full capacity, wait through rate limits" },
391
546
  { name: "90%", value: 0.9, hint: "leave 10% for other work" },
392
547
  { name: "75%", value: 0.75, hint: "conservative, plenty of headroom" },
393
548
  { name: "50%", value: 0.5, hint: "use half, keep the rest" },
394
549
  ]);
395
- 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)")}:`, [
396
551
  { name: "No", value: "no", hint: "stop when plan limits are reached" },
397
552
  { name: "Yes, with $ limit", value: "budget", hint: "set a spending cap" },
398
553
  { name: "Yes, unlimited", value: "unlimited", hint: "keep going no matter what" },
@@ -406,7 +561,7 @@ async function main() {
406
561
  }
407
562
  else if (extraChoice === "unlimited")
408
563
  allowExtraUsage = true;
409
- // Permission mode (skip if --yolo or --perm set)
564
+ // Permission mode (skip if --yolo or --perm set)
410
565
  const cliYolo = argv.includes("--yolo");
411
566
  if (cliFlags.perm) {
412
567
  permissionMode = cliFlags.perm;
@@ -415,13 +570,13 @@ async function main() {
415
570
  permissionMode = "bypassPermissions";
416
571
  }
417
572
  else {
418
- permissionMode = await select(`${chalk.cyan("")} Permissions:`, [
573
+ permissionMode = await select(`${chalk.cyan("")} Permissions:`, [
419
574
  { name: "Auto", value: "auto", hint: "accept low-risk, reject high-risk" },
420
575
  { name: "Bypass all", value: "bypassPermissions", hint: "agents can run anything (yolo)" },
421
576
  { name: "Prompt each", value: "default", hint: "ask for every dangerous op" },
422
577
  ]);
423
578
  }
424
- // Worktrees + merge (skip if --yolo, --worktrees, --no-worktrees, or --merge set)
579
+ // Worktrees + merge (skip if --yolo, --worktrees, --no-worktrees, or --merge set)
425
580
  const gitRepo = isGitRepo(cwd);
426
581
  if (cliYolo || argv.includes("--no-worktrees")) {
427
582
  useWorktrees = false;
@@ -432,7 +587,7 @@ async function main() {
432
587
  mergeStrategy = cliFlags.merge || "yolo";
433
588
  }
434
589
  else if (gitRepo) {
435
- const wtChoice = await select(`${chalk.cyan("")} Git isolation:`, [
590
+ const wtChoice = await select(`${chalk.cyan("")} Git isolation:`, [
436
591
  { name: "Worktrees + yolo merge", value: "wt-yolo", hint: "isolate agents, merge into current branch" },
437
592
  { name: "Worktrees + new branch", value: "wt-branch", hint: "isolate agents, merge into a new branch" },
438
593
  { name: "No worktrees", value: "no-wt", hint: "all agents share the working directory" },
@@ -475,6 +630,14 @@ async function main() {
475
630
  models = await fetchModels(5_000);
476
631
  workerModel = cliFlags.model ?? fileCfg?.model ?? (models[0]?.value || "claude-sonnet-4-6");
477
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
+ }
478
641
  concurrency = cliFlags.concurrency ? parseInt(cliFlags.concurrency) : (fileCfg?.concurrency ?? 5);
479
642
  budget = cliFlags.budget ? parseInt(cliFlags.budget) : undefined;
480
643
  if (budget != null && (isNaN(budget) || budget < 1)) {
@@ -527,6 +690,29 @@ async function main() {
527
690
  }
528
691
  if (useWorktrees)
529
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
+ }
530
716
  if (nonInteractive) {
531
717
  const capStr = usageCap != null ? ` cap=${Math.round(usageCap * 100)}%` : "";
532
718
  const extraStr = allowExtraUsage ? (extraUsageBudget ? ` extra=$${extraUsageBudget}` : " extra=∞") : " extra=off";
@@ -553,7 +739,9 @@ async function main() {
553
739
  saveRunState(runDir, {
554
740
  id: runDir.split(/[/\\]/).pop() ?? "",
555
741
  objective, budget: budget ?? 10, remaining: budget ?? 10,
556
- workerModel, plannerModel, concurrency, permissionMode,
742
+ workerModel, plannerModel,
743
+ workerProviderId: workerProvider?.id, plannerProviderId: plannerProvider?.id,
744
+ concurrency, permissionMode,
557
745
  usageCap, allowExtraUsage, extraUsageBudget,
558
746
  flex, useWorktrees, mergeStrategy,
559
747
  waveNum: 0, currentTasks: [],
@@ -612,9 +800,10 @@ async function main() {
612
800
  process.stdout.write("\x1B[?25l");
613
801
  try {
614
802
  let answer = "";
803
+ const plannerEnv = envForModel(plannerModel);
615
804
  for await (const msg of query({
616
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}`,
617
- options: { cwd, model: plannerModel, permissionMode, persistSession: false },
806
+ options: { cwd, model: plannerModel, permissionMode, persistSession: false, ...(plannerEnv && { env: plannerEnv }) },
618
807
  })) {
619
808
  if (msg.type === "result" && msg.subtype === "success")
620
809
  answer = msg.result || "";
@@ -654,6 +843,7 @@ async function main() {
654
843
  const thinkingSwarm = new Swarm({
655
844
  tasks: thinkingTasks, concurrency, cwd, model: plannerModel, permissionMode,
656
845
  useWorktrees: false, mergeStrategy: "yolo", agentTimeoutMs, usageCap, allowExtraUsage, extraUsageBudget,
846
+ envForModel,
657
847
  });
658
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() };
659
849
  const thinkDisplay = new RunDisplay(thinkRunInfo, { remaining: 0, usageCap, concurrency, paused: false, dirty: false });
@@ -680,7 +870,9 @@ async function main() {
680
870
  saveRunState(runDir, {
681
871
  id: runDir.split(/[/\\]/).pop() ?? "",
682
872
  objective: objective, budget: budget ?? 10, remaining: (budget ?? 10) - thinkingUsed,
683
- workerModel, plannerModel, concurrency, permissionMode,
873
+ workerModel, plannerModel,
874
+ workerProviderId: workerProvider?.id, plannerProviderId: plannerProvider?.id,
875
+ concurrency, permissionMode,
684
876
  usageCap, allowExtraUsage, extraUsageBudget,
685
877
  flex, useWorktrees, mergeStrategy,
686
878
  waveNum: 0, currentTasks: [],
@@ -755,9 +947,10 @@ async function main() {
755
947
  process.stdout.write("\x1B[?25l");
756
948
  try {
757
949
  let answer = "";
950
+ const plannerEnv = envForModel(plannerModel);
758
951
  for await (const msg of query({
759
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}`,
760
- options: { cwd, model: plannerModel, permissionMode, persistSession: false },
953
+ options: { cwd, model: plannerModel, permissionMode, persistSession: false, ...(plannerEnv && { env: plannerEnv }) },
761
954
  })) {
762
955
  if (msg.type === "result" && msg.subtype === "success")
763
956
  answer = msg.result || "";
@@ -798,7 +991,8 @@ async function main() {
798
991
  }
799
992
  // ── Execute ──
800
993
  await executeRun({
801
- tasks, objective, budget: budget ?? tasks.length, workerModel, plannerModel, concurrency,
994
+ tasks, objective, budget: budget ?? tasks.length, workerModel, plannerModel,
995
+ workerProvider, plannerProvider, concurrency,
802
996
  permissionMode, useWorktrees, mergeStrategy, usageCap, allowExtraUsage, extraUsageBudget,
803
997
  flex, agentTimeoutMs, cwd, allowedTools, runDir, previousKnowledge,
804
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;