@neotx/core 0.1.0-alpha.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/dist/index.d.ts +1359 -0
- package/dist/index.js +3190 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3190 @@
|
|
|
1
|
+
// src/agents/loader.ts
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
|
|
6
|
+
// src/agents/schema.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
var agentModelSchema = z.enum(["opus", "sonnet", "haiku"]);
|
|
9
|
+
var agentToolSchema = z.enum([
|
|
10
|
+
"Read",
|
|
11
|
+
"Write",
|
|
12
|
+
"Edit",
|
|
13
|
+
"Bash",
|
|
14
|
+
"Glob",
|
|
15
|
+
"Grep",
|
|
16
|
+
"Agent",
|
|
17
|
+
"WebSearch",
|
|
18
|
+
"WebFetch",
|
|
19
|
+
"NotebookEdit"
|
|
20
|
+
]);
|
|
21
|
+
var agentToolEntrySchema = z.union([agentToolSchema, z.literal("$inherited")]);
|
|
22
|
+
var agentSandboxSchema = z.enum(["writable", "readonly"]);
|
|
23
|
+
var agentConfigSchema = z.object({
|
|
24
|
+
name: z.string(),
|
|
25
|
+
extends: z.string().optional(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
model: agentModelSchema.optional(),
|
|
28
|
+
tools: z.array(agentToolEntrySchema).optional(),
|
|
29
|
+
prompt: z.string().optional(),
|
|
30
|
+
promptAppend: z.string().optional(),
|
|
31
|
+
sandbox: agentSandboxSchema.optional(),
|
|
32
|
+
maxTurns: z.number().optional(),
|
|
33
|
+
mcpServers: z.array(z.string()).optional()
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// src/agents/loader.ts
|
|
37
|
+
async function loadAgentFile(filePath) {
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = await readFile(filePath, "utf-8");
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`Agent file not found: ${filePath}`);
|
|
43
|
+
}
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = parseYaml(raw);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Invalid YAML in agent file ${filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const result = agentConfigSchema.safeParse(parsed);
|
|
53
|
+
if (!result.success) {
|
|
54
|
+
const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
55
|
+
throw new Error(`Invalid agent config in ${filePath}:
|
|
56
|
+
${issues}`);
|
|
57
|
+
}
|
|
58
|
+
const config = result.data;
|
|
59
|
+
if (config.prompt?.endsWith(".md")) {
|
|
60
|
+
const promptPath = path.resolve(path.dirname(filePath), config.prompt);
|
|
61
|
+
try {
|
|
62
|
+
config.prompt = await readFile(promptPath, "utf-8");
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Prompt file not found: ${promptPath} (referenced in ${filePath})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/agents/registry.ts
|
|
71
|
+
import { readdir } from "fs/promises";
|
|
72
|
+
import path2 from "path";
|
|
73
|
+
|
|
74
|
+
// src/agents/resolver.ts
|
|
75
|
+
function resolveAgent(config, builtIns) {
|
|
76
|
+
const extendsName = config.extends ?? (builtIns.has(config.name) && config.extends === void 0 ? config.name : void 0);
|
|
77
|
+
const isExtending = extendsName !== void 0;
|
|
78
|
+
if (isExtending) {
|
|
79
|
+
const base = builtIns.get(extendsName);
|
|
80
|
+
if (!base) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Agent "${config.name}" extends "${extendsName}", but no built-in agent with that name exists.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
let tools2;
|
|
86
|
+
if (config.tools) {
|
|
87
|
+
if (config.tools.includes("$inherited")) {
|
|
88
|
+
const baseTols = base.tools ?? [];
|
|
89
|
+
const newTools = config.tools.filter((t) => t !== "$inherited");
|
|
90
|
+
tools2 = [...baseTols, ...newTools];
|
|
91
|
+
} else {
|
|
92
|
+
tools2 = config.tools;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
tools2 = base.tools ?? [];
|
|
96
|
+
}
|
|
97
|
+
let prompt2;
|
|
98
|
+
if (config.prompt) {
|
|
99
|
+
prompt2 = config.prompt;
|
|
100
|
+
} else {
|
|
101
|
+
prompt2 = base.prompt ?? "";
|
|
102
|
+
}
|
|
103
|
+
if (config.promptAppend) {
|
|
104
|
+
prompt2 = `${prompt2}
|
|
105
|
+
|
|
106
|
+
${config.promptAppend}`;
|
|
107
|
+
}
|
|
108
|
+
const definition2 = {
|
|
109
|
+
description: config.description ?? base.description ?? "",
|
|
110
|
+
prompt: prompt2,
|
|
111
|
+
tools: tools2,
|
|
112
|
+
model: config.model ?? base.model ?? "sonnet"
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
name: config.name,
|
|
116
|
+
definition: definition2,
|
|
117
|
+
sandbox: config.sandbox ?? base.sandbox ?? "readonly",
|
|
118
|
+
...config.maxTurns !== void 0 ? { maxTurns: config.maxTurns } : base.maxTurns !== void 0 ? { maxTurns: base.maxTurns } : {},
|
|
119
|
+
source: config.name === extendsName && !config.extends ? "built-in" : "extended"
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!config.description) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Agent "${config.name}" has no "extends" and no "description". Add a 'description' field to the agent YAML.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!config.model) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Agent "${config.name}" has no "extends" and no "model". Add a 'model' field (e.g., 'claude-sonnet-4-20250514').`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!config.tools) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Agent "${config.name}" has no "extends" and no "tools". Add a 'tools' array to the agent YAML.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!config.sandbox) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Agent "${config.name}" has no "extends" and no "sandbox". Add a 'sandbox' field ('full' or 'permissive').`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!config.prompt) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Agent "${config.name}" has no "extends" and no "prompt". Add a 'prompt' field or 'promptFile' reference.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const tools = config.tools.filter((t) => t !== "$inherited");
|
|
148
|
+
let prompt = config.prompt;
|
|
149
|
+
if (config.promptAppend) {
|
|
150
|
+
prompt = `${prompt}
|
|
151
|
+
|
|
152
|
+
${config.promptAppend}`;
|
|
153
|
+
}
|
|
154
|
+
const definition = {
|
|
155
|
+
description: config.description,
|
|
156
|
+
prompt,
|
|
157
|
+
tools,
|
|
158
|
+
model: config.model
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
name: config.name,
|
|
162
|
+
definition,
|
|
163
|
+
sandbox: config.sandbox,
|
|
164
|
+
...config.maxTurns !== void 0 ? { maxTurns: config.maxTurns } : {},
|
|
165
|
+
source: "custom"
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/agents/registry.ts
|
|
170
|
+
var AgentRegistry = class {
|
|
171
|
+
builtInDir;
|
|
172
|
+
customDir;
|
|
173
|
+
agents = /* @__PURE__ */ new Map();
|
|
174
|
+
constructor(builtInDir, customDir) {
|
|
175
|
+
this.builtInDir = builtInDir;
|
|
176
|
+
this.customDir = customDir;
|
|
177
|
+
}
|
|
178
|
+
async load() {
|
|
179
|
+
this.agents.clear();
|
|
180
|
+
const builtInConfigs = await loadAgentsFromDir(this.builtInDir);
|
|
181
|
+
const builtInMap = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const config of builtInConfigs) {
|
|
183
|
+
builtInMap.set(config.name, config);
|
|
184
|
+
}
|
|
185
|
+
for (const config of builtInConfigs) {
|
|
186
|
+
const resolved = resolveAgent(config, builtInMap);
|
|
187
|
+
this.agents.set(config.name, { ...resolved, source: "built-in" });
|
|
188
|
+
}
|
|
189
|
+
if (this.customDir) {
|
|
190
|
+
let customConfigs;
|
|
191
|
+
try {
|
|
192
|
+
customConfigs = await loadAgentsFromDir(this.customDir);
|
|
193
|
+
} catch {
|
|
194
|
+
customConfigs = [];
|
|
195
|
+
}
|
|
196
|
+
for (const config of customConfigs) {
|
|
197
|
+
const resolved = resolveAgent(config, builtInMap);
|
|
198
|
+
this.agents.set(config.name, resolved);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
get(name) {
|
|
203
|
+
return this.agents.get(name);
|
|
204
|
+
}
|
|
205
|
+
list() {
|
|
206
|
+
return [...this.agents.values()];
|
|
207
|
+
}
|
|
208
|
+
has(name) {
|
|
209
|
+
return this.agents.has(name);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
async function loadAgentsFromDir(dir) {
|
|
213
|
+
const entries = await readdir(dir);
|
|
214
|
+
const ymlFiles = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
215
|
+
const configs = [];
|
|
216
|
+
for (const file of ymlFiles) {
|
|
217
|
+
const config = await loadAgentFile(path2.join(dir, file));
|
|
218
|
+
configs.push(config);
|
|
219
|
+
}
|
|
220
|
+
return configs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/concurrency/queue.ts
|
|
224
|
+
var PRIORITY_ORDER = {
|
|
225
|
+
critical: 0,
|
|
226
|
+
high: 1,
|
|
227
|
+
medium: 2,
|
|
228
|
+
low: 3
|
|
229
|
+
};
|
|
230
|
+
var PriorityQueue = class {
|
|
231
|
+
items = [];
|
|
232
|
+
maxSize;
|
|
233
|
+
insertionCounter = 0;
|
|
234
|
+
constructor(maxSize) {
|
|
235
|
+
this.maxSize = maxSize;
|
|
236
|
+
}
|
|
237
|
+
enqueue(value, priority) {
|
|
238
|
+
if (this.items.length === 0) {
|
|
239
|
+
this.insertionCounter = 0;
|
|
240
|
+
}
|
|
241
|
+
if (this.items.length >= this.maxSize) {
|
|
242
|
+
throw new Error(`Queue full (${this.maxSize} items). Cannot enqueue.`);
|
|
243
|
+
}
|
|
244
|
+
const item = {
|
|
245
|
+
value,
|
|
246
|
+
priority,
|
|
247
|
+
insertionOrder: this.insertionCounter++
|
|
248
|
+
};
|
|
249
|
+
let inserted = false;
|
|
250
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
251
|
+
const existing = this.items[i];
|
|
252
|
+
if (existing && this.comparePriority(item, existing) < 0) {
|
|
253
|
+
this.items.splice(i, 0, item);
|
|
254
|
+
inserted = true;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!inserted) {
|
|
259
|
+
this.items.push(item);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
dequeue() {
|
|
263
|
+
const item = this.items.shift();
|
|
264
|
+
return item?.value;
|
|
265
|
+
}
|
|
266
|
+
peek() {
|
|
267
|
+
return this.items[0]?.value;
|
|
268
|
+
}
|
|
269
|
+
get size() {
|
|
270
|
+
return this.items.length;
|
|
271
|
+
}
|
|
272
|
+
get isEmpty() {
|
|
273
|
+
return this.items.length === 0;
|
|
274
|
+
}
|
|
275
|
+
remove(predicate) {
|
|
276
|
+
const index = this.items.findIndex((entry) => predicate(entry.value));
|
|
277
|
+
if (index === -1) return false;
|
|
278
|
+
this.items.splice(index, 1);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
/** Dequeue the first item matching the predicate (respects priority order). */
|
|
282
|
+
dequeueWhere(predicate) {
|
|
283
|
+
const index = this.items.findIndex((entry) => predicate(entry.value));
|
|
284
|
+
if (index === -1) return void 0;
|
|
285
|
+
const removed = this.items.splice(index, 1)[0];
|
|
286
|
+
if (!removed) return void 0;
|
|
287
|
+
return removed.value;
|
|
288
|
+
}
|
|
289
|
+
/** Compare by priority first, then by insertion order (FIFO within same priority). */
|
|
290
|
+
comparePriority(a, b) {
|
|
291
|
+
const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
|
292
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
293
|
+
return a.insertionOrder - b.insertionOrder;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// src/concurrency/semaphore.ts
|
|
298
|
+
var Semaphore = class {
|
|
299
|
+
maxSessions;
|
|
300
|
+
maxPerRepo;
|
|
301
|
+
queue;
|
|
302
|
+
callbacks;
|
|
303
|
+
// sessionId → repo
|
|
304
|
+
activeSessions = /* @__PURE__ */ new Map();
|
|
305
|
+
// repo → count
|
|
306
|
+
repoCounts = /* @__PURE__ */ new Map();
|
|
307
|
+
constructor(config, callbacks = {}) {
|
|
308
|
+
this.maxSessions = config.maxSessions;
|
|
309
|
+
this.maxPerRepo = config.maxPerRepo;
|
|
310
|
+
this.queue = new PriorityQueue(config.queueMax ?? 50);
|
|
311
|
+
this.callbacks = callbacks;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Acquire a slot. Blocks (via promise) if at capacity.
|
|
315
|
+
* Throws if the queue is full.
|
|
316
|
+
*/
|
|
317
|
+
async acquire(repo, sessionId, priority = "medium", signal) {
|
|
318
|
+
if (signal?.aborted) {
|
|
319
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
320
|
+
}
|
|
321
|
+
if (this.canAcquire(repo)) {
|
|
322
|
+
this.allocate(repo, sessionId);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
return new Promise((resolve4, reject) => {
|
|
326
|
+
const entry = {
|
|
327
|
+
sessionId,
|
|
328
|
+
repo,
|
|
329
|
+
resolve: resolve4,
|
|
330
|
+
reject,
|
|
331
|
+
enqueuedAt: Date.now()
|
|
332
|
+
};
|
|
333
|
+
this.queue.enqueue(entry, priority);
|
|
334
|
+
this.callbacks.onEnqueue?.(sessionId, repo, this.queue.size);
|
|
335
|
+
if (signal) {
|
|
336
|
+
const onAbort = () => {
|
|
337
|
+
this.queue.remove((e) => e === entry);
|
|
338
|
+
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
339
|
+
};
|
|
340
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
341
|
+
entry._cleanupAbort = () => signal.removeEventListener("abort", onAbort);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/** Release a slot and process the next waiting entry. */
|
|
346
|
+
release(sessionId) {
|
|
347
|
+
const repo = this.activeSessions.get(sessionId);
|
|
348
|
+
if (!repo) return;
|
|
349
|
+
this.activeSessions.delete(sessionId);
|
|
350
|
+
const count = this.repoCounts.get(repo) ?? 0;
|
|
351
|
+
if (count <= 1) {
|
|
352
|
+
this.repoCounts.delete(repo);
|
|
353
|
+
} else {
|
|
354
|
+
this.repoCounts.set(repo, count - 1);
|
|
355
|
+
}
|
|
356
|
+
this.processQueue();
|
|
357
|
+
}
|
|
358
|
+
/** Non-blocking attempt to acquire a slot. Returns true if successful. */
|
|
359
|
+
tryAcquire(repo, sessionId) {
|
|
360
|
+
if (!this.canAcquire(repo)) return false;
|
|
361
|
+
this.allocate(repo, sessionId);
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
/** Total number of active slots. */
|
|
365
|
+
activeCount() {
|
|
366
|
+
return this.activeSessions.size;
|
|
367
|
+
}
|
|
368
|
+
/** Number of active slots for a specific repo. */
|
|
369
|
+
activeCountForRepo(repo) {
|
|
370
|
+
return this.repoCounts.get(repo) ?? 0;
|
|
371
|
+
}
|
|
372
|
+
/** Can a slot be acquired for this repo without blocking? */
|
|
373
|
+
isAvailable(repo) {
|
|
374
|
+
return this.canAcquire(repo);
|
|
375
|
+
}
|
|
376
|
+
/** Current queue depth. */
|
|
377
|
+
queueDepth() {
|
|
378
|
+
return this.queue.size;
|
|
379
|
+
}
|
|
380
|
+
canAcquire(repo) {
|
|
381
|
+
if (this.activeSessions.size >= this.maxSessions) return false;
|
|
382
|
+
const repoCount = this.repoCounts.get(repo) ?? 0;
|
|
383
|
+
return repoCount < this.maxPerRepo;
|
|
384
|
+
}
|
|
385
|
+
allocate(repo, sessionId) {
|
|
386
|
+
this.activeSessions.set(sessionId, repo);
|
|
387
|
+
this.repoCounts.set(repo, (this.repoCounts.get(repo) ?? 0) + 1);
|
|
388
|
+
}
|
|
389
|
+
processQueue() {
|
|
390
|
+
const entry = this.queue.dequeueWhere((e) => this.canAcquire(e.repo));
|
|
391
|
+
if (!entry) return;
|
|
392
|
+
this.allocate(entry.repo, entry.sessionId);
|
|
393
|
+
entry._cleanupAbort?.();
|
|
394
|
+
const waitedMs = Date.now() - entry.enqueuedAt;
|
|
395
|
+
this.callbacks.onDequeue?.(entry.sessionId, entry.repo, waitedMs);
|
|
396
|
+
entry.resolve();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// src/config.ts
|
|
401
|
+
import { existsSync } from "fs";
|
|
402
|
+
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
403
|
+
import path4 from "path";
|
|
404
|
+
import { parse as parseYaml2, stringify as stringifyYaml } from "yaml";
|
|
405
|
+
import { z as z2 } from "zod";
|
|
406
|
+
|
|
407
|
+
// src/paths.ts
|
|
408
|
+
import { homedir } from "os";
|
|
409
|
+
import path3 from "path";
|
|
410
|
+
function getDataDir() {
|
|
411
|
+
return path3.join(homedir(), ".neo");
|
|
412
|
+
}
|
|
413
|
+
function getJournalsDir() {
|
|
414
|
+
return path3.join(getDataDir(), "journals");
|
|
415
|
+
}
|
|
416
|
+
function getRunsDir() {
|
|
417
|
+
return path3.join(getDataDir(), "runs");
|
|
418
|
+
}
|
|
419
|
+
function toRepoSlug(repo) {
|
|
420
|
+
const raw = repo.name ?? path3.basename(repo.path);
|
|
421
|
+
return raw.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
422
|
+
}
|
|
423
|
+
function getRepoRunsDir(repoSlug) {
|
|
424
|
+
return path3.join(getRunsDir(), repoSlug);
|
|
425
|
+
}
|
|
426
|
+
function getRunDispatchPath(repoSlug, runId) {
|
|
427
|
+
return path3.join(getRepoRunsDir(repoSlug), `${runId}.dispatch.json`);
|
|
428
|
+
}
|
|
429
|
+
function getRunLogPath(repoSlug, runId) {
|
|
430
|
+
return path3.join(getRepoRunsDir(repoSlug), `${runId}.log`);
|
|
431
|
+
}
|
|
432
|
+
function getSupervisorsDir() {
|
|
433
|
+
return path3.join(getDataDir(), "supervisors");
|
|
434
|
+
}
|
|
435
|
+
function getSupervisorDir(name) {
|
|
436
|
+
return path3.join(getSupervisorsDir(), name);
|
|
437
|
+
}
|
|
438
|
+
function getSupervisorStatePath(name) {
|
|
439
|
+
return path3.join(getSupervisorDir(name), "state.json");
|
|
440
|
+
}
|
|
441
|
+
function getSupervisorMemoryPath(name) {
|
|
442
|
+
return path3.join(getSupervisorDir(name), "memory.md");
|
|
443
|
+
}
|
|
444
|
+
function getSupervisorActivityPath(name) {
|
|
445
|
+
return path3.join(getSupervisorDir(name), "activity.jsonl");
|
|
446
|
+
}
|
|
447
|
+
function getSupervisorInboxPath(name) {
|
|
448
|
+
return path3.join(getSupervisorDir(name), "inbox.jsonl");
|
|
449
|
+
}
|
|
450
|
+
function getSupervisorEventsPath(name) {
|
|
451
|
+
return path3.join(getSupervisorDir(name), "events.jsonl");
|
|
452
|
+
}
|
|
453
|
+
function getSupervisorLockPath(name) {
|
|
454
|
+
return path3.join(getSupervisorDir(name), "daemon.lock");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/config.ts
|
|
458
|
+
var httpMcpServerSchema = z2.object({
|
|
459
|
+
type: z2.literal("http"),
|
|
460
|
+
url: z2.string(),
|
|
461
|
+
headers: z2.record(z2.string(), z2.string()).optional()
|
|
462
|
+
});
|
|
463
|
+
var stdioMcpServerSchema = z2.object({
|
|
464
|
+
type: z2.literal("stdio"),
|
|
465
|
+
command: z2.string(),
|
|
466
|
+
args: z2.array(z2.string()).optional(),
|
|
467
|
+
env: z2.record(z2.string(), z2.string()).optional()
|
|
468
|
+
});
|
|
469
|
+
var mcpServerConfigSchema = z2.discriminatedUnion("type", [
|
|
470
|
+
httpMcpServerSchema,
|
|
471
|
+
stdioMcpServerSchema
|
|
472
|
+
]);
|
|
473
|
+
var repoConfigSchema = z2.object({
|
|
474
|
+
path: z2.string(),
|
|
475
|
+
name: z2.string().optional(),
|
|
476
|
+
defaultBranch: z2.string().default("main"),
|
|
477
|
+
branchPrefix: z2.string().default("feat"),
|
|
478
|
+
pushRemote: z2.string().default("origin"),
|
|
479
|
+
autoCreatePr: z2.boolean().default(false),
|
|
480
|
+
prBaseBranch: z2.string().optional()
|
|
481
|
+
});
|
|
482
|
+
var globalConfigSchema = z2.object({
|
|
483
|
+
repos: z2.array(repoConfigSchema).default([]),
|
|
484
|
+
concurrency: z2.object({
|
|
485
|
+
maxSessions: z2.number().default(5),
|
|
486
|
+
maxPerRepo: z2.number().default(2),
|
|
487
|
+
queueMax: z2.number().default(50)
|
|
488
|
+
}).default({ maxSessions: 5, maxPerRepo: 2, queueMax: 50 }),
|
|
489
|
+
budget: z2.object({
|
|
490
|
+
dailyCapUsd: z2.number().default(500),
|
|
491
|
+
alertThresholdPct: z2.number().default(80)
|
|
492
|
+
}).default({ dailyCapUsd: 500, alertThresholdPct: 80 }),
|
|
493
|
+
recovery: z2.object({
|
|
494
|
+
maxRetries: z2.number().default(3),
|
|
495
|
+
backoffBaseMs: z2.number().default(3e4)
|
|
496
|
+
}).default({ maxRetries: 3, backoffBaseMs: 3e4 }),
|
|
497
|
+
sessions: z2.object({
|
|
498
|
+
initTimeoutMs: z2.number().default(12e4),
|
|
499
|
+
maxDurationMs: z2.number().default(36e5)
|
|
500
|
+
}).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5 }),
|
|
501
|
+
webhooks: z2.array(
|
|
502
|
+
z2.object({
|
|
503
|
+
url: z2.string().url(),
|
|
504
|
+
events: z2.array(z2.string()).optional(),
|
|
505
|
+
secret: z2.string().optional(),
|
|
506
|
+
timeoutMs: z2.number().default(5e3)
|
|
507
|
+
})
|
|
508
|
+
).default([]),
|
|
509
|
+
supervisor: z2.object({
|
|
510
|
+
port: z2.number().default(7777),
|
|
511
|
+
secret: z2.string().optional(),
|
|
512
|
+
idleIntervalMs: z2.number().default(6e4),
|
|
513
|
+
heartbeatTimeoutMs: z2.number().default(3e5),
|
|
514
|
+
maxConsecutiveFailures: z2.number().default(3),
|
|
515
|
+
maxEventsPerSec: z2.number().default(10),
|
|
516
|
+
dailyCapUsd: z2.number().default(50),
|
|
517
|
+
instructions: z2.string().optional()
|
|
518
|
+
}).default({
|
|
519
|
+
port: 7777,
|
|
520
|
+
idleIntervalMs: 6e4,
|
|
521
|
+
heartbeatTimeoutMs: 3e5,
|
|
522
|
+
maxConsecutiveFailures: 3,
|
|
523
|
+
maxEventsPerSec: 10,
|
|
524
|
+
dailyCapUsd: 50
|
|
525
|
+
}),
|
|
526
|
+
mcpServers: z2.record(z2.string(), mcpServerConfigSchema).optional(),
|
|
527
|
+
claudeCodePath: z2.string().optional(),
|
|
528
|
+
idempotency: z2.object({
|
|
529
|
+
enabled: z2.boolean().default(true),
|
|
530
|
+
key: z2.enum(["metadata", "prompt"]).default("metadata"),
|
|
531
|
+
ttlMs: z2.number().default(36e5)
|
|
532
|
+
}).optional()
|
|
533
|
+
});
|
|
534
|
+
var neoConfigSchema = globalConfigSchema;
|
|
535
|
+
var DEFAULT_GLOBAL_CONFIG = {
|
|
536
|
+
repos: [],
|
|
537
|
+
concurrency: {
|
|
538
|
+
maxSessions: 5,
|
|
539
|
+
maxPerRepo: 2,
|
|
540
|
+
queueMax: 50
|
|
541
|
+
},
|
|
542
|
+
budget: {
|
|
543
|
+
dailyCapUsd: 500,
|
|
544
|
+
alertThresholdPct: 80
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
function parseYamlFile(raw, filePath) {
|
|
548
|
+
try {
|
|
549
|
+
return parseYaml2(raw);
|
|
550
|
+
} catch (err) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Invalid YAML in ${filePath}: ${err instanceof Error ? err.message : String(err)}. Check YAML syntax at the indicated line.`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function formatZodErrors(issues, filePath) {
|
|
557
|
+
const formatted = issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
558
|
+
return `Invalid config in ${filePath}:
|
|
559
|
+
${formatted}`;
|
|
560
|
+
}
|
|
561
|
+
async function loadConfig(configPath) {
|
|
562
|
+
let raw;
|
|
563
|
+
try {
|
|
564
|
+
raw = await readFile2(configPath, "utf-8");
|
|
565
|
+
} catch {
|
|
566
|
+
throw new Error(`Config file not found: ${configPath}. Run 'neo init' to get started.`);
|
|
567
|
+
}
|
|
568
|
+
const parsed = parseYamlFile(raw, configPath);
|
|
569
|
+
const result = neoConfigSchema.safeParse(parsed);
|
|
570
|
+
if (!result.success) {
|
|
571
|
+
throw new Error(formatZodErrors(result.error.issues, configPath));
|
|
572
|
+
}
|
|
573
|
+
return result.data;
|
|
574
|
+
}
|
|
575
|
+
async function loadGlobalConfig() {
|
|
576
|
+
const configPath = path4.join(getDataDir(), "config.yml");
|
|
577
|
+
if (!existsSync(configPath)) {
|
|
578
|
+
await mkdir(getDataDir(), { recursive: true });
|
|
579
|
+
await writeFile(configPath, stringifyYaml(DEFAULT_GLOBAL_CONFIG), "utf-8");
|
|
580
|
+
return globalConfigSchema.parse(DEFAULT_GLOBAL_CONFIG);
|
|
581
|
+
}
|
|
582
|
+
const raw = await readFile2(configPath, "utf-8");
|
|
583
|
+
const parsed = parseYamlFile(raw, configPath);
|
|
584
|
+
const result = globalConfigSchema.safeParse(parsed);
|
|
585
|
+
if (!result.success) {
|
|
586
|
+
throw new Error(formatZodErrors(result.error.issues, configPath));
|
|
587
|
+
}
|
|
588
|
+
return result.data;
|
|
589
|
+
}
|
|
590
|
+
async function addRepoToGlobalConfig(repo) {
|
|
591
|
+
const config = await loadGlobalConfig();
|
|
592
|
+
const resolvedPath = path4.resolve(repo.path);
|
|
593
|
+
const parsed = repoConfigSchema.parse({ ...repo, path: resolvedPath });
|
|
594
|
+
const existing = config.repos.findIndex((r) => path4.resolve(r.path) === resolvedPath);
|
|
595
|
+
if (existing >= 0) {
|
|
596
|
+
config.repos[existing] = parsed;
|
|
597
|
+
} else {
|
|
598
|
+
config.repos.push(parsed);
|
|
599
|
+
}
|
|
600
|
+
const configPath = path4.join(getDataDir(), "config.yml");
|
|
601
|
+
await writeFile(configPath, stringifyYaml(config), "utf-8");
|
|
602
|
+
}
|
|
603
|
+
async function removeRepoFromGlobalConfig(pathOrName) {
|
|
604
|
+
const config = await loadGlobalConfig();
|
|
605
|
+
const resolvedPath = path4.resolve(pathOrName);
|
|
606
|
+
const initialLength = config.repos.length;
|
|
607
|
+
config.repos = config.repos.filter(
|
|
608
|
+
(r) => path4.resolve(r.path) !== resolvedPath && r.name !== pathOrName && toRepoSlug(r) !== pathOrName
|
|
609
|
+
);
|
|
610
|
+
if (config.repos.length === initialLength) return false;
|
|
611
|
+
const configPath = path4.join(getDataDir(), "config.yml");
|
|
612
|
+
await writeFile(configPath, stringifyYaml(config), "utf-8");
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
async function listReposFromGlobalConfig() {
|
|
616
|
+
const config = await loadGlobalConfig();
|
|
617
|
+
return config.repos;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/cost/journal.ts
|
|
621
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
|
|
622
|
+
import path5 from "path";
|
|
623
|
+
var CostJournal = class {
|
|
624
|
+
dir;
|
|
625
|
+
dirCreated = false;
|
|
626
|
+
dayCache = null;
|
|
627
|
+
constructor(options) {
|
|
628
|
+
this.dir = options.dir;
|
|
629
|
+
}
|
|
630
|
+
async append(entry) {
|
|
631
|
+
await this.ensureDir();
|
|
632
|
+
const file = this.fileForDate(new Date(entry.timestamp));
|
|
633
|
+
await appendFile(file, `${JSON.stringify(entry)}
|
|
634
|
+
`, "utf-8");
|
|
635
|
+
this.dayCache = null;
|
|
636
|
+
}
|
|
637
|
+
async getDayTotal(date) {
|
|
638
|
+
const d = date ?? /* @__PURE__ */ new Date();
|
|
639
|
+
const dayKey = toDateKey(d);
|
|
640
|
+
if (this.dayCache?.key === dayKey) {
|
|
641
|
+
return this.dayCache.total;
|
|
642
|
+
}
|
|
643
|
+
const file = this.fileForDate(d);
|
|
644
|
+
let total = 0;
|
|
645
|
+
try {
|
|
646
|
+
const content = await readFile3(file, "utf-8");
|
|
647
|
+
for (const line of content.split("\n")) {
|
|
648
|
+
if (!line.trim()) continue;
|
|
649
|
+
const entry = JSON.parse(line);
|
|
650
|
+
if (toDateKey(new Date(entry.timestamp)) === dayKey) {
|
|
651
|
+
total += entry.costUsd;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (error.code !== "ENOENT") throw error;
|
|
656
|
+
}
|
|
657
|
+
this.dayCache = { key: dayKey, total };
|
|
658
|
+
return total;
|
|
659
|
+
}
|
|
660
|
+
fileForDate(date) {
|
|
661
|
+
const yyyy = date.getUTCFullYear();
|
|
662
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
663
|
+
return path5.join(this.dir, `cost-${yyyy}-${mm}.jsonl`);
|
|
664
|
+
}
|
|
665
|
+
async ensureDir() {
|
|
666
|
+
if (this.dirCreated) return;
|
|
667
|
+
await mkdir2(this.dir, { recursive: true });
|
|
668
|
+
this.dirCreated = true;
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
function toDateKey(date) {
|
|
672
|
+
return date.toISOString().slice(0, 10);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/events/emitter.ts
|
|
676
|
+
import { EventEmitter } from "events";
|
|
677
|
+
var NeoEventEmitter = class {
|
|
678
|
+
emitter = new EventEmitter();
|
|
679
|
+
emit(event) {
|
|
680
|
+
this.safeEmit(event.type, event);
|
|
681
|
+
this.safeEmit("*", event);
|
|
682
|
+
}
|
|
683
|
+
on(eventType, listener) {
|
|
684
|
+
this.emitter.on(eventType, listener);
|
|
685
|
+
return this;
|
|
686
|
+
}
|
|
687
|
+
off(eventType, listener) {
|
|
688
|
+
this.emitter.off(eventType, listener);
|
|
689
|
+
return this;
|
|
690
|
+
}
|
|
691
|
+
once(eventType, listener) {
|
|
692
|
+
this.emitter.once(eventType, listener);
|
|
693
|
+
return this;
|
|
694
|
+
}
|
|
695
|
+
removeAllListeners(eventType) {
|
|
696
|
+
this.emitter.removeAllListeners(eventType);
|
|
697
|
+
return this;
|
|
698
|
+
}
|
|
699
|
+
safeEmit(eventType, event) {
|
|
700
|
+
try {
|
|
701
|
+
this.emitter.emit(eventType, event);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (eventType !== "error") {
|
|
704
|
+
try {
|
|
705
|
+
this.emitter.emit("error", error);
|
|
706
|
+
} catch {
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// src/events/journal.ts
|
|
714
|
+
import { appendFile as appendFile2, mkdir as mkdir3 } from "fs/promises";
|
|
715
|
+
import path6 from "path";
|
|
716
|
+
var EventJournal = class {
|
|
717
|
+
dir;
|
|
718
|
+
dirCreated = false;
|
|
719
|
+
constructor(options) {
|
|
720
|
+
this.dir = options.dir;
|
|
721
|
+
}
|
|
722
|
+
async append(event) {
|
|
723
|
+
await this.ensureDir();
|
|
724
|
+
const file = this.fileForDate(new Date(event.timestamp));
|
|
725
|
+
await appendFile2(file, `${JSON.stringify(event)}
|
|
726
|
+
`, "utf-8");
|
|
727
|
+
}
|
|
728
|
+
fileForDate(date) {
|
|
729
|
+
const yyyy = date.getUTCFullYear();
|
|
730
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
731
|
+
return path6.join(this.dir, `events-${yyyy}-${mm}.jsonl`);
|
|
732
|
+
}
|
|
733
|
+
async ensureDir() {
|
|
734
|
+
if (this.dirCreated) return;
|
|
735
|
+
await mkdir3(this.dir, { recursive: true });
|
|
736
|
+
this.dirCreated = true;
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// src/events/webhook.ts
|
|
741
|
+
import { createHmac } from "crypto";
|
|
742
|
+
var WebhookDispatcher = class {
|
|
743
|
+
webhooks;
|
|
744
|
+
constructor(webhooks) {
|
|
745
|
+
this.webhooks = webhooks;
|
|
746
|
+
}
|
|
747
|
+
dispatch(event) {
|
|
748
|
+
if (event.type === "gate:waiting") return;
|
|
749
|
+
for (const webhook of this.webhooks) {
|
|
750
|
+
if (!matchesFilter(event.type, webhook.events)) continue;
|
|
751
|
+
const payload = {
|
|
752
|
+
version: 1,
|
|
753
|
+
event: toSerializable(event),
|
|
754
|
+
source: "neo",
|
|
755
|
+
deliveredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
756
|
+
};
|
|
757
|
+
const body = JSON.stringify(payload);
|
|
758
|
+
const headers = {
|
|
759
|
+
"Content-Type": "application/json"
|
|
760
|
+
};
|
|
761
|
+
if (webhook.secret) {
|
|
762
|
+
headers["X-Neo-Signature"] = sign(body, webhook.secret);
|
|
763
|
+
}
|
|
764
|
+
fetch(webhook.url, {
|
|
765
|
+
method: "POST",
|
|
766
|
+
headers,
|
|
767
|
+
body,
|
|
768
|
+
signal: AbortSignal.timeout(webhook.timeoutMs)
|
|
769
|
+
}).catch(() => {
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
function matchesFilter(eventType, filters) {
|
|
775
|
+
if (!filters || filters.length === 0) return true;
|
|
776
|
+
return filters.some((f) => {
|
|
777
|
+
if (f.endsWith(":*")) return eventType.startsWith(f.slice(0, -1));
|
|
778
|
+
return f === eventType;
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function sign(body, secret) {
|
|
782
|
+
return createHmac("sha256", secret).update(body).digest("hex");
|
|
783
|
+
}
|
|
784
|
+
function toSerializable(event) {
|
|
785
|
+
const obj = {};
|
|
786
|
+
for (const [key, value] of Object.entries(event)) {
|
|
787
|
+
if (typeof value !== "function") {
|
|
788
|
+
obj[key] = value;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return obj;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/isolation/git.ts
|
|
795
|
+
import { execFile } from "child_process";
|
|
796
|
+
import { resolve } from "path";
|
|
797
|
+
import { promisify } from "util";
|
|
798
|
+
|
|
799
|
+
// src/isolation/git-mutex.ts
|
|
800
|
+
var locks = /* @__PURE__ */ new Map();
|
|
801
|
+
async function withGitLock(repoPath, fn) {
|
|
802
|
+
const previous = locks.get(repoPath) ?? Promise.resolve();
|
|
803
|
+
let releaseLock;
|
|
804
|
+
const current = new Promise((resolve4) => {
|
|
805
|
+
releaseLock = resolve4;
|
|
806
|
+
});
|
|
807
|
+
locks.set(repoPath, current);
|
|
808
|
+
await previous;
|
|
809
|
+
try {
|
|
810
|
+
return await fn();
|
|
811
|
+
} finally {
|
|
812
|
+
releaseLock?.();
|
|
813
|
+
if (locks.get(repoPath) === current) {
|
|
814
|
+
locks.delete(repoPath);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/isolation/git.ts
|
|
820
|
+
var execFileAsync = promisify(execFile);
|
|
821
|
+
var GIT_TIMEOUT = 6e4;
|
|
822
|
+
async function git(repoPath, args) {
|
|
823
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
824
|
+
cwd: resolve(repoPath),
|
|
825
|
+
timeout: GIT_TIMEOUT
|
|
826
|
+
});
|
|
827
|
+
return stdout.trim();
|
|
828
|
+
}
|
|
829
|
+
async function createBranch(repoPath, branch, baseBranch) {
|
|
830
|
+
await withGitLock(repoPath, () => git(repoPath, ["branch", branch, baseBranch]));
|
|
831
|
+
}
|
|
832
|
+
async function pushBranch(repoPath, branch, remote) {
|
|
833
|
+
await withGitLock(repoPath, () => git(repoPath, ["push", remote, branch]));
|
|
834
|
+
}
|
|
835
|
+
async function fetchRemote(repoPath, remote) {
|
|
836
|
+
await withGitLock(repoPath, () => git(repoPath, ["fetch", remote]));
|
|
837
|
+
}
|
|
838
|
+
async function deleteBranch(repoPath, branch) {
|
|
839
|
+
await withGitLock(repoPath, () => git(repoPath, ["branch", "-D", branch]));
|
|
840
|
+
}
|
|
841
|
+
async function getCurrentBranch(repoPath) {
|
|
842
|
+
return withGitLock(repoPath, () => git(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
843
|
+
}
|
|
844
|
+
function getBranchName(config, runId) {
|
|
845
|
+
const prefix = config.branchPrefix ?? "feat";
|
|
846
|
+
const sanitized = runId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
847
|
+
return `${prefix}/run-${sanitized}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/isolation/sandbox.ts
|
|
851
|
+
import { resolve as resolve2 } from "path";
|
|
852
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
|
|
853
|
+
function buildSandboxConfig(agent, worktreePath) {
|
|
854
|
+
const isWritable = agent.sandbox === "writable";
|
|
855
|
+
const absWorktree = worktreePath ? resolve2(worktreePath) : void 0;
|
|
856
|
+
const allowedTools = isWritable ? agent.definition.tools : agent.definition.tools.filter((t) => !WRITE_TOOLS.has(t));
|
|
857
|
+
const readablePaths = absWorktree ? [absWorktree] : [];
|
|
858
|
+
const writablePaths = isWritable && absWorktree ? [absWorktree] : [];
|
|
859
|
+
return {
|
|
860
|
+
allowedTools,
|
|
861
|
+
readablePaths,
|
|
862
|
+
writablePaths,
|
|
863
|
+
writable: isWritable
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/isolation/worktree.ts
|
|
868
|
+
import { execFile as execFile2 } from "child_process";
|
|
869
|
+
import { existsSync as existsSync2 } from "fs";
|
|
870
|
+
import { readdir as readdir2, rm } from "fs/promises";
|
|
871
|
+
import { resolve as resolve3 } from "path";
|
|
872
|
+
import { promisify as promisify2 } from "util";
|
|
873
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
874
|
+
var GIT_TIMEOUT2 = 6e4;
|
|
875
|
+
async function createWorktree(options) {
|
|
876
|
+
const repoPath = resolve3(options.repoPath);
|
|
877
|
+
const worktreeDir = resolve3(options.worktreeDir);
|
|
878
|
+
await withGitLock(repoPath, async () => {
|
|
879
|
+
await execFileAsync2(
|
|
880
|
+
"git",
|
|
881
|
+
["worktree", "add", "-b", options.branch, worktreeDir, options.baseBranch],
|
|
882
|
+
{ cwd: repoPath, timeout: GIT_TIMEOUT2 }
|
|
883
|
+
);
|
|
884
|
+
});
|
|
885
|
+
await execFileAsync2("git", ["config", "core.hooksPath", "/dev/null"], {
|
|
886
|
+
cwd: worktreeDir,
|
|
887
|
+
timeout: GIT_TIMEOUT2
|
|
888
|
+
});
|
|
889
|
+
return { path: worktreeDir, branch: options.branch, repoPath };
|
|
890
|
+
}
|
|
891
|
+
async function removeWorktree(worktreePath) {
|
|
892
|
+
const absPath = resolve3(worktreePath);
|
|
893
|
+
if (!existsSync2(absPath)) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const repoPath = await findRepoForWorktree(absPath);
|
|
897
|
+
if (repoPath) {
|
|
898
|
+
await withGitLock(repoPath, async () => {
|
|
899
|
+
try {
|
|
900
|
+
await execFileAsync2("git", ["worktree", "remove", absPath, "--force"], {
|
|
901
|
+
cwd: repoPath,
|
|
902
|
+
timeout: GIT_TIMEOUT2
|
|
903
|
+
});
|
|
904
|
+
} catch {
|
|
905
|
+
await rm(absPath, { recursive: true, force: true });
|
|
906
|
+
await execFileAsync2("git", ["worktree", "prune"], {
|
|
907
|
+
cwd: repoPath,
|
|
908
|
+
timeout: GIT_TIMEOUT2
|
|
909
|
+
}).catch(() => {
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
await execFileAsync2("git", ["update-index", "--refresh"], {
|
|
913
|
+
cwd: repoPath,
|
|
914
|
+
timeout: GIT_TIMEOUT2
|
|
915
|
+
}).catch(() => {
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
} else {
|
|
919
|
+
await rm(absPath, { recursive: true, force: true });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function listWorktrees(repoPath) {
|
|
923
|
+
const absRepoPath = resolve3(repoPath);
|
|
924
|
+
const { stdout } = await execFileAsync2("git", ["worktree", "list", "--porcelain"], {
|
|
925
|
+
cwd: absRepoPath,
|
|
926
|
+
timeout: GIT_TIMEOUT2
|
|
927
|
+
});
|
|
928
|
+
const worktrees = [];
|
|
929
|
+
let current;
|
|
930
|
+
for (const line of stdout.split("\n")) {
|
|
931
|
+
if (line.startsWith("worktree ")) {
|
|
932
|
+
if (current) {
|
|
933
|
+
worktrees.push({ ...current, repoPath: absRepoPath });
|
|
934
|
+
}
|
|
935
|
+
current = { path: line.slice(9), branch: "" };
|
|
936
|
+
} else if (line.startsWith("branch ") && current) {
|
|
937
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (current) {
|
|
941
|
+
worktrees.push({ ...current, repoPath: absRepoPath });
|
|
942
|
+
}
|
|
943
|
+
return worktrees;
|
|
944
|
+
}
|
|
945
|
+
async function cleanupOrphanedWorktrees(worktreeBaseDir) {
|
|
946
|
+
const absBase = resolve3(worktreeBaseDir);
|
|
947
|
+
if (!existsSync2(absBase)) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const entries = await readdir2(absBase, { withFileTypes: true });
|
|
951
|
+
for (const entry of entries) {
|
|
952
|
+
if (!entry.isDirectory()) continue;
|
|
953
|
+
const worktreePath = resolve3(absBase, entry.name);
|
|
954
|
+
await removeWorktree(worktreePath);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async function findRepoForWorktree(worktreePath) {
|
|
958
|
+
try {
|
|
959
|
+
const { stdout } = await execFileAsync2("git", ["rev-parse", "--git-common-dir"], {
|
|
960
|
+
cwd: worktreePath,
|
|
961
|
+
timeout: GIT_TIMEOUT2
|
|
962
|
+
});
|
|
963
|
+
const gitCommonDir = resolve3(worktreePath, stdout.trim());
|
|
964
|
+
return resolve3(gitCommonDir, "..");
|
|
965
|
+
} catch {
|
|
966
|
+
return void 0;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/middleware/audit-log.ts
|
|
971
|
+
import { appendFile as appendFile3, mkdir as mkdir4 } from "fs/promises";
|
|
972
|
+
import path7 from "path";
|
|
973
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 500;
|
|
974
|
+
var DEFAULT_FLUSH_SIZE = 20;
|
|
975
|
+
function auditLog(options) {
|
|
976
|
+
const {
|
|
977
|
+
dir,
|
|
978
|
+
includeInput = true,
|
|
979
|
+
includeOutput = false,
|
|
980
|
+
flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS,
|
|
981
|
+
flushSize = DEFAULT_FLUSH_SIZE
|
|
982
|
+
} = options;
|
|
983
|
+
let dirCreated = false;
|
|
984
|
+
const buffers = /* @__PURE__ */ new Map();
|
|
985
|
+
let flushTimer;
|
|
986
|
+
async function ensureDir() {
|
|
987
|
+
if (!dirCreated) {
|
|
988
|
+
await mkdir4(dir, { recursive: true });
|
|
989
|
+
dirCreated = true;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
async function flushAll() {
|
|
993
|
+
if (buffers.size === 0) return;
|
|
994
|
+
await ensureDir();
|
|
995
|
+
const writes = [];
|
|
996
|
+
for (const [sessionId, lines] of buffers) {
|
|
997
|
+
const filePath = path7.join(dir, `${sessionId}.jsonl`);
|
|
998
|
+
writes.push(appendFile3(filePath, lines.join(""), "utf-8"));
|
|
999
|
+
}
|
|
1000
|
+
buffers.clear();
|
|
1001
|
+
await Promise.all(writes);
|
|
1002
|
+
}
|
|
1003
|
+
async function flushSession(sessionId) {
|
|
1004
|
+
const lines = buffers.get(sessionId);
|
|
1005
|
+
if (!lines || lines.length === 0) return;
|
|
1006
|
+
await ensureDir();
|
|
1007
|
+
const filePath = path7.join(dir, `${sessionId}.jsonl`);
|
|
1008
|
+
await appendFile3(filePath, lines.join(""), "utf-8");
|
|
1009
|
+
buffers.delete(sessionId);
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
name: "audit-log",
|
|
1013
|
+
on: "PostToolUse",
|
|
1014
|
+
async flush() {
|
|
1015
|
+
await flushAll();
|
|
1016
|
+
if (flushTimer !== void 0) {
|
|
1017
|
+
clearInterval(flushTimer);
|
|
1018
|
+
flushTimer = void 0;
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
async handler(event, context) {
|
|
1022
|
+
const entry = {
|
|
1023
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1024
|
+
sessionId: event.sessionId,
|
|
1025
|
+
agent: context.agent,
|
|
1026
|
+
toolName: event.toolName
|
|
1027
|
+
};
|
|
1028
|
+
if (includeInput && event.input !== void 0) {
|
|
1029
|
+
entry.input = event.input;
|
|
1030
|
+
}
|
|
1031
|
+
if (includeOutput && event.output !== void 0) {
|
|
1032
|
+
entry.output = event.output;
|
|
1033
|
+
}
|
|
1034
|
+
const sessionId = event.sessionId;
|
|
1035
|
+
let lines = buffers.get(sessionId);
|
|
1036
|
+
if (!lines) {
|
|
1037
|
+
lines = [];
|
|
1038
|
+
buffers.set(sessionId, lines);
|
|
1039
|
+
}
|
|
1040
|
+
lines.push(`${JSON.stringify(entry)}
|
|
1041
|
+
`);
|
|
1042
|
+
if (lines.length >= flushSize) {
|
|
1043
|
+
await flushSession(sessionId);
|
|
1044
|
+
}
|
|
1045
|
+
if (flushTimer === void 0 && flushIntervalMs > 0) {
|
|
1046
|
+
flushTimer = setInterval(() => {
|
|
1047
|
+
void flushAll();
|
|
1048
|
+
}, flushIntervalMs);
|
|
1049
|
+
if (typeof flushTimer === "object" && "unref" in flushTimer) {
|
|
1050
|
+
flushTimer.unref();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return { decision: "async", asyncTimeout: 5e3 };
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// src/middleware/budget-guard.ts
|
|
1059
|
+
function budgetGuard() {
|
|
1060
|
+
return {
|
|
1061
|
+
name: "budget-guard",
|
|
1062
|
+
on: "PreToolUse",
|
|
1063
|
+
async handler(_event, context) {
|
|
1064
|
+
const costToday = context.get("costToday");
|
|
1065
|
+
const budgetCapUsd = context.get("budgetCapUsd");
|
|
1066
|
+
if (costToday !== void 0 && budgetCapUsd !== void 0 && costToday >= budgetCapUsd) {
|
|
1067
|
+
return {
|
|
1068
|
+
decision: "block",
|
|
1069
|
+
reason: "Daily budget exceeded"
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
return { decision: "pass" };
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/middleware/chain.ts
|
|
1078
|
+
function matchesTool(match, toolName) {
|
|
1079
|
+
if (match === void 0) return true;
|
|
1080
|
+
if (toolName === void 0) return false;
|
|
1081
|
+
if (Array.isArray(match)) return match.includes(toolName);
|
|
1082
|
+
return match === toolName;
|
|
1083
|
+
}
|
|
1084
|
+
function buildMiddlewareChain(middleware) {
|
|
1085
|
+
return {
|
|
1086
|
+
async execute(event, context) {
|
|
1087
|
+
let lastAsync;
|
|
1088
|
+
for (const mw of middleware) {
|
|
1089
|
+
if (mw.on !== event.hookEvent) continue;
|
|
1090
|
+
if (!matchesTool(mw.match, event.toolName)) continue;
|
|
1091
|
+
const result = await mw.handler(event, context);
|
|
1092
|
+
switch (result.decision) {
|
|
1093
|
+
case "block":
|
|
1094
|
+
return result;
|
|
1095
|
+
case "async":
|
|
1096
|
+
lastAsync = result;
|
|
1097
|
+
break;
|
|
1098
|
+
case "pass":
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return lastAsync ?? { decision: "pass" };
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
function buildSDKHooks(chain, context, middleware = []) {
|
|
1107
|
+
function makeCallback(hookEvent) {
|
|
1108
|
+
return async (input) => {
|
|
1109
|
+
const event = {
|
|
1110
|
+
hookEvent,
|
|
1111
|
+
sessionId: input.session_id,
|
|
1112
|
+
toolName: "tool_name" in input ? input.tool_name : void 0,
|
|
1113
|
+
input: "tool_input" in input ? input.tool_input : void 0,
|
|
1114
|
+
output: "tool_response" in input ? String(input.tool_response) : void 0,
|
|
1115
|
+
message: "message" in input ? input.message : void 0
|
|
1116
|
+
};
|
|
1117
|
+
const result = await chain.execute(event, context);
|
|
1118
|
+
switch (result.decision) {
|
|
1119
|
+
case "block":
|
|
1120
|
+
return { decision: "block", reason: result.reason };
|
|
1121
|
+
case "async":
|
|
1122
|
+
return { async: true, asyncTimeout: result.asyncTimeout };
|
|
1123
|
+
case "pass":
|
|
1124
|
+
return {};
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
const usedEvents = new Set(middleware.map((mw) => mw.on));
|
|
1129
|
+
const allEvents = ["PreToolUse", "PostToolUse", "Notification"];
|
|
1130
|
+
const hooks = {};
|
|
1131
|
+
for (const hookEvent of allEvents) {
|
|
1132
|
+
if (middleware.length === 0 || usedEvents.has(hookEvent)) {
|
|
1133
|
+
hooks[hookEvent] = [{ hooks: [makeCallback(hookEvent)] }];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return hooks;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/middleware/loop-detection.ts
|
|
1140
|
+
function loopDetection(options) {
|
|
1141
|
+
const { threshold } = options;
|
|
1142
|
+
const commandHistory = /* @__PURE__ */ new Map();
|
|
1143
|
+
return {
|
|
1144
|
+
name: "loop-detection",
|
|
1145
|
+
on: "PreToolUse",
|
|
1146
|
+
match: "Bash",
|
|
1147
|
+
cleanup(sessionId) {
|
|
1148
|
+
commandHistory.delete(sessionId);
|
|
1149
|
+
},
|
|
1150
|
+
async handler(event) {
|
|
1151
|
+
const sessionId = event.sessionId;
|
|
1152
|
+
const command = event.input && typeof event.input === "object" && "command" in event.input ? String(event.input.command) : "";
|
|
1153
|
+
if (!command) return { decision: "pass" };
|
|
1154
|
+
if (!commandHistory.has(sessionId)) {
|
|
1155
|
+
commandHistory.set(sessionId, /* @__PURE__ */ new Map());
|
|
1156
|
+
}
|
|
1157
|
+
const sessionHistory = commandHistory.get(sessionId) ?? /* @__PURE__ */ new Map();
|
|
1158
|
+
const count = (sessionHistory.get(command) ?? 0) + 1;
|
|
1159
|
+
sessionHistory.set(command, count);
|
|
1160
|
+
if (count >= threshold) {
|
|
1161
|
+
return {
|
|
1162
|
+
decision: "block",
|
|
1163
|
+
reason: `Loop detected: you have run this exact command ${String(count)} times. STOP and escalate \u2014 do not retry the same approach.`
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
return { decision: "pass" };
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/orchestrator.ts
|
|
1172
|
+
import { randomUUID } from "crypto";
|
|
1173
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1174
|
+
import { mkdir as mkdir5, readdir as readdir4, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
|
|
1175
|
+
import path9 from "path";
|
|
1176
|
+
|
|
1177
|
+
// src/runner/output-parser.ts
|
|
1178
|
+
function extractJson(raw) {
|
|
1179
|
+
try {
|
|
1180
|
+
return JSON.parse(raw);
|
|
1181
|
+
} catch {
|
|
1182
|
+
}
|
|
1183
|
+
const codeBlockRegex = /```(?:json)?\s*\n?([\s\S]*?)```/;
|
|
1184
|
+
const match = raw.match(codeBlockRegex);
|
|
1185
|
+
if (match?.[1]) {
|
|
1186
|
+
try {
|
|
1187
|
+
return JSON.parse(match[1].trim());
|
|
1188
|
+
} catch {
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return void 0;
|
|
1192
|
+
}
|
|
1193
|
+
function parseOutput(raw, schema) {
|
|
1194
|
+
if (!schema) {
|
|
1195
|
+
return { rawOutput: raw };
|
|
1196
|
+
}
|
|
1197
|
+
const extracted = extractJson(raw);
|
|
1198
|
+
if (extracted === void 0) {
|
|
1199
|
+
return {
|
|
1200
|
+
rawOutput: raw,
|
|
1201
|
+
parseError: "Failed to extract JSON from output"
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const result = schema.safeParse(extracted);
|
|
1205
|
+
if (!result.success) {
|
|
1206
|
+
return {
|
|
1207
|
+
rawOutput: raw,
|
|
1208
|
+
parseError: `Schema validation failed: ${result.error.message}`
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
rawOutput: raw,
|
|
1213
|
+
output: result.data
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// src/runner/session.ts
|
|
1218
|
+
function isInitMessage(msg) {
|
|
1219
|
+
return msg.type === "system" && msg.subtype === "init";
|
|
1220
|
+
}
|
|
1221
|
+
function isResultMessage(msg) {
|
|
1222
|
+
return msg.type === "result";
|
|
1223
|
+
}
|
|
1224
|
+
function checkAborted(signal) {
|
|
1225
|
+
if (signal.aborted) {
|
|
1226
|
+
const reason = signal.reason;
|
|
1227
|
+
throw reason instanceof Error ? reason : new Error(String(reason));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function toSessionError(error, isTimeout, sessionId) {
|
|
1231
|
+
if (error instanceof SessionError) return error;
|
|
1232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1233
|
+
return new SessionError(message, isTimeout ? "timeout" : "unknown", sessionId);
|
|
1234
|
+
}
|
|
1235
|
+
async function runSession(options) {
|
|
1236
|
+
const { agent, prompt, worktreePath, sandboxConfig, initTimeoutMs, maxDurationMs, onEvent } = options;
|
|
1237
|
+
const startTime = Date.now();
|
|
1238
|
+
let sessionId = "";
|
|
1239
|
+
const abortController = new AbortController();
|
|
1240
|
+
const initTimer = setTimeout(() => {
|
|
1241
|
+
abortController.abort(new Error("Session init timeout exceeded"));
|
|
1242
|
+
}, initTimeoutMs);
|
|
1243
|
+
const maxDurationTimer = setTimeout(() => {
|
|
1244
|
+
abortController.abort(new Error("Session max duration exceeded"));
|
|
1245
|
+
}, maxDurationMs);
|
|
1246
|
+
try {
|
|
1247
|
+
const sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
1248
|
+
const queryOptions = {
|
|
1249
|
+
// Always pass cwd: worktree for writable agents, repo root for readonly.
|
|
1250
|
+
// Without this, readonly agents default to process.cwd() and may write to main tree.
|
|
1251
|
+
cwd: worktreePath ?? options.repoPath,
|
|
1252
|
+
maxTurns: agent.maxTurns,
|
|
1253
|
+
allowedTools: sandboxConfig.allowedTools
|
|
1254
|
+
};
|
|
1255
|
+
if (options.resumeSessionId) {
|
|
1256
|
+
queryOptions.resume = options.resumeSessionId;
|
|
1257
|
+
}
|
|
1258
|
+
if (options.mcpServers?.length) {
|
|
1259
|
+
queryOptions.mcpServers = options.mcpServers;
|
|
1260
|
+
}
|
|
1261
|
+
let output = "";
|
|
1262
|
+
let costUsd = 0;
|
|
1263
|
+
let turnCount = 0;
|
|
1264
|
+
const stream = sdk.query({ prompt, options: queryOptions });
|
|
1265
|
+
for await (const message of stream) {
|
|
1266
|
+
checkAborted(abortController.signal);
|
|
1267
|
+
const msg = message;
|
|
1268
|
+
if (isInitMessage(msg)) {
|
|
1269
|
+
sessionId = msg.session_id;
|
|
1270
|
+
clearTimeout(initTimer);
|
|
1271
|
+
onEvent?.({ type: "session:start", sessionId });
|
|
1272
|
+
}
|
|
1273
|
+
if (isResultMessage(msg)) {
|
|
1274
|
+
output = msg.result ?? "";
|
|
1275
|
+
costUsd = msg.total_cost_usd ?? 0;
|
|
1276
|
+
turnCount = msg.num_turns ?? 0;
|
|
1277
|
+
sessionId = msg.session_id ?? sessionId;
|
|
1278
|
+
if (msg.subtype !== "success") {
|
|
1279
|
+
throw new SessionError(
|
|
1280
|
+
`Session ended with error: ${msg.subtype}`,
|
|
1281
|
+
msg.subtype,
|
|
1282
|
+
sessionId
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
const sessionResult = {
|
|
1288
|
+
sessionId,
|
|
1289
|
+
output,
|
|
1290
|
+
costUsd,
|
|
1291
|
+
durationMs: Date.now() - startTime,
|
|
1292
|
+
turnCount
|
|
1293
|
+
};
|
|
1294
|
+
onEvent?.({ type: "session:complete", sessionId, result: sessionResult });
|
|
1295
|
+
return sessionResult;
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
const errorSessionId = sessionId || "unknown";
|
|
1298
|
+
const sessionError = toSessionError(error, abortController.signal.aborted, errorSessionId);
|
|
1299
|
+
onEvent?.({ type: "session:fail", sessionId: errorSessionId, error: sessionError.message });
|
|
1300
|
+
throw sessionError;
|
|
1301
|
+
} finally {
|
|
1302
|
+
clearTimeout(initTimer);
|
|
1303
|
+
clearTimeout(maxDurationTimer);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
var SessionError = class extends Error {
|
|
1307
|
+
constructor(message, errorType, sessionId) {
|
|
1308
|
+
super(message);
|
|
1309
|
+
this.errorType = errorType;
|
|
1310
|
+
this.sessionId = sessionId;
|
|
1311
|
+
this.name = "SessionError";
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
// src/runner/recovery.ts
|
|
1316
|
+
var DEFAULT_NON_RETRYABLE = ["error_max_turns", "budget_exceeded"];
|
|
1317
|
+
function getStrategy(attempt) {
|
|
1318
|
+
switch (attempt) {
|
|
1319
|
+
case 1:
|
|
1320
|
+
return "normal";
|
|
1321
|
+
case 2:
|
|
1322
|
+
return "resume";
|
|
1323
|
+
default:
|
|
1324
|
+
return "fresh";
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function sleep(ms) {
|
|
1328
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
1329
|
+
}
|
|
1330
|
+
function isNonRetryable(error, nonRetryable) {
|
|
1331
|
+
return error instanceof SessionError && nonRetryable.includes(error.errorType);
|
|
1332
|
+
}
|
|
1333
|
+
function updateSessionId(error, current) {
|
|
1334
|
+
if (error instanceof SessionError && error.sessionId !== "unknown") {
|
|
1335
|
+
return error.sessionId;
|
|
1336
|
+
}
|
|
1337
|
+
return current;
|
|
1338
|
+
}
|
|
1339
|
+
function buildFinalError(error, maxRetries) {
|
|
1340
|
+
if (error instanceof Error) {
|
|
1341
|
+
return new Error(`Recovery failed after ${maxRetries} attempts. Last error: ${error.message}`, {
|
|
1342
|
+
cause: error
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
return new Error(`Recovery failed after ${maxRetries} attempts`);
|
|
1346
|
+
}
|
|
1347
|
+
async function runWithRecovery(options) {
|
|
1348
|
+
const {
|
|
1349
|
+
maxRetries,
|
|
1350
|
+
backoffBaseMs,
|
|
1351
|
+
nonRetryable = DEFAULT_NON_RETRYABLE,
|
|
1352
|
+
onAttempt,
|
|
1353
|
+
...rest
|
|
1354
|
+
} = options;
|
|
1355
|
+
let lastSessionId;
|
|
1356
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1357
|
+
const strategy = getStrategy(attempt);
|
|
1358
|
+
onAttempt?.(attempt, strategy);
|
|
1359
|
+
try {
|
|
1360
|
+
const result = await runSession({
|
|
1361
|
+
...rest,
|
|
1362
|
+
resumeSessionId: strategy === "resume" ? lastSessionId : void 0
|
|
1363
|
+
});
|
|
1364
|
+
return result;
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
lastSessionId = updateSessionId(error, lastSessionId);
|
|
1367
|
+
if (isNonRetryable(error, nonRetryable)) throw error;
|
|
1368
|
+
if (attempt === maxRetries) throw buildFinalError(error, maxRetries);
|
|
1369
|
+
if (getStrategy(attempt + 1) === "fresh") {
|
|
1370
|
+
lastSessionId = void 0;
|
|
1371
|
+
}
|
|
1372
|
+
await sleep(backoffBaseMs * attempt);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
throw new Error("Recovery failed: unreachable");
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/workflows/registry.ts
|
|
1379
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1380
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
1381
|
+
import path8 from "path";
|
|
1382
|
+
|
|
1383
|
+
// src/workflows/loader.ts
|
|
1384
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1385
|
+
import { parse } from "yaml";
|
|
1386
|
+
import { z as z3 } from "zod";
|
|
1387
|
+
var workflowStepDefSchema = z3.object({
|
|
1388
|
+
type: z3.literal("step").optional().default("step"),
|
|
1389
|
+
agent: z3.string(),
|
|
1390
|
+
dependsOn: z3.array(z3.string()).optional(),
|
|
1391
|
+
prompt: z3.string().optional(),
|
|
1392
|
+
sandbox: z3.enum(["writable", "readonly"]).optional(),
|
|
1393
|
+
maxTurns: z3.number().int().positive().optional(),
|
|
1394
|
+
mcpServers: z3.array(z3.string()).optional(),
|
|
1395
|
+
recovery: z3.object({
|
|
1396
|
+
maxRetries: z3.number().int().nonnegative().optional(),
|
|
1397
|
+
nonRetryable: z3.array(z3.string()).optional()
|
|
1398
|
+
}).optional(),
|
|
1399
|
+
condition: z3.string().optional()
|
|
1400
|
+
});
|
|
1401
|
+
var workflowGateDefSchema = z3.object({
|
|
1402
|
+
type: z3.literal("gate"),
|
|
1403
|
+
dependsOn: z3.array(z3.string()).optional(),
|
|
1404
|
+
description: z3.string(),
|
|
1405
|
+
timeout: z3.string().optional(),
|
|
1406
|
+
autoApprove: z3.boolean().optional()
|
|
1407
|
+
});
|
|
1408
|
+
var workflowHeaderSchema = z3.object({
|
|
1409
|
+
name: z3.string().min(1),
|
|
1410
|
+
description: z3.string().optional(),
|
|
1411
|
+
steps: z3.record(z3.string(), z3.unknown())
|
|
1412
|
+
});
|
|
1413
|
+
function parseStepEntry(stepName, stepValue) {
|
|
1414
|
+
const obj = stepValue;
|
|
1415
|
+
const schema = obj.type === "gate" ? workflowGateDefSchema : workflowStepDefSchema;
|
|
1416
|
+
const result = schema.safeParse(stepValue);
|
|
1417
|
+
if (result.success) {
|
|
1418
|
+
return { step: result.data, errors: [] };
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
step: stepValue,
|
|
1422
|
+
errors: result.error.issues.map(
|
|
1423
|
+
(i) => ` - steps.${stepName}.${i.path.join(".")}: ${i.message}`
|
|
1424
|
+
)
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
function parseSteps(rawSteps, filePath) {
|
|
1428
|
+
if (Object.keys(rawSteps).length === 0) {
|
|
1429
|
+
throw new Error(
|
|
1430
|
+
`Invalid workflow definition in ${filePath}:
|
|
1431
|
+
- steps: Workflow must have at least one step`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
const steps = {};
|
|
1435
|
+
const errors = [];
|
|
1436
|
+
for (const [name, value] of Object.entries(rawSteps)) {
|
|
1437
|
+
const { step, errors: stepErrors } = parseStepEntry(name, value);
|
|
1438
|
+
if (stepErrors.length > 0) {
|
|
1439
|
+
errors.push(...stepErrors);
|
|
1440
|
+
} else {
|
|
1441
|
+
steps[name] = step;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (errors.length > 0) {
|
|
1445
|
+
throw new Error(`Invalid workflow definition in ${filePath}:
|
|
1446
|
+
${errors.join("\n")}`);
|
|
1447
|
+
}
|
|
1448
|
+
return steps;
|
|
1449
|
+
}
|
|
1450
|
+
async function loadWorkflow(filePath) {
|
|
1451
|
+
const content = await readFile4(filePath, "utf-8");
|
|
1452
|
+
const raw = parse(content);
|
|
1453
|
+
const headerResult = workflowHeaderSchema.safeParse(raw);
|
|
1454
|
+
if (!headerResult.success) {
|
|
1455
|
+
const issues = headerResult.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
1456
|
+
throw new Error(`Invalid workflow definition in ${filePath}:
|
|
1457
|
+
${issues}`);
|
|
1458
|
+
}
|
|
1459
|
+
const { name, description, steps: rawSteps } = headerResult.data;
|
|
1460
|
+
const steps = parseSteps(rawSteps, filePath);
|
|
1461
|
+
return { name, description, steps };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// src/workflows/registry.ts
|
|
1465
|
+
var WorkflowRegistry = class {
|
|
1466
|
+
builtInDir;
|
|
1467
|
+
customDir;
|
|
1468
|
+
workflows = /* @__PURE__ */ new Map();
|
|
1469
|
+
constructor(builtInDir, customDir) {
|
|
1470
|
+
this.builtInDir = builtInDir;
|
|
1471
|
+
this.customDir = customDir;
|
|
1472
|
+
}
|
|
1473
|
+
async load() {
|
|
1474
|
+
await this.loadFromDir(this.builtInDir);
|
|
1475
|
+
if (this.customDir) {
|
|
1476
|
+
await this.loadFromDir(this.customDir);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
get(name) {
|
|
1480
|
+
return this.workflows.get(name);
|
|
1481
|
+
}
|
|
1482
|
+
list() {
|
|
1483
|
+
return [...this.workflows.values()];
|
|
1484
|
+
}
|
|
1485
|
+
has(name) {
|
|
1486
|
+
return this.workflows.has(name);
|
|
1487
|
+
}
|
|
1488
|
+
async loadFromDir(dir) {
|
|
1489
|
+
if (!existsSync3(dir)) return;
|
|
1490
|
+
const files = await readdir3(dir);
|
|
1491
|
+
for (const file of files) {
|
|
1492
|
+
if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
|
|
1493
|
+
const filePath = path8.join(dir, file);
|
|
1494
|
+
const workflow = await loadWorkflow(filePath);
|
|
1495
|
+
this.workflows.set(workflow.name, workflow);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
// src/orchestrator.ts
|
|
1501
|
+
var MAX_PROMPT_SIZE = 100 * 1024;
|
|
1502
|
+
var MAX_METADATA_DEPTH = 5;
|
|
1503
|
+
var SHUTDOWN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1504
|
+
var WORKTREES_DIR = ".neo/worktrees";
|
|
1505
|
+
var textEncoder = new TextEncoder();
|
|
1506
|
+
var Orchestrator = class extends NeoEventEmitter {
|
|
1507
|
+
config;
|
|
1508
|
+
semaphore;
|
|
1509
|
+
userMiddleware;
|
|
1510
|
+
workflows = /* @__PURE__ */ new Map();
|
|
1511
|
+
registeredAgents = /* @__PURE__ */ new Map();
|
|
1512
|
+
_activeSessions = /* @__PURE__ */ new Map();
|
|
1513
|
+
idempotencyCache = /* @__PURE__ */ new Map();
|
|
1514
|
+
abortControllers = /* @__PURE__ */ new Map();
|
|
1515
|
+
repoIndex = /* @__PURE__ */ new Map();
|
|
1516
|
+
createdRunDirs = /* @__PURE__ */ new Set();
|
|
1517
|
+
journalDir;
|
|
1518
|
+
builtInWorkflowDir;
|
|
1519
|
+
customWorkflowDir;
|
|
1520
|
+
costJournal = null;
|
|
1521
|
+
eventJournal = null;
|
|
1522
|
+
webhookDispatcher = null;
|
|
1523
|
+
_paused = false;
|
|
1524
|
+
_costToday = 0;
|
|
1525
|
+
_startedAt = 0;
|
|
1526
|
+
_drainResolve = null;
|
|
1527
|
+
constructor(config, options = {}) {
|
|
1528
|
+
super();
|
|
1529
|
+
this.config = config;
|
|
1530
|
+
this.userMiddleware = options.middleware ?? [];
|
|
1531
|
+
this.journalDir = options.journalDir ?? getJournalsDir();
|
|
1532
|
+
this.builtInWorkflowDir = options.builtInWorkflowDir;
|
|
1533
|
+
this.customWorkflowDir = options.customWorkflowDir;
|
|
1534
|
+
for (const repo of config.repos) {
|
|
1535
|
+
const resolvedPath = path9.resolve(repo.path);
|
|
1536
|
+
const normalizedRepo = { ...repo, path: resolvedPath };
|
|
1537
|
+
this.repoIndex.set(resolvedPath, normalizedRepo);
|
|
1538
|
+
}
|
|
1539
|
+
this.semaphore = new Semaphore(
|
|
1540
|
+
{
|
|
1541
|
+
maxSessions: config.concurrency.maxSessions,
|
|
1542
|
+
maxPerRepo: config.concurrency.maxPerRepo,
|
|
1543
|
+
queueMax: config.concurrency.queueMax
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
onEnqueue: (sessionId, repo, position) => {
|
|
1547
|
+
this.emit({
|
|
1548
|
+
type: "queue:enqueue",
|
|
1549
|
+
sessionId,
|
|
1550
|
+
repo,
|
|
1551
|
+
position,
|
|
1552
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1553
|
+
});
|
|
1554
|
+
},
|
|
1555
|
+
onDequeue: (sessionId, repo, waitedMs) => {
|
|
1556
|
+
this.emit({
|
|
1557
|
+
type: "queue:dequeue",
|
|
1558
|
+
sessionId,
|
|
1559
|
+
repo,
|
|
1560
|
+
waitedMs,
|
|
1561
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
// ─── Registration ──────────────────────────────────────
|
|
1568
|
+
registerWorkflow(definition) {
|
|
1569
|
+
this.workflows.set(definition.name, definition);
|
|
1570
|
+
}
|
|
1571
|
+
registerAgent(agent) {
|
|
1572
|
+
this.registeredAgents.set(agent.name, agent);
|
|
1573
|
+
}
|
|
1574
|
+
// ─── Dispatch ──────────────────────────────────────────
|
|
1575
|
+
async dispatch(input) {
|
|
1576
|
+
const idempotencyKey = this.preDispatchChecks(input);
|
|
1577
|
+
const ctx = this.buildDispatchContext(input);
|
|
1578
|
+
const abortController = new AbortController();
|
|
1579
|
+
this.abortControllers.set(ctx.sessionId, abortController);
|
|
1580
|
+
await this.semaphore.acquire(
|
|
1581
|
+
input.repo,
|
|
1582
|
+
ctx.sessionId,
|
|
1583
|
+
input.priority ?? "medium",
|
|
1584
|
+
abortController.signal
|
|
1585
|
+
);
|
|
1586
|
+
ctx.activeSession.status = "running";
|
|
1587
|
+
const stepResult = await this.executeStep(ctx);
|
|
1588
|
+
return this.finalizeDispatch(ctx, stepResult, idempotencyKey);
|
|
1589
|
+
}
|
|
1590
|
+
// ─── Control ───────────────────────────────────────────
|
|
1591
|
+
pause() {
|
|
1592
|
+
this._paused = true;
|
|
1593
|
+
}
|
|
1594
|
+
resume() {
|
|
1595
|
+
this._paused = false;
|
|
1596
|
+
}
|
|
1597
|
+
async kill(sessionId) {
|
|
1598
|
+
const controller = this.abortControllers.get(sessionId);
|
|
1599
|
+
if (controller) {
|
|
1600
|
+
controller.abort(new Error("Session killed"));
|
|
1601
|
+
}
|
|
1602
|
+
this._activeSessions.delete(sessionId);
|
|
1603
|
+
this.abortControllers.delete(sessionId);
|
|
1604
|
+
this.semaphore.release(sessionId);
|
|
1605
|
+
}
|
|
1606
|
+
async drain() {
|
|
1607
|
+
this._paused = true;
|
|
1608
|
+
if (this._activeSessions.size === 0) return;
|
|
1609
|
+
return new Promise((resolve4) => {
|
|
1610
|
+
this._drainResolve = resolve4;
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
// ─── Getters ───────────────────────────────────────────
|
|
1614
|
+
get status() {
|
|
1615
|
+
return {
|
|
1616
|
+
paused: this._paused,
|
|
1617
|
+
activeSessions: [...this._activeSessions.values()],
|
|
1618
|
+
queueDepth: this.semaphore.queueDepth(),
|
|
1619
|
+
costToday: this._costToday,
|
|
1620
|
+
budgetCapUsd: this.config.budget.dailyCapUsd,
|
|
1621
|
+
budgetRemainingPct: this.computeBudgetRemainingPct(),
|
|
1622
|
+
uptime: this._startedAt > 0 ? Date.now() - this._startedAt : 0
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
get activeSessions() {
|
|
1626
|
+
return [...this._activeSessions.values()];
|
|
1627
|
+
}
|
|
1628
|
+
// ─── Lifecycle ─────────────────────────────────────────
|
|
1629
|
+
async start() {
|
|
1630
|
+
this._startedAt = Date.now();
|
|
1631
|
+
this.costJournal = new CostJournal({ dir: this.journalDir });
|
|
1632
|
+
this.eventJournal = new EventJournal({ dir: this.journalDir });
|
|
1633
|
+
if (this.config.webhooks.length > 0) {
|
|
1634
|
+
this.webhookDispatcher = new WebhookDispatcher(this.config.webhooks);
|
|
1635
|
+
}
|
|
1636
|
+
this._costToday = await this.costJournal.getDayTotal();
|
|
1637
|
+
if (this.builtInWorkflowDir) {
|
|
1638
|
+
const registry = new WorkflowRegistry(this.builtInWorkflowDir, this.customWorkflowDir);
|
|
1639
|
+
await registry.load();
|
|
1640
|
+
for (const workflow of registry.list()) {
|
|
1641
|
+
this.registerWorkflow(workflow);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
await this.recoverOrphanedRuns();
|
|
1645
|
+
for (const repo of this.config.repos) {
|
|
1646
|
+
const worktreeBase = path9.join(repo.path, WORKTREES_DIR);
|
|
1647
|
+
await cleanupOrphanedWorktrees(worktreeBase).catch(() => {
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
async shutdown() {
|
|
1652
|
+
this._paused = true;
|
|
1653
|
+
if (this._activeSessions.size > 0) {
|
|
1654
|
+
await Promise.race([
|
|
1655
|
+
this.drain(),
|
|
1656
|
+
new Promise((resolve4) => setTimeout(resolve4, SHUTDOWN_TIMEOUT_MS))
|
|
1657
|
+
]);
|
|
1658
|
+
}
|
|
1659
|
+
for (const mw of this.userMiddleware) {
|
|
1660
|
+
if ("flush" in mw && typeof mw.flush === "function") {
|
|
1661
|
+
await mw.flush();
|
|
1662
|
+
}
|
|
1663
|
+
if ("cleanup" in mw && typeof mw.cleanup === "function") {
|
|
1664
|
+
for (const session of this._activeSessions.values()) {
|
|
1665
|
+
mw.cleanup(session.sessionId);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
this.emit({
|
|
1670
|
+
type: "orchestrator:shutdown",
|
|
1671
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
// ─── Emit override (journal events) ───────────────────
|
|
1675
|
+
emit(event) {
|
|
1676
|
+
super.emit(event);
|
|
1677
|
+
if (this.eventJournal) {
|
|
1678
|
+
this.eventJournal.append(event).catch(() => {
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
if (this.webhookDispatcher) {
|
|
1682
|
+
this.webhookDispatcher.dispatch(event);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// ─── Static middleware factories ───────────────────────
|
|
1686
|
+
static middleware = {
|
|
1687
|
+
loopDetection: (options) => loopDetection(options),
|
|
1688
|
+
auditLog: (options) => auditLog(options),
|
|
1689
|
+
budgetGuard: () => budgetGuard()
|
|
1690
|
+
};
|
|
1691
|
+
// ─── Private: Dispatch phases ──────────────────────────
|
|
1692
|
+
preDispatchChecks(input) {
|
|
1693
|
+
this.validateInput(input);
|
|
1694
|
+
const idempotencyKey = this.computeIdempotencyKey(input);
|
|
1695
|
+
if (idempotencyKey) {
|
|
1696
|
+
this.evictExpiredIdempotencyEntries();
|
|
1697
|
+
const cached = this.idempotencyCache.get(idempotencyKey);
|
|
1698
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
1699
|
+
throw new Error(
|
|
1700
|
+
`Duplicate dispatch rejected: runId '${input.runId ?? "auto-generated"}' already exists. Each dispatch must use a unique runId.`
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (this._paused) {
|
|
1705
|
+
throw new Error(
|
|
1706
|
+
"Dispatch rejected: orchestrator is paused. Call orchestrator.resume() before dispatching."
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
return idempotencyKey;
|
|
1710
|
+
}
|
|
1711
|
+
buildDispatchContext(input) {
|
|
1712
|
+
const runId = input.runId ?? randomUUID();
|
|
1713
|
+
const sessionId = randomUUID();
|
|
1714
|
+
const workflow = this.workflows.get(input.workflow);
|
|
1715
|
+
if (!workflow) {
|
|
1716
|
+
const available = [...this.workflows.keys()].join(", ") || "none";
|
|
1717
|
+
throw new Error(
|
|
1718
|
+
`Workflow "${input.workflow}" not found. Available workflows: ${available}. Check the workflow name or register it first.`
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
const [stepName, stepDef] = this.getFirstStep(workflow, input);
|
|
1722
|
+
const agent = this.resolveStepAgent(stepDef, workflow.name);
|
|
1723
|
+
const repoConfig = this.resolveRepo(input.repo);
|
|
1724
|
+
const activeSession = {
|
|
1725
|
+
sessionId,
|
|
1726
|
+
runId,
|
|
1727
|
+
workflow: input.workflow,
|
|
1728
|
+
step: stepName,
|
|
1729
|
+
agent: agent.name,
|
|
1730
|
+
repo: input.repo,
|
|
1731
|
+
status: "queued",
|
|
1732
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1733
|
+
};
|
|
1734
|
+
this._activeSessions.set(sessionId, activeSession);
|
|
1735
|
+
return {
|
|
1736
|
+
input,
|
|
1737
|
+
runId,
|
|
1738
|
+
sessionId,
|
|
1739
|
+
startedAt: Date.now(),
|
|
1740
|
+
stepName,
|
|
1741
|
+
stepDef,
|
|
1742
|
+
agent,
|
|
1743
|
+
repoConfig,
|
|
1744
|
+
activeSession
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
async executeStep(ctx) {
|
|
1748
|
+
const { input, runId, sessionId, startedAt, agent, repoConfig, activeSession } = ctx;
|
|
1749
|
+
let worktreePath;
|
|
1750
|
+
try {
|
|
1751
|
+
if (agent.sandbox === "writable") {
|
|
1752
|
+
const branchName = getBranchName(repoConfig, runId);
|
|
1753
|
+
const worktreeDir = path9.join(input.repo, WORKTREES_DIR, runId);
|
|
1754
|
+
const info = await createWorktree({
|
|
1755
|
+
repoPath: input.repo,
|
|
1756
|
+
branch: branchName,
|
|
1757
|
+
baseBranch: repoConfig.defaultBranch,
|
|
1758
|
+
worktreeDir
|
|
1759
|
+
});
|
|
1760
|
+
worktreePath = info.path;
|
|
1761
|
+
activeSession.worktreePath = worktreePath;
|
|
1762
|
+
}
|
|
1763
|
+
const stepResult = await this.runAgentSession(ctx, worktreePath);
|
|
1764
|
+
this.emitCostEvents(sessionId, stepResult.costUsd, ctx);
|
|
1765
|
+
this.emitSessionComplete(ctx, stepResult);
|
|
1766
|
+
return stepResult;
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1769
|
+
this.emitSessionFail(ctx, errorMsg);
|
|
1770
|
+
return {
|
|
1771
|
+
status: "failure",
|
|
1772
|
+
sessionId,
|
|
1773
|
+
costUsd: 0,
|
|
1774
|
+
durationMs: Date.now() - startedAt,
|
|
1775
|
+
agent: agent.name,
|
|
1776
|
+
startedAt: activeSession.startedAt,
|
|
1777
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1778
|
+
error: errorMsg,
|
|
1779
|
+
attempt: 1
|
|
1780
|
+
};
|
|
1781
|
+
} finally {
|
|
1782
|
+
this.semaphore.release(sessionId);
|
|
1783
|
+
this._activeSessions.delete(sessionId);
|
|
1784
|
+
this.abortControllers.delete(sessionId);
|
|
1785
|
+
if (this._activeSessions.size === 0 && this._drainResolve) {
|
|
1786
|
+
this._drainResolve();
|
|
1787
|
+
this._drainResolve = null;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
async runAgentSession(ctx, worktreePath) {
|
|
1792
|
+
const { input, runId, sessionId, stepName, stepDef, agent, activeSession } = ctx;
|
|
1793
|
+
const sandboxConfig = buildSandboxConfig(agent, worktreePath);
|
|
1794
|
+
const chain = buildMiddlewareChain(this.userMiddleware);
|
|
1795
|
+
const middlewareContext = this.buildMiddlewareContext(
|
|
1796
|
+
runId,
|
|
1797
|
+
input.workflow,
|
|
1798
|
+
stepName,
|
|
1799
|
+
agent.name,
|
|
1800
|
+
input.repo
|
|
1801
|
+
);
|
|
1802
|
+
const hooks = buildSDKHooks(chain, middlewareContext, this.userMiddleware);
|
|
1803
|
+
this.emit({
|
|
1804
|
+
type: "session:start",
|
|
1805
|
+
sessionId,
|
|
1806
|
+
runId,
|
|
1807
|
+
workflow: input.workflow,
|
|
1808
|
+
step: stepName,
|
|
1809
|
+
agent: agent.name,
|
|
1810
|
+
repo: input.repo,
|
|
1811
|
+
metadata: input.metadata,
|
|
1812
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1813
|
+
});
|
|
1814
|
+
const recoveryOpts = stepDef.recovery;
|
|
1815
|
+
const sessionResult = await runWithRecovery({
|
|
1816
|
+
agent,
|
|
1817
|
+
prompt: stepDef.prompt ?? input.prompt,
|
|
1818
|
+
repoPath: input.repo,
|
|
1819
|
+
sandboxConfig,
|
|
1820
|
+
hooks,
|
|
1821
|
+
initTimeoutMs: this.config.sessions.initTimeoutMs,
|
|
1822
|
+
maxDurationMs: this.config.sessions.maxDurationMs,
|
|
1823
|
+
maxRetries: recoveryOpts?.maxRetries ?? this.config.recovery.maxRetries,
|
|
1824
|
+
backoffBaseMs: this.config.recovery.backoffBaseMs,
|
|
1825
|
+
...worktreePath ? { worktreePath } : {},
|
|
1826
|
+
...recoveryOpts?.nonRetryable ? { nonRetryable: recoveryOpts.nonRetryable } : {},
|
|
1827
|
+
onAttempt: (attempt, strategy) => {
|
|
1828
|
+
if (attempt > 1) {
|
|
1829
|
+
this.emit({
|
|
1830
|
+
type: "session:fail",
|
|
1831
|
+
sessionId,
|
|
1832
|
+
runId,
|
|
1833
|
+
error: `Retrying with strategy: ${strategy}`,
|
|
1834
|
+
attempt: attempt - 1,
|
|
1835
|
+
maxRetries: recoveryOpts?.maxRetries ?? this.config.recovery.maxRetries,
|
|
1836
|
+
willRetry: true,
|
|
1837
|
+
metadata: input.metadata,
|
|
1838
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
const parsed = parseOutput(sessionResult.output);
|
|
1844
|
+
return {
|
|
1845
|
+
status: "success",
|
|
1846
|
+
sessionId: sessionResult.sessionId,
|
|
1847
|
+
output: parsed.output ?? parsed.rawOutput,
|
|
1848
|
+
rawOutput: sessionResult.output,
|
|
1849
|
+
costUsd: sessionResult.costUsd,
|
|
1850
|
+
durationMs: sessionResult.durationMs,
|
|
1851
|
+
agent: agent.name,
|
|
1852
|
+
startedAt: activeSession.startedAt,
|
|
1853
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1854
|
+
attempt: 1
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
async finalizeDispatch(ctx, stepResult, idempotencyKey) {
|
|
1858
|
+
const { input, runId, stepName, repoConfig, activeSession } = ctx;
|
|
1859
|
+
const taskResult = {
|
|
1860
|
+
runId,
|
|
1861
|
+
workflow: input.workflow,
|
|
1862
|
+
repo: input.repo,
|
|
1863
|
+
status: stepResult.status === "success" ? "success" : "failure",
|
|
1864
|
+
steps: { [stepName]: stepResult },
|
|
1865
|
+
branch: stepResult.status === "success" && activeSession.worktreePath ? getBranchName(repoConfig, runId) : void 0,
|
|
1866
|
+
costUsd: stepResult.costUsd,
|
|
1867
|
+
durationMs: Date.now() - ctx.startedAt,
|
|
1868
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1869
|
+
metadata: input.metadata
|
|
1870
|
+
};
|
|
1871
|
+
await this.persistRun({
|
|
1872
|
+
version: 1,
|
|
1873
|
+
runId,
|
|
1874
|
+
workflow: input.workflow,
|
|
1875
|
+
repo: input.repo,
|
|
1876
|
+
prompt: input.prompt,
|
|
1877
|
+
branch: taskResult.branch,
|
|
1878
|
+
status: taskResult.status === "success" ? "completed" : "failed",
|
|
1879
|
+
steps: taskResult.steps,
|
|
1880
|
+
createdAt: activeSession.startedAt,
|
|
1881
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1882
|
+
metadata: input.metadata
|
|
1883
|
+
});
|
|
1884
|
+
if (idempotencyKey) {
|
|
1885
|
+
const ttl = this.config.idempotency?.ttlMs ?? 36e5;
|
|
1886
|
+
this.idempotencyCache.set(idempotencyKey, {
|
|
1887
|
+
result: taskResult,
|
|
1888
|
+
expiresAt: Date.now() + ttl
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
return taskResult;
|
|
1892
|
+
}
|
|
1893
|
+
// ─── Private: Event helpers ────────────────────────────
|
|
1894
|
+
emitCostEvents(sessionId, sessionCost, ctx) {
|
|
1895
|
+
this._costToday += sessionCost;
|
|
1896
|
+
if (this.costJournal) {
|
|
1897
|
+
const costEntry = {
|
|
1898
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1899
|
+
runId: ctx.runId,
|
|
1900
|
+
workflow: ctx.input.workflow,
|
|
1901
|
+
step: ctx.stepName,
|
|
1902
|
+
sessionId,
|
|
1903
|
+
agent: ctx.agent.name,
|
|
1904
|
+
costUsd: sessionCost,
|
|
1905
|
+
models: {},
|
|
1906
|
+
durationMs: Date.now() - ctx.startedAt,
|
|
1907
|
+
repo: ctx.input.repo
|
|
1908
|
+
};
|
|
1909
|
+
this.costJournal.append(costEntry).catch(() => {
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
this.emit({
|
|
1913
|
+
type: "cost:update",
|
|
1914
|
+
sessionId,
|
|
1915
|
+
sessionCost,
|
|
1916
|
+
todayTotal: this._costToday,
|
|
1917
|
+
budgetRemainingPct: this.computeBudgetRemainingPct(),
|
|
1918
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1919
|
+
});
|
|
1920
|
+
const utilizationPct = this._costToday / this.config.budget.dailyCapUsd * 100;
|
|
1921
|
+
if (utilizationPct >= this.config.budget.alertThresholdPct) {
|
|
1922
|
+
this.emit({
|
|
1923
|
+
type: "budget:alert",
|
|
1924
|
+
todayTotal: this._costToday,
|
|
1925
|
+
capUsd: this.config.budget.dailyCapUsd,
|
|
1926
|
+
utilizationPct,
|
|
1927
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
emitSessionComplete(ctx, stepResult) {
|
|
1932
|
+
this.emit({
|
|
1933
|
+
type: "session:complete",
|
|
1934
|
+
sessionId: ctx.sessionId,
|
|
1935
|
+
runId: ctx.runId,
|
|
1936
|
+
status: "success",
|
|
1937
|
+
costUsd: stepResult.costUsd,
|
|
1938
|
+
durationMs: stepResult.durationMs,
|
|
1939
|
+
output: stepResult.output,
|
|
1940
|
+
metadata: ctx.input.metadata,
|
|
1941
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
emitSessionFail(ctx, errorMsg) {
|
|
1945
|
+
this.emit({
|
|
1946
|
+
type: "session:fail",
|
|
1947
|
+
sessionId: ctx.sessionId,
|
|
1948
|
+
runId: ctx.runId,
|
|
1949
|
+
error: errorMsg,
|
|
1950
|
+
attempt: 1,
|
|
1951
|
+
maxRetries: this.config.recovery.maxRetries,
|
|
1952
|
+
willRetry: false,
|
|
1953
|
+
metadata: ctx.input.metadata,
|
|
1954
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
// ─── Private: Input validation ─────────────────────────
|
|
1958
|
+
validateInput(input) {
|
|
1959
|
+
if (!input.prompt || input.prompt.trim().length === 0) {
|
|
1960
|
+
throw new Error("Validation error: prompt must be a non-empty string");
|
|
1961
|
+
}
|
|
1962
|
+
if (textEncoder.encode(input.prompt).length > MAX_PROMPT_SIZE) {
|
|
1963
|
+
throw new Error(
|
|
1964
|
+
`Validation error: prompt exceeds maximum size of ${String(MAX_PROMPT_SIZE)} bytes`
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
if (!existsSync4(input.repo)) {
|
|
1968
|
+
throw new Error(`Validation error: repo path does not exist: ${input.repo}`);
|
|
1969
|
+
}
|
|
1970
|
+
if (!this.workflows.has(input.workflow)) {
|
|
1971
|
+
throw new Error(`Validation error: workflow "${input.workflow}" not found in registry`);
|
|
1972
|
+
}
|
|
1973
|
+
if (input.metadata !== void 0) {
|
|
1974
|
+
if (!isPlainObject(input.metadata)) {
|
|
1975
|
+
throw new Error("Validation error: metadata must be a plain object");
|
|
1976
|
+
}
|
|
1977
|
+
if (objectDepth(input.metadata) > MAX_METADATA_DEPTH) {
|
|
1978
|
+
throw new Error(
|
|
1979
|
+
`Validation error: metadata exceeds maximum nesting depth of ${String(MAX_METADATA_DEPTH)}`
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
const resumeOptions = [input.step, input.from, input.retry].filter(Boolean);
|
|
1984
|
+
if (resumeOptions.length > 1) {
|
|
1985
|
+
throw new Error("Validation error: step, from, and retry are mutually exclusive");
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
// ─── Private: Helpers ──────────────────────────────────
|
|
1989
|
+
evictExpiredIdempotencyEntries() {
|
|
1990
|
+
const now = Date.now();
|
|
1991
|
+
for (const [key, entry] of this.idempotencyCache) {
|
|
1992
|
+
if (entry.expiresAt <= now) {
|
|
1993
|
+
this.idempotencyCache.delete(key);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
computeIdempotencyKey(input) {
|
|
1998
|
+
const idempotency = this.config.idempotency;
|
|
1999
|
+
if (!idempotency?.enabled) return null;
|
|
2000
|
+
const key = idempotency.key ?? "metadata";
|
|
2001
|
+
if (key === "prompt") {
|
|
2002
|
+
return `${input.workflow}:${input.repo}:${input.prompt}`;
|
|
2003
|
+
}
|
|
2004
|
+
return `${input.workflow}:${input.repo}:${JSON.stringify(input.metadata ?? {})}`;
|
|
2005
|
+
}
|
|
2006
|
+
getFirstStep(workflow, input) {
|
|
2007
|
+
if (input.step) {
|
|
2008
|
+
const step = workflow.steps[input.step];
|
|
2009
|
+
if (!step || step.type === "gate") {
|
|
2010
|
+
throw new Error(
|
|
2011
|
+
`Step "${input.step}" not found in workflow "${workflow.name}" or is a gate step. Check the step name in the workflow definition.`
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
return [input.step, step];
|
|
2015
|
+
}
|
|
2016
|
+
for (const [name, step] of Object.entries(workflow.steps)) {
|
|
2017
|
+
if (step.type === "gate") continue;
|
|
2018
|
+
const stepDef = step;
|
|
2019
|
+
if (!stepDef.dependsOn || stepDef.dependsOn.length === 0) {
|
|
2020
|
+
return [name, stepDef];
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
const entries = Object.entries(workflow.steps);
|
|
2024
|
+
const first = entries[0];
|
|
2025
|
+
if (!first) {
|
|
2026
|
+
throw new Error(`Workflow "${workflow.name}" has no steps`);
|
|
2027
|
+
}
|
|
2028
|
+
return [first[0], first[1]];
|
|
2029
|
+
}
|
|
2030
|
+
resolveStepAgent(step, workflowName) {
|
|
2031
|
+
const agent = this.registeredAgents.get(step.agent);
|
|
2032
|
+
if (!agent) {
|
|
2033
|
+
throw new Error(
|
|
2034
|
+
`Agent "${step.agent}" required by workflow "${workflowName}" not found in registry. Register the agent or check the workflow definition.`
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
return agent;
|
|
2038
|
+
}
|
|
2039
|
+
resolveRepo(repoPath) {
|
|
2040
|
+
const repo = this.repoIndex.get(path9.resolve(repoPath));
|
|
2041
|
+
if (repo) return repo;
|
|
2042
|
+
return {
|
|
2043
|
+
path: repoPath,
|
|
2044
|
+
defaultBranch: "main",
|
|
2045
|
+
branchPrefix: "feat",
|
|
2046
|
+
pushRemote: "origin",
|
|
2047
|
+
autoCreatePr: false
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
buildMiddlewareContext(runId, workflow, step, agent, repo) {
|
|
2051
|
+
const store = /* @__PURE__ */ new Map();
|
|
2052
|
+
return {
|
|
2053
|
+
runId,
|
|
2054
|
+
workflow,
|
|
2055
|
+
step,
|
|
2056
|
+
agent,
|
|
2057
|
+
repo,
|
|
2058
|
+
get: ((key) => {
|
|
2059
|
+
if (key === "costToday") return this._costToday;
|
|
2060
|
+
if (key === "budgetCapUsd") return this.config.budget.dailyCapUsd;
|
|
2061
|
+
return store.get(key);
|
|
2062
|
+
}),
|
|
2063
|
+
set: ((key, value) => {
|
|
2064
|
+
store.set(key, value);
|
|
2065
|
+
})
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
computeBudgetRemainingPct() {
|
|
2069
|
+
const cap = this.config.budget.dailyCapUsd;
|
|
2070
|
+
if (cap <= 0) return 0;
|
|
2071
|
+
return Math.max(0, (cap - this._costToday) / cap * 100);
|
|
2072
|
+
}
|
|
2073
|
+
// ─── Private: Run persistence ──────────────────────────
|
|
2074
|
+
async persistRun(run) {
|
|
2075
|
+
try {
|
|
2076
|
+
const slug = toRepoSlug({ path: run.repo });
|
|
2077
|
+
const runsDir = getRepoRunsDir(slug);
|
|
2078
|
+
if (!this.createdRunDirs.has(runsDir)) {
|
|
2079
|
+
await mkdir5(runsDir, { recursive: true });
|
|
2080
|
+
this.createdRunDirs.add(runsDir);
|
|
2081
|
+
}
|
|
2082
|
+
const filePath = path9.join(runsDir, `${run.runId}.json`);
|
|
2083
|
+
await writeFile2(filePath, JSON.stringify(run, null, 2), "utf-8");
|
|
2084
|
+
} catch {
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
async recoverOrphanedRuns() {
|
|
2088
|
+
const runsDir = getRunsDir();
|
|
2089
|
+
if (!existsSync4(runsDir)) return;
|
|
2090
|
+
try {
|
|
2091
|
+
const entries = await readdir4(runsDir, { withFileTypes: true });
|
|
2092
|
+
const jsonFiles = [];
|
|
2093
|
+
for (const entry of entries) {
|
|
2094
|
+
if (entry.isDirectory()) {
|
|
2095
|
+
const subDir = path9.join(runsDir, entry.name);
|
|
2096
|
+
const subFiles = await readdir4(subDir);
|
|
2097
|
+
for (const f of subFiles) {
|
|
2098
|
+
if (f.endsWith(".json")) jsonFiles.push(path9.join(subDir, f));
|
|
2099
|
+
}
|
|
2100
|
+
} else if (entry.name.endsWith(".json")) {
|
|
2101
|
+
jsonFiles.push(path9.join(runsDir, entry.name));
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
for (const filePath of jsonFiles) {
|
|
2105
|
+
const content = await readFile5(filePath, "utf-8");
|
|
2106
|
+
const run = JSON.parse(content);
|
|
2107
|
+
if (run.status === "running") {
|
|
2108
|
+
run.status = "failed";
|
|
2109
|
+
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2110
|
+
await writeFile2(filePath, JSON.stringify(run, null, 2), "utf-8");
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
} catch {
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
function isPlainObject(value) {
|
|
2118
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2119
|
+
}
|
|
2120
|
+
function objectDepth(obj, current = 0) {
|
|
2121
|
+
if (!isPlainObject(obj)) return current;
|
|
2122
|
+
let max = current + 1;
|
|
2123
|
+
for (const value of Object.values(obj)) {
|
|
2124
|
+
const depth = objectDepth(value, current + 1);
|
|
2125
|
+
if (depth > max) max = depth;
|
|
2126
|
+
}
|
|
2127
|
+
return max;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// src/supervisor/schemas.ts
|
|
2131
|
+
import { z as z4 } from "zod";
|
|
2132
|
+
var supervisorDaemonStateSchema = z4.object({
|
|
2133
|
+
pid: z4.number(),
|
|
2134
|
+
tmuxSession: z4.string(),
|
|
2135
|
+
sessionId: z4.string(),
|
|
2136
|
+
port: z4.number(),
|
|
2137
|
+
cwd: z4.string(),
|
|
2138
|
+
startedAt: z4.string(),
|
|
2139
|
+
lastHeartbeat: z4.string().optional(),
|
|
2140
|
+
heartbeatCount: z4.number().default(0),
|
|
2141
|
+
totalCostUsd: z4.number().default(0),
|
|
2142
|
+
todayCostUsd: z4.number().default(0),
|
|
2143
|
+
costResetDate: z4.string().optional(),
|
|
2144
|
+
status: z4.enum(["running", "draining", "stopped"]).default("running")
|
|
2145
|
+
});
|
|
2146
|
+
var webhookIncomingEventSchema = z4.object({
|
|
2147
|
+
id: z4.string().optional(),
|
|
2148
|
+
source: z4.string().optional(),
|
|
2149
|
+
event: z4.string().optional(),
|
|
2150
|
+
payload: z4.record(z4.string(), z4.unknown()).optional(),
|
|
2151
|
+
receivedAt: z4.string(),
|
|
2152
|
+
processedAt: z4.string().optional()
|
|
2153
|
+
});
|
|
2154
|
+
var inboxMessageSchema = z4.object({
|
|
2155
|
+
id: z4.string(),
|
|
2156
|
+
from: z4.enum(["tui", "api", "external"]),
|
|
2157
|
+
text: z4.string(),
|
|
2158
|
+
timestamp: z4.string(),
|
|
2159
|
+
processedAt: z4.string().optional()
|
|
2160
|
+
});
|
|
2161
|
+
var activityEntrySchema = z4.object({
|
|
2162
|
+
id: z4.string(),
|
|
2163
|
+
type: z4.enum([
|
|
2164
|
+
"heartbeat",
|
|
2165
|
+
"decision",
|
|
2166
|
+
"action",
|
|
2167
|
+
"error",
|
|
2168
|
+
"event",
|
|
2169
|
+
"message",
|
|
2170
|
+
"thinking",
|
|
2171
|
+
"plan",
|
|
2172
|
+
"dispatch",
|
|
2173
|
+
"tool_use"
|
|
2174
|
+
]),
|
|
2175
|
+
summary: z4.string(),
|
|
2176
|
+
detail: z4.unknown().optional(),
|
|
2177
|
+
timestamp: z4.string()
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
// src/supervisor/activity-log.ts
|
|
2181
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2182
|
+
import { appendFile as appendFile4, readFile as readFile6, rename, stat } from "fs/promises";
|
|
2183
|
+
import path10 from "path";
|
|
2184
|
+
var ACTIVITY_FILE = "activity.jsonl";
|
|
2185
|
+
var MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
|
2186
|
+
var ActivityLog = class {
|
|
2187
|
+
filePath;
|
|
2188
|
+
dir;
|
|
2189
|
+
constructor(dir) {
|
|
2190
|
+
this.dir = dir;
|
|
2191
|
+
this.filePath = path10.join(dir, ACTIVITY_FILE);
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Append a structured entry to the activity log.
|
|
2195
|
+
* Rotates the file if it exceeds MAX_SIZE_BYTES.
|
|
2196
|
+
*/
|
|
2197
|
+
async append(entry) {
|
|
2198
|
+
await this.checkRotation();
|
|
2199
|
+
const line = `${JSON.stringify(entry)}
|
|
2200
|
+
`;
|
|
2201
|
+
await appendFile4(this.filePath, line, "utf-8");
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Create and append a new entry with auto-generated id and timestamp.
|
|
2205
|
+
*/
|
|
2206
|
+
async log(type, summary, detail) {
|
|
2207
|
+
await this.append({
|
|
2208
|
+
id: randomUUID2(),
|
|
2209
|
+
type,
|
|
2210
|
+
summary,
|
|
2211
|
+
detail,
|
|
2212
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Read the last N entries from the activity log.
|
|
2217
|
+
*/
|
|
2218
|
+
async tail(n) {
|
|
2219
|
+
let content;
|
|
2220
|
+
try {
|
|
2221
|
+
content = await readFile6(this.filePath, "utf-8");
|
|
2222
|
+
} catch {
|
|
2223
|
+
return [];
|
|
2224
|
+
}
|
|
2225
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
2226
|
+
const lastLines = lines.slice(-n);
|
|
2227
|
+
const entries = [];
|
|
2228
|
+
for (const line of lastLines) {
|
|
2229
|
+
try {
|
|
2230
|
+
entries.push(JSON.parse(line));
|
|
2231
|
+
} catch {
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return entries;
|
|
2235
|
+
}
|
|
2236
|
+
async checkRotation() {
|
|
2237
|
+
try {
|
|
2238
|
+
const stats = await stat(this.filePath);
|
|
2239
|
+
if (stats.size > MAX_SIZE_BYTES) {
|
|
2240
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2241
|
+
const rotatedPath = path10.join(this.dir, `activity-${timestamp}.jsonl`);
|
|
2242
|
+
await rename(this.filePath, rotatedPath);
|
|
2243
|
+
}
|
|
2244
|
+
} catch {
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
};
|
|
2248
|
+
|
|
2249
|
+
// src/supervisor/daemon.ts
|
|
2250
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2251
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2252
|
+
import { mkdir as mkdir6, readFile as readFile10, rm as rm2, writeFile as writeFile6 } from "fs/promises";
|
|
2253
|
+
import { homedir as homedir3 } from "os";
|
|
2254
|
+
import path13 from "path";
|
|
2255
|
+
|
|
2256
|
+
// src/supervisor/event-queue.ts
|
|
2257
|
+
import { watch } from "fs";
|
|
2258
|
+
import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
|
|
2259
|
+
var EventQueue = class {
|
|
2260
|
+
queue = [];
|
|
2261
|
+
seenIds = /* @__PURE__ */ new Set();
|
|
2262
|
+
maxSeenIds = 1e3;
|
|
2263
|
+
maxEventsPerSec;
|
|
2264
|
+
eventCountThisSecond = 0;
|
|
2265
|
+
currentSecond = 0;
|
|
2266
|
+
watchers = [];
|
|
2267
|
+
fileOffsets = /* @__PURE__ */ new Map();
|
|
2268
|
+
/** Resolve function to wake up the heartbeat loop when an event arrives */
|
|
2269
|
+
wakeUp = null;
|
|
2270
|
+
constructor(options) {
|
|
2271
|
+
this.maxEventsPerSec = options.maxEventsPerSec;
|
|
2272
|
+
}
|
|
2273
|
+
/**
|
|
2274
|
+
* Push an event into the queue. Applies dedup and rate limiting.
|
|
2275
|
+
*/
|
|
2276
|
+
push(event) {
|
|
2277
|
+
const id = this.getEventId(event);
|
|
2278
|
+
if (id && this.seenIds.has(id)) return false;
|
|
2279
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2280
|
+
if (now !== this.currentSecond) {
|
|
2281
|
+
this.currentSecond = now;
|
|
2282
|
+
this.eventCountThisSecond = 0;
|
|
2283
|
+
}
|
|
2284
|
+
if (this.eventCountThisSecond >= this.maxEventsPerSec) return false;
|
|
2285
|
+
this.eventCountThisSecond++;
|
|
2286
|
+
if (id) {
|
|
2287
|
+
this.seenIds.add(id);
|
|
2288
|
+
if (this.seenIds.size > this.maxSeenIds) {
|
|
2289
|
+
const first = this.seenIds.values().next().value;
|
|
2290
|
+
if (first) this.seenIds.delete(first);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
this.queue.push(event);
|
|
2294
|
+
this.wakeUp?.();
|
|
2295
|
+
return true;
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Drain all queued events and return them. Clears the queue.
|
|
2299
|
+
*/
|
|
2300
|
+
drain() {
|
|
2301
|
+
const events = [...this.queue];
|
|
2302
|
+
this.queue.length = 0;
|
|
2303
|
+
return events;
|
|
2304
|
+
}
|
|
2305
|
+
size() {
|
|
2306
|
+
return this.queue.length;
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Start watching inbox.jsonl and events.jsonl for new entries.
|
|
2310
|
+
* New lines are parsed and pushed into the queue.
|
|
2311
|
+
*/
|
|
2312
|
+
startWatching(inboxPath, eventsPath) {
|
|
2313
|
+
this.watchJsonlFile(inboxPath, "message");
|
|
2314
|
+
this.watchJsonlFile(eventsPath, "webhook");
|
|
2315
|
+
}
|
|
2316
|
+
stopWatching() {
|
|
2317
|
+
for (const w of this.watchers) w.close();
|
|
2318
|
+
this.watchers = [];
|
|
2319
|
+
this.fileOffsets.clear();
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Replay unprocessed events from disk on startup.
|
|
2323
|
+
*/
|
|
2324
|
+
async replayUnprocessed(inboxPath, eventsPath) {
|
|
2325
|
+
await this.replayFile(inboxPath, "message");
|
|
2326
|
+
await this.replayFile(eventsPath, "webhook");
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Returns a promise that resolves when a new event arrives or timeout is reached.
|
|
2330
|
+
*/
|
|
2331
|
+
waitForEvent(timeoutMs) {
|
|
2332
|
+
if (this.queue.length > 0) return Promise.resolve();
|
|
2333
|
+
return new Promise((resolve4) => {
|
|
2334
|
+
const timer = setTimeout(() => {
|
|
2335
|
+
this.wakeUp = null;
|
|
2336
|
+
resolve4();
|
|
2337
|
+
}, timeoutMs);
|
|
2338
|
+
this.wakeUp = () => {
|
|
2339
|
+
clearTimeout(timer);
|
|
2340
|
+
this.wakeUp = null;
|
|
2341
|
+
resolve4();
|
|
2342
|
+
};
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
/**
|
|
2346
|
+
* Interrupt any pending waitForEvent — used during shutdown.
|
|
2347
|
+
*/
|
|
2348
|
+
interrupt() {
|
|
2349
|
+
this.wakeUp?.();
|
|
2350
|
+
}
|
|
2351
|
+
getEventId(event) {
|
|
2352
|
+
if (event.kind === "webhook") return event.data.id;
|
|
2353
|
+
if (event.kind === "message") return event.data.id;
|
|
2354
|
+
if (event.kind === "run_complete") return `run:${event.runId}`;
|
|
2355
|
+
return void 0;
|
|
2356
|
+
}
|
|
2357
|
+
watchJsonlFile(filePath, kind) {
|
|
2358
|
+
try {
|
|
2359
|
+
const watcher = watch(filePath, () => {
|
|
2360
|
+
this.readNewLines(filePath, kind).catch(() => {
|
|
2361
|
+
});
|
|
2362
|
+
});
|
|
2363
|
+
this.watchers.push(watcher);
|
|
2364
|
+
} catch {
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
async readNewLines(filePath, kind) {
|
|
2368
|
+
let content;
|
|
2369
|
+
try {
|
|
2370
|
+
content = await readFile7(filePath, "utf-8");
|
|
2371
|
+
} catch {
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
const offset = this.fileOffsets.get(filePath) ?? 0;
|
|
2375
|
+
if (content.length <= offset) return;
|
|
2376
|
+
const newContent = content.slice(offset);
|
|
2377
|
+
this.fileOffsets.set(filePath, content.length);
|
|
2378
|
+
const lines = newContent.trim().split("\n").filter(Boolean);
|
|
2379
|
+
for (const line of lines) {
|
|
2380
|
+
try {
|
|
2381
|
+
const parsed = JSON.parse(line);
|
|
2382
|
+
if (parsed.processedAt) continue;
|
|
2383
|
+
if (kind === "webhook") {
|
|
2384
|
+
this.push({ kind: "webhook", data: parsed });
|
|
2385
|
+
} else {
|
|
2386
|
+
this.push({ kind: "message", data: parsed });
|
|
2387
|
+
}
|
|
2388
|
+
} catch {
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
async replayFile(filePath, kind) {
|
|
2393
|
+
let content;
|
|
2394
|
+
try {
|
|
2395
|
+
content = await readFile7(filePath, "utf-8");
|
|
2396
|
+
} catch {
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
this.fileOffsets.set(filePath, content.length);
|
|
2400
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
2401
|
+
const unprocessed = [];
|
|
2402
|
+
for (const line of lines) {
|
|
2403
|
+
try {
|
|
2404
|
+
const parsed = JSON.parse(line);
|
|
2405
|
+
if (parsed.processedAt) continue;
|
|
2406
|
+
if (kind === "webhook") {
|
|
2407
|
+
this.push({ kind: "webhook", data: parsed });
|
|
2408
|
+
} else {
|
|
2409
|
+
this.push({ kind: "message", data: parsed });
|
|
2410
|
+
}
|
|
2411
|
+
unprocessed.push(line);
|
|
2412
|
+
} catch {
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Mark events as processed by rewriting the source files.
|
|
2418
|
+
*/
|
|
2419
|
+
async markProcessed(inboxPath, eventsPath, events) {
|
|
2420
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2421
|
+
for (const event of events) {
|
|
2422
|
+
if (event.kind === "webhook") {
|
|
2423
|
+
await this.markInFile(eventsPath, event.data.receivedAt, now);
|
|
2424
|
+
} else if (event.kind === "message") {
|
|
2425
|
+
await this.markInFile(inboxPath, event.data.timestamp, now);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
async markInFile(filePath, matchTimestamp, processedAt) {
|
|
2430
|
+
try {
|
|
2431
|
+
const content = await readFile7(filePath, "utf-8");
|
|
2432
|
+
const lines = content.split("\n");
|
|
2433
|
+
let changed = false;
|
|
2434
|
+
const updated = lines.map((line) => {
|
|
2435
|
+
if (!line.trim()) return line;
|
|
2436
|
+
try {
|
|
2437
|
+
const parsed = JSON.parse(line);
|
|
2438
|
+
if ((parsed.receivedAt === matchTimestamp || parsed.timestamp === matchTimestamp) && !parsed.processedAt) {
|
|
2439
|
+
parsed.processedAt = processedAt;
|
|
2440
|
+
changed = true;
|
|
2441
|
+
return JSON.stringify(parsed);
|
|
2442
|
+
}
|
|
2443
|
+
} catch {
|
|
2444
|
+
}
|
|
2445
|
+
return line;
|
|
2446
|
+
});
|
|
2447
|
+
if (changed) {
|
|
2448
|
+
await writeFile3(filePath, updated.join("\n"), "utf-8");
|
|
2449
|
+
this.fileOffsets.set(filePath, updated.join("\n").length);
|
|
2450
|
+
}
|
|
2451
|
+
} catch {
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
|
|
2456
|
+
// src/supervisor/heartbeat.ts
|
|
2457
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2458
|
+
import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
|
|
2459
|
+
import { homedir as homedir2 } from "os";
|
|
2460
|
+
import path12 from "path";
|
|
2461
|
+
|
|
2462
|
+
// src/supervisor/memory.ts
|
|
2463
|
+
import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
|
|
2464
|
+
import path11 from "path";
|
|
2465
|
+
var MEMORY_FILE = "memory.md";
|
|
2466
|
+
var MAX_SIZE_KB = 10;
|
|
2467
|
+
async function loadMemory(dir) {
|
|
2468
|
+
try {
|
|
2469
|
+
return await readFile8(path11.join(dir, MEMORY_FILE), "utf-8");
|
|
2470
|
+
} catch {
|
|
2471
|
+
return "";
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
async function saveMemory(dir, content) {
|
|
2475
|
+
await writeFile4(path11.join(dir, MEMORY_FILE), content, "utf-8");
|
|
2476
|
+
}
|
|
2477
|
+
function extractMemoryFromResponse(response) {
|
|
2478
|
+
const match = /<memory>([\s\S]*?)<\/memory>/i.exec(response);
|
|
2479
|
+
if (!match?.[1]) return null;
|
|
2480
|
+
const content = match[1].trim();
|
|
2481
|
+
if (content.startsWith("{")) {
|
|
2482
|
+
try {
|
|
2483
|
+
JSON.parse(content);
|
|
2484
|
+
return content;
|
|
2485
|
+
} catch {
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
return content;
|
|
2489
|
+
}
|
|
2490
|
+
function checkMemorySize(content) {
|
|
2491
|
+
const sizeKB = Buffer.byteLength(content, "utf-8") / 1024;
|
|
2492
|
+
return { ok: sizeKB <= MAX_SIZE_KB, sizeKB: Math.round(sizeKB * 10) / 10 };
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// src/supervisor/prompt-builder.ts
|
|
2496
|
+
function buildHeartbeatPrompt(opts) {
|
|
2497
|
+
const sections = [];
|
|
2498
|
+
sections.push(`You are the neo autonomous supervisor (heartbeat #${opts.heartbeatCount}).
|
|
2499
|
+
You orchestrate developer agents across repositories. You make decisions autonomously.
|
|
2500
|
+
|
|
2501
|
+
Your job:
|
|
2502
|
+
1. Process any incoming events (webhooks, user messages, run completions)
|
|
2503
|
+
2. Decide what actions to take (dispatch agents, check status, respond to users)
|
|
2504
|
+
3. Update your memory with relevant context for future heartbeats
|
|
2505
|
+
4. If nothing to do, simply acknowledge and wait
|
|
2506
|
+
|
|
2507
|
+
Available commands (via bash):
|
|
2508
|
+
neo run <agent> --prompt "..." [--repo <path>] dispatch an agent
|
|
2509
|
+
neo runs --short [--all] check recent runs
|
|
2510
|
+
neo cost --short [--all] check budget
|
|
2511
|
+
neo agents list available agents
|
|
2512
|
+
|
|
2513
|
+
IMPORTANT: Always include a <memory>...</memory> block at the end of your response with your updated memory.`);
|
|
2514
|
+
if (opts.customInstructions) {
|
|
2515
|
+
sections.push(`## Custom instructions
|
|
2516
|
+
${opts.customInstructions}`);
|
|
2517
|
+
}
|
|
2518
|
+
if (opts.repos.length > 0) {
|
|
2519
|
+
const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
|
|
2520
|
+
sections.push(`## Registered repositories
|
|
2521
|
+
${repoList}`);
|
|
2522
|
+
} else {
|
|
2523
|
+
sections.push("## Registered repositories\n(none \u2014 run 'neo init' in a repo to register it)");
|
|
2524
|
+
}
|
|
2525
|
+
if (opts.mcpServerNames.length > 0) {
|
|
2526
|
+
const mcpList = opts.mcpServerNames.map((n) => `- ${n}`).join("\n");
|
|
2527
|
+
sections.push(
|
|
2528
|
+
`## Available integrations (MCP)
|
|
2529
|
+
${mcpList}
|
|
2530
|
+
|
|
2531
|
+
You can use these tools directly to query external systems.`
|
|
2532
|
+
);
|
|
2533
|
+
}
|
|
2534
|
+
sections.push(
|
|
2535
|
+
`## Budget status
|
|
2536
|
+
- Today: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`
|
|
2537
|
+
);
|
|
2538
|
+
if (opts.activeRuns.length > 0) {
|
|
2539
|
+
sections.push(`## Active runs
|
|
2540
|
+
${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
|
|
2541
|
+
}
|
|
2542
|
+
if (opts.events.length > 0) {
|
|
2543
|
+
const eventDescriptions = opts.events.map(formatEvent);
|
|
2544
|
+
sections.push(`## Pending events (${opts.events.length})
|
|
2545
|
+
${eventDescriptions.join("\n\n")}`);
|
|
2546
|
+
} else {
|
|
2547
|
+
sections.push(
|
|
2548
|
+
"## Pending events\nNo new events. This is an idle heartbeat \u2014 check on active runs if any, or wait."
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
sections.push(buildMemorySection(opts.memory, opts.memorySizeKB));
|
|
2552
|
+
return sections.join("\n\n---\n\n");
|
|
2553
|
+
}
|
|
2554
|
+
function buildMemorySection(memory, memorySizeKB) {
|
|
2555
|
+
const schema = `{
|
|
2556
|
+
"activeWork": ["description of current task 1", ...],
|
|
2557
|
+
"blockers": ["what is stuck and why", ...],
|
|
2558
|
+
"repoNotes": { "/path/to/repo": "relevant context about this repo" },
|
|
2559
|
+
"recentDecisions": [{ "date": "YYYY-MM-DD", "decision": "what you decided", "outcome": "result" }],
|
|
2560
|
+
"trackerSync": { "ticket-id": "last known status" },
|
|
2561
|
+
"notes": "free-form context that doesn't fit elsewhere"
|
|
2562
|
+
}`;
|
|
2563
|
+
if (!memory) {
|
|
2564
|
+
return `## Your current memory
|
|
2565
|
+
(empty \u2014 this is your first heartbeat, initialize your memory)
|
|
2566
|
+
|
|
2567
|
+
Your memory MUST be a JSON object inside \`<memory>...</memory>\` tags:
|
|
2568
|
+
\`\`\`
|
|
2569
|
+
${schema}
|
|
2570
|
+
\`\`\`
|
|
2571
|
+
Keep under 8KB. Prune old decisions (keep last 10).`;
|
|
2572
|
+
}
|
|
2573
|
+
const sizeWarning = memorySizeKB > 8 ? "\n\n**Memory is over 8KB \u2014 condense it. Remove old decisions, summarize notes.**" : "";
|
|
2574
|
+
return `## Your current memory (${memorySizeKB}KB)${sizeWarning}
|
|
2575
|
+
${memory}
|
|
2576
|
+
|
|
2577
|
+
Remember: update your memory as a JSON object inside \`<memory>...</memory>\` tags.
|
|
2578
|
+
Schema: ${schema}`;
|
|
2579
|
+
}
|
|
2580
|
+
function formatEvent(event) {
|
|
2581
|
+
switch (event.kind) {
|
|
2582
|
+
case "webhook":
|
|
2583
|
+
return `**Webhook** [${event.data.source ?? "unknown"}] ${event.data.event ?? ""}
|
|
2584
|
+
\`\`\`json
|
|
2585
|
+
${JSON.stringify(event.data.payload ?? {}, null, 2).slice(0, 2e3)}
|
|
2586
|
+
\`\`\``;
|
|
2587
|
+
case "message":
|
|
2588
|
+
return `**Message from ${event.data.from}**: ${event.data.text}`;
|
|
2589
|
+
case "run_complete":
|
|
2590
|
+
return `**Run completed**: ${event.runId} (check with \`neo runs\`)`;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// src/supervisor/heartbeat.ts
|
|
2595
|
+
var HeartbeatLoop = class {
|
|
2596
|
+
stopping = false;
|
|
2597
|
+
consecutiveFailures = 0;
|
|
2598
|
+
activeAbort = null;
|
|
2599
|
+
config;
|
|
2600
|
+
supervisorDir;
|
|
2601
|
+
statePath;
|
|
2602
|
+
sessionId;
|
|
2603
|
+
eventQueue;
|
|
2604
|
+
activityLog;
|
|
2605
|
+
customInstructions;
|
|
2606
|
+
constructor(options) {
|
|
2607
|
+
this.config = options.config;
|
|
2608
|
+
this.supervisorDir = options.supervisorDir;
|
|
2609
|
+
this.statePath = options.statePath;
|
|
2610
|
+
this.sessionId = options.sessionId;
|
|
2611
|
+
this.eventQueue = options.eventQueue;
|
|
2612
|
+
this.activityLog = options.activityLog;
|
|
2613
|
+
}
|
|
2614
|
+
async start() {
|
|
2615
|
+
this.customInstructions = await this.loadInstructions();
|
|
2616
|
+
await this.activityLog.log("heartbeat", "Supervisor heartbeat loop started");
|
|
2617
|
+
while (!this.stopping) {
|
|
2618
|
+
try {
|
|
2619
|
+
await this.runHeartbeat();
|
|
2620
|
+
this.consecutiveFailures = 0;
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
this.consecutiveFailures++;
|
|
2623
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2624
|
+
await this.activityLog.log("error", `Heartbeat failed: ${msg}`, { error: msg });
|
|
2625
|
+
if (this.consecutiveFailures >= this.config.supervisor.maxConsecutiveFailures) {
|
|
2626
|
+
const backoffMs = Math.min(
|
|
2627
|
+
this.config.supervisor.idleIntervalMs * 2 ** (this.consecutiveFailures - this.config.supervisor.maxConsecutiveFailures),
|
|
2628
|
+
15 * 60 * 1e3
|
|
2629
|
+
// max 15 minutes
|
|
2630
|
+
);
|
|
2631
|
+
await this.activityLog.log(
|
|
2632
|
+
"error",
|
|
2633
|
+
`Circuit breaker: backing off ${Math.round(backoffMs / 1e3)}s after ${this.consecutiveFailures} failures`
|
|
2634
|
+
);
|
|
2635
|
+
await this.sleep(backoffMs);
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
if (this.stopping) break;
|
|
2640
|
+
await this.eventQueue.waitForEvent(this.config.supervisor.idleIntervalMs);
|
|
2641
|
+
}
|
|
2642
|
+
await this.activityLog.log("heartbeat", "Supervisor heartbeat loop stopped");
|
|
2643
|
+
}
|
|
2644
|
+
stop() {
|
|
2645
|
+
this.stopping = true;
|
|
2646
|
+
this.activeAbort?.abort(new Error("Supervisor shutting down"));
|
|
2647
|
+
this.eventQueue.interrupt();
|
|
2648
|
+
}
|
|
2649
|
+
async runHeartbeat() {
|
|
2650
|
+
const startTime = Date.now();
|
|
2651
|
+
const heartbeatId = randomUUID3();
|
|
2652
|
+
const state = await this.readState();
|
|
2653
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2654
|
+
const todayCost = state?.costResetDate === today ? state.todayCostUsd ?? 0 : 0;
|
|
2655
|
+
if (todayCost >= this.config.supervisor.dailyCapUsd) {
|
|
2656
|
+
await this.activityLog.log(
|
|
2657
|
+
"error",
|
|
2658
|
+
`Supervisor daily budget exceeded ($${todayCost.toFixed(2)} / $${this.config.supervisor.dailyCapUsd}). Skipping heartbeat.`
|
|
2659
|
+
);
|
|
2660
|
+
await this.sleep(this.config.supervisor.idleIntervalMs);
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
const events = this.eventQueue.drain();
|
|
2664
|
+
const memory = await loadMemory(this.supervisorDir);
|
|
2665
|
+
const memoryCheck = checkMemorySize(memory);
|
|
2666
|
+
const mcpServerNames = this.config.mcpServers ? Object.keys(this.config.mcpServers) : [];
|
|
2667
|
+
const prompt = buildHeartbeatPrompt({
|
|
2668
|
+
repos: this.config.repos,
|
|
2669
|
+
memory,
|
|
2670
|
+
memorySizeKB: memoryCheck.sizeKB,
|
|
2671
|
+
events,
|
|
2672
|
+
budgetStatus: {
|
|
2673
|
+
todayUsd: todayCost,
|
|
2674
|
+
capUsd: this.config.supervisor.dailyCapUsd,
|
|
2675
|
+
remainingPct: (this.config.supervisor.dailyCapUsd - todayCost) / this.config.supervisor.dailyCapUsd * 100
|
|
2676
|
+
},
|
|
2677
|
+
activeRuns: [],
|
|
2678
|
+
// TODO: read from persisted runs
|
|
2679
|
+
heartbeatCount: state?.heartbeatCount ?? 0,
|
|
2680
|
+
mcpServerNames,
|
|
2681
|
+
customInstructions: this.customInstructions
|
|
2682
|
+
});
|
|
2683
|
+
await this.activityLog.log("heartbeat", `Heartbeat #${state?.heartbeatCount ?? 0} starting`, {
|
|
2684
|
+
heartbeatId,
|
|
2685
|
+
eventCount: events.length,
|
|
2686
|
+
triggeredBy: events.map((e) => e.kind)
|
|
2687
|
+
});
|
|
2688
|
+
const abortController = new AbortController();
|
|
2689
|
+
this.activeAbort = abortController;
|
|
2690
|
+
const timeout = setTimeout(() => {
|
|
2691
|
+
abortController.abort(new Error("Heartbeat timeout exceeded"));
|
|
2692
|
+
}, this.config.supervisor.heartbeatTimeoutMs);
|
|
2693
|
+
let output = "";
|
|
2694
|
+
let costUsd = 0;
|
|
2695
|
+
let turnCount = 0;
|
|
2696
|
+
try {
|
|
2697
|
+
const sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
2698
|
+
const queryOptions = {
|
|
2699
|
+
cwd: homedir2(),
|
|
2700
|
+
maxTurns: 50,
|
|
2701
|
+
allowedTools: ["Bash", "Read"],
|
|
2702
|
+
permissionMode: "bypassPermissions",
|
|
2703
|
+
allowDangerouslySkipPermissions: true
|
|
2704
|
+
};
|
|
2705
|
+
if (this.config.mcpServers) {
|
|
2706
|
+
queryOptions.mcpServers = this.config.mcpServers;
|
|
2707
|
+
}
|
|
2708
|
+
const stream = sdk.query({ prompt, options: queryOptions });
|
|
2709
|
+
for await (const message of stream) {
|
|
2710
|
+
if (abortController.signal.aborted) break;
|
|
2711
|
+
const msg = message;
|
|
2712
|
+
if (msg.type === "system" && msg.subtype === "init") {
|
|
2713
|
+
this.sessionId = msg.session_id;
|
|
2714
|
+
}
|
|
2715
|
+
if (msg.type === "result") {
|
|
2716
|
+
output = msg.result ?? "";
|
|
2717
|
+
costUsd = msg.total_cost_usd ?? 0;
|
|
2718
|
+
turnCount = msg.num_turns ?? 0;
|
|
2719
|
+
}
|
|
2720
|
+
await this.logStreamMessage(msg, heartbeatId);
|
|
2721
|
+
}
|
|
2722
|
+
} finally {
|
|
2723
|
+
clearTimeout(timeout);
|
|
2724
|
+
this.activeAbort = null;
|
|
2725
|
+
}
|
|
2726
|
+
const newMemory = extractMemoryFromResponse(output);
|
|
2727
|
+
if (newMemory) {
|
|
2728
|
+
await saveMemory(this.supervisorDir, newMemory);
|
|
2729
|
+
}
|
|
2730
|
+
const durationMs = Date.now() - startTime;
|
|
2731
|
+
await this.updateState({
|
|
2732
|
+
sessionId: this.sessionId,
|
|
2733
|
+
lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2734
|
+
heartbeatCount: (state?.heartbeatCount ?? 0) + 1,
|
|
2735
|
+
totalCostUsd: (state?.totalCostUsd ?? 0) + costUsd,
|
|
2736
|
+
todayCostUsd: todayCost + costUsd,
|
|
2737
|
+
costResetDate: today
|
|
2738
|
+
});
|
|
2739
|
+
await this.activityLog.log(
|
|
2740
|
+
"heartbeat",
|
|
2741
|
+
`Heartbeat #${(state?.heartbeatCount ?? 0) + 1} complete`,
|
|
2742
|
+
{
|
|
2743
|
+
heartbeatId,
|
|
2744
|
+
costUsd,
|
|
2745
|
+
durationMs,
|
|
2746
|
+
turnCount,
|
|
2747
|
+
memoryUpdated: !!newMemory,
|
|
2748
|
+
responseSummary: output.slice(0, 500)
|
|
2749
|
+
}
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
async readState() {
|
|
2753
|
+
try {
|
|
2754
|
+
const raw = await readFile9(this.statePath, "utf-8");
|
|
2755
|
+
return JSON.parse(raw);
|
|
2756
|
+
} catch {
|
|
2757
|
+
return null;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
async updateState(updates) {
|
|
2761
|
+
try {
|
|
2762
|
+
const raw = await readFile9(this.statePath, "utf-8");
|
|
2763
|
+
const state = JSON.parse(raw);
|
|
2764
|
+
Object.assign(state, updates);
|
|
2765
|
+
await writeFile5(this.statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
2766
|
+
} catch {
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* Load custom instructions from SUPERVISOR.md.
|
|
2771
|
+
* Resolution order:
|
|
2772
|
+
* 1. Explicit path via `supervisor.instructions` in config
|
|
2773
|
+
* 2. Default: ~/.neo/SUPERVISOR.md
|
|
2774
|
+
*/
|
|
2775
|
+
async loadInstructions() {
|
|
2776
|
+
const candidates = [];
|
|
2777
|
+
if (this.config.supervisor.instructions) {
|
|
2778
|
+
candidates.push(path12.resolve(this.config.supervisor.instructions));
|
|
2779
|
+
}
|
|
2780
|
+
candidates.push(path12.join(getDataDir(), "SUPERVISOR.md"));
|
|
2781
|
+
for (const filePath of candidates) {
|
|
2782
|
+
try {
|
|
2783
|
+
const content = await readFile9(filePath, "utf-8");
|
|
2784
|
+
await this.activityLog.log("event", `Loaded custom instructions from ${filePath}`);
|
|
2785
|
+
return content;
|
|
2786
|
+
} catch {
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
return void 0;
|
|
2790
|
+
}
|
|
2791
|
+
/** Route a single SDK stream message to the appropriate log handler. */
|
|
2792
|
+
async logStreamMessage(msg, heartbeatId) {
|
|
2793
|
+
if (msg.type !== "assistant") return;
|
|
2794
|
+
if (!msg.subtype) {
|
|
2795
|
+
await this.logContentBlocks(msg, heartbeatId);
|
|
2796
|
+
} else if (msg.subtype === "tool_use") {
|
|
2797
|
+
await this.logToolUse(msg, heartbeatId);
|
|
2798
|
+
} else if (msg.subtype === "tool_result") {
|
|
2799
|
+
await this.logToolResult(msg, heartbeatId);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
/** Log thinking and plan blocks from assistant content. */
|
|
2803
|
+
async logContentBlocks(msg, heartbeatId) {
|
|
2804
|
+
const content = msg.message?.content;
|
|
2805
|
+
if (!content) return;
|
|
2806
|
+
for (const block of content) {
|
|
2807
|
+
if (block.type === "thinking" && block.thinking) {
|
|
2808
|
+
await this.activityLog.log("thinking", block.thinking.slice(0, 500), { heartbeatId });
|
|
2809
|
+
}
|
|
2810
|
+
if (block.type === "text" && block.text) {
|
|
2811
|
+
await this.activityLog.log("plan", block.text.slice(0, 500), { heartbeatId });
|
|
2812
|
+
break;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
/** Log tool use events — distinguish MCP tools from built-in tools. */
|
|
2817
|
+
async logToolUse(msg, heartbeatId) {
|
|
2818
|
+
const toolName = String(msg.tool ?? "unknown");
|
|
2819
|
+
const isMcp = toolName.startsWith("mcp__");
|
|
2820
|
+
await this.activityLog.log(
|
|
2821
|
+
isMcp ? "tool_use" : "action",
|
|
2822
|
+
isMcp ? toolName : `Tool use: ${toolName}`,
|
|
2823
|
+
{ heartbeatId, tool: toolName, input: msg.input }
|
|
2824
|
+
);
|
|
2825
|
+
}
|
|
2826
|
+
/** Detect agent dispatches from bash tool results. */
|
|
2827
|
+
async logToolResult(msg, heartbeatId) {
|
|
2828
|
+
const result = String(msg.result ?? "");
|
|
2829
|
+
const runMatch = /Run\s+(\S+)\s+dispatched/i.exec(result);
|
|
2830
|
+
if (runMatch) {
|
|
2831
|
+
await this.activityLog.log("dispatch", `Agent dispatched: ${runMatch[1]}`, {
|
|
2832
|
+
heartbeatId,
|
|
2833
|
+
runId: runMatch[1]
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
sleep(ms) {
|
|
2838
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
|
|
2842
|
+
// src/supervisor/webhook-server.ts
|
|
2843
|
+
import { timingSafeEqual } from "crypto";
|
|
2844
|
+
import { appendFile as appendFile5 } from "fs/promises";
|
|
2845
|
+
import { createServer } from "http";
|
|
2846
|
+
var MAX_BODY_SIZE = 1024 * 1024;
|
|
2847
|
+
var WebhookServer = class {
|
|
2848
|
+
server = null;
|
|
2849
|
+
port;
|
|
2850
|
+
secret;
|
|
2851
|
+
eventsPath;
|
|
2852
|
+
onEvent;
|
|
2853
|
+
getHealth;
|
|
2854
|
+
constructor(options) {
|
|
2855
|
+
this.port = options.port;
|
|
2856
|
+
this.secret = options.secret;
|
|
2857
|
+
this.eventsPath = options.eventsPath;
|
|
2858
|
+
this.onEvent = options.onEvent;
|
|
2859
|
+
this.getHealth = options.getHealth;
|
|
2860
|
+
}
|
|
2861
|
+
async start() {
|
|
2862
|
+
return new Promise((resolve4, reject) => {
|
|
2863
|
+
this.server = createServer((req, res) => {
|
|
2864
|
+
this.handleRequest(req, res).catch((err) => {
|
|
2865
|
+
this.sendJson(res, 500, { error: "Internal server error", detail: String(err) });
|
|
2866
|
+
});
|
|
2867
|
+
});
|
|
2868
|
+
this.server.on("error", reject);
|
|
2869
|
+
this.server.listen(this.port, () => {
|
|
2870
|
+
resolve4();
|
|
2871
|
+
});
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
async stop() {
|
|
2875
|
+
return new Promise((resolve4) => {
|
|
2876
|
+
if (!this.server) {
|
|
2877
|
+
resolve4();
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
this.server.close(() => resolve4());
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
async handleRequest(req, res) {
|
|
2884
|
+
const url = req.url ?? "/";
|
|
2885
|
+
if (req.method === "GET" && url === "/health") {
|
|
2886
|
+
this.sendJson(res, 200, this.getHealth());
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
if (req.method === "POST" && url === "/webhook") {
|
|
2890
|
+
await this.handleWebhook(req, res);
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
this.sendJson(res, 404, { error: "Not found" });
|
|
2894
|
+
}
|
|
2895
|
+
async handleWebhook(req, res) {
|
|
2896
|
+
if (this.secret) {
|
|
2897
|
+
const provided = req.headers["x-neo-secret"];
|
|
2898
|
+
if (!provided) {
|
|
2899
|
+
this.sendJson(res, 401, { error: "Missing X-Neo-Secret header" });
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
const expected = Buffer.from(this.secret, "utf-8");
|
|
2903
|
+
const actual = Buffer.from(provided, "utf-8");
|
|
2904
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
2905
|
+
this.sendJson(res, 403, { error: "Invalid secret" });
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
const body = await this.readBody(req);
|
|
2910
|
+
if (body === null) {
|
|
2911
|
+
this.sendJson(res, 413, { error: "Payload too large (max 1MB)" });
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
let parsed;
|
|
2915
|
+
try {
|
|
2916
|
+
parsed = JSON.parse(body);
|
|
2917
|
+
} catch {
|
|
2918
|
+
this.sendJson(res, 400, { error: "Invalid JSON" });
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
const event = {
|
|
2922
|
+
id: typeof parsed.id === "string" ? parsed.id : void 0,
|
|
2923
|
+
source: typeof parsed.source === "string" ? parsed.source : void 0,
|
|
2924
|
+
event: typeof parsed.event === "string" ? parsed.event : void 0,
|
|
2925
|
+
payload: parsed.payload ?? parsed,
|
|
2926
|
+
receivedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2927
|
+
};
|
|
2928
|
+
await appendFile5(this.eventsPath, `${JSON.stringify(event)}
|
|
2929
|
+
`, "utf-8");
|
|
2930
|
+
this.onEvent(event);
|
|
2931
|
+
this.sendJson(res, 200, { ok: true, id: event.id });
|
|
2932
|
+
}
|
|
2933
|
+
readBody(req) {
|
|
2934
|
+
return new Promise((resolve4) => {
|
|
2935
|
+
const chunks = [];
|
|
2936
|
+
let size = 0;
|
|
2937
|
+
req.on("data", (chunk) => {
|
|
2938
|
+
size += chunk.length;
|
|
2939
|
+
if (size > MAX_BODY_SIZE) {
|
|
2940
|
+
resolve4(null);
|
|
2941
|
+
req.destroy();
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
chunks.push(chunk);
|
|
2945
|
+
});
|
|
2946
|
+
req.on("end", () => {
|
|
2947
|
+
resolve4(Buffer.concat(chunks).toString("utf-8"));
|
|
2948
|
+
});
|
|
2949
|
+
req.on("error", () => resolve4(null));
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
sendJson(res, status, data) {
|
|
2953
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2954
|
+
res.end(JSON.stringify(data));
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
|
|
2958
|
+
// src/supervisor/daemon.ts
|
|
2959
|
+
var SupervisorDaemon = class {
|
|
2960
|
+
name;
|
|
2961
|
+
config;
|
|
2962
|
+
dir;
|
|
2963
|
+
webhookServer = null;
|
|
2964
|
+
eventQueue = null;
|
|
2965
|
+
heartbeatLoop = null;
|
|
2966
|
+
activityLog = null;
|
|
2967
|
+
sessionId = "";
|
|
2968
|
+
constructor(options) {
|
|
2969
|
+
this.name = options.name;
|
|
2970
|
+
this.config = options.config;
|
|
2971
|
+
this.dir = getSupervisorDir(options.name);
|
|
2972
|
+
}
|
|
2973
|
+
async start() {
|
|
2974
|
+
await mkdir6(this.dir, { recursive: true });
|
|
2975
|
+
const lockPath = path13.join(this.dir, "daemon.lock");
|
|
2976
|
+
if (existsSync5(lockPath)) {
|
|
2977
|
+
const lockPid = await this.readLockPid(lockPath);
|
|
2978
|
+
if (lockPid && this.isProcessAlive(lockPid)) {
|
|
2979
|
+
throw new Error(
|
|
2980
|
+
`Supervisor "${this.name}" already running (PID ${lockPid}). Use --kill first.`
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
await rm2(lockPath, { force: true });
|
|
2984
|
+
}
|
|
2985
|
+
const tempLock = `${lockPath}.${process.pid}`;
|
|
2986
|
+
await writeFile6(tempLock, String(process.pid), "utf-8");
|
|
2987
|
+
const { rename: rename2 } = await import("fs/promises");
|
|
2988
|
+
await rename2(tempLock, lockPath);
|
|
2989
|
+
const existingState = await this.readState();
|
|
2990
|
+
if (existingState?.sessionId && existingState.status !== "stopped") {
|
|
2991
|
+
this.sessionId = existingState.sessionId;
|
|
2992
|
+
} else {
|
|
2993
|
+
this.sessionId = randomUUID4();
|
|
2994
|
+
}
|
|
2995
|
+
this.activityLog = new ActivityLog(this.dir);
|
|
2996
|
+
this.eventQueue = new EventQueue({
|
|
2997
|
+
maxEventsPerSec: this.config.supervisor.maxEventsPerSec
|
|
2998
|
+
});
|
|
2999
|
+
const inboxPath = path13.join(this.dir, "inbox.jsonl");
|
|
3000
|
+
const eventsPath = path13.join(this.dir, "events.jsonl");
|
|
3001
|
+
await this.eventQueue.replayUnprocessed(inboxPath, eventsPath);
|
|
3002
|
+
this.eventQueue.startWatching(inboxPath, eventsPath);
|
|
3003
|
+
this.webhookServer = new WebhookServer({
|
|
3004
|
+
port: this.config.supervisor.port,
|
|
3005
|
+
secret: this.config.supervisor.secret,
|
|
3006
|
+
eventsPath,
|
|
3007
|
+
onEvent: (event) => {
|
|
3008
|
+
this.eventQueue?.push({ kind: "webhook", data: event });
|
|
3009
|
+
},
|
|
3010
|
+
getHealth: () => this.getHealthInfo()
|
|
3011
|
+
});
|
|
3012
|
+
await this.webhookServer.start();
|
|
3013
|
+
await this.writeState({
|
|
3014
|
+
pid: process.pid,
|
|
3015
|
+
tmuxSession: `neo-${this.name}`,
|
|
3016
|
+
sessionId: this.sessionId,
|
|
3017
|
+
port: this.config.supervisor.port,
|
|
3018
|
+
cwd: homedir3(),
|
|
3019
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3020
|
+
lastHeartbeat: existingState?.lastHeartbeat,
|
|
3021
|
+
heartbeatCount: existingState?.heartbeatCount ?? 0,
|
|
3022
|
+
totalCostUsd: existingState?.totalCostUsd ?? 0,
|
|
3023
|
+
todayCostUsd: existingState?.todayCostUsd ?? 0,
|
|
3024
|
+
costResetDate: existingState?.costResetDate,
|
|
3025
|
+
status: "running"
|
|
3026
|
+
});
|
|
3027
|
+
const shutdown = () => {
|
|
3028
|
+
this.stop().catch(console.error);
|
|
3029
|
+
};
|
|
3030
|
+
process.on("SIGTERM", shutdown);
|
|
3031
|
+
process.on("SIGINT", shutdown);
|
|
3032
|
+
await this.activityLog.log(
|
|
3033
|
+
"event",
|
|
3034
|
+
`Supervisor "${this.name}" started on port ${this.config.supervisor.port}`
|
|
3035
|
+
);
|
|
3036
|
+
const statePath = path13.join(this.dir, "state.json");
|
|
3037
|
+
this.heartbeatLoop = new HeartbeatLoop({
|
|
3038
|
+
config: this.config,
|
|
3039
|
+
supervisorDir: this.dir,
|
|
3040
|
+
statePath,
|
|
3041
|
+
sessionId: this.sessionId,
|
|
3042
|
+
eventQueue: this.eventQueue,
|
|
3043
|
+
activityLog: this.activityLog
|
|
3044
|
+
});
|
|
3045
|
+
await this.heartbeatLoop.start();
|
|
3046
|
+
}
|
|
3047
|
+
async stop() {
|
|
3048
|
+
this.heartbeatLoop?.stop();
|
|
3049
|
+
this.eventQueue?.stopWatching();
|
|
3050
|
+
if (this.webhookServer) {
|
|
3051
|
+
await this.webhookServer.stop();
|
|
3052
|
+
}
|
|
3053
|
+
const state = await this.readState();
|
|
3054
|
+
if (state) {
|
|
3055
|
+
state.status = "stopped";
|
|
3056
|
+
await this.writeState(state);
|
|
3057
|
+
}
|
|
3058
|
+
const lockPath = path13.join(this.dir, "daemon.lock");
|
|
3059
|
+
await rm2(lockPath, { force: true });
|
|
3060
|
+
if (this.activityLog) {
|
|
3061
|
+
await this.activityLog.log("event", `Supervisor "${this.name}" stopped`);
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
getHealthInfo() {
|
|
3065
|
+
return {
|
|
3066
|
+
status: "ok",
|
|
3067
|
+
name: this.name,
|
|
3068
|
+
pid: process.pid,
|
|
3069
|
+
uptime: process.uptime(),
|
|
3070
|
+
sessionId: this.sessionId,
|
|
3071
|
+
port: this.config.supervisor.port
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
async readState() {
|
|
3075
|
+
const statePath = path13.join(this.dir, "state.json");
|
|
3076
|
+
try {
|
|
3077
|
+
const raw = await readFile10(statePath, "utf-8");
|
|
3078
|
+
return JSON.parse(raw);
|
|
3079
|
+
} catch {
|
|
3080
|
+
return null;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
async writeState(state) {
|
|
3084
|
+
const statePath = path13.join(this.dir, "state.json");
|
|
3085
|
+
await writeFile6(statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
3086
|
+
}
|
|
3087
|
+
async readLockPid(lockPath) {
|
|
3088
|
+
try {
|
|
3089
|
+
const raw = await readFile10(lockPath, "utf-8");
|
|
3090
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
3091
|
+
return Number.isNaN(pid) ? null : pid;
|
|
3092
|
+
} catch {
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
isProcessAlive(pid) {
|
|
3097
|
+
try {
|
|
3098
|
+
process.kill(pid, 0);
|
|
3099
|
+
return true;
|
|
3100
|
+
} catch {
|
|
3101
|
+
return false;
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
|
|
3106
|
+
// src/index.ts
|
|
3107
|
+
var VERSION = "0.1.0";
|
|
3108
|
+
export {
|
|
3109
|
+
ActivityLog,
|
|
3110
|
+
AgentRegistry,
|
|
3111
|
+
CostJournal,
|
|
3112
|
+
EventJournal,
|
|
3113
|
+
EventQueue,
|
|
3114
|
+
HeartbeatLoop,
|
|
3115
|
+
NeoEventEmitter,
|
|
3116
|
+
Orchestrator,
|
|
3117
|
+
Semaphore,
|
|
3118
|
+
SessionError,
|
|
3119
|
+
SupervisorDaemon,
|
|
3120
|
+
VERSION,
|
|
3121
|
+
WebhookDispatcher,
|
|
3122
|
+
WebhookServer,
|
|
3123
|
+
WorkflowRegistry,
|
|
3124
|
+
activityEntrySchema,
|
|
3125
|
+
addRepoToGlobalConfig,
|
|
3126
|
+
agentConfigSchema,
|
|
3127
|
+
agentModelSchema,
|
|
3128
|
+
agentSandboxSchema,
|
|
3129
|
+
agentToolEntrySchema,
|
|
3130
|
+
agentToolSchema,
|
|
3131
|
+
auditLog,
|
|
3132
|
+
budgetGuard,
|
|
3133
|
+
buildHeartbeatPrompt,
|
|
3134
|
+
buildMiddlewareChain,
|
|
3135
|
+
buildSDKHooks,
|
|
3136
|
+
buildSandboxConfig,
|
|
3137
|
+
checkMemorySize,
|
|
3138
|
+
cleanupOrphanedWorktrees,
|
|
3139
|
+
createBranch,
|
|
3140
|
+
createWorktree,
|
|
3141
|
+
deleteBranch,
|
|
3142
|
+
extractMemoryFromResponse,
|
|
3143
|
+
fetchRemote,
|
|
3144
|
+
getBranchName,
|
|
3145
|
+
getCurrentBranch,
|
|
3146
|
+
getDataDir,
|
|
3147
|
+
getJournalsDir,
|
|
3148
|
+
getRepoRunsDir,
|
|
3149
|
+
getRunDispatchPath,
|
|
3150
|
+
getRunLogPath,
|
|
3151
|
+
getRunsDir,
|
|
3152
|
+
getSupervisorActivityPath,
|
|
3153
|
+
getSupervisorDir,
|
|
3154
|
+
getSupervisorEventsPath,
|
|
3155
|
+
getSupervisorInboxPath,
|
|
3156
|
+
getSupervisorLockPath,
|
|
3157
|
+
getSupervisorMemoryPath,
|
|
3158
|
+
getSupervisorStatePath,
|
|
3159
|
+
getSupervisorsDir,
|
|
3160
|
+
globalConfigSchema,
|
|
3161
|
+
inboxMessageSchema,
|
|
3162
|
+
listReposFromGlobalConfig,
|
|
3163
|
+
listWorktrees,
|
|
3164
|
+
loadAgentFile,
|
|
3165
|
+
loadConfig,
|
|
3166
|
+
loadGlobalConfig,
|
|
3167
|
+
loadMemory,
|
|
3168
|
+
loadWorkflow,
|
|
3169
|
+
loopDetection,
|
|
3170
|
+
matchesFilter,
|
|
3171
|
+
mcpServerConfigSchema,
|
|
3172
|
+
neoConfigSchema,
|
|
3173
|
+
parseOutput,
|
|
3174
|
+
pushBranch,
|
|
3175
|
+
removeRepoFromGlobalConfig,
|
|
3176
|
+
removeWorktree,
|
|
3177
|
+
repoConfigSchema,
|
|
3178
|
+
resolveAgent,
|
|
3179
|
+
runSession,
|
|
3180
|
+
runWithRecovery,
|
|
3181
|
+
saveMemory,
|
|
3182
|
+
supervisorDaemonStateSchema,
|
|
3183
|
+
supervisorDaemonStateSchema as supervisorStateSchema,
|
|
3184
|
+
toRepoSlug,
|
|
3185
|
+
webhookIncomingEventSchema,
|
|
3186
|
+
withGitLock,
|
|
3187
|
+
workflowGateDefSchema,
|
|
3188
|
+
workflowStepDefSchema
|
|
3189
|
+
};
|
|
3190
|
+
//# sourceMappingURL=index.js.map
|