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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # claude-overnight
2
2
 
3
- Run parallel Claude Code agents with a real-time terminal UI.
3
+ Set a task budget, describe an objective, walk away. Come back to shipped work.
4
4
 
5
- Give it an objective and it plans, executes, and merges the results or feed it explicit tasks. Each agent gets full Claude Code tooling (Read, Edit, Bash, etc.) and optionally runs in an isolated git worktree. A live TUI shows progress, cost, rate limits, and per-agent status.
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
- Prompts for model, concurrency, and permission mode, then asks for an objective. A planner agent analyzes your codebase and breaks it into parallel tasks.
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` | prompted | Isolate each agent in a git worktree |
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
- | `--concurrency=N` | `5` | Max parallel agents (overrides task file) |
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
- When worktrees are enabled, 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 are merged back sequentially. The default `"yolo"` strategy merges directly into your current branch; `"branch"` creates a new `swarm/run-{timestamp}` branch instead. If a merge conflicts, the swarm retries 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.
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
- const lower = msg.toLowerCase();
44
- return AUTH_PATTERNS.some((p) => lower.includes(p));
41
+ return AUTH_PATTERNS.some((p) => msg.toLowerCase().includes(p));
45
42
  }
46
- function authErrorMessage() {
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 Model fetch timed out — continuing with defaults"));
58
+ console.warn(chalk.yellow("\n Model fetch timed out — continuing with defaults"));
65
59
  }
66
60
  return [];
67
61
  }
68
62
  }
69
- // ── Interactive prompts ──
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 pickModel(models) {
80
- if (models.length === 0) {
81
- console.log(chalk.yellow(" Could not fetch models. Enter model ID manually."));
82
- const ans = await ask(chalk.dim(" Model: "));
83
- return ans || "claude-sonnet-4-6";
84
- }
85
- console.log(chalk.bold("\n Model:"));
86
- for (let i = 0; i < models.length; i++) {
87
- const marker = i === 0 ? chalk.green("→") : " ";
88
- const name = models[i].displayName;
89
- const desc = models[i].description ? chalk.dim(` — ${models[i].description}`) : "";
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
- return ans;
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
- // Validate concurrency if present
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 this to initialize one:\n\n` +
240
- ` cd ${cwd} && git init\n\n` +
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 — pick model, concurrency, objective")}
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
- --concurrency=N Max parallel agents ${chalk.dim("(overrides task file & defaults)")}
267
- --model=NAME Model to use ${chalk.dim("(overrides task file & defaults)")}
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 (git repo) perms: 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 (got "${cliFlags.concurrency}")`));
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 (got "${cliFlags.timeout}")`));
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: ${jsonFiles.join(", ")}`));
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 path but doesn't end in .json.`));
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
- // ── Config: defaults for non-interactive, prompts for interactive ──
322
- console.log(chalk.bold("\n 🌙 claude-overnight\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
- validateCwd(cwd);
328
- if (!nonInteractive) {
329
- console.log(chalk.dim(" Run parallel Claude Code agents with real-time UI.\n"));
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
- let models = [];
338
- // Fetch models unless already overridden by CLI/file — non-interactive uses a shorter timeout
339
- const modelAlreadySet = cliFlags.model || fileCfg?.model;
340
- if (!modelAlreadySet) {
341
- if (nonInteractive) {
342
- models = await fetchModels(5_000);
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
- process.stdout.write(chalk.dim(" Fetching available models..."));
346
- models = await fetchModels();
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 useWorktrees = fileCfg?.useWorktrees ?? (nonInteractive ? (noTTY ? false : isGitRepo(cwd)) : await pickWorktrees());
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 ?? (useWorktrees && !nonInteractive ? await pickMergeStrategy() : "yolo");
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
- // If no tasks yet, ask for an objective and plan
363
- let planMode = tasks.length === 0;
364
- let objective;
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("\n No tasks provided and stdin is not a TTY. Provide tasks via args or a JSON file."));
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
- objective = await pickObjective();
371
- if (!objective) {
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(`Generated ${tasks.length} tasks`)}\n\n`);
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 ${authErrorMessage()}\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
- for (const t of tasks) {
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
- concurrency,
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
- // Replace simple handlers with graceful drain: first signal stops queue, second force-exits
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... waiting for ${swarm.active} active agent(s) to finish (send again to force)`)}\n`);
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
- swarm.abort();
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 ${authErrorMessage()}\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
- const prompt = a.task.prompt;
498
- const label = prompt.slice(0, 60) + (prompt.length > 60 ? "…" : "");
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} branch(es) had unresolved conflicts:`));
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
- // Exit codes: 0 = all succeeded, 1 = some failed, 2 = all failed or aborted
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
- * Coordinator: analyzes the codebase, breaks objective into parallel tasks.
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
- * Coordinator: analyzes the codebase, breaks objective into parallel tasks.
4
- */
5
- export async function planTasks(objective, cwd, model, permissionMode, onLog) {
6
- onLog("Analyzing codebase...");
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
- plannerQuery.close();
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 plannerQuery) {
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
- const parsed = await extractTaskJson(resultText, async () => {
82
- onLog("Retrying for valid JSON...");
83
- let retryText = "";
84
- for await (const msg of query({
85
- prompt: `Your previous response did not contain valid JSON. Output ONLY a JSON object with this shape, nothing else:\n{"tasks":[{"prompt":"..."}]}`,
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
- // Deduplicate tasks with very similar prompts (>80% word overlap)
111
- {
112
- const dominated = new Set();
113
- for (let i = 0; i < tasks.length; i++) {
114
- if (dominated.has(i))
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 setA = new Set(tasks[i].prompt.toLowerCase().split(/\s+/));
117
- for (let j = i + 1; j < tasks.length; j++) {
118
- if (dominated.has(j))
119
- continue;
120
- const setB = new Set(tasks[j].prompt.toLowerCase().split(/\s+/));
121
- const shared = [...setA].filter((w) => setB.has(w)).length;
122
- const overlap = shared / Math.min(setA.size, setB.size);
123
- if (overlap > 0.8) {
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
- // Warn on compound tasks joining unrelated changes with 'and'
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
- parts.every((p) => p.trim().split(/\s+/).length >= 3)) {
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 between tasks
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
- // Warn if every task targets the same file — high merge conflict risk
164
- if (tasks.length > 1 && pathToTasks.size === 1) {
165
- const [singlePath] = pathToTasks.keys();
166
- onLog(`⚠ All ${tasks.length} tasks target ${singlePath} — high merge conflict risk`);
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
- // Sort test-related tasks last — they benefit from other changes landing first
176
- tasks.sort((a, b) => Number(/\btest/i.test(a.prompt)) - Number(/\btest/i.test(b.prompt)));
177
- onLog(`Generated ${tasks.length} tasks`);
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 braces2 = extractOutermostBraces(stripped);
226
- if (braces2) {
248
+ const b2 = extractOutermostBraces(stripped);
249
+ if (b2) {
227
250
  try {
228
- const obj = JSON.parse(braces2);
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.0",
4
- "description": "Fire off parallel Claude Code agents, come back to shipped work",
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"