@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/README.md +83 -39
- package/dist/index.d.ts +380 -95
- package/dist/index.js +1511 -434
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|
|
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:
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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:
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
1195
|
-
import
|
|
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
|
|
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 (!
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 (!
|
|
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 =
|
|
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
|
|
2571
|
-
if (!
|
|
2572
|
-
const available = [...this.
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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,
|
|
2964
|
+
const { input, runId, agent, activeSession } = ctx;
|
|
2776
2965
|
const taskResult = {
|
|
2777
2966
|
runId,
|
|
2778
|
-
|
|
2967
|
+
agent: agent.name,
|
|
2779
2968
|
repo: input.repo,
|
|
2780
2969
|
status: stepResult.status === "success" ? "success" : "failure",
|
|
2781
|
-
steps: {
|
|
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
|
-
|
|
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 =
|
|
2821
|
-
this.memoryStore = new MemoryStore(
|
|
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
|
-
|
|
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.
|
|
2919
|
-
throw new Error(`Validation error:
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
3159
|
+
resolveMcpServers(agent) {
|
|
3005
3160
|
const configServers = this.config.mcpServers;
|
|
3006
3161
|
if (!configServers) return void 0;
|
|
3007
|
-
const names =
|
|
3008
|
-
if (
|
|
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:
|
|
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
|
|
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 =
|
|
3037
|
-
const raw = await
|
|
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
|
|
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
|
|
3158
|
-
import { appendFile as
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
3229
|
-
import { homedir as
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
3808
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3474
3809
|
import { existsSync as existsSync7 } from "fs";
|
|
3475
|
-
import { readdir as
|
|
3476
|
-
import { homedir as
|
|
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
|
|
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
|
|
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
|
|
3903
|
+
await writeFile5(filePath, result, "utf-8");
|
|
3569
3904
|
}
|
|
3570
3905
|
async function appendLogBuffer(dir, entry) {
|
|
3571
3906
|
try {
|
|
3572
|
-
await
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
4508
|
-
if (abortController.signal.aborted)
|
|
4509
|
-
|
|
4510
|
-
|
|
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
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
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
|
-
|
|
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
|
|
5198
|
+
await writeFile6(this.statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
4540
5199
|
} catch {
|
|
4541
5200
|
}
|
|
4542
5201
|
}
|
|
4543
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
5222
|
+
if (isRunActive(run)) {
|
|
4560
5223
|
active.push(
|
|
4561
|
-
`${run.runId} [${run.status}] ${run.
|
|
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
|
-
|
|
4641
|
-
|
|
5303
|
+
const runId = runMatch?.[1];
|
|
5304
|
+
if (runId) {
|
|
5305
|
+
await this.activityLog.log("dispatch", `Agent dispatched: ${runId}`, {
|
|
4642
5306
|
heartbeatId,
|
|
4643
|
-
runId
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
4930
|
-
import
|
|
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
|
-
|
|
5015
|
-
|
|
5016
|
-
workflowStepDefSchema
|
|
6092
|
+
webhookEntrySchema,
|
|
6093
|
+
webhookIncomingEventSchema
|
|
5017
6094
|
};
|
|
5018
6095
|
//# sourceMappingURL=index.js.map
|