claude-overnight 0.1.0 → 0.1.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 +19 -8
- package/dist/index.js +256 -239
- package/dist/planner.d.ts +2 -4
- package/dist/planner.js +125 -103
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# claude-overnight
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Set a task budget, describe an objective, walk away. Come back to shipped work.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Tell it what to build. Set a budget — 10 tasks, 100, 1000, whatever the job needs. A planner agent analyzes your codebase and breaks the objective into that many independent tasks. Then they all run: parallel autonomous Claude Code agents, each in its own git worktree, each with full tooling (Read, Edit, Bash, grep, tests — everything). Rate limits hit? It waits. Windows reset? It resumes. It doesn't stop until every task is done or you tell it to.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -20,7 +20,7 @@ Requires Node.js >= 20 and a valid Claude authentication (OAuth via `claude` CLI
|
|
|
20
20
|
claude-overnight
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Describe your objective, set a task budget, pick a model. The planner generates the task breakdown — you can review, edit, chat about it, then run.
|
|
24
24
|
|
|
25
25
|
### Task file
|
|
26
26
|
|
|
@@ -38,8 +38,6 @@ Each quoted argument becomes one parallel task.
|
|
|
38
38
|
|
|
39
39
|
## Task file format
|
|
40
40
|
|
|
41
|
-
A JSON file with a `tasks` array and optional configuration:
|
|
42
|
-
|
|
43
41
|
```json
|
|
44
42
|
{
|
|
45
43
|
"tasks": [
|
|
@@ -59,7 +57,7 @@ A plain JSON array of strings also works: `["task one", "task two"]`.
|
|
|
59
57
|
| `tasks` | `(string \| {prompt, cwd?, model?})[]` | required | Tasks to run in parallel |
|
|
60
58
|
| `model` | `string` | prompted | Model for all agents (per-task overridable) |
|
|
61
59
|
| `concurrency` | `number` | `5` | Max agents running simultaneously |
|
|
62
|
-
| `worktrees` | `boolean` |
|
|
60
|
+
| `worktrees` | `boolean` | auto (git repo) | Isolate each agent in a git worktree |
|
|
63
61
|
| `permissionMode` | `"auto" \| "bypassPermissions" \| "default"` | `"auto"` | How agents handle dangerous operations |
|
|
64
62
|
| `cwd` | `string` | `process.cwd()` | Working directory for all agents |
|
|
65
63
|
| `allowedTools` | `string[]` | all | Restrict which tools agents can use |
|
|
@@ -69,14 +67,27 @@ A plain JSON array of strings also works: `["task one", "task two"]`.
|
|
|
69
67
|
|
|
70
68
|
| Flag | Default | Description |
|
|
71
69
|
|---|---|---|
|
|
72
|
-
| `--
|
|
70
|
+
| `--budget=N` | `10` | How many tasks the planner generates — the total size of the run |
|
|
71
|
+
| `--concurrency=N` | `5` | How many agents run at the same time (budget = total, concurrency = pace) |
|
|
73
72
|
| `--model=NAME` | — | Model override for all agents |
|
|
74
73
|
| `--timeout=SECONDS` | `300` | Agent inactivity timeout (kills only silent agents) |
|
|
75
74
|
| `--dry-run` | — | Show planned tasks without executing them |
|
|
75
|
+
| `-h, --help` | — | Show help |
|
|
76
|
+
| `-v, --version` | — | Print version |
|
|
77
|
+
|
|
78
|
+
## Rate limits and long runs
|
|
79
|
+
|
|
80
|
+
claude-overnight is built to run unattended for hours, days, weeks, or months. It handles API rate limits without supervision:
|
|
81
|
+
|
|
82
|
+
- **Hard block**: when the API rejects a request and returns a reset timestamp, the swarm pauses and resumes exactly when the window opens.
|
|
83
|
+
- **Soft throttle**: at >75% utilization, dispatch slows proactively to avoid hitting the limit.
|
|
84
|
+
- **Retry with backoff**: transient errors (429, overloaded, connection reset) retry with exponential backoff.
|
|
85
|
+
|
|
86
|
+
No tasks are dropped. Set a budget of 1000, go to sleep, and it will work through every rate limit window until the run is complete.
|
|
76
87
|
|
|
77
88
|
## Worktrees and merging
|
|
78
89
|
|
|
79
|
-
|
|
90
|
+
Each agent runs in an isolated git worktree on a `swarm/task-N` branch. Changes are auto-committed when the agent finishes. After all agents complete, branches merge back sequentially. The default `"yolo"` strategy merges directly into your current branch; `"branch"` creates a new `swarm/run-{timestamp}` branch instead. Merge conflicts retry with `-X theirs`; if that still fails, the branch is preserved for manual resolution. Stale worktrees and orphaned `swarm/*` branches from previous runs are cleaned up automatically on startup.
|
|
80
91
|
|
|
81
92
|
## Exit codes
|
|
82
93
|
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,11 @@ import { createInterface } from "readline";
|
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
9
9
|
import { Swarm } from "./swarm.js";
|
|
10
|
-
import { planTasks } from "./planner.js";
|
|
10
|
+
import { planTasks, refinePlan } from "./planner.js";
|
|
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"]);
|
|
14
|
+
const known = new Set(["concurrency", "model", "timeout", "budget"]);
|
|
15
15
|
const booleans = new Set(["--dry-run", "-h", "--help", "-v", "--version"]);
|
|
16
16
|
const flags = {};
|
|
17
17
|
const positional = [];
|
|
@@ -19,13 +19,11 @@ function parseCliFlags(argv) {
|
|
|
19
19
|
const arg = argv[i];
|
|
20
20
|
if (booleans.has(arg))
|
|
21
21
|
continue;
|
|
22
|
-
// --key=value
|
|
23
22
|
const eq = arg.match(/^--(\w[\w-]*)=(.+)$/);
|
|
24
23
|
if (eq && known.has(eq[1])) {
|
|
25
24
|
flags[eq[1]] = eq[2];
|
|
26
25
|
continue;
|
|
27
26
|
}
|
|
28
|
-
// --key value
|
|
29
27
|
const bare = arg.match(/^--(\w[\w-]*)$/);
|
|
30
28
|
if (bare && known.has(bare[1]) && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
31
29
|
flags[bare[1]] = argv[++i];
|
|
@@ -40,13 +38,9 @@ function parseCliFlags(argv) {
|
|
|
40
38
|
const AUTH_PATTERNS = ["unauthorized", "forbidden", "invalid_api_key", "authentication"];
|
|
41
39
|
function isAuthError(err) {
|
|
42
40
|
const msg = err instanceof Error ? err.message : String(err);
|
|
43
|
-
|
|
44
|
-
return AUTH_PATTERNS.some((p) => lower.includes(p));
|
|
41
|
+
return AUTH_PATTERNS.some((p) => msg.toLowerCase().includes(p));
|
|
45
42
|
}
|
|
46
|
-
|
|
47
|
-
return "Authentication failed — check your API key or run: claude auth";
|
|
48
|
-
}
|
|
49
|
-
// ── Fetch models via SDK (works with OAuth / Max / API key) ──
|
|
43
|
+
// ── Fetch models via SDK ──
|
|
50
44
|
async function fetchModels(timeoutMs = 10_000) {
|
|
51
45
|
let q;
|
|
52
46
|
try {
|
|
@@ -61,89 +55,99 @@ async function fetchModels(timeoutMs = 10_000) {
|
|
|
61
55
|
catch (err) {
|
|
62
56
|
q?.close();
|
|
63
57
|
if (err.message === "model_fetch_timeout") {
|
|
64
|
-
console.warn(chalk.yellow("\n
|
|
58
|
+
console.warn(chalk.yellow("\n Model fetch timed out — continuing with defaults"));
|
|
65
59
|
}
|
|
66
60
|
return [];
|
|
67
61
|
}
|
|
68
62
|
}
|
|
69
|
-
// ── Interactive
|
|
63
|
+
// ── Interactive primitives ──
|
|
70
64
|
function ask(question) {
|
|
71
65
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
72
66
|
return new Promise((res) => {
|
|
73
|
-
rl.question(question, (answer) => {
|
|
74
|
-
rl.close();
|
|
75
|
-
res(answer.trim());
|
|
76
|
-
});
|
|
67
|
+
rl.question(question, (answer) => { rl.close(); res(answer.trim()); });
|
|
77
68
|
});
|
|
78
69
|
}
|
|
79
|
-
async function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const label = i === 0 ? chalk.green(name) + desc : chalk.dim(name) + desc;
|
|
91
|
-
console.log(` ${marker} ${i + 1}. ${label}`);
|
|
92
|
-
}
|
|
93
|
-
const ans = await ask(chalk.dim(` Choose [1]: `));
|
|
94
|
-
const idx = ans ? parseInt(ans) - 1 : 0;
|
|
95
|
-
const pick = models[idx] ?? models[0];
|
|
96
|
-
console.log(chalk.dim(` Using ${pick.displayName}`));
|
|
97
|
-
return pick.value;
|
|
98
|
-
}
|
|
99
|
-
async function pickConcurrency() {
|
|
100
|
-
const ans = await ask(chalk.dim(" Concurrency [5]: "));
|
|
101
|
-
return parseInt(ans) || 5;
|
|
102
|
-
}
|
|
103
|
-
async function pickWorktrees() {
|
|
104
|
-
const ans = await ask(chalk.dim(" Use git worktrees? [Y/n]: "));
|
|
105
|
-
return ans.toLowerCase() !== "n";
|
|
106
|
-
}
|
|
107
|
-
async function pickMergeStrategy() {
|
|
108
|
-
console.log(chalk.bold("\n Merge strategy:"));
|
|
109
|
-
console.log(` ${chalk.green("→")} 1. ${chalk.green("YOLO")}${chalk.dim(" — merge into current branch")}`);
|
|
110
|
-
console.log(` 2. ${chalk.dim("New branch")}${chalk.dim(" — merge into a new branch (safe for PRs)")}`);
|
|
111
|
-
const ans = await ask(chalk.dim(" Choose [1]: "));
|
|
112
|
-
const pick = ans === "2" ? "branch" : "yolo";
|
|
113
|
-
console.log(chalk.dim(` Using ${pick === "yolo" ? "YOLO (merge into current)" : "new branch"}`));
|
|
114
|
-
return pick;
|
|
115
|
-
}
|
|
116
|
-
const PERM_MODES = [
|
|
117
|
-
{ label: "Auto", value: "auto", desc: "AI decides what's safe" },
|
|
118
|
-
{ label: "Bypass permissions", value: "bypassPermissions", desc: "skip all prompts (dangerous)" },
|
|
119
|
-
{ label: "Default", value: "default", desc: "prompt for dangerous ops" },
|
|
120
|
-
];
|
|
121
|
-
async function pickPermissionMode() {
|
|
122
|
-
console.log(chalk.bold("\n Permission mode:"));
|
|
123
|
-
for (let i = 0; i < PERM_MODES.length; i++) {
|
|
124
|
-
const marker = i === 0 ? chalk.green("→") : " ";
|
|
125
|
-
const name = i === 0 ? chalk.green(PERM_MODES[i].label) : chalk.dim(PERM_MODES[i].label);
|
|
126
|
-
const desc = chalk.dim(` — ${PERM_MODES[i].desc}`);
|
|
127
|
-
console.log(` ${marker} ${i + 1}. ${name}${desc}`);
|
|
128
|
-
}
|
|
129
|
-
const ans = await ask(chalk.dim(" Choose [1]: "));
|
|
130
|
-
const idx = ans ? parseInt(ans) - 1 : 0;
|
|
131
|
-
const pick = PERM_MODES[idx] ?? PERM_MODES[0];
|
|
132
|
-
console.log(chalk.dim(` Using ${pick.label}`));
|
|
133
|
-
return pick.value;
|
|
134
|
-
}
|
|
135
|
-
async function pickObjective() {
|
|
136
|
-
console.log("");
|
|
137
|
-
while (true) {
|
|
138
|
-
const ans = await ask(chalk.bold(" What should the swarm do?\n > "));
|
|
139
|
-
if (!ans)
|
|
140
|
-
return ans;
|
|
141
|
-
if (ans.split(/\s+/).length < 5) {
|
|
142
|
-
console.log(chalk.yellow(' Tip: be specific about what you want, e.g. "refactor auth module and add tests"'));
|
|
143
|
-
continue;
|
|
70
|
+
async function select(label, items, defaultIdx = 0) {
|
|
71
|
+
const { stdin, stdout } = process;
|
|
72
|
+
let idx = defaultIdx;
|
|
73
|
+
const draw = (first = false) => {
|
|
74
|
+
if (!first)
|
|
75
|
+
stdout.write(`\x1B[${items.length}A`);
|
|
76
|
+
for (let i = 0; i < items.length; i++) {
|
|
77
|
+
const arrow = i === idx ? chalk.green(" → ") : " ";
|
|
78
|
+
const name = i === idx ? chalk.green(items[i].name) : chalk.dim(items[i].name);
|
|
79
|
+
const hint = items[i].hint ? chalk.dim(` — ${items[i].hint}`) : "";
|
|
80
|
+
stdout.write(`\x1B[2K${arrow}${name}${hint}\n`);
|
|
144
81
|
}
|
|
145
|
-
|
|
146
|
-
}
|
|
82
|
+
};
|
|
83
|
+
stdout.write(`\n ${chalk.bold(label)}\n`);
|
|
84
|
+
draw(true);
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
stdin.setRawMode(true);
|
|
87
|
+
stdin.resume();
|
|
88
|
+
const done = (val) => {
|
|
89
|
+
stdin.setRawMode(false);
|
|
90
|
+
stdin.removeListener("data", handler);
|
|
91
|
+
stdin.pause();
|
|
92
|
+
resolve(val);
|
|
93
|
+
};
|
|
94
|
+
const handler = (buf) => {
|
|
95
|
+
const s = buf.toString();
|
|
96
|
+
if (s === "\x1B[A") {
|
|
97
|
+
idx = (idx - 1 + items.length) % items.length;
|
|
98
|
+
draw();
|
|
99
|
+
}
|
|
100
|
+
else if (s === "\x1B[B") {
|
|
101
|
+
idx = (idx + 1) % items.length;
|
|
102
|
+
draw();
|
|
103
|
+
}
|
|
104
|
+
else if (s === "\r")
|
|
105
|
+
done(items[idx].value);
|
|
106
|
+
else if (s === "\x03") {
|
|
107
|
+
stdin.setRawMode(false);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
else if (/^[1-9]$/.test(s)) {
|
|
111
|
+
const n = parseInt(s) - 1;
|
|
112
|
+
if (n < items.length) {
|
|
113
|
+
idx = n;
|
|
114
|
+
draw();
|
|
115
|
+
done(items[idx].value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
stdin.on("data", handler);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function selectKey(label, options) {
|
|
123
|
+
const { stdin, stdout } = process;
|
|
124
|
+
const keys = options.map((o) => o.key.toLowerCase());
|
|
125
|
+
stdout.write(`\n ${label} ${options.map((o) => `[${chalk.bold(o.key.toUpperCase())}]${chalk.dim(o.desc)}`).join(" ")}\n `);
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
stdin.setRawMode(true);
|
|
128
|
+
stdin.resume();
|
|
129
|
+
const handler = (buf) => {
|
|
130
|
+
const s = buf.toString().toLowerCase();
|
|
131
|
+
if (s === "\x03") {
|
|
132
|
+
stdin.setRawMode(false);
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
if (s === "\r") {
|
|
136
|
+
stdin.setRawMode(false);
|
|
137
|
+
stdin.removeListener("data", handler);
|
|
138
|
+
stdin.pause();
|
|
139
|
+
resolve(keys[0]);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (keys.includes(s)) {
|
|
143
|
+
stdin.setRawMode(false);
|
|
144
|
+
stdin.removeListener("data", handler);
|
|
145
|
+
stdin.pause();
|
|
146
|
+
resolve(s);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
stdin.on("data", handler);
|
|
150
|
+
});
|
|
147
151
|
}
|
|
148
152
|
const KNOWN_TASK_FILE_KEYS = new Set([
|
|
149
153
|
"tasks", "concurrency", "cwd", "model", "permissionMode", "allowedTools", "worktrees", "mergeStrategy",
|
|
@@ -167,15 +171,12 @@ function loadTaskFile(file) {
|
|
|
167
171
|
const parsed = Array.isArray(json)
|
|
168
172
|
? { tasks: json }
|
|
169
173
|
: json;
|
|
170
|
-
// Reject unknown top-level keys
|
|
171
174
|
if (!Array.isArray(json) && typeof json === "object" && json !== null) {
|
|
172
175
|
const unknown = Object.keys(json).filter((k) => !KNOWN_TASK_FILE_KEYS.has(k));
|
|
173
176
|
if (unknown.length > 0) {
|
|
174
|
-
throw new Error(`Unknown key${unknown.length > 1 ? "s" : ""} in task file: ${unknown.join(", ")}. `
|
|
175
|
-
`Allowed keys: ${[...KNOWN_TASK_FILE_KEYS].join(", ")}`);
|
|
177
|
+
throw new Error(`Unknown key${unknown.length > 1 ? "s" : ""} in task file: ${unknown.join(", ")}. Allowed: ${[...KNOWN_TASK_FILE_KEYS].join(", ")}`);
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
|
-
// Validate tasks array
|
|
179
180
|
if (!Array.isArray(parsed.tasks)) {
|
|
180
181
|
throw new Error(`Task file must contain a "tasks" array (got ${typeof parsed.tasks})`);
|
|
181
182
|
}
|
|
@@ -189,19 +190,16 @@ function loadTaskFile(file) {
|
|
|
189
190
|
tasks.push({ id, prompt: t });
|
|
190
191
|
}
|
|
191
192
|
else if (typeof t === "object" && t !== null) {
|
|
192
|
-
if (typeof t.prompt !== "string" || !t.prompt.trim())
|
|
193
|
+
if (typeof t.prompt !== "string" || !t.prompt.trim())
|
|
193
194
|
throw new Error(`Task ${i} is missing a "prompt" string`);
|
|
194
|
-
}
|
|
195
195
|
tasks.push({ id, prompt: t.prompt, cwd: t.cwd ? resolve(t.cwd) : undefined, model: t.model });
|
|
196
196
|
}
|
|
197
197
|
else {
|
|
198
198
|
throw new Error(`Task ${i} must be a string or object with a "prompt" field (got ${typeof t})`);
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
-
|
|
202
|
-
if (parsed.concurrency !== undefined) {
|
|
201
|
+
if (parsed.concurrency !== undefined)
|
|
203
202
|
validateConcurrency(parsed.concurrency);
|
|
204
|
-
}
|
|
205
203
|
return {
|
|
206
204
|
tasks,
|
|
207
205
|
concurrency: parsed.concurrency,
|
|
@@ -219,11 +217,6 @@ function validateConcurrency(value) {
|
|
|
219
217
|
throw new Error(`Concurrency must be a positive integer (got ${JSON.stringify(value)})`);
|
|
220
218
|
}
|
|
221
219
|
}
|
|
222
|
-
function validateCwd(cwd) {
|
|
223
|
-
if (!existsSync(cwd)) {
|
|
224
|
-
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
220
|
function isGitRepo(cwd) {
|
|
228
221
|
try {
|
|
229
222
|
execSync("git rev-parse --git-dir", { cwd, encoding: "utf-8", stdio: "pipe" });
|
|
@@ -236,11 +229,17 @@ function isGitRepo(cwd) {
|
|
|
236
229
|
function validateGitRepo(cwd) {
|
|
237
230
|
if (!isGitRepo(cwd)) {
|
|
238
231
|
throw new Error(`Worktrees require a git repository, but ${cwd} is not inside one.\n` +
|
|
239
|
-
` Run
|
|
240
|
-
`
|
|
241
|
-
` Or disable worktrees (set "worktrees": false in your task file).`);
|
|
232
|
+
` Run: cd ${cwd} && git init\n` +
|
|
233
|
+
` Or set "worktrees": false in your task file.`);
|
|
242
234
|
}
|
|
243
235
|
}
|
|
236
|
+
// ── Show plan ──
|
|
237
|
+
function showPlan(tasks) {
|
|
238
|
+
for (const t of tasks) {
|
|
239
|
+
console.log(chalk.dim(` ${Number(t.id) + 1}. ${t.prompt.slice(0, 90)}`));
|
|
240
|
+
}
|
|
241
|
+
console.log("");
|
|
242
|
+
}
|
|
244
243
|
// ── Main ──
|
|
245
244
|
async function main() {
|
|
246
245
|
const argv = process.argv.slice(2);
|
|
@@ -255,7 +254,7 @@ async function main() {
|
|
|
255
254
|
${chalk.bold("claude-overnight")} — fire off Claude agents, come back to shipped work
|
|
256
255
|
|
|
257
256
|
${chalk.dim("Usage:")}
|
|
258
|
-
claude-overnight ${chalk.dim("interactive —
|
|
257
|
+
claude-overnight ${chalk.dim("interactive — describe what to do, review plan, run")}
|
|
259
258
|
claude-overnight tasks.json ${chalk.dim("run tasks defined in a JSON file")}
|
|
260
259
|
claude-overnight "fix auth" "add tests" ${chalk.dim("run inline tasks in parallel")}
|
|
261
260
|
|
|
@@ -263,34 +262,29 @@ async function main() {
|
|
|
263
262
|
-h, --help Show this help
|
|
264
263
|
-v, --version Print version
|
|
265
264
|
--dry-run Show planned tasks without running them
|
|
266
|
-
--
|
|
267
|
-
--
|
|
265
|
+
--budget=N Target number of agent runs ${chalk.dim("(planner aims for this many tasks)")}
|
|
266
|
+
--concurrency=N Max parallel agents ${chalk.dim("(default: 5)")}
|
|
267
|
+
--model=NAME Model override
|
|
268
268
|
--timeout=SECONDS Agent inactivity timeout ${chalk.dim("(default: 300s, kills only silent agents)")}
|
|
269
269
|
|
|
270
|
-
${chalk.dim("Permission modes")} ${chalk.dim("(task file: \"permissionMode\", interactive: prompted)")}
|
|
271
|
-
auto AI decides which ops are safe ${chalk.dim("(default)")}
|
|
272
|
-
bypassPermissions Skip all permission prompts ${chalk.yellow("(dangerous)")}
|
|
273
|
-
default Prompt before destructive operations
|
|
274
|
-
|
|
275
270
|
${chalk.dim("Non-interactive defaults (task file / inline / piped):")}
|
|
276
|
-
model: first available concurrency: 5 worktrees: auto
|
|
271
|
+
model: first available concurrency: 5 worktrees: auto perms: auto
|
|
277
272
|
`);
|
|
278
273
|
process.exit(0);
|
|
279
274
|
}
|
|
280
275
|
const dryRun = argv.includes("--dry-run");
|
|
281
276
|
const { flags: cliFlags, positional: args } = parseCliFlags(argv);
|
|
282
|
-
// Validate CLI flag values early
|
|
283
277
|
if (cliFlags.concurrency !== undefined) {
|
|
284
278
|
const n = parseInt(cliFlags.concurrency);
|
|
285
279
|
if (!Number.isInteger(n) || n < 1) {
|
|
286
|
-
console.error(chalk.red(` --concurrency must be a positive integer
|
|
280
|
+
console.error(chalk.red(` --concurrency must be a positive integer`));
|
|
287
281
|
process.exit(1);
|
|
288
282
|
}
|
|
289
283
|
}
|
|
290
284
|
if (cliFlags.timeout !== undefined) {
|
|
291
285
|
const n = parseFloat(cliFlags.timeout);
|
|
292
286
|
if (isNaN(n) || n <= 0) {
|
|
293
|
-
console.error(chalk.red(` --timeout must be a positive number
|
|
287
|
+
console.error(chalk.red(` --timeout must be a positive number`));
|
|
294
288
|
process.exit(1);
|
|
295
289
|
}
|
|
296
290
|
}
|
|
@@ -299,8 +293,7 @@ async function main() {
|
|
|
299
293
|
let fileCfg;
|
|
300
294
|
const jsonFiles = args.filter((a) => a.endsWith(".json"));
|
|
301
295
|
if (jsonFiles.length > 1) {
|
|
302
|
-
console.error(chalk.red(` Multiple task files provided
|
|
303
|
-
console.error(chalk.red(` Only one .json task file is supported at a time.`));
|
|
296
|
+
console.error(chalk.red(` Multiple task files provided. Only one .json file is supported.`));
|
|
304
297
|
process.exit(1);
|
|
305
298
|
}
|
|
306
299
|
for (const arg of args) {
|
|
@@ -309,136 +302,186 @@ async function main() {
|
|
|
309
302
|
tasks = fileCfg.tasks;
|
|
310
303
|
}
|
|
311
304
|
else if (!arg.startsWith("-") && existsSync(resolve(arg))) {
|
|
312
|
-
console.error(chalk.red(` "${arg}" looks like a file
|
|
313
|
-
console.error(chalk.red(` To use it as a task file, rename it: mv ${arg} ${arg.replace(/(\.\w+)?$/, ".json")}`));
|
|
314
|
-
console.error(chalk.red(` To run it as an inline task prompt, ignore this message and quote the string.`));
|
|
305
|
+
console.error(chalk.red(` "${arg}" looks like a file but doesn't end in .json. Rename it or quote the string.`));
|
|
315
306
|
process.exit(1);
|
|
316
307
|
}
|
|
317
308
|
else {
|
|
318
309
|
tasks.push({ id: String(tasks.length), prompt: arg });
|
|
319
310
|
}
|
|
320
311
|
}
|
|
321
|
-
// ──
|
|
322
|
-
console.log(chalk.bold("\n
|
|
312
|
+
// ── Determine mode ──
|
|
313
|
+
console.log(chalk.bold("\n \uD83C\uDF19 claude-overnight\n"));
|
|
323
314
|
const noTTY = !process.stdin.isTTY;
|
|
324
315
|
const nonInteractive = noTTY || fileCfg !== undefined || tasks.length > 0;
|
|
325
316
|
const cwd = fileCfg?.cwd ?? process.cwd();
|
|
326
317
|
const allowedTools = fileCfg?.allowedTools;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
console.log(chalk.dim(" • Interactive — describe an objective, auto-plan into tasks"));
|
|
331
|
-
console.log(chalk.dim(" • Task file — claude-overnight tasks.json"));
|
|
332
|
-
console.log(chalk.dim(" • Inline args — claude-overnight \"fix auth\" \"add tests\""));
|
|
333
|
-
}
|
|
334
|
-
if (noTTY) {
|
|
335
|
-
console.log(chalk.dim(" Non-interactive mode — using defaults"));
|
|
318
|
+
if (!existsSync(cwd)) {
|
|
319
|
+
console.error(chalk.red(` Working directory does not exist: ${cwd}`));
|
|
320
|
+
process.exit(1);
|
|
336
321
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
322
|
+
if (noTTY)
|
|
323
|
+
console.log(chalk.dim(" Non-interactive mode — using defaults\n"));
|
|
324
|
+
// ── Interactive flow: Objective → Budget → Model → Plan → Review ──
|
|
325
|
+
let model;
|
|
326
|
+
let budget;
|
|
327
|
+
let concurrency;
|
|
328
|
+
let objective;
|
|
329
|
+
if (!nonInteractive) {
|
|
330
|
+
console.log(chalk.dim(" Fire off Claude agents, come back to shipped work.\n"));
|
|
331
|
+
// 1. Objective first — it's the whole point
|
|
332
|
+
while (true) {
|
|
333
|
+
objective = await ask(chalk.bold(" What should the agents do?\n > "));
|
|
334
|
+
if (!objective) {
|
|
335
|
+
console.error(chalk.red("\n No objective provided."));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
if (objective.split(/\s+/).length >= 5)
|
|
339
|
+
break;
|
|
340
|
+
console.log(chalk.yellow(' Be specific, e.g. "refactor the auth module, add tests, and update docs"\n'));
|
|
341
|
+
}
|
|
342
|
+
// 2. Budget — how many agent runs to spend
|
|
343
|
+
const budgetAns = await ask(chalk.dim("\n Agent budget [10]: "));
|
|
344
|
+
budget = parseInt(budgetAns) || 10;
|
|
345
|
+
// 3. Model — arrow keys
|
|
346
|
+
process.stdout.write(chalk.dim(" Fetching models..."));
|
|
347
|
+
const models = await fetchModels();
|
|
348
|
+
process.stdout.write(`\x1B[2K\r`);
|
|
349
|
+
if (models.length > 0) {
|
|
350
|
+
model = await select("Model:", models.map((m) => ({
|
|
351
|
+
name: m.displayName,
|
|
352
|
+
value: m.value,
|
|
353
|
+
hint: m.description,
|
|
354
|
+
})));
|
|
343
355
|
}
|
|
344
356
|
else {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
process.stdout.write(`\x1B[2K\r`);
|
|
357
|
+
const ans = await ask(chalk.dim(" Model [claude-sonnet-4-6]: "));
|
|
358
|
+
model = ans || "claude-sonnet-4-6";
|
|
348
359
|
}
|
|
360
|
+
// Concurrency defaults based on budget
|
|
361
|
+
concurrency = Math.min(5, budget);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// Non-interactive: resolve config from file/flags/defaults
|
|
365
|
+
let models = [];
|
|
366
|
+
if (!cliFlags.model && !fileCfg?.model)
|
|
367
|
+
models = await fetchModels(5_000);
|
|
368
|
+
model = cliFlags.model ?? fileCfg?.model ?? (models[0]?.value || "claude-sonnet-4-6");
|
|
369
|
+
concurrency = cliFlags.concurrency ? parseInt(cliFlags.concurrency) : (fileCfg?.concurrency ?? 5);
|
|
370
|
+
budget = cliFlags.budget ? parseInt(cliFlags.budget) : undefined;
|
|
349
371
|
}
|
|
350
|
-
// CLI flags override task file values, which override interactive defaults
|
|
351
|
-
const model = cliFlags.model ?? fileCfg?.model ?? (nonInteractive ? (models[0]?.value || "claude-sonnet-4-6") : await pickModel(models));
|
|
352
|
-
const permissionMode = fileCfg?.permissionMode ?? (nonInteractive ? "auto" : await pickPermissionMode());
|
|
353
|
-
const concurrency = cliFlags.concurrency ? parseInt(cliFlags.concurrency) : (fileCfg?.concurrency ?? (nonInteractive ? 5 : await pickConcurrency()));
|
|
354
372
|
validateConcurrency(concurrency);
|
|
355
|
-
const
|
|
373
|
+
const permissionMode = fileCfg?.permissionMode ?? "auto";
|
|
374
|
+
const useWorktrees = fileCfg?.useWorktrees ?? (isGitRepo(cwd));
|
|
356
375
|
if (useWorktrees)
|
|
357
376
|
validateGitRepo(cwd);
|
|
358
|
-
const mergeStrategy = fileCfg?.mergeStrategy ??
|
|
377
|
+
const mergeStrategy = fileCfg?.mergeStrategy ?? "yolo";
|
|
359
378
|
if (nonInteractive) {
|
|
360
379
|
console.log(chalk.dim(` ${model} concurrency=${concurrency} worktrees=${useWorktrees} merge=${mergeStrategy} perms=${permissionMode}`));
|
|
361
380
|
}
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (planMode) {
|
|
381
|
+
// ── Plan phase (interactive: review loop, non-interactive: auto-plan or skip) ──
|
|
382
|
+
const needsPlan = tasks.length === 0;
|
|
383
|
+
if (needsPlan) {
|
|
366
384
|
if (noTTY) {
|
|
367
|
-
console.error(chalk.red("
|
|
385
|
+
console.error(chalk.red(" No tasks provided and stdin is not a TTY. Provide tasks via args or a .json file."));
|
|
368
386
|
process.exit(1);
|
|
369
387
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
console.error(chalk.red("\n No objective provided."));
|
|
373
|
-
process.exit(1);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
// Hide cursor + graceful shutdown (swarm-aware handler installed after swarm is created)
|
|
377
|
-
process.stdout.write("\x1B[?25l");
|
|
378
|
-
const restore = () => process.stdout.write("\x1B[?25h\n");
|
|
379
|
-
process.on("SIGINT", () => { restore(); process.exit(0); });
|
|
380
|
-
process.on("SIGTERM", () => { restore(); process.exit(0); });
|
|
381
|
-
// ── Plan phase ──
|
|
382
|
-
if (planMode && objective) {
|
|
388
|
+
process.stdout.write("\x1B[?25l");
|
|
389
|
+
const restore = () => process.stdout.write("\x1B[?25h");
|
|
383
390
|
console.log(chalk.magenta("\n Planning...\n"));
|
|
384
391
|
try {
|
|
385
|
-
tasks = await planTasks(objective, cwd, model, permissionMode, (text) => {
|
|
392
|
+
tasks = await planTasks(objective, cwd, model, permissionMode, budget, concurrency, (text) => {
|
|
386
393
|
process.stdout.write(`\x1B[2K\r ${chalk.dim(text)}`);
|
|
387
394
|
});
|
|
388
|
-
process.stdout.write(`\x1B[2K\r ${chalk.green(
|
|
389
|
-
for (const t of tasks) {
|
|
390
|
-
console.log(chalk.dim(` ${t.id}. ${t.prompt.slice(0, 70)}`));
|
|
391
|
-
}
|
|
392
|
-
console.log("");
|
|
393
|
-
process.stdout.write("\x1B[?25h"); // show cursor for prompt
|
|
394
|
-
const confirm = await ask(` Run ${tasks.length} task${tasks.length === 1 ? "" : "s"} with concurrency ${concurrency}? [Y/n] `);
|
|
395
|
-
if (confirm.toLowerCase() === "n") {
|
|
396
|
-
restore();
|
|
397
|
-
console.log(chalk.dim(" Aborted.\n"));
|
|
398
|
-
process.exit(0);
|
|
399
|
-
}
|
|
400
|
-
process.stdout.write("\x1B[?25l"); // re-hide cursor
|
|
395
|
+
process.stdout.write(`\x1B[2K\r ${chalk.green(`${tasks.length} tasks`)}\n\n`);
|
|
401
396
|
}
|
|
402
397
|
catch (err) {
|
|
403
398
|
restore();
|
|
404
|
-
if (isAuthError(err))
|
|
405
|
-
console.error(chalk.red(`\n
|
|
406
|
-
|
|
407
|
-
else {
|
|
399
|
+
if (isAuthError(err))
|
|
400
|
+
console.error(chalk.red(`\n Authentication failed — check your API key or run: claude auth\n`));
|
|
401
|
+
else
|
|
408
402
|
console.error(chalk.red(`\n Planning failed: ${err.message}\n`));
|
|
409
|
-
}
|
|
410
403
|
process.exit(1);
|
|
411
404
|
}
|
|
405
|
+
// ── Review loop ──
|
|
406
|
+
restore();
|
|
407
|
+
let reviewing = true;
|
|
408
|
+
while (reviewing) {
|
|
409
|
+
showPlan(tasks);
|
|
410
|
+
const action = await selectKey(`${tasks.length} tasks, concurrency ${concurrency}.`, [
|
|
411
|
+
{ key: "r", desc: "un" },
|
|
412
|
+
{ key: "e", desc: "dit" },
|
|
413
|
+
{ key: "c", desc: "hat" },
|
|
414
|
+
{ key: "q", desc: "uit" },
|
|
415
|
+
]);
|
|
416
|
+
switch (action) {
|
|
417
|
+
case "r":
|
|
418
|
+
reviewing = false;
|
|
419
|
+
break;
|
|
420
|
+
case "e": {
|
|
421
|
+
const feedback = await ask(chalk.bold("\n What should change?\n > "));
|
|
422
|
+
if (!feedback)
|
|
423
|
+
break;
|
|
424
|
+
console.log(chalk.magenta("\n Re-planning...\n"));
|
|
425
|
+
process.stdout.write("\x1B[?25l");
|
|
426
|
+
try {
|
|
427
|
+
tasks = await refinePlan(objective, tasks, feedback, cwd, model, permissionMode, budget, concurrency, (text) => {
|
|
428
|
+
process.stdout.write(`\x1B[2K\r ${chalk.dim(text)}`);
|
|
429
|
+
});
|
|
430
|
+
process.stdout.write(`\x1B[2K\r ${chalk.green(`${tasks.length} tasks`)}\n\n`);
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
console.error(chalk.red(`\n Re-planning failed: ${err.message}\n`));
|
|
434
|
+
}
|
|
435
|
+
restore();
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case "c": {
|
|
439
|
+
const question = await ask(chalk.bold("\n Ask about the plan:\n > "));
|
|
440
|
+
if (!question)
|
|
441
|
+
break;
|
|
442
|
+
process.stdout.write("\x1B[?25l");
|
|
443
|
+
try {
|
|
444
|
+
let answer = "";
|
|
445
|
+
for await (const msg of query({
|
|
446
|
+
prompt: `You planned these tasks for the objective "${objective}":\n${tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join("\n")}\n\nUser question: ${question}`,
|
|
447
|
+
options: { cwd, model, permissionMode, persistSession: false },
|
|
448
|
+
})) {
|
|
449
|
+
if (msg.type === "result" && msg.subtype === "success")
|
|
450
|
+
answer = msg.result || "";
|
|
451
|
+
}
|
|
452
|
+
restore();
|
|
453
|
+
if (answer)
|
|
454
|
+
console.log(chalk.dim(`\n ${answer.slice(0, 500)}\n`));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
restore();
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
case "q":
|
|
462
|
+
console.log(chalk.dim("\n Aborted.\n"));
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
412
466
|
}
|
|
413
467
|
if (tasks.length === 0) {
|
|
414
|
-
restore();
|
|
415
468
|
console.error("No tasks provided.");
|
|
416
469
|
process.exit(1);
|
|
417
470
|
}
|
|
418
471
|
if (dryRun) {
|
|
419
|
-
restore();
|
|
420
472
|
console.log(chalk.bold(" Tasks:"));
|
|
421
|
-
|
|
422
|
-
console.log(` ${chalk.dim(`${Number(t.id) + 1}.`)} ${t.prompt}`);
|
|
423
|
-
}
|
|
424
|
-
console.log("");
|
|
473
|
+
showPlan(tasks);
|
|
425
474
|
process.exit(0);
|
|
426
475
|
}
|
|
476
|
+
// ── Run ──
|
|
477
|
+
process.stdout.write("\x1B[?25l");
|
|
478
|
+
const restore = () => process.stdout.write("\x1B[?25h\n");
|
|
427
479
|
const agentTimeoutMs = cliFlags.timeout ? parseFloat(cliFlags.timeout) * 1000 : undefined;
|
|
428
480
|
const swarm = new Swarm({
|
|
429
|
-
tasks,
|
|
430
|
-
|
|
431
|
-
cwd,
|
|
432
|
-
model,
|
|
433
|
-
permissionMode,
|
|
434
|
-
allowedTools,
|
|
435
|
-
useWorktrees,
|
|
436
|
-
mergeStrategy,
|
|
437
|
-
agentTimeoutMs,
|
|
481
|
+
tasks, concurrency, cwd, model, permissionMode, allowedTools,
|
|
482
|
+
useWorktrees, mergeStrategy, agentTimeoutMs,
|
|
438
483
|
});
|
|
439
|
-
//
|
|
440
|
-
process.removeAllListeners("SIGINT");
|
|
441
|
-
process.removeAllListeners("SIGTERM");
|
|
484
|
+
// Graceful drain
|
|
442
485
|
let stopping = false;
|
|
443
486
|
const gracefulStop = (signal) => {
|
|
444
487
|
if (stopping) {
|
|
@@ -447,26 +490,13 @@ async function main() {
|
|
|
447
490
|
process.exit(0);
|
|
448
491
|
}
|
|
449
492
|
stopping = true;
|
|
450
|
-
process.stdout.write(`\n ${chalk.yellow(`${signal}: stopping...
|
|
493
|
+
process.stdout.write(`\n ${chalk.yellow(`${signal}: stopping... ${swarm.active} active (send again to force)`)}\n`);
|
|
451
494
|
swarm.abort();
|
|
452
495
|
};
|
|
453
496
|
process.on("SIGINT", () => gracefulStop("SIGINT"));
|
|
454
497
|
process.on("SIGTERM", () => gracefulStop("SIGTERM"));
|
|
455
|
-
process.on("uncaughtException", (err) => {
|
|
456
|
-
|
|
457
|
-
swarm.cleanup();
|
|
458
|
-
restore();
|
|
459
|
-
console.error(chalk.red(`\n Uncaught exception: ${err.message}`));
|
|
460
|
-
process.exit(1);
|
|
461
|
-
});
|
|
462
|
-
process.on("unhandledRejection", (reason) => {
|
|
463
|
-
swarm.abort();
|
|
464
|
-
swarm.cleanup();
|
|
465
|
-
restore();
|
|
466
|
-
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
467
|
-
console.error(chalk.red(`\n Unhandled rejection: ${msg}`));
|
|
468
|
-
process.exit(1);
|
|
469
|
-
});
|
|
498
|
+
process.on("uncaughtException", (err) => { swarm.abort(); swarm.cleanup(); restore(); console.error(chalk.red(`\n Uncaught: ${err.message}`)); process.exit(1); });
|
|
499
|
+
process.on("unhandledRejection", (reason) => { swarm.abort(); swarm.cleanup(); restore(); console.error(chalk.red(`\n Unhandled: ${reason instanceof Error ? reason.message : reason}`)); process.exit(1); });
|
|
470
500
|
const stopRender = startRenderLoop(swarm);
|
|
471
501
|
try {
|
|
472
502
|
await swarm.run();
|
|
@@ -475,7 +505,7 @@ async function main() {
|
|
|
475
505
|
if (isAuthError(err)) {
|
|
476
506
|
stopRender();
|
|
477
507
|
restore();
|
|
478
|
-
console.error(chalk.red(`\n
|
|
508
|
+
console.error(chalk.red(`\n Authentication failed — check your API key or run: claude auth\n`));
|
|
479
509
|
process.exit(1);
|
|
480
510
|
}
|
|
481
511
|
throw err;
|
|
@@ -487,18 +517,13 @@ async function main() {
|
|
|
487
517
|
const summary = failedAgents.length > 0
|
|
488
518
|
? chalk.yellow(`${swarm.completed} done, ${failedAgents.length} failed`)
|
|
489
519
|
: chalk.green(`${swarm.completed} done`);
|
|
490
|
-
const cost = swarm.totalCostUsd > 0
|
|
491
|
-
? ` ($${swarm.totalCostUsd.toFixed(3)})`
|
|
492
|
-
: "";
|
|
520
|
+
const cost = swarm.totalCostUsd > 0 ? ` ($${swarm.totalCostUsd.toFixed(3)})` : "";
|
|
493
521
|
console.log(`\n ${chalk.bold("Complete:")} ${summary}${chalk.dim(cost)}`);
|
|
494
522
|
if (failedAgents.length > 0) {
|
|
495
523
|
console.log(chalk.red(`\n Failed agents:`));
|
|
496
524
|
for (const a of failedAgents) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const reason = a.error || "unknown error";
|
|
500
|
-
console.log(chalk.red(` ✗ Agent ${a.id + 1}: ${label}`));
|
|
501
|
-
console.log(chalk.dim(` ${reason}`));
|
|
525
|
+
console.log(chalk.red(` \u2717 Agent ${a.id + 1}: ${a.task.prompt.slice(0, 60)}${a.task.prompt.length > 60 ? "\u2026" : ""}`));
|
|
526
|
+
console.log(chalk.dim(` ${a.error || "unknown error"}`));
|
|
502
527
|
}
|
|
503
528
|
}
|
|
504
529
|
const elapsed = Math.round((Date.now() - swarm.startedAt) / 1000);
|
|
@@ -514,33 +539,25 @@ async function main() {
|
|
|
514
539
|
const extra = autoResolved > 0 ? chalk.yellow(` (${autoResolved} auto-resolved)`) : "";
|
|
515
540
|
console.log(chalk.green(` Merged ${merged.length} branch(es) into ${target}`) + extra);
|
|
516
541
|
}
|
|
517
|
-
if (swarm.mergeBranch)
|
|
542
|
+
if (swarm.mergeBranch)
|
|
518
543
|
console.log(chalk.dim(` Branch: ${swarm.mergeBranch} — create a PR or: git merge ${swarm.mergeBranch}`));
|
|
519
|
-
}
|
|
520
544
|
if (conflicts.length > 0) {
|
|
521
|
-
console.log(chalk.red(` ${conflicts.length}
|
|
522
|
-
for (const c of conflicts)
|
|
545
|
+
console.log(chalk.red(` ${conflicts.length} unresolved conflict(s):`));
|
|
546
|
+
for (const c of conflicts)
|
|
523
547
|
console.log(chalk.red(` ${c.branch}`));
|
|
524
|
-
|
|
525
|
-
console.log(chalk.dim(" Branches preserved — merge manually with: git merge <branch>"));
|
|
548
|
+
console.log(chalk.dim(" Merge manually: git merge <branch>"));
|
|
526
549
|
}
|
|
527
550
|
}
|
|
528
|
-
if (swarm.logFile)
|
|
551
|
+
if (swarm.logFile)
|
|
529
552
|
console.log(chalk.dim(` Log: ${swarm.logFile}`));
|
|
530
|
-
}
|
|
531
553
|
console.log("");
|
|
532
554
|
}
|
|
533
|
-
|
|
534
|
-
if (swarm.aborted || swarm.completed === 0) {
|
|
555
|
+
if (swarm.aborted || swarm.completed === 0)
|
|
535
556
|
process.exit(2);
|
|
536
|
-
|
|
537
|
-
if (swarm.failed > 0) {
|
|
557
|
+
if (swarm.failed > 0)
|
|
538
558
|
process.exit(1);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
function sleep(ms) {
|
|
542
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
543
559
|
}
|
|
560
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
544
561
|
function fmtTokens(n) {
|
|
545
562
|
if (n >= 1_000_000)
|
|
546
563
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
package/dist/planner.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
1
|
import type { Task, PermMode } from "./types.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*/
|
|
5
|
-
export declare function planTasks(objective: string, cwd: string, model: string, permissionMode: PermMode, onLog: (text: string) => void): Promise<Task[]>;
|
|
2
|
+
export declare function planTasks(objective: string, cwd: string, model: string, permissionMode: PermMode, budget: number | undefined, concurrency: number, onLog: (text: string) => void): Promise<Task[]>;
|
|
3
|
+
export declare function refinePlan(objective: string, previousTasks: Task[], feedback: string, cwd: string, model: string, permissionMode: PermMode, budget: number | undefined, concurrency: number, onLog: (text: string) => void): Promise<Task[]>;
|
package/dist/planner.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const INACTIVITY_MS = 5 * 60 * 1000;
|
|
8
|
-
let resultText = "";
|
|
9
|
-
const plannerQuery = query({
|
|
10
|
-
prompt: `You are a task coordinator for a parallel agent swarm. Analyze this codebase and break the following objective into independent tasks.
|
|
2
|
+
const INACTIVITY_MS = 5 * 60 * 1000;
|
|
3
|
+
function plannerPrompt(objective, budget, concurrency) {
|
|
4
|
+
const budgetLine = budget ? `\n- Target exactly ~${budget} tasks (this is the user's agent budget)` : "\n- Aim for 3-15 tasks depending on scope";
|
|
5
|
+
const concLine = concurrency ? `\n- ${concurrency} agents will run in parallel — design tasks so parallel agents touch DIFFERENT files to avoid merge conflicts` : "";
|
|
6
|
+
return `You are a task coordinator for a parallel agent system. Analyze this codebase and break the following objective into independent tasks.
|
|
11
7
|
|
|
12
8
|
Objective: ${objective}
|
|
13
9
|
|
|
@@ -15,8 +11,7 @@ Requirements:
|
|
|
15
11
|
- Each task MUST be independent — no task depends on another
|
|
16
12
|
- Each task should target specific files/areas to avoid merge conflicts
|
|
17
13
|
- Be specific: mention exact file paths, function names, what to change
|
|
18
|
-
- Keep tasks focused: one logical change per task
|
|
19
|
-
- Aim for 3-15 tasks depending on scope
|
|
14
|
+
- Keep tasks focused: one logical change per task${budgetLine}${concLine}
|
|
20
15
|
|
|
21
16
|
Respond with ONLY a JSON object (no markdown fences):
|
|
22
17
|
{
|
|
@@ -24,51 +19,50 @@ Respond with ONLY a JSON object (no markdown fences):
|
|
|
24
19
|
{ "prompt": "In src/foo.ts, refactor the bar() function to..." },
|
|
25
20
|
{ "prompt": "Add unit tests for the baz module in test/baz.test.ts..." }
|
|
26
21
|
]
|
|
27
|
-
}
|
|
22
|
+
}`;
|
|
23
|
+
}
|
|
24
|
+
async function runPlannerQuery(prompt, opts, onLog) {
|
|
25
|
+
let resultText = "";
|
|
26
|
+
const pq = query({
|
|
27
|
+
prompt,
|
|
28
28
|
options: {
|
|
29
|
-
cwd,
|
|
30
|
-
model,
|
|
29
|
+
cwd: opts.cwd,
|
|
30
|
+
model: opts.model,
|
|
31
31
|
tools: ["Read", "Glob", "Grep"],
|
|
32
32
|
allowedTools: ["Read", "Glob", "Grep"],
|
|
33
|
-
permissionMode: permissionMode,
|
|
34
|
-
...(permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
|
|
33
|
+
permissionMode: opts.permissionMode,
|
|
34
|
+
...(opts.permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
|
|
35
35
|
persistSession: false,
|
|
36
36
|
includePartialMessages: true,
|
|
37
37
|
},
|
|
38
38
|
});
|
|
39
|
-
// Inactivity watchdog — only kills planner if it goes completely silent
|
|
40
39
|
let lastActivity = Date.now();
|
|
41
40
|
let timer;
|
|
42
41
|
const watchdog = new Promise((_, reject) => {
|
|
43
42
|
const check = () => {
|
|
44
43
|
const silent = Date.now() - lastActivity;
|
|
45
44
|
if (silent >= INACTIVITY_MS) {
|
|
46
|
-
|
|
45
|
+
pq.close();
|
|
47
46
|
reject(new Error(`Planner silent for ${Math.round(silent / 1000)}s — assumed hung`));
|
|
48
47
|
}
|
|
49
|
-
else
|
|
48
|
+
else
|
|
50
49
|
timer = setTimeout(check, Math.min(30_000, INACTIVITY_MS - silent + 1000));
|
|
51
|
-
}
|
|
52
50
|
};
|
|
53
51
|
timer = setTimeout(check, INACTIVITY_MS);
|
|
54
52
|
});
|
|
55
53
|
const consume = async () => {
|
|
56
|
-
for await (const msg of
|
|
54
|
+
for await (const msg of pq) {
|
|
57
55
|
lastActivity = Date.now();
|
|
58
56
|
if (msg.type === "stream_event") {
|
|
59
57
|
const ev = msg.event;
|
|
60
|
-
if (ev?.type === "content_block_start" &&
|
|
61
|
-
ev.content_block?.type === "tool_use") {
|
|
58
|
+
if (ev?.type === "content_block_start" && ev.content_block?.type === "tool_use")
|
|
62
59
|
onLog(ev.content_block.name);
|
|
63
|
-
}
|
|
64
60
|
}
|
|
65
61
|
if (msg.type === "result") {
|
|
66
|
-
if (msg.subtype === "success")
|
|
62
|
+
if (msg.subtype === "success")
|
|
67
63
|
resultText = msg.result || "";
|
|
68
|
-
|
|
69
|
-
else {
|
|
64
|
+
else
|
|
70
65
|
throw new Error(`Planner failed: ${msg.subtype}`);
|
|
71
|
-
}
|
|
72
66
|
}
|
|
73
67
|
}
|
|
74
68
|
};
|
|
@@ -78,73 +72,47 @@ Respond with ONLY a JSON object (no markdown fences):
|
|
|
78
72
|
finally {
|
|
79
73
|
clearTimeout(timer);
|
|
80
74
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
options: {
|
|
87
|
-
cwd,
|
|
88
|
-
model,
|
|
89
|
-
permissionMode,
|
|
90
|
-
...(permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
|
|
91
|
-
persistSession: false,
|
|
92
|
-
},
|
|
93
|
-
})) {
|
|
94
|
-
if (msg.type === "result" && msg.subtype === "success") {
|
|
95
|
-
retryText = msg.result || "";
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return retryText;
|
|
99
|
-
});
|
|
100
|
-
let tasks = (parsed.tasks || []).map((t, i) => ({
|
|
101
|
-
id: String(i),
|
|
102
|
-
prompt: typeof t === "string" ? t : t.prompt,
|
|
103
|
-
}));
|
|
104
|
-
// Filter garbage tasks (require at least 3 space-separated words)
|
|
75
|
+
return resultText;
|
|
76
|
+
}
|
|
77
|
+
function postProcess(raw, onLog) {
|
|
78
|
+
let tasks = raw;
|
|
79
|
+
// Filter garbage (< 3 words)
|
|
105
80
|
const before = tasks.length;
|
|
106
81
|
tasks = tasks.filter((t) => t.prompt && t.prompt.trim().split(/\s+/).length >= 3);
|
|
107
|
-
if (tasks.length < before)
|
|
82
|
+
if (tasks.length < before)
|
|
108
83
|
onLog(`Filtered ${before - tasks.length} task(s) with fewer than 3 words`);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
84
|
+
// Dedup >80% word overlap
|
|
85
|
+
const dominated = new Set();
|
|
86
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
87
|
+
if (dominated.has(i))
|
|
88
|
+
continue;
|
|
89
|
+
const setA = new Set(tasks[i].prompt.toLowerCase().split(/\s+/));
|
|
90
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
91
|
+
if (dominated.has(j))
|
|
115
92
|
continue;
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Keep the more specific (longer) prompt
|
|
125
|
-
const drop = setA.size >= setB.size ? j : i;
|
|
126
|
-
const keep = drop === j ? i : j;
|
|
127
|
-
onLog(`Dedup: task ${tasks[drop].id} >${Math.round(overlap * 100)}% overlap with ${tasks[keep].id}, dropping`);
|
|
128
|
-
dominated.add(drop);
|
|
129
|
-
if (drop === i)
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
93
|
+
const setB = new Set(tasks[j].prompt.toLowerCase().split(/\s+/));
|
|
94
|
+
const shared = [...setA].filter((w) => setB.has(w)).length;
|
|
95
|
+
const overlap = shared / Math.min(setA.size, setB.size);
|
|
96
|
+
if (overlap > 0.8) {
|
|
97
|
+
const drop = setA.size >= setB.size ? j : i;
|
|
98
|
+
dominated.add(drop);
|
|
99
|
+
if (drop === i)
|
|
100
|
+
break;
|
|
132
101
|
}
|
|
133
102
|
}
|
|
134
|
-
if (dominated.size) {
|
|
135
|
-
tasks = tasks.filter((_, i) => !dominated.has(i));
|
|
136
|
-
onLog(`Deduplicated to ${tasks.length} tasks`);
|
|
137
|
-
}
|
|
138
103
|
}
|
|
139
|
-
|
|
104
|
+
if (dominated.size) {
|
|
105
|
+
tasks = tasks.filter((_, i) => !dominated.has(i));
|
|
106
|
+
onLog(`Deduplicated to ${tasks.length} tasks`);
|
|
107
|
+
}
|
|
108
|
+
// Warn on compound tasks
|
|
140
109
|
for (const t of tasks) {
|
|
141
110
|
const parts = t.prompt.split(/\s+and\s+/i);
|
|
142
|
-
if (parts.length >= 2 &&
|
|
143
|
-
|
|
144
|
-
onLog(`⚠ Task ${t.id} looks compound ("…and…") — consider splitting into separate tasks`);
|
|
111
|
+
if (parts.length >= 2 && parts.every((p) => p.trim().split(/\s+/).length >= 3)) {
|
|
112
|
+
onLog(`Task ${t.id} looks compound — consider splitting`);
|
|
145
113
|
}
|
|
146
114
|
}
|
|
147
|
-
// Warn on file overlap
|
|
115
|
+
// Warn on file overlap
|
|
148
116
|
const fileRe = /(?:^|\s)((?:[\w.-]+\/)+[\w.-]+\.\w+)/g;
|
|
149
117
|
const pathToTasks = new Map();
|
|
150
118
|
for (const t of tasks) {
|
|
@@ -160,21 +128,79 @@ Respond with ONLY a JSON object (no markdown fences):
|
|
|
160
128
|
if (ids.length > 1)
|
|
161
129
|
onLog(`Overlap risk: ${path} in tasks ${ids.join(", ")}`);
|
|
162
130
|
}
|
|
163
|
-
//
|
|
164
|
-
if (tasks.length >
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
// Cap at 20 tasks
|
|
169
|
-
if (tasks.length > 20) {
|
|
170
|
-
onLog(`Too many tasks (${tasks.length}), truncating to 20`);
|
|
171
|
-
tasks = tasks.slice(0, 20);
|
|
131
|
+
// Cap and sort (tests last)
|
|
132
|
+
if (tasks.length > 30) {
|
|
133
|
+
onLog(`Truncating ${tasks.length} → 30`);
|
|
134
|
+
tasks = tasks.slice(0, 30);
|
|
172
135
|
}
|
|
136
|
+
tasks.sort((a, b) => Number(/\btest/i.test(a.prompt)) - Number(/\btest/i.test(b.prompt)));
|
|
137
|
+
// Re-index
|
|
138
|
+
tasks = tasks.map((t, i) => ({ ...t, id: String(i) }));
|
|
139
|
+
return tasks;
|
|
140
|
+
}
|
|
141
|
+
export async function planTasks(objective, cwd, model, permissionMode, budget, concurrency, onLog) {
|
|
142
|
+
onLog("Analyzing codebase...");
|
|
143
|
+
const resultText = await runPlannerQuery(plannerPrompt(objective, budget, concurrency), { cwd, model, permissionMode }, onLog);
|
|
144
|
+
const parsed = await extractTaskJson(resultText, async () => {
|
|
145
|
+
onLog("Retrying for valid JSON...");
|
|
146
|
+
let retryText = "";
|
|
147
|
+
for await (const msg of query({
|
|
148
|
+
prompt: `Your previous response did not contain valid JSON. Output ONLY a JSON object:\n{"tasks":[{"prompt":"..."}]}`,
|
|
149
|
+
options: { cwd, model, permissionMode, ...(permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }), persistSession: false },
|
|
150
|
+
})) {
|
|
151
|
+
if (msg.type === "result" && msg.subtype === "success")
|
|
152
|
+
retryText = msg.result || "";
|
|
153
|
+
}
|
|
154
|
+
return retryText;
|
|
155
|
+
});
|
|
156
|
+
let tasks = (parsed.tasks || []).map((t, i) => ({
|
|
157
|
+
id: String(i),
|
|
158
|
+
prompt: typeof t === "string" ? t : t.prompt,
|
|
159
|
+
}));
|
|
160
|
+
tasks = postProcess(tasks, onLog);
|
|
173
161
|
if (tasks.length === 0)
|
|
174
162
|
throw new Error("Planner generated 0 tasks");
|
|
175
|
-
|
|
176
|
-
tasks
|
|
177
|
-
|
|
163
|
+
onLog(`${tasks.length} tasks`);
|
|
164
|
+
return tasks;
|
|
165
|
+
}
|
|
166
|
+
export async function refinePlan(objective, previousTasks, feedback, cwd, model, permissionMode, budget, concurrency, onLog) {
|
|
167
|
+
onLog("Refining plan...");
|
|
168
|
+
const prev = previousTasks.map((t, i) => `${i + 1}. ${t.prompt}`).join("\n");
|
|
169
|
+
const budgetLine = budget ? `Target ~${budget} tasks.` : "";
|
|
170
|
+
const prompt = `You are a task coordinator. You previously planned these tasks for the objective:
|
|
171
|
+
|
|
172
|
+
Objective: ${objective}
|
|
173
|
+
|
|
174
|
+
Previous plan:
|
|
175
|
+
${prev}
|
|
176
|
+
|
|
177
|
+
The user wants changes: ${feedback}
|
|
178
|
+
|
|
179
|
+
${budgetLine} ${concurrency} agents run in parallel. Update the plan accordingly. Keep tasks independent and targeting different files.
|
|
180
|
+
|
|
181
|
+
Respond with ONLY a JSON object (no markdown):
|
|
182
|
+
{"tasks":[{"prompt":"..."}]}`;
|
|
183
|
+
const resultText = await runPlannerQuery(prompt, { cwd, model, permissionMode }, onLog);
|
|
184
|
+
const parsed = await extractTaskJson(resultText, async () => {
|
|
185
|
+
onLog("Retrying...");
|
|
186
|
+
let retryText = "";
|
|
187
|
+
for await (const msg of query({
|
|
188
|
+
prompt: `Output ONLY a JSON object:\n{"tasks":[{"prompt":"..."}]}`,
|
|
189
|
+
options: { cwd, model, permissionMode, ...(permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }), persistSession: false },
|
|
190
|
+
})) {
|
|
191
|
+
if (msg.type === "result" && msg.subtype === "success")
|
|
192
|
+
retryText = msg.result || "";
|
|
193
|
+
}
|
|
194
|
+
return retryText;
|
|
195
|
+
});
|
|
196
|
+
let tasks = (parsed.tasks || []).map((t, i) => ({
|
|
197
|
+
id: String(i),
|
|
198
|
+
prompt: typeof t === "string" ? t : t.prompt,
|
|
199
|
+
}));
|
|
200
|
+
tasks = postProcess(tasks, onLog);
|
|
201
|
+
if (tasks.length === 0)
|
|
202
|
+
throw new Error("Refinement produced 0 tasks");
|
|
203
|
+
onLog(`${tasks.length} tasks`);
|
|
178
204
|
return tasks;
|
|
179
205
|
}
|
|
180
206
|
/** Find the outermost balanced { } substring. */
|
|
@@ -196,14 +222,12 @@ function extractOutermostBraces(text) {
|
|
|
196
222
|
/** Try multiple strategies to parse task JSON, with one retry callback. */
|
|
197
223
|
async function extractTaskJson(raw, retry) {
|
|
198
224
|
const attempt = (text) => {
|
|
199
|
-
// 1) Direct parse
|
|
200
225
|
try {
|
|
201
226
|
const obj = JSON.parse(text);
|
|
202
227
|
if (obj?.tasks)
|
|
203
228
|
return obj;
|
|
204
229
|
}
|
|
205
230
|
catch { }
|
|
206
|
-
// 2) Outermost braces
|
|
207
231
|
const braces = extractOutermostBraces(text);
|
|
208
232
|
if (braces) {
|
|
209
233
|
try {
|
|
@@ -213,7 +237,6 @@ async function extractTaskJson(raw, retry) {
|
|
|
213
237
|
}
|
|
214
238
|
catch { }
|
|
215
239
|
}
|
|
216
|
-
// 3) Strip markdown fences and retry
|
|
217
240
|
const stripped = text.replace(/```json?\s*/g, "").replace(/```/g, "").trim();
|
|
218
241
|
if (stripped !== text) {
|
|
219
242
|
try {
|
|
@@ -222,10 +245,10 @@ async function extractTaskJson(raw, retry) {
|
|
|
222
245
|
return obj;
|
|
223
246
|
}
|
|
224
247
|
catch { }
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
248
|
+
const b2 = extractOutermostBraces(stripped);
|
|
249
|
+
if (b2) {
|
|
227
250
|
try {
|
|
228
|
-
const obj = JSON.parse(
|
|
251
|
+
const obj = JSON.parse(b2);
|
|
229
252
|
if (obj?.tasks)
|
|
230
253
|
return obj;
|
|
231
254
|
}
|
|
@@ -237,7 +260,6 @@ async function extractTaskJson(raw, retry) {
|
|
|
237
260
|
const first = attempt(raw);
|
|
238
261
|
if (first)
|
|
239
262
|
return first;
|
|
240
|
-
// One retry with a shorter prompt
|
|
241
263
|
const retryText = await retry();
|
|
242
264
|
const second = attempt(retryText);
|
|
243
265
|
if (second)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-overnight",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Fire off
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
7
7
|
"claude-overnight": "dist/index.js"
|