claude-overnight 0.1.2 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/swarm.js CHANGED
@@ -16,6 +16,7 @@ export class Swarm {
16
16
  totalOutputTokens = 0;
17
17
  phase = "running";
18
18
  aborted = false;
19
+ cappedOut = false;
19
20
  mergeResults = [];
20
21
  // Rate limit tracking for auto-concurrency
21
22
  rateLimitUtilization = 0;
@@ -29,6 +30,7 @@ export class Swarm {
29
30
  cleanedUp = false;
30
31
  logFile;
31
32
  model;
33
+ usageCap;
32
34
  constructor(config) {
33
35
  if (!config.tasks.length) {
34
36
  throw new Error("SwarmConfig: tasks array must not be empty");
@@ -49,6 +51,7 @@ export class Swarm {
49
51
  }
50
52
  this.config = config;
51
53
  this.model = config.model;
54
+ this.usageCap = config.usageCap;
52
55
  this.queue = [...config.tasks];
53
56
  this.total = config.tasks.length;
54
57
  }
@@ -85,18 +88,23 @@ export class Swarm {
85
88
  this.activeQueries.forEach((q) => q.close());
86
89
  this.activeQueries.clear();
87
90
  }
91
+ /** Monotonic counter so non-TTY consumers can detect log trimming. */
92
+ logSequence = 0;
88
93
  log(agentId, text) {
89
94
  const entry = { time: Date.now(), agentId, text };
90
95
  this.logs.push(entry);
91
96
  this.allLogs.push(entry);
97
+ this.logSequence++;
92
98
  if (this.logs.length > 300)
93
99
  this.logs.splice(0, this.logs.length - 150);
94
100
  }
95
101
  // ── Worker loop with auto-concurrency throttling ──
96
102
  async worker() {
97
103
  let tasksProcessed = 0;
98
- while (this.queue.length > 0 && !this.aborted) {
104
+ while (this.queue.length > 0 && !this.aborted && !this.cappedOut) {
99
105
  await this.throttle();
106
+ if (this.cappedOut)
107
+ break;
100
108
  const task = this.queue.shift();
101
109
  if (!task)
102
110
  break;
@@ -112,14 +120,25 @@ export class Swarm {
112
120
  this.log(-1, `Worker finished (${tasksProcessed} tasks)`);
113
121
  }
114
122
  async throttle() {
123
+ // Usage cap: stop dispatching when utilization exceeds user's cap
124
+ const cap = this.config.usageCap;
125
+ if (cap != null && cap < 1 && this.rateLimitUtilization >= cap) {
126
+ this.cappedOut = true;
127
+ this.log(-1, `Usage cap ${Math.round(cap * 100)}% reached (at ${Math.round(this.rateLimitUtilization * 100)}%) — finishing active agents, no new tasks`);
128
+ return;
129
+ }
115
130
  // Hard block: rate limit rejected — wait until reset
116
131
  if (this.rateLimitResetsAt) {
117
- const waitMs = this.rateLimitResetsAt - Date.now();
132
+ const resetTarget = this.rateLimitResetsAt;
133
+ const waitMs = resetTarget - Date.now();
118
134
  if (waitMs > 0) {
119
135
  this.log(-1, `Rate limited, pausing ${Math.ceil(waitMs / 1000)}s`);
120
136
  await sleep(waitMs);
121
137
  }
122
- this.rateLimitResetsAt = undefined;
138
+ // Only clear if no newer deadline arrived while we slept
139
+ if (this.rateLimitResetsAt === resetTarget) {
140
+ this.rateLimitResetsAt = undefined;
141
+ }
123
142
  }
124
143
  // Soft throttle: 0-15s proportional to 75-100% utilization
125
144
  else if (this.rateLimitUtilization > 0.75) {
@@ -229,7 +248,10 @@ export class Swarm {
229
248
  break; // Success — exit retry loop
230
249
  }
231
250
  catch (err) {
232
- const canRetry = attempt < maxRetries && isTransientError(err);
251
+ // If handleMsg already processed a result, don't double-count
252
+ if (agent.status !== "running")
253
+ break;
254
+ const canRetry = attempt < maxRetries && !this.aborted && isTransientError(err);
233
255
  if (canRetry) {
234
256
  this.log(id, `Transient error: ${String(err.message || err).slice(0, 80)}`);
235
257
  continue;
@@ -295,6 +317,16 @@ export class Swarm {
295
317
  this.log(-1, "No changes to merge");
296
318
  return;
297
319
  }
320
+ // Remember current branch so we can return to it reliably
321
+ let originalRef;
322
+ try {
323
+ const branch = exec("git rev-parse --abbrev-ref HEAD", this.config.cwd).trim();
324
+ // Detached HEAD returns "HEAD" — fall back to the commit hash so we can restore it
325
+ originalRef = branch === "HEAD"
326
+ ? exec("git rev-parse HEAD", this.config.cwd).trim()
327
+ : branch;
328
+ }
329
+ catch { }
298
330
  // Stash dirty working tree before merging
299
331
  let stashed = false;
300
332
  try {
@@ -381,10 +413,10 @@ export class Swarm {
381
413
  }
382
414
  const totalFilesChanged = this.mergeResults.reduce((sum, r) => sum + (r.ok ? r.filesChanged : 0), 0);
383
415
  this.log(-1, `Merged ${merged.length}/${branches.length} branches, ${totalFilesChanged} files changed${failed.length > 0 ? ` (${failed.length} unresolved)` : ""}`);
384
- if (strategy === "branch" && this.mergeBranch) {
385
- // Switch back to the original branch
416
+ if (strategy === "branch" && this.mergeBranch && originalRef) {
417
+ // Switch back to the original branch (or detached commit)
386
418
  try {
387
- exec("git checkout -", this.config.cwd);
419
+ exec(`git checkout "${originalRef}"`, this.config.cwd);
388
420
  }
389
421
  catch { }
390
422
  }
@@ -432,9 +464,14 @@ export class Swarm {
432
464
  try {
433
465
  const list = exec("git worktree list --porcelain", this.config.cwd);
434
466
  const stale = [];
467
+ const tmp = tmpdir();
435
468
  for (const line of list.split("\n")) {
436
- if (line.startsWith("worktree ") && line.includes("claude-overnight-")) {
437
- stale.push(line.slice("worktree ".length));
469
+ if (line.startsWith("worktree ")) {
470
+ const wpath = line.slice("worktree ".length);
471
+ // Only clean worktrees created by us in tmpdir — never touch repo dirs
472
+ if (wpath.startsWith(tmp) && wpath.includes("claude-overnight-")) {
473
+ stale.push(wpath);
474
+ }
438
475
  }
439
476
  }
440
477
  if (stale.length > 0) {
@@ -447,11 +484,18 @@ export class Swarm {
447
484
  }
448
485
  exec("git worktree prune", this.config.cwd);
449
486
  }
450
- // Also clean orphaned swarm branches from previous runs
487
+ // Clean orphaned task branches from previous runs (preserve swarm/run-* user branches)
488
+ // Only delete branches not actively checked out in a worktree
489
+ const worktreeBranches = new Set();
490
+ for (const line of list.split("\n")) {
491
+ if (line.startsWith("branch refs/heads/")) {
492
+ worktreeBranches.add(line.slice("branch refs/heads/".length));
493
+ }
494
+ }
451
495
  const branches = exec("git branch", this.config.cwd)
452
496
  .split("\n")
453
497
  .map((b) => b.trim().replace(/^\* /, ""))
454
- .filter((b) => b.startsWith("swarm/"));
498
+ .filter((b) => b.startsWith("swarm/task-") && !worktreeBranches.has(b));
455
499
  for (const b of branches) {
456
500
  try {
457
501
  exec(`git branch -D "${b}"`, this.config.cwd);
@@ -492,7 +536,6 @@ export class Swarm {
492
536
  time: new Date(l.time).toISOString(), agent: l.agentId, text: l.text,
493
537
  })),
494
538
  }, null, 2));
495
- process.stderr.write(`Log: ${this.logFile}\n`);
496
539
  }
497
540
  catch { }
498
541
  }
@@ -506,16 +549,13 @@ export class Swarm {
506
549
  handleMsg(agent, msg) {
507
550
  switch (msg.type) {
508
551
  case "assistant": {
552
+ // Tool calls are counted via stream_event (content_block_start) to avoid
553
+ // double-counting — the assistant message repeats the same tool_use blocks.
509
554
  const m = msg;
510
555
  if (!m.message?.content)
511
556
  break;
512
557
  for (const block of m.message.content) {
513
- if (block.type === "tool_use") {
514
- agent.currentTool = block.name;
515
- agent.toolCalls++;
516
- this.log(agent.id, block.name);
517
- }
518
- else if (block.type === "text" && block.text) {
558
+ if (block.type === "text" && block.text) {
519
559
  const line = block.text.trim().split("\n")[0]?.slice(0, 80);
520
560
  if (line)
521
561
  agent.lastText = line;
@@ -541,15 +581,16 @@ export class Swarm {
541
581
  if (t)
542
582
  agent.lastText = t.slice(0, 80);
543
583
  }
544
- }
545
- else if (ev.type === "content_block_stop") {
546
- agent.currentTool = undefined;
584
+ // Note: content_block_stop is NOT used to clear currentTool — the block
585
+ // finishes streaming but the tool hasn't executed yet. Clear it when the
586
+ // next content_block_start arrives (above) or on turn end (result handler).
547
587
  }
548
588
  break;
549
589
  }
550
590
  case "result": {
551
591
  const safeAdd = (v) => typeof v === 'number' && isFinite(v) && v >= 0 ? v : 0;
552
592
  const r = msg;
593
+ agent.currentTool = undefined;
553
594
  agent.finishedAt = Date.now();
554
595
  const cost = safeAdd(r.total_cost_usd);
555
596
  agent.costUsd = cost;
package/dist/types.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface Task {
11
11
  }
12
12
  /** Schema for a JSON task file that defines a batch of work for the swarm. */
13
13
  export interface TaskFile {
14
+ /** High-level objective for multi-wave steering (required when flexiblePlan is true). */
15
+ objective?: string;
14
16
  /** Max number of agents running in parallel. */
15
17
  concurrency?: number;
16
18
  /** Default working directory for all tasks. */
@@ -23,6 +25,10 @@ export interface TaskFile {
23
25
  allowedTools?: string[];
24
26
  /** Merge strategy: "yolo" merges into current branch, "branch" creates a new branch. */
25
27
  mergeStrategy?: MergeStrategy;
28
+ /** Stop dispatching new tasks when rate-limit utilization reaches this percentage (0-100). */
29
+ usageCap?: number;
30
+ /** Enable adaptive multi-wave planning: after each wave, a steering agent reads the codebase and plans the next wave. Default true in interactive mode. */
31
+ flexiblePlan?: boolean;
26
32
  /** Tasks to execute — either plain prompt strings or objects with per-task overrides. */
27
33
  tasks: (string | {
28
34
  prompt: string;
package/dist/ui.js CHANGED
@@ -41,18 +41,36 @@ export function renderFrame(swarm) {
41
41
  const cost = swarm.totalCostUsd > 0
42
42
  ? chalk.yellow(`$${swarm.totalCostUsd.toFixed(3)}`)
43
43
  : "";
44
- const rlPct = swarm.rateLimitUtilization;
45
- const rlBar = rlPct > 0
46
- ? " " +
47
- (rlPct > 0.8
48
- ? chalk.red(`RL ${Math.round(rlPct * 100)}%`)
49
- : rlPct > 0.5
50
- ? chalk.yellow(`RL ${Math.round(rlPct * 100)}%`)
51
- : chalk.green(`RL ${Math.round(rlPct * 100)}%`))
52
- : "";
53
44
  out.push(chalk.gray(` \u2191 ${tokIn} in \u2193 ${tokOut} out`) +
54
- (cost ? ` ${cost}` : "") +
55
- rlBar);
45
+ (cost ? ` ${cost}` : ""));
46
+ // ── Usage bar ──
47
+ const rlPct = swarm.rateLimitUtilization;
48
+ if (rlPct > 0 || swarm.rateLimitResetsAt || swarm.cappedOut) {
49
+ const barW = Math.min(30, w - 40);
50
+ const filled = Math.round(rlPct * barW);
51
+ const capFrac = swarm.usageCap;
52
+ 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`);
65
+ }
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`}`);
71
+ }
72
+ out.push(` ${chalk.dim("Usage")} ${barStr} ${label}`);
73
+ }
56
74
  out.push("");
57
75
  // ── Agent table ──
58
76
  const running = swarm.agents.filter((a) => a.status === "running");
@@ -251,7 +269,7 @@ export function renderSummary(swarm) {
251
269
  return out.join("\n");
252
270
  }
253
271
  function startPlainLog(swarm) {
254
- let lastLogLen = 0;
272
+ let lastSeq = swarm.logSequence;
255
273
  let lastCompleted = -1;
256
274
  const write = (line) => {
257
275
  try {
@@ -262,13 +280,18 @@ function startPlainLog(swarm) {
262
280
  }
263
281
  };
264
282
  const interval = setInterval(() => {
265
- if (swarm.logs.length > lastLogLen) {
266
- for (const entry of swarm.logs.slice(lastLogLen)) {
283
+ const currentSeq = swarm.logSequence;
284
+ if (currentSeq > lastSeq) {
285
+ // Read the most recent (currentSeq - lastSeq) entries from the tail of the log
286
+ const newCount = currentSeq - lastSeq;
287
+ const available = swarm.logs.length;
288
+ const toShow = Math.min(newCount, available);
289
+ for (const entry of swarm.logs.slice(available - toShow)) {
267
290
  const t = new Date(entry.time).toLocaleTimeString("en", { hour12: false });
268
291
  const tag = entry.agentId < 0 ? "[sys]" : `[${entry.agentId}]`;
269
292
  write(`${t} ${tag} ${entry.text}`);
270
293
  }
271
- lastLogLen = swarm.logs.length;
294
+ lastSeq = currentSeq;
272
295
  }
273
296
  if (swarm.completed !== lastCompleted) {
274
297
  lastCompleted = swarm.completed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "0.1.2",
3
+ "version": "0.3.2",
4
4
  "description": "Fire off Claude agents, come back days later to shipped work. Maximizes every token in your plan.",
5
5
  "type": "module",
6
6
  "bin": {