claude-overnight 0.1.0
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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +555 -0
- package/dist/planner.d.ts +5 -0
- package/dist/planner.js +246 -0
- package/dist/swarm.d.ts +68 -0
- package/dist/swarm.js +625 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +4 -0
- package/dist/ui.js +282 -0
- package/package.json +49 -0
package/dist/planner.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
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.
|
|
11
|
+
|
|
12
|
+
Objective: ${objective}
|
|
13
|
+
|
|
14
|
+
Requirements:
|
|
15
|
+
- Each task MUST be independent — no task depends on another
|
|
16
|
+
- Each task should target specific files/areas to avoid merge conflicts
|
|
17
|
+
- 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
|
|
20
|
+
|
|
21
|
+
Respond with ONLY a JSON object (no markdown fences):
|
|
22
|
+
{
|
|
23
|
+
"tasks": [
|
|
24
|
+
{ "prompt": "In src/foo.ts, refactor the bar() function to..." },
|
|
25
|
+
{ "prompt": "Add unit tests for the baz module in test/baz.test.ts..." }
|
|
26
|
+
]
|
|
27
|
+
}`,
|
|
28
|
+
options: {
|
|
29
|
+
cwd,
|
|
30
|
+
model,
|
|
31
|
+
tools: ["Read", "Glob", "Grep"],
|
|
32
|
+
allowedTools: ["Read", "Glob", "Grep"],
|
|
33
|
+
permissionMode: permissionMode,
|
|
34
|
+
...(permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
|
|
35
|
+
persistSession: false,
|
|
36
|
+
includePartialMessages: true,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
// Inactivity watchdog — only kills planner if it goes completely silent
|
|
40
|
+
let lastActivity = Date.now();
|
|
41
|
+
let timer;
|
|
42
|
+
const watchdog = new Promise((_, reject) => {
|
|
43
|
+
const check = () => {
|
|
44
|
+
const silent = Date.now() - lastActivity;
|
|
45
|
+
if (silent >= INACTIVITY_MS) {
|
|
46
|
+
plannerQuery.close();
|
|
47
|
+
reject(new Error(`Planner silent for ${Math.round(silent / 1000)}s — assumed hung`));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
timer = setTimeout(check, Math.min(30_000, INACTIVITY_MS - silent + 1000));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
timer = setTimeout(check, INACTIVITY_MS);
|
|
54
|
+
});
|
|
55
|
+
const consume = async () => {
|
|
56
|
+
for await (const msg of plannerQuery) {
|
|
57
|
+
lastActivity = Date.now();
|
|
58
|
+
if (msg.type === "stream_event") {
|
|
59
|
+
const ev = msg.event;
|
|
60
|
+
if (ev?.type === "content_block_start" &&
|
|
61
|
+
ev.content_block?.type === "tool_use") {
|
|
62
|
+
onLog(ev.content_block.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (msg.type === "result") {
|
|
66
|
+
if (msg.subtype === "success") {
|
|
67
|
+
resultText = msg.result || "";
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw new Error(`Planner failed: ${msg.subtype}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
await Promise.race([consume(), watchdog]);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
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)
|
|
105
|
+
const before = tasks.length;
|
|
106
|
+
tasks = tasks.filter((t) => t.prompt && t.prompt.trim().split(/\s+/).length >= 3);
|
|
107
|
+
if (tasks.length < before) {
|
|
108
|
+
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))
|
|
115
|
+
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (dominated.size) {
|
|
135
|
+
tasks = tasks.filter((_, i) => !dominated.has(i));
|
|
136
|
+
onLog(`Deduplicated to ${tasks.length} tasks`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Warn on compound tasks joining unrelated changes with 'and'
|
|
140
|
+
for (const t of tasks) {
|
|
141
|
+
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`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Warn on file overlap between tasks
|
|
148
|
+
const fileRe = /(?:^|\s)((?:[\w.-]+\/)+[\w.-]+\.\w+)/g;
|
|
149
|
+
const pathToTasks = new Map();
|
|
150
|
+
for (const t of tasks) {
|
|
151
|
+
for (const m of t.prompt.matchAll(fileRe)) {
|
|
152
|
+
const ids = pathToTasks.get(m[1]);
|
|
153
|
+
if (ids)
|
|
154
|
+
ids.push(t.id);
|
|
155
|
+
else
|
|
156
|
+
pathToTasks.set(m[1], [t.id]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const [path, ids] of pathToTasks) {
|
|
160
|
+
if (ids.length > 1)
|
|
161
|
+
onLog(`Overlap risk: ${path} in tasks ${ids.join(", ")}`);
|
|
162
|
+
}
|
|
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);
|
|
172
|
+
}
|
|
173
|
+
if (tasks.length === 0)
|
|
174
|
+
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`);
|
|
178
|
+
return tasks;
|
|
179
|
+
}
|
|
180
|
+
/** Find the outermost balanced { } substring. */
|
|
181
|
+
function extractOutermostBraces(text) {
|
|
182
|
+
const start = text.indexOf("{");
|
|
183
|
+
if (start === -1)
|
|
184
|
+
return null;
|
|
185
|
+
let depth = 0;
|
|
186
|
+
for (let i = start; i < text.length; i++) {
|
|
187
|
+
if (text[i] === "{")
|
|
188
|
+
depth++;
|
|
189
|
+
else if (text[i] === "}")
|
|
190
|
+
depth--;
|
|
191
|
+
if (depth === 0)
|
|
192
|
+
return text.slice(start, i + 1);
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/** Try multiple strategies to parse task JSON, with one retry callback. */
|
|
197
|
+
async function extractTaskJson(raw, retry) {
|
|
198
|
+
const attempt = (text) => {
|
|
199
|
+
// 1) Direct parse
|
|
200
|
+
try {
|
|
201
|
+
const obj = JSON.parse(text);
|
|
202
|
+
if (obj?.tasks)
|
|
203
|
+
return obj;
|
|
204
|
+
}
|
|
205
|
+
catch { }
|
|
206
|
+
// 2) Outermost braces
|
|
207
|
+
const braces = extractOutermostBraces(text);
|
|
208
|
+
if (braces) {
|
|
209
|
+
try {
|
|
210
|
+
const obj = JSON.parse(braces);
|
|
211
|
+
if (obj?.tasks)
|
|
212
|
+
return obj;
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
// 3) Strip markdown fences and retry
|
|
217
|
+
const stripped = text.replace(/```json?\s*/g, "").replace(/```/g, "").trim();
|
|
218
|
+
if (stripped !== text) {
|
|
219
|
+
try {
|
|
220
|
+
const obj = JSON.parse(stripped);
|
|
221
|
+
if (obj?.tasks)
|
|
222
|
+
return obj;
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
const braces2 = extractOutermostBraces(stripped);
|
|
226
|
+
if (braces2) {
|
|
227
|
+
try {
|
|
228
|
+
const obj = JSON.parse(braces2);
|
|
229
|
+
if (obj?.tasks)
|
|
230
|
+
return obj;
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
};
|
|
237
|
+
const first = attempt(raw);
|
|
238
|
+
if (first)
|
|
239
|
+
return first;
|
|
240
|
+
// One retry with a shorter prompt
|
|
241
|
+
const retryText = await retry();
|
|
242
|
+
const second = attempt(retryText);
|
|
243
|
+
if (second)
|
|
244
|
+
return second;
|
|
245
|
+
throw new Error("Planner did not return valid task JSON after retry");
|
|
246
|
+
}
|
package/dist/swarm.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Task, AgentState, SwarmPhase, PermMode, MergeStrategy } from "./types.js";
|
|
2
|
+
export interface SwarmConfig {
|
|
3
|
+
tasks: Task[];
|
|
4
|
+
concurrency: number;
|
|
5
|
+
cwd: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
allowedTools?: string[];
|
|
8
|
+
useWorktrees?: boolean;
|
|
9
|
+
permissionMode?: PermMode;
|
|
10
|
+
agentTimeoutMs?: number;
|
|
11
|
+
maxRetries?: number;
|
|
12
|
+
mergeStrategy?: MergeStrategy;
|
|
13
|
+
}
|
|
14
|
+
export interface MergeResult {
|
|
15
|
+
branch: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
autoResolved?: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
filesChanged: number;
|
|
20
|
+
}
|
|
21
|
+
export declare class Swarm {
|
|
22
|
+
readonly agents: AgentState[];
|
|
23
|
+
readonly logs: {
|
|
24
|
+
time: number;
|
|
25
|
+
agentId: number;
|
|
26
|
+
text: string;
|
|
27
|
+
}[];
|
|
28
|
+
private readonly allLogs;
|
|
29
|
+
readonly startedAt: number;
|
|
30
|
+
readonly total: number;
|
|
31
|
+
completed: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
totalCostUsd: number;
|
|
34
|
+
totalInputTokens: number;
|
|
35
|
+
totalOutputTokens: number;
|
|
36
|
+
phase: SwarmPhase;
|
|
37
|
+
aborted: boolean;
|
|
38
|
+
mergeResults: MergeResult[];
|
|
39
|
+
rateLimitUtilization: number;
|
|
40
|
+
rateLimitStatus: string;
|
|
41
|
+
private rateLimitResetsAt?;
|
|
42
|
+
private queue;
|
|
43
|
+
private config;
|
|
44
|
+
private nextId;
|
|
45
|
+
private worktreeBase?;
|
|
46
|
+
private activeQueries;
|
|
47
|
+
private cleanedUp;
|
|
48
|
+
logFile?: string;
|
|
49
|
+
readonly model: string | undefined;
|
|
50
|
+
constructor(config: SwarmConfig);
|
|
51
|
+
get active(): number;
|
|
52
|
+
get pending(): number;
|
|
53
|
+
run(): Promise<void>;
|
|
54
|
+
abort(): void;
|
|
55
|
+
log(agentId: number, text: string): void;
|
|
56
|
+
private worker;
|
|
57
|
+
private throttle;
|
|
58
|
+
private runAgent;
|
|
59
|
+
private autoCommit;
|
|
60
|
+
mergeBranch?: string;
|
|
61
|
+
private mergeAll;
|
|
62
|
+
cleanup(): void;
|
|
63
|
+
private warnDirtyTree;
|
|
64
|
+
private cleanStaleWorktrees;
|
|
65
|
+
private writeLogFile;
|
|
66
|
+
private agentSummary;
|
|
67
|
+
private handleMsg;
|
|
68
|
+
}
|