@neotx/core 0.1.0-alpha.15 → 0.1.0-alpha.19

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
@@ -406,11 +406,10 @@ var Semaphore = class {
406
406
  };
407
407
 
408
408
  // src/config.ts
409
- import { existsSync } from "fs";
409
+ import { existsSync as existsSync2 } from "fs";
410
410
  import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
411
411
  import path4 from "path";
412
- import { parse as parseYaml2, stringify as stringifyYaml } from "yaml";
413
- import { z as z2 } from "zod";
412
+ import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
414
413
 
415
414
  // src/paths.ts
416
415
  import { homedir } from "os";
@@ -458,8 +457,12 @@ function getSupervisorEventsPath(name) {
458
457
  function getSupervisorLockPath(name) {
459
458
  return path3.join(getSupervisorDir(name), "daemon.lock");
460
459
  }
460
+ function getSupervisorDecisionsPath(name) {
461
+ return path3.join(getSupervisorDir(name), "decisions.jsonl");
462
+ }
461
463
 
462
- // src/config.ts
464
+ // src/config/schema.ts
465
+ import { z as z2 } from "zod";
463
466
  var httpMcpServerSchema = z2.object({
464
467
  type: z2.literal("http"),
465
468
  url: z2.string(),
@@ -484,26 +487,54 @@ var repoConfigSchema = z2.object({
484
487
  pushRemote: z2.string().default("origin"),
485
488
  gitStrategy: gitStrategySchema
486
489
  });
490
+ var concurrencyConfigSchema = z2.object({
491
+ maxSessions: z2.number().default(5),
492
+ maxPerRepo: z2.number().default(4),
493
+ queueMax: z2.number().default(50)
494
+ }).default({ maxSessions: 5, maxPerRepo: 4, queueMax: 50 });
495
+ var budgetConfigSchema = z2.object({
496
+ dailyCapUsd: z2.number().default(500),
497
+ alertThresholdPct: z2.number().default(80)
498
+ }).default({ dailyCapUsd: 500, alertThresholdPct: 80 });
499
+ var recoveryConfigSchema = z2.object({
500
+ maxRetries: z2.number().default(3),
501
+ backoffBaseMs: z2.number().default(3e4)
502
+ }).default({ maxRetries: 3, backoffBaseMs: 3e4 });
503
+ var sessionsConfigSchema = z2.object({
504
+ initTimeoutMs: z2.number().default(12e4),
505
+ maxDurationMs: z2.number().default(36e5),
506
+ dir: z2.string().default("/tmp/neo-sessions")
507
+ }).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5, dir: "/tmp/neo-sessions" });
508
+ var supervisorConfigSchema = z2.object({
509
+ port: z2.number().default(7777),
510
+ secret: z2.string().optional(),
511
+ heartbeatTimeoutMs: z2.number().default(3e5),
512
+ maxConsecutiveFailures: z2.number().default(3),
513
+ maxEventsPerSec: z2.number().default(10),
514
+ dailyCapUsd: z2.number().default(50),
515
+ /** How often consolidation runs (ms) */
516
+ consolidationIntervalMs: z2.number().default(3e5),
517
+ /** How often compaction runs (ms) */
518
+ compactionIntervalMs: z2.number().default(36e5),
519
+ /** Safety timeout for waitForWork (ms) */
520
+ eventTimeoutMs: z2.number().default(3e5),
521
+ instructions: z2.string().optional()
522
+ }).default({
523
+ port: 7777,
524
+ heartbeatTimeoutMs: 3e5,
525
+ maxConsecutiveFailures: 3,
526
+ maxEventsPerSec: 10,
527
+ dailyCapUsd: 50,
528
+ consolidationIntervalMs: 3e5,
529
+ compactionIntervalMs: 36e5,
530
+ eventTimeoutMs: 3e5
531
+ });
487
532
  var globalConfigSchema = z2.object({
488
533
  repos: z2.array(repoConfigSchema).default([]),
489
- concurrency: z2.object({
490
- maxSessions: z2.number().default(5),
491
- maxPerRepo: z2.number().default(4),
492
- queueMax: z2.number().default(50)
493
- }).default({ maxSessions: 5, maxPerRepo: 4, queueMax: 50 }),
494
- budget: z2.object({
495
- dailyCapUsd: z2.number().default(500),
496
- alertThresholdPct: z2.number().default(80)
497
- }).default({ dailyCapUsd: 500, alertThresholdPct: 80 }),
498
- recovery: z2.object({
499
- maxRetries: z2.number().default(3),
500
- backoffBaseMs: z2.number().default(3e4)
501
- }).default({ maxRetries: 3, backoffBaseMs: 3e4 }),
502
- sessions: z2.object({
503
- initTimeoutMs: z2.number().default(12e4),
504
- maxDurationMs: z2.number().default(36e5),
505
- dir: z2.string().default("/tmp/neo-sessions")
506
- }).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5, dir: "/tmp/neo-sessions" }),
534
+ concurrency: concurrencyConfigSchema,
535
+ budget: budgetConfigSchema,
536
+ recovery: recoveryConfigSchema,
537
+ sessions: sessionsConfigSchema,
507
538
  webhooks: z2.array(
508
539
  z2.object({
509
540
  url: z2.string().url(),
@@ -512,30 +543,7 @@ var globalConfigSchema = z2.object({
512
543
  timeoutMs: z2.number().default(5e3)
513
544
  })
514
545
  ).default([]),
515
- supervisor: z2.object({
516
- port: z2.number().default(7777),
517
- secret: z2.string().optional(),
518
- heartbeatTimeoutMs: z2.number().default(3e5),
519
- maxConsecutiveFailures: z2.number().default(3),
520
- maxEventsPerSec: z2.number().default(10),
521
- dailyCapUsd: z2.number().default(50),
522
- /** How often consolidation runs (ms) */
523
- consolidationIntervalMs: z2.number().default(3e5),
524
- /** How often compaction runs (ms) */
525
- compactionIntervalMs: z2.number().default(36e5),
526
- /** Safety timeout for waitForWork (ms) */
527
- eventTimeoutMs: z2.number().default(3e5),
528
- instructions: z2.string().optional()
529
- }).default({
530
- port: 7777,
531
- heartbeatTimeoutMs: 3e5,
532
- maxConsecutiveFailures: 3,
533
- maxEventsPerSec: 10,
534
- dailyCapUsd: 50,
535
- consolidationIntervalMs: 3e5,
536
- compactionIntervalMs: 36e5,
537
- eventTimeoutMs: 3e5
538
- }),
546
+ supervisor: supervisorConfigSchema,
539
547
  memory: z2.object({
540
548
  embeddings: z2.boolean().default(true)
541
549
  }).default({ embeddings: true }),
@@ -548,6 +556,296 @@ var globalConfigSchema = z2.object({
548
556
  }).optional()
549
557
  });
550
558
  var neoConfigSchema = globalConfigSchema;
559
+ var repoOverrideConfigSchema = z2.object({
560
+ concurrency: concurrencyConfigSchema.unwrap().partial().optional(),
561
+ budget: budgetConfigSchema.unwrap().partial().optional(),
562
+ recovery: recoveryConfigSchema.unwrap().partial().optional(),
563
+ sessions: sessionsConfigSchema.unwrap().partial().optional()
564
+ }).partial();
565
+
566
+ // src/config/dotNotation.ts
567
+ function getConfigValue(config, path19) {
568
+ if (path19 === "") {
569
+ return config;
570
+ }
571
+ const keys = path19.split(".");
572
+ let current = config;
573
+ for (const key of keys) {
574
+ if (current === null || current === void 0) {
575
+ return void 0;
576
+ }
577
+ if (typeof current !== "object") {
578
+ return void 0;
579
+ }
580
+ current = current[key];
581
+ }
582
+ return current;
583
+ }
584
+
585
+ // src/config/merge.ts
586
+ var defaultConfig = {
587
+ repos: [],
588
+ concurrency: concurrencyConfigSchema.parse(void 0),
589
+ budget: budgetConfigSchema.parse(void 0),
590
+ recovery: recoveryConfigSchema.parse(void 0),
591
+ sessions: sessionsConfigSchema.parse(void 0),
592
+ webhooks: [],
593
+ supervisor: {
594
+ port: 7777,
595
+ heartbeatTimeoutMs: 3e5,
596
+ maxConsecutiveFailures: 3,
597
+ maxEventsPerSec: 10,
598
+ dailyCapUsd: 50,
599
+ consolidationIntervalMs: 3e5,
600
+ compactionIntervalMs: 36e5,
601
+ eventTimeoutMs: 3e5
602
+ },
603
+ memory: { embeddings: true }
604
+ };
605
+ function deepMerge(target, source) {
606
+ const result = { ...target };
607
+ for (const key of Object.keys(source)) {
608
+ const sourceValue = source[key];
609
+ const targetValue = target[key];
610
+ if (sourceValue === void 0) {
611
+ continue;
612
+ }
613
+ if (sourceValue !== null && typeof sourceValue === "object" && !Array.isArray(sourceValue) && targetValue !== null && typeof targetValue === "object" && !Array.isArray(targetValue)) {
614
+ result[key] = deepMerge(
615
+ targetValue,
616
+ sourceValue
617
+ );
618
+ } else {
619
+ result[key] = sourceValue;
620
+ }
621
+ }
622
+ return result;
623
+ }
624
+ function deepFreeze(obj) {
625
+ if (obj === null || typeof obj !== "object") {
626
+ return obj;
627
+ }
628
+ if (Array.isArray(obj)) {
629
+ for (const item of obj) {
630
+ deepFreeze(item);
631
+ }
632
+ return Object.freeze(obj);
633
+ }
634
+ for (const value of Object.values(obj)) {
635
+ deepFreeze(value);
636
+ }
637
+ return Object.freeze(obj);
638
+ }
639
+ function mergeConfigs(defaults, globalConfig, repoConfig) {
640
+ let result = { ...defaults };
641
+ if (globalConfig) {
642
+ result = deepMerge(result, globalConfig);
643
+ }
644
+ if (repoConfig) {
645
+ result = deepMerge(result, repoConfig);
646
+ }
647
+ return deepFreeze(result);
648
+ }
649
+
650
+ // src/config/ConfigStore.ts
651
+ import { existsSync, readFileSync } from "fs";
652
+ import { homedir as homedir2 } from "os";
653
+ import { join } from "path";
654
+ import { parse as parseYaml2 } from "yaml";
655
+ var ConfigStore = class {
656
+ repoPath;
657
+ config = null;
658
+ constructor(repoPath) {
659
+ this.repoPath = repoPath;
660
+ }
661
+ // ─── Public API ────────────────────────────────────────
662
+ /**
663
+ * Loads and merges config files from all locations.
664
+ * Must be called before get() or getAll().
665
+ */
666
+ async load() {
667
+ const globalConfig = await this.loadGlobalConfig();
668
+ const repoConfig = await this.loadRepoConfig();
669
+ this.config = mergeConfigs(defaultConfig, globalConfig, repoConfig);
670
+ }
671
+ /**
672
+ * Gets a config value using dot notation.
673
+ *
674
+ * @param key - Dot-separated path (e.g., "budget.dailyCapUsd")
675
+ * @returns The value at the path
676
+ * @throws Error if load() has not been called
677
+ *
678
+ * @example
679
+ * store.get<number>('budget.dailyCapUsd') // 500
680
+ * store.get<string>('sessions.dir') // '/tmp/neo-sessions'
681
+ */
682
+ get(key) {
683
+ if (this.config === null) {
684
+ throw new Error("ConfigStore not loaded. Call load() first.");
685
+ }
686
+ return getConfigValue(this.config, key);
687
+ }
688
+ /**
689
+ * Returns the full merged configuration.
690
+ *
691
+ * @throws Error if load() has not been called
692
+ */
693
+ getAll() {
694
+ if (this.config === null) {
695
+ throw new Error("ConfigStore not loaded. Call load() first.");
696
+ }
697
+ return this.config;
698
+ }
699
+ /**
700
+ * Returns the repository path, if one was provided.
701
+ * Used by ConfigWatcher to determine which files to watch.
702
+ */
703
+ getRepoPath() {
704
+ return this.repoPath;
705
+ }
706
+ // ─── Private loaders ───────────────────────────────────
707
+ /**
708
+ * Loads global config from ~/.neo/config.yml
709
+ */
710
+ async loadGlobalConfig() {
711
+ const globalPath = join(homedir2(), ".neo", "config.yml");
712
+ const raw = await this.loadFile(globalPath);
713
+ if (raw === null) {
714
+ return null;
715
+ }
716
+ const parsed = neoConfigSchema.safeParse(raw);
717
+ if (!parsed.success) {
718
+ return null;
719
+ }
720
+ return parsed.data;
721
+ }
722
+ /**
723
+ * Loads repo-level overrides from <repoPath>/.neo/config.yml
724
+ */
725
+ async loadRepoConfig() {
726
+ if (!this.repoPath) {
727
+ return null;
728
+ }
729
+ const repoConfigPath = join(this.repoPath, ".neo", "config.yml");
730
+ const raw = await this.loadFile(repoConfigPath);
731
+ if (raw === null) {
732
+ return null;
733
+ }
734
+ const parsed = repoOverrideConfigSchema.safeParse(raw);
735
+ if (!parsed.success) {
736
+ return null;
737
+ }
738
+ return parsed.data;
739
+ }
740
+ /**
741
+ * Loads and parses a YAML config file.
742
+ *
743
+ * @param filePath - Absolute path to the config file
744
+ * @returns Parsed YAML content or null if file doesn't exist
745
+ */
746
+ async loadFile(filePath) {
747
+ if (!existsSync(filePath)) {
748
+ return null;
749
+ }
750
+ try {
751
+ const content = readFileSync(filePath, "utf-8");
752
+ const parsed = parseYaml2(content);
753
+ if (parsed === null || typeof parsed !== "object") {
754
+ return null;
755
+ }
756
+ return parsed;
757
+ } catch {
758
+ return null;
759
+ }
760
+ }
761
+ };
762
+
763
+ // src/config/ConfigWatcher.ts
764
+ import { EventEmitter } from "events";
765
+ import { homedir as homedir3 } from "os";
766
+ import { join as join2 } from "path";
767
+ import { watch } from "chokidar";
768
+ var ConfigWatcher = class extends EventEmitter {
769
+ store;
770
+ debounceMs;
771
+ repoPath;
772
+ watcher = null;
773
+ debounceTimer = null;
774
+ constructor(store, options) {
775
+ super();
776
+ this.store = store;
777
+ this.debounceMs = options?.debounceMs ?? 500;
778
+ this.repoPath = store.getRepoPath();
779
+ }
780
+ /**
781
+ * Starts watching config files for changes.
782
+ * Watches both global (~/.neo/config.yml) and repo config files.
783
+ */
784
+ start() {
785
+ if (this.watcher) {
786
+ return;
787
+ }
788
+ const paths = this.getConfigPaths();
789
+ this.watcher = watch(paths, {
790
+ ignoreInitial: true,
791
+ // Don't error if files don't exist — they may be created later
792
+ ignorePermissionErrors: true
793
+ });
794
+ this.watcher.on("change", () => this.handleChange());
795
+ this.watcher.on("add", () => this.handleChange());
796
+ this.watcher.on("unlink", () => this.handleChange());
797
+ }
798
+ /**
799
+ * Stops watching config files.
800
+ */
801
+ stop() {
802
+ if (this.debounceTimer) {
803
+ clearTimeout(this.debounceTimer);
804
+ this.debounceTimer = null;
805
+ }
806
+ if (this.watcher) {
807
+ this.watcher.close();
808
+ this.watcher = null;
809
+ }
810
+ }
811
+ // ─── Private ─────────────────────────────────────────────
812
+ /**
813
+ * Returns the list of config file paths to watch.
814
+ */
815
+ getConfigPaths() {
816
+ const globalPath = join2(homedir3(), ".neo", "config.yml");
817
+ const paths = [globalPath];
818
+ if (this.repoPath) {
819
+ const repoConfigPath = join2(this.repoPath, ".neo", "config.yml");
820
+ paths.push(repoConfigPath);
821
+ }
822
+ return paths;
823
+ }
824
+ /**
825
+ * Handles file change events with debouncing.
826
+ */
827
+ handleChange() {
828
+ if (this.debounceTimer) {
829
+ clearTimeout(this.debounceTimer);
830
+ }
831
+ this.debounceTimer = setTimeout(() => {
832
+ this.debounceTimer = null;
833
+ this.reloadConfig();
834
+ }, this.debounceMs);
835
+ }
836
+ /**
837
+ * Reloads the config and emits 'change' event.
838
+ */
839
+ async reloadConfig() {
840
+ try {
841
+ await this.store.load();
842
+ this.emit("change");
843
+ } catch {
844
+ }
845
+ }
846
+ };
847
+
848
+ // src/config.ts
551
849
  var DEFAULT_GLOBAL_CONFIG = {
552
850
  repos: [],
553
851
  concurrency: {
@@ -562,7 +860,7 @@ var DEFAULT_GLOBAL_CONFIG = {
562
860
  };
563
861
  function parseYamlFile(raw, filePath) {
564
862
  try {
565
- return parseYaml2(raw);
863
+ return parseYaml3(raw);
566
864
  } catch (err) {
567
865
  throw new Error(
568
866
  `Invalid YAML in ${filePath}: ${err instanceof Error ? err.message : String(err)}. Check YAML syntax at the indicated line.`
@@ -590,7 +888,7 @@ async function loadConfig(configPath) {
590
888
  }
591
889
  async function loadGlobalConfig() {
592
890
  const configPath = path4.join(getDataDir(), "config.yml");
593
- if (!existsSync(configPath)) {
891
+ if (!existsSync2(configPath)) {
594
892
  await mkdir(getDataDir(), { recursive: true });
595
893
  await writeFile(configPath, stringifyYaml(DEFAULT_GLOBAL_CONFIG), "utf-8");
596
894
  return globalConfigSchema.parse(DEFAULT_GLOBAL_CONFIG);
@@ -698,9 +996,9 @@ var CostJournal = class {
698
996
  };
699
997
 
700
998
  // src/events/emitter.ts
701
- import { EventEmitter } from "events";
999
+ import { EventEmitter as EventEmitter2 } from "events";
702
1000
  var NeoEventEmitter = class {
703
- emitter = new EventEmitter();
1001
+ emitter = new EventEmitter2();
704
1002
  emit(event) {
705
1003
  this.safeEmit(event.type, event);
706
1004
  this.safeEmit("*", event);
@@ -842,7 +1140,7 @@ function toSerializable(event) {
842
1140
 
843
1141
  // src/isolation/clone.ts
844
1142
  import { execFile } from "child_process";
845
- import { existsSync as existsSync2 } from "fs";
1143
+ import { existsSync as existsSync3 } from "fs";
846
1144
  import { mkdir as mkdir3, readdir as readdir2, rm } from "fs/promises";
847
1145
  import { dirname, resolve } from "path";
848
1146
  import { promisify } from "util";
@@ -886,14 +1184,14 @@ async function createSessionClone(options) {
886
1184
  }
887
1185
  async function removeSessionClone(sessionPath) {
888
1186
  const absPath = resolve(sessionPath);
889
- if (!existsSync2(absPath)) {
1187
+ if (!existsSync3(absPath)) {
890
1188
  return;
891
1189
  }
892
1190
  await rm(absPath, { recursive: true, force: true });
893
1191
  }
894
1192
  async function listSessionClones(sessionsBaseDir) {
895
1193
  const absBase = resolve(sessionsBaseDir);
896
- if (!existsSync2(absBase)) {
1194
+ if (!existsSync3(absBase)) {
897
1195
  return [];
898
1196
  }
899
1197
  const entries = await readdir2(absBase, { withFileTypes: true });
@@ -1191,11 +1489,11 @@ function loopDetection(options) {
1191
1489
  // src/orchestrator.ts
1192
1490
  import { randomUUID as randomUUID3 } from "crypto";
1193
1491
  import { existsSync as existsSync6 } from "fs";
1194
- import { mkdir as mkdir6, readFile as readFile7 } from "fs/promises";
1195
- import path11 from "path";
1492
+ import { mkdir as mkdir6, readFile as readFile6 } from "fs/promises";
1493
+ import path10 from "path";
1196
1494
 
1197
1495
  // src/orchestrator/run-store.ts
1198
- import { existsSync as existsSync3 } from "fs";
1496
+ import { existsSync as existsSync4 } from "fs";
1199
1497
  import { mkdir as mkdir5, readdir as readdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
1200
1498
  import path7 from "path";
1201
1499
 
@@ -1245,7 +1543,7 @@ var RunStore = class {
1245
1543
  * Returns them so the caller can emit failure events and update status.
1246
1544
  */
1247
1545
  async recoverOrphanedRuns() {
1248
- if (!existsSync3(this.runsDir)) return [];
1546
+ if (!existsSync4(this.runsDir)) return [];
1249
1547
  const orphaned = [];
1250
1548
  try {
1251
1549
  const jsonFiles = await this.collectRunFiles();
@@ -1296,9 +1594,137 @@ var RunStore = class {
1296
1594
  }
1297
1595
  };
1298
1596
 
1299
- // src/runner/session-executor.ts
1597
+ // src/orchestrator/prompt-builder.ts
1300
1598
  import { readFile as readFile5 } from "fs/promises";
1301
1599
  import path8 from "path";
1600
+ var INSTRUCTIONS_PATH = ".neo/INSTRUCTIONS.md";
1601
+ async function loadRepoInstructions(repoPath) {
1602
+ const filePath = path8.join(repoPath, INSTRUCTIONS_PATH);
1603
+ try {
1604
+ return await readFile5(filePath, "utf-8");
1605
+ } catch {
1606
+ return void 0;
1607
+ }
1608
+ }
1609
+ function buildGitStrategyInstructions(strategy, agent, branch, baseBranch, remote, metadata) {
1610
+ const prNumber = metadata?.prNumber;
1611
+ if (agent.sandbox !== "writable") {
1612
+ if (prNumber) {
1613
+ return `## Pull Request
1614
+
1615
+ PR #${String(prNumber)} is open for this task. After your review, leave your findings as a comment: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1616
+ }
1617
+ return null;
1618
+ }
1619
+ if (strategy === "pr") {
1620
+ if (prNumber) {
1621
+ return `## Git workflow
1622
+
1623
+ You are on branch \`${branch}\`.
1624
+ An open PR exists: #${String(prNumber)}.
1625
+ After committing, push your changes to the branch. The PR will be updated automatically.
1626
+ Leave a review comment on the PR summarizing what you did: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1627
+ }
1628
+ return `## Git workflow
1629
+
1630
+ You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1631
+ After committing:
1632
+ 1. Push: \`git push -u ${remote} ${branch}\`
1633
+ 2. Create a PR against \`${baseBranch}\` \u2014 choose a title and description that reflect the work you completed. End the PR body with: \`\u{1F916} Generated with [neo](https://neotx.dev)\`
1634
+ 3. Output the PR URL on a dedicated line: \`PR_URL: <url>\``;
1635
+ }
1636
+ return `## Git workflow
1637
+
1638
+ You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1639
+ Commit your changes. The branch will be pushed automatically.`;
1640
+ }
1641
+ function buildReportingInstructions(_runId) {
1642
+ return `## Reporting & Memory
1643
+
1644
+ You have two tools to communicate what you learn and do: \`neo log\` (real-time visibility) and \`neo memory\` (persistent knowledge for future agents). Use both deliberately throughout your work.
1645
+
1646
+ ### Progress reporting \u2014 \`neo log\`
1647
+
1648
+ Chain \`neo log\` with the command that triggered the event \u2014 never call it standalone.
1649
+
1650
+ <log-types>
1651
+ | Type | When to use | Example |
1652
+ |------|------------|---------|
1653
+ | \`milestone\` | A meaningful goal is achieved (tests pass, build succeeds, feature complete) | \`neo log milestone "auth middleware passing all tests"\` |
1654
+ | \`action\` | You performed a significant action (push, PR, deploy) | \`neo log action "pushed 3 commits to feat/auth"\` |
1655
+ | \`decision\` | You made a non-obvious choice \u2014 record WHY | \`neo log decision "chose JWT over sessions \u2014 stateless, simpler for MVP"\` |
1656
+ | \`blocker\` | Something is preventing progress | \`neo log blocker "CI fails: missing DATABASE_URL in test env"\` |
1657
+ | \`discovery\` | You found something surprising or important about the codebase | \`neo log discovery "API rate limiter is disabled in dev \u2014 tests hit real endpoints"\` |
1658
+ | \`progress\` | General progress update | \`neo log progress "3/5 endpoints migrated"\` |
1659
+ </log-types>
1660
+
1661
+ <log-rules>
1662
+ - **Chain with commands**: \`pnpm test && neo log milestone "tests passing" || neo log blocker "tests failing"\`
1663
+ - **Log decisions with reasoning**: the "why" is more valuable than the "what"
1664
+ - **Log blockers immediately**: do not continue silently \u2014 surface problems so the supervisor can act
1665
+ - **Log at natural boundaries**: after completing a subtask, before switching context, when hitting an obstacle
1666
+ </log-rules>
1667
+
1668
+ ### Memory \u2014 \`neo memory\`
1669
+
1670
+ Memory is persistent knowledge injected into future agent prompts. Write a memory when you learn something that would change HOW the next agent approaches work on this repo.
1671
+
1672
+ <memory-types>
1673
+ | Type | When to write | Example |
1674
+ |------|--------------|---------|
1675
+ | \`fact\` | Stable truth that affects workflow decisions | Build quirks, CI config, auth patterns, deployment constraints |
1676
+ | \`procedure\` | Non-obvious multi-step workflow learned from failure | "Run X before Y otherwise Z breaks" |
1677
+ </memory-types>
1678
+
1679
+ <memory-decision-tree>
1680
+ Before writing a memory, apply this test:
1681
+ 1. Can \`cat package.json\`, \`ls\`, or reading the README answer this? \u2192 Do NOT memorize.
1682
+ 2. Would knowing this change how you approach the task? \u2192 Write a \`fact\`.
1683
+ 3. Did you fail before discovering this workflow? \u2192 Write a \`procedure\`.
1684
+ 4. Is this just a detail about what you did (file counts, line numbers, component names)? \u2192 Do NOT memorize.
1685
+ </memory-decision-tree>
1686
+
1687
+ <memory-examples type="good">
1688
+ # Affects workflow \u2014 non-obvious build/CI constraints
1689
+ neo memory write --type fact --scope $NEO_REPOSITORY "CI requires pnpm build before push \u2014 no auto-rebuild in pipeline"
1690
+ neo memory write --type fact --scope $NEO_REPOSITORY "Biome enforces complexity max 20 \u2014 extract helpers for large functions"
1691
+
1692
+ # Learned from failure \u2014 save the next agent from the same mistake
1693
+ neo memory write --type procedure --scope $NEO_REPOSITORY "Run pnpm db:generate after any schema.prisma change \u2014 TypeScript types won't update otherwise"
1694
+ neo memory write --type procedure --scope $NEO_REPOSITORY "E2E tests need STRIPE_TEST_KEY in .env.test \u2014 tests hang silently without it"
1695
+ </memory-examples>
1696
+
1697
+ <memory-examples type="bad">
1698
+ # NEVER write these \u2014 trivial or derivable
1699
+ # "packages/core has 71 files" \u2192 derivable from ls
1700
+ # "Uses React 19" \u2192 visible in package.json
1701
+ # "Main entry is src/index.ts" \u2192 visible in package.json
1702
+ # "Tests use vitest" \u2192 visible in config files
1703
+ </memory-examples>
1704
+
1705
+ <when-to-write>
1706
+ Write memories at these key moments:
1707
+ - **After resolving a non-obvious issue**: the fix revealed a constraint future agents should know
1708
+ - **After discovering a build/CI/deploy quirk**: the next agent will hit the same wall without this
1709
+ - **Before finishing your task**: review what you learned \u2014 anything that would save the next agent 10+ minutes?
1710
+ - **After a failed attempt**: if you tried something that seemed right but failed, document why
1711
+ </when-to-write>`;
1712
+ }
1713
+ function buildFullPrompt(agentPrompt, repoInstructions, gitInstructions, taskPrompt, memoryContext, cwdInstructions, reportingInstructions) {
1714
+ const sections = [];
1715
+ if (agentPrompt) sections.push(agentPrompt);
1716
+ if (cwdInstructions) sections.push(cwdInstructions);
1717
+ if (memoryContext) sections.push(memoryContext);
1718
+ if (repoInstructions) sections.push(`## Repository instructions
1719
+
1720
+ ${repoInstructions}`);
1721
+ if (gitInstructions) sections.push(gitInstructions);
1722
+ if (reportingInstructions) sections.push(reportingInstructions);
1723
+ sections.push(`## Task
1724
+
1725
+ ${taskPrompt}`);
1726
+ return sections.join("\n\n---\n\n");
1727
+ }
1302
1728
 
1303
1729
  // src/runner/output-parser.ts
1304
1730
  function extractJson(raw) {
@@ -1542,98 +1968,10 @@ async function runWithRecovery(options) {
1542
1968
  }
1543
1969
 
1544
1970
  // src/runner/session-executor.ts
1545
- var INSTRUCTIONS_PATH = ".neo/INSTRUCTIONS.md";
1546
- async function loadRepoInstructions(repoPath) {
1547
- const filePath = path8.join(repoPath, INSTRUCTIONS_PATH);
1548
- try {
1549
- return await readFile5(filePath, "utf-8");
1550
- } catch {
1551
- return void 0;
1552
- }
1553
- }
1554
- function buildGitStrategyInstructions(strategy, agent, branch, baseBranch, remote, metadata) {
1555
- const prNumber = metadata?.prNumber;
1556
- if (agent.sandbox !== "writable") {
1557
- if (prNumber) {
1558
- return `## Pull Request
1559
-
1560
- PR #${String(prNumber)} is open for this task. After your review, leave your findings as a comment: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1561
- }
1562
- return null;
1563
- }
1564
- if (strategy === "pr") {
1565
- if (prNumber) {
1566
- return `## Git workflow
1567
-
1568
- You are on branch \`${branch}\`.
1569
- An open PR exists: #${String(prNumber)}.
1570
- After committing, push your changes to the branch. The PR will be updated automatically.
1571
- Leave a review comment on the PR summarizing what you did: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1572
- }
1573
- return `## Git workflow
1574
-
1575
- You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1576
- After committing:
1577
- 1. Push: \`git push -u ${remote} ${branch}\`
1578
- 2. Create a PR against \`${baseBranch}\` \u2014 choose a title and description that reflect the work you completed. End the PR body with: \`\u{1F916} Generated with [neo](https://neotx.dev)\`
1579
- 3. Output the PR URL on a dedicated line: \`PR_URL: <url>\``;
1580
- }
1581
- return `## Git workflow
1582
-
1583
- You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1584
- Commit your changes. The branch will be pushed automatically.`;
1585
- }
1586
- function buildReportingInstructions(_runId) {
1587
- return `## Reporting & Memory
1588
-
1589
- ### Progress reporting (real-time, visible in TUI)
1590
- Chain \`neo log\` with the command that triggered it \u2014 never standalone:
1591
- \`\`\`bash
1592
- pnpm test && neo log milestone "all tests passing" || neo log blocker "tests failing"
1593
- git push origin HEAD && neo log action "pushed to branch"
1594
- neo log decision "chose JWT over sessions \u2014 simpler for MVP"
1595
- \`\`\`
1596
-
1597
- ### Memory (persistent, injected into future agent prompts)
1598
- Write discoveries so the next agent on this repo starts smarter.
1599
-
1600
- **Be selective** \u2014 only write a memory if it would change HOW you or future agents approach work:
1601
- \`\`\`bash
1602
- # GOOD: affects workflow decisions
1603
- neo memory write --type fact --scope $NEO_REPOSITORY "CI requires pnpm build before push \u2014 no auto-rebuild in pipeline"
1604
- neo memory write --type fact --scope $NEO_REPOSITORY "Biome enforces complexity max 20 \u2014 extract helpers for large functions"
1605
- neo memory write --type procedure --scope $NEO_REPOSITORY "Integration tests require DATABASE_URL env var \u2014 set before running"
1606
-
1607
- # BAD: trivial or derivable \u2014 do NOT write these
1608
- # "packages/core has 71 files" \u2014 derivable from ls
1609
- # "Uses React 19" \u2014 visible in package.json
1610
- # "apps/web has no test framework" \u2014 derivable from ls/cat
1611
- \`\`\`
1612
-
1613
- **The test**: if \`cat package.json\`, \`ls\`, or reading the README can answer it, do NOT memorize it. Only memorize truths that affect decisions or non-obvious workflows learned from failure.
1614
-
1615
- Write at key moments: after resolving a non-obvious issue, after discovering a build/CI quirk, before finishing.`;
1616
- }
1617
- function buildFullPrompt(agentPrompt, repoInstructions, gitInstructions, taskPrompt, memoryContext, cwdInstructions, reportingInstructions) {
1618
- const sections = [];
1619
- if (agentPrompt) sections.push(agentPrompt);
1620
- if (cwdInstructions) sections.push(cwdInstructions);
1621
- if (memoryContext) sections.push(memoryContext);
1622
- if (repoInstructions) sections.push(`## Repository instructions
1623
-
1624
- ${repoInstructions}`);
1625
- if (gitInstructions) sections.push(gitInstructions);
1626
- if (reportingInstructions) sections.push(reportingInstructions);
1627
- sections.push(`## Task
1628
-
1629
- ${taskPrompt}`);
1630
- return sections.join("\n\n---\n\n");
1631
- }
1632
- function buildMiddlewareContext(runId, workflow, step, agent, repo, getContextValue) {
1971
+ function buildMiddlewareContext(runId, step, agent, repo, getContextValue) {
1633
1972
  const store = /* @__PURE__ */ new Map();
1634
1973
  return {
1635
1974
  runId,
1636
- workflow,
1637
1975
  step,
1638
1976
  agent,
1639
1977
  repo,
@@ -1660,7 +1998,6 @@ var SessionExecutor = class {
1660
1998
  const {
1661
1999
  runId,
1662
2000
  agent,
1663
- stepDef,
1664
2001
  repoConfig,
1665
2002
  repoPath,
1666
2003
  prompt: taskPrompt,
@@ -1681,7 +2018,6 @@ var SessionExecutor = class {
1681
2018
  const chain = buildMiddlewareChain(middleware);
1682
2019
  const middlewareContext = buildMiddlewareContext(
1683
2020
  runId,
1684
- stepDef.prompt ? "workflow" : "direct",
1685
2021
  "execute",
1686
2022
  agent.name,
1687
2023
  repoPath,
@@ -1706,12 +2042,11 @@ ALWAYS run commands from this directory. NEVER cd to or operate on any other rep
1706
2042
  agent.definition.prompt,
1707
2043
  repoInstructions,
1708
2044
  gitInstructions,
1709
- stepDef.prompt ?? taskPrompt,
2045
+ taskPrompt,
1710
2046
  memoryContext,
1711
2047
  cwdInstructions,
1712
2048
  reportingInstructions
1713
2049
  );
1714
- const recoveryOpts = stepDef.recovery;
1715
2050
  const agentEnv = {
1716
2051
  NEO_RUN_ID: runId,
1717
2052
  NEO_AGENT_NAME: agent.name,
@@ -1726,11 +2061,10 @@ ALWAYS run commands from this directory. NEVER cd to or operate on any other rep
1726
2061
  env: agentEnv,
1727
2062
  initTimeoutMs: this.config.initTimeoutMs,
1728
2063
  maxDurationMs: this.config.maxDurationMs,
1729
- maxRetries: recoveryOpts?.maxRetries ?? this.config.maxRetries,
2064
+ maxRetries: this.config.maxRetries,
1730
2065
  backoffBaseMs: this.config.backoffBaseMs,
1731
2066
  ...sessionPath ? { sessionPath } : {},
1732
2067
  ...mcpServers ? { mcpServers } : {},
1733
- ...recoveryOpts?.nonRetryable ? { nonRetryable: recoveryOpts.nonRetryable } : {},
1734
2068
  ...onAttempt ? { onAttempt } : {}
1735
2069
  });
1736
2070
  const parsed = parseOutput(sessionResult.output);
@@ -1869,7 +2203,7 @@ ${sections.join("\n\n")}`;
1869
2203
 
1870
2204
  // src/supervisor/memory/store.ts
1871
2205
  import { randomUUID as randomUUID2 } from "crypto";
1872
- import { existsSync as existsSync4, mkdirSync } from "fs";
2206
+ import { existsSync as existsSync5, mkdirSync } from "fs";
1873
2207
  import { createRequire } from "module";
1874
2208
  import path9 from "path";
1875
2209
  var esmRequire = createRequire(import.meta.url);
@@ -1879,7 +2213,7 @@ var MemoryStore = class {
1879
2213
  hasVec;
1880
2214
  constructor(dbPath, embedder) {
1881
2215
  const dir = path9.dirname(dbPath);
1882
- if (!existsSync4(dir)) {
2216
+ if (!existsSync5(dir)) {
1883
2217
  mkdirSync(dir, { recursive: true });
1884
2218
  }
1885
2219
  const Database = esmRequire("better-sqlite3");
@@ -2221,128 +2555,6 @@ function rowToEntry(row) {
2221
2555
  };
2222
2556
  }
2223
2557
 
2224
- // src/workflows/registry.ts
2225
- import { existsSync as existsSync5 } from "fs";
2226
- import { readdir as readdir4 } from "fs/promises";
2227
- import path10 from "path";
2228
-
2229
- // src/workflows/loader.ts
2230
- import { readFile as readFile6 } from "fs/promises";
2231
- import { parse } from "yaml";
2232
- import { z as z4 } from "zod";
2233
- var workflowStepDefSchema = z4.object({
2234
- type: z4.literal("step").optional().default("step"),
2235
- agent: z4.string(),
2236
- dependsOn: z4.array(z4.string()).optional(),
2237
- prompt: z4.string().optional(),
2238
- sandbox: z4.enum(["writable", "readonly"]).optional(),
2239
- maxTurns: z4.number().int().positive().optional(),
2240
- mcpServers: z4.array(z4.string()).optional(),
2241
- recovery: z4.object({
2242
- maxRetries: z4.number().int().nonnegative().optional(),
2243
- nonRetryable: z4.array(z4.string()).optional()
2244
- }).optional(),
2245
- condition: z4.string().optional()
2246
- });
2247
- var workflowGateDefSchema = z4.object({
2248
- type: z4.literal("gate"),
2249
- dependsOn: z4.array(z4.string()).optional(),
2250
- description: z4.string(),
2251
- timeout: z4.string().optional(),
2252
- autoApprove: z4.boolean().optional()
2253
- });
2254
- var workflowHeaderSchema = z4.object({
2255
- name: z4.string().min(1),
2256
- description: z4.string().optional(),
2257
- steps: z4.record(z4.string(), z4.unknown())
2258
- });
2259
- function parseStepEntry(stepName, stepValue) {
2260
- const obj = stepValue;
2261
- const schema = obj.type === "gate" ? workflowGateDefSchema : workflowStepDefSchema;
2262
- const result = schema.safeParse(stepValue);
2263
- if (result.success) {
2264
- return { step: result.data, errors: [] };
2265
- }
2266
- return {
2267
- step: stepValue,
2268
- errors: result.error.issues.map(
2269
- (i) => ` - steps.${stepName}.${i.path.join(".")}: ${i.message}`
2270
- )
2271
- };
2272
- }
2273
- function parseSteps(rawSteps, filePath) {
2274
- if (Object.keys(rawSteps).length === 0) {
2275
- throw new Error(
2276
- `Invalid workflow definition in ${filePath}:
2277
- - steps: Workflow must have at least one step`
2278
- );
2279
- }
2280
- const steps = {};
2281
- const errors = [];
2282
- for (const [name, value] of Object.entries(rawSteps)) {
2283
- const { step, errors: stepErrors } = parseStepEntry(name, value);
2284
- if (stepErrors.length > 0) {
2285
- errors.push(...stepErrors);
2286
- } else {
2287
- steps[name] = step;
2288
- }
2289
- }
2290
- if (errors.length > 0) {
2291
- throw new Error(`Invalid workflow definition in ${filePath}:
2292
- ${errors.join("\n")}`);
2293
- }
2294
- return steps;
2295
- }
2296
- async function loadWorkflow(filePath) {
2297
- const content = await readFile6(filePath, "utf-8");
2298
- const raw = parse(content);
2299
- const headerResult = workflowHeaderSchema.safeParse(raw);
2300
- if (!headerResult.success) {
2301
- const issues = headerResult.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
2302
- throw new Error(`Invalid workflow definition in ${filePath}:
2303
- ${issues}`);
2304
- }
2305
- const { name, description, steps: rawSteps } = headerResult.data;
2306
- const steps = parseSteps(rawSteps, filePath);
2307
- return { name, description, steps };
2308
- }
2309
-
2310
- // src/workflows/registry.ts
2311
- var WorkflowRegistry = class {
2312
- builtInDir;
2313
- customDir;
2314
- workflows = /* @__PURE__ */ new Map();
2315
- constructor(builtInDir, customDir) {
2316
- this.builtInDir = builtInDir;
2317
- this.customDir = customDir;
2318
- }
2319
- async load() {
2320
- await this.loadFromDir(this.builtInDir);
2321
- if (this.customDir) {
2322
- await this.loadFromDir(this.customDir);
2323
- }
2324
- }
2325
- get(name) {
2326
- return this.workflows.get(name);
2327
- }
2328
- list() {
2329
- return [...this.workflows.values()];
2330
- }
2331
- has(name) {
2332
- return this.workflows.has(name);
2333
- }
2334
- async loadFromDir(dir) {
2335
- if (!existsSync5(dir)) return;
2336
- const files = await readdir4(dir);
2337
- for (const file of files) {
2338
- if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
2339
- const filePath = path10.join(dir, file);
2340
- const workflow = await loadWorkflow(filePath);
2341
- this.workflows.set(workflow.name, workflow);
2342
- }
2343
- }
2344
- };
2345
-
2346
2558
  // src/orchestrator.ts
2347
2559
  var MAX_PROMPT_SIZE = 100 * 1024;
2348
2560
  var MAX_METADATA_DEPTH = 5;
@@ -2352,7 +2564,6 @@ var Orchestrator = class extends NeoEventEmitter {
2352
2564
  config;
2353
2565
  semaphore;
2354
2566
  userMiddleware;
2355
- workflows = /* @__PURE__ */ new Map();
2356
2567
  registeredAgents = /* @__PURE__ */ new Map();
2357
2568
  _activeSessions = /* @__PURE__ */ new Map();
2358
2569
  idempotencyCache = /* @__PURE__ */ new Map();
@@ -2360,8 +2571,6 @@ var Orchestrator = class extends NeoEventEmitter {
2360
2571
  repoIndex = /* @__PURE__ */ new Map();
2361
2572
  runStore = new RunStore();
2362
2573
  journalDir;
2363
- builtInWorkflowDir;
2364
- customWorkflowDir;
2365
2574
  costJournal = null;
2366
2575
  eventJournal = null;
2367
2576
  webhookDispatcher = null;
@@ -2376,11 +2585,9 @@ var Orchestrator = class extends NeoEventEmitter {
2376
2585
  this.config = config;
2377
2586
  this.userMiddleware = options.middleware ?? [];
2378
2587
  this.journalDir = options.journalDir ?? getJournalsDir();
2379
- this.builtInWorkflowDir = options.builtInWorkflowDir;
2380
- this.customWorkflowDir = options.customWorkflowDir;
2381
2588
  this.skipOrphanRecovery = options.skipOrphanRecovery ?? false;
2382
2589
  for (const repo of config.repos) {
2383
- const resolvedPath = path11.resolve(repo.path);
2590
+ const resolvedPath = path10.resolve(repo.path);
2384
2591
  const normalizedRepo = { ...repo, path: resolvedPath };
2385
2592
  this.repoIndex.set(resolvedPath, normalizedRepo);
2386
2593
  }
@@ -2413,9 +2620,6 @@ var Orchestrator = class extends NeoEventEmitter {
2413
2620
  );
2414
2621
  }
2415
2622
  // ─── Registration ──────────────────────────────────────
2416
- registerWorkflow(definition) {
2417
- this.workflows.set(definition.name, definition);
2418
- }
2419
2623
  registerAgent(agent) {
2420
2624
  this.registeredAgents.set(agent.name, agent);
2421
2625
  }
@@ -2489,13 +2693,6 @@ var Orchestrator = class extends NeoEventEmitter {
2489
2693
  );
2490
2694
  }
2491
2695
  this._costToday = await this.costJournal.getDayTotal();
2492
- if (this.builtInWorkflowDir) {
2493
- const registry = new WorkflowRegistry(this.builtInWorkflowDir, this.customWorkflowDir);
2494
- await registry.load();
2495
- for (const workflow of registry.list()) {
2496
- this.registerWorkflow(workflow);
2497
- }
2498
- }
2499
2696
  if (!this.skipOrphanRecovery) {
2500
2697
  await this.recoverOrphanedRuns();
2501
2698
  }
@@ -2567,21 +2764,18 @@ var Orchestrator = class extends NeoEventEmitter {
2567
2764
  buildDispatchContext(input) {
2568
2765
  const runId = input.runId ?? randomUUID3();
2569
2766
  const sessionId = randomUUID3();
2570
- const workflow = this.workflows.get(input.workflow);
2571
- if (!workflow) {
2572
- const available = [...this.workflows.keys()].join(", ") || "none";
2767
+ const agent = this.registeredAgents.get(input.agent);
2768
+ if (!agent) {
2769
+ const available = [...this.registeredAgents.keys()].join(", ") || "none";
2573
2770
  throw new Error(
2574
- `Workflow "${input.workflow}" not found. Available workflows: ${available}. Check the workflow name or register it first.`
2771
+ `Agent "${input.agent}" not found. Available agents: ${available}. Register the agent first.`
2575
2772
  );
2576
2773
  }
2577
- const [stepName, stepDef] = this.getFirstStep(workflow, input);
2578
- const agent = this.resolveStepAgent(stepDef, workflow.name);
2579
2774
  const repoConfig = this.resolveRepo(input.repo);
2580
2775
  const activeSession = {
2581
2776
  sessionId,
2582
2777
  runId,
2583
- workflow: input.workflow,
2584
- step: stepName,
2778
+ step: "execute",
2585
2779
  agent: agent.name,
2586
2780
  repo: input.repo,
2587
2781
  status: "queued",
@@ -2593,8 +2787,6 @@ var Orchestrator = class extends NeoEventEmitter {
2593
2787
  runId,
2594
2788
  sessionId,
2595
2789
  startedAt: Date.now(),
2596
- stepName,
2597
- stepDef,
2598
2790
  agent,
2599
2791
  repoConfig,
2600
2792
  activeSession
@@ -2606,7 +2798,7 @@ var Orchestrator = class extends NeoEventEmitter {
2606
2798
  await this.persistRun({
2607
2799
  version: 1,
2608
2800
  runId,
2609
- workflow: input.workflow,
2801
+ agent: agent.name,
2610
2802
  repo: input.repo,
2611
2803
  prompt: input.prompt,
2612
2804
  pid: process.pid,
@@ -2618,7 +2810,7 @@ var Orchestrator = class extends NeoEventEmitter {
2618
2810
  });
2619
2811
  try {
2620
2812
  const branchName = input.branch || repoConfig.defaultBranch;
2621
- const sessionDir = path11.join(this.config.sessions.dir, runId);
2813
+ const sessionDir = path10.join(this.config.sessions.dir, runId);
2622
2814
  const info = await createSessionClone({
2623
2815
  repoPath: input.repo,
2624
2816
  branch: branchName,
@@ -2691,13 +2883,12 @@ var Orchestrator = class extends NeoEventEmitter {
2691
2883
  }
2692
2884
  }
2693
2885
  async runAgentSession(ctx, sessionPath) {
2694
- const { input, runId, sessionId, stepName, stepDef, agent, repoConfig, activeSession } = ctx;
2886
+ const { input, runId, sessionId, agent, repoConfig, activeSession } = ctx;
2695
2887
  this.emit({
2696
2888
  type: "session:start",
2697
2889
  sessionId,
2698
2890
  runId,
2699
- workflow: input.workflow,
2700
- step: stepName,
2891
+ step: "execute",
2701
2892
  agent: agent.name,
2702
2893
  repo: input.repo,
2703
2894
  metadata: input.metadata,
@@ -2717,15 +2908,13 @@ var Orchestrator = class extends NeoEventEmitter {
2717
2908
  }
2718
2909
  );
2719
2910
  const strategy = input.gitStrategy ?? repoConfig.gitStrategy ?? "branch";
2720
- const mcpServers = this.resolveMcpServers(stepDef, agent);
2911
+ const mcpServers = this.resolveMcpServers(agent);
2721
2912
  const memoryContext = this.loadMemoryContext(input.repo);
2722
- const recoveryOpts = stepDef.recovery;
2723
2913
  const result = await executor.execute(
2724
2914
  {
2725
2915
  runId,
2726
2916
  sessionId,
2727
2917
  agent,
2728
- stepDef,
2729
2918
  repoConfig,
2730
2919
  repoPath: input.repo,
2731
2920
  prompt: input.prompt,
@@ -2747,7 +2936,7 @@ var Orchestrator = class extends NeoEventEmitter {
2747
2936
  runId,
2748
2937
  error: `Retrying with strategy: ${strategy2}`,
2749
2938
  attempt: attempt - 1,
2750
- maxRetries: recoveryOpts?.maxRetries ?? this.config.recovery.maxRetries,
2939
+ maxRetries: this.config.recovery.maxRetries,
2751
2940
  willRetry: true,
2752
2941
  metadata: input.metadata,
2753
2942
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -2772,13 +2961,13 @@ var Orchestrator = class extends NeoEventEmitter {
2772
2961
  return result;
2773
2962
  }
2774
2963
  async finalizeDispatch(ctx, stepResult, idempotencyKey) {
2775
- const { input, runId, stepName, activeSession } = ctx;
2964
+ const { input, runId, agent, activeSession } = ctx;
2776
2965
  const taskResult = {
2777
2966
  runId,
2778
- workflow: input.workflow,
2967
+ agent: agent.name,
2779
2968
  repo: input.repo,
2780
2969
  status: stepResult.status === "success" ? "success" : "failure",
2781
- steps: { [stepName]: stepResult },
2970
+ steps: { execute: stepResult },
2782
2971
  branch: stepResult.status === "success" && activeSession.sessionPath ? input.branch : void 0,
2783
2972
  costUsd: stepResult.costUsd,
2784
2973
  durationMs: Date.now() - ctx.startedAt,
@@ -2794,7 +2983,7 @@ var Orchestrator = class extends NeoEventEmitter {
2794
2983
  await this.persistRun({
2795
2984
  version: 1,
2796
2985
  runId,
2797
- workflow: input.workflow,
2986
+ agent: agent.name,
2798
2987
  repo: input.repo,
2799
2988
  prompt: input.prompt,
2800
2989
  pid: process.pid,
@@ -2817,8 +3006,8 @@ var Orchestrator = class extends NeoEventEmitter {
2817
3006
  // ─── Private: Memory injection ──────────────────────────
2818
3007
  getMemoryStore() {
2819
3008
  if (!this.memoryStore) {
2820
- const supervisorDir = path11.join(getSupervisorsDir(), "supervisor");
2821
- this.memoryStore = new MemoryStore(path11.join(supervisorDir, "memory.sqlite"));
3009
+ const supervisorDir = path10.join(getSupervisorsDir(), "supervisor");
3010
+ this.memoryStore = new MemoryStore(path10.join(supervisorDir, "memory.sqlite"));
2822
3011
  }
2823
3012
  return this.memoryStore;
2824
3013
  }
@@ -2845,8 +3034,7 @@ var Orchestrator = class extends NeoEventEmitter {
2845
3034
  const costEntry = {
2846
3035
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2847
3036
  runId: ctx.runId,
2848
- workflow: ctx.input.workflow,
2849
- step: ctx.stepName,
3037
+ step: "execute",
2850
3038
  sessionId,
2851
3039
  agent: ctx.agent.name,
2852
3040
  costUsd: sessionCost,
@@ -2915,8 +3103,8 @@ var Orchestrator = class extends NeoEventEmitter {
2915
3103
  if (!existsSync6(input.repo)) {
2916
3104
  throw new Error(`Validation error: repo path does not exist: ${input.repo}`);
2917
3105
  }
2918
- if (!this.workflows.has(input.workflow)) {
2919
- throw new Error(`Validation error: workflow "${input.workflow}" not found in registry`);
3106
+ if (!this.registeredAgents.has(input.agent)) {
3107
+ throw new Error(`Validation error: agent "${input.agent}" not found in registry`);
2920
3108
  }
2921
3109
  if (input.metadata !== void 0) {
2922
3110
  if (!isPlainObject(input.metadata)) {
@@ -2947,45 +3135,12 @@ var Orchestrator = class extends NeoEventEmitter {
2947
3135
  if (!idempotency?.enabled) return null;
2948
3136
  const key = idempotency.key ?? "metadata";
2949
3137
  if (key === "prompt") {
2950
- return `${input.workflow}:${input.repo}:${input.prompt}`;
2951
- }
2952
- return `${input.workflow}:${input.repo}:${JSON.stringify(input.metadata ?? {})}`;
2953
- }
2954
- getFirstStep(workflow, input) {
2955
- if (input.step) {
2956
- const step = workflow.steps[input.step];
2957
- if (!step || step.type === "gate") {
2958
- throw new Error(
2959
- `Step "${input.step}" not found in workflow "${workflow.name}" or is a gate step. Check the step name in the workflow definition.`
2960
- );
2961
- }
2962
- return [input.step, step];
2963
- }
2964
- for (const [name, step] of Object.entries(workflow.steps)) {
2965
- if (step.type === "gate") continue;
2966
- const stepDef = step;
2967
- if (!stepDef.dependsOn || stepDef.dependsOn.length === 0) {
2968
- return [name, stepDef];
2969
- }
2970
- }
2971
- const entries = Object.entries(workflow.steps);
2972
- const first = entries[0];
2973
- if (!first) {
2974
- throw new Error(`Workflow "${workflow.name}" has no steps`);
3138
+ return `${input.agent}:${input.repo}:${input.prompt}`;
2975
3139
  }
2976
- return [first[0], first[1]];
2977
- }
2978
- resolveStepAgent(step, workflowName) {
2979
- const agent = this.registeredAgents.get(step.agent);
2980
- if (!agent) {
2981
- throw new Error(
2982
- `Agent "${step.agent}" required by workflow "${workflowName}" not found in registry. Register the agent or check the workflow definition.`
2983
- );
2984
- }
2985
- return agent;
3140
+ return `${input.agent}:${input.repo}:${JSON.stringify(input.metadata ?? {})}`;
2986
3141
  }
2987
3142
  resolveRepo(repoPath) {
2988
- const repo = this.repoIndex.get(path11.resolve(repoPath));
3143
+ const repo = this.repoIndex.get(path10.resolve(repoPath));
2989
3144
  if (repo) return repo;
2990
3145
  return {
2991
3146
  path: repoPath,
@@ -3001,17 +3156,11 @@ var Orchestrator = class extends NeoEventEmitter {
3001
3156
  return Math.max(0, (cap - this._costToday) / cap * 100);
3002
3157
  }
3003
3158
  // ─── Private: MCP server resolution ────────────────────
3004
- resolveMcpServers(stepDef, agent) {
3159
+ resolveMcpServers(agent) {
3005
3160
  const configServers = this.config.mcpServers;
3006
3161
  if (!configServers) return void 0;
3007
- const names = /* @__PURE__ */ new Set();
3008
- if (stepDef.mcpServers) {
3009
- for (const name of stepDef.mcpServers) names.add(name);
3010
- }
3011
- if (agent.definition.mcpServers) {
3012
- for (const name of agent.definition.mcpServers) names.add(name);
3013
- }
3014
- if (names.size === 0) return void 0;
3162
+ const names = agent.definition.mcpServers;
3163
+ if (!names || names.length === 0) return void 0;
3015
3164
  const resolved = {};
3016
3165
  for (const name of names) {
3017
3166
  const serverConfig = configServers[name];
@@ -3024,17 +3173,17 @@ var Orchestrator = class extends NeoEventEmitter {
3024
3173
  // ─── Private: Supervisor discovery ─────────────────────
3025
3174
  /** Discover running supervisor daemons and return webhook configs for their endpoints. */
3026
3175
  async discoverSupervisorWebhooks() {
3027
- const { readdir: readdir7 } = await import("fs/promises");
3176
+ const { readdir: readdir6 } = await import("fs/promises");
3028
3177
  const supervisorsDir = getSupervisorsDir();
3029
3178
  if (!existsSync6(supervisorsDir)) return [];
3030
3179
  const webhooks = [];
3031
3180
  try {
3032
- const entries = await readdir7(supervisorsDir, { withFileTypes: true });
3181
+ const entries = await readdir6(supervisorsDir, { withFileTypes: true });
3033
3182
  for (const entry of entries) {
3034
3183
  if (!entry.isDirectory()) continue;
3035
3184
  try {
3036
- const statePath = path11.join(supervisorsDir, entry.name, "state.json");
3037
- const raw = await readFile7(statePath, "utf-8");
3185
+ const statePath = path10.join(supervisorsDir, entry.name, "state.json");
3186
+ const raw = await readFile6(statePath, "utf-8");
3038
3187
  const state = JSON.parse(raw);
3039
3188
  if (state.status !== "running" || !state.port) continue;
3040
3189
  if (state.pid && !isProcessAlive(state.pid)) continue;
@@ -3087,13 +3236,176 @@ function objectDepth(obj, current = 0) {
3087
3236
 
3088
3237
  // src/supervisor/schemas.ts
3089
3238
  import { z as z5 } from "zod";
3239
+
3240
+ // src/supervisor/decisions.ts
3241
+ import { randomUUID as randomUUID4 } from "crypto";
3242
+ import { appendFile as appendFile4, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
3243
+ import path11 from "path";
3244
+ import { z as z4 } from "zod";
3245
+ var decisionOptionSchema = z4.object({
3246
+ key: z4.string(),
3247
+ label: z4.string(),
3248
+ description: z4.string().optional()
3249
+ });
3250
+ var decisionSchema = z4.object({
3251
+ id: z4.string(),
3252
+ question: z4.string(),
3253
+ context: z4.string().optional(),
3254
+ options: z4.array(decisionOptionSchema).optional(),
3255
+ type: z4.string().default("generic"),
3256
+ source: z4.string(),
3257
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
3258
+ createdAt: z4.string(),
3259
+ expiresAt: z4.string().optional(),
3260
+ defaultAnswer: z4.string().optional(),
3261
+ answeredAt: z4.string().optional(),
3262
+ answer: z4.string().optional(),
3263
+ expiredAt: z4.string().optional()
3264
+ });
3265
+ var DecisionStore = class {
3266
+ filePath;
3267
+ dir;
3268
+ dirCache = /* @__PURE__ */ new Set();
3269
+ constructor(filePath) {
3270
+ this.filePath = filePath;
3271
+ this.dir = path11.dirname(filePath);
3272
+ }
3273
+ /**
3274
+ * Create a new decision and persist it.
3275
+ * @returns The generated decision ID
3276
+ */
3277
+ async create(input) {
3278
+ await ensureDir(this.dir, this.dirCache);
3279
+ const id = `dec_${randomUUID4().replace(/-/g, "").slice(0, 20)}`;
3280
+ const decision = {
3281
+ ...input,
3282
+ id,
3283
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3284
+ };
3285
+ await appendFile4(this.filePath, `${JSON.stringify(decision)}
3286
+ `, "utf-8");
3287
+ return id;
3288
+ }
3289
+ /**
3290
+ * Answer a decision by ID.
3291
+ * Reads all entries, updates the matching one, and rewrites the file.
3292
+ */
3293
+ async answer(id, answer) {
3294
+ const decisions = await this.readAll();
3295
+ const decision = decisions.find((d) => d.id === id);
3296
+ if (!decision) {
3297
+ throw new Error(`Decision not found: ${id}`);
3298
+ }
3299
+ if (decision.answer !== void 0) {
3300
+ throw new Error(`Decision already answered: ${id}`);
3301
+ }
3302
+ decision.answer = answer;
3303
+ decision.answeredAt = (/* @__PURE__ */ new Date()).toISOString();
3304
+ await this.writeAll(decisions);
3305
+ }
3306
+ /**
3307
+ * Get all pending decisions (unanswered, not expired, not timed out).
3308
+ */
3309
+ async pending() {
3310
+ const decisions = await this.readAll();
3311
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3312
+ return decisions.filter((d) => {
3313
+ if (d.answer !== void 0) return false;
3314
+ if (d.expiredAt !== void 0) return false;
3315
+ if (d.expiresAt && d.expiresAt < now) return false;
3316
+ return true;
3317
+ });
3318
+ }
3319
+ /**
3320
+ * Get answered decisions, optionally filtered by timestamp.
3321
+ * @param since - ISO timestamp to filter decisions answered after this time
3322
+ */
3323
+ async answered(since) {
3324
+ const decisions = await this.readAll();
3325
+ return decisions.filter((d) => {
3326
+ if (d.answer === void 0) return false;
3327
+ if (since && d.answeredAt && d.answeredAt < since) return false;
3328
+ return true;
3329
+ });
3330
+ }
3331
+ /**
3332
+ * Get a specific decision by ID.
3333
+ */
3334
+ async get(id) {
3335
+ const decisions = await this.readAll();
3336
+ return decisions.find((d) => d.id === id) ?? null;
3337
+ }
3338
+ /**
3339
+ * Auto-answer expired decisions with their defaultAnswer.
3340
+ * Decisions without defaultAnswer are marked as expired (expiredAt).
3341
+ * @returns The decisions that were auto-answered or marked expired
3342
+ */
3343
+ async expire() {
3344
+ const decisions = await this.readAll();
3345
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3346
+ const expired = [];
3347
+ for (const decision of decisions) {
3348
+ if (decision.answer === void 0 && decision.expiredAt === void 0 && decision.expiresAt && decision.expiresAt < now) {
3349
+ if (decision.defaultAnswer !== void 0) {
3350
+ decision.answer = decision.defaultAnswer;
3351
+ decision.answeredAt = now;
3352
+ } else {
3353
+ decision.expiredAt = now;
3354
+ }
3355
+ expired.push(decision);
3356
+ }
3357
+ }
3358
+ if (expired.length > 0) {
3359
+ await this.writeAll(decisions);
3360
+ }
3361
+ return expired;
3362
+ }
3363
+ // ─── Private helpers ─────────────────────────────────────
3364
+ async readAll() {
3365
+ let content;
3366
+ try {
3367
+ content = await readFile7(this.filePath, "utf-8");
3368
+ } catch (error) {
3369
+ if (error.code === "ENOENT") {
3370
+ return [];
3371
+ }
3372
+ throw error;
3373
+ }
3374
+ const decisions = [];
3375
+ for (const line of content.split("\n")) {
3376
+ if (!line.trim()) continue;
3377
+ try {
3378
+ const parsed = decisionSchema.parse(JSON.parse(line));
3379
+ decisions.push(parsed);
3380
+ } catch (error) {
3381
+ console.warn(
3382
+ `[DecisionStore] Skipping malformed JSONL line: ${error instanceof Error ? error.message : "unknown error"}`
3383
+ );
3384
+ }
3385
+ }
3386
+ return decisions;
3387
+ }
3388
+ async writeAll(decisions) {
3389
+ await ensureDir(this.dir, this.dirCache);
3390
+ const content = `${decisions.map((d) => JSON.stringify(d)).join("\n")}
3391
+ `;
3392
+ await writeFile3(this.filePath, content, "utf-8");
3393
+ }
3394
+ };
3395
+
3396
+ // src/supervisor/schemas.ts
3090
3397
  var wakeReasonSchema = z5.enum(["events", "timer", "active_runs", "forced"]);
3091
- var supervisorDaemonStateSchema = z5.object({
3398
+ var supervisorBaseFieldsSchema = z5.object({
3092
3399
  pid: z5.number(),
3093
3400
  sessionId: z5.string(),
3401
+ startedAt: z5.string(),
3402
+ heartbeatCount: z5.number(),
3403
+ totalCostUsd: z5.number(),
3404
+ todayCostUsd: z5.number()
3405
+ });
3406
+ var supervisorDaemonStateSchema = supervisorBaseFieldsSchema.extend({
3094
3407
  port: z5.number(),
3095
3408
  cwd: z5.string(),
3096
- startedAt: z5.string(),
3097
3409
  lastHeartbeat: z5.string().optional(),
3098
3410
  heartbeatCount: z5.number().default(0),
3099
3411
  totalCostUsd: z5.number().default(0),
@@ -3129,6 +3441,7 @@ var activityEntrySchema = z5.object({
3129
3441
  "decision",
3130
3442
  "action",
3131
3443
  "error",
3444
+ "warning",
3132
3445
  "event",
3133
3446
  "message",
3134
3447
  "thinking",
@@ -3151,11 +3464,33 @@ var logBufferEntrySchema = z5.object({
3151
3464
  timestamp: z5.string(),
3152
3465
  consolidatedAt: z5.string().optional()
3153
3466
  });
3154
- var internalEventKindSchema = z5.enum(["consolidation_timer", "active_run_check"]);
3467
+ var internalEventKindSchema = z5.enum(["consolidation_timer", "active_run_check"]);
3468
+ var supervisorStatusSchema = supervisorBaseFieldsSchema.extend({
3469
+ status: z5.enum(["running", "idle", "stopping"]),
3470
+ lastHeartbeat: z5.string(),
3471
+ activeRunCount: z5.number(),
3472
+ recentActivitySummary: z5.array(z5.string())
3473
+ });
3474
+ var activityTypeFilterSchema = z5.enum([
3475
+ "decision",
3476
+ "action",
3477
+ "error",
3478
+ "event",
3479
+ "message",
3480
+ "plan",
3481
+ "dispatch"
3482
+ ]);
3483
+ var activityQueryOptionsSchema = z5.object({
3484
+ limit: z5.number().int().min(1).max(500).default(50).optional(),
3485
+ offset: z5.number().int().min(0).default(0).optional(),
3486
+ type: activityTypeFilterSchema.optional(),
3487
+ since: z5.string().datetime().optional(),
3488
+ until: z5.string().datetime().optional()
3489
+ });
3155
3490
 
3156
3491
  // src/supervisor/activity-log.ts
3157
- import { randomUUID as randomUUID4 } from "crypto";
3158
- import { appendFile as appendFile4, readFile as readFile8, rename, stat } from "fs/promises";
3492
+ import { randomUUID as randomUUID5 } from "crypto";
3493
+ import { appendFile as appendFile5, readFile as readFile8, rename, stat } from "fs/promises";
3159
3494
  import path12 from "path";
3160
3495
  var ACTIVITY_FILE = "activity.jsonl";
3161
3496
  var MAX_SIZE_BYTES = 10 * 1024 * 1024;
@@ -3174,14 +3509,14 @@ var ActivityLog = class {
3174
3509
  await this.checkRotation();
3175
3510
  const line = `${JSON.stringify(entry)}
3176
3511
  `;
3177
- await appendFile4(this.filePath, line, "utf-8");
3512
+ await appendFile5(this.filePath, line, "utf-8");
3178
3513
  }
3179
3514
  /**
3180
3515
  * Create and append a new entry with auto-generated id and timestamp.
3181
3516
  */
3182
3517
  async log(type, summary, detail) {
3183
3518
  await this.append({
3184
- id: randomUUID4(),
3519
+ id: randomUUID5(),
3185
3520
  type,
3186
3521
  summary,
3187
3522
  detail,
@@ -3223,15 +3558,15 @@ var ActivityLog = class {
3223
3558
  };
3224
3559
 
3225
3560
  // src/supervisor/daemon.ts
3226
- import { randomUUID as randomUUID6 } from "crypto";
3561
+ import { randomUUID as randomUUID7 } from "crypto";
3227
3562
  import { existsSync as existsSync8 } from "fs";
3228
- import { mkdir as mkdir7, readFile as readFile12, rm as rm2, writeFile as writeFile6 } from "fs/promises";
3229
- import { homedir as homedir3 } from "os";
3563
+ import { mkdir as mkdir7, readFile as readFile12, rm as rm2, writeFile as writeFile7 } from "fs/promises";
3564
+ import { homedir as homedir5 } from "os";
3230
3565
  import path15 from "path";
3231
3566
 
3232
3567
  // src/supervisor/event-queue.ts
3233
- import { watch } from "fs";
3234
- import { readFile as readFile9, writeFile as writeFile3 } from "fs/promises";
3568
+ import { watch as watch2 } from "fs";
3569
+ import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
3235
3570
  var EventQueue = class {
3236
3571
  queue = [];
3237
3572
  seenIds = /* @__PURE__ */ new Set();
@@ -3322,7 +3657,7 @@ var EventQueue = class {
3322
3657
  async startWatching(inboxPath, eventsPath) {
3323
3658
  for (const p of [inboxPath, eventsPath]) {
3324
3659
  try {
3325
- await writeFile3(p, "", { flag: "a" });
3660
+ await writeFile4(p, "", { flag: "a" });
3326
3661
  } catch {
3327
3662
  }
3328
3663
  }
@@ -3372,7 +3707,7 @@ var EventQueue = class {
3372
3707
  }
3373
3708
  watchJsonlFile(filePath, kind) {
3374
3709
  try {
3375
- const watcher = watch(filePath, () => {
3710
+ const watcher = watch2(filePath, () => {
3376
3711
  this.readNewLines(filePath, kind).catch(() => {
3377
3712
  });
3378
3713
  });
@@ -3461,7 +3796,7 @@ var EventQueue = class {
3461
3796
  return line;
3462
3797
  });
3463
3798
  if (changed) {
3464
- await writeFile3(filePath, updated.join("\n"), "utf-8");
3799
+ await writeFile4(filePath, updated.join("\n"), "utf-8");
3465
3800
  this.fileOffsets.set(filePath, updated.join("\n").length);
3466
3801
  }
3467
3802
  } catch {
@@ -3470,14 +3805,14 @@ var EventQueue = class {
3470
3805
  };
3471
3806
 
3472
3807
  // src/supervisor/heartbeat.ts
3473
- import { randomUUID as randomUUID5 } from "crypto";
3808
+ import { randomUUID as randomUUID6 } from "crypto";
3474
3809
  import { existsSync as existsSync7 } from "fs";
3475
- import { readdir as readdir5, readFile as readFile11, writeFile as writeFile5 } from "fs/promises";
3476
- import { homedir as homedir2 } from "os";
3810
+ import { readdir as readdir4, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3811
+ import { homedir as homedir4 } from "os";
3477
3812
  import path14 from "path";
3478
3813
 
3479
3814
  // src/supervisor/log-buffer.ts
3480
- import { appendFile as appendFile5, readFile as readFile10, stat as stat2, writeFile as writeFile4 } from "fs/promises";
3815
+ import { appendFile as appendFile6, readFile as readFile10, stat as stat2, writeFile as writeFile5 } from "fs/promises";
3481
3816
  import path13 from "path";
3482
3817
  var LOG_BUFFER_FILE = "log-buffer.jsonl";
3483
3818
  var MAX_FILE_BYTES = 1024 * 1024;
@@ -3531,7 +3866,7 @@ async function markConsolidated(dir, ids) {
3531
3866
  updated.push(line);
3532
3867
  }
3533
3868
  }
3534
- await writeFile4(filePath, `${updated.join("\n")}
3869
+ await writeFile5(filePath, `${updated.join("\n")}
3535
3870
  `, "utf-8");
3536
3871
  }
3537
3872
  async function compactLogBuffer(dir) {
@@ -3565,11 +3900,11 @@ async function compactLogBuffer(dir) {
3565
3900
  result = `${kept.join("\n")}
3566
3901
  `;
3567
3902
  }
3568
- await writeFile4(filePath, result, "utf-8");
3903
+ await writeFile5(filePath, result, "utf-8");
3569
3904
  }
3570
3905
  async function appendLogBuffer(dir, entry) {
3571
3906
  try {
3572
- await appendFile5(bufferPath(dir), `${JSON.stringify(entry)}
3907
+ await appendFile6(bufferPath(dir), `${JSON.stringify(entry)}
3573
3908
  `, "utf-8");
3574
3909
  } catch {
3575
3910
  }
@@ -3588,7 +3923,8 @@ var OPERATING_PRINCIPLES = `### Operating principles
3588
3923
  - Prevent silent stalls: monitor long-running jobs, detect blocked work early, and actively unblock.
3589
3924
  - Keep initiative boundaries strict: decisions for initiative A must not be influenced by unrelated state from B.
3590
3925
  - Your user-visible channel is \`neo log\` only; produce concise tool calls (not reasoning/explanations) and avoid wasted tokens.
3591
- - You may inspect repositories available via \`neo repos\`, read-only to launch agents.`;
3926
+ - You may inspect repositories available via \`neo repos\`, read-only to launch agents.
3927
+ - Task hygiene is non-negotiable: update task outcomes EVERY heartbeat. A task without a current outcome is a blind spot.`;
3592
3928
  var COMMANDS = `### Dispatching agents
3593
3929
  \`\`\`bash
3594
3930
  neo run <agent> --prompt "..." --repo <path> --branch <name> [--priority critical|high|medium|low] [--meta '<json>']
@@ -3627,6 +3963,27 @@ neo memory search "keyword"
3627
3963
  neo memory list --type fact
3628
3964
  \`\`\`
3629
3965
 
3966
+ ### Configuration
3967
+ \`\`\`bash
3968
+ neo config get <key> # read a value (dot notation)
3969
+ neo config set <key> <value> --global # update global config (~/.neo/config.yml)
3970
+ neo config list # show full merged config
3971
+ \`\`\`
3972
+
3973
+ Keys use dot notation (e.g., \`budget.dailyCapUsd\`, \`supervisor.dailyCapUsd\`, \`concurrency.maxSessions\`).
3974
+ Changes are hot-reloaded \u2014 the new values take effect at the next heartbeat.
3975
+
3976
+ Use cases: raise budget cap mid-run, adjust concurrency, change heartbeat timeout.
3977
+
3978
+ ### Decisions
3979
+ When you need human input on something that cannot be decided autonomously:
3980
+ \`\`\`bash
3981
+ neo decision create "<question>" --options "key1:label1,key2:label2:description" [--default <key>] [--expires-in 24h] [--context "..."]
3982
+ neo decision list # show pending decisions
3983
+ neo decision answer <id> <answer> # answer a decision (usually done by human via TUI)
3984
+ \`\`\`
3985
+ The decision ID is returned by \`create\`. If no answer arrives before expiration, the \`--default\` answer is applied automatically (or the decision expires without resolution).
3986
+
3630
3987
  ### Reporting
3631
3988
  \`\`\`bash
3632
3989
  neo log <type> "<message>" # visible in TUI only
@@ -3634,7 +3991,9 @@ neo log <type> "<message>" # visible in TUI only
3634
3991
  var COMMANDS_COMPACT = `### Commands (reference)
3635
3992
  \`neo run <agent> --prompt "..." --repo <path> --branch <name> --meta '{"label":"T1-auth",...}'\`
3636
3993
  \`neo runs [--short | <runId>]\` \xB7 \`neo runs --short --status running\` \xB7 \`neo cost --short\`
3637
- \`neo memory write|update|forget|search|list\` \xB7 \`neo log <type> "<msg>"\``;
3994
+ \`neo memory write|update|forget|search|list\` \xB7 \`neo log <type> "<msg>"\`
3995
+ \`neo config get <key>\` \xB7 \`neo config set <key> <value> --global\` \xB7 \`neo config list\`
3996
+ \`neo decision create "<question>" --options "..." [--default <key>]\` \xB7 \`neo decision list\``;
3638
3997
  var HEARTBEAT_RULES = `### Heartbeat lifecycle
3639
3998
 
3640
3999
  <decision-tree>
@@ -3644,7 +4003,8 @@ var HEARTBEAT_RULES = `### Heartbeat lifecycle
3644
4003
  4. EVENTS? \u2014 process run completions, messages, webhooks. Parse agent JSON output.
3645
4004
  5. FOLLOW-UPS? \u2014 check CI (\`gh pr checks\`), deferred dispatches.
3646
4005
  6. DISPATCH \u2014 route work to agents. Mark tasks \`in_progress\`, add ACTIVE to focus.
3647
- 7. SERIALIZE & YIELD \u2014 rewrite focus (see <focus>), log your decisions, and yield. Do not poll.
4006
+ 7. UPDATE TASKS \u2014 review ALL in_progress/blocked tasks. For each: confirm status matches reality (run still active? PR merged? blocked resolved?). Update outcomes immediately \u2014 do not defer to next heartbeat.
4007
+ 8. SERIALIZE & YIELD \u2014 rewrite focus (see <focus>), log your decisions, and yield. Do not poll.
3648
4008
  </decision-tree>
3649
4009
 
3650
4010
  <run-monitoring>
@@ -3662,6 +4022,12 @@ Runs are your agents in the field. You MUST actively track them:
3662
4022
 
3663
4023
  **Post-completion:** if agent opened a PR, dispatch \`reviewer\` in parallel with CI (do not wait). Update task outcome with concrete details (PR#, what was done) and update the initiative note.
3664
4024
 
4025
+ **Task tracking discipline:**
4026
+ - On dispatch: \`neo memory update <id> --outcome in_progress\` immediately \u2014 never dispatch without updating the task.
4027
+ - On run completion: update to \`done\` with details OR \`blocked\` with reason. Do this in the SAME heartbeat you read the run output.
4028
+ - On run failure: update to \`blocked\` with root cause. Never leave a failed run's task as \`in_progress\`.
4029
+ - Every heartbeat: cross-check active tasks against \`neo runs --short\`. If a run finished but the task is still \`in_progress\`, something was missed \u2014 fix it now.
4030
+
3665
4031
  **Memory:** store key outputs as facts if they affect future tasks (e.g. "T5 added dateRange param to fetchAllFstRecords").
3666
4032
  </multi-task-initiatives>`;
3667
4033
  var REPORTING_RULES = `### Reporting
@@ -3708,6 +4074,13 @@ Create tasks for: incoming tickets, architect decompositions, sub-tickets, follo
3708
4074
  - \`--tags "depends:mem_<id>"\` \u2014 blocks until dependency is done
3709
4075
  - \`--category\` \u2014 retrieval command (MANDATORY). Examples: \`"neo runs <runId>"\` \xB7 \`"cat ${notesDir}/plan-feature.md"\` \xB7 \`"API-retrieve-a-page <notionPageId>"\`
3710
4076
  Lifecycle: create \u2192 in_progress (on dispatch) \u2192 done | blocked | abandoned
4077
+
4078
+ **Update frequency:** task outcomes MUST be updated in the same heartbeat as the triggering event. Never defer a task update to "next heartbeat" \u2014 by then you will have forgotten. Stale task states cause duplicate dispatches and wasted budget.
4079
+
4080
+ **Mandatory cross-check:** before yielding, verify that:
4081
+ 1. Every dispatched run has a corresponding \`in_progress\` task
4082
+ 2. Every completed run has a corresponding \`done\` or \`blocked\` task
4083
+ 3. No task is \`in_progress\` without an active run (unless manually worked)
3711
4084
  </task-workflow>
3712
4085
 
3713
4086
  <focus>
@@ -3767,6 +4140,44 @@ ${lines}
3767
4140
  }
3768
4141
  return "<focus>\n(empty \u2014 use neo memory write --type focus to set working context)\n</focus>";
3769
4142
  }
4143
+ function buildPendingDecisionsSection(decisions) {
4144
+ if (!decisions || decisions.length === 0) {
4145
+ return "";
4146
+ }
4147
+ const lines = [];
4148
+ for (const d of decisions) {
4149
+ const expiry = d.expiresAt ? ` (expires: ${d.expiresAt})` : "";
4150
+ const defaultHint = d.defaultAnswer ? ` [default: ${d.defaultAnswer}]` : "";
4151
+ lines.push(`- **${d.id}**: ${d.question}${expiry}${defaultHint}`);
4152
+ if (d.options && d.options.length > 0) {
4153
+ for (const opt of d.options) {
4154
+ const desc = opt.description ? ` \u2014 ${opt.description}` : "";
4155
+ lines.push(` \u2022 \`${opt.key}\`: ${opt.label}${desc}`);
4156
+ }
4157
+ }
4158
+ if (d.context) {
4159
+ lines.push(` Context: ${d.context}`);
4160
+ }
4161
+ }
4162
+ return `Pending decisions (${decisions.length}):
4163
+ ${lines.join("\n")}
4164
+
4165
+ To answer a decision, emit a \`decision:answer\` event:
4166
+ \`\`\`bash
4167
+ neo event emit decision:answer --data '{"id":"<decision_id>","answer":"<option_key>"}'
4168
+ \`\`\``;
4169
+ }
4170
+ function buildAnsweredDecisionsSection(decisions) {
4171
+ if (!decisions || decisions.length === 0) {
4172
+ return "";
4173
+ }
4174
+ const lines = decisions.map((d) => {
4175
+ const answeredBy = d.source ? ` (by ${d.source})` : "";
4176
+ return `- ${d.id}: "${d.question}" \u2192 **${d.answer}**${answeredBy}`;
4177
+ });
4178
+ return `Recent decisions (${decisions.length}):
4179
+ ${lines.join("\n")}`;
4180
+ }
3770
4181
  function buildFullContext(opts) {
3771
4182
  const parts = [];
3772
4183
  parts.push(buildFocusSection(opts.memories));
@@ -3782,6 +4193,14 @@ ${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
3782
4193
  if (recentActions) {
3783
4194
  parts.push(recentActions);
3784
4195
  }
4196
+ const pendingDecisions = buildPendingDecisionsSection(opts.pendingDecisions);
4197
+ if (pendingDecisions) {
4198
+ parts.push(pendingDecisions);
4199
+ }
4200
+ const answeredDecisions = buildAnsweredDecisionsSection(opts.answeredDecisions);
4201
+ if (answeredDecisions) {
4202
+ parts.push(answeredDecisions);
4203
+ }
3785
4204
  parts.push(buildKnowledgeSection(opts.memories));
3786
4205
  parts.push(...buildEnvironmentSections(opts));
3787
4206
  parts.push(`Events:
@@ -4082,15 +4501,75 @@ function isIdleHeartbeat(opts) {
4082
4501
  return countEvents(opts.grouped) === 0 && opts.activeRuns.length === 0 && !hasWork;
4083
4502
  }
4084
4503
  function buildIdlePrompt(opts) {
4085
- return `${buildRoleSection(opts.heartbeatCount)}
4504
+ const budgetLine = `Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`;
4505
+ const hasRepos = opts.repos.length > 0;
4506
+ const hasBudget = opts.budgetStatus.remainingPct > 10;
4507
+ const hasPendingDecisions = (opts.pendingDecisions?.length ?? 0) > 0;
4508
+ if (!hasRepos || !hasBudget) {
4509
+ return `${buildRoleSection(opts.heartbeatCount)}
4086
4510
 
4087
4511
  <context>
4088
4512
  No events. No active runs. No pending tasks.
4089
- Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)
4513
+ ${budgetLine}
4090
4514
  </context>
4091
4515
 
4092
4516
  <directive>
4093
4517
  Nothing to do. Run \`neo log discovery "idle"\` and yield. Do not produce any other output.
4518
+ </directive>`;
4519
+ }
4520
+ const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
4521
+ if (hasPendingDecisions) {
4522
+ const pendingSection = buildPendingDecisionsSection(opts.pendingDecisions);
4523
+ return `${buildRoleSection(opts.heartbeatCount)}
4524
+
4525
+ <context>
4526
+ No events. No active runs. No pending tasks.
4527
+ ${budgetLine}
4528
+
4529
+ ${pendingSection}
4530
+
4531
+ Repositories:
4532
+ ${repoList}
4533
+ </context>
4534
+
4535
+ <directive>
4536
+ Idle \u2014 but there are pending decisions awaiting user response.
4537
+ Run \`neo log discovery "idle \u2014 waiting on ${String(opts.pendingDecisions?.length ?? 0)} pending decision(s)"\` and yield.
4538
+ </directive>`;
4539
+ }
4540
+ return `${buildRoleSection(opts.heartbeatCount)}
4541
+
4542
+ <context>
4543
+ No events. No active runs. No pending tasks.
4544
+ ${budgetLine}
4545
+
4546
+ Repositories:
4547
+ ${repoList}
4548
+ </context>
4549
+
4550
+ <reference>
4551
+ ${getCommandsSection(opts.heartbeatCount)}
4552
+ </reference>
4553
+
4554
+ <directive>
4555
+ Idle \u2014 no work in progress. Use this downtime to dispatch a \`scout\` agent on one of your repositories.
4556
+
4557
+ 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.
4558
+
4559
+ **Rules:**
4560
+ - Pick the repo that was least recently scouted (check your memory for previous scout runs).
4561
+ - Only ONE scout at a time \u2014 never dispatch multiple scouts in parallel.
4562
+ - Use \`--branch main\` (or the repo's default branch) \u2014 scouts are read-only.
4563
+ - Log your decision before dispatching.
4564
+
4565
+ **Example:**
4566
+ \`\`\`bash
4567
+ neo log decision "Idle \u2014 dispatching scout on <repo>"
4568
+ neo run scout --prompt "Explore this repository. Surface bugs, improvements, security issues, and tech debt. Create decisions for critical and high-impact findings." \\
4569
+ --repo <path> \\
4570
+ --branch <default-branch> \\
4571
+ --meta '{"stage":"scout","label":"scout-<repo-name>"}'
4572
+ \`\`\`
4094
4573
  </directive>`;
4095
4574
  }
4096
4575
  function buildStandardPrompt(opts) {
@@ -4158,6 +4637,58 @@ neo memory forget <stale-id>
4158
4637
  ].join("\n\n");
4159
4638
  }
4160
4639
 
4640
+ // src/supervisor/webhookEvents.ts
4641
+ import { z as z6 } from "zod";
4642
+ var supervisorStartedEventSchema = z6.object({
4643
+ type: z6.literal("supervisor_started"),
4644
+ supervisorId: z6.string(),
4645
+ startedAt: z6.string().datetime()
4646
+ });
4647
+ var heartbeatEventSchema = z6.object({
4648
+ type: z6.literal("heartbeat"),
4649
+ supervisorId: z6.string(),
4650
+ heartbeatNumber: z6.number().int().min(0),
4651
+ timestamp: z6.string().datetime(),
4652
+ runsActive: z6.number().int().min(0),
4653
+ budget: z6.object({
4654
+ todayUsd: z6.number().min(0),
4655
+ limitUsd: z6.number().min(0)
4656
+ })
4657
+ });
4658
+ var runDispatchedEventSchema = z6.object({
4659
+ type: z6.literal("run_dispatched"),
4660
+ supervisorId: z6.string(),
4661
+ runId: z6.string(),
4662
+ agent: z6.string(),
4663
+ repo: z6.string(),
4664
+ branch: z6.string(),
4665
+ prompt: z6.string().max(500)
4666
+ // truncated
4667
+ });
4668
+ var runCompletedEventSchema = z6.object({
4669
+ type: z6.literal("run_completed"),
4670
+ supervisorId: z6.string(),
4671
+ runId: z6.string(),
4672
+ status: z6.enum(["completed", "failed", "cancelled"]),
4673
+ output: z6.string().max(1e3).optional(),
4674
+ // truncated
4675
+ costUsd: z6.number().min(0),
4676
+ durationMs: z6.number().int().min(0)
4677
+ });
4678
+ var supervisorStoppedEventSchema = z6.object({
4679
+ type: z6.literal("supervisor_stopped"),
4680
+ supervisorId: z6.string(),
4681
+ stoppedAt: z6.string().datetime(),
4682
+ reason: z6.enum(["shutdown", "budget_exceeded", "error", "manual"])
4683
+ });
4684
+ var supervisorWebhookEventSchema = z6.discriminatedUnion("type", [
4685
+ supervisorStartedEventSchema,
4686
+ heartbeatEventSchema,
4687
+ runDispatchedEventSchema,
4688
+ runCompletedEventSchema,
4689
+ supervisorStoppedEventSchema
4690
+ ]);
4691
+
4161
4692
  // src/supervisor/heartbeat.ts
4162
4693
  var DEFAULT_IDLE_SKIP_MAX = 20;
4163
4694
  var DEFAULT_ACTIVE_WORK_SKIP_MAX = 3;
@@ -4172,6 +4703,23 @@ function shouldCompact(heartbeatCount, lastCompactionHeartbeat, compactionInterv
4172
4703
  const since = heartbeatCount - lastCompactionHeartbeat;
4173
4704
  return since >= compactionInterval;
4174
4705
  }
4706
+ var STALE_GRACE_PERIOD_MS = 3e4;
4707
+ function isRunActive(run, isAlive = isProcessAlive, now = Date.now()) {
4708
+ if (run.status !== "running" && run.status !== "paused") {
4709
+ return false;
4710
+ }
4711
+ if (run.status === "paused") {
4712
+ return true;
4713
+ }
4714
+ if (run.pid && isAlive(run.pid)) {
4715
+ return true;
4716
+ }
4717
+ if (run.pid) {
4718
+ return false;
4719
+ }
4720
+ const ageMs = now - new Date(run.createdAt).getTime();
4721
+ return ageMs < STALE_GRACE_PERIOD_MS;
4722
+ }
4175
4723
  var HeartbeatLoop = class {
4176
4724
  stopping = false;
4177
4725
  consecutiveFailures = 0;
@@ -4187,6 +4735,13 @@ var HeartbeatLoop = class {
4187
4735
  defaultInstructionsPath;
4188
4736
  memoryStore = null;
4189
4737
  memoryDbPath;
4738
+ onWebhookEvent;
4739
+ decisionStore = null;
4740
+ /** ConfigWatcher for hot-reload support */
4741
+ configWatcher = null;
4742
+ configStore = null;
4743
+ repoPath;
4744
+ configWatcherDebounceMs;
4190
4745
  constructor(options) {
4191
4746
  this.config = options.config;
4192
4747
  this.supervisorDir = options.supervisorDir;
@@ -4197,6 +4752,9 @@ var HeartbeatLoop = class {
4197
4752
  this._eventsPath = options.eventsPath;
4198
4753
  this.defaultInstructionsPath = options.defaultInstructionsPath;
4199
4754
  this.memoryDbPath = options.memoryDbPath;
4755
+ this.onWebhookEvent = options.onWebhookEvent;
4756
+ this.repoPath = options.repoPath;
4757
+ this.configWatcherDebounceMs = options.configWatcherDebounceMs;
4200
4758
  }
4201
4759
  /** Path to the inbox/events directory for markProcessed() calls */
4202
4760
  get eventsPath() {
@@ -4211,9 +4769,17 @@ var HeartbeatLoop = class {
4211
4769
  }
4212
4770
  return this.memoryStore;
4213
4771
  }
4772
+ getDecisionStore() {
4773
+ if (!this.decisionStore) {
4774
+ this.decisionStore = new DecisionStore(path14.join(this.supervisorDir, "decisions.jsonl"));
4775
+ }
4776
+ return this.decisionStore;
4777
+ }
4214
4778
  async start() {
4215
4779
  this.customInstructions = await this.loadInstructions();
4780
+ await this.initConfigWatcher();
4216
4781
  await this.activityLog.log("heartbeat", "Supervisor heartbeat loop started");
4782
+ await this.emitSupervisorStarted();
4217
4783
  while (!this.stopping) {
4218
4784
  try {
4219
4785
  await this.runHeartbeat();
@@ -4239,16 +4805,48 @@ var HeartbeatLoop = class {
4239
4805
  if (this.stopping) break;
4240
4806
  await this.eventQueue.waitForEvent(this.config.supervisor.eventTimeoutMs);
4241
4807
  }
4808
+ await this.emitSupervisorStopped("shutdown");
4242
4809
  await this.activityLog.log("heartbeat", "Supervisor heartbeat loop stopped");
4243
4810
  }
4244
4811
  stop() {
4245
4812
  this.stopping = true;
4246
4813
  this.activeAbort?.abort(new Error("Supervisor shutting down"));
4247
4814
  this.eventQueue.interrupt();
4815
+ if (this.configWatcher) {
4816
+ this.configWatcher.stop();
4817
+ this.configWatcher = null;
4818
+ }
4819
+ }
4820
+ /**
4821
+ * Initialize and start the ConfigWatcher for hot-reload support.
4822
+ * Subscribes to config file changes and logs reload events.
4823
+ */
4824
+ async initConfigWatcher() {
4825
+ this.configStore = new ConfigStore(this.repoPath);
4826
+ await this.configStore.load();
4827
+ const watcherOptions = this.configWatcherDebounceMs !== void 0 ? { debounceMs: this.configWatcherDebounceMs } : void 0;
4828
+ this.configWatcher = new ConfigWatcher(this.configStore, watcherOptions);
4829
+ this.configWatcher.on("change", () => {
4830
+ this.handleConfigChange();
4831
+ });
4832
+ this.configWatcher.start();
4833
+ await this.activityLog.log("event", "ConfigWatcher started for hot-reload");
4834
+ }
4835
+ /**
4836
+ * Handle config file changes. Propagates reloaded config to the running
4837
+ * loop and triggers an immediate heartbeat.
4838
+ */
4839
+ handleConfigChange() {
4840
+ if (this.configStore) {
4841
+ this.config = this.configStore.getAll();
4842
+ }
4843
+ this.activityLog.log("event", "Configuration reloaded (hot-reload)").catch(() => {
4844
+ });
4845
+ this.eventQueue.interrupt();
4248
4846
  }
4249
4847
  async runHeartbeat() {
4250
4848
  const startTime = Date.now();
4251
- const heartbeatId = randomUUID5();
4849
+ const heartbeatId = randomUUID6();
4252
4850
  const state = await this.readState();
4253
4851
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4254
4852
  const budgetCheck = await this.checkBudgetExceeded(state, today);
@@ -4256,6 +4854,11 @@ var HeartbeatLoop = class {
4256
4854
  const { grouped, rawEvents } = this.eventQueue.drainAndGroup();
4257
4855
  const totalEventCount = grouped.messages.length + grouped.webhooks.length + grouped.runCompletions.length;
4258
4856
  const activeRuns = await this.getActiveRuns();
4857
+ const decisionStore = this.getDecisionStore();
4858
+ await this.processDecisionAnswers(rawEvents, decisionStore);
4859
+ await decisionStore.expire();
4860
+ const _pendingDecisions = await decisionStore.pending();
4861
+ void _pendingDecisions;
4259
4862
  const skipResult = await this.handleSkipLogic({
4260
4863
  state,
4261
4864
  totalEventCount,
@@ -4290,6 +4893,13 @@ var HeartbeatLoop = class {
4290
4893
  }
4291
4894
  );
4292
4895
  const { costUsd, turnCount } = await this.callSdk(prompt, heartbeatId);
4896
+ if (turnCount === 0) {
4897
+ await this.activityLog.log(
4898
+ "warning",
4899
+ `Heartbeat #${modeResult.heartbeatCount} completed with turnCount=0. SDK stream may have timed out before any turns completed.`,
4900
+ { heartbeatId }
4901
+ );
4902
+ }
4293
4903
  if (rawEvents.length > 0) {
4294
4904
  const inboxPath = path14.join(this.supervisorDir, "inbox.jsonl");
4295
4905
  await this.eventQueue.markProcessed(inboxPath, this.eventsPath, rawEvents);
@@ -4323,6 +4933,27 @@ var HeartbeatLoop = class {
4323
4933
  isConsolidation: modeResult.isConsolidation
4324
4934
  }
4325
4935
  );
4936
+ await this.emitHeartbeatCompleted({
4937
+ heartbeatNumber: modeResult.heartbeatCount + 1,
4938
+ runsActive: activeRuns.length,
4939
+ todayUsd: budgetCheck.todayCost + costUsd,
4940
+ limitUsd: this.config.supervisor.dailyCapUsd
4941
+ });
4942
+ for (const event of rawEvents) {
4943
+ if (event.kind === "run_complete") {
4944
+ const runData = await this.readPersistedRun(event.runId);
4945
+ const emitOpts = {
4946
+ runId: event.runId,
4947
+ status: runData?.status === "failed" ? "failed" : "completed",
4948
+ costUsd: runData?.totalCostUsd ?? 0,
4949
+ durationMs: runData?.durationMs ?? 0
4950
+ };
4951
+ if (runData?.output) {
4952
+ emitOpts.output = runData.output;
4953
+ }
4954
+ await this.emitRunCompleted(emitOpts);
4955
+ }
4956
+ }
4326
4957
  }
4327
4958
  /**
4328
4959
  * Check if supervisor daily budget is exceeded.
@@ -4398,7 +5029,6 @@ var HeartbeatLoop = class {
4398
5029
  isCompaction,
4399
5030
  unconsolidated,
4400
5031
  heartbeatCount,
4401
- lastConsolidation,
4402
5032
  lastConsolidationTs
4403
5033
  };
4404
5034
  }
@@ -4478,6 +5108,12 @@ var HeartbeatLoop = class {
4478
5108
  }
4479
5109
  /**
4480
5110
  * Call the Claude SDK and stream results.
5111
+ *
5112
+ * Uses Promise.race to enable non-blocking abort detection. The standard
5113
+ * `for await (const message of stream)` pattern only checks the abort signal
5114
+ * AFTER each yield — if the SDK hangs (no messages), the abort never executes.
5115
+ * This implementation races each iterator.next() against an abort promise,
5116
+ * allowing immediate response to shutdown/timeout signals.
4481
5117
  */
4482
5118
  async callSdk(prompt, heartbeatId) {
4483
5119
  const abortController = new AbortController();
@@ -4497,25 +5133,48 @@ var HeartbeatLoop = class {
4497
5133
  }
4498
5134
  }
4499
5135
  const queryOptions = {
4500
- cwd: homedir2(),
5136
+ cwd: homedir4(),
4501
5137
  allowedTools,
4502
5138
  permissionMode: "bypassPermissions",
4503
5139
  allowDangerouslySkipPermissions: true,
4504
- mcpServers: this.config.mcpServers ?? {}
5140
+ mcpServers: this.config.mcpServers ?? {},
5141
+ // Don't persist session history — each heartbeat is a fresh conversation.
5142
+ // Without this, supervisor restarts could replay old messages.
5143
+ persistSession: false
4505
5144
  };
4506
5145
  const stream = sdk.query({ prompt, options: queryOptions });
4507
- for await (const message of stream) {
4508
- if (abortController.signal.aborted) break;
4509
- const msg = message;
4510
- if (isInitMessage(msg)) {
4511
- this.sessionId = msg.session_id;
5146
+ const abortPromise = new Promise((resolve4) => {
5147
+ if (abortController.signal.aborted) {
5148
+ resolve4({ aborted: true });
5149
+ return;
4512
5150
  }
4513
- if (isResultMessage(msg)) {
4514
- output = msg.result ?? "";
4515
- costUsd = msg.total_cost_usd ?? 0;
4516
- turnCount = msg.num_turns ?? 0;
5151
+ abortController.signal.addEventListener("abort", () => resolve4({ aborted: true }), {
5152
+ once: true
5153
+ });
5154
+ });
5155
+ const iterator = stream[Symbol.asyncIterator]();
5156
+ try {
5157
+ while (true) {
5158
+ const raceResult = await Promise.race([iterator.next(), abortPromise]);
5159
+ if ("aborted" in raceResult) {
5160
+ await this.activityLog.log("heartbeat", "Heartbeat aborted", { heartbeatId });
5161
+ break;
5162
+ }
5163
+ const iterResult = raceResult;
5164
+ if (iterResult.done) break;
5165
+ const msg = iterResult.value;
5166
+ if (isInitMessage(msg)) {
5167
+ this.sessionId = msg.session_id;
5168
+ }
5169
+ if (isResultMessage(msg)) {
5170
+ output = msg.result ?? "";
5171
+ costUsd = msg.total_cost_usd ?? 0;
5172
+ turnCount = msg.num_turns ?? 0;
5173
+ }
5174
+ await this.logStreamMessage(msg, heartbeatId);
4517
5175
  }
4518
- await this.logStreamMessage(msg, heartbeatId);
5176
+ } finally {
5177
+ await iterator.return?.();
4519
5178
  }
4520
5179
  } finally {
4521
5180
  clearTimeout(timeout);
@@ -4536,29 +5195,33 @@ var HeartbeatLoop = class {
4536
5195
  const raw = await readFile11(this.statePath, "utf-8");
4537
5196
  const state = JSON.parse(raw);
4538
5197
  Object.assign(state, updates);
4539
- await writeFile5(this.statePath, JSON.stringify(state, null, 2), "utf-8");
5198
+ await writeFile6(this.statePath, JSON.stringify(state, null, 2), "utf-8");
4540
5199
  } catch {
4541
5200
  }
4542
5201
  }
4543
- /** Read persisted run files and return summaries of active (running/paused) runs. */
5202
+ /**
5203
+ * Read persisted run files and return summaries of active (running/paused) runs.
5204
+ * Validates that "running" runs are actually alive by checking their PID.
5205
+ * Stale runs (dead PID past grace period) are filtered out to prevent ghost runs.
5206
+ */
4544
5207
  async getActiveRuns() {
4545
5208
  const runsDir = getRunsDir();
4546
5209
  if (!existsSync7(runsDir)) return [];
4547
5210
  try {
4548
- const entries = await readdir5(runsDir, { withFileTypes: true });
5211
+ const entries = await readdir4(runsDir, { withFileTypes: true });
4549
5212
  const active = [];
4550
5213
  for (const entry of entries) {
4551
5214
  if (!entry.isDirectory()) continue;
4552
5215
  const subDir = path14.join(runsDir, entry.name);
4553
- const files = await readdir5(subDir);
5216
+ const files = await readdir4(subDir);
4554
5217
  for (const f of files) {
4555
5218
  if (!f.endsWith(".json")) continue;
4556
5219
  try {
4557
5220
  const raw = await readFile11(path14.join(subDir, f), "utf-8");
4558
5221
  const run = JSON.parse(raw);
4559
- if (run.status === "running" || run.status === "paused") {
5222
+ if (isRunActive(run)) {
4560
5223
  active.push(
4561
- `${run.runId} [${run.status}] ${run.workflow} on ${path14.basename(run.repo)}`
5224
+ `${run.runId} [${run.status}] ${run.agent} on ${path14.basename(run.repo)}`
4562
5225
  );
4563
5226
  }
4564
5227
  } catch {
@@ -4637,21 +5300,191 @@ var HeartbeatLoop = class {
4637
5300
  if (!isToolResultMessage(msg)) return;
4638
5301
  const result = msg.result ?? "";
4639
5302
  const runMatch = /Run\s+(\S+)\s+dispatched/i.exec(result);
4640
- if (runMatch) {
4641
- await this.activityLog.log("dispatch", `Agent dispatched: ${runMatch[1]}`, {
5303
+ const runId = runMatch?.[1];
5304
+ if (runId) {
5305
+ await this.activityLog.log("dispatch", `Agent dispatched: ${runId}`, {
4642
5306
  heartbeatId,
4643
- runId: runMatch[1]
5307
+ runId
5308
+ });
5309
+ const agentMatch = /agent[:\s]+(\S+)/i.exec(result);
5310
+ const repoMatch = /repo[:\s]+(\S+)/i.exec(result);
5311
+ const branchMatch = /branch[:\s]+(\S+)/i.exec(result);
5312
+ const agent = agentMatch?.[1] ?? "unknown";
5313
+ const repo = repoMatch?.[1] ?? "unknown";
5314
+ const branch = branchMatch?.[1] ?? "unknown";
5315
+ await this.emitRunDispatched({
5316
+ runId,
5317
+ agent,
5318
+ repo,
5319
+ branch,
5320
+ prompt: result.slice(0, 500)
4644
5321
  });
4645
5322
  }
4646
5323
  }
4647
5324
  sleep(ms) {
4648
5325
  return new Promise((resolve4) => setTimeout(resolve4, ms));
4649
5326
  }
5327
+ /**
5328
+ * Process decision:answer events from inbox messages.
5329
+ * Expected format: "decision:answer <decisionId> <answer>"
5330
+ */
5331
+ async processDecisionAnswers(rawEvents, store) {
5332
+ for (const event of rawEvents) {
5333
+ if (event.kind !== "message") continue;
5334
+ const text = event.data.text.trim();
5335
+ const match = /^decision:answer\s+(\S+)\s+(.+)$/i.exec(text);
5336
+ if (!match) continue;
5337
+ const decisionId = match[1];
5338
+ const answer = match[2];
5339
+ if (!decisionId || !answer) continue;
5340
+ try {
5341
+ await store.answer(decisionId, answer);
5342
+ await this.activityLog.log("event", `Decision answered: ${decisionId}`, {
5343
+ decisionId,
5344
+ answer
5345
+ });
5346
+ } catch (error) {
5347
+ const msg = error instanceof Error ? error.message : String(error);
5348
+ await this.activityLog.log("error", `Failed to answer decision ${decisionId}: ${msg}`, {
5349
+ decisionId,
5350
+ answer
5351
+ });
5352
+ }
5353
+ }
5354
+ }
5355
+ /**
5356
+ * Read persisted run data to extract actual status, cost, and duration.
5357
+ * Returns null if the run file cannot be found or parsed.
5358
+ */
5359
+ async readPersistedRun(runId) {
5360
+ const runsDir = getRunsDir();
5361
+ if (!existsSync7(runsDir)) return null;
5362
+ try {
5363
+ const entries = await readdir4(runsDir, { withFileTypes: true });
5364
+ for (const entry of entries) {
5365
+ if (!entry.isDirectory()) continue;
5366
+ const subDir = path14.join(runsDir, entry.name);
5367
+ const runPath = path14.join(subDir, `${runId}.json`);
5368
+ if (existsSync7(runPath)) {
5369
+ const raw = await readFile11(runPath, "utf-8");
5370
+ const run = JSON.parse(raw);
5371
+ const totalCostUsd = Object.values(run.steps).reduce(
5372
+ (sum, step) => sum + (step.costUsd ?? 0),
5373
+ 0
5374
+ );
5375
+ const durationMs = new Date(run.updatedAt).getTime() - new Date(run.createdAt).getTime();
5376
+ const completedSteps = Object.values(run.steps).filter(
5377
+ (s) => s.status === "success" || s.status === "failure"
5378
+ );
5379
+ const lastStep = completedSteps[completedSteps.length - 1];
5380
+ const output = typeof lastStep?.rawOutput === "string" ? lastStep.rawOutput.slice(0, 1e3) : void 0;
5381
+ return { status: run.status, totalCostUsd, durationMs, output };
5382
+ }
5383
+ }
5384
+ } catch {
5385
+ }
5386
+ return null;
5387
+ }
5388
+ // ─── Webhook event emission ───────────────────────────────
5389
+ /**
5390
+ * Emit a webhook event if a callback is configured.
5391
+ * Validates the event against its schema before emission.
5392
+ */
5393
+ async emitWebhookEvent(event) {
5394
+ if (!this.onWebhookEvent) return;
5395
+ try {
5396
+ switch (event.type) {
5397
+ case "supervisor_started":
5398
+ supervisorStartedEventSchema.parse(event);
5399
+ break;
5400
+ case "heartbeat":
5401
+ heartbeatEventSchema.parse(event);
5402
+ break;
5403
+ case "run_dispatched":
5404
+ runDispatchedEventSchema.parse(event);
5405
+ break;
5406
+ case "run_completed":
5407
+ runCompletedEventSchema.parse(event);
5408
+ break;
5409
+ case "supervisor_stopped":
5410
+ supervisorStoppedEventSchema.parse(event);
5411
+ break;
5412
+ }
5413
+ await this.onWebhookEvent(event);
5414
+ } catch (error) {
5415
+ const msg = error instanceof Error ? error.message : String(error);
5416
+ await this.activityLog.log("error", `Webhook event emission failed: ${msg}`, {
5417
+ eventType: event.type
5418
+ });
5419
+ }
5420
+ }
5421
+ /** Emit SupervisorStartedEvent */
5422
+ async emitSupervisorStarted() {
5423
+ const event = {
5424
+ type: "supervisor_started",
5425
+ supervisorId: this.sessionId,
5426
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5427
+ };
5428
+ await this.emitWebhookEvent(event);
5429
+ }
5430
+ /** Emit SupervisorStoppedEvent */
5431
+ async emitSupervisorStopped(reason) {
5432
+ const event = {
5433
+ type: "supervisor_stopped",
5434
+ supervisorId: this.sessionId,
5435
+ stoppedAt: (/* @__PURE__ */ new Date()).toISOString(),
5436
+ reason
5437
+ };
5438
+ await this.emitWebhookEvent(event);
5439
+ }
5440
+ /** Emit HeartbeatEvent */
5441
+ async emitHeartbeatCompleted(opts) {
5442
+ const event = {
5443
+ type: "heartbeat",
5444
+ supervisorId: this.sessionId,
5445
+ heartbeatNumber: opts.heartbeatNumber,
5446
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5447
+ runsActive: opts.runsActive,
5448
+ budget: {
5449
+ todayUsd: opts.todayUsd,
5450
+ limitUsd: opts.limitUsd
5451
+ }
5452
+ };
5453
+ await this.emitWebhookEvent(event);
5454
+ }
5455
+ /** Emit RunDispatchedEvent from tool result detection */
5456
+ async emitRunDispatched(opts) {
5457
+ const event = {
5458
+ type: "run_dispatched",
5459
+ supervisorId: this.sessionId,
5460
+ runId: opts.runId,
5461
+ agent: opts.agent,
5462
+ repo: opts.repo,
5463
+ branch: opts.branch,
5464
+ prompt: opts.prompt.slice(0, 500)
5465
+ // Truncate to schema max
5466
+ };
5467
+ await this.emitWebhookEvent(event);
5468
+ }
5469
+ /** Emit RunCompletedEvent when processing run_complete events */
5470
+ async emitRunCompleted(opts) {
5471
+ const event = {
5472
+ type: "run_completed",
5473
+ supervisorId: this.sessionId,
5474
+ runId: opts.runId,
5475
+ status: opts.status,
5476
+ output: opts.output?.slice(0, 1e3),
5477
+ // Truncate to schema max
5478
+ costUsd: opts.costUsd,
5479
+ durationMs: opts.durationMs
5480
+ };
5481
+ await this.emitWebhookEvent(event);
5482
+ }
4650
5483
  };
4651
5484
 
4652
5485
  // src/supervisor/webhook-server.ts
4653
5486
  import { createHmac as createHmac2, timingSafeEqual } from "crypto";
4654
- import { appendFile as appendFile6 } from "fs/promises";
5487
+ import { appendFile as appendFile7 } from "fs/promises";
4655
5488
  import { createServer } from "http";
4656
5489
  var MAX_BODY_SIZE = 1024 * 1024;
4657
5490
  var WebhookServer = class {
@@ -4736,7 +5569,7 @@ var WebhookServer = class {
4736
5569
  payload: parsed.payload ?? parsed,
4737
5570
  receivedAt: (/* @__PURE__ */ new Date()).toISOString()
4738
5571
  };
4739
- await appendFile6(this.eventsPath, `${JSON.stringify(event)}
5572
+ await appendFile7(this.eventsPath, `${JSON.stringify(event)}
4740
5573
  `, "utf-8");
4741
5574
  this.onEvent(event);
4742
5575
  this.sendJson(res, 200, { ok: true, id: event.id });
@@ -4776,6 +5609,7 @@ var SupervisorDaemon = class {
4776
5609
  eventQueue = null;
4777
5610
  heartbeatLoop = null;
4778
5611
  activityLog = null;
5612
+ decisionStore = null;
4779
5613
  sessionId = "";
4780
5614
  constructor(options) {
4781
5615
  this.name = options.name;
@@ -4796,16 +5630,17 @@ var SupervisorDaemon = class {
4796
5630
  await rm2(lockPath, { force: true });
4797
5631
  }
4798
5632
  const tempLock = `${lockPath}.${process.pid}`;
4799
- await writeFile6(tempLock, String(process.pid), "utf-8");
5633
+ await writeFile7(tempLock, String(process.pid), "utf-8");
4800
5634
  const { rename: rename2 } = await import("fs/promises");
4801
5635
  await rename2(tempLock, lockPath);
4802
5636
  const existingState = await this.readState();
4803
5637
  if (existingState?.sessionId && existingState.status !== "stopped") {
4804
5638
  this.sessionId = existingState.sessionId;
4805
5639
  } else {
4806
- this.sessionId = randomUUID6();
5640
+ this.sessionId = randomUUID7();
4807
5641
  }
4808
5642
  this.activityLog = new ActivityLog(this.dir);
5643
+ this.decisionStore = new DecisionStore(getSupervisorDecisionsPath(this.name));
4809
5644
  this.eventQueue = new EventQueue({
4810
5645
  maxEventsPerSec: this.config.supervisor.maxEventsPerSec
4811
5646
  });
@@ -4819,6 +5654,12 @@ var SupervisorDaemon = class {
4819
5654
  eventsPath,
4820
5655
  onEvent: (event) => {
4821
5656
  this.eventQueue?.push({ kind: "webhook", data: event });
5657
+ this.handleDecisionAnswer(event).catch((err) => {
5658
+ this.activityLog?.log(
5659
+ "error",
5660
+ `Failed to handle decision:answer: ${err instanceof Error ? err.message : String(err)}`
5661
+ );
5662
+ });
4822
5663
  if ((event.event === "session:complete" || event.event === "session:fail") && event.payload) {
4823
5664
  const runId = typeof event.payload.runId === "string" ? event.payload.runId : void 0;
4824
5665
  if (runId) {
@@ -4837,7 +5678,7 @@ var SupervisorDaemon = class {
4837
5678
  pid: process.pid,
4838
5679
  sessionId: this.sessionId,
4839
5680
  port: this.config.supervisor.port,
4840
- cwd: homedir3(),
5681
+ cwd: homedir5(),
4841
5682
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4842
5683
  lastHeartbeat: existingState?.lastHeartbeat,
4843
5684
  heartbeatCount: existingState?.heartbeatCount ?? 0,
@@ -4911,7 +5752,7 @@ var SupervisorDaemon = class {
4911
5752
  }
4912
5753
  async writeState(state) {
4913
5754
  const statePath = path15.join(this.dir, "state.json");
4914
- await writeFile6(statePath, JSON.stringify(state, null, 2), "utf-8");
5755
+ await writeFile7(statePath, JSON.stringify(state, null, 2), "utf-8");
4915
5756
  }
4916
5757
  async readLockPid(lockPath) {
4917
5758
  try {
@@ -4922,19 +5763,248 @@ var SupervisorDaemon = class {
4922
5763
  return null;
4923
5764
  }
4924
5765
  }
5766
+ /**
5767
+ * Handle decision:answer webhook events.
5768
+ * Extracts decisionId and answer from the payload and records the answer.
5769
+ */
5770
+ async handleDecisionAnswer(event) {
5771
+ if (event.event !== "decision:answer") return;
5772
+ if (!this.decisionStore || !event.payload) return;
5773
+ const decisionId = typeof event.payload.decisionId === "string" ? event.payload.decisionId : void 0;
5774
+ const answer = typeof event.payload.answer === "string" ? event.payload.answer : void 0;
5775
+ if (!decisionId || !answer) {
5776
+ await this.activityLog?.log(
5777
+ "error",
5778
+ `decision:answer webhook missing required fields (decisionId: ${decisionId}, answer: ${answer})`
5779
+ );
5780
+ return;
5781
+ }
5782
+ try {
5783
+ await this.decisionStore.answer(decisionId, answer);
5784
+ await this.activityLog?.log(
5785
+ "decision",
5786
+ `Decision ${decisionId} answered via webhook: "${answer}"`
5787
+ );
5788
+ } catch (err) {
5789
+ await this.activityLog?.log(
5790
+ "error",
5791
+ `Failed to answer decision ${decisionId}: ${err instanceof Error ? err.message : String(err)}`
5792
+ );
5793
+ }
5794
+ }
5795
+ };
5796
+
5797
+ // src/supervisor/StatusReader.ts
5798
+ import { readFileSync as readFileSync2 } from "fs";
5799
+ import { readFile as readFile13 } from "fs/promises";
5800
+ import path16 from "path";
5801
+ var STATE_FILE = "state.json";
5802
+ var ACTIVITY_FILE2 = "activity.jsonl";
5803
+ var StatusReader = class {
5804
+ dataDir;
5805
+ statePath;
5806
+ activityPath;
5807
+ constructor(dataDir) {
5808
+ this.dataDir = dataDir;
5809
+ this.statePath = path16.join(dataDir, STATE_FILE);
5810
+ this.activityPath = path16.join(dataDir, ACTIVITY_FILE2);
5811
+ }
5812
+ /**
5813
+ * Read and parse supervisor status from disk.
5814
+ * Returns null if the state file doesn't exist (supervisor not running).
5815
+ */
5816
+ async getStatus() {
5817
+ let raw;
5818
+ try {
5819
+ raw = await readFile13(this.statePath, "utf-8");
5820
+ } catch {
5821
+ return null;
5822
+ }
5823
+ let parsed;
5824
+ try {
5825
+ parsed = JSON.parse(raw);
5826
+ } catch {
5827
+ return null;
5828
+ }
5829
+ const result = supervisorDaemonStateSchema.safeParse(parsed);
5830
+ if (!result.success) {
5831
+ return null;
5832
+ }
5833
+ const daemon = result.data;
5834
+ const statusMap = {
5835
+ running: "running",
5836
+ draining: "stopping",
5837
+ stopped: "idle"
5838
+ };
5839
+ const recentActivity = this.queryActivity({ limit: 5 });
5840
+ return {
5841
+ pid: daemon.pid,
5842
+ sessionId: daemon.sessionId,
5843
+ startedAt: daemon.startedAt,
5844
+ heartbeatCount: daemon.heartbeatCount,
5845
+ totalCostUsd: daemon.totalCostUsd,
5846
+ todayCostUsd: daemon.todayCostUsd,
5847
+ status: statusMap[daemon.status],
5848
+ lastHeartbeat: daemon.lastHeartbeat ?? daemon.startedAt,
5849
+ activeRunCount: 0,
5850
+ // TODO: count active runs from .neo/runs/
5851
+ recentActivitySummary: recentActivity.map((e) => `[${e.type}] ${e.summary}`)
5852
+ };
5853
+ }
5854
+ /**
5855
+ * Query activity entries with optional filtering.
5856
+ * Returns empty array if the activity file doesn't exist or is empty.
5857
+ */
5858
+ queryActivity(options = {}) {
5859
+ const { limit = 50, offset = 0, type, since, until } = options;
5860
+ let content;
5861
+ try {
5862
+ content = readFileSync2(this.activityPath, "utf-8");
5863
+ } catch {
5864
+ return [];
5865
+ }
5866
+ const lines = content.trim().split("\n").filter(Boolean);
5867
+ let entries = [];
5868
+ for (const line of lines) {
5869
+ try {
5870
+ const parsed = JSON.parse(line);
5871
+ const result = activityEntrySchema.safeParse(parsed);
5872
+ if (result.success) {
5873
+ entries.push(result.data);
5874
+ }
5875
+ } catch {
5876
+ }
5877
+ }
5878
+ if (type) {
5879
+ entries = entries.filter((e) => e.type === type);
5880
+ }
5881
+ if (since) {
5882
+ const sinceDate = new Date(since);
5883
+ entries = entries.filter((e) => new Date(e.timestamp) >= sinceDate);
5884
+ }
5885
+ if (until) {
5886
+ const untilDate = new Date(until);
5887
+ entries = entries.filter((e) => new Date(e.timestamp) <= untilDate);
5888
+ }
5889
+ return entries.slice(offset, offset + limit);
5890
+ }
4925
5891
  };
4926
5892
 
4927
5893
  // src/supervisor/shutdown.ts
4928
5894
  import { existsSync as existsSync9 } from "fs";
4929
- import { readdir as readdir6, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
4930
- import path16 from "path";
5895
+ import { readdir as readdir5, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
5896
+ import path17 from "path";
5897
+
5898
+ // src/webhook-config.ts
5899
+ import { existsSync as existsSync10 } from "fs";
5900
+ import { mkdir as mkdir8, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
5901
+ import path18 from "path";
5902
+ import { z as z7 } from "zod";
5903
+ var webhookEntrySchema = z7.object({
5904
+ url: z7.string().url(),
5905
+ events: z7.array(z7.string()).optional(),
5906
+ secret: z7.string().optional(),
5907
+ timeoutMs: z7.number().default(5e3),
5908
+ createdAt: z7.string().default(() => (/* @__PURE__ */ new Date()).toISOString())
5909
+ });
5910
+ var webhooksConfigSchema = z7.object({
5911
+ webhooks: z7.array(webhookEntrySchema).default([])
5912
+ });
5913
+ function getWebhooksConfigPath() {
5914
+ return path18.join(getDataDir(), "webhooks.json");
5915
+ }
5916
+ async function loadWebhooksConfig() {
5917
+ const configPath = getWebhooksConfigPath();
5918
+ if (!existsSync10(configPath)) {
5919
+ return { webhooks: [] };
5920
+ }
5921
+ const raw = await readFile15(configPath, "utf-8");
5922
+ const parsed = JSON.parse(raw);
5923
+ return webhooksConfigSchema.parse(parsed);
5924
+ }
5925
+ async function saveWebhooksConfig(config) {
5926
+ const configPath = getWebhooksConfigPath();
5927
+ await mkdir8(getDataDir(), { recursive: true });
5928
+ await writeFile9(configPath, JSON.stringify(config, null, 2), "utf-8");
5929
+ }
5930
+ async function addWebhook(input) {
5931
+ const config = await loadWebhooksConfig();
5932
+ const entry = webhookEntrySchema.parse(input);
5933
+ const existing = config.webhooks.findIndex((w) => w.url === entry.url);
5934
+ if (existing >= 0) {
5935
+ config.webhooks[existing] = entry;
5936
+ } else {
5937
+ config.webhooks.push(entry);
5938
+ }
5939
+ await saveWebhooksConfig(config);
5940
+ return entry;
5941
+ }
5942
+ async function removeWebhook(url) {
5943
+ const config = await loadWebhooksConfig();
5944
+ const initialLength = config.webhooks.length;
5945
+ config.webhooks = config.webhooks.filter((w) => w.url !== url);
5946
+ if (config.webhooks.length === initialLength) {
5947
+ return false;
5948
+ }
5949
+ await saveWebhooksConfig(config);
5950
+ return true;
5951
+ }
5952
+ async function listWebhooks() {
5953
+ const config = await loadWebhooksConfig();
5954
+ return config.webhooks;
5955
+ }
5956
+ async function testWebhooks() {
5957
+ const webhooks = await listWebhooks();
5958
+ if (webhooks.length === 0) {
5959
+ return [];
5960
+ }
5961
+ const payload = {
5962
+ type: "test",
5963
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5964
+ runId: `test-${Date.now()}`,
5965
+ status: "test",
5966
+ summary: "Test webhook from neo CLI"
5967
+ };
5968
+ const results = await Promise.all(
5969
+ webhooks.map(async (webhook) => {
5970
+ const start = Date.now();
5971
+ const body = JSON.stringify(payload);
5972
+ try {
5973
+ const response = await fetch(webhook.url, {
5974
+ method: "POST",
5975
+ headers: {
5976
+ "Content-Type": "application/json"
5977
+ },
5978
+ body,
5979
+ signal: AbortSignal.timeout(webhook.timeoutMs)
5980
+ });
5981
+ return {
5982
+ url: webhook.url,
5983
+ success: response.ok,
5984
+ statusCode: response.status,
5985
+ durationMs: Date.now() - start
5986
+ };
5987
+ } catch (err) {
5988
+ return {
5989
+ url: webhook.url,
5990
+ success: false,
5991
+ error: err instanceof Error ? err.message : String(err),
5992
+ durationMs: Date.now() - start
5993
+ };
5994
+ }
5995
+ })
5996
+ );
5997
+ return results;
5998
+ }
4931
5999
 
4932
6000
  // src/index.ts
4933
6001
  var VERSION = "0.1.0";
4934
6002
  export {
4935
6003
  ActivityLog,
4936
6004
  AgentRegistry,
6005
+ ConfigStore,
4937
6006
  CostJournal,
6007
+ DecisionStore,
4938
6008
  EventJournal,
4939
6009
  EventQueue,
4940
6010
  HeartbeatLoop,
@@ -4945,13 +6015,14 @@ export {
4945
6015
  Semaphore,
4946
6016
  SessionError,
4947
6017
  SessionExecutor,
6018
+ StatusReader,
4948
6019
  SupervisorDaemon,
4949
6020
  VERSION,
4950
6021
  WebhookDispatcher,
4951
6022
  WebhookServer,
4952
- WorkflowRegistry,
4953
6023
  activityEntrySchema,
4954
6024
  addRepoToGlobalConfig,
6025
+ addWebhook,
4955
6026
  agentConfigSchema,
4956
6027
  agentModelSchema,
4957
6028
  agentSandboxSchema,
@@ -4968,6 +6039,8 @@ export {
4968
6039
  buildSandboxConfig,
4969
6040
  createBranch,
4970
6041
  createSessionClone,
6042
+ decisionOptionSchema,
6043
+ decisionSchema,
4971
6044
  deleteBranch,
4972
6045
  fetchRemote,
4973
6046
  getBranchName,
@@ -4979,6 +6052,7 @@ export {
4979
6052
  getRunLogPath,
4980
6053
  getRunsDir,
4981
6054
  getSupervisorActivityPath,
6055
+ getSupervisorDecisionsPath,
4982
6056
  getSupervisorDir,
4983
6057
  getSupervisorEventsPath,
4984
6058
  getSupervisorInboxPath,
@@ -4990,11 +6064,11 @@ export {
4990
6064
  isProcessAlive,
4991
6065
  listReposFromGlobalConfig,
4992
6066
  listSessionClones,
6067
+ listWebhooks,
4993
6068
  loadAgentFile,
4994
6069
  loadConfig,
4995
6070
  loadGlobalConfig,
4996
6071
  loadRepoInstructions,
4997
- loadWorkflow,
4998
6072
  loopDetection,
4999
6073
  matchesFilter,
5000
6074
  mcpServerConfigSchema,
@@ -5004,15 +6078,18 @@ export {
5004
6078
  pushSessionBranch,
5005
6079
  removeRepoFromGlobalConfig,
5006
6080
  removeSessionClone,
6081
+ removeWebhook,
5007
6082
  repoConfigSchema,
6083
+ repoOverrideConfigSchema,
5008
6084
  resolveAgent,
5009
6085
  runSession,
5010
6086
  runWithRecovery,
5011
6087
  supervisorDaemonStateSchema,
5012
6088
  supervisorDaemonStateSchema as supervisorStateSchema,
6089
+ supervisorStatusSchema,
6090
+ testWebhooks,
5013
6091
  toRepoSlug,
5014
- webhookIncomingEventSchema,
5015
- workflowGateDefSchema,
5016
- workflowStepDefSchema
6092
+ webhookEntrySchema,
6093
+ webhookIncomingEventSchema
5017
6094
  };
5018
6095
  //# sourceMappingURL=index.js.map