claude-overnight 1.11.7 → 1.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,33 @@ export declare function parseCliFlags(argv: string[]): {
6
6
  };
7
7
  export declare function isAuthError(err: unknown): boolean;
8
8
  export declare function fetchModels(timeoutMs?: number): Promise<ModelInfo[]>;
9
+ export declare const PASTE_START = "\u001B[200~";
10
+ export declare const PASTE_END = "\u001B[201~";
11
+ export declare const PASTE_PLACEHOLDER_MAX = 80;
12
+ export type InputSegment = {
13
+ type: "text";
14
+ content: string;
15
+ } | {
16
+ type: "paste";
17
+ content: string;
18
+ };
19
+ /** Split a raw stdin chunk into typed and pasted segments. */
20
+ export declare function splitPaste(chunk: string): Array<{
21
+ type: "typed" | "paste";
22
+ text: string;
23
+ }>;
24
+ export declare function segmentsToString(segs: InputSegment[]): string;
25
+ export declare function renderSegments(segs: InputSegment[]): string;
26
+ export declare function appendCharToSegments(segs: InputSegment[], ch: string): void;
27
+ /** Appends a pasted block. Short single-line pastes inline as text; the rest become placeholders. */
28
+ export declare function appendPasteToSegments(segs: InputSegment[], text: string): void;
29
+ /** Backspace removes one char, or an entire paste block atomically. */
30
+ export declare function backspaceSegments(segs: InputSegment[]): void;
31
+ /**
32
+ * Read a line from the user with bracketed-paste awareness.
33
+ * Pasted multi-line text stays in the buffer as a single block — only a typed
34
+ * Enter submits. Falls back to cooked readline when stdin isn't a TTY.
35
+ */
9
36
  export declare function ask(question: string): Promise<string>;
10
37
  export declare function select<T>(label: string, items: {
11
38
  name: string;
package/dist/cli.js CHANGED
@@ -67,11 +67,149 @@ export async function fetchModels(timeoutMs = 10_000) {
67
67
  return [];
68
68
  }
69
69
  }
70
+ // ── Bracketed paste + segment-based input ──
71
+ //
72
+ // When the terminal is in bracketed paste mode, pasted content is wrapped with
73
+ // \x1B[200~ ... \x1B[201~ so we can distinguish typed Enter from pasted newlines.
74
+ // Multi-line or long pastes are stored as opaque segments and shown as a compact
75
+ // [Pasted +N lines] placeholder while editing — the full text is substituted on submit.
76
+ export const PASTE_START = "\x1B[200~";
77
+ export const PASTE_END = "\x1B[201~";
78
+ export const PASTE_PLACEHOLDER_MAX = 80;
79
+ /** Split a raw stdin chunk into typed and pasted segments. */
80
+ export function splitPaste(chunk) {
81
+ const out = [];
82
+ let i = 0;
83
+ while (i < chunk.length) {
84
+ const start = chunk.indexOf(PASTE_START, i);
85
+ if (start === -1) {
86
+ out.push({ type: "typed", text: chunk.slice(i) });
87
+ break;
88
+ }
89
+ if (start > i)
90
+ out.push({ type: "typed", text: chunk.slice(i, start) });
91
+ const bodyStart = start + PASTE_START.length;
92
+ const end = chunk.indexOf(PASTE_END, bodyStart);
93
+ if (end === -1) {
94
+ out.push({ type: "paste", text: chunk.slice(bodyStart) });
95
+ break;
96
+ }
97
+ out.push({ type: "paste", text: chunk.slice(bodyStart, end) });
98
+ i = end + PASTE_END.length;
99
+ }
100
+ return out;
101
+ }
102
+ export function segmentsToString(segs) {
103
+ return segs.map((s) => s.content).join("");
104
+ }
105
+ export function renderSegments(segs) {
106
+ return segs.map((s) => {
107
+ if (s.type === "text")
108
+ return s.content;
109
+ const lines = s.content.split("\n").length;
110
+ return chalk.dim(`[Pasted +${lines} line${lines === 1 ? "" : "s"}]`);
111
+ }).join("");
112
+ }
113
+ export function appendCharToSegments(segs, ch) {
114
+ const last = segs[segs.length - 1];
115
+ if (last && last.type === "text")
116
+ last.content += ch;
117
+ else
118
+ segs.push({ type: "text", content: ch });
119
+ }
120
+ /** Appends a pasted block. Short single-line pastes inline as text; the rest become placeholders. */
121
+ export function appendPasteToSegments(segs, text) {
122
+ if (!text)
123
+ return;
124
+ const norm = text.replace(/\r\n?/g, "\n");
125
+ if (!norm.includes("\n") && norm.length <= PASTE_PLACEHOLDER_MAX) {
126
+ appendCharToSegments(segs, norm);
127
+ return;
128
+ }
129
+ segs.push({ type: "paste", content: norm });
130
+ }
131
+ /** Backspace removes one char, or an entire paste block atomically. */
132
+ export function backspaceSegments(segs) {
133
+ while (segs.length > 0) {
134
+ const last = segs[segs.length - 1];
135
+ if (last.type === "paste") {
136
+ segs.pop();
137
+ return;
138
+ }
139
+ if (last.content.length > 1) {
140
+ last.content = last.content.slice(0, -1);
141
+ return;
142
+ }
143
+ segs.pop();
144
+ return;
145
+ }
146
+ }
70
147
  // ── Interactive primitives ──
148
+ /**
149
+ * Read a line from the user with bracketed-paste awareness.
150
+ * Pasted multi-line text stays in the buffer as a single block — only a typed
151
+ * Enter submits. Falls back to cooked readline when stdin isn't a TTY.
152
+ */
71
153
  export function ask(question) {
72
- const rl = createInterface({ input: process.stdin, output: process.stdout });
73
- return new Promise((res) => {
74
- rl.question(question, (answer) => { rl.close(); res(answer.trim()); });
154
+ const { stdin, stdout } = process;
155
+ if (!stdin.isTTY) {
156
+ const rl = createInterface({ input: stdin, output: stdout });
157
+ return new Promise((res) => rl.question(question, (a) => { rl.close(); res(a.trim()); }));
158
+ }
159
+ return new Promise((resolve) => {
160
+ const segs = [];
161
+ const lastLine = question.split("\n").pop() ?? "";
162
+ const redraw = () => {
163
+ stdout.write("\r\x1B[K" + lastLine + renderSegments(segs));
164
+ };
165
+ stdout.write(question);
166
+ stdout.write("\x1B[?2004h");
167
+ try {
168
+ stdin.setRawMode(true);
169
+ }
170
+ catch { }
171
+ stdin.resume();
172
+ const cleanup = () => {
173
+ stdout.write("\x1B[?2004l");
174
+ try {
175
+ stdin.setRawMode(false);
176
+ }
177
+ catch { }
178
+ stdin.removeListener("data", onData);
179
+ stdin.pause();
180
+ };
181
+ const onData = (buf) => {
182
+ const chunk = buf.toString();
183
+ for (const seg of splitPaste(chunk)) {
184
+ if (seg.type === "paste") {
185
+ appendPasteToSegments(segs, seg.text);
186
+ redraw();
187
+ continue;
188
+ }
189
+ for (const ch of seg.text) {
190
+ if (ch === "\r" || ch === "\n") {
191
+ stdout.write("\n");
192
+ cleanup();
193
+ resolve(segmentsToString(segs).trim());
194
+ return;
195
+ }
196
+ if (ch === "\x03") {
197
+ cleanup();
198
+ stdout.write("\n");
199
+ process.exit(130);
200
+ }
201
+ if (ch === "\x7F" || ch === "\b") {
202
+ backspaceSegments(segs);
203
+ continue;
204
+ }
205
+ const code = ch.charCodeAt(0);
206
+ if (ch !== "\x1B" && code >= 0x20)
207
+ appendCharToSegments(segs, ch);
208
+ }
209
+ redraw();
210
+ }
211
+ };
212
+ stdin.on("data", onData);
75
213
  });
76
214
  }
77
215
  export async function select(label, items, defaultIdx = 0) {
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { RunDisplay } from "./ui.js";
11
11
  import { renderSummary } from "./render.js";
12
12
  import { executeRun } from "./run.js";
13
13
  import { parseCliFlags, isAuthError, fetchModels, ask, select, selectKey, loadTaskFile, validateConcurrency, isGitRepo, validateGitRepo, showPlan, BRAILLE, makeProgressLog, } from "./cli.js";
14
- import { loadRunState, findIncompleteRuns, findOrphanedDesigns, formatTimeAgo, showRunHistory, readPreviousRunKnowledge, createRunDir, updateLatestSymlink, readMdDir, saveRunState, autoMergeBranches, } from "./state.js";
14
+ import { loadRunState, findIncompleteRuns, findOrphanedDesigns, backfillOrphanedPlans, formatTimeAgo, showRunHistory, readPreviousRunKnowledge, createRunDir, updateLatestSymlink, readMdDir, saveRunState, autoMergeBranches, } from "./state.js";
15
15
  function countTasksInFile(path) {
16
16
  try {
17
17
  const parsed = JSON.parse(readFileSync(path, "utf-8"));
@@ -123,6 +123,12 @@ async function main() {
123
123
  // ── Run history ──
124
124
  const rootDir = join(cwd, ".claude-overnight");
125
125
  const runsDir = join(rootDir, "runs");
126
+ // Backfill run.json for pre-1.11.7 orphaned plans so they become visible
127
+ // to the resume picker. One-shot, idempotent, silent if there's nothing.
128
+ const backfilled = backfillOrphanedPlans(rootDir, cwd);
129
+ if (backfilled > 0 && !noTTY) {
130
+ console.log(chalk.dim(`\n ↻ Recovered ${backfilled} orphaned plan${backfilled > 1 ? "s" : ""} from disk`));
131
+ }
126
132
  const allRuns = [];
127
133
  try {
128
134
  for (const d of readdirSync(runsDir).sort().reverse()) {
@@ -256,17 +262,22 @@ async function main() {
256
262
  }
257
263
  }
258
264
  if (resuming && resumeState && resumeRunDir) {
259
- // Planning-phase resume: the prior run died before executeRun ran any
260
- // wave, but the orchestrate agent wrote tasks.json to disk. Load those
261
- // tasks into currentTasks so executeRun can pick them up as wave 0.
262
- if (resumeState.phase === "planning") {
265
+ // If currentTasks is empty but tasks.json exists on disk, reload it.
266
+ // Covers two cases:
267
+ // 1. Planning-phase resumes (the prior run died before executeRun).
268
+ // 2. Stopped/capped runs whose state was saved with currentTasks: []
269
+ // (saveRunState always stores [] — the plan is on disk in tasks.json).
270
+ if (resumeState.currentTasks.length === 0) {
263
271
  const loaded = salvageFromFile(join(resumeRunDir, "tasks.json"), resumeState.budget, () => { }, "resume");
264
- if (!loaded) {
272
+ if (!loaded && resumeState.phase === "planning") {
265
273
  console.error(chalk.red(`\n Planning-phase run has no usable tasks.json — start Fresh instead.\n`));
266
274
  process.exit(1);
267
275
  }
268
- resumeState.currentTasks = loaded;
269
- console.log(chalk.green(`\n ✓ Resuming plan · ${loaded.length} tasks loaded from tasks.json`));
276
+ if (loaded) {
277
+ resumeState.currentTasks = loaded;
278
+ const label = resumeState.phase === "planning" ? "Resuming plan" : `Resuming ${resumeState.phase} run`;
279
+ console.log(chalk.green(`\n ✓ ${label} · ${loaded.length} tasks loaded from tasks.json`));
280
+ }
270
281
  }
271
282
  const unmerged = resumeState.branches.filter(b => b.status === "unmerged").length;
272
283
  if (unmerged > 0) {
package/dist/merge.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface MergeResult {
7
7
  error?: string;
8
8
  filesChanged: number;
9
9
  }
10
- export declare function autoCommit(agentId: number, taskPrompt: string, worktreeCwd: string, log: (id: number, msg: string) => void): number;
10
+ export declare function autoCommit(agentId: number, taskPrompt: string, worktreeCwd: string, baseRef: string | undefined, log: (id: number, msg: string) => void): number;
11
11
  export interface MergeAllResult {
12
12
  mergeResults: MergeResult[];
13
13
  mergeBranch?: string;
package/dist/merge.js CHANGED
@@ -5,11 +5,13 @@ import { tmpdir } from "os";
5
5
  export function gitExec(cmd, cwd) {
6
6
  return execSync(cmd, { cwd, encoding: "utf-8", stdio: "pipe" });
7
7
  }
8
- export function autoCommit(agentId, taskPrompt, worktreeCwd, log) {
8
+ export function autoCommit(agentId, taskPrompt, worktreeCwd, baseRef, log) {
9
9
  if (!existsSync(worktreeCwd)) {
10
10
  log(agentId, "Worktree directory gone, skipping commit");
11
11
  return 0;
12
12
  }
13
+ // Step 1: commit any uncommitted changes. Agents *may* commit their own work;
14
+ // we pick up whatever is still dirty.
13
15
  let status;
14
16
  try {
15
17
  status = gitExec("git status --porcelain", worktreeCwd);
@@ -18,27 +20,40 @@ export function autoCommit(agentId, taskPrompt, worktreeCwd, log) {
18
20
  log(agentId, `git status failed: ${String(err.message || err).slice(0, 120)}`);
19
21
  return 0;
20
22
  }
21
- if (!status.trim())
22
- return 0;
23
- const lines = status.trim().split("\n").length;
24
- try {
25
- gitExec("git add -A", worktreeCwd);
26
- }
27
- catch (err) {
28
- log(agentId, `git add failed: ${String(err.message || err).slice(0, 120)}`);
29
- return lines;
23
+ if (status.trim()) {
24
+ try {
25
+ gitExec("git add -A", worktreeCwd);
26
+ }
27
+ catch (err) {
28
+ log(agentId, `git add failed: ${String(err.message || err).slice(0, 120)}`);
29
+ }
30
+ try {
31
+ const msg = taskPrompt.slice(0, 72).replace(/'/g, "'\\''");
32
+ gitExec(`git commit -m 'swarm: ${msg}'`, worktreeCwd);
33
+ }
34
+ catch (err) {
35
+ const m = String(err.message || err);
36
+ if (!m.includes("nothing to commit"))
37
+ log(agentId, `git commit failed: ${m.slice(0, 120)}`);
38
+ }
30
39
  }
40
+ // Step 2: measure against the worktree's origin commit — true regardless of
41
+ // who made the commits (agent or autoCommit). Pre-1.11.10 this counted
42
+ // `git status --porcelain` lines, which was zero whenever the agent committed
43
+ // its own work → filesChanged=0 → branch skipped by the merge gate → orphaned.
44
+ if (!baseRef)
45
+ return 0;
31
46
  try {
32
- const msg = taskPrompt.slice(0, 72).replace(/'/g, "'\\''");
33
- gitExec(`git commit -m 'swarm: ${msg}'`, worktreeCwd);
34
- log(agentId, `Committed ${lines} file(s)`);
47
+ const diff = gitExec(`git diff --name-only ${baseRef}..HEAD`, worktreeCwd);
48
+ const count = diff.trim().split("\n").filter(Boolean).length;
49
+ if (count > 0)
50
+ log(agentId, `${count} file(s) changed`);
51
+ return count;
35
52
  }
36
53
  catch (err) {
37
- const msg = String(err.message || err);
38
- if (!msg.includes("nothing to commit"))
39
- log(agentId, `git commit failed: ${msg.slice(0, 120)}`);
54
+ log(agentId, `diff vs base failed: ${String(err.message || err).slice(0, 120)}`);
55
+ return 0;
40
56
  }
41
- return lines;
42
57
  }
43
58
  export function mergeAllBranches(agents, cwd, strategy, log) {
44
59
  const mergeResults = [];
@@ -162,11 +177,16 @@ export function cleanStaleWorktrees(cwd, log) {
162
177
  try {
163
178
  const list = gitExec("git worktree list --porcelain", cwd);
164
179
  const stale = [];
165
- const tmp = tmpdir();
180
+ // Match any worktree whose path contains our mkdtemp prefix. We used to
181
+ // gate on `startsWith(tmpdir())` too, but on macOS `os.tmpdir()` returns
182
+ // `/var/folders/...` while git reports worktrees as `/private/var/...`
183
+ // (realpath-resolved), so the prefix never matched and stale worktrees
184
+ // silently accumulated. The `claude-overnight-` substring is unambiguous
185
+ // enough on its own — nothing else in the repo uses that prefix.
166
186
  for (const line of list.split("\n")) {
167
187
  if (line.startsWith("worktree ")) {
168
188
  const wpath = line.slice("worktree ".length);
169
- if (wpath.startsWith(tmp) && wpath.includes("claude-overnight-"))
189
+ if (wpath.includes("/claude-overnight-"))
170
190
  stale.push(wpath);
171
191
  }
172
192
  }
@@ -189,14 +209,23 @@ export function cleanStaleWorktrees(cwd, log) {
189
209
  .split("\n")
190
210
  .map(b => b.trim().replace(/^\* /, ""))
191
211
  .filter(b => b.startsWith("swarm/task-") && !worktreeBranches.has(b));
212
+ // Use -d (safe delete) rather than -D: branches whose commits aren't in
213
+ // HEAD are orphaned work from a prior failed/unmerged run and must survive
214
+ // so the user can recover them. Only already-merged (ff) branches are
215
+ // actually deleted here.
216
+ let cleaned = 0;
192
217
  for (const b of branches) {
193
218
  try {
194
- gitExec(`git branch -D "${b}"`, cwd);
219
+ gitExec(`git branch -d "${b}"`, cwd);
220
+ cleaned++;
195
221
  }
196
222
  catch { }
197
223
  }
198
- if (branches.length > 0)
199
- log(-1, `Cleaned ${branches.length} stale swarm branch(es)`);
224
+ if (cleaned > 0)
225
+ log(-1, `Cleaned ${cleaned} stale swarm branch(es)`);
226
+ const orphaned = branches.length - cleaned;
227
+ if (orphaned > 0)
228
+ log(-1, `Kept ${orphaned} orphaned swarm branch(es) with unmerged commits — resume to recover`);
200
229
  }
201
230
  catch { }
202
231
  }
package/dist/state.d.ts CHANGED
@@ -36,6 +36,19 @@ export declare function findIncompleteRuns(rootDir: string, filterCwd: string):
36
36
  state: RunState;
37
37
  }[];
38
38
  export declare function findOrphanedDesigns(rootDir: string): string | null;
39
+ /**
40
+ * Backfill run.json for pre-1.11.7 orphaned plans: runs where orchestrate's
41
+ * agent wrote tasks.json via its Write tool but the process died before
42
+ * executeRun ever got to saveRunState. Without this, those runs are invisible
43
+ * to findIncompleteRuns forever.
44
+ *
45
+ * Idempotent: runs with an existing run.json are skipped. Synthesizes a
46
+ * minimal "planning" state from what can be read off disk — dir name for
47
+ * timestamp, task count for budget, sane defaults for everything else.
48
+ * The cwd field is set to filterCwd so findIncompleteRuns picks it up on the
49
+ * current project (which is safe — rootDir is already scoped to `cwd`).
50
+ */
51
+ export declare function backfillOrphanedPlans(rootDir: string, filterCwd: string): number;
39
52
  export declare function formatTimeAgo(isoStr: string): string;
40
53
  export declare function showRunHistory(allRuns: {
41
54
  dir: string;
package/dist/state.js CHANGED
@@ -219,6 +219,70 @@ export function findOrphanedDesigns(rootDir) {
219
219
  catch { }
220
220
  return null;
221
221
  }
222
+ /**
223
+ * Backfill run.json for pre-1.11.7 orphaned plans: runs where orchestrate's
224
+ * agent wrote tasks.json via its Write tool but the process died before
225
+ * executeRun ever got to saveRunState. Without this, those runs are invisible
226
+ * to findIncompleteRuns forever.
227
+ *
228
+ * Idempotent: runs with an existing run.json are skipped. Synthesizes a
229
+ * minimal "planning" state from what can be read off disk — dir name for
230
+ * timestamp, task count for budget, sane defaults for everything else.
231
+ * The cwd field is set to filterCwd so findIncompleteRuns picks it up on the
232
+ * current project (which is safe — rootDir is already scoped to `cwd`).
233
+ */
234
+ export function backfillOrphanedPlans(rootDir, filterCwd) {
235
+ const runsDir = join(rootDir, "runs");
236
+ let count = 0;
237
+ try {
238
+ const dirs = readdirSync(runsDir);
239
+ for (const d of dirs) {
240
+ const runDir = join(runsDir, d);
241
+ if (existsSync(join(runDir, "run.json")))
242
+ continue;
243
+ const tasksFile = join(runDir, "tasks.json");
244
+ if (!existsSync(tasksFile))
245
+ continue;
246
+ let taskCount = 0;
247
+ try {
248
+ const parsed = JSON.parse(readFileSync(tasksFile, "utf-8"));
249
+ if (!Array.isArray(parsed?.tasks))
250
+ continue;
251
+ taskCount = parsed.tasks.length;
252
+ }
253
+ catch {
254
+ continue;
255
+ }
256
+ if (taskCount === 0)
257
+ continue;
258
+ // Dir name format: 2026-04-12T13-03-57 (UTC). Convert to ISO.
259
+ const m = d.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})$/);
260
+ const startedAt = m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.000Z` : new Date(0).toISOString();
261
+ try {
262
+ saveRunState(runDir, {
263
+ id: d,
264
+ objective: `(recovered pre-1.11.7 plan · ${taskCount} tasks)`,
265
+ budget: taskCount, remaining: taskCount,
266
+ workerModel: "claude-opus-4-6", plannerModel: "claude-opus-4-6",
267
+ concurrency: 5, permissionMode: "bypassPermissions",
268
+ flex: false, useWorktrees: true, mergeStrategy: "yolo",
269
+ allowExtraUsage: false,
270
+ waveNum: 0, currentTasks: [],
271
+ accCost: 0, accCompleted: 0, accFailed: 0,
272
+ accIn: 0, accOut: 0, accTools: 0,
273
+ branches: [],
274
+ phase: "planning",
275
+ startedAt,
276
+ cwd: filterCwd,
277
+ });
278
+ count++;
279
+ }
280
+ catch { }
281
+ }
282
+ }
283
+ catch { }
284
+ return count;
285
+ }
222
286
  // ── History display ──
223
287
  export function formatTimeAgo(isoStr) {
224
288
  const ms = Date.now() - new Date(isoStr).getTime();
@@ -377,7 +441,10 @@ export function recordBranches(agents, mergeResults, branches) {
377
441
  }
378
442
  }
379
443
  export function autoMergeBranches(cwd, branches, onLog) {
380
- const unmerged = branches.filter(b => b.status === "unmerged" && b.filesChanged > 0);
444
+ // Do NOT gate on filesChanged — pre-1.11.10 runs can record filesChanged=0
445
+ // for branches that actually contain real commits (agent self-committed).
446
+ // Feed every unmerged branch to git; it will no-op harmlessly if truly empty.
447
+ const unmerged = branches.filter(b => b.status === "unmerged");
381
448
  if (unmerged.length === 0)
382
449
  return;
383
450
  onLog(`Merging ${unmerged.length} unmerged branches...`);
package/dist/swarm.js CHANGED
@@ -227,6 +227,11 @@ export class Swarm {
227
227
  if (this.config.useWorktrees && this.worktreeBase && !task.noWorktree) {
228
228
  const branch = `swarm/task-${id}`;
229
229
  const dir = join(this.worktreeBase, `agent-${id}`);
230
+ let baseRef;
231
+ try {
232
+ baseRef = gitExec("git rev-parse HEAD", this.config.cwd).trim();
233
+ }
234
+ catch { }
230
235
  let worktreeOk = false;
231
236
  for (let wt = 0; wt < 2 && !worktreeOk; wt++) {
232
237
  try {
@@ -254,6 +259,7 @@ export class Swarm {
254
259
  if (worktreeOk) {
255
260
  agentCwd = dir;
256
261
  agent.branch = branch;
262
+ agent.baseRef = baseRef;
257
263
  this.log(id, `Worktree: ${branch}`);
258
264
  }
259
265
  else {
@@ -397,7 +403,7 @@ export class Swarm {
397
403
  }
398
404
  }
399
405
  if (this.config.useWorktrees && agent.branch) {
400
- agent.filesChanged = autoCommit(agent.id, agent.task.prompt, agentCwd, (id, text) => this.log(id, text));
406
+ agent.filesChanged = autoCommit(agent.id, agent.task.prompt, agentCwd, agent.baseRef, (id, text) => this.log(id, text));
401
407
  }
402
408
  }
403
409
  agentSummary(agent) {
package/dist/types.d.ts CHANGED
@@ -64,6 +64,8 @@ export interface AgentState {
64
64
  costUsd?: number;
65
65
  /** Git branch name when using worktree isolation. */
66
66
  branch?: string;
67
+ /** Commit the worktree branch was created from — the baseline for measuring filesChanged. */
68
+ baseRef?: string;
67
69
  /** Number of files changed by the agent (from git diff). */
68
70
  filesChanged?: number;
69
71
  }
package/dist/ui.d.ts CHANGED
@@ -57,7 +57,7 @@ export declare class RunDisplay {
57
57
  private interval?;
58
58
  private keyHandler?;
59
59
  private inputMode;
60
- private inputBuf;
60
+ private inputSegs;
61
61
  private started;
62
62
  private readonly isTTY;
63
63
  private lastSeq;
@@ -94,6 +94,10 @@ export declare class RunDisplay {
94
94
  private renderAskPanel;
95
95
  private hasHotkeys;
96
96
  private setupHotkeys;
97
+ /** Handle a pasted block. Returns true if the frame needs a redraw. */
98
+ private handlePaste;
99
+ /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw. */
100
+ private handleTyped;
97
101
  private plainTick;
98
102
  }
99
103
  export {};
package/dist/ui.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { renderFrame, renderSteeringFrame } from "./render.js";
3
+ import { splitPaste, segmentsToString, renderSegments, appendCharToSegments, appendPasteToSegments, backspaceSegments, } from "./cli.js";
3
4
  const MAX_STEERING_EVENTS = 60;
4
5
  const MAX_INPUT_LEN = 600;
5
6
  export class RunDisplay {
@@ -14,7 +15,7 @@ export class RunDisplay {
14
15
  interval;
15
16
  keyHandler;
16
17
  inputMode = "none";
17
- inputBuf = "";
18
+ inputSegs = [];
18
19
  started = false;
19
20
  isTTY;
20
21
  lastSeq = 0;
@@ -87,6 +88,10 @@ export class RunDisplay {
87
88
  if (this.keyHandler) {
88
89
  process.stdin.removeListener("data", this.keyHandler);
89
90
  this.keyHandler = undefined;
91
+ try {
92
+ process.stdout.write("\x1B[?2004l");
93
+ }
94
+ catch { }
90
95
  try {
91
96
  process.stdin.setRawMode(false);
92
97
  process.stdin.pause();
@@ -149,17 +154,18 @@ export class RunDisplay {
149
154
  renderInputPrompt() {
150
155
  if (this.inputMode === "none")
151
156
  return "";
157
+ const rendered = renderSegments(this.inputSegs);
152
158
  if (this.inputMode === "budget") {
153
- return `\n ${chalk.cyan(">")} New budget (remaining sessions): ${this.inputBuf}\u2588`;
159
+ return `\n ${chalk.cyan(">")} New budget (remaining sessions): ${rendered}\u2588`;
154
160
  }
155
161
  if (this.inputMode === "threshold") {
156
- return `\n ${chalk.cyan(">")} New usage cap (0-100%): ${this.inputBuf}\u2588`;
162
+ return `\n ${chalk.cyan(">")} New usage cap (0-100%): ${rendered}\u2588`;
157
163
  }
158
164
  if (this.inputMode === "steer") {
159
- return `\n ${chalk.cyan(">")} ${chalk.bold("Steer next wave")} ${chalk.dim("(Enter to queue, Esc to cancel)")}\n ${this.inputBuf}\u2588`;
165
+ return `\n ${chalk.cyan(">")} ${chalk.bold("Steer next wave")} ${chalk.dim("(Enter to queue, Esc to cancel)")}\n ${rendered}\u2588`;
160
166
  }
161
167
  if (this.inputMode === "ask") {
162
- return `\n ${chalk.cyan(">")} ${chalk.bold("Ask the planner")} ${chalk.dim("(Enter to send, Esc to cancel)")}\n ${this.inputBuf}\u2588`;
168
+ return `\n ${chalk.cyan(">")} ${chalk.bold("Ask the planner")} ${chalk.dim("(Enter to send, Esc to cancel)")}\n ${rendered}\u2588`;
163
169
  }
164
170
  return "";
165
171
  }
@@ -196,12 +202,52 @@ export class RunDisplay {
196
202
  catch {
197
203
  return;
198
204
  }
199
- const lc = this.liveConfig;
205
+ try {
206
+ process.stdout.write("\x1B[?2004h");
207
+ }
208
+ catch { }
200
209
  this.keyHandler = (buf) => {
201
- const s = buf.toString();
202
- if (this.inputMode === "budget" || this.inputMode === "threshold") {
203
- if (s === "\r" || s === "\n") {
204
- const val = parseFloat(this.inputBuf);
210
+ const chunk = buf.toString();
211
+ let dirty = false;
212
+ for (const seg of splitPaste(chunk)) {
213
+ if (seg.type === "paste") {
214
+ if (this.handlePaste(seg.text))
215
+ dirty = true;
216
+ }
217
+ else {
218
+ if (this.handleTyped(seg.text))
219
+ dirty = true;
220
+ }
221
+ }
222
+ if (dirty)
223
+ this.flush();
224
+ };
225
+ process.stdin.on("data", this.keyHandler);
226
+ }
227
+ /** Handle a pasted block. Returns true if the frame needs a redraw. */
228
+ handlePaste(text) {
229
+ if (this.inputMode === "budget" || this.inputMode === "threshold") {
230
+ const clean = text.replace(/[^0-9.]/g, "");
231
+ if (clean)
232
+ appendCharToSegments(this.inputSegs, clean);
233
+ return !!clean;
234
+ }
235
+ if (this.inputMode === "steer" || this.inputMode === "ask") {
236
+ if (segmentsToString(this.inputSegs).length + text.length > MAX_INPUT_LEN)
237
+ return false;
238
+ appendPasteToSegments(this.inputSegs, text);
239
+ return true;
240
+ }
241
+ return false;
242
+ }
243
+ /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw. */
244
+ handleTyped(s) {
245
+ const lc = this.liveConfig;
246
+ if (this.inputMode === "budget" || this.inputMode === "threshold") {
247
+ let dirty = false;
248
+ for (const ch of s) {
249
+ if (ch === "\r" || ch === "\n") {
250
+ const val = parseFloat(segmentsToString(this.inputSegs));
205
251
  if (this.inputMode === "budget" && !isNaN(val) && val > 0) {
206
252
  lc.remaining = Math.round(val);
207
253
  lc.dirty = true;
@@ -216,105 +262,113 @@ export class RunDisplay {
216
262
  this.swarm?.log(-1, `Usage cap changed to ${val > 0 ? val + "%" : "unlimited"}`);
217
263
  }
218
264
  this.inputMode = "none";
219
- this.inputBuf = "";
265
+ this.inputSegs = [];
266
+ return true;
220
267
  }
221
- else if (s === "\x1B" || s === "\x03") {
268
+ if (ch === "\x1B" || ch === "\x03") {
222
269
  this.inputMode = "none";
223
- this.inputBuf = "";
270
+ this.inputSegs = [];
271
+ return true;
224
272
  }
225
- else if (s === "\x7F") {
226
- this.inputBuf = this.inputBuf.slice(0, -1);
273
+ if (ch === "\x7F") {
274
+ backspaceSegments(this.inputSegs);
275
+ dirty = true;
276
+ continue;
227
277
  }
228
- else if (/^[0-9.]$/.test(s)) {
229
- this.inputBuf += s;
278
+ if (/^[0-9.]$/.test(ch)) {
279
+ appendCharToSegments(this.inputSegs, ch);
280
+ dirty = true;
230
281
  }
231
- this.flush();
232
- return;
233
282
  }
234
- if (this.inputMode === "steer" || this.inputMode === "ask") {
235
- for (const ch of s) {
236
- if (ch === "\r" || ch === "\n") {
237
- const text = this.inputBuf.trim();
238
- const wasAsk = this.inputMode === "ask";
239
- this.inputMode = "none";
240
- this.inputBuf = "";
241
- if (text) {
242
- if (wasAsk)
243
- this.onAsk?.(text);
244
- else
245
- this.onSteer?.(text);
246
- }
247
- this.flush();
248
- return;
249
- }
250
- if (ch === "\x03") {
251
- this.inputMode = "none";
252
- this.inputBuf = "";
253
- this.flush();
254
- return;
255
- }
256
- // Ignore raw ESC only — let ANSI sequences (arrows etc.) fall through
257
- if (ch === "\x1B" && s.length === 1) {
258
- this.inputMode = "none";
259
- this.inputBuf = "";
260
- this.flush();
261
- return;
262
- }
263
- if (ch === "\x7F" || ch === "\b") {
264
- this.inputBuf = this.inputBuf.slice(0, -1);
265
- continue;
266
- }
267
- const code = ch.charCodeAt(0);
268
- if (code >= 0x20 && code <= 0x7E && this.inputBuf.length < MAX_INPUT_LEN) {
269
- this.inputBuf += ch;
283
+ return dirty;
284
+ }
285
+ if (this.inputMode === "steer" || this.inputMode === "ask") {
286
+ let dirty = false;
287
+ for (const ch of s) {
288
+ if (ch === "\r" || ch === "\n") {
289
+ const text = segmentsToString(this.inputSegs).trim();
290
+ const wasAsk = this.inputMode === "ask";
291
+ this.inputMode = "none";
292
+ this.inputSegs = [];
293
+ if (text) {
294
+ if (wasAsk)
295
+ this.onAsk?.(text);
296
+ else
297
+ this.onSteer?.(text);
270
298
  }
299
+ return true;
271
300
  }
272
- this.flush();
273
- return;
274
- }
275
- // Dismiss completed ask panel on Escape
276
- if (s === "\x1B" && this.askState && !this.askState.streaming) {
277
- this.askState = undefined;
278
- return;
279
- }
280
- if (s === "b" || s === "B") {
281
- this.inputMode = "budget";
282
- this.inputBuf = "";
283
- }
284
- else if (s === "t" || s === "T") {
285
- if (this.swarm) {
286
- this.inputMode = "threshold";
287
- this.inputBuf = "";
301
+ if (ch === "\x03") {
302
+ this.inputMode = "none";
303
+ this.inputSegs = [];
304
+ return true;
305
+ }
306
+ // Ignore raw ESC only — let ANSI sequences (arrows etc.) fall through
307
+ if (ch === "\x1B" && s.length === 1) {
308
+ this.inputMode = "none";
309
+ this.inputSegs = [];
310
+ return true;
311
+ }
312
+ if (ch === "\x7F" || ch === "\b") {
313
+ backspaceSegments(this.inputSegs);
314
+ dirty = true;
315
+ continue;
316
+ }
317
+ const code = ch.charCodeAt(0);
318
+ if (code >= 0x20 && code <= 0x7E && segmentsToString(this.inputSegs).length < MAX_INPUT_LEN) {
319
+ appendCharToSegments(this.inputSegs, ch);
320
+ dirty = true;
288
321
  }
289
322
  }
290
- else if ((s === "f" || s === "F") && this.swarm && this.swarm.failed > 0 && this.swarm.active > 0) {
291
- this.swarm.requeueFailed();
292
- }
293
- else if ((s === "s" || s === "S") && this.onSteer) {
294
- this.inputMode = "steer";
295
- this.inputBuf = "";
323
+ return dirty;
324
+ }
325
+ // Hotkey mode
326
+ if (s === "\x1B" && this.askState && !this.askState.streaming) {
327
+ this.askState = undefined;
328
+ return false;
329
+ }
330
+ if (s === "b" || s === "B") {
331
+ this.inputMode = "budget";
332
+ this.inputSegs = [];
333
+ return true;
334
+ }
335
+ if (s === "t" || s === "T") {
336
+ if (this.swarm) {
337
+ this.inputMode = "threshold";
338
+ this.inputSegs = [];
339
+ return true;
296
340
  }
297
- else if (s === "?" && this.onAsk && this.swarm && !this.askBusy) {
298
- // If ask panel is showing a completed answer, dismiss it instead of opening new
299
- if (this.askState && !this.askState.streaming) {
300
- this.askState = undefined;
301
- return;
302
- }
303
- this.inputMode = "ask";
304
- this.inputBuf = "";
341
+ return false;
342
+ }
343
+ if ((s === "f" || s === "F") && this.swarm && this.swarm.failed > 0 && this.swarm.active > 0) {
344
+ this.swarm.requeueFailed();
345
+ return false;
346
+ }
347
+ if ((s === "s" || s === "S") && this.onSteer) {
348
+ this.inputMode = "steer";
349
+ this.inputSegs = [];
350
+ return true;
351
+ }
352
+ if (s === "?" && this.onAsk && this.swarm && !this.askBusy) {
353
+ if (this.askState && !this.askState.streaming) {
354
+ this.askState = undefined;
355
+ return false;
305
356
  }
306
- else if (s === "q" || s === "Q" || s === "\x03") {
307
- if (this.swarm) {
308
- if (this.swarm.aborted)
309
- process.exit(0);
310
- this.swarm.abort();
311
- }
312
- else {
357
+ this.inputMode = "ask";
358
+ this.inputSegs = [];
359
+ return true;
360
+ }
361
+ if (s === "q" || s === "Q" || s === "\x03") {
362
+ if (this.swarm) {
363
+ if (this.swarm.aborted)
313
364
  process.exit(0);
314
- }
365
+ this.swarm.abort();
315
366
  }
316
- };
317
- process.stdin.on("data", this.keyHandler);
367
+ else {
368
+ process.exit(0);
369
+ }
370
+ }
371
+ return false;
318
372
  }
319
373
  plainTick() {
320
374
  if (!this.swarm)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.11.7",
3
+ "version": "1.11.11",
4
4
  "description": "Run 10, 100, or 1000 Claude agents overnight. Parallel autonomous AI coding with thinking waves, iterative quality steering, crash recovery, and rate limit handling. Built on the Claude Agent SDK.",
5
5
  "type": "module",
6
6
  "bin": {