claude-overnight 1.2.1 → 1.3.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
@@ -178,7 +178,7 @@ claude-overnight "fix auth bug in src/auth.ts" "add tests for user model"
178
178
  | `--usage-cap=N` | unlimited | Stop at N% utilization |
179
179
  | `--allow-extra-usage` | off | Allow extra/overage usage (billed separately) |
180
180
  | `--extra-usage-budget=N` | — | Max $ for extra usage (implies --allow-extra-usage) |
181
- | `--timeout=SECONDS` | `300` | Inactivity timeout per agent |
181
+ | `--timeout=SECONDS` | `900` | Inactivity timeout per agent (nudges at timeout, kills at 2×) |
182
182
  | `--no-flex` | — | Disable multi-wave steering |
183
183
  | `--dry-run` | — | Show planned tasks without running |
184
184
 
@@ -225,6 +225,8 @@ The usage bar cycles through all rate limit windows (5h, 7d, etc.) every 3 secon
225
225
 
226
226
  Built for unattended runs lasting hours or days.
227
227
 
228
+ - **Smooth overage transition**: when extra usage is allowed, plan limit rejection is seamless — no dispatch blocking, agents continue into overage
229
+ - **Interrupt + resume**: agents and planner queries that go silent are interrupted and resumed with full conversation context via SDK session resume — not killed and restarted from scratch
228
230
  - **Hard block**: pauses until the rate limit window resets, then resumes
229
231
  - **Soft throttle**: slows dispatch at >75% utilization
230
232
  - **Extra usage guard**: detects overage billing and stops unless explicitly allowed
package/dist/index.js CHANGED
@@ -525,7 +525,7 @@ async function main() {
525
525
  --usage-cap=N Stop at N% utilization ${chalk.dim("(e.g. 90 to save 10% for other work)")}
526
526
  --allow-extra-usage Allow extra/overage usage ${chalk.dim("(default: stop when plan limits hit)")}
527
527
  --extra-usage-budget=N Max $ for extra usage ${chalk.dim("(implies --allow-extra-usage)")}
528
- --timeout=SECONDS Agent inactivity timeout ${chalk.dim("(default: 300s, kills only silent agents)")}
528
+ --timeout=SECONDS Agent inactivity timeout ${chalk.dim("(default: 900s, nudges at timeout, kills at )")}
529
529
  --no-flex Disable adaptive multi-wave planning ${chalk.dim("(run all tasks in one shot)")}
530
530
 
531
531
  ${chalk.cyan("Defaults")} ${chalk.dim("(non-interactive)")}
package/dist/planner.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { readFileSync } from "fs";
3
- const INACTIVITY_MS = 5 * 60 * 1000;
3
+ import { NudgeError } from "./types.js";
4
+ const NUDGE_MS = 15 * 60 * 1000; // 15 min — close & restart with "continue"
5
+ const HARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 min — give up
4
6
  export function detectModelTier(model) {
5
7
  const m = model.toLowerCase();
6
8
  if (m === "default" || m.includes("opus"))
@@ -155,11 +157,25 @@ function isRateLimitError(err) {
155
157
  async function runPlannerQuery(prompt, opts, onLog) {
156
158
  const MAX_RETRIES = 3;
157
159
  const BACKOFF = [30_000, 60_000, 120_000];
160
+ let currentPrompt = prompt;
161
+ let currentOpts = opts;
158
162
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
159
163
  try {
160
- return await runPlannerQueryOnce(prompt, opts, onLog);
164
+ return await runPlannerQueryOnce(currentPrompt, currentOpts, onLog);
161
165
  }
162
166
  catch (err) {
167
+ if (err instanceof NudgeError) {
168
+ if (err.sessionId) {
169
+ onLog("Silent 15m — resuming session with continue");
170
+ currentPrompt = "Continue. Complete the task.";
171
+ currentOpts = { ...opts, resumeSessionId: err.sessionId };
172
+ }
173
+ else {
174
+ onLog("Silent 15m — restarting planner (no session to resume)");
175
+ // No session captured, just retry from scratch
176
+ }
177
+ continue;
178
+ }
163
179
  if (attempt < MAX_RETRIES && isRateLimitError(err)) {
164
180
  const waitMs = BACKOFF[attempt];
165
181
  const waitSec = Math.round(waitMs / 1000);
@@ -181,6 +197,7 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
181
197
  _plannerRateLimitInfo = { utilization: 0, status: "", isUsingOverage: false, windows: new Map(), costUsd: 0 };
182
198
  let resultText = "";
183
199
  const startedAt = Date.now();
200
+ const isResume = !!opts.resumeSessionId;
184
201
  const pq = query({
185
202
  prompt,
186
203
  options: {
@@ -190,8 +207,9 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
190
207
  allowedTools: ["Read", "Glob", "Grep", "Write"],
191
208
  permissionMode: opts.permissionMode,
192
209
  ...(opts.permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
193
- persistSession: false,
210
+ persistSession: true, // needed for interrupt+resume
194
211
  includePartialMessages: true,
212
+ ...(isResume && { resume: opts.resumeSessionId }),
195
213
  },
196
214
  });
197
215
  // Progress ticker — fast updates with compact format
@@ -210,23 +228,34 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
210
228
  const extra = lastLogText ? ` · ${lastLogText}` : "";
211
229
  onLog(`${timeStr}${toolStr}${costStr}${rlStr}${extra}`);
212
230
  }, 500);
231
+ const timeoutMs = isResume ? HARD_TIMEOUT_MS : NUDGE_MS;
232
+ let sessionId;
213
233
  let lastActivity = Date.now();
214
234
  let timer;
215
235
  const watchdog = new Promise((_, reject) => {
216
236
  const check = () => {
217
237
  const silent = Date.now() - lastActivity;
218
- if (silent >= INACTIVITY_MS) {
219
- pq.close();
220
- reject(new Error(`Planner silent for ${Math.round(silent / 1000)}s — assumed hung`));
238
+ if (silent >= timeoutMs) {
239
+ // Try interrupt (graceful), fall back to close (hard kill)
240
+ pq.interrupt().catch(() => pq.close());
241
+ if (isResume) {
242
+ reject(new Error(`Planner silent for ${Math.round(silent / 1000)}s — assumed hung`));
243
+ }
244
+ else {
245
+ reject(new NudgeError(sessionId, silent));
246
+ }
221
247
  }
222
248
  else
223
- timer = setTimeout(check, Math.min(30_000, INACTIVITY_MS - silent + 1000));
249
+ timer = setTimeout(check, Math.min(30_000, timeoutMs - silent + 1000));
224
250
  };
225
- timer = setTimeout(check, INACTIVITY_MS);
251
+ timer = setTimeout(check, timeoutMs);
226
252
  });
227
253
  const consume = async () => {
228
254
  for await (const msg of pq) {
229
255
  lastActivity = Date.now();
256
+ if (!sessionId && "session_id" in msg) {
257
+ sessionId = msg.session_id;
258
+ }
230
259
  if (msg.type === "stream_event") {
231
260
  const ev = msg.event;
232
261
  if (ev?.type === "content_block_start" && ev.content_block?.type === "tool_use") {
package/dist/swarm.d.ts CHANGED
@@ -44,7 +44,6 @@ export declare class Swarm {
44
44
  cappedOut: boolean;
45
45
  mergeResults: MergeResult[];
46
46
  rateLimitUtilization: number;
47
- rateLimitStatus: string;
48
47
  rateLimitResetsAt?: number;
49
48
  /** Per-window rate limit snapshots (updated on every rate_limit_event). */
50
49
  rateLimitWindows: Map<string, RateLimitWindow>;
package/dist/swarm.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { tmpdir } from "os";
5
5
  import { query } from "@anthropic-ai/claude-agent-sdk";
6
+ import { NudgeError } from "./types.js";
6
7
  export class Swarm {
7
8
  agents = [];
8
9
  logs = [];
@@ -20,7 +21,6 @@ export class Swarm {
20
21
  mergeResults = [];
21
22
  // Rate limit tracking for auto-concurrency
22
23
  rateLimitUtilization = 0;
23
- rateLimitStatus = "";
24
24
  rateLimitResetsAt;
25
25
  /** Per-window rate limit snapshots (updated on every rate_limit_event). */
26
26
  rateLimitWindows = new Map();
@@ -212,7 +212,7 @@ export class Swarm {
212
212
  this.log(id, `Starting: ${task.prompt.slice(0, 60)}`);
213
213
  const maxRetries = this.config.maxRetries ?? 2;
214
214
  // Inactivity timeout: kill agent only if it goes silent (no messages)
215
- const inactivityMs = this.config.agentTimeoutMs ?? 5 * 60 * 1000;
215
+ const inactivityMs = this.config.agentTimeoutMs ?? 15 * 60 * 1000;
216
216
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
217
217
  if (attempt > 0) {
218
218
  const backoffMs = Math.min(30000, 1000 * 2 ** (attempt - 1)) * (0.5 + Math.random());
@@ -224,51 +224,82 @@ export class Swarm {
224
224
  }
225
225
  try {
226
226
  const perm = this.config.permissionMode ?? "auto";
227
- const agentQuery = query({
228
- prompt: this.config.useWorktrees
229
- ? `You are working in an isolated git worktree. Focus only on this task. Do NOT commit your changes — the framework handles that.\n\n${task.prompt}`
230
- : task.prompt,
231
- options: {
232
- cwd: agentCwd,
233
- model: task.model || this.config.model,
234
- permissionMode: perm,
235
- ...(perm === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
236
- allowedTools: this.config.allowedTools,
237
- includePartialMessages: true,
238
- persistSession: false,
239
- },
240
- });
241
- // Inactivity watchdog: resets on every message, only fires if agent goes silent
242
- let lastActivity = Date.now();
243
- let timer;
244
- const watchdog = new Promise((_, reject) => {
245
- const check = () => {
246
- const silent = Date.now() - lastActivity;
247
- if (silent >= inactivityMs) {
248
- agentQuery.close();
249
- reject(new AgentTimeoutError(silent));
250
- }
251
- else {
252
- timer = setTimeout(check, Math.min(30_000, inactivityMs - silent + 1000));
253
- }
254
- };
255
- timer = setTimeout(check, inactivityMs);
256
- });
257
- this.activeQueries.add(agentQuery);
258
- try {
259
- await Promise.race([
260
- (async () => {
261
- for await (const msg of agentQuery) {
262
- lastActivity = Date.now();
263
- this.handleMsg(agent, msg);
227
+ let resumeSessionId;
228
+ const runOnce = async (isResume) => {
229
+ const agentPrompt = isResume
230
+ ? "Continue. Complete the task."
231
+ : this.config.useWorktrees
232
+ ? `You are working in an isolated git worktree. Focus only on this task. Do NOT commit your changes — the framework handles that.\n\n${task.prompt}`
233
+ : task.prompt;
234
+ const agentQuery = query({
235
+ prompt: agentPrompt,
236
+ options: {
237
+ cwd: agentCwd,
238
+ model: task.model || this.config.model,
239
+ permissionMode: perm,
240
+ ...(perm === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
241
+ allowedTools: this.config.allowedTools,
242
+ includePartialMessages: true,
243
+ persistSession: true,
244
+ ...(isResume && resumeSessionId && { resume: resumeSessionId }),
245
+ },
246
+ });
247
+ const timeoutMs = isResume ? inactivityMs * 2 : inactivityMs;
248
+ let sessionId;
249
+ let lastActivity = Date.now();
250
+ let timer;
251
+ const watchdog = new Promise((_, reject) => {
252
+ const check = () => {
253
+ const silent = Date.now() - lastActivity;
254
+ if (silent >= timeoutMs) {
255
+ agentQuery.interrupt().catch(() => agentQuery.close());
256
+ if (isResume) {
257
+ reject(new AgentTimeoutError(silent));
258
+ }
259
+ else {
260
+ reject(new NudgeError(sessionId, silent));
261
+ }
264
262
  }
265
- })(),
266
- watchdog,
267
- ]);
263
+ else {
264
+ timer = setTimeout(check, Math.min(30_000, timeoutMs - silent + 1000));
265
+ }
266
+ };
267
+ timer = setTimeout(check, timeoutMs);
268
+ });
269
+ this.activeQueries.add(agentQuery);
270
+ try {
271
+ await Promise.race([
272
+ (async () => {
273
+ for await (const msg of agentQuery) {
274
+ lastActivity = Date.now();
275
+ if (!sessionId && "session_id" in msg) {
276
+ sessionId = msg.session_id;
277
+ }
278
+ this.handleMsg(agent, msg);
279
+ }
280
+ })(),
281
+ watchdog,
282
+ ]);
283
+ }
284
+ finally {
285
+ clearTimeout(timer);
286
+ this.activeQueries.delete(agentQuery);
287
+ if (sessionId)
288
+ resumeSessionId = sessionId;
289
+ }
290
+ };
291
+ // Run with nudge: first attempt can be interrupted and resumed
292
+ try {
293
+ await runOnce(false);
268
294
  }
269
- finally {
270
- clearTimeout(timer);
271
- this.activeQueries.delete(agentQuery);
295
+ catch (nudgeErr) {
296
+ if (nudgeErr instanceof NudgeError && resumeSessionId) {
297
+ this.log(id, `Silent ${Math.round(inactivityMs / 60000)}m — resuming with continue`);
298
+ await runOnce(true);
299
+ }
300
+ else {
301
+ throw nudgeErr;
302
+ }
272
303
  }
273
304
  if (agent.status === "running") {
274
305
  agent.finishedAt = Date.now();
@@ -658,10 +689,14 @@ export class Swarm {
658
689
  const rl = msg;
659
690
  const info = rl.rate_limit_info;
660
691
  this.rateLimitUtilization = info.utilization ?? 0;
661
- this.rateLimitStatus = info.status;
662
- if (info.status === "rejected" && info.resetsAt) {
692
+ if (info.status === "rejected" && info.resetsAt && !this.allowExtraUsage) {
693
+ // Only block dispatch on rejection if extra usage is NOT allowed.
694
+ // When extra usage is allowed, rejection is just the plan→overage transition.
663
695
  this.rateLimitResetsAt = info.resetsAt;
664
696
  }
697
+ else if (info.status !== "rejected") {
698
+ this.rateLimitResetsAt = undefined;
699
+ }
665
700
  // Track per-window state
666
701
  const windowType = info.rateLimitType;
667
702
  if (windowType) {
@@ -684,7 +719,10 @@ export class Swarm {
684
719
  }
685
720
  const pct = info.utilization != null ? `${Math.round(info.utilization * 100)}%` : "";
686
721
  const overageTag = this.isUsingOverage ? " [EXTRA]" : "";
687
- this.log(agent.id, `Rate: ${info.status} ${pct}${overageTag}${windowType ? ` (${windowType})` : ""}`);
722
+ const statusLabel = info.status === "rejected" && this.allowExtraUsage
723
+ ? "switching to extra usage"
724
+ : info.status;
725
+ this.log(agent.id, `Rate: ${statusLabel} ${pct}${overageTag}${windowType ? ` (${windowType})` : ""}`);
688
726
  break;
689
727
  }
690
728
  }
package/dist/types.d.ts CHANGED
@@ -110,6 +110,11 @@ export interface RateLimitWindow {
110
110
  status: string;
111
111
  resetsAt?: number;
112
112
  }
113
+ /** Thrown when a query goes silent — carries session ID for interrupt+resume. */
114
+ export declare class NudgeError extends Error {
115
+ sessionId: string | undefined;
116
+ constructor(sessionId: string | undefined, silentMs: number);
117
+ }
113
118
  /** Persisted run state for crash recovery and resume. */
114
119
  export interface RunState {
115
120
  id: string;
package/dist/types.js CHANGED
@@ -1 +1,9 @@
1
- export {};
1
+ /** Thrown when a query goes silent — carries session ID for interrupt+resume. */
2
+ export class NudgeError extends Error {
3
+ sessionId;
4
+ constructor(sessionId, silentMs) {
5
+ super(`Silent for ${Math.round(silentMs / 1000)}s — nudging`);
6
+ this.sessionId = sessionId;
7
+ this.name = "NudgeError";
8
+ }
9
+ }
package/dist/ui.js CHANGED
@@ -75,14 +75,18 @@ export function renderFrame(swarm, showHotkeys = false) {
75
75
  label = chalk.yellow(`Capped at ${capFrac != null ? Math.round(capFrac * 100) : 100}% — finishing active`);
76
76
  }
77
77
  }
78
- else if (swarm.rateLimitResetsAt) {
79
- const waitSec = Math.max(0, Math.ceil((swarm.rateLimitResetsAt - Date.now()) / 1000));
78
+ else if (swarm.rateLimitResetsAt && swarm.rateLimitResetsAt > Date.now()) {
79
+ const waitSec = Math.ceil((swarm.rateLimitResetsAt - Date.now()) / 1000);
80
80
  const mm = Math.floor(waitSec / 60);
81
81
  const ss = waitSec % 60;
82
82
  label = chalk.red(`Waiting for reset ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`);
83
83
  }
84
- if (swarm.isUsingOverage && !swarm.cappedOut)
85
- label += chalk.red(" [EXTRA USAGE]");
84
+ if (swarm.isUsingOverage && !swarm.cappedOut) {
85
+ const budgetInfo = swarm.extraUsageBudget != null
86
+ ? ` $${swarm.overageCostUsd.toFixed(2)}/$${swarm.extraUsageBudget}`
87
+ : "";
88
+ label += chalk.red(` [EXTRA USAGE${budgetInfo}]`);
89
+ }
86
90
  const prefix = windowLabel ? chalk.dim(windowLabel.padEnd(6)) : chalk.dim("Usage ");
87
91
  out.push(` ${prefix}${barStr} ${label}`);
88
92
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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": {