@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.
Files changed (53) hide show
  1. package/README.md +13 -9
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/hooks-deployer.test.ts +185 -12
  5. package/src/agents/hooks-deployer.ts +57 -1
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +494 -6
  9. package/src/commands/coordinator.ts +200 -4
  10. package/src/commands/dashboard.ts +84 -18
  11. package/src/commands/ecosystem.test.ts +101 -0
  12. package/src/commands/init.test.ts +211 -0
  13. package/src/commands/init.ts +93 -15
  14. package/src/commands/log.test.ts +10 -11
  15. package/src/commands/log.ts +31 -32
  16. package/src/commands/prime.ts +30 -5
  17. package/src/commands/sling.test.ts +33 -0
  18. package/src/commands/sling.ts +416 -358
  19. package/src/commands/spec.ts +8 -2
  20. package/src/commands/stop.test.ts +127 -6
  21. package/src/commands/stop.ts +95 -43
  22. package/src/commands/supervisor.ts +2 -0
  23. package/src/commands/watch.ts +29 -9
  24. package/src/config.test.ts +72 -0
  25. package/src/config.ts +26 -1
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +383 -25
  28. package/src/merge/resolver.ts +291 -98
  29. package/src/runtimes/claude.test.ts +32 -7
  30. package/src/runtimes/claude.ts +19 -4
  31. package/src/runtimes/codex.test.ts +13 -0
  32. package/src/runtimes/codex.ts +18 -2
  33. package/src/runtimes/copilot.ts +3 -0
  34. package/src/runtimes/cursor.test.ts +497 -0
  35. package/src/runtimes/cursor.ts +205 -0
  36. package/src/runtimes/gemini.ts +3 -0
  37. package/src/runtimes/opencode.ts +3 -0
  38. package/src/runtimes/pi.test.ts +119 -2
  39. package/src/runtimes/pi.ts +64 -12
  40. package/src/runtimes/registry.test.ts +21 -1
  41. package/src/runtimes/registry.ts +3 -0
  42. package/src/runtimes/sapling.ts +3 -0
  43. package/src/runtimes/types.ts +5 -0
  44. package/src/schema-consistency.test.ts +1 -0
  45. package/src/sessions/store.test.ts +178 -0
  46. package/src/sessions/store.ts +44 -8
  47. package/src/types.ts +25 -1
  48. package/src/watchdog/daemon.test.ts +257 -0
  49. package/src/watchdog/daemon.ts +66 -23
  50. package/src/worktree/manager.test.ts +65 -1
  51. package/src/worktree/manager.ts +36 -0
  52. package/src/worktree/tmux.test.ts +150 -0
  53. package/src/worktree/tmux.ts +126 -23
@@ -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
- // 8. Generate + write overlay CLAUDE.md
728
- const agentDefPath = join(config.project.root, config.agents.baseDir, agentDef.file);
729
- const baseDefinition = await Bun.file(agentDefPath).text();
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
- // 8a. Fetch file-scoped mulch expertise if mulch is enabled and files are provided
732
- let mulchExpertise: string | undefined;
733
- if (config.mulch.enabled && fileScope.length > 0) {
734
- try {
735
- const mulch = createMulchClient(config.project.root);
736
- mulchExpertise = await mulch.prime(undefined, undefined, {
737
- files: fileScope,
738
- sortByScore: true,
739
- });
740
- } catch {
741
- // Non-fatal: mulch expertise is supplementary context
742
- mulchExpertise = undefined;
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
- // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
747
- const runtime = getRuntime(opts.runtime, config, capability);
787
+ // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
788
+ const runtime = getRuntime(opts.runtime, config, capability);
748
789
 
749
- const overlayConfig: OverlayConfig = {
750
- agentName: name,
751
- taskId: taskId,
752
- specPath: absoluteSpecPath,
753
- branchName,
754
- worktreePath,
755
- fileScope,
756
- mulchDomains: config.mulch.enabled
757
- ? inferDomainsFromFiles(fileScope, config.mulch.domains)
758
- : [],
759
- parentAgent: parentAgent,
760
- depth,
761
- canSpawn: agentDef.canSpawn,
762
- capability,
763
- baseDefinition,
764
- mulchExpertise,
765
- skipScout: skipScout && capability === "lead",
766
- skipReview: opts.skipReview === true && capability === "lead",
767
- maxAgentsOverride:
768
- opts.dispatchMaxAgents !== undefined
769
- ? Number.parseInt(opts.dispatchMaxAgents, 10)
770
- : undefined,
771
- qualityGates: config.project.qualityGates,
772
- trackerCli: trackerCliName(resolvedBackend),
773
- trackerName: resolvedBackend,
774
- instructionPath: runtime.instructionPath,
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
- // 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
795
- const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
820
+ // 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
821
+ const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
796
822
 
797
- // 9a. Deploy hooks config (capability-specific guards)
798
- await runtime.deployConfig(worktreePath, undefined, {
799
- agentName: name,
800
- capability,
801
- worktreePath,
802
- qualityGates: config.project.qualityGates,
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
- // 10. Claim tracker issue
831
- if (config.taskTracker.enabled && !skipTaskCheck) {
832
- try {
833
- await tracker.claim(taskId);
834
- } catch {
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
- created: new Date().toISOString(),
847
- sessionsCompleted: 0,
848
- expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
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
- // 11b. Save applied mulch record IDs for session-end outcome tracking.
854
- // Written to .overstory/agents/{name}/applied-records.json so log.ts
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 Bun.write(appliedRecordsPath, `${JSON.stringify(appliedData, null, "\t")}\n`);
859
+ await tracker.claim(taskId);
863
860
  } catch {
864
- // Non-fatal: outcome tracking is supplementary context
861
+ // Non-fatal: issue may already be claimed
865
862
  }
866
863
  }
867
- }
868
864
 
869
- // 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
870
- if (runtime.headless === true && runtime.buildDirectSpawn) {
871
- const directEnv = {
872
- ...runtime.buildEnv(resolvedModel),
873
- OVERSTORY_AGENT_NAME: name,
874
- OVERSTORY_WORKTREE_PATH: worktreePath,
875
- };
876
- const argv = runtime.buildDirectSpawn({
877
- cwd: worktreePath,
878
- env: directEnv,
879
- ...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
880
- instructionPath: runtime.instructionPath,
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
- // Create a timestamped log dir for this headless agent session.
884
- // Always redirect stdout to a file. This prevents SIGPIPE death:
885
- // ov sling exits after spawning, closing the pipe's read end.
886
- // If stdout is a pipe, the agent dies on the next write (SIGPIPE).
887
- // File writes have no such limit, and the agent survives the CLI exit.
888
- //
889
- // Note: RPC connection wiring is intentionally omitted here. The RPC pipe
890
- // is only useful when the spawner stays alive to consume it. ov sling is
891
- // a short-lived CLI — any connection created here dies with the process.
892
- const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
893
- const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
894
- mkdirSync(agentLogDir, { recursive: true });
895
-
896
- const headlessProc = await spawnHeadlessAgent(argv, {
897
- cwd: worktreePath,
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
- // 13. Record session with empty tmuxSession (no tmux pane for headless agents).
904
- const session: AgentSession = {
905
- id: `session-${Date.now()}-${name}`,
906
- agentName: name,
907
- capability,
908
- worktreePath,
909
- branchName,
910
- taskId: taskId,
911
- tmuxSession: "",
912
- state: "booting",
913
- pid: headlessProc.pid,
914
- parentAgent: parentAgent,
915
- depth,
916
- runId,
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
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
926
- try {
927
- runStore.incrementAgentCount(runId);
928
- } finally {
929
- runStore.close();
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
- // 14. Output result (headless)
933
- if (opts.json ?? false) {
934
- jsonOutput("sling", {
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
- taskId,
938
- branch: branchName,
939
- worktree: worktreePath,
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
- printSuccess("Agent launched (headless)", name);
945
- process.stdout.write(` Task: ${taskId}\n`);
946
- process.stdout.write(` Branch: ${branchName}\n`);
947
- process.stdout.write(` Worktree: ${worktreePath}\n`);
948
- process.stdout.write(` PID: ${headlessProc.pid}\n`);
949
- }
950
- } else {
951
- // 11c. Preflight: verify tmux is available before attempting session creation
952
- await ensureTmuxAvailable();
953
-
954
- // 12. Create tmux session running claude in interactive mode
955
- const tmuxSessionName = `overstory-${config.project.name}-${name}`;
956
- const spawnCmd = runtime.buildSpawnCommand({
957
- model: resolvedModel.model,
958
- permissionMode: "bypass",
959
- cwd: worktreePath,
960
- env: {
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
- store.upsert(session);
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
- // Increment agent count for the run
999
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
1000
- try {
1001
- runStore.incrementAgentCount(runId);
1002
- } finally {
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
- // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
1007
- const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
1008
- if (shellDelay > 0) {
1009
- await Bun.sleep(shellDelay);
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
- // Wait for Claude Code TUI to render before sending input.
1013
- // Polling capture-pane is more reliable than a fixed sleep because
1014
- // TUI init time varies by machine load and model state.
1015
- await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
1016
- // Buffer for the input handler to attach after initial render
1017
- await Bun.sleep(1_000);
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
- const beacon = buildBeacon({
1020
- agentName: name,
1021
- capability,
1022
- taskId,
1023
- parentAgent,
1024
- depth,
1025
- instructionPath: runtime.instructionPath,
1026
- });
1027
- await sendKeys(tmuxSessionName, beacon);
1028
-
1029
- // 13c. Follow-up Enters with increasing delays to ensure submission.
1030
- // Claude Code's TUI may consume early Enters during late initialization
1031
- // (overstory-yhv6). An Enter on an empty input line is harmless.
1032
- for (const delay of [1_000, 2_000, 3_000, 5_000]) {
1033
- await Bun.sleep(delay);
1034
- await sendKeys(tmuxSessionName, "");
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
- // 13d. Verify beacon was received — if pane still shows the welcome
1038
- // screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
1039
- // sometimes consumes the Enter keystroke during late initialization, swallowing
1040
- // the beacon text entirely (overstory-3271).
1041
- //
1042
- // Skipped for runtimes that return false from requiresBeaconVerification().
1043
- // Pi's TUI idle and processing states are indistinguishable via detectReady
1044
- // (both show "pi v..." header and the token-usage status bar), so the loop
1045
- // would incorrectly conclude the beacon was not received and spam duplicate
1046
- // startup messages.
1047
- const needsVerification =
1048
- !runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
1049
- if (needsVerification) {
1050
- const verifyAttempts = 5;
1051
- for (let v = 0; v < verifyAttempts; v++) {
1052
- await Bun.sleep(2_000);
1053
- const paneContent = await capturePaneContent(tmuxSessionName);
1054
- if (paneContent) {
1055
- const readyState = runtime.detectReady(paneContent);
1056
- if (readyState.phase !== "ready") {
1057
- break; // Agent is processing — beacon was received
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
- if (opts.json ?? false) {
1079
- jsonOutput("sling", output);
1080
- } else {
1081
- printSuccess("Agent launched", name);
1082
- process.stdout.write(` Task: ${taskId}\n`);
1083
- process.stdout.write(` Branch: ${branchName}\n`);
1084
- process.stdout.write(` Worktree: ${worktreePath}\n`);
1085
- process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
1086
- process.stdout.write(` PID: ${pid}\n`);
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();