@neotx/core 0.1.0-alpha.20 → 0.1.0-alpha.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +65 -4
- package/dist/index.js +578 -207
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -60,8 +60,10 @@ ${issues}`);
|
|
|
60
60
|
const promptPath = path.resolve(path.dirname(filePath), config.prompt);
|
|
61
61
|
try {
|
|
62
62
|
config.prompt = await readFile(promptPath, "utf-8");
|
|
63
|
-
} catch {
|
|
64
|
-
throw new Error(
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Prompt file not found: ${promptPath} (referenced in ${filePath}). Error: ${err instanceof Error ? err.message : String(err)}`
|
|
66
|
+
);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
return config;
|
|
@@ -198,7 +200,10 @@ var AgentRegistry = class {
|
|
|
198
200
|
let customConfigs;
|
|
199
201
|
try {
|
|
200
202
|
customConfigs = await loadAgentsFromDir(this.customDir);
|
|
201
|
-
} catch {
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.debug(
|
|
205
|
+
`[registry] Custom agents dir not found: ${err instanceof Error ? err.message : String(err)}`
|
|
206
|
+
);
|
|
202
207
|
customConfigs = [];
|
|
203
208
|
}
|
|
204
209
|
for (const config of customConfigs) {
|
|
@@ -505,6 +510,15 @@ var sessionsConfigSchema = z2.object({
|
|
|
505
510
|
maxDurationMs: z2.number().default(36e5),
|
|
506
511
|
dir: z2.string().default("/tmp/neo-sessions")
|
|
507
512
|
}).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5, dir: "/tmp/neo-sessions" });
|
|
513
|
+
var journalConfigSchema = z2.object({
|
|
514
|
+
maxCostJournalSizeBytes: z2.number().default(100 * 1024 * 1024),
|
|
515
|
+
// 100MB
|
|
516
|
+
maxEventJournalSizeBytes: z2.number().default(500 * 1024 * 1024)
|
|
517
|
+
// 500MB
|
|
518
|
+
}).default({
|
|
519
|
+
maxCostJournalSizeBytes: 100 * 1024 * 1024,
|
|
520
|
+
maxEventJournalSizeBytes: 500 * 1024 * 1024
|
|
521
|
+
});
|
|
508
522
|
var supervisorConfigSchema = z2.object({
|
|
509
523
|
port: z2.number().default(7777),
|
|
510
524
|
secret: z2.string().optional(),
|
|
@@ -544,6 +558,7 @@ var globalConfigSchema = z2.object({
|
|
|
544
558
|
budget: budgetConfigSchema,
|
|
545
559
|
recovery: recoveryConfigSchema,
|
|
546
560
|
sessions: sessionsConfigSchema,
|
|
561
|
+
journal: journalConfigSchema.optional(),
|
|
547
562
|
webhooks: z2.array(
|
|
548
563
|
z2.object({
|
|
549
564
|
url: z2.string().url(),
|
|
@@ -598,6 +613,7 @@ var defaultConfig = {
|
|
|
598
613
|
budget: budgetConfigSchema.parse(void 0),
|
|
599
614
|
recovery: recoveryConfigSchema.parse(void 0),
|
|
600
615
|
sessions: sessionsConfigSchema.parse(void 0),
|
|
616
|
+
journal: journalConfigSchema.parse(void 0),
|
|
601
617
|
webhooks: [],
|
|
602
618
|
supervisor: {
|
|
603
619
|
port: 7777,
|
|
@@ -727,6 +743,7 @@ var ConfigStore = class {
|
|
|
727
743
|
}
|
|
728
744
|
const parsed = neoConfigSchema.safeParse(raw);
|
|
729
745
|
if (!parsed.success) {
|
|
746
|
+
console.warn(`[neo] Failed to parse config at ${globalPath}:`, parsed.error.message);
|
|
730
747
|
return null;
|
|
731
748
|
}
|
|
732
749
|
return parsed.data;
|
|
@@ -745,6 +762,7 @@ var ConfigStore = class {
|
|
|
745
762
|
}
|
|
746
763
|
const parsed = repoOverrideConfigSchema.safeParse(raw);
|
|
747
764
|
if (!parsed.success) {
|
|
765
|
+
console.warn(`[neo] Failed to parse config at ${repoConfigPath}:`, parsed.error.message);
|
|
748
766
|
return null;
|
|
749
767
|
}
|
|
750
768
|
return parsed.data;
|
|
@@ -844,6 +862,9 @@ var ConfigWatcher = class extends EventEmitter {
|
|
|
844
862
|
this.debounceTimer = null;
|
|
845
863
|
this.reloadConfig();
|
|
846
864
|
}, this.debounceMs);
|
|
865
|
+
if (typeof this.debounceTimer === "object" && "unref" in this.debounceTimer) {
|
|
866
|
+
this.debounceTimer.unref();
|
|
867
|
+
}
|
|
847
868
|
}
|
|
848
869
|
/**
|
|
849
870
|
* Reloads the config and emits 'change' event.
|
|
@@ -857,6 +878,51 @@ var ConfigWatcher = class extends EventEmitter {
|
|
|
857
878
|
}
|
|
858
879
|
};
|
|
859
880
|
|
|
881
|
+
// src/config/parser.ts
|
|
882
|
+
var NEO_CONFIG_KEYS = {
|
|
883
|
+
"": /* @__PURE__ */ new Set([
|
|
884
|
+
"repos",
|
|
885
|
+
"concurrency",
|
|
886
|
+
"budget",
|
|
887
|
+
"recovery",
|
|
888
|
+
"sessions",
|
|
889
|
+
"webhooks",
|
|
890
|
+
"supervisor",
|
|
891
|
+
"memory",
|
|
892
|
+
"mcpServers",
|
|
893
|
+
"claudeCodePath",
|
|
894
|
+
"idempotency"
|
|
895
|
+
]),
|
|
896
|
+
concurrency: /* @__PURE__ */ new Set(["maxSessions", "maxPerRepo", "queueMax"]),
|
|
897
|
+
budget: /* @__PURE__ */ new Set(["dailyCapUsd", "alertThresholdPct"]),
|
|
898
|
+
recovery: /* @__PURE__ */ new Set(["maxRetries", "backoffBaseMs"]),
|
|
899
|
+
sessions: /* @__PURE__ */ new Set(["initTimeoutMs", "maxDurationMs", "dir"]),
|
|
900
|
+
supervisor: /* @__PURE__ */ new Set([
|
|
901
|
+
"port",
|
|
902
|
+
"secret",
|
|
903
|
+
"heartbeatTimeoutMs",
|
|
904
|
+
"maxConsecutiveFailures",
|
|
905
|
+
"maxEventsPerSec",
|
|
906
|
+
"dailyCapUsd",
|
|
907
|
+
"consolidationIntervalMs",
|
|
908
|
+
"compactionIntervalMs",
|
|
909
|
+
"eventTimeoutMs",
|
|
910
|
+
"instructions",
|
|
911
|
+
"idleSkipMax",
|
|
912
|
+
"activeWorkSkipMax",
|
|
913
|
+
"autoDecide"
|
|
914
|
+
]),
|
|
915
|
+
memory: /* @__PURE__ */ new Set(["embeddings"]),
|
|
916
|
+
idempotency: /* @__PURE__ */ new Set(["enabled", "key", "ttlMs"])
|
|
917
|
+
};
|
|
918
|
+
var REPO_OVERRIDE_KEYS = {
|
|
919
|
+
"": /* @__PURE__ */ new Set(["concurrency", "budget", "recovery", "sessions"]),
|
|
920
|
+
concurrency: NEO_CONFIG_KEYS.concurrency,
|
|
921
|
+
budget: NEO_CONFIG_KEYS.budget,
|
|
922
|
+
recovery: NEO_CONFIG_KEYS.recovery,
|
|
923
|
+
sessions: NEO_CONFIG_KEYS.sessions
|
|
924
|
+
};
|
|
925
|
+
|
|
860
926
|
// src/config.ts
|
|
861
927
|
var DEFAULT_GLOBAL_CONFIG = {
|
|
862
928
|
repos: [],
|
|
@@ -888,8 +954,10 @@ async function loadConfig(configPath) {
|
|
|
888
954
|
let raw;
|
|
889
955
|
try {
|
|
890
956
|
raw = await readFile2(configPath, "utf-8");
|
|
891
|
-
} catch {
|
|
892
|
-
throw new Error(
|
|
957
|
+
} catch (err) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`Config file not found: ${configPath}. Run 'neo init' to get started. (${err instanceof Error ? err.message : String(err)})`
|
|
960
|
+
);
|
|
893
961
|
}
|
|
894
962
|
const parsed = parseYamlFile(raw, configPath);
|
|
895
963
|
const result = neoConfigSchema.safeParse(parsed);
|
|
@@ -944,7 +1012,9 @@ async function listReposFromGlobalConfig() {
|
|
|
944
1012
|
}
|
|
945
1013
|
|
|
946
1014
|
// src/cost/journal.ts
|
|
947
|
-
import {
|
|
1015
|
+
import { createReadStream } from "fs";
|
|
1016
|
+
import { appendFile, stat } from "fs/promises";
|
|
1017
|
+
import { createInterface } from "readline";
|
|
948
1018
|
|
|
949
1019
|
// src/shared/date.ts
|
|
950
1020
|
import path5 from "path";
|
|
@@ -968,12 +1038,25 @@ async function ensureDir(dirPath, cache) {
|
|
|
968
1038
|
}
|
|
969
1039
|
|
|
970
1040
|
// src/cost/journal.ts
|
|
1041
|
+
var JournalFileSizeError = class extends Error {
|
|
1042
|
+
constructor(filePath, fileSizeBytes, maxSizeBytes) {
|
|
1043
|
+
super(
|
|
1044
|
+
`Journal file exceeds maximum size: ${filePath} (${(fileSizeBytes / 1024 / 1024).toFixed(2)}MB > ${(maxSizeBytes / 1024 / 1024).toFixed(2)}MB)`
|
|
1045
|
+
);
|
|
1046
|
+
this.filePath = filePath;
|
|
1047
|
+
this.fileSizeBytes = fileSizeBytes;
|
|
1048
|
+
this.maxSizeBytes = maxSizeBytes;
|
|
1049
|
+
this.name = "JournalFileSizeError";
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
971
1052
|
var CostJournal = class {
|
|
972
1053
|
dir;
|
|
973
1054
|
dirCache = /* @__PURE__ */ new Set();
|
|
974
1055
|
dayCache = null;
|
|
1056
|
+
maxFileSizeBytes;
|
|
975
1057
|
constructor(options) {
|
|
976
1058
|
this.dir = options.dir;
|
|
1059
|
+
this.maxFileSizeBytes = options.maxFileSizeBytes ?? 100 * 1024 * 1024;
|
|
977
1060
|
}
|
|
978
1061
|
async append(entry) {
|
|
979
1062
|
await ensureDir(this.dir, this.dirCache);
|
|
@@ -991,8 +1074,13 @@ var CostJournal = class {
|
|
|
991
1074
|
const file = fileForDate(d, "cost", this.dir);
|
|
992
1075
|
let total = 0;
|
|
993
1076
|
try {
|
|
994
|
-
const
|
|
995
|
-
|
|
1077
|
+
const stats = await stat(file);
|
|
1078
|
+
if (stats.size > this.maxFileSizeBytes) {
|
|
1079
|
+
throw new JournalFileSizeError(file, stats.size, this.maxFileSizeBytes);
|
|
1080
|
+
}
|
|
1081
|
+
const stream = createReadStream(file, { encoding: "utf-8" });
|
|
1082
|
+
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
1083
|
+
for await (const line of rl) {
|
|
996
1084
|
if (!line.trim()) continue;
|
|
997
1085
|
const entry = JSON.parse(line);
|
|
998
1086
|
if (toDateKey(new Date(entry.timestamp)) === dayKey) {
|
|
@@ -1038,7 +1126,11 @@ var NeoEventEmitter = class {
|
|
|
1038
1126
|
if (eventType !== "error") {
|
|
1039
1127
|
try {
|
|
1040
1128
|
this.emitter.emit("error", error);
|
|
1041
|
-
} catch {
|
|
1129
|
+
} catch (nestedErr) {
|
|
1130
|
+
console.error(
|
|
1131
|
+
"[emitter] Error handler threw:",
|
|
1132
|
+
nestedErr instanceof Error ? nestedErr.message : String(nestedErr)
|
|
1133
|
+
);
|
|
1042
1134
|
}
|
|
1043
1135
|
}
|
|
1044
1136
|
}
|
|
@@ -1046,16 +1138,37 @@ var NeoEventEmitter = class {
|
|
|
1046
1138
|
};
|
|
1047
1139
|
|
|
1048
1140
|
// src/events/journal.ts
|
|
1049
|
-
import { appendFile as appendFile2 } from "fs/promises";
|
|
1141
|
+
import { appendFile as appendFile2, stat as stat2 } from "fs/promises";
|
|
1142
|
+
var JournalFileSizeError2 = class extends Error {
|
|
1143
|
+
constructor(filePath, fileSizeBytes, maxSizeBytes) {
|
|
1144
|
+
super(
|
|
1145
|
+
`Journal file exceeds maximum size: ${filePath} (${(fileSizeBytes / 1024 / 1024).toFixed(2)}MB > ${(maxSizeBytes / 1024 / 1024).toFixed(2)}MB)`
|
|
1146
|
+
);
|
|
1147
|
+
this.filePath = filePath;
|
|
1148
|
+
this.fileSizeBytes = fileSizeBytes;
|
|
1149
|
+
this.maxSizeBytes = maxSizeBytes;
|
|
1150
|
+
this.name = "JournalFileSizeError";
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1050
1153
|
var EventJournal = class {
|
|
1051
1154
|
dir;
|
|
1052
1155
|
dirCache = /* @__PURE__ */ new Set();
|
|
1156
|
+
maxFileSizeBytes;
|
|
1053
1157
|
constructor(options) {
|
|
1054
1158
|
this.dir = options.dir;
|
|
1159
|
+
this.maxFileSizeBytes = options.maxFileSizeBytes ?? 500 * 1024 * 1024;
|
|
1055
1160
|
}
|
|
1056
1161
|
async append(event) {
|
|
1057
1162
|
await ensureDir(this.dir, this.dirCache);
|
|
1058
1163
|
const file = fileForDate(new Date(event.timestamp), "events", this.dir);
|
|
1164
|
+
try {
|
|
1165
|
+
const stats = await stat2(file);
|
|
1166
|
+
if (stats.size > this.maxFileSizeBytes) {
|
|
1167
|
+
throw new JournalFileSizeError2(file, stats.size, this.maxFileSizeBytes);
|
|
1168
|
+
}
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
if (error.code !== "ENOENT") throw error;
|
|
1171
|
+
}
|
|
1059
1172
|
await appendFile2(file, `${JSON.stringify(event)}
|
|
1060
1173
|
`, "utf-8");
|
|
1061
1174
|
}
|
|
@@ -1092,7 +1205,11 @@ var WebhookDispatcher = class {
|
|
|
1092
1205
|
headers["X-Neo-Signature"] = sign(body, webhook.secret);
|
|
1093
1206
|
}
|
|
1094
1207
|
if (RETRY_EVENT_TYPES.has(event.type)) {
|
|
1095
|
-
const p = sendWithRetry(webhook.url, headers, body, webhook.timeoutMs).catch(() => {
|
|
1208
|
+
const p = sendWithRetry(webhook.url, headers, body, webhook.timeoutMs).catch((err) => {
|
|
1209
|
+
console.debug(
|
|
1210
|
+
`[neo] Webhook delivery failed for ${event.type} to ${webhook.url}:`,
|
|
1211
|
+
err
|
|
1212
|
+
);
|
|
1096
1213
|
}).finally(() => this.pending.delete(p));
|
|
1097
1214
|
this.pending.add(p);
|
|
1098
1215
|
} else {
|
|
@@ -1101,7 +1218,8 @@ var WebhookDispatcher = class {
|
|
|
1101
1218
|
headers,
|
|
1102
1219
|
body,
|
|
1103
1220
|
signal: AbortSignal.timeout(webhook.timeoutMs)
|
|
1104
|
-
}).catch(() => {
|
|
1221
|
+
}).catch((err) => {
|
|
1222
|
+
console.debug(`[neo] Webhook delivery failed for ${event.type} to ${webhook.url}:`, err);
|
|
1105
1223
|
});
|
|
1106
1224
|
}
|
|
1107
1225
|
}
|
|
@@ -1122,7 +1240,10 @@ async function sendWithRetry(url, headers, body, timeoutMs) {
|
|
|
1122
1240
|
signal: AbortSignal.timeout(timeoutMs)
|
|
1123
1241
|
});
|
|
1124
1242
|
if (res.ok) return;
|
|
1125
|
-
} catch {
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
console.debug(
|
|
1245
|
+
`[webhook] Network error on attempt ${attempt}: ${err instanceof Error ? err.message : String(err)}`
|
|
1246
|
+
);
|
|
1126
1247
|
}
|
|
1127
1248
|
if (attempt < RETRY_MAX_ATTEMPTS) {
|
|
1128
1249
|
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
@@ -1158,14 +1279,38 @@ import { dirname, resolve } from "path";
|
|
|
1158
1279
|
import { promisify } from "util";
|
|
1159
1280
|
var execFileAsync = promisify(execFile);
|
|
1160
1281
|
var GIT_TIMEOUT = 6e4;
|
|
1282
|
+
function validateGitRef(refName, paramName) {
|
|
1283
|
+
if (!refName || typeof refName !== "string") {
|
|
1284
|
+
throw new Error(`${paramName} must be a non-empty string`);
|
|
1285
|
+
}
|
|
1286
|
+
if (refName.includes("..")) {
|
|
1287
|
+
throw new Error(`${paramName} contains invalid pattern '..' (directory traversal)`);
|
|
1288
|
+
}
|
|
1289
|
+
if (refName.startsWith("-")) {
|
|
1290
|
+
throw new Error(`${paramName} cannot start with '-' (option injection)`);
|
|
1291
|
+
}
|
|
1292
|
+
const validRefPattern = /^[a-zA-Z0-9/_+.-]+$/;
|
|
1293
|
+
if (!validRefPattern.test(refName)) {
|
|
1294
|
+
throw new Error(
|
|
1295
|
+
`${paramName} contains invalid characters. Only alphanumeric, dash, underscore, slash, plus, and dot are allowed. Got: ${refName}`
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1161
1299
|
async function createSessionClone(options) {
|
|
1300
|
+
validateGitRef(options.branch, "branch");
|
|
1301
|
+
validateGitRef(options.baseBranch, "baseBranch");
|
|
1162
1302
|
const repoPath = resolve(options.repoPath);
|
|
1163
1303
|
const sessionDir = resolve(options.sessionDir);
|
|
1164
1304
|
await mkdir3(dirname(sessionDir), { recursive: true });
|
|
1165
1305
|
const remoteUrl = await execFileAsync("git", ["config", "--get", "remote.origin.url"], {
|
|
1166
1306
|
cwd: repoPath,
|
|
1167
1307
|
timeout: GIT_TIMEOUT
|
|
1168
|
-
}).then(({ stdout }) => stdout.trim()).catch(() =>
|
|
1308
|
+
}).then(({ stdout }) => stdout.trim()).catch((err) => {
|
|
1309
|
+
console.debug(
|
|
1310
|
+
`[neo] No remote.origin.url for ${repoPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
1311
|
+
);
|
|
1312
|
+
return "";
|
|
1313
|
+
});
|
|
1169
1314
|
const cloneSource = remoteUrl || repoPath;
|
|
1170
1315
|
await execFileAsync("git", ["clone", "--branch", options.baseBranch, cloneSource, sessionDir], {
|
|
1171
1316
|
timeout: GIT_TIMEOUT
|
|
@@ -1175,7 +1320,12 @@ async function createSessionClone(options) {
|
|
|
1175
1320
|
"git",
|
|
1176
1321
|
["ls-remote", "--heads", "origin", options.branch],
|
|
1177
1322
|
{ cwd: sessionDir, timeout: GIT_TIMEOUT }
|
|
1178
|
-
).then(({ stdout }) => stdout.trim().length > 0).catch(() =>
|
|
1323
|
+
).then(({ stdout }) => stdout.trim().length > 0).catch((err) => {
|
|
1324
|
+
console.debug(
|
|
1325
|
+
`[neo] ls-remote failed for branch ${options.branch}: ${err instanceof Error ? err.message : String(err)}`
|
|
1326
|
+
);
|
|
1327
|
+
return false;
|
|
1328
|
+
});
|
|
1179
1329
|
if (branchExists) {
|
|
1180
1330
|
await execFileAsync("git", ["fetch", "origin", options.branch], {
|
|
1181
1331
|
cwd: sessionDir,
|
|
@@ -1229,14 +1379,20 @@ async function listSessionClones(sessionsBaseDir) {
|
|
|
1229
1379
|
);
|
|
1230
1380
|
const url = originUrl.trim();
|
|
1231
1381
|
if (url) repoPath = resolve(clonePath, url);
|
|
1232
|
-
} catch {
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
console.debug(
|
|
1384
|
+
`[neo] Failed to get origin URL for ${clonePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
1385
|
+
);
|
|
1233
1386
|
}
|
|
1234
1387
|
clones.push({
|
|
1235
1388
|
path: clonePath,
|
|
1236
1389
|
branch: branchOut.trim(),
|
|
1237
1390
|
repoPath
|
|
1238
1391
|
});
|
|
1239
|
-
} catch {
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
console.debug(
|
|
1394
|
+
`[neo] Skipping ${clonePath}, not a valid git repo: ${err instanceof Error ? err.message : String(err)}`
|
|
1395
|
+
);
|
|
1240
1396
|
}
|
|
1241
1397
|
}
|
|
1242
1398
|
return clones;
|
|
@@ -1256,15 +1412,21 @@ async function git(repoPath, args) {
|
|
|
1256
1412
|
return stdout.trim();
|
|
1257
1413
|
}
|
|
1258
1414
|
async function createBranch(repoPath, branch, baseBranch) {
|
|
1415
|
+
validateGitRef(branch, "branch");
|
|
1416
|
+
validateGitRef(baseBranch, "baseBranch");
|
|
1259
1417
|
await git(repoPath, ["branch", branch, baseBranch]);
|
|
1260
1418
|
}
|
|
1261
1419
|
async function pushBranch(repoPath, branch, remote) {
|
|
1420
|
+
validateGitRef(branch, "branch");
|
|
1421
|
+
validateGitRef(remote, "remote");
|
|
1262
1422
|
await git(repoPath, ["push", remote, branch]);
|
|
1263
1423
|
}
|
|
1264
1424
|
async function fetchRemote(repoPath, remote) {
|
|
1425
|
+
validateGitRef(remote, "remote");
|
|
1265
1426
|
await git(repoPath, ["fetch", remote]);
|
|
1266
1427
|
}
|
|
1267
1428
|
async function deleteBranch(repoPath, branch) {
|
|
1429
|
+
validateGitRef(branch, "branch");
|
|
1268
1430
|
await git(repoPath, ["branch", "-D", branch]);
|
|
1269
1431
|
}
|
|
1270
1432
|
async function getCurrentBranch(repoPath) {
|
|
@@ -1277,6 +1439,8 @@ function getBranchName(config, runId, branch) {
|
|
|
1277
1439
|
return `${prefix}/run-${sanitized}`;
|
|
1278
1440
|
}
|
|
1279
1441
|
async function pushSessionBranch(sessionPath, branch, remote) {
|
|
1442
|
+
validateGitRef(branch, "branch");
|
|
1443
|
+
validateGitRef(remote, "remote");
|
|
1280
1444
|
await git(sessionPath, ["push", "-u", remote, branch]);
|
|
1281
1445
|
}
|
|
1282
1446
|
|
|
@@ -1338,15 +1502,21 @@ function auditLog(options) {
|
|
|
1338
1502
|
await appendFile3(filePath, lines.join(""), "utf-8");
|
|
1339
1503
|
buffers.delete(sessionId);
|
|
1340
1504
|
}
|
|
1505
|
+
function stopTimer() {
|
|
1506
|
+
if (flushTimer !== void 0) {
|
|
1507
|
+
clearInterval(flushTimer);
|
|
1508
|
+
flushTimer = void 0;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1341
1511
|
return {
|
|
1342
1512
|
name: "audit-log",
|
|
1343
1513
|
on: "PostToolUse",
|
|
1344
1514
|
async flush() {
|
|
1515
|
+
stopTimer();
|
|
1345
1516
|
await flushAll();
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1517
|
+
},
|
|
1518
|
+
cleanup() {
|
|
1519
|
+
stopTimer();
|
|
1350
1520
|
},
|
|
1351
1521
|
async handler(event, context) {
|
|
1352
1522
|
const entry = {
|
|
@@ -1501,12 +1671,12 @@ function loopDetection(options) {
|
|
|
1501
1671
|
// src/orchestrator.ts
|
|
1502
1672
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
1503
1673
|
import { existsSync as existsSync6 } from "fs";
|
|
1504
|
-
import { mkdir as mkdir6, readFile as
|
|
1674
|
+
import { mkdir as mkdir6, readFile as readFile5 } from "fs/promises";
|
|
1505
1675
|
import path10 from "path";
|
|
1506
1676
|
|
|
1507
1677
|
// src/orchestrator/run-store.ts
|
|
1508
1678
|
import { existsSync as existsSync4 } from "fs";
|
|
1509
|
-
import { mkdir as mkdir5, readdir as readdir3, readFile as
|
|
1679
|
+
import { mkdir as mkdir5, readdir as readdir3, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
1510
1680
|
import path7 from "path";
|
|
1511
1681
|
|
|
1512
1682
|
// src/shared/process.ts
|
|
@@ -1592,7 +1762,7 @@ var RunStore = class {
|
|
|
1592
1762
|
* If so, update its status to "failed" and return it.
|
|
1593
1763
|
*/
|
|
1594
1764
|
async recoverRunIfOrphaned(filePath) {
|
|
1595
|
-
const content = await
|
|
1765
|
+
const content = await readFile3(filePath, "utf-8");
|
|
1596
1766
|
const run = JSON.parse(content);
|
|
1597
1767
|
if (run.status !== "running") return null;
|
|
1598
1768
|
if (run.pid && run.pid === process.pid) return null;
|
|
@@ -1607,13 +1777,13 @@ var RunStore = class {
|
|
|
1607
1777
|
};
|
|
1608
1778
|
|
|
1609
1779
|
// src/orchestrator/prompt-builder.ts
|
|
1610
|
-
import { readFile as
|
|
1780
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1611
1781
|
import path8 from "path";
|
|
1612
1782
|
var INSTRUCTIONS_PATH = ".neo/INSTRUCTIONS.md";
|
|
1613
1783
|
async function loadRepoInstructions(repoPath) {
|
|
1614
1784
|
const filePath = path8.join(repoPath, INSTRUCTIONS_PATH);
|
|
1615
1785
|
try {
|
|
1616
|
-
return await
|
|
1786
|
+
return await readFile4(filePath, "utf-8");
|
|
1617
1787
|
} catch {
|
|
1618
1788
|
return void 0;
|
|
1619
1789
|
}
|
|
@@ -2679,6 +2849,7 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2679
2849
|
return {
|
|
2680
2850
|
paused: this._paused,
|
|
2681
2851
|
activeSessions: [...this._activeSessions.values()],
|
|
2852
|
+
activeRunCount: this.activeRunCount,
|
|
2682
2853
|
queueDepth: this.semaphore.queueDepth(),
|
|
2683
2854
|
costToday: this._costToday,
|
|
2684
2855
|
budgetCapUsd: this.config.budget.dailyCapUsd,
|
|
@@ -2689,6 +2860,15 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2689
2860
|
get activeSessions() {
|
|
2690
2861
|
return [...this._activeSessions.values()];
|
|
2691
2862
|
}
|
|
2863
|
+
get activeRunCount() {
|
|
2864
|
+
let count = 0;
|
|
2865
|
+
for (const session of this._activeSessions.values()) {
|
|
2866
|
+
if (session.status === "running") {
|
|
2867
|
+
count++;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
return count;
|
|
2871
|
+
}
|
|
2692
2872
|
// ─── Lifecycle ─────────────────────────────────────────
|
|
2693
2873
|
async start() {
|
|
2694
2874
|
this._startedAt = Date.now();
|
|
@@ -2740,7 +2920,8 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2740
2920
|
emit(event) {
|
|
2741
2921
|
super.emit(event);
|
|
2742
2922
|
if (this.eventJournal) {
|
|
2743
|
-
this.eventJournal.append(event).catch(() => {
|
|
2923
|
+
this.eventJournal.append(event).catch((err) => {
|
|
2924
|
+
console.debug("[neo] Event journal append failed:", err);
|
|
2744
2925
|
});
|
|
2745
2926
|
}
|
|
2746
2927
|
if (this.webhookDispatcher) {
|
|
@@ -2859,13 +3040,21 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2859
3040
|
outcome: "failure",
|
|
2860
3041
|
runId
|
|
2861
3042
|
});
|
|
2862
|
-
} catch {
|
|
3043
|
+
} catch (err) {
|
|
3044
|
+
console.debug(
|
|
3045
|
+
`[orchestrator] Failed to write failure episode to memory: ${err instanceof Error ? err.message : String(err)}`
|
|
3046
|
+
);
|
|
2863
3047
|
}
|
|
2864
3048
|
return failResult;
|
|
2865
3049
|
} finally {
|
|
2866
3050
|
if (sessionPath) {
|
|
2867
3051
|
await this.finalizeSession(sessionPath, ctx);
|
|
2868
3052
|
}
|
|
3053
|
+
for (const mw of this.userMiddleware) {
|
|
3054
|
+
if ("cleanup" in mw && typeof mw.cleanup === "function") {
|
|
3055
|
+
mw.cleanup(sessionId);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
2869
3058
|
this.semaphore.release(sessionId);
|
|
2870
3059
|
this._activeSessions.delete(sessionId);
|
|
2871
3060
|
this.abortControllers.delete(sessionId);
|
|
@@ -2884,14 +3073,17 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2884
3073
|
const branch = ctx.input.branch;
|
|
2885
3074
|
const remote = ctx.repoConfig.pushRemote ?? "origin";
|
|
2886
3075
|
try {
|
|
2887
|
-
await pushSessionBranch(sessionPath, branch, remote).catch(() => {
|
|
3076
|
+
await pushSessionBranch(sessionPath, branch, remote).catch((err) => {
|
|
3077
|
+
console.debug("[neo] Push failed:", err);
|
|
2888
3078
|
});
|
|
2889
|
-
} catch {
|
|
3079
|
+
} catch (err) {
|
|
3080
|
+
console.debug("[neo] Finalization error:", err);
|
|
2890
3081
|
}
|
|
2891
3082
|
}
|
|
2892
3083
|
try {
|
|
2893
3084
|
await removeSessionClone(sessionPath);
|
|
2894
|
-
} catch {
|
|
3085
|
+
} catch (err) {
|
|
3086
|
+
console.debug("[neo] Session cleanup failed:", err);
|
|
2895
3087
|
}
|
|
2896
3088
|
}
|
|
2897
3089
|
async runAgentSession(ctx, sessionPath) {
|
|
@@ -2968,7 +3160,10 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2968
3160
|
outcome: isSuccess ? "success" : "failure",
|
|
2969
3161
|
runId
|
|
2970
3162
|
});
|
|
2971
|
-
} catch {
|
|
3163
|
+
} catch (err) {
|
|
3164
|
+
console.debug(
|
|
3165
|
+
`[orchestrator] Failed to write completion episode to memory: ${err instanceof Error ? err.message : String(err)}`
|
|
3166
|
+
);
|
|
2972
3167
|
}
|
|
2973
3168
|
return result;
|
|
2974
3169
|
}
|
|
@@ -3035,7 +3230,10 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
3035
3230
|
if (memories.length === 0) return void 0;
|
|
3036
3231
|
store.markAccessed(memories.map((m) => m.id));
|
|
3037
3232
|
return formatMemoriesForPrompt(memories);
|
|
3038
|
-
} catch {
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
console.debug(
|
|
3235
|
+
`[orchestrator] Failed to load memories: ${err instanceof Error ? err.message : String(err)}`
|
|
3236
|
+
);
|
|
3039
3237
|
return void 0;
|
|
3040
3238
|
}
|
|
3041
3239
|
}
|
|
@@ -3054,7 +3252,8 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
3054
3252
|
durationMs: Date.now() - ctx.startedAt,
|
|
3055
3253
|
repo: ctx.input.repo
|
|
3056
3254
|
};
|
|
3057
|
-
this.costJournal.append(costEntry).catch(() => {
|
|
3255
|
+
this.costJournal.append(costEntry).catch((err) => {
|
|
3256
|
+
console.debug("[neo] Cost journal append failed:", err);
|
|
3058
3257
|
});
|
|
3059
3258
|
}
|
|
3060
3259
|
this.emit({
|
|
@@ -3185,17 +3384,17 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
3185
3384
|
// ─── Private: Supervisor discovery ─────────────────────
|
|
3186
3385
|
/** Discover running supervisor daemons and return webhook configs for their endpoints. */
|
|
3187
3386
|
async discoverSupervisorWebhooks() {
|
|
3188
|
-
const { readdir:
|
|
3387
|
+
const { readdir: readdir7 } = await import("fs/promises");
|
|
3189
3388
|
const supervisorsDir = getSupervisorsDir();
|
|
3190
3389
|
if (!existsSync6(supervisorsDir)) return [];
|
|
3191
3390
|
const webhooks = [];
|
|
3192
3391
|
try {
|
|
3193
|
-
const entries = await
|
|
3392
|
+
const entries = await readdir7(supervisorsDir, { withFileTypes: true });
|
|
3194
3393
|
for (const entry of entries) {
|
|
3195
3394
|
if (!entry.isDirectory()) continue;
|
|
3196
3395
|
try {
|
|
3197
3396
|
const statePath = path10.join(supervisorsDir, entry.name, "state.json");
|
|
3198
|
-
const raw = await
|
|
3397
|
+
const raw = await readFile5(statePath, "utf-8");
|
|
3199
3398
|
const state = JSON.parse(raw);
|
|
3200
3399
|
if (state.status !== "running" || !state.port) continue;
|
|
3201
3400
|
if (state.pid && !isProcessAlive(state.pid)) continue;
|
|
@@ -3205,10 +3404,16 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
3205
3404
|
secret: this.config.supervisor.secret,
|
|
3206
3405
|
timeoutMs: 5e3
|
|
3207
3406
|
});
|
|
3208
|
-
} catch {
|
|
3407
|
+
} catch (err) {
|
|
3408
|
+
console.debug(
|
|
3409
|
+
`[orchestrator] Failed to load supervisor webhook config: ${err instanceof Error ? err.message : String(err)}`
|
|
3410
|
+
);
|
|
3209
3411
|
}
|
|
3210
3412
|
}
|
|
3211
|
-
} catch {
|
|
3413
|
+
} catch (err) {
|
|
3414
|
+
console.debug(
|
|
3415
|
+
`[orchestrator] Failed to read supervisors directory: ${err instanceof Error ? err.message : String(err)}`
|
|
3416
|
+
);
|
|
3212
3417
|
}
|
|
3213
3418
|
return webhooks;
|
|
3214
3419
|
}
|
|
@@ -3251,7 +3456,7 @@ import { z as z5 } from "zod";
|
|
|
3251
3456
|
|
|
3252
3457
|
// src/supervisor/decisions.ts
|
|
3253
3458
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
3254
|
-
import { appendFile as appendFile4, readFile as
|
|
3459
|
+
import { appendFile as appendFile4, readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
|
|
3255
3460
|
import path11 from "path";
|
|
3256
3461
|
import { z as z4 } from "zod";
|
|
3257
3462
|
var decisionOptionSchema = z4.object({
|
|
@@ -3267,21 +3472,41 @@ var decisionSchema = z4.object({
|
|
|
3267
3472
|
type: z4.string().default("generic"),
|
|
3268
3473
|
source: z4.string(),
|
|
3269
3474
|
metadata: z4.record(z4.string(), z4.unknown()).optional(),
|
|
3270
|
-
createdAt: z4.string(),
|
|
3271
|
-
expiresAt: z4.string().optional(),
|
|
3475
|
+
createdAt: z4.coerce.string(),
|
|
3476
|
+
expiresAt: z4.coerce.string().optional(),
|
|
3272
3477
|
defaultAnswer: z4.string().optional(),
|
|
3273
|
-
answeredAt: z4.string().optional(),
|
|
3478
|
+
answeredAt: z4.coerce.string().optional(),
|
|
3274
3479
|
answer: z4.string().optional(),
|
|
3275
|
-
expiredAt: z4.string().optional()
|
|
3480
|
+
expiredAt: z4.coerce.string().optional()
|
|
3276
3481
|
});
|
|
3277
3482
|
var DecisionStore = class {
|
|
3278
3483
|
filePath;
|
|
3279
3484
|
dir;
|
|
3280
3485
|
dirCache = /* @__PURE__ */ new Set();
|
|
3486
|
+
/** Promise-based mutex to serialize write operations */
|
|
3487
|
+
writeLock = Promise.resolve();
|
|
3281
3488
|
constructor(filePath) {
|
|
3282
3489
|
this.filePath = filePath;
|
|
3283
3490
|
this.dir = path11.dirname(filePath);
|
|
3284
3491
|
}
|
|
3492
|
+
/**
|
|
3493
|
+
* Acquire the write lock and execute a callback.
|
|
3494
|
+
* Serializes all write operations to prevent race conditions.
|
|
3495
|
+
*/
|
|
3496
|
+
async withWriteLock(fn) {
|
|
3497
|
+
const release = this.writeLock;
|
|
3498
|
+
let releaseLock = () => {
|
|
3499
|
+
};
|
|
3500
|
+
this.writeLock = new Promise((r) => {
|
|
3501
|
+
releaseLock = r;
|
|
3502
|
+
});
|
|
3503
|
+
try {
|
|
3504
|
+
await release;
|
|
3505
|
+
return await fn();
|
|
3506
|
+
} finally {
|
|
3507
|
+
releaseLock();
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3285
3510
|
/**
|
|
3286
3511
|
* Create a new decision and persist it.
|
|
3287
3512
|
* @returns The generated decision ID
|
|
@@ -3301,19 +3526,22 @@ var DecisionStore = class {
|
|
|
3301
3526
|
/**
|
|
3302
3527
|
* Answer a decision by ID.
|
|
3303
3528
|
* Reads all entries, updates the matching one, and rewrites the file.
|
|
3529
|
+
* Uses a mutex to serialize concurrent calls and prevent race conditions.
|
|
3304
3530
|
*/
|
|
3305
3531
|
async answer(id, answer) {
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3532
|
+
await this.withWriteLock(async () => {
|
|
3533
|
+
const decisions = await this.readAll();
|
|
3534
|
+
const decision = decisions.find((d) => d.id === id);
|
|
3535
|
+
if (!decision) {
|
|
3536
|
+
throw new Error(`Decision not found: ${id}`);
|
|
3537
|
+
}
|
|
3538
|
+
if (decision.answer !== void 0) {
|
|
3539
|
+
throw new Error(`Decision already answered: ${id}`);
|
|
3540
|
+
}
|
|
3541
|
+
decision.answer = answer;
|
|
3542
|
+
decision.answeredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3543
|
+
await this.writeAll(decisions);
|
|
3544
|
+
});
|
|
3317
3545
|
}
|
|
3318
3546
|
/**
|
|
3319
3547
|
* Get all pending decisions (unanswered, not expired, not timed out).
|
|
@@ -3350,33 +3578,36 @@ var DecisionStore = class {
|
|
|
3350
3578
|
/**
|
|
3351
3579
|
* Auto-answer expired decisions with their defaultAnswer.
|
|
3352
3580
|
* Decisions without defaultAnswer are marked as expired (expiredAt).
|
|
3581
|
+
* Uses a mutex to serialize concurrent calls and prevent race conditions.
|
|
3353
3582
|
* @returns The decisions that were auto-answered or marked expired
|
|
3354
3583
|
*/
|
|
3355
3584
|
async expire() {
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
if (decision.
|
|
3362
|
-
decision.
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3585
|
+
return this.withWriteLock(async () => {
|
|
3586
|
+
const decisions = await this.readAll();
|
|
3587
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3588
|
+
const expired = [];
|
|
3589
|
+
for (const decision of decisions) {
|
|
3590
|
+
if (decision.answer === void 0 && decision.expiredAt === void 0 && decision.expiresAt && decision.expiresAt < now) {
|
|
3591
|
+
if (decision.defaultAnswer !== void 0) {
|
|
3592
|
+
decision.answer = decision.defaultAnswer;
|
|
3593
|
+
decision.answeredAt = now;
|
|
3594
|
+
} else {
|
|
3595
|
+
decision.expiredAt = now;
|
|
3596
|
+
}
|
|
3597
|
+
expired.push(decision);
|
|
3366
3598
|
}
|
|
3367
|
-
expired.push(decision);
|
|
3368
3599
|
}
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3600
|
+
if (expired.length > 0) {
|
|
3601
|
+
await this.writeAll(decisions);
|
|
3602
|
+
}
|
|
3603
|
+
return expired;
|
|
3604
|
+
});
|
|
3374
3605
|
}
|
|
3375
3606
|
// ─── Private helpers ─────────────────────────────────────
|
|
3376
3607
|
async readAll() {
|
|
3377
3608
|
let content;
|
|
3378
3609
|
try {
|
|
3379
|
-
content = await
|
|
3610
|
+
content = await readFile6(this.filePath, "utf-8");
|
|
3380
3611
|
} catch (error) {
|
|
3381
3612
|
if (error.code === "ENOENT") {
|
|
3382
3613
|
return [];
|
|
@@ -3502,7 +3733,7 @@ var activityQueryOptionsSchema = z5.object({
|
|
|
3502
3733
|
|
|
3503
3734
|
// src/supervisor/activity-log.ts
|
|
3504
3735
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
3505
|
-
import { appendFile as appendFile5, readFile as
|
|
3736
|
+
import { appendFile as appendFile5, readFile as readFile7, rename, stat as stat3 } from "fs/promises";
|
|
3506
3737
|
import path12 from "path";
|
|
3507
3738
|
var ACTIVITY_FILE = "activity.jsonl";
|
|
3508
3739
|
var MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
|
@@ -3541,8 +3772,11 @@ var ActivityLog = class {
|
|
|
3541
3772
|
async tail(n) {
|
|
3542
3773
|
let content;
|
|
3543
3774
|
try {
|
|
3544
|
-
content = await
|
|
3545
|
-
} catch {
|
|
3775
|
+
content = await readFile7(this.filePath, "utf-8");
|
|
3776
|
+
} catch (err) {
|
|
3777
|
+
console.debug(
|
|
3778
|
+
`[ActivityLog] Failed to read activity log: ${err instanceof Error ? err.message : String(err)}`
|
|
3779
|
+
);
|
|
3546
3780
|
return [];
|
|
3547
3781
|
}
|
|
3548
3782
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
@@ -3551,14 +3785,17 @@ var ActivityLog = class {
|
|
|
3551
3785
|
for (const line of lastLines) {
|
|
3552
3786
|
try {
|
|
3553
3787
|
entries.push(JSON.parse(line));
|
|
3554
|
-
} catch {
|
|
3788
|
+
} catch (err) {
|
|
3789
|
+
console.debug(
|
|
3790
|
+
`[ActivityLog] Skipping malformed line: ${err instanceof Error ? err.message : String(err)}`
|
|
3791
|
+
);
|
|
3555
3792
|
}
|
|
3556
3793
|
}
|
|
3557
3794
|
return entries;
|
|
3558
3795
|
}
|
|
3559
3796
|
async checkRotation() {
|
|
3560
3797
|
try {
|
|
3561
|
-
const stats = await
|
|
3798
|
+
const stats = await stat3(this.filePath);
|
|
3562
3799
|
if (stats.size > MAX_SIZE_BYTES) {
|
|
3563
3800
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3564
3801
|
const rotatedPath = path12.join(this.dir, `activity-${timestamp}.jsonl`);
|
|
@@ -3572,13 +3809,13 @@ var ActivityLog = class {
|
|
|
3572
3809
|
// src/supervisor/daemon.ts
|
|
3573
3810
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
3574
3811
|
import { existsSync as existsSync8 } from "fs";
|
|
3575
|
-
import { mkdir as mkdir7, readFile as
|
|
3812
|
+
import { mkdir as mkdir7, readFile as readFile11, rm as rm2, writeFile as writeFile7 } from "fs/promises";
|
|
3576
3813
|
import { homedir as homedir5 } from "os";
|
|
3577
3814
|
import path15 from "path";
|
|
3578
3815
|
|
|
3579
3816
|
// src/supervisor/event-queue.ts
|
|
3580
3817
|
import { watch as watch2 } from "fs";
|
|
3581
|
-
import { readFile as
|
|
3818
|
+
import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
|
|
3582
3819
|
var EventQueue = class {
|
|
3583
3820
|
queue = [];
|
|
3584
3821
|
seenIds = /* @__PURE__ */ new Set();
|
|
@@ -3720,7 +3957,8 @@ var EventQueue = class {
|
|
|
3720
3957
|
watchJsonlFile(filePath, kind) {
|
|
3721
3958
|
try {
|
|
3722
3959
|
const watcher = watch2(filePath, () => {
|
|
3723
|
-
this.readNewLines(filePath, kind).catch(() => {
|
|
3960
|
+
this.readNewLines(filePath, kind).catch((err) => {
|
|
3961
|
+
console.debug(`[neo] Failed to read new lines from ${filePath}:`, err);
|
|
3724
3962
|
});
|
|
3725
3963
|
});
|
|
3726
3964
|
this.watchers.push(watcher);
|
|
@@ -3730,7 +3968,7 @@ var EventQueue = class {
|
|
|
3730
3968
|
async readNewLines(filePath, kind) {
|
|
3731
3969
|
let content;
|
|
3732
3970
|
try {
|
|
3733
|
-
content = await
|
|
3971
|
+
content = await readFile8(filePath, "utf-8");
|
|
3734
3972
|
} catch (_err) {
|
|
3735
3973
|
return;
|
|
3736
3974
|
}
|
|
@@ -3755,7 +3993,7 @@ var EventQueue = class {
|
|
|
3755
3993
|
async replayFile(filePath, kind) {
|
|
3756
3994
|
let content;
|
|
3757
3995
|
try {
|
|
3758
|
-
content = await
|
|
3996
|
+
content = await readFile8(filePath, "utf-8");
|
|
3759
3997
|
} catch (_err) {
|
|
3760
3998
|
return;
|
|
3761
3999
|
}
|
|
@@ -3791,7 +4029,7 @@ var EventQueue = class {
|
|
|
3791
4029
|
}
|
|
3792
4030
|
async markInFile(filePath, matchTimestamp, processedAt) {
|
|
3793
4031
|
try {
|
|
3794
|
-
const content = await
|
|
4032
|
+
const content = await readFile8(filePath, "utf-8");
|
|
3795
4033
|
const lines = content.split("\n");
|
|
3796
4034
|
let changed = false;
|
|
3797
4035
|
const updated = lines.map((line) => {
|
|
@@ -3819,7 +4057,7 @@ var EventQueue = class {
|
|
|
3819
4057
|
// src/supervisor/heartbeat.ts
|
|
3820
4058
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
3821
4059
|
import { existsSync as existsSync7 } from "fs";
|
|
3822
|
-
import { readdir as readdir4, readFile as
|
|
4060
|
+
import { readdir as readdir4, readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
|
|
3823
4061
|
import { homedir as homedir4 } from "os";
|
|
3824
4062
|
import path14 from "path";
|
|
3825
4063
|
|
|
@@ -3875,7 +4113,7 @@ var IdleDetector = class {
|
|
|
3875
4113
|
};
|
|
3876
4114
|
|
|
3877
4115
|
// src/supervisor/log-buffer.ts
|
|
3878
|
-
import { appendFile as appendFile6, readFile as
|
|
4116
|
+
import { appendFile as appendFile6, readFile as readFile9, stat as stat4, writeFile as writeFile5 } from "fs/promises";
|
|
3879
4117
|
import path13 from "path";
|
|
3880
4118
|
var LOG_BUFFER_FILE = "log-buffer.jsonl";
|
|
3881
4119
|
var MAX_FILE_BYTES = 1024 * 1024;
|
|
@@ -3889,16 +4127,22 @@ function parseLines(content) {
|
|
|
3889
4127
|
for (const line of lines) {
|
|
3890
4128
|
try {
|
|
3891
4129
|
entries.push(JSON.parse(line));
|
|
3892
|
-
} catch {
|
|
4130
|
+
} catch (err) {
|
|
4131
|
+
console.debug(
|
|
4132
|
+
`[log-buffer] Skipping malformed line: ${err instanceof Error ? err.message : String(err)}`
|
|
4133
|
+
);
|
|
3893
4134
|
}
|
|
3894
4135
|
}
|
|
3895
4136
|
return entries;
|
|
3896
4137
|
}
|
|
3897
4138
|
async function readLogBuffer(dir) {
|
|
3898
4139
|
try {
|
|
3899
|
-
const content = await
|
|
4140
|
+
const content = await readFile9(bufferPath(dir), "utf-8");
|
|
3900
4141
|
return parseLines(content);
|
|
3901
|
-
} catch {
|
|
4142
|
+
} catch (err) {
|
|
4143
|
+
console.debug(
|
|
4144
|
+
`[log-buffer] Failed to read buffer: ${err instanceof Error ? err.message : String(err)}`
|
|
4145
|
+
);
|
|
3902
4146
|
return [];
|
|
3903
4147
|
}
|
|
3904
4148
|
}
|
|
@@ -3910,8 +4154,11 @@ async function markConsolidated(dir, ids) {
|
|
|
3910
4154
|
const filePath = bufferPath(dir);
|
|
3911
4155
|
let content;
|
|
3912
4156
|
try {
|
|
3913
|
-
content = await
|
|
3914
|
-
} catch {
|
|
4157
|
+
content = await readFile9(filePath, "utf-8");
|
|
4158
|
+
} catch (err) {
|
|
4159
|
+
console.debug(
|
|
4160
|
+
`[log-buffer] Failed to read for consolidation: ${err instanceof Error ? err.message : String(err)}`
|
|
4161
|
+
);
|
|
3915
4162
|
return;
|
|
3916
4163
|
}
|
|
3917
4164
|
const idSet = new Set(ids);
|
|
@@ -3925,7 +4172,10 @@ async function markConsolidated(dir, ids) {
|
|
|
3925
4172
|
entry.consolidatedAt = now;
|
|
3926
4173
|
}
|
|
3927
4174
|
updated.push(JSON.stringify(entry));
|
|
3928
|
-
} catch {
|
|
4175
|
+
} catch (err) {
|
|
4176
|
+
console.debug(
|
|
4177
|
+
`[log-buffer] Preserving malformed line during consolidation: ${err instanceof Error ? err.message : String(err)}`
|
|
4178
|
+
);
|
|
3929
4179
|
updated.push(line);
|
|
3930
4180
|
}
|
|
3931
4181
|
}
|
|
@@ -3936,8 +4186,11 @@ async function compactLogBuffer(dir) {
|
|
|
3936
4186
|
const filePath = bufferPath(dir);
|
|
3937
4187
|
let content;
|
|
3938
4188
|
try {
|
|
3939
|
-
content = await
|
|
3940
|
-
} catch {
|
|
4189
|
+
content = await readFile9(filePath, "utf-8");
|
|
4190
|
+
} catch (err) {
|
|
4191
|
+
console.debug(
|
|
4192
|
+
`[log-buffer] Failed to read for compaction: ${err instanceof Error ? err.message : String(err)}`
|
|
4193
|
+
);
|
|
3941
4194
|
return;
|
|
3942
4195
|
}
|
|
3943
4196
|
const now = Date.now();
|
|
@@ -3953,7 +4206,10 @@ async function compactLogBuffer(dir) {
|
|
|
3953
4206
|
}
|
|
3954
4207
|
}
|
|
3955
4208
|
kept.push(JSON.stringify(entry));
|
|
3956
|
-
} catch {
|
|
4209
|
+
} catch (err) {
|
|
4210
|
+
console.debug(
|
|
4211
|
+
`[log-buffer] Dropping malformed line during compaction: ${err instanceof Error ? err.message : String(err)}`
|
|
4212
|
+
);
|
|
3957
4213
|
}
|
|
3958
4214
|
}
|
|
3959
4215
|
let result = `${kept.join("\n")}
|
|
@@ -3969,7 +4225,10 @@ async function appendLogBuffer(dir, entry) {
|
|
|
3969
4225
|
try {
|
|
3970
4226
|
await appendFile6(bufferPath(dir), `${JSON.stringify(entry)}
|
|
3971
4227
|
`, "utf-8");
|
|
3972
|
-
} catch {
|
|
4228
|
+
} catch (err) {
|
|
4229
|
+
console.debug(
|
|
4230
|
+
`[log-buffer] Failed to append entry: ${err instanceof Error ? err.message : String(err)}`
|
|
4231
|
+
);
|
|
3973
4232
|
}
|
|
3974
4233
|
}
|
|
3975
4234
|
|
|
@@ -3987,7 +4246,8 @@ var OPERATING_PRINCIPLES = `### Operating principles
|
|
|
3987
4246
|
- Keep initiative boundaries strict: decisions for initiative A must not be influenced by unrelated state from B.
|
|
3988
4247
|
- Your user-visible channel is \`neo log\` only; produce concise tool calls (not reasoning/explanations) and avoid wasted tokens.
|
|
3989
4248
|
- You may inspect repositories available via \`neo repos\`, read-only to launch agents.
|
|
3990
|
-
- Task hygiene is non-negotiable: update task outcomes EVERY heartbeat. A task without a current outcome is a blind spot
|
|
4249
|
+
- Task hygiene is non-negotiable: update task outcomes EVERY heartbeat. A task without a current outcome is a blind spot.
|
|
4250
|
+
- **No duplicate dispatches**: before dispatching a \`developer\` for any finding, ALWAYS check for open or recently merged PRs on the same topic: \`gh pr list --repo <repo> --search "<keywords>" --state open\` and \`--state merged --limit 5\`. If a similar PR exists \u2192 skip and log with \`neo log discovery\`. Dispatching duplicate agents wastes budget and pollutes the PR list.`;
|
|
3991
4251
|
var COMMANDS = `### Dispatching agents
|
|
3992
4252
|
\`\`\`bash
|
|
3993
4253
|
neo run <agent> --prompt "..." --repo <path> --branch <name> [--priority critical|high|medium|low] [--meta '<json>']
|
|
@@ -4203,7 +4463,7 @@ ${lines}
|
|
|
4203
4463
|
}
|
|
4204
4464
|
return "<focus>\n(empty \u2014 use neo memory write --type focus to set working context)\n</focus>";
|
|
4205
4465
|
}
|
|
4206
|
-
function buildPendingDecisionsSection(decisions
|
|
4466
|
+
function buildPendingDecisionsSection(decisions) {
|
|
4207
4467
|
if (!decisions || decisions.length === 0) {
|
|
4208
4468
|
return "";
|
|
4209
4469
|
}
|
|
@@ -4222,7 +4482,7 @@ function buildPendingDecisionsSection(decisions, autoDecide = false) {
|
|
|
4222
4482
|
lines.push(` Context: ${d.context}`);
|
|
4223
4483
|
}
|
|
4224
4484
|
}
|
|
4225
|
-
const instruction =
|
|
4485
|
+
const instruction = `You are in **autoDecide** mode \u2014 answer each pending decision yourself based on available context, project knowledge, and best engineering judgment.
|
|
4226
4486
|
|
|
4227
4487
|
\`\`\`bash
|
|
4228
4488
|
neo decision answer <decision_id> <answer>
|
|
@@ -4230,10 +4490,7 @@ neo decision answer <decision_id> <answer>
|
|
|
4230
4490
|
|
|
4231
4491
|
For each decision: analyze the options, consider the project context and risk, then answer decisively. Prefer safe, incremental choices when uncertain. Log your reasoning before answering.
|
|
4232
4492
|
|
|
4233
|
-
**Merge authority:** In autoDecide mode you MAY merge branches when the PR is ready (CI green, reviews approved). Use \`gh pr merge\` with the appropriate merge strategy
|
|
4234
|
-
\`\`\`bash
|
|
4235
|
-
neo event emit decision:answer --data '{"id":"<decision_id>","answer":"<option_key>"}'
|
|
4236
|
-
\`\`\``;
|
|
4493
|
+
**Merge authority:** In autoDecide mode you MAY merge branches when the PR is ready (CI green, reviews approved). Use \`gh pr merge\` with the appropriate merge strategy.`;
|
|
4237
4494
|
return `Pending decisions (${decisions.length}):
|
|
4238
4495
|
${lines.join("\n")}
|
|
4239
4496
|
|
|
@@ -4265,7 +4522,7 @@ ${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
|
|
|
4265
4522
|
if (recentActions) {
|
|
4266
4523
|
parts.push(recentActions);
|
|
4267
4524
|
}
|
|
4268
|
-
const pendingDecisions = buildPendingDecisionsSection(opts.pendingDecisions
|
|
4525
|
+
const pendingDecisions = buildPendingDecisionsSection(opts.pendingDecisions);
|
|
4269
4526
|
if (pendingDecisions) {
|
|
4270
4527
|
parts.push(pendingDecisions);
|
|
4271
4528
|
}
|
|
@@ -4576,7 +4833,7 @@ function buildIdlePrompt(opts) {
|
|
|
4576
4833
|
const budgetLine = `Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`;
|
|
4577
4834
|
const hasRepos = opts.repos.length > 0;
|
|
4578
4835
|
const hasBudget = opts.budgetStatus.remainingPct > 10;
|
|
4579
|
-
const hasPendingDecisions = (opts.pendingDecisions?.length ?? 0) > 0;
|
|
4836
|
+
const hasPendingDecisions = opts.hasPendingDecisions ?? (opts.pendingDecisions?.length ?? 0) > 0;
|
|
4580
4837
|
if (!hasRepos || !hasBudget) {
|
|
4581
4838
|
return `${buildRoleSection(opts.heartbeatCount)}
|
|
4582
4839
|
|
|
@@ -4591,7 +4848,7 @@ Nothing to do. Run \`neo log discovery "idle"\` and yield. Do not produce any ot
|
|
|
4591
4848
|
}
|
|
4592
4849
|
const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
|
|
4593
4850
|
if (hasPendingDecisions) {
|
|
4594
|
-
const pendingSection = buildPendingDecisionsSection(opts.pendingDecisions
|
|
4851
|
+
const pendingSection = buildPendingDecisionsSection(opts.pendingDecisions);
|
|
4595
4852
|
if (opts.autoDecide) {
|
|
4596
4853
|
return `${buildRoleSection(opts.heartbeatCount)}
|
|
4597
4854
|
|
|
@@ -4640,29 +4897,8 @@ Repositories:
|
|
|
4640
4897
|
${repoList}
|
|
4641
4898
|
</context>
|
|
4642
4899
|
|
|
4643
|
-
<reference>
|
|
4644
|
-
${getCommandsSection(opts.heartbeatCount)}
|
|
4645
|
-
</reference>
|
|
4646
|
-
|
|
4647
4900
|
<directive>
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
The scout explores the codebase and surfaces bugs, improvements, security issues, and tech debt. It creates decisions (via \`neo decision create\`) for each critical or high-impact finding, so the user can choose what to act on.
|
|
4651
|
-
|
|
4652
|
-
**Rules:**
|
|
4653
|
-
- Pick the repo that was least recently scouted (check your memory for previous scout runs).
|
|
4654
|
-
- Only ONE scout at a time \u2014 never dispatch multiple scouts in parallel.
|
|
4655
|
-
- Use \`--branch main\` (or the repo's default branch) \u2014 scouts are read-only.
|
|
4656
|
-
- Log your decision before dispatching.
|
|
4657
|
-
|
|
4658
|
-
**Example:**
|
|
4659
|
-
\`\`\`bash
|
|
4660
|
-
neo log decision "Idle \u2014 dispatching scout on <repo>"
|
|
4661
|
-
neo run scout --prompt "Explore this repository. Surface bugs, improvements, security issues, and tech debt. Create decisions for critical and high-impact findings." \\
|
|
4662
|
-
--repo <path> \\
|
|
4663
|
-
--branch <default-branch> \\
|
|
4664
|
-
--meta '{"stage":"scout","label":"scout-<repo-name>"}'
|
|
4665
|
-
\`\`\`
|
|
4901
|
+
Nothing to do. Run \`neo log discovery "idle"\` and yield. Do not produce any other output.
|
|
4666
4902
|
</directive>`;
|
|
4667
4903
|
}
|
|
4668
4904
|
function buildStandardPrompt(opts) {
|
|
@@ -4931,7 +5167,8 @@ var HeartbeatLoop = class {
|
|
|
4931
5167
|
if (this.configStore) {
|
|
4932
5168
|
this.config = this.configStore.getAll();
|
|
4933
5169
|
}
|
|
4934
|
-
this.activityLog.log("event", "Configuration reloaded (hot-reload)").catch(() => {
|
|
5170
|
+
this.activityLog.log("event", "Configuration reloaded (hot-reload)").catch((err) => {
|
|
5171
|
+
console.debug("[neo] Config reload log failed:", err);
|
|
4935
5172
|
});
|
|
4936
5173
|
this.eventQueue.interrupt();
|
|
4937
5174
|
}
|
|
@@ -4942,21 +5179,14 @@ var HeartbeatLoop = class {
|
|
|
4942
5179
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4943
5180
|
const budgetCheck = await this.checkBudgetExceeded(state, today);
|
|
4944
5181
|
if (budgetCheck.exceeded) return;
|
|
4945
|
-
const
|
|
4946
|
-
const
|
|
4947
|
-
const activeRuns = await this.getActiveRuns();
|
|
4948
|
-
const decisionStore = this.getDecisionStore();
|
|
4949
|
-
await this.processDecisionAnswers(rawEvents, decisionStore);
|
|
4950
|
-
const expiredDecisions = await decisionStore.expire();
|
|
4951
|
-
const hasExpiredDecisions = expiredDecisions.length > 0;
|
|
4952
|
-
const pendingDecisions = this.config.supervisor.autoDecide ? await decisionStore.pending() : [];
|
|
4953
|
-
const answeredDecisions = this.config.supervisor.autoDecide ? await decisionStore.answered(state?.lastHeartbeat) : [];
|
|
5182
|
+
const eventCtx = await this.gatherEventContext();
|
|
5183
|
+
const { pendingDecisions, answeredDecisions, hasExpiredDecisions, hasPendingDecisions } = await this.processDecisions(eventCtx.rawEvents, state?.lastHeartbeat);
|
|
4954
5184
|
const unconsolidatedEntries = await readUnconsolidated(this.supervisorDir);
|
|
4955
5185
|
const hasPendingConsolidation = unconsolidatedEntries.length > 0;
|
|
4956
5186
|
const skipResult = await this.handleSkipLogic({
|
|
4957
5187
|
state,
|
|
4958
|
-
totalEventCount,
|
|
4959
|
-
activeRuns,
|
|
5188
|
+
totalEventCount: eventCtx.totalEventCount,
|
|
5189
|
+
activeRuns: eventCtx.activeRuns,
|
|
4960
5190
|
hasPendingConsolidation,
|
|
4961
5191
|
hasExpiredDecisions
|
|
4962
5192
|
});
|
|
@@ -4966,27 +5196,31 @@ var HeartbeatLoop = class {
|
|
|
4966
5196
|
}
|
|
4967
5197
|
const modeResult = await this.determineHeartbeatMode(state);
|
|
4968
5198
|
const { prompt, modeLabel } = await this.buildHeartbeatModePrompt({
|
|
4969
|
-
grouped,
|
|
5199
|
+
grouped: eventCtx.grouped,
|
|
4970
5200
|
todayCost: budgetCheck.todayCost,
|
|
4971
5201
|
heartbeatCount: modeResult.heartbeatCount,
|
|
4972
5202
|
unconsolidated: modeResult.unconsolidated,
|
|
4973
5203
|
isCompaction: modeResult.isCompaction,
|
|
4974
5204
|
isConsolidation: modeResult.isConsolidation,
|
|
4975
|
-
activeRuns,
|
|
5205
|
+
activeRuns: eventCtx.activeRuns,
|
|
4976
5206
|
pendingDecisions,
|
|
4977
5207
|
answeredDecisions,
|
|
5208
|
+
hasPendingDecisions,
|
|
4978
5209
|
lastHeartbeat: state?.lastHeartbeat,
|
|
4979
|
-
lastConsolidationTimestamp: modeResult.lastConsolidationTs
|
|
5210
|
+
lastConsolidationTimestamp: modeResult.lastConsolidationTs,
|
|
5211
|
+
memories: eventCtx.memories,
|
|
5212
|
+
recentActions: eventCtx.recentActions,
|
|
5213
|
+
mcpServerNames: eventCtx.mcpServerNames
|
|
4980
5214
|
});
|
|
4981
5215
|
await this.activityLog.log(
|
|
4982
5216
|
"heartbeat",
|
|
4983
5217
|
`Heartbeat #${modeResult.heartbeatCount} starting (${modeLabel})`,
|
|
4984
5218
|
{
|
|
4985
5219
|
heartbeatId,
|
|
4986
|
-
eventCount: totalEventCount,
|
|
4987
|
-
messages: grouped.messages.length,
|
|
4988
|
-
webhooks: grouped.webhooks.length,
|
|
4989
|
-
runCompletions: grouped.runCompletions.length,
|
|
5220
|
+
eventCount: eventCtx.totalEventCount,
|
|
5221
|
+
messages: eventCtx.grouped.messages.length,
|
|
5222
|
+
webhooks: eventCtx.grouped.webhooks.length,
|
|
5223
|
+
runCompletions: eventCtx.grouped.runCompletions.length,
|
|
4990
5224
|
isConsolidation: modeResult.isConsolidation
|
|
4991
5225
|
}
|
|
4992
5226
|
);
|
|
@@ -4998,17 +5232,11 @@ var HeartbeatLoop = class {
|
|
|
4998
5232
|
{ heartbeatId }
|
|
4999
5233
|
);
|
|
5000
5234
|
}
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
const allIds = modeResult.unconsolidated.map((e) => e.id);
|
|
5007
|
-
if (allIds.length > 0) {
|
|
5008
|
-
await markConsolidated(this.supervisorDir, allIds);
|
|
5009
|
-
}
|
|
5010
|
-
await compactLogBuffer(this.supervisorDir);
|
|
5011
|
-
}
|
|
5235
|
+
await this.handlePostSdkProcessing({
|
|
5236
|
+
rawEvents: eventCtx.rawEvents,
|
|
5237
|
+
isConsolidation: modeResult.isConsolidation,
|
|
5238
|
+
unconsolidated: modeResult.unconsolidated
|
|
5239
|
+
});
|
|
5012
5240
|
const durationMs = Date.now() - startTime;
|
|
5013
5241
|
const { stateUpdate } = this.buildStateUpdate({
|
|
5014
5242
|
state,
|
|
@@ -5031,13 +5259,92 @@ var HeartbeatLoop = class {
|
|
|
5031
5259
|
isConsolidation: modeResult.isConsolidation
|
|
5032
5260
|
}
|
|
5033
5261
|
);
|
|
5262
|
+
await this.emitCompletionEvents({
|
|
5263
|
+
heartbeatCount: modeResult.heartbeatCount,
|
|
5264
|
+
activeRuns: eventCtx.activeRuns,
|
|
5265
|
+
todayCost: budgetCheck.todayCost,
|
|
5266
|
+
costUsd,
|
|
5267
|
+
rawEvents: eventCtx.rawEvents
|
|
5268
|
+
});
|
|
5269
|
+
}
|
|
5270
|
+
/**
|
|
5271
|
+
* Check if supervisor daily budget is exceeded.
|
|
5272
|
+
*/
|
|
5273
|
+
async checkBudgetExceeded(state, today) {
|
|
5274
|
+
const todayCost = state?.costResetDate === today ? state.todayCostUsd ?? 0 : 0;
|
|
5275
|
+
if (todayCost >= this.config.supervisor.dailyCapUsd) {
|
|
5276
|
+
await this.activityLog.log(
|
|
5277
|
+
"error",
|
|
5278
|
+
`Supervisor daily budget exceeded ($${todayCost.toFixed(2)} / $${this.config.supervisor.dailyCapUsd}). Skipping heartbeat.`
|
|
5279
|
+
);
|
|
5280
|
+
await this.sleep(this.config.supervisor.eventTimeoutMs);
|
|
5281
|
+
return { todayCost, exceeded: true };
|
|
5282
|
+
}
|
|
5283
|
+
return { todayCost, exceeded: false };
|
|
5284
|
+
}
|
|
5285
|
+
/**
|
|
5286
|
+
* Process decision answers from inbox and expire old decisions.
|
|
5287
|
+
* Returns pending, answered, and expiry status for prompt context.
|
|
5288
|
+
*/
|
|
5289
|
+
async processDecisions(rawEvents, lastHeartbeat) {
|
|
5290
|
+
const decisionStore = this.getDecisionStore();
|
|
5291
|
+
await this.processDecisionAnswers(rawEvents, decisionStore);
|
|
5292
|
+
const expiredDecisions = await decisionStore.expire();
|
|
5293
|
+
const hasExpiredDecisions = expiredDecisions.length > 0;
|
|
5294
|
+
const allPending = await decisionStore.pending();
|
|
5295
|
+
const hasPendingDecisions = allPending.length > 0;
|
|
5296
|
+
const pendingDecisions = this.config.supervisor.autoDecide ? allPending : [];
|
|
5297
|
+
const answeredDecisions = this.config.supervisor.autoDecide ? await decisionStore.answered(lastHeartbeat) : [];
|
|
5298
|
+
return { pendingDecisions, answeredDecisions, hasExpiredDecisions, hasPendingDecisions };
|
|
5299
|
+
}
|
|
5300
|
+
/**
|
|
5301
|
+
* Gather event context: drain queue, fetch active runs, memories, and recent actions.
|
|
5302
|
+
*/
|
|
5303
|
+
async gatherEventContext() {
|
|
5304
|
+
const { grouped, rawEvents } = this.eventQueue.drainAndGroup();
|
|
5305
|
+
const totalEventCount = grouped.messages.length + grouped.webhooks.length + grouped.runCompletions.length;
|
|
5306
|
+
const activeRuns = await this.getActiveRuns();
|
|
5307
|
+
const mcpServerNames = this.config.mcpServers ? Object.keys(this.config.mcpServers) : [];
|
|
5308
|
+
const store = this.getMemoryStore();
|
|
5309
|
+
const memories = store ? store.query({ limit: 40, sortBy: "relevance" }) : [];
|
|
5310
|
+
const recentActions = await this.activityLog.tail(20);
|
|
5311
|
+
return {
|
|
5312
|
+
grouped,
|
|
5313
|
+
rawEvents,
|
|
5314
|
+
totalEventCount,
|
|
5315
|
+
activeRuns,
|
|
5316
|
+
memories,
|
|
5317
|
+
recentActions,
|
|
5318
|
+
mcpServerNames
|
|
5319
|
+
};
|
|
5320
|
+
}
|
|
5321
|
+
/**
|
|
5322
|
+
* Handle post-SDK processing: mark events as processed, consolidate log buffer.
|
|
5323
|
+
*/
|
|
5324
|
+
async handlePostSdkProcessing(input) {
|
|
5325
|
+
if (input.rawEvents.length > 0) {
|
|
5326
|
+
const inboxPath = path14.join(this.supervisorDir, "inbox.jsonl");
|
|
5327
|
+
await this.eventQueue.markProcessed(inboxPath, this.eventsPath, input.rawEvents);
|
|
5328
|
+
}
|
|
5329
|
+
if (input.isConsolidation) {
|
|
5330
|
+
const allIds = input.unconsolidated.map((e) => e.id);
|
|
5331
|
+
if (allIds.length > 0) {
|
|
5332
|
+
await markConsolidated(this.supervisorDir, allIds);
|
|
5333
|
+
}
|
|
5334
|
+
await compactLogBuffer(this.supervisorDir);
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
/**
|
|
5338
|
+
* Emit completion webhook events: heartbeat completed and run completed events.
|
|
5339
|
+
*/
|
|
5340
|
+
async emitCompletionEvents(input) {
|
|
5034
5341
|
await this.emitHeartbeatCompleted({
|
|
5035
|
-
heartbeatNumber:
|
|
5036
|
-
runsActive: activeRuns.length,
|
|
5037
|
-
todayUsd:
|
|
5342
|
+
heartbeatNumber: input.heartbeatCount + 1,
|
|
5343
|
+
runsActive: input.activeRuns.length,
|
|
5344
|
+
todayUsd: input.todayCost + input.costUsd,
|
|
5038
5345
|
limitUsd: this.config.supervisor.dailyCapUsd
|
|
5039
5346
|
});
|
|
5040
|
-
for (const event of rawEvents) {
|
|
5347
|
+
for (const event of input.rawEvents) {
|
|
5041
5348
|
if (event.kind === "run_complete") {
|
|
5042
5349
|
const runData = await this.readPersistedRun(event.runId);
|
|
5043
5350
|
const emitOpts = {
|
|
@@ -5053,21 +5360,6 @@ var HeartbeatLoop = class {
|
|
|
5053
5360
|
}
|
|
5054
5361
|
}
|
|
5055
5362
|
}
|
|
5056
|
-
/**
|
|
5057
|
-
* Check if supervisor daily budget is exceeded.
|
|
5058
|
-
*/
|
|
5059
|
-
async checkBudgetExceeded(state, today) {
|
|
5060
|
-
const todayCost = state?.costResetDate === today ? state.todayCostUsd ?? 0 : 0;
|
|
5061
|
-
if (todayCost >= this.config.supervisor.dailyCapUsd) {
|
|
5062
|
-
await this.activityLog.log(
|
|
5063
|
-
"error",
|
|
5064
|
-
`Supervisor daily budget exceeded ($${todayCost.toFixed(2)} / $${this.config.supervisor.dailyCapUsd}). Skipping heartbeat.`
|
|
5065
|
-
);
|
|
5066
|
-
await this.sleep(this.config.supervisor.eventTimeoutMs);
|
|
5067
|
-
return { todayCost, exceeded: true };
|
|
5068
|
-
}
|
|
5069
|
-
return { todayCost, exceeded: false };
|
|
5070
|
-
}
|
|
5071
5363
|
/**
|
|
5072
5364
|
* Handle skip logic for idle and active-work scenarios.
|
|
5073
5365
|
* Uses IdleDetector to make skip decisions based on context.
|
|
@@ -5170,10 +5462,6 @@ var HeartbeatLoop = class {
|
|
|
5170
5462
|
* Build the prompt for the current heartbeat mode.
|
|
5171
5463
|
*/
|
|
5172
5464
|
async buildHeartbeatModePrompt(opts) {
|
|
5173
|
-
const mcpServerNames = this.config.mcpServers ? Object.keys(this.config.mcpServers) : [];
|
|
5174
|
-
const store = this.getMemoryStore();
|
|
5175
|
-
const memories = store ? store.query({ limit: 40, sortBy: "relevance" }) : [];
|
|
5176
|
-
const recentActions = await this.activityLog.tail(20);
|
|
5177
5465
|
const sharedOpts = {
|
|
5178
5466
|
repos: this.config.repos,
|
|
5179
5467
|
grouped: opts.grouped,
|
|
@@ -5184,13 +5472,14 @@ var HeartbeatLoop = class {
|
|
|
5184
5472
|
},
|
|
5185
5473
|
activeRuns: opts.activeRuns,
|
|
5186
5474
|
heartbeatCount: opts.heartbeatCount,
|
|
5187
|
-
mcpServerNames,
|
|
5475
|
+
mcpServerNames: opts.mcpServerNames,
|
|
5188
5476
|
customInstructions: this.customInstructions,
|
|
5189
5477
|
supervisorDir: this.supervisorDir,
|
|
5190
|
-
memories,
|
|
5191
|
-
recentActions,
|
|
5478
|
+
memories: opts.memories,
|
|
5479
|
+
recentActions: opts.recentActions,
|
|
5192
5480
|
pendingDecisions: opts.pendingDecisions,
|
|
5193
5481
|
answeredDecisions: opts.answeredDecisions,
|
|
5482
|
+
hasPendingDecisions: opts.hasPendingDecisions,
|
|
5194
5483
|
autoDecide: this.config.supervisor.autoDecide
|
|
5195
5484
|
};
|
|
5196
5485
|
if (opts.isCompaction) {
|
|
@@ -5300,7 +5589,7 @@ var HeartbeatLoop = class {
|
|
|
5300
5589
|
}
|
|
5301
5590
|
async readState() {
|
|
5302
5591
|
try {
|
|
5303
|
-
const raw = await
|
|
5592
|
+
const raw = await readFile10(this.statePath, "utf-8");
|
|
5304
5593
|
return JSON.parse(raw);
|
|
5305
5594
|
} catch {
|
|
5306
5595
|
return null;
|
|
@@ -5308,7 +5597,7 @@ var HeartbeatLoop = class {
|
|
|
5308
5597
|
}
|
|
5309
5598
|
async updateState(updates) {
|
|
5310
5599
|
try {
|
|
5311
|
-
const raw = await
|
|
5600
|
+
const raw = await readFile10(this.statePath, "utf-8");
|
|
5312
5601
|
const state = JSON.parse(raw);
|
|
5313
5602
|
Object.assign(state, updates);
|
|
5314
5603
|
await writeFile6(this.statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
@@ -5333,7 +5622,7 @@ var HeartbeatLoop = class {
|
|
|
5333
5622
|
for (const f of files) {
|
|
5334
5623
|
if (!f.endsWith(".json")) continue;
|
|
5335
5624
|
try {
|
|
5336
|
-
const raw = await
|
|
5625
|
+
const raw = await readFile10(path14.join(subDir, f), "utf-8");
|
|
5337
5626
|
const run = JSON.parse(raw);
|
|
5338
5627
|
if (isRunActive(run)) {
|
|
5339
5628
|
active.push(
|
|
@@ -5367,7 +5656,7 @@ var HeartbeatLoop = class {
|
|
|
5367
5656
|
}
|
|
5368
5657
|
for (const filePath of candidates) {
|
|
5369
5658
|
try {
|
|
5370
|
-
const content = await
|
|
5659
|
+
const content = await readFile10(filePath, "utf-8");
|
|
5371
5660
|
await this.activityLog.log("event", `Loaded instructions from ${filePath}`);
|
|
5372
5661
|
return content;
|
|
5373
5662
|
} catch {
|
|
@@ -5482,7 +5771,7 @@ var HeartbeatLoop = class {
|
|
|
5482
5771
|
const subDir = path14.join(runsDir, entry.name);
|
|
5483
5772
|
const runPath = path14.join(subDir, `${runId}.json`);
|
|
5484
5773
|
if (existsSync7(runPath)) {
|
|
5485
|
-
const raw = await
|
|
5774
|
+
const raw = await readFile10(runPath, "utf-8");
|
|
5486
5775
|
const run = JSON.parse(raw);
|
|
5487
5776
|
const totalCostUsd = Object.values(run.steps).reduce(
|
|
5488
5777
|
(sum, step) => sum + (step.costUsd ?? 0),
|
|
@@ -5860,9 +6149,12 @@ var SupervisorDaemon = class {
|
|
|
5860
6149
|
async readState() {
|
|
5861
6150
|
const statePath = path15.join(this.dir, "state.json");
|
|
5862
6151
|
try {
|
|
5863
|
-
const raw = await
|
|
6152
|
+
const raw = await readFile11(statePath, "utf-8");
|
|
5864
6153
|
return JSON.parse(raw);
|
|
5865
|
-
} catch {
|
|
6154
|
+
} catch (err) {
|
|
6155
|
+
console.debug(
|
|
6156
|
+
`[SupervisorDaemon] Failed to read state: ${err instanceof Error ? err.message : String(err)}`
|
|
6157
|
+
);
|
|
5866
6158
|
return null;
|
|
5867
6159
|
}
|
|
5868
6160
|
}
|
|
@@ -5872,10 +6164,13 @@ var SupervisorDaemon = class {
|
|
|
5872
6164
|
}
|
|
5873
6165
|
async readLockPid(lockPath) {
|
|
5874
6166
|
try {
|
|
5875
|
-
const raw = await
|
|
6167
|
+
const raw = await readFile11(lockPath, "utf-8");
|
|
5876
6168
|
const pid = Number.parseInt(raw.trim(), 10);
|
|
5877
6169
|
return Number.isNaN(pid) ? null : pid;
|
|
5878
|
-
} catch {
|
|
6170
|
+
} catch (err) {
|
|
6171
|
+
console.debug(
|
|
6172
|
+
`[SupervisorDaemon] Failed to read lock PID from ${lockPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
6173
|
+
);
|
|
5879
6174
|
return null;
|
|
5880
6175
|
}
|
|
5881
6176
|
}
|
|
@@ -5911,8 +6206,8 @@ var SupervisorDaemon = class {
|
|
|
5911
6206
|
};
|
|
5912
6207
|
|
|
5913
6208
|
// src/supervisor/StatusReader.ts
|
|
5914
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
5915
|
-
import { readFile as
|
|
6209
|
+
import { existsSync as existsSync9, readFileSync as readFileSync2 } from "fs";
|
|
6210
|
+
import { readdir as readdir5, readFile as readFile12 } from "fs/promises";
|
|
5916
6211
|
import path16 from "path";
|
|
5917
6212
|
var STATE_FILE = "state.json";
|
|
5918
6213
|
var ACTIVITY_FILE2 = "activity.jsonl";
|
|
@@ -5932,14 +6227,20 @@ var StatusReader = class {
|
|
|
5932
6227
|
async getStatus() {
|
|
5933
6228
|
let raw;
|
|
5934
6229
|
try {
|
|
5935
|
-
raw = await
|
|
5936
|
-
} catch {
|
|
6230
|
+
raw = await readFile12(this.statePath, "utf-8");
|
|
6231
|
+
} catch (err) {
|
|
6232
|
+
console.debug(
|
|
6233
|
+
`[StatusReader] State file not found: ${err instanceof Error ? err.message : String(err)}`
|
|
6234
|
+
);
|
|
5937
6235
|
return null;
|
|
5938
6236
|
}
|
|
5939
6237
|
let parsed;
|
|
5940
6238
|
try {
|
|
5941
6239
|
parsed = JSON.parse(raw);
|
|
5942
|
-
} catch {
|
|
6240
|
+
} catch (err) {
|
|
6241
|
+
console.debug(
|
|
6242
|
+
`[StatusReader] Malformed state JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
6243
|
+
);
|
|
5943
6244
|
return null;
|
|
5944
6245
|
}
|
|
5945
6246
|
const result = supervisorDaemonStateSchema.safeParse(parsed);
|
|
@@ -5953,6 +6254,7 @@ var StatusReader = class {
|
|
|
5953
6254
|
stopped: "idle"
|
|
5954
6255
|
};
|
|
5955
6256
|
const recentActivity = this.queryActivity({ limit: 5 });
|
|
6257
|
+
const activeRunCount = await this.countActiveRuns();
|
|
5956
6258
|
return {
|
|
5957
6259
|
pid: daemon.pid,
|
|
5958
6260
|
sessionId: daemon.sessionId,
|
|
@@ -5962,8 +6264,7 @@ var StatusReader = class {
|
|
|
5962
6264
|
todayCostUsd: daemon.todayCostUsd,
|
|
5963
6265
|
status: statusMap[daemon.status],
|
|
5964
6266
|
lastHeartbeat: daemon.lastHeartbeat ?? daemon.startedAt,
|
|
5965
|
-
activeRunCount
|
|
5966
|
-
// TODO: count active runs from .neo/runs/
|
|
6267
|
+
activeRunCount,
|
|
5967
6268
|
recentActivitySummary: recentActivity.map((e) => `[${e.type}] ${e.summary}`)
|
|
5968
6269
|
};
|
|
5969
6270
|
}
|
|
@@ -5976,7 +6277,10 @@ var StatusReader = class {
|
|
|
5976
6277
|
let content;
|
|
5977
6278
|
try {
|
|
5978
6279
|
content = readFileSync2(this.activityPath, "utf-8");
|
|
5979
|
-
} catch {
|
|
6280
|
+
} catch (err) {
|
|
6281
|
+
console.debug(
|
|
6282
|
+
`[StatusReader] Activity file not found: ${err instanceof Error ? err.message : String(err)}`
|
|
6283
|
+
);
|
|
5980
6284
|
return [];
|
|
5981
6285
|
}
|
|
5982
6286
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
@@ -5988,7 +6292,10 @@ var StatusReader = class {
|
|
|
5988
6292
|
if (result.success) {
|
|
5989
6293
|
entries.push(result.data);
|
|
5990
6294
|
}
|
|
5991
|
-
} catch {
|
|
6295
|
+
} catch (err) {
|
|
6296
|
+
console.debug(
|
|
6297
|
+
`[StatusReader] Skipping malformed activity line: ${err instanceof Error ? err.message : String(err)}`
|
|
6298
|
+
);
|
|
5992
6299
|
}
|
|
5993
6300
|
}
|
|
5994
6301
|
if (type) {
|
|
@@ -6004,16 +6311,80 @@ var StatusReader = class {
|
|
|
6004
6311
|
}
|
|
6005
6312
|
return entries.slice(offset, offset + limit);
|
|
6006
6313
|
}
|
|
6314
|
+
/**
|
|
6315
|
+
* Count runs with status "running" from .neo/runs/.
|
|
6316
|
+
* Fails silently — returns 0 if the runs directory doesn't exist.
|
|
6317
|
+
*/
|
|
6318
|
+
async countActiveRuns() {
|
|
6319
|
+
const runsDir = getRunsDir();
|
|
6320
|
+
if (!existsSync9(runsDir)) return 0;
|
|
6321
|
+
try {
|
|
6322
|
+
const runFiles = await this.collectRunFiles(runsDir);
|
|
6323
|
+
let count = 0;
|
|
6324
|
+
for (const filePath of runFiles) {
|
|
6325
|
+
if (await this.isRunning(filePath)) count++;
|
|
6326
|
+
}
|
|
6327
|
+
return count;
|
|
6328
|
+
} catch (err) {
|
|
6329
|
+
console.debug(
|
|
6330
|
+
`[StatusReader] Failed to count active runs: ${err instanceof Error ? err.message : String(err)}`
|
|
6331
|
+
);
|
|
6332
|
+
return 0;
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
/**
|
|
6336
|
+
* Collect all run JSON files from the runs directory tree.
|
|
6337
|
+
* Searches both top-level and repo subdirectories.
|
|
6338
|
+
*/
|
|
6339
|
+
async collectRunFiles(runsDir) {
|
|
6340
|
+
const entries = await readdir5(runsDir, { withFileTypes: true });
|
|
6341
|
+
const jsonFiles = [];
|
|
6342
|
+
for (const entry of entries) {
|
|
6343
|
+
if (entry.isDirectory()) {
|
|
6344
|
+
const subDir = path16.join(runsDir, entry.name);
|
|
6345
|
+
const subFiles = await readdir5(subDir);
|
|
6346
|
+
for (const f of subFiles) {
|
|
6347
|
+
if (this.isRunFile(f)) {
|
|
6348
|
+
jsonFiles.push(path16.join(subDir, f));
|
|
6349
|
+
}
|
|
6350
|
+
}
|
|
6351
|
+
} else if (this.isRunFile(entry.name)) {
|
|
6352
|
+
jsonFiles.push(path16.join(runsDir, entry.name));
|
|
6353
|
+
}
|
|
6354
|
+
}
|
|
6355
|
+
return jsonFiles;
|
|
6356
|
+
}
|
|
6357
|
+
/**
|
|
6358
|
+
* Check if a filename is a run file (JSON but not dispatch).
|
|
6359
|
+
*/
|
|
6360
|
+
isRunFile(filename) {
|
|
6361
|
+
return filename.endsWith(".json") && !filename.endsWith(".dispatch.json");
|
|
6362
|
+
}
|
|
6363
|
+
/**
|
|
6364
|
+
* Check if a run file represents an active (running) run.
|
|
6365
|
+
*/
|
|
6366
|
+
async isRunning(filePath) {
|
|
6367
|
+
try {
|
|
6368
|
+
const content = await readFile12(filePath, "utf-8");
|
|
6369
|
+
const run = JSON.parse(content);
|
|
6370
|
+
return run.status === "running";
|
|
6371
|
+
} catch (err) {
|
|
6372
|
+
console.debug(
|
|
6373
|
+
`[StatusReader] Failed to read run file ${filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
6374
|
+
);
|
|
6375
|
+
return false;
|
|
6376
|
+
}
|
|
6377
|
+
}
|
|
6007
6378
|
};
|
|
6008
6379
|
|
|
6009
6380
|
// src/supervisor/shutdown.ts
|
|
6010
|
-
import { existsSync as
|
|
6011
|
-
import { readdir as
|
|
6381
|
+
import { existsSync as existsSync10 } from "fs";
|
|
6382
|
+
import { readdir as readdir6, readFile as readFile13, writeFile as writeFile8 } from "fs/promises";
|
|
6012
6383
|
import path17 from "path";
|
|
6013
6384
|
|
|
6014
6385
|
// src/webhook-config.ts
|
|
6015
|
-
import { existsSync as
|
|
6016
|
-
import { mkdir as mkdir8, readFile as
|
|
6386
|
+
import { existsSync as existsSync11 } from "fs";
|
|
6387
|
+
import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile9 } from "fs/promises";
|
|
6017
6388
|
import path18 from "path";
|
|
6018
6389
|
import { z as z8 } from "zod";
|
|
6019
6390
|
var webhookEntrySchema = z8.object({
|
|
@@ -6031,10 +6402,10 @@ function getWebhooksConfigPath() {
|
|
|
6031
6402
|
}
|
|
6032
6403
|
async function loadWebhooksConfig() {
|
|
6033
6404
|
const configPath = getWebhooksConfigPath();
|
|
6034
|
-
if (!
|
|
6405
|
+
if (!existsSync11(configPath)) {
|
|
6035
6406
|
return { webhooks: [] };
|
|
6036
6407
|
}
|
|
6037
|
-
const raw = await
|
|
6408
|
+
const raw = await readFile14(configPath, "utf-8");
|
|
6038
6409
|
const parsed = JSON.parse(raw);
|
|
6039
6410
|
return webhooksConfigSchema.parse(parsed);
|
|
6040
6411
|
}
|