@os-eco/overstory-cli 0.8.5 → 0.8.7
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 +13 -9
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +494 -6
- package/src/commands/coordinator.ts +200 -4
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +93 -15
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +416 -358
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/supervisor.ts +2 -0
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +383 -25
- package/src/merge/resolver.ts +291 -98
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +64 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +25 -1
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +66 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
package/src/commands/sling.ts
CHANGED
|
@@ -38,12 +38,15 @@ import { createRunStore } from "../sessions/store.ts";
|
|
|
38
38
|
import type { TrackerIssue } from "../tracker/factory.ts";
|
|
39
39
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
40
40
|
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
41
|
-
import { createWorktree } from "../worktree/manager.ts";
|
|
41
|
+
import { createWorktree, rollbackWorktree } from "../worktree/manager.ts";
|
|
42
42
|
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
43
43
|
import {
|
|
44
44
|
capturePaneContent,
|
|
45
|
+
checkSessionState,
|
|
45
46
|
createSession,
|
|
46
47
|
ensureTmuxAvailable,
|
|
48
|
+
isSessionAlive,
|
|
49
|
+
killSession,
|
|
47
50
|
sendKeys,
|
|
48
51
|
waitForTuiReady,
|
|
49
52
|
} from "../worktree/tmux.ts";
|
|
@@ -274,6 +277,27 @@ export function shouldShowScoutWarning(
|
|
|
274
277
|
return !parentHasScouts(sessions, parentAgent);
|
|
275
278
|
}
|
|
276
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Resolve which canonical repo directories should be writable to an
|
|
282
|
+
* interactive agent runtime in addition to its worktree sandbox.
|
|
283
|
+
*
|
|
284
|
+
* All interactive agents need `.overstory` so they can access shared mail,
|
|
285
|
+
* metrics, and session state. Only `lead` agents need canonical `.git`
|
|
286
|
+
* because they can spawn child worktrees from inside the runtime.
|
|
287
|
+
*
|
|
288
|
+
* @param projectRoot - Absolute path to the canonical repository root
|
|
289
|
+
* @param capability - Capability being launched
|
|
290
|
+
*/
|
|
291
|
+
export function getSharedWritableDirs(projectRoot: string, capability: string): string[] {
|
|
292
|
+
const sharedWritableDirs = [join(projectRoot, ".overstory")];
|
|
293
|
+
|
|
294
|
+
if (capability === "lead") {
|
|
295
|
+
sharedWritableDirs.push(join(projectRoot, ".git"));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return sharedWritableDirs;
|
|
299
|
+
}
|
|
300
|
+
|
|
277
301
|
/**
|
|
278
302
|
* Check if any active agent is already working on the given task ID.
|
|
279
303
|
* Returns the agent name if locked, or null if the task is free.
|
|
@@ -569,47 +593,63 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
569
593
|
// 4. Resolve or create run_id for this spawn
|
|
570
594
|
const overstoryDir = join(config.project.root, ".overstory");
|
|
571
595
|
const currentRunPath = join(overstoryDir, "current-run.txt");
|
|
572
|
-
let runId: string;
|
|
573
|
-
|
|
574
|
-
const currentRunFile = Bun.file(currentRunPath);
|
|
575
|
-
if (await currentRunFile.exists()) {
|
|
576
|
-
runId = (await currentRunFile.text()).trim();
|
|
577
|
-
} else {
|
|
578
|
-
runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
579
|
-
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
580
|
-
try {
|
|
581
|
-
runStore.createRun({
|
|
582
|
-
id: runId,
|
|
583
|
-
startedAt: new Date().toISOString(),
|
|
584
|
-
coordinatorSessionId: null,
|
|
585
|
-
status: "active",
|
|
586
|
-
});
|
|
587
|
-
} finally {
|
|
588
|
-
runStore.close();
|
|
589
|
-
}
|
|
590
|
-
await Bun.write(currentRunPath, runId);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// 4b. Check per-run session limit
|
|
594
|
-
if (config.agents.maxSessionsPerRun > 0) {
|
|
595
|
-
const runCheckStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
596
|
-
try {
|
|
597
|
-
const run = runCheckStore.getRun(runId);
|
|
598
|
-
if (run && checkRunSessionLimit(config.agents.maxSessionsPerRun, run.agentCount)) {
|
|
599
|
-
throw new AgentError(
|
|
600
|
-
`Run session limit reached: ${run.agentCount}/${config.agents.maxSessionsPerRun} agents spawned in run "${runId}". ` +
|
|
601
|
-
`Increase agents.maxSessionsPerRun in config.yaml or start a new run.`,
|
|
602
|
-
{ agentName: name },
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
} finally {
|
|
606
|
-
runCheckStore.close();
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
596
|
|
|
610
597
|
// 5. Check name uniqueness and concurrency limit against active sessions
|
|
598
|
+
// (Session store opened here so we can also use it for parent run ID inheritance in step 4.)
|
|
611
599
|
const { store } = openSessionStore(overstoryDir);
|
|
612
600
|
try {
|
|
601
|
+
// 4a. Resolve run ID: inherit from parent → current-run.txt fallback → create new.
|
|
602
|
+
// Parent inheritance ensures child agents belong to the same run as their coordinator.
|
|
603
|
+
const runId = await (async (): Promise<string> => {
|
|
604
|
+
if (parentAgent) {
|
|
605
|
+
const parentSession = store.getByName(parentAgent);
|
|
606
|
+
if (parentSession?.runId) {
|
|
607
|
+
return parentSession.runId;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Fallback: read current-run.txt (backward compat with single-coordinator setups).
|
|
612
|
+
const currentRunFile = Bun.file(currentRunPath);
|
|
613
|
+
if (await currentRunFile.exists()) {
|
|
614
|
+
const text = (await currentRunFile.text()).trim();
|
|
615
|
+
if (text) return text;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Create a new run if none exists.
|
|
619
|
+
const newRunId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
620
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
621
|
+
try {
|
|
622
|
+
runStore.createRun({
|
|
623
|
+
id: newRunId,
|
|
624
|
+
startedAt: new Date().toISOString(),
|
|
625
|
+
coordinatorSessionId: null,
|
|
626
|
+
coordinatorName: null,
|
|
627
|
+
status: "active",
|
|
628
|
+
});
|
|
629
|
+
} finally {
|
|
630
|
+
runStore.close();
|
|
631
|
+
}
|
|
632
|
+
await Bun.write(currentRunPath, newRunId);
|
|
633
|
+
return newRunId;
|
|
634
|
+
})();
|
|
635
|
+
|
|
636
|
+
// 4b. Check per-run session limit
|
|
637
|
+
if (config.agents.maxSessionsPerRun > 0) {
|
|
638
|
+
const runCheckStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
639
|
+
try {
|
|
640
|
+
const run = runCheckStore.getRun(runId);
|
|
641
|
+
if (run && checkRunSessionLimit(config.agents.maxSessionsPerRun, run.agentCount)) {
|
|
642
|
+
throw new AgentError(
|
|
643
|
+
`Run session limit reached: ${run.agentCount}/${config.agents.maxSessionsPerRun} agents spawned in run "${runId}". ` +
|
|
644
|
+
`Increase agents.maxSessionsPerRun in config.yaml or start a new run.`,
|
|
645
|
+
{ agentName: name },
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
} finally {
|
|
649
|
+
runCheckStore.close();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
613
653
|
const activeSessions = store.getActive();
|
|
614
654
|
if (activeSessions.length >= config.agents.maxConcurrent) {
|
|
615
655
|
throw new AgentError(
|
|
@@ -724,367 +764,385 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
724
764
|
taskId: taskId,
|
|
725
765
|
});
|
|
726
766
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
767
|
+
try {
|
|
768
|
+
// 8. Generate + write overlay CLAUDE.md
|
|
769
|
+
const agentDefPath = join(config.project.root, config.agents.baseDir, agentDef.file);
|
|
770
|
+
const baseDefinition = await Bun.file(agentDefPath).text();
|
|
730
771
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
772
|
+
// 8a. Fetch file-scoped mulch expertise if mulch is enabled and files are provided
|
|
773
|
+
let mulchExpertise: string | undefined;
|
|
774
|
+
if (config.mulch.enabled && fileScope.length > 0) {
|
|
775
|
+
try {
|
|
776
|
+
const mulch = createMulchClient(config.project.root);
|
|
777
|
+
mulchExpertise = await mulch.prime(undefined, undefined, {
|
|
778
|
+
files: fileScope,
|
|
779
|
+
sortByScore: true,
|
|
780
|
+
});
|
|
781
|
+
} catch {
|
|
782
|
+
// Non-fatal: mulch expertise is supplementary context
|
|
783
|
+
mulchExpertise = undefined;
|
|
784
|
+
}
|
|
743
785
|
}
|
|
744
|
-
}
|
|
745
786
|
|
|
746
|
-
|
|
747
|
-
|
|
787
|
+
// Resolve runtime before overlayConfig so we can pass runtime.instructionPath
|
|
788
|
+
const runtime = getRuntime(opts.runtime, config, capability);
|
|
748
789
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
790
|
+
const overlayConfig: OverlayConfig = {
|
|
791
|
+
agentName: name,
|
|
792
|
+
taskId: taskId,
|
|
793
|
+
specPath: absoluteSpecPath,
|
|
794
|
+
branchName,
|
|
795
|
+
worktreePath,
|
|
796
|
+
fileScope,
|
|
797
|
+
mulchDomains: config.mulch.enabled
|
|
798
|
+
? inferDomainsFromFiles(fileScope, config.mulch.domains)
|
|
799
|
+
: [],
|
|
800
|
+
parentAgent: parentAgent,
|
|
801
|
+
depth,
|
|
802
|
+
canSpawn: agentDef.canSpawn,
|
|
803
|
+
capability,
|
|
804
|
+
baseDefinition,
|
|
805
|
+
mulchExpertise,
|
|
806
|
+
skipScout: skipScout && capability === "lead",
|
|
807
|
+
skipReview: opts.skipReview === true && capability === "lead",
|
|
808
|
+
maxAgentsOverride:
|
|
809
|
+
opts.dispatchMaxAgents !== undefined
|
|
810
|
+
? Number.parseInt(opts.dispatchMaxAgents, 10)
|
|
811
|
+
: undefined,
|
|
812
|
+
qualityGates: config.project.qualityGates,
|
|
813
|
+
trackerCli: trackerCliName(resolvedBackend),
|
|
814
|
+
trackerName: resolvedBackend,
|
|
815
|
+
instructionPath: runtime.instructionPath,
|
|
816
|
+
};
|
|
776
817
|
|
|
777
|
-
try {
|
|
778
818
|
await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
|
|
779
|
-
} catch (err) {
|
|
780
|
-
// Clean up the orphaned worktree created in step 7 (overstory-p4st)
|
|
781
|
-
try {
|
|
782
|
-
const cleanupProc = Bun.spawn(["git", "worktree", "remove", "--force", worktreePath], {
|
|
783
|
-
cwd: config.project.root,
|
|
784
|
-
stdout: "pipe",
|
|
785
|
-
stderr: "pipe",
|
|
786
|
-
});
|
|
787
|
-
await cleanupProc.exited;
|
|
788
|
-
} catch {
|
|
789
|
-
// Best-effort cleanup; the original error is more important
|
|
790
|
-
}
|
|
791
|
-
throw err;
|
|
792
|
-
}
|
|
793
819
|
|
|
794
|
-
|
|
795
|
-
|
|
820
|
+
// 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
|
|
821
|
+
const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
|
|
796
822
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
806
|
-
// This eliminates the race where coordinator sends dispatch AFTER agent boots.
|
|
807
|
-
const dispatch = buildAutoDispatch({
|
|
808
|
-
agentName: name,
|
|
809
|
-
taskId,
|
|
810
|
-
capability,
|
|
811
|
-
specPath: absoluteSpecPath,
|
|
812
|
-
parentAgent,
|
|
813
|
-
instructionPath: runtime.instructionPath,
|
|
814
|
-
});
|
|
815
|
-
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
816
|
-
try {
|
|
817
|
-
const mailClient = createMailClient(mailStore);
|
|
818
|
-
mailClient.send({
|
|
819
|
-
from: dispatch.from,
|
|
820
|
-
to: dispatch.to,
|
|
821
|
-
subject: dispatch.subject,
|
|
822
|
-
body: dispatch.body,
|
|
823
|
-
type: "dispatch",
|
|
824
|
-
priority: "normal",
|
|
823
|
+
// 9a. Deploy hooks config (capability-specific guards)
|
|
824
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
825
|
+
agentName: name,
|
|
826
|
+
capability,
|
|
827
|
+
worktreePath,
|
|
828
|
+
qualityGates: config.project.qualityGates,
|
|
825
829
|
});
|
|
826
|
-
} finally {
|
|
827
|
-
mailStore.close();
|
|
828
|
-
}
|
|
829
830
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
// Non-fatal: issue may already be claimed
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// 11. Create agent identity (if new)
|
|
840
|
-
const identityBaseDir = join(config.project.root, ".overstory", "agents");
|
|
841
|
-
const existingIdentity = await loadIdentity(identityBaseDir, name);
|
|
842
|
-
if (!existingIdentity) {
|
|
843
|
-
await createIdentity(identityBaseDir, {
|
|
844
|
-
name,
|
|
831
|
+
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
832
|
+
// This eliminates the race where coordinator sends dispatch AFTER agent boots.
|
|
833
|
+
const dispatch = buildAutoDispatch({
|
|
834
|
+
agentName: name,
|
|
835
|
+
taskId,
|
|
845
836
|
capability,
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
recentTasks: [],
|
|
837
|
+
specPath: absoluteSpecPath,
|
|
838
|
+
parentAgent,
|
|
839
|
+
instructionPath: runtime.instructionPath,
|
|
850
840
|
});
|
|
851
|
-
|
|
841
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
842
|
+
try {
|
|
843
|
+
const mailClient = createMailClient(mailStore);
|
|
844
|
+
mailClient.send({
|
|
845
|
+
from: dispatch.from,
|
|
846
|
+
to: dispatch.to,
|
|
847
|
+
subject: dispatch.subject,
|
|
848
|
+
body: dispatch.body,
|
|
849
|
+
type: "dispatch",
|
|
850
|
+
priority: "normal",
|
|
851
|
+
});
|
|
852
|
+
} finally {
|
|
853
|
+
mailStore.close();
|
|
854
|
+
}
|
|
852
855
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// can append outcomes when the session completes.
|
|
856
|
-
if (mulchExpertise) {
|
|
857
|
-
const appliedRecords = extractMulchRecordIds(mulchExpertise);
|
|
858
|
-
if (appliedRecords.length > 0) {
|
|
859
|
-
const appliedRecordsPath = join(identityBaseDir, name, "applied-records.json");
|
|
860
|
-
const appliedData = { taskId, agentName: name, capability, records: appliedRecords };
|
|
856
|
+
// 10. Claim tracker issue
|
|
857
|
+
if (config.taskTracker.enabled && !skipTaskCheck) {
|
|
861
858
|
try {
|
|
862
|
-
await
|
|
859
|
+
await tracker.claim(taskId);
|
|
863
860
|
} catch {
|
|
864
|
-
// Non-fatal:
|
|
861
|
+
// Non-fatal: issue may already be claimed
|
|
865
862
|
}
|
|
866
863
|
}
|
|
867
|
-
}
|
|
868
864
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
}
|
|
865
|
+
// 11. Create agent identity (if new)
|
|
866
|
+
const identityBaseDir = join(config.project.root, ".overstory", "agents");
|
|
867
|
+
const existingIdentity = await loadIdentity(identityBaseDir, name);
|
|
868
|
+
if (!existingIdentity) {
|
|
869
|
+
await createIdentity(identityBaseDir, {
|
|
870
|
+
name,
|
|
871
|
+
capability,
|
|
872
|
+
created: new Date().toISOString(),
|
|
873
|
+
sessionsCompleted: 0,
|
|
874
|
+
expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
|
|
875
|
+
recentTasks: [],
|
|
876
|
+
});
|
|
877
|
+
}
|
|
882
878
|
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
899
|
-
stdoutFile: join(agentLogDir, "stdout.log"),
|
|
900
|
-
stderrFile: join(agentLogDir, "stderr.log"),
|
|
901
|
-
});
|
|
879
|
+
// 11b. Save applied mulch record IDs for session-end outcome tracking.
|
|
880
|
+
// Written to .overstory/agents/{name}/applied-records.json so log.ts
|
|
881
|
+
// can append outcomes when the session completes.
|
|
882
|
+
if (mulchExpertise) {
|
|
883
|
+
const appliedRecords = extractMulchRecordIds(mulchExpertise);
|
|
884
|
+
if (appliedRecords.length > 0) {
|
|
885
|
+
const appliedRecordsPath = join(identityBaseDir, name, "applied-records.json");
|
|
886
|
+
const appliedData = { taskId, agentName: name, capability, records: appliedRecords };
|
|
887
|
+
try {
|
|
888
|
+
await Bun.write(appliedRecordsPath, `${JSON.stringify(appliedData, null, "\t")}\n`);
|
|
889
|
+
} catch {
|
|
890
|
+
// Non-fatal: outcome tracking is supplementary context
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
902
894
|
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
startedAt: new Date().toISOString(),
|
|
918
|
-
lastActivity: new Date().toISOString(),
|
|
919
|
-
escalationLevel: 0,
|
|
920
|
-
stalledSince: null,
|
|
921
|
-
transcriptPath: null,
|
|
922
|
-
};
|
|
923
|
-
store.upsert(session);
|
|
895
|
+
// 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
|
|
896
|
+
if (runtime.headless === true && runtime.buildDirectSpawn) {
|
|
897
|
+
const directEnv = {
|
|
898
|
+
...runtime.buildEnv(resolvedModel),
|
|
899
|
+
OVERSTORY_AGENT_NAME: name,
|
|
900
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
901
|
+
OVERSTORY_TASK_ID: taskId,
|
|
902
|
+
};
|
|
903
|
+
const argv = runtime.buildDirectSpawn({
|
|
904
|
+
cwd: worktreePath,
|
|
905
|
+
env: directEnv,
|
|
906
|
+
...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
|
|
907
|
+
instructionPath: runtime.instructionPath,
|
|
908
|
+
});
|
|
924
909
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
910
|
+
// Create a timestamped log dir for this headless agent session.
|
|
911
|
+
// Always redirect stdout to a file. This prevents SIGPIPE death:
|
|
912
|
+
// ov sling exits after spawning, closing the pipe's read end.
|
|
913
|
+
// If stdout is a pipe, the agent dies on the next write (SIGPIPE).
|
|
914
|
+
// File writes have no such limit, and the agent survives the CLI exit.
|
|
915
|
+
//
|
|
916
|
+
// Note: RPC connection wiring is intentionally omitted here. The RPC pipe
|
|
917
|
+
// is only useful when the spawner stays alive to consume it. ov sling is
|
|
918
|
+
// a short-lived CLI — any connection created here dies with the process.
|
|
919
|
+
const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
920
|
+
const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
|
|
921
|
+
mkdirSync(agentLogDir, { recursive: true });
|
|
922
|
+
|
|
923
|
+
const headlessProc = await spawnHeadlessAgent(argv, {
|
|
924
|
+
cwd: worktreePath,
|
|
925
|
+
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
926
|
+
stdoutFile: join(agentLogDir, "stdout.log"),
|
|
927
|
+
stderrFile: join(agentLogDir, "stderr.log"),
|
|
928
|
+
});
|
|
931
929
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
930
|
+
// 13. Record session with empty tmuxSession (no tmux pane for headless agents).
|
|
931
|
+
const session: AgentSession = {
|
|
932
|
+
id: `session-${Date.now()}-${name}`,
|
|
935
933
|
agentName: name,
|
|
936
934
|
capability,
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
935
|
+
worktreePath,
|
|
936
|
+
branchName,
|
|
937
|
+
taskId: taskId,
|
|
940
938
|
tmuxSession: "",
|
|
939
|
+
state: "booting",
|
|
941
940
|
pid: headlessProc.pid,
|
|
942
|
-
|
|
941
|
+
parentAgent: parentAgent,
|
|
942
|
+
depth,
|
|
943
|
+
runId,
|
|
944
|
+
startedAt: new Date().toISOString(),
|
|
945
|
+
lastActivity: new Date().toISOString(),
|
|
946
|
+
escalationLevel: 0,
|
|
947
|
+
stalledSince: null,
|
|
948
|
+
transcriptPath: null,
|
|
949
|
+
};
|
|
950
|
+
store.upsert(session);
|
|
951
|
+
|
|
952
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
953
|
+
try {
|
|
954
|
+
runStore.incrementAgentCount(runId);
|
|
955
|
+
} finally {
|
|
956
|
+
runStore.close();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// 14. Output result (headless)
|
|
960
|
+
if (opts.json ?? false) {
|
|
961
|
+
jsonOutput("sling", {
|
|
962
|
+
agentName: name,
|
|
963
|
+
capability,
|
|
964
|
+
taskId,
|
|
965
|
+
branch: branchName,
|
|
966
|
+
worktree: worktreePath,
|
|
967
|
+
tmuxSession: "",
|
|
968
|
+
pid: headlessProc.pid,
|
|
969
|
+
});
|
|
970
|
+
} else {
|
|
971
|
+
printSuccess("Agent launched (headless)", name);
|
|
972
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
973
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
974
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
975
|
+
process.stdout.write(` PID: ${headlessProc.pid}\n`);
|
|
976
|
+
}
|
|
943
977
|
} else {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
978
|
+
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
979
|
+
await ensureTmuxAvailable();
|
|
980
|
+
|
|
981
|
+
// 12. Create tmux session running claude in interactive mode
|
|
982
|
+
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
983
|
+
const spawnCmd = runtime.buildSpawnCommand({
|
|
984
|
+
model: resolvedModel.model,
|
|
985
|
+
permissionMode: "bypass",
|
|
986
|
+
cwd: worktreePath,
|
|
987
|
+
sharedWritableDirs: getSharedWritableDirs(config.project.root, capability),
|
|
988
|
+
env: {
|
|
989
|
+
...runtime.buildEnv(resolvedModel),
|
|
990
|
+
OVERSTORY_AGENT_NAME: name,
|
|
991
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
992
|
+
OVERSTORY_TASK_ID: taskId,
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
961
996
|
...runtime.buildEnv(resolvedModel),
|
|
962
997
|
OVERSTORY_AGENT_NAME: name,
|
|
963
998
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
967
|
-
...runtime.buildEnv(resolvedModel),
|
|
968
|
-
OVERSTORY_AGENT_NAME: name,
|
|
969
|
-
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
973
|
-
// updateLastActivity() can find the entry and transition booting->working.
|
|
974
|
-
// Without this, a race exists: hooks fire before the session is persisted,
|
|
975
|
-
// leaving the agent stuck in "booting" (overstory-036f).
|
|
976
|
-
const session: AgentSession = {
|
|
977
|
-
id: `session-${Date.now()}-${name}`,
|
|
978
|
-
agentName: name,
|
|
979
|
-
capability,
|
|
980
|
-
worktreePath,
|
|
981
|
-
branchName,
|
|
982
|
-
taskId: taskId,
|
|
983
|
-
tmuxSession: tmuxSessionName,
|
|
984
|
-
state: "booting",
|
|
985
|
-
pid,
|
|
986
|
-
parentAgent: parentAgent,
|
|
987
|
-
depth,
|
|
988
|
-
runId,
|
|
989
|
-
startedAt: new Date().toISOString(),
|
|
990
|
-
lastActivity: new Date().toISOString(),
|
|
991
|
-
escalationLevel: 0,
|
|
992
|
-
stalledSince: null,
|
|
993
|
-
transcriptPath: null,
|
|
994
|
-
};
|
|
999
|
+
OVERSTORY_TASK_ID: taskId,
|
|
1000
|
+
});
|
|
995
1001
|
|
|
996
|
-
|
|
1002
|
+
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
1003
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
1004
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
1005
|
+
// leaving the agent stuck in "booting" (overstory-036f).
|
|
1006
|
+
const session: AgentSession = {
|
|
1007
|
+
id: `session-${Date.now()}-${name}`,
|
|
1008
|
+
agentName: name,
|
|
1009
|
+
capability,
|
|
1010
|
+
worktreePath,
|
|
1011
|
+
branchName,
|
|
1012
|
+
taskId: taskId,
|
|
1013
|
+
tmuxSession: tmuxSessionName,
|
|
1014
|
+
state: "booting",
|
|
1015
|
+
pid,
|
|
1016
|
+
parentAgent: parentAgent,
|
|
1017
|
+
depth,
|
|
1018
|
+
runId,
|
|
1019
|
+
startedAt: new Date().toISOString(),
|
|
1020
|
+
lastActivity: new Date().toISOString(),
|
|
1021
|
+
escalationLevel: 0,
|
|
1022
|
+
stalledSince: null,
|
|
1023
|
+
transcriptPath: null,
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
store.upsert(session);
|
|
1027
|
+
|
|
1028
|
+
// Increment agent count for the run
|
|
1029
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
1030
|
+
try {
|
|
1031
|
+
runStore.incrementAgentCount(runId);
|
|
1032
|
+
} finally {
|
|
1033
|
+
runStore.close();
|
|
1034
|
+
}
|
|
997
1035
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
runStore.close();
|
|
1004
|
-
}
|
|
1036
|
+
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
1037
|
+
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
1038
|
+
if (shellDelay > 0) {
|
|
1039
|
+
await Bun.sleep(shellDelay);
|
|
1040
|
+
}
|
|
1005
1041
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
await
|
|
1010
|
-
|
|
1042
|
+
// Wait for Claude Code TUI to render before sending input.
|
|
1043
|
+
// Polling capture-pane is more reliable than a fixed sleep because
|
|
1044
|
+
// TUI init time varies by machine load and model state.
|
|
1045
|
+
const tuiReady = await waitForTuiReady(tmuxSessionName, (content) =>
|
|
1046
|
+
runtime.detectReady(content),
|
|
1047
|
+
);
|
|
1048
|
+
if (!tuiReady) {
|
|
1049
|
+
const alive = await isSessionAlive(tmuxSessionName);
|
|
1050
|
+
store.updateState(name, "completed");
|
|
1051
|
+
|
|
1052
|
+
if (alive) {
|
|
1053
|
+
await killSession(tmuxSessionName);
|
|
1054
|
+
throw new AgentError(
|
|
1055
|
+
`Agent tmux session "${tmuxSessionName}" did not become ready during startup. The runtime may still be waiting on an interactive dialog or initializing too slowly.`,
|
|
1056
|
+
{ agentName: name },
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1011
1059
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1060
|
+
const sessionState = await checkSessionState(tmuxSessionName);
|
|
1061
|
+
const detail =
|
|
1062
|
+
sessionState === "no_server"
|
|
1063
|
+
? "The tmux server is no longer running. It may have crashed or been killed externally."
|
|
1064
|
+
: "The agent process may have crashed or exited immediately before the TUI became ready.";
|
|
1065
|
+
throw new AgentError(
|
|
1066
|
+
`Agent tmux session "${tmuxSessionName}" died during startup. ${detail}`,
|
|
1067
|
+
{ agentName: name },
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
// Buffer for the input handler to attach after initial render
|
|
1071
|
+
await Bun.sleep(1_000);
|
|
1018
1072
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1073
|
+
const beacon = buildBeacon({
|
|
1074
|
+
agentName: name,
|
|
1075
|
+
capability,
|
|
1076
|
+
taskId,
|
|
1077
|
+
parentAgent,
|
|
1078
|
+
depth,
|
|
1079
|
+
instructionPath: runtime.instructionPath,
|
|
1080
|
+
});
|
|
1081
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1082
|
+
|
|
1083
|
+
// 13c. Follow-up Enters with increasing delays to ensure submission.
|
|
1084
|
+
// Claude Code's TUI may consume early Enters during late initialization
|
|
1085
|
+
// (overstory-yhv6). An Enter on an empty input line is harmless.
|
|
1086
|
+
for (const delay of [1_000, 2_000, 3_000, 5_000]) {
|
|
1087
|
+
await Bun.sleep(delay);
|
|
1088
|
+
await sendKeys(tmuxSessionName, "");
|
|
1089
|
+
}
|
|
1036
1090
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1091
|
+
// 13d. Verify beacon was received — if pane still shows the welcome
|
|
1092
|
+
// screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
|
|
1093
|
+
// sometimes consumes the Enter keystroke during late initialization, swallowing
|
|
1094
|
+
// the beacon text entirely (overstory-3271).
|
|
1095
|
+
//
|
|
1096
|
+
// Skipped for runtimes that return false from requiresBeaconVerification().
|
|
1097
|
+
// Pi's TUI idle and processing states are indistinguishable via detectReady
|
|
1098
|
+
// (both show "pi v..." header and the token-usage status bar), so the loop
|
|
1099
|
+
// would incorrectly conclude the beacon was not received and spam duplicate
|
|
1100
|
+
// startup messages.
|
|
1101
|
+
const needsVerification =
|
|
1102
|
+
!runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
|
|
1103
|
+
if (needsVerification) {
|
|
1104
|
+
const verifyAttempts = 5;
|
|
1105
|
+
for (let v = 0; v < verifyAttempts; v++) {
|
|
1106
|
+
await Bun.sleep(2_000);
|
|
1107
|
+
const paneContent = await capturePaneContent(tmuxSessionName);
|
|
1108
|
+
if (paneContent) {
|
|
1109
|
+
const readyState = runtime.detectReady(paneContent);
|
|
1110
|
+
if (readyState.phase !== "ready") {
|
|
1111
|
+
break; // Agent is processing — beacon was received
|
|
1112
|
+
}
|
|
1058
1113
|
}
|
|
1114
|
+
// Still at welcome/idle screen — resend beacon
|
|
1115
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1116
|
+
await Bun.sleep(1_000);
|
|
1117
|
+
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
1059
1118
|
}
|
|
1060
|
-
// Still at welcome/idle screen — resend beacon
|
|
1061
|
-
await sendKeys(tmuxSessionName, beacon);
|
|
1062
|
-
await Bun.sleep(1_000);
|
|
1063
|
-
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
1064
1119
|
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// 14. Output result
|
|
1068
|
-
const output = {
|
|
1069
|
-
agentName: name,
|
|
1070
|
-
capability,
|
|
1071
|
-
taskId,
|
|
1072
|
-
branch: branchName,
|
|
1073
|
-
worktree: worktreePath,
|
|
1074
|
-
tmuxSession: tmuxSessionName,
|
|
1075
|
-
pid,
|
|
1076
|
-
};
|
|
1077
1120
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1121
|
+
// 14. Output result
|
|
1122
|
+
const output = {
|
|
1123
|
+
agentName: name,
|
|
1124
|
+
capability,
|
|
1125
|
+
taskId,
|
|
1126
|
+
branch: branchName,
|
|
1127
|
+
worktree: worktreePath,
|
|
1128
|
+
tmuxSession: tmuxSessionName,
|
|
1129
|
+
pid,
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
if (opts.json ?? false) {
|
|
1133
|
+
jsonOutput("sling", output);
|
|
1134
|
+
} else {
|
|
1135
|
+
printSuccess("Agent launched", name);
|
|
1136
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
1137
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
1138
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
1139
|
+
process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
|
|
1140
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
1141
|
+
}
|
|
1087
1142
|
}
|
|
1143
|
+
} catch (err) {
|
|
1144
|
+
await rollbackWorktree(config.project.root, worktreePath, branchName);
|
|
1145
|
+
throw err;
|
|
1088
1146
|
}
|
|
1089
1147
|
} finally {
|
|
1090
1148
|
store.close();
|