@os-eco/overstory-cli 0.9.4 → 0.11.0

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 (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +219 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, realpathSync } from "node:fs";
3
- import { mkdir, mkdtemp } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { WorktreeError } from "../errors.ts";
@@ -9,6 +9,7 @@ import {
9
9
  commitFile,
10
10
  createTempGitRepo,
11
11
  getDefaultBranch,
12
+ runGitInDir,
12
13
  } from "../test-helpers.ts";
13
14
  import {
14
15
  createWorktree,
@@ -16,6 +17,7 @@ import {
16
17
  listWorktrees,
17
18
  removeWorktree,
18
19
  rollbackWorktree,
20
+ validateWorktreeCreation,
19
21
  } from "./manager.ts";
20
22
 
21
23
  /**
@@ -145,6 +147,61 @@ describe("createWorktree", () => {
145
147
  expect(wtErr.branchName).toBe("overstory/auth-login/bead-abc123");
146
148
  }
147
149
  });
150
+
151
+ test("rejects creation when target branch is already checked out elsewhere", async () => {
152
+ // Pre-check should fail-fast with a precise diagnostic before git
153
+ // worktree add runs, so the operator sees the actual cause rather
154
+ // than git's generic "already exists" error or, worse, a silently
155
+ // half-built worktree (overstory-6878).
156
+ const first = await createWorktree({
157
+ repoRoot: repoDir,
158
+ baseDir: worktreesDir,
159
+ agentName: "auth-login",
160
+ baseBranch: defaultBranch,
161
+ taskId: "bead-abc123",
162
+ });
163
+
164
+ try {
165
+ await createWorktree({
166
+ repoRoot: repoDir,
167
+ baseDir: worktreesDir,
168
+ agentName: "auth-login",
169
+ baseBranch: defaultBranch,
170
+ taskId: "bead-abc123",
171
+ });
172
+ expect(true).toBe(false);
173
+ } catch (err: unknown) {
174
+ expect(err).toBeInstanceOf(WorktreeError);
175
+ const wtErr = err as WorktreeError;
176
+ expect(wtErr.message).toContain("already checked out");
177
+ expect(wtErr.message).toContain(first.path);
178
+ expect(wtErr.branchName).toBe("overstory/auth-login/bead-abc123");
179
+ }
180
+
181
+ // The original worktree must remain intact — the pre-check rejected
182
+ // before any state-mutating git command ran.
183
+ expect(existsSync(first.path)).toBe(true);
184
+ const entries = await listWorktrees(repoDir);
185
+ expect(entries.some((e) => e.path === first.path)).toBe(true);
186
+ });
187
+
188
+ test("post-creation: new worktree is registered and contains tracked files", async () => {
189
+ const { path: wtPath } = await createWorktree({
190
+ repoRoot: repoDir,
191
+ baseDir: worktreesDir,
192
+ agentName: "auth-login",
193
+ baseBranch: defaultBranch,
194
+ taskId: "bead-files",
195
+ });
196
+
197
+ // Registration check — listWorktrees must include the new path
198
+ const entries = await listWorktrees(repoDir);
199
+ expect(entries.map((e) => e.path)).toContain(wtPath);
200
+
201
+ // File-presence check — git ls-files inside the worktree must be non-empty
202
+ const lsFiles = await git(wtPath, ["ls-files"]);
203
+ expect(lsFiles.trim().length).toBeGreaterThan(0);
204
+ });
148
205
  });
149
206
 
150
207
  describe("listWorktrees", () => {
@@ -501,3 +558,163 @@ describe("rollbackWorktree", () => {
501
558
  expect(branchList).toContain("overstory/auth-login/bead-abc");
502
559
  });
503
560
  });
561
+
562
+ describe("validateWorktreeCreation", () => {
563
+ let repoDir: string;
564
+ let worktreesDir: string;
565
+ let defaultBranch: string;
566
+
567
+ beforeEach(async () => {
568
+ repoDir = realpathSync(await createTempGitRepo());
569
+ defaultBranch = await getDefaultBranch(repoDir);
570
+ worktreesDir = join(repoDir, ".overstory", "worktrees");
571
+ await mkdir(worktreesDir, { recursive: true });
572
+ });
573
+
574
+ afterEach(async () => {
575
+ await cleanupTempDir(repoDir);
576
+ });
577
+
578
+ test("passes for a normally created worktree", async () => {
579
+ const { path: wtPath, branch } = await createWorktree({
580
+ repoRoot: repoDir,
581
+ baseDir: worktreesDir,
582
+ agentName: "feature-agent",
583
+ baseBranch: defaultBranch,
584
+ taskId: "bead-ok",
585
+ });
586
+
587
+ // Re-running validation against the live worktree should be a no-op
588
+ await expect(
589
+ validateWorktreeCreation({
590
+ repoRoot: repoDir,
591
+ worktreePath: wtPath,
592
+ branchName: branch,
593
+ }),
594
+ ).resolves.toBeUndefined();
595
+ });
596
+
597
+ test("throws when worktree path is not registered with git", async () => {
598
+ const fakePath = join(worktreesDir, "ghost-agent");
599
+
600
+ try {
601
+ await validateWorktreeCreation({
602
+ repoRoot: repoDir,
603
+ worktreePath: fakePath,
604
+ branchName: "overstory/ghost-agent/bead-missing",
605
+ });
606
+ expect(true).toBe(false);
607
+ } catch (err: unknown) {
608
+ expect(err).toBeInstanceOf(WorktreeError);
609
+ const wtErr = err as WorktreeError;
610
+ expect(wtErr.worktreePath).toBe(fakePath);
611
+ expect(wtErr.branchName).toBe("overstory/ghost-agent/bead-missing");
612
+ expect(wtErr.message).toContain("not registered with git");
613
+ }
614
+ });
615
+
616
+ test("rolls back the dangling branch when validation fails", async () => {
617
+ // Create a real branch that's not attached to any worktree, then ask
618
+ // validation to check a path it can't possibly be registered at.
619
+ await runGitInDir(repoDir, ["branch", "overstory/orphan-agent/bead-x", defaultBranch]);
620
+ const fakePath = join(worktreesDir, "orphan-agent");
621
+
622
+ await expect(
623
+ validateWorktreeCreation({
624
+ repoRoot: repoDir,
625
+ worktreePath: fakePath,
626
+ branchName: "overstory/orphan-agent/bead-x",
627
+ }),
628
+ ).rejects.toThrow(WorktreeError);
629
+
630
+ // rollbackWorktree should have force-deleted the orphan branch
631
+ const branchList = await git(repoDir, ["branch", "--list"]);
632
+ expect(branchList).not.toContain("overstory/orphan-agent/bead-x");
633
+ });
634
+
635
+ test("throws when worktree contains zero tracked files", async () => {
636
+ // Build a base branch that points at an empty tree, then create a
637
+ // worktree from it. git happily registers the worktree, but ls-files
638
+ // returns nothing — the exact silent-failure shape from overstory-6878.
639
+ const emptyTree = (
640
+ await runGitInDir(repoDir, ["hash-object", "-t", "tree", "/dev/null"])
641
+ ).trim();
642
+ const emptyCommit = (
643
+ await runGitInDir(repoDir, ["commit-tree", emptyTree, "-m", "empty base"])
644
+ ).trim();
645
+ await runGitInDir(repoDir, ["branch", "empty-base", emptyCommit]);
646
+
647
+ const wtPath = join(worktreesDir, "empty-agent");
648
+ const branchName = "overstory/empty-agent/bead-empty";
649
+ await runGitInDir(repoDir, ["worktree", "add", "-b", branchName, wtPath, "empty-base"]);
650
+
651
+ try {
652
+ await validateWorktreeCreation({
653
+ repoRoot: repoDir,
654
+ worktreePath: wtPath,
655
+ branchName,
656
+ });
657
+ expect(true).toBe(false);
658
+ } catch (err: unknown) {
659
+ expect(err).toBeInstanceOf(WorktreeError);
660
+ const wtErr = err as WorktreeError;
661
+ expect(wtErr.worktreePath).toBe(wtPath);
662
+ expect(wtErr.branchName).toBe(branchName);
663
+ expect(wtErr.message).toContain("zero tracked files");
664
+ }
665
+
666
+ // Rollback removed both worktree and branch
667
+ expect(existsSync(wtPath)).toBe(false);
668
+ const branchList = await git(repoDir, ["branch", "--list"]);
669
+ expect(branchList).not.toContain(branchName);
670
+ });
671
+
672
+ test("createWorktree rejects when base branch has no tracked files", async () => {
673
+ // End-to-end: createWorktree should surface the same error and clean
674
+ // up after itself, so sling never sees a half-built worktree.
675
+ const emptyTree = (
676
+ await runGitInDir(repoDir, ["hash-object", "-t", "tree", "/dev/null"])
677
+ ).trim();
678
+ const emptyCommit = (
679
+ await runGitInDir(repoDir, ["commit-tree", emptyTree, "-m", "empty base"])
680
+ ).trim();
681
+ await runGitInDir(repoDir, ["branch", "empty-base", emptyCommit]);
682
+
683
+ await expect(
684
+ createWorktree({
685
+ repoRoot: repoDir,
686
+ baseDir: worktreesDir,
687
+ agentName: "empty-agent",
688
+ baseBranch: "empty-base",
689
+ taskId: "bead-empty",
690
+ }),
691
+ ).rejects.toThrow(WorktreeError);
692
+
693
+ // Caller observes a clean repo: no worktree dir, no leaked branch
694
+ expect(existsSync(join(worktreesDir, "empty-agent"))).toBe(false);
695
+ const branchList = await git(repoDir, ["branch", "--list"]);
696
+ expect(branchList).not.toContain("overstory/empty-agent/bead-empty");
697
+ });
698
+
699
+ test("createWorktree rejects when target dir pre-exists with files", async () => {
700
+ // Simulates the witnessed scenario: a stale directory survives at the
701
+ // target path from a previous run. createWorktree must surface a
702
+ // WorktreeError rather than returning a path that points at non-git
703
+ // state — the contract that protects the agent from being trapped.
704
+ const wtPath = join(worktreesDir, "preexisting-agent");
705
+ await mkdir(wtPath, { recursive: true });
706
+ await Bun.write(join(wtPath, "stale.txt"), "leftover from a previous run");
707
+
708
+ await expect(
709
+ createWorktree({
710
+ repoRoot: repoDir,
711
+ baseDir: worktreesDir,
712
+ agentName: "preexisting-agent",
713
+ baseBranch: defaultBranch,
714
+ taskId: "bead-pre",
715
+ }),
716
+ ).rejects.toThrow(WorktreeError);
717
+
718
+ await rm(wtPath, { recursive: true, force: true });
719
+ });
720
+ });
@@ -41,6 +41,14 @@ async function runGit(
41
41
  * Creates a worktree at `{baseDir}/{agentName}` with a new branch
42
42
  * named `overstory/{agentName}/{taskId}` based on `baseBranch`.
43
43
  *
44
+ * Before running `git worktree add`, rejects when the target branch is
45
+ * already checked out in another worktree — this avoids the silent-overwrite
46
+ * class of failure entirely. After `git worktree add` returns, validates
47
+ * that the worktree is actually registered with git AND contains tracked
48
+ * files; if either check fails, rolls back and throws. sling has previously
49
+ * hit edge cases where the dir exists but git did not populate it
50
+ * (overstory-6878), trapping the agent in a non-worktree directory.
51
+ *
44
52
  * @returns The absolute worktree path and branch name.
45
53
  */
46
54
  export async function createWorktree(options: {
@@ -55,14 +63,61 @@ export async function createWorktree(options: {
55
63
  const worktreePath = join(baseDir, agentName);
56
64
  const branchName = `overstory/${agentName}/${taskId}`;
57
65
 
66
+ const existing = await listWorktrees(repoRoot);
67
+ const occupied = existing.find((entry) => entry.branch === branchName);
68
+ if (occupied !== undefined) {
69
+ throw new WorktreeError(`branch ${branchName} is already checked out at ${occupied.path}`, {
70
+ worktreePath,
71
+ branchName,
72
+ });
73
+ }
74
+
58
75
  await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
59
76
  worktreePath,
60
77
  branchName,
61
78
  });
62
79
 
80
+ await validateWorktreeCreation({ repoRoot, worktreePath, branchName });
81
+
63
82
  return { path: worktreePath, branch: branchName };
64
83
  }
65
84
 
85
+ /**
86
+ * Verify that a freshly created worktree is registered with git and contains
87
+ * tracked files. Throws WorktreeError with a precise diagnostic on failure
88
+ * and rolls back the worktree + branch so callers don't leak state.
89
+ *
90
+ * Exported for direct testing of edge cases (empty base branches, racy
91
+ * cleanup) that are awkward to provoke through createWorktree end-to-end.
92
+ */
93
+ export async function validateWorktreeCreation(opts: {
94
+ repoRoot: string;
95
+ worktreePath: string;
96
+ branchName: string;
97
+ }): Promise<void> {
98
+ const { repoRoot, worktreePath, branchName } = opts;
99
+
100
+ const entries = await listWorktrees(repoRoot);
101
+ const registered = entries.some((entry) => entry.path === worktreePath);
102
+ if (!registered) {
103
+ await rollbackWorktree(repoRoot, worktreePath, branchName);
104
+ throw new WorktreeError(
105
+ `Worktree creation reported success but path is not registered with git: ${worktreePath}. Possible causes: pre-existing directory, branch already checked out elsewhere, or git worktree add failed silently.`,
106
+ { worktreePath, branchName },
107
+ );
108
+ }
109
+
110
+ const lsFiles = await runGit(worktreePath, ["ls-files"], { worktreePath, branchName });
111
+ const fileCount = lsFiles.split("\n").filter((line) => line.length > 0).length;
112
+ if (fileCount === 0) {
113
+ await rollbackWorktree(repoRoot, worktreePath, branchName);
114
+ throw new WorktreeError(
115
+ `Worktree was registered but contains zero tracked files: ${worktreePath}. The base branch may be empty or the working tree was not populated.`,
116
+ { worktreePath, branchName },
117
+ );
118
+ }
119
+ }
120
+
66
121
  /**
67
122
  * Roll back a worktree and its associated branch after a failed spawn.
68
123
  *
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { getConnection, removeConnection } from "../runtimes/connections.ts";
6
+ import { HeadlessClaudeConnection } from "../runtimes/headless-connection.ts";
5
7
  import { spawnHeadlessAgent } from "./process.ts";
6
8
 
7
9
  describe("spawnHeadlessAgent", () => {
@@ -22,6 +24,75 @@ describe("spawnHeadlessAgent", () => {
22
24
  );
23
25
  });
24
26
 
27
+ describe("agentName connection registration", () => {
28
+ const registeredNames: string[] = [];
29
+
30
+ afterEach(() => {
31
+ for (const name of registeredNames.splice(0)) {
32
+ removeConnection(name);
33
+ }
34
+ });
35
+
36
+ it("registers a HeadlessClaudeConnection when agentName is provided", async () => {
37
+ const agentName = "test-headless-agent-xyz";
38
+ registeredNames.push(agentName);
39
+
40
+ const proc = await spawnHeadlessAgent(["sleep", "5"], {
41
+ cwd: process.cwd(),
42
+ env: { ...(process.env as Record<string, string>) },
43
+ agentName,
44
+ });
45
+
46
+ expect(proc.pid).toBeGreaterThan(0);
47
+ const conn = getConnection(agentName);
48
+ expect(conn).toBeDefined();
49
+ expect(conn).toBeInstanceOf(HeadlessClaudeConnection);
50
+
51
+ // Clean up the spawned process
52
+ try {
53
+ process.kill(proc.pid, "SIGTERM");
54
+ } catch {
55
+ // ignore
56
+ }
57
+ });
58
+
59
+ it("does not register a connection when agentName is omitted", async () => {
60
+ const proc = await spawnHeadlessAgent(["echo", "no-register"], {
61
+ cwd: process.cwd(),
62
+ env: { ...(process.env as Record<string, string>) },
63
+ });
64
+
65
+ // Drain stdout so process exits cleanly
66
+ if (proc.stdout) {
67
+ await new Response(proc.stdout).text();
68
+ }
69
+
70
+ // No connection was registered (use a stable lookup key that was never set)
71
+ expect(getConnection("never-registered-in-this-test")).toBeUndefined();
72
+ });
73
+
74
+ it("registered connection pid matches the spawned process pid", async () => {
75
+ const agentName = "test-headless-pid-check-xyz";
76
+ registeredNames.push(agentName);
77
+
78
+ const proc = await spawnHeadlessAgent(["sleep", "5"], {
79
+ cwd: process.cwd(),
80
+ env: { ...(process.env as Record<string, string>) },
81
+ agentName,
82
+ });
83
+
84
+ const conn = getConnection(agentName) as HeadlessClaudeConnection;
85
+ expect(conn).toBeDefined();
86
+ expect(conn.pid).toBe(proc.pid);
87
+
88
+ try {
89
+ process.kill(proc.pid, "SIGTERM");
90
+ } catch {
91
+ // ignore
92
+ }
93
+ });
94
+ });
95
+
25
96
  describe("file redirect mode", () => {
26
97
  let tmpDir: string;
27
98
 
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * Headless subprocess management for non-tmux agent runtimes.
3
3
  *
4
- * Used by `ov sling` when runtime.headless === true to bypass tmux entirely.
5
- * Provides spawnHeadlessAgent() for direct Bun.spawn() invocation of
6
- * headless agent processes (e.g., Sapling running with --json).
4
+ * Used by long-lived headless runtimes that bypass tmux (e.g., Sapling running
5
+ * with --json). Provides spawnHeadlessAgent() for direct Bun.spawn() invocation.
6
+ *
7
+ * Headless Claude Code does NOT use this path — under spawn-per-turn (Phase 3),
8
+ * Claude agents have no persistent process; each turn spawns a fresh claude
9
+ * inside `runTurn` (src/agents/turn-runner.ts). This module remains for
10
+ * runtimes that genuinely need a long-lived RPC channel.
7
11
  *
8
12
  * Note: isProcessAlive() and killProcessTree() for headless process lifecycle
9
13
  * management already exist in src/worktree/tmux.ts — not duplicated here.
10
14
  */
11
15
 
12
16
  import { AgentError } from "../errors.ts";
17
+ import { registerHeadlessConnection } from "../runtimes/connections.ts";
13
18
 
14
19
  /**
15
20
  * Handle to a spawned headless agent subprocess.
@@ -57,6 +62,15 @@ export interface SpawnHeadlessOptions {
57
62
  * When set, redirect subprocess stderr to this file path instead of a pipe.
58
63
  */
59
64
  stderrFile?: string;
65
+ /**
66
+ * When set, registers the spawned process as a `RuntimeConnection` keyed by
67
+ * this agent name (sibling of Sapling's RPC connect() flow). Lets `ov nudge`,
68
+ * the watchdog's liveness/abort path, etc. find the live process via
69
+ * `getConnection(agentName)`.
70
+ *
71
+ * Same namespace as AgentSession.agentName.
72
+ */
73
+ agentName?: string;
60
74
  }
61
75
 
62
76
  /**
@@ -103,9 +117,15 @@ export async function spawnHeadlessAgent(
103
117
  stdin: "pipe",
104
118
  });
105
119
 
106
- return {
120
+ const result: HeadlessProcess = {
107
121
  pid: proc.pid,
108
- stdin: proc.stdin,
122
+ stdin: proc.stdin as HeadlessProcess["stdin"],
109
123
  stdout: opts.stdoutFile ? null : (proc.stdout as ReadableStream<Uint8Array>),
110
124
  };
125
+
126
+ if (opts.agentName) {
127
+ registerHeadlessConnection(opts.agentName, result);
128
+ }
129
+
130
+ return result;
111
131
  }
@@ -112,6 +112,9 @@ describe("createSession", () => {
112
112
  const wrappedCmd = cmd[9] as string;
113
113
  expect(wrappedCmd).toContain("echo hello");
114
114
  expect(wrappedCmd).toContain("export PATH=");
115
+ // `exec` replaces the bash wrapper with the command so SIGHUP from a
116
+ // dying tmux server is delivered directly to claude (overstory-505d).
117
+ expect(wrappedCmd).toContain("exec echo hello");
115
118
 
116
119
  const opts = tmuxCallArgs[1] as { cwd: string };
117
120
  expect(opts.cwd).toBe("/work/dir");
@@ -827,6 +830,22 @@ describe("killSession", () => {
827
830
  expect(agentErr.agentName).toBe("ghost-agent");
828
831
  }
829
832
  });
833
+
834
+ test("throws AgentError when called with empty session name", async () => {
835
+ // Defense in depth (overstory-74ce): tmux's `-t` argument prefix-matches
836
+ // every session in the server when given an empty string. Without this
837
+ // guard a regression in any caller would wildcard-kill the entire
838
+ // overstory swarm. spawn must NOT be invoked.
839
+ await expect(killSession("")).rejects.toThrow(AgentError);
840
+ expect(spawnSpy).not.toHaveBeenCalled();
841
+
842
+ try {
843
+ await killSession("");
844
+ } catch (err: unknown) {
845
+ const agentErr = err as AgentError;
846
+ expect(agentErr.message).toContain("wildcard");
847
+ }
848
+ });
830
849
  });
831
850
 
832
851
  describe("isSessionAlive", () => {
@@ -866,6 +885,15 @@ describe("isSessionAlive", () => {
866
885
  const cmd = callArgs[0] as string[];
867
886
  expect(cmd).toEqual(["tmux", "-L", "overstory", "has-session", "-t", "my-agent"]);
868
887
  });
888
+
889
+ test("returns false for empty session name without calling tmux", async () => {
890
+ // Defense in depth (overstory-74ce): an empty `-t` argument prefix-matches
891
+ // every overstory session, so `has-session` would falsely report alive
892
+ // whenever any agent is running. Short-circuit to false without invoking tmux.
893
+ const alive = await isSessionAlive("");
894
+ expect(alive).toBe(false);
895
+ expect(spawnSpy).not.toHaveBeenCalled();
896
+ });
869
897
  });
870
898
 
871
899
  describe("checkSessionState", () => {
@@ -149,9 +149,16 @@ export async function createSession(
149
149
  // causes the session to die instantly. Single-quote wrapping with escaped
150
150
  // single quotes prevents any intermediate shell from expanding variables
151
151
  // before bash receives them. (GitHub #86)
152
- const startupScript = exports.length > 0 ? `${exports.join(" && ")} && ${command}` : command;
153
- const wrappedCommand =
154
- exports.length > 0 ? `/bin/bash -c '${startupScript.replace(/'/g, "'\\''")}'` : command;
152
+ //
153
+ // The `exec` prefix replaces the bash wrapper with the spawned command
154
+ // so there is no separate wrapper PID to orphan if the tmux server dies
155
+ // externally. Without exec, bash receives SIGHUP on tmux teardown but its
156
+ // claude child gets reparented to init and continues running. With exec,
157
+ // the wrapper IS the command — SIGHUP is delivered directly to claude.
158
+ // (overstory-505d)
159
+ const startupScript =
160
+ exports.length > 0 ? `${exports.join(" && ")} && exec ${command}` : `exec ${command}`;
161
+ const wrappedCommand = `/bin/bash -c '${startupScript.replace(/'/g, "'\\''")}'`;
155
162
 
156
163
  const { exitCode, stderr } = await runCommand(
157
164
  tmuxCmd("new-session", "-d", "-s", name, "-c", cwd, wrappedCommand),
@@ -397,6 +404,17 @@ function sendSignal(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
397
404
  * failures are silently handled since the goal is best-effort cleanup)
398
405
  */
399
406
  export async function killSession(name: string): Promise<void> {
407
+ // Defense in depth: an empty session name passed to `tmux -t` is prefix-matched
408
+ // against every session in the server, wildcard-killing the entire overstory
409
+ // swarm (overstory-74ce). Reject empty names at the boundary so a regression in
410
+ // any caller surfaces loudly instead of silently nuking the tmux server.
411
+ if (name === "") {
412
+ throw new AgentError(
413
+ "killSession called with empty session name (would wildcard-kill all tmux sessions due to prefix matching)",
414
+ { agentName: name },
415
+ );
416
+ }
417
+
400
418
  // Step 1: Get the pane PID before killing the tmux session
401
419
  const panePid = await getPanePid(name);
402
420
 
@@ -450,6 +468,12 @@ export async function getCurrentSessionName(): Promise<string | null> {
450
468
  * @returns true if the session exists, false otherwise
451
469
  */
452
470
  export async function isSessionAlive(name: string): Promise<boolean> {
471
+ // Defense in depth: an empty `-t` argument is prefix-matched against every
472
+ // session, so `has-session` would return true whenever any overstory session
473
+ // exists. Treat empty as "not alive" without contacting tmux (overstory-74ce).
474
+ if (name === "") {
475
+ return false;
476
+ }
453
477
  const { exitCode } = await runCommand(tmuxCmd("has-session", "-t", name));
454
478
  return exitCode === 0;
455
479
  }
@@ -6,6 +6,11 @@ This project uses **overstory** for Claude Code agent orchestration. Your sessio
6
6
  acts as the orchestrator: you decide what work to delegate, spawn worker agents,
7
7
  monitor progress, and merge results.
8
8
 
9
+ The **web UI is your primary operator surface** — run `ov serve` and open
10
+ http://localhost:8080 to watch the swarm. Workers spawn headless by default, so
11
+ the UI sees them with full structured-event fidelity. `tmux attach` is the opt-in
12
+ escape hatch when you need to steer a single agent live (`ov sling --no-headless`).
13
+
9
14
  ## Quick Reference
10
15
 
11
16
  ```bash
@@ -48,9 +53,12 @@ ov log <event> --agent <name> # Hook-driven event logging
48
53
  3. Assign exclusive file scope so agents do not conflict
49
54
  4. Spawn: `ov sling <bead-id> --capability <type> --name <unique-name> --files src/foo.ts,src/bar.ts`
50
55
 
51
- Each spawned agent gets its own git worktree, branch, CLAUDE.md overlay, and
52
- tmux session. Agents communicate via `ov mail` and report completion
53
- by closing their {{TRACKER_NAME}} issue (`{{TRACKER_CLI}} close <id> --reason "summary"`).
56
+ Each spawned agent gets its own git worktree, branch, and CLAUDE.md overlay.
57
+ Claude agents spawn **headless by default** — the web UI (`ov serve`, then open
58
+ http://localhost:8080) is the primary operator surface. Pass `--no-headless` to
59
+ spawn into a tmux session you can attach to (`tmux attach -t ov-<agent>`).
60
+ Agents communicate via `ov mail` and report completion by closing their
61
+ {{TRACKER_NAME}} issue (`{{TRACKER_CLI}} close <id> --reason "summary"`).
54
62
 
55
63
  ## Hierarchical Delegation
56
64
 
@@ -69,11 +77,14 @@ to track hierarchy.
69
77
 
70
78
  ## Checking Status
71
79
 
72
- Run `ov status` to see:
73
- - Active agents and their states (booting, working, stalled, zombie)
74
- - Worktree locations and branches
75
- - Beads issue progress
76
- - Unread mail count
80
+ The web UI (`ov serve`, http://localhost:8080) is the primary view —
81
+ fleet topology, per-agent timelines, mail inbox, and live events.
82
+
83
+ CLI alternatives for scripting / quick checks:
84
+
85
+ - `ov status` — active agents and states, worktrees, {{TRACKER_NAME}} progress, unread mail
86
+ - `ov dashboard` — live TUI dashboard if you don't want to leave the terminal
87
+ - `ov inspect <agent>` — deep view of one agent
77
88
 
78
89
  ## Canonical Branch
79
90
 
@@ -25,6 +25,8 @@
25
25
 
26
26
  {{DISPATCH_OVERRIDES}}
27
27
 
28
+ {{SIBLINGS}}
29
+
28
30
  ## Working Directory
29
31
 
30
32
  Your worktree root is: `{{WORKTREE_PATH}}`
@@ -68,9 +70,10 @@ ov mail send --to {{PARENT_AGENT}} --subject "status" \
68
70
  ov mail send --to {{PARENT_AGENT}} --subject "question" \
69
71
  --body "Your question here" --type question --priority high --agent {{AGENT_NAME}}
70
72
 
71
- # Report completion
73
+ # Report completion (terminal exit signal — workers send worker_done; merger
74
+ # sends merged/merge_failed; see "Constraints" / "Completion" sections below).
72
75
  ov mail send --to {{PARENT_AGENT}} --subject "done" \
73
- --body "Summary of what was done" --type result --agent {{AGENT_NAME}}
76
+ --body "Summary of what was done" --type worker_done --agent {{AGENT_NAME}}
74
77
 
75
78
  # Reply to a message
76
79
  ov mail reply <message-id> --body "Your reply" --agent {{AGENT_NAME}}