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/README.md +97 -29
- package/dist/index.js +254 -85
- package/dist/planner.d.ts +19 -2
- package/dist/planner.js +284 -75
- package/dist/swarm.d.ts +7 -1
- package/dist/swarm.js +62 -21
- package/dist/types.d.ts +6 -0
- package/dist/ui.js +38 -15
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ")
|
|
437
|
-
|
|
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
|
-
//
|
|
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 === "
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
294
|
+
lastSeq = currentSeq;
|
|
272
295
|
}
|
|
273
296
|
if (swarm.completed !== lastCompleted) {
|
|
274
297
|
lastCompleted = swarm.completed;
|