claude-overnight 1.1.0 → 1.2.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
@@ -33,22 +33,26 @@ claude-overnight
33
33
  ● Sonnet — Sonnet 4.6 · Best for everyday tasks
34
34
  ○ Opus — Opus 4.6 · Most capable
35
35
 
36
- ④ Usage:
36
+ ④ Usage cap:
37
37
  ● 90% · leave 10% for other work
38
38
 
39
- ╭──────────────────────────────────────────╮
40
- sonnet · budget 200 · · flex · 90% │
41
- ╰──────────────────────────────────────────╯
39
+ ⑤ Allow extra usage (billed separately):
40
+ No · stop when plan limits are reached
42
41
 
42
+ ╭──────────────────────────────────────────────────╮
43
+ │ sonnet · budget 200 · 5× · flex · cap 90% · no extra │
44
+ ╰──────────────────────────────────────────────────╯
45
+
46
+ ⠹ 8s · $0.04 · 12% · identifying themes ← every phase shows cost + usage
43
47
  ✓ 5 themes → review, press Run, walk away
44
48
 
45
- ◆ Thinking: 5 agents exploring... ← architects analyze your codebase
46
- ◆ Orchestrating plan... ← synthesizes 50 concrete tasks
47
- ◆ Wave 1 · 50 tasks ← fully autonomous from here
49
+ ◆ Thinking: 5 agents exploring... ← architects analyze your codebase
50
+ ◆ Orchestrating plan... ← synthesizes 50 concrete tasks
51
+ ◆ Wave 1 · 50 tasks · $4.20 spent ← fully autonomous from here
48
52
  ◆ Assessing... how close to amazing?
49
- ◆ Wave 2 · 30 tasks ← improvements from assessment
50
- ◆ Reflection: 2 agents reviewing ← deep quality audit
51
- ◆ Wave 3 · 20 tasks ← fixes from review findings
53
+ ◆ Wave 2 · 30 tasks · $18.50 spent ← improvements from assessment
54
+ ◆ Reflection: 2 agents reviewing ← deep quality audit
55
+ ◆ Wave 3 · 20 tasks · $31.00 spent ← fixes from review findings
52
56
  ◆ Assessing... ✓ Vision met
53
57
  ```
54
58
 
@@ -172,6 +176,8 @@ claude-overnight "fix auth bug in src/auth.ts" "add tests for user model"
172
176
  | `--concurrency=N` | `5` | Parallel agents |
173
177
  | `--model=NAME` | prompted | Worker model (planner uses best available) |
174
178
  | `--usage-cap=N` | unlimited | Stop at N% utilization |
179
+ | `--allow-extra-usage` | off | Allow extra/overage usage (billed separately) |
180
+ | `--extra-usage-budget=N` | — | Max $ for extra usage (implies --allow-extra-usage) |
175
181
  | `--timeout=SECONDS` | `300` | Inactivity timeout per agent |
176
182
  | `--no-flex` | — | Disable multi-wave steering |
177
183
  | `--dry-run` | — | Show planned tasks without running |
@@ -190,12 +196,38 @@ claude-overnight "fix auth bug in src/auth.ts" "add tests for user model"
190
196
  | `mergeStrategy` | `"yolo" \| "branch"` | `"yolo"` | Merge into HEAD or new branch |
191
197
  | `usageCap` | `number (0-100)` | unlimited | Stop at N% utilization |
192
198
 
199
+ ## Usage controls
200
+
201
+ ### Extra usage protection
202
+
203
+ By default, extra/overage usage is **blocked**. When your plan's rate limits are exhausted, the run stops cleanly and is resumable. You control this in the interactive prompt (step ⑤) or via CLI flags:
204
+
205
+ - `--allow-extra-usage` — opt in to extra usage (billed separately)
206
+ - `--extra-usage-budget=20` — allow up to $20 of extra usage, then stop
207
+
208
+ ### Live controls during execution
209
+
210
+ Press these keys while agents are running:
211
+
212
+ | Key | Action |
213
+ |---|---|
214
+ | `b` | Change remaining budget (number of sessions) |
215
+ | `t` | Change usage cap threshold (0-100%) |
216
+ | `q` | Graceful stop (press twice to force quit) |
217
+
218
+ Changes take effect between waves — active agents finish their current task.
219
+
220
+ ### Multi-window usage display
221
+
222
+ The usage bar cycles through all rate limit windows (5h, 7d, etc.) every 3 seconds, showing utilization per window. Usage info is shown during all phases — thinking, orchestration, steering, and execution.
223
+
193
224
  ## Rate limits
194
225
 
195
226
  Built for unattended runs lasting hours or days.
196
227
 
197
228
  - **Hard block**: pauses until the rate limit window resets, then resumes
198
229
  - **Soft throttle**: slows dispatch at >75% utilization
230
+ - **Extra usage guard**: detects overage billing and stops unless explicitly allowed
199
231
  - **Cooldown between phases**: waits for rate limit reset after thinking before starting orchestration
200
232
  - **Retry with backoff**: transient errors (429, overloaded) retry automatically
201
233
  - **Usage cap**: set a ceiling, active agents finish, no new ones start — run is resumable
package/dist/index.js CHANGED
@@ -11,8 +11,8 @@ import { planTasks, refinePlan, detectModelTier, steerWave, identifyThemes, buil
11
11
  import { startRenderLoop, renderSummary } from "./ui.js";
12
12
  // ── CLI flag parsing ──
13
13
  function parseCliFlags(argv) {
14
- const known = new Set(["concurrency", "model", "timeout", "budget", "usage-cap"]);
15
- const booleans = new Set(["--dry-run", "-h", "--help", "-v", "--version", "--no-flex"]);
14
+ const known = new Set(["concurrency", "model", "timeout", "budget", "usage-cap", "extra-usage-budget"]);
15
+ const booleans = new Set(["--dry-run", "-h", "--help", "-v", "--version", "--no-flex", "--allow-extra-usage"]);
16
16
  const flags = {};
17
17
  const positional = [];
18
18
  for (let i = 0; i < argv.length; i++) {
@@ -523,6 +523,8 @@ async function main() {
523
523
  --concurrency=N Max parallel agents ${chalk.dim("(default: 5)")}
524
524
  --model=NAME Worker model override ${chalk.dim("(planner always uses best available)")}
525
525
  --usage-cap=N Stop at N% utilization ${chalk.dim("(e.g. 90 to save 10% for other work)")}
526
+ --allow-extra-usage Allow extra/overage usage ${chalk.dim("(default: stop when plan limits hit)")}
527
+ --extra-usage-budget=N Max $ for extra usage ${chalk.dim("(implies --allow-extra-usage)")}
526
528
  --timeout=SECONDS Agent inactivity timeout ${chalk.dim("(default: 300s, kills only silent agents)")}
527
529
  --no-flex Disable adaptive multi-wave planning ${chalk.dim("(run all tasks in one shot)")}
528
530
 
@@ -677,6 +679,8 @@ async function main() {
677
679
  let concurrency;
678
680
  let objective = fileCfg?.objective;
679
681
  let usageCap;
682
+ let allowExtraUsage = false;
683
+ let extraUsageBudget;
680
684
  if (!nonInteractive) {
681
685
  // ① Objective
682
686
  while (true) {
@@ -724,13 +728,29 @@ async function main() {
724
728
  const ans = await ask(` ${chalk.cyan("③")} ${chalk.dim("Worker model [claude-sonnet-4-6]:")} `);
725
729
  workerModel = ans || "claude-sonnet-4-6";
726
730
  }
727
- // ④ Usage
728
- usageCap = await select(`${chalk.cyan("④")} Usage:`, [
731
+ // ④ Usage cap
732
+ usageCap = await select(`${chalk.cyan("④")} Usage cap:`, [
729
733
  { name: "Unlimited", value: undefined, hint: "full capacity, wait through rate limits" },
730
734
  { name: "90%", value: 0.9, hint: "leave 10% for other work" },
731
735
  { name: "75%", value: 0.75, hint: "conservative, plenty of headroom" },
732
736
  { name: "50%", value: 0.5, hint: "use half, keep the rest" },
733
737
  ]);
738
+ // ⑤ Extra usage
739
+ const extraChoice = await select(`${chalk.cyan("⑤")} Allow extra usage ${chalk.dim("(billed separately)")}:`, [
740
+ { name: "No", value: "no", hint: "stop when plan limits are reached" },
741
+ { name: "Yes, with $ limit", value: "budget", hint: "set a spending cap" },
742
+ { name: "Yes, unlimited", value: "unlimited", hint: "keep going no matter what" },
743
+ ]);
744
+ if (extraChoice === "budget") {
745
+ const budgetAns = await ask(` ${chalk.dim("Max extra usage $:")} `);
746
+ extraUsageBudget = parseFloat(budgetAns);
747
+ if (!extraUsageBudget || extraUsageBudget <= 0)
748
+ extraUsageBudget = 5;
749
+ allowExtraUsage = true;
750
+ }
751
+ else if (extraChoice === "unlimited") {
752
+ allowExtraUsage = true;
753
+ }
734
754
  concurrency = Math.min(5, budget);
735
755
  // Config summary box
736
756
  const parts = [];
@@ -747,6 +767,10 @@ async function main() {
747
767
  parts.push("flex");
748
768
  if (usageCap != null)
749
769
  parts.push(`cap ${Math.round(usageCap * 100)}%`);
770
+ if (allowExtraUsage)
771
+ parts.push(extraUsageBudget ? `extra $${extraUsageBudget}` : "extra ∞");
772
+ else
773
+ parts.push("no extra");
750
774
  if (completedRuns.length > 0)
751
775
  parts.push(`${completedRuns.length} prior`);
752
776
  const inner = parts.join(chalk.dim(" · "));
@@ -780,6 +804,17 @@ async function main() {
780
804
  else {
781
805
  usageCap = fileCfg?.usageCap != null ? fileCfg.usageCap / 100 : undefined;
782
806
  }
807
+ // Extra usage: default OFF for non-interactive
808
+ allowExtraUsage = argv.includes("--allow-extra-usage");
809
+ const extraBudgetFlag = cliFlags["extra-usage-budget"];
810
+ if (extraBudgetFlag != null) {
811
+ extraUsageBudget = parseFloat(extraBudgetFlag);
812
+ if (isNaN(extraUsageBudget) || extraUsageBudget <= 0) {
813
+ console.error(chalk.red(` --extra-usage-budget must be a positive number`));
814
+ process.exit(1);
815
+ }
816
+ allowExtraUsage = true;
817
+ }
783
818
  }
784
819
  validateConcurrency(concurrency);
785
820
  const permissionMode = fileCfg?.permissionMode ?? "auto";
@@ -789,7 +824,8 @@ async function main() {
789
824
  const mergeStrategy = fileCfg?.mergeStrategy ?? "yolo";
790
825
  if (nonInteractive) {
791
826
  const capStr = usageCap != null ? ` cap=${Math.round(usageCap * 100)}%` : "";
792
- console.log(chalk.dim(` ${workerModel} concurrency=${concurrency} worktrees=${useWorktrees} merge=${mergeStrategy} perms=${permissionMode}${capStr}`));
827
+ const extraStr = allowExtraUsage ? (extraUsageBudget ? ` extra=$${extraUsageBudget}` : " extra=∞") : " extra=off";
828
+ console.log(chalk.dim(` ${workerModel} concurrency=${concurrency} worktrees=${useWorktrees} merge=${mergeStrategy} perms=${permissionMode}${capStr}${extraStr}`));
793
829
  }
794
830
  // ── Flex mode: adaptive multi-wave planning ──
795
831
  let flex = !argv.includes("--no-flex") && (fileCfg?.flexiblePlan ?? objective != null) && objective != null && (budget ?? 10) > 2;
@@ -816,18 +852,8 @@ async function main() {
816
852
  try {
817
853
  if (useThinking) {
818
854
  // Phase 1: Quick theme identification → review → then autonomous
819
- let themeFrame = 0;
820
- const themeSpinner = setInterval(() => {
821
- const spin = chalk.cyan(BRAILLE[themeFrame++ % BRAILLE.length]);
822
- process.stdout.write(`\x1B[2K\r ${spin} ${chalk.dim("identifying themes...")}`);
823
- }, 120);
824
855
  let themes;
825
- try {
826
- themes = await identifyThemes(objective, thinkingCount, plannerModel, permissionMode);
827
- }
828
- finally {
829
- clearInterval(themeSpinner);
830
- }
856
+ themes = await identifyThemes(objective, thinkingCount, plannerModel, permissionMode, makeProgressLog());
831
857
  process.stdout.write(`\x1B[2K\r ${chalk.green(`\u2713 ${themes.length} themes`)}\n\n`);
832
858
  // Show themes for review — this is the LAST user interaction
833
859
  planRestore();
@@ -852,7 +878,7 @@ async function main() {
852
878
  break;
853
879
  process.stdout.write("\x1B[?25l");
854
880
  try {
855
- themes = await identifyThemes(`${objective}\n\nUser feedback: ${feedback}`, thinkingCount, plannerModel, permissionMode);
881
+ themes = await identifyThemes(`${objective}\n\nUser feedback: ${feedback}`, thinkingCount, plannerModel, permissionMode, makeProgressLog());
856
882
  process.stdout.write(`\x1B[2K\r ${chalk.green(`\u2713 ${themes.length} themes`)}\n\n`);
857
883
  }
858
884
  catch (err) {
@@ -894,9 +920,9 @@ async function main() {
894
920
  useWorktrees: false,
895
921
  mergeStrategy: "yolo",
896
922
  agentTimeoutMs,
897
- usageCap,
923
+ usageCap, allowExtraUsage, extraUsageBudget,
898
924
  });
899
- const stopThinkRender = startRenderLoop(thinkingSwarm);
925
+ const stopThinkRender = startRenderLoop(thinkingSwarm, { remaining: 0, usageCap, dirty: false });
900
926
  try {
901
927
  await thinkingSwarm.run();
902
928
  }
@@ -1046,6 +1072,7 @@ async function main() {
1046
1072
  let currentSwarm;
1047
1073
  let remaining;
1048
1074
  let currentTasks;
1075
+ const liveConfig = { remaining: 0, usageCap, dirty: false };
1049
1076
  let waveNum;
1050
1077
  const waveHistory = [];
1051
1078
  let accCost, accCompleted, accFailed, accTools;
@@ -1073,6 +1100,8 @@ async function main() {
1073
1100
  concurrency = resumeState.concurrency;
1074
1101
  flex = resumeState.flex;
1075
1102
  usageCap = resumeState.usageCap;
1103
+ allowExtraUsage = resumeState.allowExtraUsage ?? false;
1104
+ extraUsageBudget = resumeState.extraUsageBudget;
1076
1105
  console.log(chalk.green(`\n ✓ Resumed`) + chalk.dim(` · wave ${waveNum + 1} · ${remaining} remaining · $${accCost.toFixed(2)} spent\n`));
1077
1106
  }
1078
1107
  else {
@@ -1094,6 +1123,8 @@ async function main() {
1094
1123
  lastWaveKind = "execute";
1095
1124
  reflectionBudgetUsed = 0;
1096
1125
  }
1126
+ liveConfig.remaining = remaining;
1127
+ liveConfig.usageCap = usageCap;
1097
1128
  const maxReflectionBudget = Math.max(2, Math.ceil((budget ?? 10) * 0.05));
1098
1129
  // For flex + branch strategy: create one target branch, waves merge via yolo into it
1099
1130
  let runBranch;
@@ -1131,14 +1162,15 @@ async function main() {
1131
1162
  if (currentTasks.length > remaining)
1132
1163
  currentTasks = currentTasks.slice(0, remaining);
1133
1164
  if (flex) {
1134
- console.log(chalk.cyan(`\n ◆ Wave ${waveNum + 1}`) + chalk.dim(` · ${currentTasks.length} tasks · ${remaining} remaining\n`));
1165
+ const costSoFar = accCost > 0 ? ` · $${accCost.toFixed(2)} spent` : "";
1166
+ console.log(chalk.cyan(`\n ◆ Wave ${waveNum + 1}`) + chalk.dim(` · ${currentTasks.length} tasks · ${remaining} remaining${costSoFar}\n`));
1135
1167
  }
1136
1168
  const swarm = new Swarm({
1137
1169
  tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
1138
- useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs, usageCap,
1170
+ useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs, usageCap, allowExtraUsage, extraUsageBudget,
1139
1171
  });
1140
1172
  currentSwarm = swarm;
1141
- const stopRender = startRenderLoop(swarm);
1173
+ const stopRender = startRenderLoop(swarm, liveConfig);
1142
1174
  try {
1143
1175
  await swarm.run();
1144
1176
  }
@@ -1163,6 +1195,13 @@ async function main() {
1163
1195
  accFailed += swarm.failed;
1164
1196
  accTools += swarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
1165
1197
  remaining -= swarm.completed + swarm.failed;
1198
+ // Apply live config changes if user adjusted budget/threshold mid-wave
1199
+ if (liveConfig.dirty) {
1200
+ remaining = liveConfig.remaining;
1201
+ usageCap = liveConfig.usageCap;
1202
+ liveConfig.dirty = false;
1203
+ }
1204
+ liveConfig.remaining = remaining;
1166
1205
  lastCapped = swarm.cappedOut;
1167
1206
  lastAborted = swarm.aborted;
1168
1207
  recordBranches(swarm, branches);
@@ -1170,7 +1209,7 @@ async function main() {
1170
1209
  saveRunState(runDir, {
1171
1210
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective, budget: budget ?? tasks.length,
1172
1211
  remaining, workerModel, plannerModel, concurrency, permissionMode,
1173
- usageCap, flex, useWorktrees, mergeStrategy, waveNum, currentTasks,
1212
+ usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks,
1174
1213
  lastWaveKind, reflectionBudgetUsed, accCost, accCompleted, accFailed,
1175
1214
  branches, phase: "steering", startedAt: new Date(runStartedAt).toISOString(), cwd,
1176
1215
  });
@@ -1236,10 +1275,10 @@ async function main() {
1236
1275
  tasks: reflTasks, concurrency: 2, cwd,
1237
1276
  model: plannerModel, permissionMode,
1238
1277
  useWorktrees: false, mergeStrategy: "yolo",
1239
- agentTimeoutMs, usageCap,
1278
+ agentTimeoutMs, usageCap, allowExtraUsage, extraUsageBudget,
1240
1279
  });
1241
1280
  currentSwarm = reflSwarm;
1242
- const stopReflRender = startRenderLoop(reflSwarm);
1281
+ const stopReflRender = startRenderLoop(reflSwarm, liveConfig);
1243
1282
  try {
1244
1283
  await reflSwarm.run();
1245
1284
  }
@@ -1290,7 +1329,7 @@ async function main() {
1290
1329
  saveRunState(runDir, {
1291
1330
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: budget ?? tasks.length,
1292
1331
  remaining, workerModel, plannerModel, concurrency, permissionMode,
1293
- usageCap, flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
1332
+ usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
1294
1333
  lastWaveKind, reflectionBudgetUsed, accCost, accCompleted, accFailed,
1295
1334
  branches, phase: finalPhase, startedAt: new Date(runStartedAt).toISOString(), cwd,
1296
1335
  });
package/dist/planner.d.ts CHANGED
@@ -1,4 +1,13 @@
1
- import type { Task, PermMode } from "./types.js";
1
+ import type { Task, PermMode, RateLimitWindow } from "./types.js";
2
+ /** Rate limit info emitted by planner queries for UI display. */
3
+ export interface PlannerRateLimitInfo {
4
+ utilization: number;
5
+ status: string;
6
+ isUsingOverage: boolean;
7
+ windows: Map<string, RateLimitWindow>;
8
+ resetsAt?: number;
9
+ costUsd: number;
10
+ }
2
11
  export interface WaveSummary {
3
12
  wave: number;
4
13
  kind: "execute" | "reflect" | "think";
@@ -27,8 +36,9 @@ export interface RunMemory {
27
36
  }
28
37
  export type ModelTier = "opus" | "sonnet" | "haiku" | "unknown";
29
38
  export declare function detectModelTier(model: string): ModelTier;
39
+ export declare function getPlannerRateLimitInfo(): PlannerRateLimitInfo;
30
40
  export declare function planTasks(objective: string, cwd: string, plannerModel: string, workerModel: string, permissionMode: PermMode, budget: number | undefined, concurrency: number, onLog: (text: string) => void, flexNote?: string, outFile?: string): Promise<Task[]>;
31
- export declare function identifyThemes(objective: string, count: number, model: string, permissionMode: PermMode): Promise<string[]>;
41
+ export declare function identifyThemes(objective: string, count: number, model: string, permissionMode: PermMode, onLog?: (text: string) => void): Promise<string[]>;
32
42
  export declare function buildThinkingTasks(objective: string, themes: string[], designDir: string, plannerModel: string, previousKnowledge?: string): Task[];
33
43
  export declare function buildReflectionTasks(objective: string, goal: string, reflectionDir: string, waveNum: number, plannerModel: string): Task[];
34
44
  export declare function orchestrate(objective: string, designDocs: string, cwd: string, plannerModel: string, workerModel: string, permissionMode: PermMode, budget: number, concurrency: number, onLog: (text: string) => void, flexNote?: string, outFile?: string): Promise<Task[]>;
package/dist/planner.js CHANGED
@@ -172,7 +172,13 @@ async function runPlannerQuery(prompt, opts, onLog) {
172
172
  }
173
173
  throw new Error("Planner query failed after retries");
174
174
  }
175
+ /** Shared mutable rate limit state that planner queries write to for UI display. Reset per query. */
176
+ let _plannerRateLimitInfo = {
177
+ utilization: 0, status: "", isUsingOverage: false, windows: new Map(), costUsd: 0,
178
+ };
179
+ export function getPlannerRateLimitInfo() { return _plannerRateLimitInfo; }
175
180
  async function runPlannerQueryOnce(prompt, opts, onLog) {
181
+ _plannerRateLimitInfo = { utilization: 0, status: "", isUsingOverage: false, windows: new Map(), costUsd: 0 };
176
182
  let resultText = "";
177
183
  const startedAt = Date.now();
178
184
  const pq = query({
@@ -191,14 +197,18 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
191
197
  // Progress ticker — fast updates with compact format
192
198
  let lastLogText = "";
193
199
  let toolCount = 0;
200
+ let costUsd = 0;
194
201
  const ticker = setInterval(() => {
195
202
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
196
203
  const m = Math.floor(elapsed / 60);
197
204
  const s = elapsed % 60;
198
205
  const timeStr = m > 0 ? `${m}m ${s}s` : `${s}s`;
199
206
  const toolStr = toolCount > 0 ? ` · ${toolCount} tools` : "";
207
+ const costStr = costUsd > 0 ? ` · $${costUsd.toFixed(3)}` : "";
208
+ const rlPct = _plannerRateLimitInfo.utilization;
209
+ const rlStr = rlPct > 0 ? ` · ${Math.round(rlPct * 100)}%` : "";
200
210
  const extra = lastLogText ? ` · ${lastLogText}` : "";
201
- onLog(`${timeStr}${toolStr}${extra}`);
211
+ onLog(`${timeStr}${toolStr}${costStr}${rlStr}${extra}`);
202
212
  }, 500);
203
213
  let lastActivity = Date.now();
204
214
  let timer;
@@ -235,11 +245,35 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
235
245
  }
236
246
  }
237
247
  }
248
+ if (msg.type === "rate_limit_event") {
249
+ const info = msg.rate_limit_info;
250
+ if (info) {
251
+ _plannerRateLimitInfo.utilization = info.utilization ?? 0;
252
+ _plannerRateLimitInfo.status = info.status ?? "";
253
+ if (info.isUsingOverage)
254
+ _plannerRateLimitInfo.isUsingOverage = true;
255
+ if (info.resetsAt)
256
+ _plannerRateLimitInfo.resetsAt = info.resetsAt;
257
+ if (info.rateLimitType) {
258
+ _plannerRateLimitInfo.windows.set(info.rateLimitType, {
259
+ type: info.rateLimitType,
260
+ utilization: info.utilization ?? 0,
261
+ status: info.status,
262
+ resetsAt: info.resetsAt,
263
+ });
264
+ }
265
+ }
266
+ }
238
267
  if (msg.type === "result") {
268
+ const r = msg;
269
+ if (typeof r.total_cost_usd === "number") {
270
+ costUsd = r.total_cost_usd;
271
+ _plannerRateLimitInfo.costUsd += costUsd;
272
+ }
239
273
  if (msg.subtype === "success")
240
- resultText = msg.result || "";
274
+ resultText = r.result || "";
241
275
  else
242
- throw new Error(`Planner failed: ${msg.result || msg.subtype}`);
276
+ throw new Error(`Planner failed: ${r.result || msg.subtype}`);
243
277
  }
244
278
  }
245
279
  };
@@ -332,24 +366,8 @@ export async function planTasks(objective, cwd, plannerModel, workerModel, permi
332
366
  return tasks;
333
367
  }
334
368
  // ── Thinking wave ──
335
- export async function identifyThemes(objective, count, model, permissionMode) {
336
- let resultText = "";
337
- for await (const msg of query({
338
- prompt: `Split this objective into exactly ${count} independent research angles for architects exploring a codebase. Each angle should cover a distinct aspect.
339
-
340
- Objective: ${objective}
341
-
342
- Return ONLY a JSON object: {"themes": ["angle description", ...]}`,
343
- options: {
344
- model,
345
- permissionMode,
346
- ...(permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
347
- persistSession: false,
348
- },
349
- })) {
350
- if (msg.type === "result" && msg.subtype === "success")
351
- resultText = msg.result || "";
352
- }
369
+ export async function identifyThemes(objective, count, model, permissionMode, onLog = () => { }) {
370
+ const resultText = await runPlannerQuery(`Split this objective into exactly ${count} independent research angles for architects exploring a codebase. Each angle should cover a distinct aspect.\n\nObjective: ${objective}\n\nReturn ONLY a JSON object: {"themes": ["angle description", ...]}`, { cwd: process.cwd(), model, permissionMode }, onLog);
353
371
  const parsed = attemptJsonParse(resultText);
354
372
  if (parsed?.themes && Array.isArray(parsed.themes))
355
373
  return parsed.themes.slice(0, count);
package/dist/swarm.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Task, AgentState, SwarmPhase, PermMode, MergeStrategy } from "./types.js";
1
+ import type { Task, AgentState, SwarmPhase, PermMode, MergeStrategy, RateLimitWindow } from "./types.js";
2
2
  export interface SwarmConfig {
3
3
  tasks: Task[];
4
4
  concurrency: number;
@@ -12,6 +12,10 @@ export interface SwarmConfig {
12
12
  mergeStrategy?: MergeStrategy;
13
13
  /** Stop dispatching new tasks when rate-limit utilization reaches this fraction (0-1). */
14
14
  usageCap?: number;
15
+ /** Allow agents to use extra usage (overage billing). Default false. */
16
+ allowExtraUsage?: boolean;
17
+ /** Max $ to spend on extra usage before stopping. Only applies when allowExtraUsage is true. */
18
+ extraUsageBudget?: number;
15
19
  }
16
20
  export interface MergeResult {
17
21
  branch: string;
@@ -42,6 +46,14 @@ export declare class Swarm {
42
46
  rateLimitUtilization: number;
43
47
  rateLimitStatus: string;
44
48
  rateLimitResetsAt?: number;
49
+ /** Per-window rate limit snapshots (updated on every rate_limit_event). */
50
+ rateLimitWindows: Map<string, RateLimitWindow>;
51
+ /** Whether any agent is currently using extra/overage usage. */
52
+ isUsingOverage: boolean;
53
+ /** Why overage is disabled (if applicable). */
54
+ overageDisabledReason?: string;
55
+ /** Accumulated cost from extra/overage usage only. */
56
+ overageCostUsd: number;
45
57
  private queue;
46
58
  private config;
47
59
  private nextId;
@@ -50,7 +62,9 @@ export declare class Swarm {
50
62
  private cleanedUp;
51
63
  logFile?: string;
52
64
  readonly model: string | undefined;
53
- readonly usageCap: number | undefined;
65
+ usageCap: number | undefined;
66
+ readonly allowExtraUsage: boolean;
67
+ readonly extraUsageBudget: number | undefined;
54
68
  constructor(config: SwarmConfig);
55
69
  get active(): number;
56
70
  get pending(): number;
@@ -60,6 +74,7 @@ export declare class Swarm {
60
74
  logSequence: number;
61
75
  log(agentId: number, text: string): void;
62
76
  private worker;
77
+ private capForOverage;
63
78
  private throttle;
64
79
  private runAgent;
65
80
  private autoCommit;
package/dist/swarm.js CHANGED
@@ -22,6 +22,14 @@ export class Swarm {
22
22
  rateLimitUtilization = 0;
23
23
  rateLimitStatus = "";
24
24
  rateLimitResetsAt;
25
+ /** Per-window rate limit snapshots (updated on every rate_limit_event). */
26
+ rateLimitWindows = new Map();
27
+ /** Whether any agent is currently using extra/overage usage. */
28
+ isUsingOverage = false;
29
+ /** Why overage is disabled (if applicable). */
30
+ overageDisabledReason;
31
+ /** Accumulated cost from extra/overage usage only. */
32
+ overageCostUsd = 0;
25
33
  queue;
26
34
  config;
27
35
  nextId = 0;
@@ -30,7 +38,9 @@ export class Swarm {
30
38
  cleanedUp = false;
31
39
  logFile;
32
40
  model;
33
- usageCap;
41
+ usageCap; // mutable — can be changed live
42
+ allowExtraUsage;
43
+ extraUsageBudget;
34
44
  constructor(config) {
35
45
  if (!config.tasks.length) {
36
46
  throw new Error("SwarmConfig: tasks array must not be empty");
@@ -52,6 +62,8 @@ export class Swarm {
52
62
  this.config = config;
53
63
  this.model = config.model;
54
64
  this.usageCap = config.usageCap;
65
+ this.allowExtraUsage = config.allowExtraUsage ?? false;
66
+ this.extraUsageBudget = config.extraUsageBudget;
55
67
  this.queue = [...config.tasks];
56
68
  this.total = config.tasks.length;
57
69
  }
@@ -119,14 +131,33 @@ export class Swarm {
119
131
  }
120
132
  this.log(-1, `Worker finished (${tasksProcessed} tasks)`);
121
133
  }
134
+ capForOverage(reason) {
135
+ if (this.cappedOut)
136
+ return;
137
+ this.cappedOut = true;
138
+ this.queue.length = 0;
139
+ this.log(-1, reason);
140
+ }
122
141
  async throttle() {
142
+ if (this.cappedOut)
143
+ return;
123
144
  // Usage cap: stop dispatching when utilization exceeds user's cap
124
- const cap = this.config.usageCap;
145
+ const cap = this.usageCap;
125
146
  if (cap != null && cap < 1 && this.rateLimitUtilization >= cap) {
126
147
  this.cappedOut = true;
127
148
  this.log(-1, `Usage cap ${Math.round(cap * 100)}% reached (at ${Math.round(this.rateLimitUtilization * 100)}%) — finishing active agents, no new tasks`);
128
149
  return;
129
150
  }
151
+ // Extra usage enforcement: stop if overage detected and not allowed
152
+ if (this.isUsingOverage && !this.allowExtraUsage) {
153
+ this.capForOverage(`Extra usage detected but not allowed — stopping dispatch`);
154
+ return;
155
+ }
156
+ // Extra usage budget enforcement
157
+ if (this.isUsingOverage && this.extraUsageBudget != null && this.overageCostUsd >= this.extraUsageBudget) {
158
+ this.capForOverage(`Extra usage budget $${this.extraUsageBudget} reached ($${this.overageCostUsd.toFixed(2)} spent) — stopping dispatch`);
159
+ return;
160
+ }
130
161
  // Hard block: rate limit rejected — wait until reset
131
162
  if (this.rateLimitResetsAt) {
132
163
  const resetTarget = this.rateLimitResetsAt;
@@ -604,6 +635,8 @@ export class Swarm {
604
635
  const cost = safeAdd(r.total_cost_usd);
605
636
  agent.costUsd = cost;
606
637
  this.totalCostUsd += cost;
638
+ if (this.isUsingOverage)
639
+ this.overageCostUsd += cost;
607
640
  if (r.usage) {
608
641
  this.totalInputTokens += safeAdd(r.usage.input_tokens);
609
642
  this.totalOutputTokens += safeAdd(r.usage.output_tokens);
@@ -629,8 +662,29 @@ export class Swarm {
629
662
  if (info.status === "rejected" && info.resetsAt) {
630
663
  this.rateLimitResetsAt = info.resetsAt;
631
664
  }
665
+ // Track per-window state
666
+ const windowType = info.rateLimitType;
667
+ if (windowType) {
668
+ this.rateLimitWindows.set(windowType, {
669
+ type: windowType,
670
+ utilization: info.utilization ?? 0,
671
+ status: info.status,
672
+ resetsAt: info.resetsAt,
673
+ });
674
+ }
675
+ // Track overage state
676
+ if (info.isUsingOverage) {
677
+ this.isUsingOverage = true;
678
+ }
679
+ if (info.overageDisabledReason) {
680
+ this.overageDisabledReason = info.overageDisabledReason;
681
+ }
682
+ if (this.isUsingOverage && !this.allowExtraUsage) {
683
+ this.capForOverage(`Extra usage detected but not allowed — stopping dispatch`);
684
+ }
632
685
  const pct = info.utilization != null ? `${Math.round(info.utilization * 100)}%` : "";
633
- this.log(agent.id, `Rate: ${info.status} ${pct}`);
686
+ const overageTag = this.isUsingOverage ? " [EXTRA]" : "";
687
+ this.log(agent.id, `Rate: ${info.status} ${pct}${overageTag}${windowType ? ` (${windowType})` : ""}`);
634
688
  break;
635
689
  }
636
690
  }
package/dist/types.d.ts CHANGED
@@ -103,6 +103,13 @@ export interface BranchRecord {
103
103
  filesChanged: number;
104
104
  costUsd: number;
105
105
  }
106
+ /** Per-window rate limit snapshot (matches SDK rateLimitType). */
107
+ export interface RateLimitWindow {
108
+ type: string;
109
+ utilization: number;
110
+ status: string;
111
+ resetsAt?: number;
112
+ }
106
113
  /** Persisted run state for crash recovery and resume. */
107
114
  export interface RunState {
108
115
  id: string;
@@ -114,6 +121,8 @@ export interface RunState {
114
121
  concurrency: number;
115
122
  permissionMode: PermMode;
116
123
  usageCap?: number;
124
+ allowExtraUsage: boolean;
125
+ extraUsageBudget?: number;
117
126
  flex: boolean;
118
127
  useWorktrees: boolean;
119
128
  mergeStrategy: MergeStrategy;
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import type { Swarm } from "./swarm.js";
2
- export declare function renderFrame(swarm: Swarm): string;
3
- export declare function startRenderLoop(swarm: Swarm): () => void;
2
+ export declare function renderFrame(swarm: Swarm, showHotkeys?: boolean): string;
3
+ /** Mutable config that can be changed live during execution. */
4
+ export interface LiveConfig {
5
+ remaining: number;
6
+ usageCap: number | undefined;
7
+ /** Set by hotkey handler when user changes a value. Cleared after main loop reads it. */
8
+ dirty: boolean;
9
+ }
10
+ export declare function startRenderLoop(swarm: Swarm, liveConfig?: LiveConfig): () => void;
4
11
  export declare function renderSummary(swarm: Swarm): string;
package/dist/ui.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import chalk from "chalk";
2
2
  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
+ };
3
7
  function colorEvent(text) {
4
8
  if (text === "Done" || text.startsWith("Merged ") || text.startsWith("Committed "))
5
9
  return chalk.green(text);
@@ -11,7 +15,7 @@ function colorEvent(text) {
11
15
  return chalk.yellow(text);
12
16
  return text;
13
17
  }
14
- export function renderFrame(swarm) {
18
+ export function renderFrame(swarm, showHotkeys = false) {
15
19
  const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
16
20
  const out = [];
17
21
  // ── Header ──
@@ -43,33 +47,58 @@ export function renderFrame(swarm) {
43
47
  : "";
44
48
  out.push(chalk.gray(` \u2191 ${tokIn} in \u2193 ${tokOut} out`) +
45
49
  (cost ? ` ${cost}` : ""));
46
- // ── Usage bar ──
50
+ // ── Usage bar(s) — cycle through windows every 3s ──
51
+ const windows = Array.from(swarm.rateLimitWindows.values());
47
52
  const rlPct = swarm.rateLimitUtilization;
48
- if (rlPct > 0 || swarm.rateLimitResetsAt || swarm.cappedOut) {
53
+ if (rlPct > 0 || swarm.rateLimitResetsAt || swarm.cappedOut || windows.length > 0) {
49
54
  const barW = Math.min(30, w - 40);
50
- const filled = Math.round(rlPct * barW);
51
55
  const capFrac = swarm.usageCap;
52
56
  const capMark = capFrac != null && capFrac < 1 ? Math.round(capFrac * barW) : -1;
53
- let barStr = "";
54
- for (let i = 0; i < barW; i++) {
55
- if (i === capMark)
56
- barStr += chalk.yellow("\u2502");
57
- else if (i < filled)
58
- barStr += rlPct > 0.9 ? chalk.red("\u2588") : rlPct > 0.75 ? chalk.yellow("\u2588") : chalk.blue("\u2588");
59
- else
60
- barStr += chalk.gray("\u2591");
61
- }
62
- let label = `${Math.round(rlPct * 100)}% used`;
63
- if (swarm.cappedOut) {
64
- label = chalk.yellow(`Capped at ${capFrac != null ? Math.round(capFrac * 100) : 100}% — finishing active`);
57
+ // Show primary usage bar
58
+ const renderBar = (pct, windowLabel) => {
59
+ const filled = Math.round(pct * barW);
60
+ let barStr = "";
61
+ for (let i = 0; i < barW; i++) {
62
+ if (i === capMark)
63
+ barStr += chalk.yellow("\u2502");
64
+ else if (i < filled)
65
+ barStr += pct > 0.9 ? chalk.red("\u2588") : pct > 0.75 ? chalk.yellow("\u2588") : chalk.blue("\u2588");
66
+ else
67
+ barStr += chalk.gray("\u2591");
68
+ }
69
+ let label = `${Math.round(pct * 100)}% used`;
70
+ if (swarm.cappedOut) {
71
+ if (swarm.isUsingOverage && !swarm.allowExtraUsage) {
72
+ label = chalk.red("Extra usage blocked — stopping");
73
+ }
74
+ else {
75
+ label = chalk.yellow(`Capped at ${capFrac != null ? Math.round(capFrac * 100) : 100}% — finishing active`);
76
+ }
77
+ }
78
+ else if (swarm.rateLimitResetsAt) {
79
+ const waitSec = Math.max(0, Math.ceil((swarm.rateLimitResetsAt - Date.now()) / 1000));
80
+ const mm = Math.floor(waitSec / 60);
81
+ const ss = waitSec % 60;
82
+ label = chalk.red(`Waiting for reset ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`);
83
+ }
84
+ if (swarm.isUsingOverage && !swarm.cappedOut)
85
+ label += chalk.red(" [EXTRA USAGE]");
86
+ const prefix = windowLabel ? chalk.dim(windowLabel.padEnd(6)) : chalk.dim("Usage ");
87
+ out.push(` ${prefix}${barStr} ${label}`);
88
+ };
89
+ if (windows.length > 1) {
90
+ // Cycle through windows every 3 seconds
91
+ const cycleIdx = Math.floor(Date.now() / 3000) % windows.length;
92
+ const win = windows[cycleIdx];
93
+ const shortName = WINDOW_SHORT_NAMES[win.type] ?? win.type.replace(/_/g, " ");
94
+ renderBar(win.utilization, shortName);
95
+ // Show dots indicator for which window we're viewing
96
+ const dots = windows.map((_, i) => i === cycleIdx ? "●" : "○").join("");
97
+ out[out.length - 1] += chalk.dim(` ${dots}`);
65
98
  }
66
- else if (swarm.rateLimitResetsAt) {
67
- const waitSec = Math.max(0, Math.ceil((swarm.rateLimitResetsAt - Date.now()) / 1000));
68
- const mm = Math.floor(waitSec / 60);
69
- const ss = waitSec % 60;
70
- label = chalk.red(`Waiting for reset ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`);
99
+ else {
100
+ renderBar(rlPct);
71
101
  }
72
- out.push(` ${chalk.dim("Usage")} ${barStr} ${label}`);
73
102
  }
74
103
  out.push("");
75
104
  // ── Agent table ──
@@ -115,6 +144,8 @@ export function renderFrame(swarm) {
115
144
  : chalk.cyan(`[${entry.agentId}]`);
116
145
  out.push(chalk.gray(` ${t} `) + tag + ` ${colorEvent(truncate(entry.text, w - 22))}`);
117
146
  }
147
+ if (showHotkeys)
148
+ out.push(chalk.dim(" [b] budget [t] threshold [q] stop"));
118
149
  out.push("");
119
150
  return out.join("\n");
120
151
  }
@@ -170,7 +201,7 @@ function fmtDur(ms) {
170
201
  return `${m}m ${s % 60}s`;
171
202
  return `${Math.floor(m / 60)}h ${m % 60}m`;
172
203
  }
173
- export function startRenderLoop(swarm) {
204
+ export function startRenderLoop(swarm, liveConfig) {
174
205
  if (!process.stdout.isTTY) {
175
206
  return startPlainLog(swarm);
176
207
  }
@@ -180,17 +211,93 @@ export function startRenderLoop(swarm) {
180
211
  catch {
181
212
  return () => { };
182
213
  }
214
+ // Live hotkey input state
215
+ let inputMode = "none";
216
+ let inputBuf = "";
217
+ const hasHotkeys = !!liveConfig && !!process.stdin.isTTY;
218
+ const render = () => {
219
+ let frame = renderFrame(swarm, hasHotkeys);
220
+ if (inputMode !== "none") {
221
+ const label = inputMode === "budget" ? "New budget (remaining sessions)" : "New usage cap (0-100%)";
222
+ frame += `\n ${chalk.cyan(">")} ${label}: ${inputBuf}█`;
223
+ }
224
+ return frame;
225
+ };
183
226
  const interval = setInterval(() => {
184
227
  try {
185
228
  process.stdout.write("\x1B[H\x1B[J");
186
- process.stdout.write(renderFrame(swarm));
229
+ process.stdout.write(render());
187
230
  }
188
231
  catch {
189
232
  clearInterval(interval);
190
233
  }
191
234
  }, 250);
235
+ // Keyboard listener for live controls
236
+ let keyHandler;
237
+ if (liveConfig && process.stdin.isTTY) {
238
+ try {
239
+ process.stdin.setRawMode(true);
240
+ process.stdin.resume();
241
+ }
242
+ catch { }
243
+ keyHandler = (buf) => {
244
+ const s = buf.toString();
245
+ if (inputMode !== "none") {
246
+ if (s === "\r" || s === "\n") {
247
+ const val = parseFloat(inputBuf);
248
+ if (inputMode === "budget" && !isNaN(val) && val > 0) {
249
+ liveConfig.remaining = Math.round(val);
250
+ liveConfig.dirty = true;
251
+ swarm.log(-1, `Budget changed to ${liveConfig.remaining} remaining`);
252
+ }
253
+ else if (inputMode === "threshold" && !isNaN(val) && val >= 0 && val <= 100) {
254
+ const frac = val / 100;
255
+ liveConfig.usageCap = frac > 0 ? frac : undefined;
256
+ liveConfig.dirty = true;
257
+ swarm.usageCap = liveConfig.usageCap;
258
+ swarm.log(-1, `Usage cap changed to ${val > 0 ? val + "%" : "unlimited"}`);
259
+ }
260
+ inputMode = "none";
261
+ inputBuf = "";
262
+ }
263
+ else if (s === "\x1B" || s === "\x03") {
264
+ inputMode = "none";
265
+ inputBuf = "";
266
+ }
267
+ else if (s === "\x7F") {
268
+ inputBuf = inputBuf.slice(0, -1);
269
+ }
270
+ else if (/^[0-9.]$/.test(s)) {
271
+ inputBuf += s;
272
+ }
273
+ return;
274
+ }
275
+ if (s === "b" || s === "B") {
276
+ inputMode = "budget";
277
+ inputBuf = "";
278
+ }
279
+ else if (s === "t" || s === "T") {
280
+ inputMode = "threshold";
281
+ inputBuf = "";
282
+ }
283
+ else if (s === "q" || s === "Q" || s === "\x03") {
284
+ if (swarm.aborted)
285
+ process.exit(0); // second press = force quit
286
+ swarm.abort();
287
+ }
288
+ };
289
+ process.stdin.on("data", keyHandler);
290
+ }
192
291
  return () => {
193
292
  clearInterval(interval);
293
+ if (keyHandler) {
294
+ process.stdin.removeListener("data", keyHandler);
295
+ try {
296
+ process.stdin.setRawMode(false);
297
+ process.stdin.pause();
298
+ }
299
+ catch { }
300
+ }
194
301
  try {
195
302
  process.stdout.write("\x1B[H\x1B[J");
196
303
  process.stdout.write(renderFrame(swarm));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.1.0",
3
+ "version": "1.2.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": {