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 +27 -0
- package/dist/cli.js +141 -3
- package/dist/index.js +19 -8
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +51 -22
- package/dist/state.d.ts +13 -0
- package/dist/state.js +68 -1
- package/dist/swarm.js +7 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui.d.ts +5 -1
- package/dist/ui.js +149 -95
- package/package.json +1 -1
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
|
|
73
|
-
|
|
74
|
-
rl
|
|
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
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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 (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
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 -
|
|
219
|
+
gitExec(`git branch -d "${b}"`, cwd);
|
|
220
|
+
cleaned++;
|
|
195
221
|
}
|
|
196
222
|
catch { }
|
|
197
223
|
}
|
|
198
|
-
if (
|
|
199
|
-
log(-1, `Cleaned ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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): ${
|
|
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%): ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
205
|
+
try {
|
|
206
|
+
process.stdout.write("\x1B[?2004h");
|
|
207
|
+
}
|
|
208
|
+
catch { }
|
|
200
209
|
this.keyHandler = (buf) => {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
265
|
+
this.inputSegs = [];
|
|
266
|
+
return true;
|
|
220
267
|
}
|
|
221
|
-
|
|
268
|
+
if (ch === "\x1B" || ch === "\x03") {
|
|
222
269
|
this.inputMode = "none";
|
|
223
|
-
this.
|
|
270
|
+
this.inputSegs = [];
|
|
271
|
+
return true;
|
|
224
272
|
}
|
|
225
|
-
|
|
226
|
-
this.
|
|
273
|
+
if (ch === "\x7F") {
|
|
274
|
+
backspaceSegments(this.inputSegs);
|
|
275
|
+
dirty = true;
|
|
276
|
+
continue;
|
|
227
277
|
}
|
|
228
|
-
|
|
229
|
-
this.
|
|
278
|
+
if (/^[0-9.]$/.test(ch)) {
|
|
279
|
+
appendCharToSegments(this.inputSegs, ch);
|
|
280
|
+
dirty = true;
|
|
230
281
|
}
|
|
231
|
-
this.flush();
|
|
232
|
-
return;
|
|
233
282
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|