@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.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(`Prompt file not found: ${promptPath} (referenced in ${filePath})`);
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(`Config file not found: ${configPath}. Run 'neo init' to get started.`);
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 { appendFile, readFile as readFile3 } from "fs/promises";
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 content = await readFile3(file, "utf-8");
995
- for (const line of content.split("\n")) {
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(() => false);
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
- if (flushTimer !== void 0) {
1347
- clearInterval(flushTimer);
1348
- flushTimer = void 0;
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 readFile6 } from "fs/promises";
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 readFile4, writeFile as writeFile2 } from "fs/promises";
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 readFile4(filePath, "utf-8");
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 readFile5 } from "fs/promises";
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 readFile5(filePath, "utf-8");
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: readdir6 } = await import("fs/promises");
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 readdir6(supervisorsDir, { withFileTypes: true });
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 readFile6(statePath, "utf-8");
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 readFile7, writeFile as writeFile3 } from "fs/promises";
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
- const decisions = await this.readAll();
3307
- const decision = decisions.find((d) => d.id === id);
3308
- if (!decision) {
3309
- throw new Error(`Decision not found: ${id}`);
3310
- }
3311
- if (decision.answer !== void 0) {
3312
- throw new Error(`Decision already answered: ${id}`);
3313
- }
3314
- decision.answer = answer;
3315
- decision.answeredAt = (/* @__PURE__ */ new Date()).toISOString();
3316
- await this.writeAll(decisions);
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
- const decisions = await this.readAll();
3357
- const now = (/* @__PURE__ */ new Date()).toISOString();
3358
- const expired = [];
3359
- for (const decision of decisions) {
3360
- if (decision.answer === void 0 && decision.expiredAt === void 0 && decision.expiresAt && decision.expiresAt < now) {
3361
- if (decision.defaultAnswer !== void 0) {
3362
- decision.answer = decision.defaultAnswer;
3363
- decision.answeredAt = now;
3364
- } else {
3365
- decision.expiredAt = now;
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
- if (expired.length > 0) {
3371
- await this.writeAll(decisions);
3372
- }
3373
- return expired;
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 readFile7(this.filePath, "utf-8");
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 readFile8, rename, stat } from "fs/promises";
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 readFile8(this.filePath, "utf-8");
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 stat(this.filePath);
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 readFile12, rm as rm2, writeFile as writeFile7 } from "fs/promises";
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 readFile9, writeFile as writeFile4 } from "fs/promises";
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 readFile9(filePath, "utf-8");
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 readFile9(filePath, "utf-8");
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 readFile9(filePath, "utf-8");
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 readFile11, writeFile as writeFile6 } from "fs/promises";
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 readFile10, stat as stat2, writeFile as writeFile5 } from "fs/promises";
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 readFile10(bufferPath(dir), "utf-8");
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 readFile10(filePath, "utf-8");
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 readFile10(filePath, "utf-8");
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, autoDecide = false) {
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 = autoDecide ? `You are in **autoDecide** mode \u2014 answer each pending decision yourself based on available context, project knowledge, and best engineering judgment.
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.` : `To answer a decision, emit a \`decision:answer\` event:
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, opts.autoDecide);
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, opts.autoDecide);
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
- Idle \u2014 no work in progress. Use this downtime to dispatch a \`scout\` agent on one of your repositories.
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 { grouped, rawEvents } = this.eventQueue.drainAndGroup();
4946
- const totalEventCount = grouped.messages.length + grouped.webhooks.length + grouped.runCompletions.length;
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
- if (rawEvents.length > 0) {
5002
- const inboxPath = path14.join(this.supervisorDir, "inbox.jsonl");
5003
- await this.eventQueue.markProcessed(inboxPath, this.eventsPath, rawEvents);
5004
- }
5005
- if (modeResult.isConsolidation) {
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: modeResult.heartbeatCount + 1,
5036
- runsActive: activeRuns.length,
5037
- todayUsd: budgetCheck.todayCost + costUsd,
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 readFile11(this.statePath, "utf-8");
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 readFile11(this.statePath, "utf-8");
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 readFile11(path14.join(subDir, f), "utf-8");
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 readFile11(filePath, "utf-8");
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 readFile11(runPath, "utf-8");
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 readFile12(statePath, "utf-8");
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 readFile12(lockPath, "utf-8");
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 readFile13 } from "fs/promises";
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 readFile13(this.statePath, "utf-8");
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: 0,
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 existsSync9 } from "fs";
6011
- import { readdir as readdir5, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
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 existsSync10 } from "fs";
6016
- import { mkdir as mkdir8, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
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 (!existsSync10(configPath)) {
6405
+ if (!existsSync11(configPath)) {
6035
6406
  return { webhooks: [] };
6036
6407
  }
6037
- const raw = await readFile15(configPath, "utf-8");
6408
+ const raw = await readFile14(configPath, "utf-8");
6038
6409
  const parsed = JSON.parse(raw);
6039
6410
  return webhooksConfigSchema.parse(parsed);
6040
6411
  }