@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Tmux session management for overstory agent workers.
3
+ *
4
+ * All operations use Bun.spawn to call the tmux CLI directly.
5
+ * Session naming convention: `overstory-{projectName}-{agentName}`.
6
+ * The project name prefix prevents cross-project tmux session collisions
7
+ * and enables project-scoped cleanup (overstory-pcef).
8
+ */
9
+
10
+ import { dirname, resolve } from "node:path";
11
+ import { AgentError } from "../errors.ts";
12
+
13
+ /**
14
+ * Detect the directory containing the overstory binary.
15
+ *
16
+ * Checks process.argv[0] first (the bun/node executable path won't help,
17
+ * but process.argv[1] is the script path for `bun run`), then falls back
18
+ * to `which overstory` to find it on the current PATH.
19
+ *
20
+ * Returns null if detection fails.
21
+ */
22
+ async function detectOverstoryBinDir(): Promise<string | null> {
23
+ // process.argv[1] is the script entry point (e.g., /path/to/overstory/src/index.ts)
24
+ // The overstory binary (bun link) resolves to a bin dir
25
+ // Try `which overstory` for the most reliable result
26
+ try {
27
+ const proc = Bun.spawn(["which", "overstory"], {
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+ const exitCode = await proc.exited;
32
+ if (exitCode === 0) {
33
+ const binPath = (await new Response(proc.stdout).text()).trim();
34
+ if (binPath.length > 0) {
35
+ return dirname(resolve(binPath));
36
+ }
37
+ }
38
+ } catch {
39
+ // which not available or overstory not on PATH
40
+ }
41
+
42
+ // Fallback: if process.argv[1] points to overstory's own entry point (src/index.ts),
43
+ // derive the bin dir from the bun binary that's running it
44
+ const scriptPath = process.argv[1];
45
+ if (scriptPath?.includes("overstory")) {
46
+ const bunPath = process.argv[0];
47
+ if (bunPath) {
48
+ return dirname(resolve(bunPath));
49
+ }
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Run a shell command and capture its output.
57
+ */
58
+ async function runCommand(
59
+ cmd: string[],
60
+ cwd?: string,
61
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
62
+ const proc = Bun.spawn(cmd, {
63
+ cwd,
64
+ stdout: "pipe",
65
+ stderr: "pipe",
66
+ });
67
+ const stdout = await new Response(proc.stdout).text();
68
+ const stderr = await new Response(proc.stderr).text();
69
+ const exitCode = await proc.exited;
70
+ return { stdout, stderr, exitCode };
71
+ }
72
+
73
+ /**
74
+ * Create a new detached tmux session running the given command.
75
+ *
76
+ * @param name - Session name (e.g., "overstory-myproject-auth-login")
77
+ * @param cwd - Working directory for the session
78
+ * @param command - Command to execute inside the session
79
+ * @param env - Optional environment variables to export in the session
80
+ * @returns The PID of the tmux server process for this session
81
+ * @throws AgentError if tmux is not installed or session creation fails
82
+ */
83
+ export async function createSession(
84
+ name: string,
85
+ cwd: string,
86
+ command: string,
87
+ env?: Record<string, string>,
88
+ ): Promise<number> {
89
+ // Build environment exports for the tmux session
90
+ const exports: string[] = [];
91
+
92
+ // Ensure PATH includes the overstory binary directory
93
+ // so that hooks calling `overstory` inside the session can find it
94
+ const overstoryBinDir = await detectOverstoryBinDir();
95
+ if (overstoryBinDir) {
96
+ exports.push(`export PATH="${overstoryBinDir}:$PATH"`);
97
+ }
98
+
99
+ // Add any additional environment variables
100
+ if (env) {
101
+ for (const [key, value] of Object.entries(env)) {
102
+ exports.push(`export ${key}="${value}"`);
103
+ }
104
+ }
105
+
106
+ const wrappedCommand = exports.length > 0 ? `${exports.join(" && ")} && ${command}` : command;
107
+
108
+ const { exitCode, stderr } = await runCommand(
109
+ ["tmux", "new-session", "-d", "-s", name, "-c", cwd, wrappedCommand],
110
+ cwd,
111
+ );
112
+
113
+ if (exitCode !== 0) {
114
+ throw new AgentError(`Failed to create tmux session "${name}": ${stderr.trim()}`, {
115
+ agentName: name,
116
+ });
117
+ }
118
+
119
+ // Retrieve the actual PID of the process running inside the tmux pane
120
+ const pidResult = await runCommand(["tmux", "list-panes", "-t", name, "-F", "#{pane_pid}"]);
121
+
122
+ if (pidResult.exitCode !== 0) {
123
+ throw new AgentError(
124
+ `Created tmux session "${name}" but failed to retrieve PID: ${pidResult.stderr.trim()}`,
125
+ { agentName: name },
126
+ );
127
+ }
128
+
129
+ const pidStr = pidResult.stdout.trim().split("\n")[0];
130
+ if (pidStr) {
131
+ const pid = Number.parseInt(pidStr, 10);
132
+ if (!Number.isNaN(pid)) {
133
+ return pid;
134
+ }
135
+ }
136
+
137
+ throw new AgentError(`Created tmux session "${name}" but could not find its pane PID`, {
138
+ agentName: name,
139
+ });
140
+ }
141
+
142
+ /**
143
+ * List all active tmux sessions.
144
+ *
145
+ * @returns Array of session name/pid pairs
146
+ * @throws AgentError if tmux is not installed
147
+ */
148
+ export async function listSessions(): Promise<Array<{ name: string; pid: number }>> {
149
+ const { exitCode, stdout, stderr } = await runCommand([
150
+ "tmux",
151
+ "list-sessions",
152
+ "-F",
153
+ "#{session_name}:#{pid}",
154
+ ]);
155
+
156
+ // Exit code 1 with "no server running" means no sessions exist — not an error
157
+ if (exitCode !== 0) {
158
+ if (stderr.includes("no server running") || stderr.includes("no sessions")) {
159
+ return [];
160
+ }
161
+ throw new AgentError(`Failed to list tmux sessions: ${stderr.trim()}`);
162
+ }
163
+
164
+ const sessions: Array<{ name: string; pid: number }> = [];
165
+ const lines = stdout.trim().split("\n");
166
+
167
+ for (const line of lines) {
168
+ if (line.trim() === "") continue;
169
+ const sepIndex = line.indexOf(":");
170
+ if (sepIndex === -1) continue;
171
+
172
+ const name = line.slice(0, sepIndex);
173
+ const pidStr = line.slice(sepIndex + 1);
174
+ if (name && pidStr) {
175
+ const pid = Number.parseInt(pidStr, 10);
176
+ if (!Number.isNaN(pid)) {
177
+ sessions.push({ name, pid });
178
+ }
179
+ }
180
+ }
181
+
182
+ return sessions;
183
+ }
184
+
185
+ /**
186
+ * Grace period (ms) between SIGTERM and SIGKILL during process cleanup.
187
+ */
188
+ const KILL_GRACE_PERIOD_MS = 2000;
189
+
190
+ /**
191
+ * Get the pane PID for a tmux session.
192
+ *
193
+ * @param name - Tmux session name
194
+ * @returns The PID of the process running in the session's pane, or null if
195
+ * the session doesn't exist or the PID can't be determined
196
+ */
197
+ export async function getPanePid(name: string): Promise<number | null> {
198
+ const { exitCode, stdout } = await runCommand([
199
+ "tmux",
200
+ "display-message",
201
+ "-p",
202
+ "-t",
203
+ name,
204
+ "#{pane_pid}",
205
+ ]);
206
+
207
+ if (exitCode !== 0) {
208
+ return null;
209
+ }
210
+
211
+ const pidStr = stdout.trim();
212
+ if (pidStr.length === 0) {
213
+ return null;
214
+ }
215
+
216
+ const pid = Number.parseInt(pidStr, 10);
217
+ return Number.isNaN(pid) ? null : pid;
218
+ }
219
+
220
+ /**
221
+ * Recursively collect all descendant PIDs of a given process.
222
+ *
223
+ * Uses `pgrep -P <pid>` to find direct children, then recurses into each child.
224
+ * Returns PIDs in depth-first order (deepest descendants first), which is the
225
+ * correct order for sending signals — kill children before parents so processes
226
+ * don't get reparented to init (PID 1).
227
+ *
228
+ * @param pid - The root process PID to walk from
229
+ * @returns Array of descendant PIDs, deepest-first
230
+ */
231
+ export async function getDescendantPids(pid: number): Promise<number[]> {
232
+ const { exitCode, stdout } = await runCommand(["pgrep", "-P", String(pid)]);
233
+
234
+ // pgrep exits 1 when no children found — not an error
235
+ if (exitCode !== 0 || stdout.trim().length === 0) {
236
+ return [];
237
+ }
238
+
239
+ const childPids: number[] = [];
240
+ for (const line of stdout.trim().split("\n")) {
241
+ const childPid = Number.parseInt(line.trim(), 10);
242
+ if (!Number.isNaN(childPid)) {
243
+ childPids.push(childPid);
244
+ }
245
+ }
246
+
247
+ // Recurse into each child to get their descendants first (depth-first)
248
+ const allDescendants: number[] = [];
249
+ for (const childPid of childPids) {
250
+ const grandchildren = await getDescendantPids(childPid);
251
+ allDescendants.push(...grandchildren);
252
+ }
253
+
254
+ // Append the direct children after their descendants (deepest-first order)
255
+ allDescendants.push(...childPids);
256
+
257
+ return allDescendants;
258
+ }
259
+
260
+ /**
261
+ * Check if a process is still alive.
262
+ *
263
+ * @param pid - Process ID to check
264
+ * @returns true if the process exists, false otherwise
265
+ */
266
+ export function isProcessAlive(pid: number): boolean {
267
+ try {
268
+ // signal 0 doesn't send a signal but checks if the process exists
269
+ process.kill(pid, 0);
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Kill a process tree: SIGTERM deepest-first, wait grace period, SIGKILL survivors.
278
+ *
279
+ * Follows gastown's KillSessionWithProcesses pattern:
280
+ * 1. Walk descendant tree from the root PID
281
+ * 2. Send SIGTERM to all descendants (deepest-first so children die before parents)
282
+ * 3. Wait a grace period for processes to clean up
283
+ * 4. Send SIGKILL to any survivors
284
+ *
285
+ * Handles edge cases:
286
+ * - Already-dead processes (ESRCH) — silently ignored
287
+ * - Reparented processes (PPID=1) — caught in the initial tree walk
288
+ * - Permission errors — silently ignored (process belongs to another user)
289
+ *
290
+ * @param rootPid - The root PID whose descendants should be killed
291
+ * @param gracePeriodMs - Time to wait between SIGTERM and SIGKILL (default 2000ms)
292
+ */
293
+ export async function killProcessTree(
294
+ rootPid: number,
295
+ gracePeriodMs: number = KILL_GRACE_PERIOD_MS,
296
+ ): Promise<void> {
297
+ const descendants = await getDescendantPids(rootPid);
298
+
299
+ if (descendants.length === 0) {
300
+ // No descendants — just try to kill the root process
301
+ sendSignal(rootPid, "SIGTERM");
302
+ return;
303
+ }
304
+
305
+ // Phase 1: SIGTERM all descendants (deepest-first, then root)
306
+ for (const pid of descendants) {
307
+ sendSignal(pid, "SIGTERM");
308
+ }
309
+ sendSignal(rootPid, "SIGTERM");
310
+
311
+ // Phase 2: Wait grace period for processes to clean up
312
+ await Bun.sleep(gracePeriodMs);
313
+
314
+ // Phase 3: SIGKILL any survivors (same order: deepest-first, then root)
315
+ for (const pid of descendants) {
316
+ if (isProcessAlive(pid)) {
317
+ sendSignal(pid, "SIGKILL");
318
+ }
319
+ }
320
+ if (isProcessAlive(rootPid)) {
321
+ sendSignal(rootPid, "SIGKILL");
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Send a signal to a process, ignoring errors for already-dead or inaccessible processes.
327
+ *
328
+ * @param pid - Process ID to signal
329
+ * @param signal - Signal name (e.g., "SIGTERM", "SIGKILL")
330
+ */
331
+ function sendSignal(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
332
+ try {
333
+ process.kill(pid, signal);
334
+ } catch {
335
+ // Process already dead (ESRCH), permission denied (EPERM), or invalid PID — all OK
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Kill a tmux session by name, with proper process tree cleanup.
341
+ *
342
+ * Before killing the tmux session, walks the descendant process tree from the
343
+ * pane PID, sends SIGTERM to all descendants (deepest-first), waits a grace
344
+ * period, then sends SIGKILL to survivors. This ensures child processes
345
+ * (git, bun test, biome, etc.) are properly cleaned up rather than being
346
+ * orphaned or reparented to init.
347
+ *
348
+ * @param name - Session name to kill
349
+ * @throws AgentError if the tmux session cannot be killed (process cleanup
350
+ * failures are silently handled since the goal is best-effort cleanup)
351
+ */
352
+ export async function killSession(name: string): Promise<void> {
353
+ // Step 1: Get the pane PID before killing the tmux session
354
+ const panePid = await getPanePid(name);
355
+
356
+ // Step 2: If we have a pane PID, walk and kill the process tree
357
+ if (panePid !== null) {
358
+ await killProcessTree(panePid);
359
+ }
360
+
361
+ // Step 3: Kill the tmux session itself
362
+ const { exitCode, stderr } = await runCommand(["tmux", "kill-session", "-t", name]);
363
+
364
+ if (exitCode !== 0) {
365
+ // If the session is already gone (e.g., died during process cleanup), that's fine
366
+ if (stderr.includes("session not found") || stderr.includes("can't find session")) {
367
+ return;
368
+ }
369
+ throw new AgentError(`Failed to kill tmux session "${name}": ${stderr.trim()}`, {
370
+ agentName: name,
371
+ });
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Detect the current tmux session name.
377
+ *
378
+ * Returns the session name if running inside tmux, null otherwise.
379
+ * Used by `overstory prime` to register the orchestrator's tmux session
380
+ * so agents can nudge the orchestrator when they have results.
381
+ */
382
+ export async function getCurrentSessionName(): Promise<string | null> {
383
+ if (!process.env.TMUX) {
384
+ return null;
385
+ }
386
+ const { exitCode, stdout } = await runCommand([
387
+ "tmux",
388
+ "display-message",
389
+ "-p",
390
+ "#{session_name}",
391
+ ]);
392
+ if (exitCode !== 0) {
393
+ return null;
394
+ }
395
+ const name = stdout.trim();
396
+ return name.length > 0 ? name : null;
397
+ }
398
+
399
+ /**
400
+ * Check whether a tmux session is still alive.
401
+ *
402
+ * @param name - Session name to check
403
+ * @returns true if the session exists, false otherwise
404
+ */
405
+ export async function isSessionAlive(name: string): Promise<boolean> {
406
+ const { exitCode } = await runCommand(["tmux", "has-session", "-t", name]);
407
+ return exitCode === 0;
408
+ }
409
+
410
+ /**
411
+ * Capture the visible content of a tmux session's pane.
412
+ *
413
+ * @param name - Session name to capture from
414
+ * @param lines - Number of history lines to capture (default 50)
415
+ * @returns The trimmed pane content, or null if capture fails
416
+ */
417
+ export async function capturePaneContent(name: string, lines = 50): Promise<string | null> {
418
+ const { exitCode, stdout } = await runCommand([
419
+ "tmux",
420
+ "capture-pane",
421
+ "-t",
422
+ name,
423
+ "-p",
424
+ "-S",
425
+ `-${lines}`,
426
+ ]);
427
+ if (exitCode !== 0) {
428
+ return null;
429
+ }
430
+ const content = stdout.trim();
431
+ return content.length > 0 ? content : null;
432
+ }
433
+
434
+ /**
435
+ * Wait for a tmux session's TUI to become ready for input.
436
+ *
437
+ * Polls capture-pane until non-empty content appears, indicating the
438
+ * process has started and rendered output. More reliable than a fixed
439
+ * sleep because TUI init time varies by machine load, model download
440
+ * state, and extension loading.
441
+ *
442
+ * @param name - Tmux session name to poll
443
+ * @param timeoutMs - Maximum time to wait before giving up (default 15s)
444
+ * @param pollIntervalMs - Time between polls (default 500ms)
445
+ * @returns true once content is detected, false on timeout
446
+ */
447
+ export async function waitForTuiReady(
448
+ name: string,
449
+ timeoutMs = 15_000,
450
+ pollIntervalMs = 500,
451
+ ): Promise<boolean> {
452
+ const maxAttempts = Math.ceil(timeoutMs / pollIntervalMs);
453
+ for (let i = 0; i < maxAttempts; i++) {
454
+ const content = await capturePaneContent(name);
455
+ if (content !== null) {
456
+ return true;
457
+ }
458
+ await Bun.sleep(pollIntervalMs);
459
+ }
460
+ return false;
461
+ }
462
+
463
+ /**
464
+ * Send keys to a tmux session.
465
+ *
466
+ * @param name - Session name to send keys to
467
+ * @param keys - The keys/text to send
468
+ * @throws AgentError if the session does not exist or send fails
469
+ */
470
+ export async function sendKeys(name: string, keys: string): Promise<void> {
471
+ // Flatten newlines to spaces — multiline text via tmux send-keys causes
472
+ // Claude Code's TUI to receive embedded Enter keystrokes which prevent
473
+ // the final "Enter" from triggering message submission (overstory-y2ob).
474
+ const flatKeys = keys.replace(/\n/g, " ");
475
+ const { exitCode, stderr } = await runCommand([
476
+ "tmux",
477
+ "send-keys",
478
+ "-t",
479
+ name,
480
+ flatKeys,
481
+ "Enter",
482
+ ]);
483
+
484
+ if (exitCode !== 0) {
485
+ const trimmedStderr = stderr.trim();
486
+
487
+ if (trimmedStderr.includes("no server running")) {
488
+ throw new AgentError(
489
+ `Tmux server is not running (cannot reach session "${name}"). This often happens when running as root (UID 0) or when tmux crashed. Original error: ${trimmedStderr}`,
490
+ { agentName: name },
491
+ );
492
+ }
493
+
494
+ if (
495
+ trimmedStderr.includes("session not found") ||
496
+ trimmedStderr.includes("can't find session") ||
497
+ trimmedStderr.includes("cant find session")
498
+ ) {
499
+ throw new AgentError(
500
+ `Tmux session "${name}" does not exist. The agent may have crashed or been killed before receiving input.`,
501
+ { agentName: name },
502
+ );
503
+ }
504
+
505
+ throw new AgentError(`Failed to send keys to tmux session "${name}": ${trimmedStderr}`, {
506
+ agentName: name,
507
+ });
508
+ }
509
+ }
@@ -0,0 +1,89 @@
1
+ # {{PROJECT_NAME}} — Overstory Orchestration
2
+
3
+ > Auto-generated by `overstory init`. You may edit this file.
4
+
5
+ This project uses **overstory** for Claude Code agent orchestration. Your session
6
+ acts as the orchestrator: you decide what work to delegate, spawn worker agents,
7
+ monitor progress, and merge results.
8
+
9
+ ## Quick Reference
10
+
11
+ ```bash
12
+ # Spawn a worker agent
13
+ overstory sling <bead-id> --capability <type> --name <agent-name> \
14
+ [--spec <path>] [--files file1,file2] [--parent <agent>] [--depth <n>]
15
+
16
+ # Check system status
17
+ overstory status # Overview of all agents, worktrees, {{TRACKER_NAME}}
18
+ overstory status --json # Machine-readable output
19
+ overstory status --watch # Live updating
20
+
21
+ # Messaging (SQLite-backed, ~1-5ms per query)
22
+ overstory mail send --to <agent> --subject "..." --body "..."
23
+ overstory mail check # Your inbox
24
+ overstory mail list --unread # All unread messages
25
+ overstory mail reply <id> --body "..."
26
+
27
+ # Merge completed work
28
+ overstory merge --branch <name> # Merge a specific branch
29
+ overstory merge --all # Merge all completed branches
30
+ overstory merge --dry-run --branch <name> # Preview conflicts
31
+
32
+ # Worktree management
33
+ overstory worktree list # Show all worktrees with status
34
+ overstory worktree clean --completed # Remove finished worktrees
35
+
36
+ # Context and monitoring
37
+ overstory prime # Reload context (config, mulch, recent activity)
38
+ overstory watch --background # Start watchdog daemon
39
+ overstory metrics # Performance summary
40
+ overstory log <event> --agent <name> # Hook-driven event logging
41
+ ```
42
+
43
+ ## How to Spawn Agents
44
+
45
+ 1. Identify the work using {{TRACKER_NAME}}: `{{TRACKER_CLI}} ready` or `{{TRACKER_CLI}} create "task title"`
46
+ 2. Choose a capability based on the task:
47
+ {{AGENT_DEFINITIONS}}
48
+ 3. Assign exclusive file scope so agents do not conflict
49
+ 4. Spawn: `overstory sling <bead-id> --capability <type> --name <unique-name> --files src/foo.ts,src/bar.ts`
50
+
51
+ Each spawned agent gets its own git worktree, branch, CLAUDE.md overlay, and
52
+ tmux session. Agents communicate via `overstory mail` and report completion
53
+ by closing their {{TRACKER_NAME}} issue (`{{TRACKER_CLI}} close <id> --reason "summary"`).
54
+
55
+ ## Hierarchical Delegation
56
+
57
+ You can spawn **team leads** that themselves spawn sub-workers:
58
+
59
+ ```
60
+ Orchestrator (this session)
61
+ └── overstory sling bd-xyz --capability lead --name build-lead
62
+ ├── overstory sling bd-abc --capability builder --name auth-login
63
+ ├── overstory sling bd-def --capability builder --name auth-signup
64
+ └── overstory sling bd-ghi --capability builder --name auth-reset
65
+ ```
66
+
67
+ Depth limit is configurable (default: 2). Leads use `--parent` and `--depth`
68
+ to track hierarchy.
69
+
70
+ ## Checking Status
71
+
72
+ Run `overstory 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
77
+
78
+ ## Canonical Branch
79
+
80
+ All merges target **{{CANONICAL_BRANCH}}**. Agents work on branches named
81
+ `overstory/<agent-name>/<bead-id>`. Never push directly to {{CANONICAL_BRANCH}}.
82
+
83
+ ## Conventions
84
+
85
+ - Agents own files exclusively — no two agents modify the same file
86
+ - Use `overstory mail` for all inter-agent communication (not {{TRACKER_NAME}})
87
+ - Use `{{TRACKER_CLI}} close` to report task completion (not mail)
88
+ - Merge via `overstory merge`, not raw `git merge`
89
+ - Logs live in `.overstory/logs/` — never delete them manually
@@ -0,0 +1,105 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory prime --agent {{AGENT_NAME}}"
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "UserPromptSubmit": [
15
+ {
16
+ "matcher": "",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory mail check --inject --agent {{AGENT_NAME}}"
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "PreToolUse": [
26
+ {
27
+ "matcher": "",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory log tool-start --agent {{AGENT_NAME}} --stdin"
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "matcher": "Bash",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "read -r INPUT; CMD=$(echo \"$INPUT\" | sed 's/.*\"command\": *\"\\([^\"]*\\)\".*/\\1/'); if echo \"$CMD\" | grep -qE '\\bgit\\s+push\\b'; then echo '{\"decision\":\"block\",\"reason\":\"git push is blocked — use overstory merge to integrate changes, push manually when ready\"}'; exit 0; fi;"
41
+ }
42
+ ]
43
+ }
44
+ ],
45
+ "PostToolUse": [
46
+ {
47
+ "matcher": "",
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory log tool-end --agent {{AGENT_NAME}} --stdin"
52
+ },
53
+ {
54
+ "type": "command",
55
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory mail check --inject --agent {{AGENT_NAME}} --debounce 500"
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ "matcher": "",
61
+ "hooks": [
62
+ {
63
+ "type": "command",
64
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory mail check --inject --agent {{AGENT_NAME}} --debounce 30000"
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "matcher": "Bash",
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; read -r INPUT; if echo \"$INPUT\" | grep -qE '\\bgit\\s+commit\\b'; then mulch diff HEAD~1 >/dev/null 2>&1 || true; fi; exit 0;"
74
+ }
75
+ ]
76
+ }
77
+ ],
78
+ "Stop": [
79
+ {
80
+ "matcher": "",
81
+ "hooks": [
82
+ {
83
+ "type": "command",
84
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory log session-end --agent {{AGENT_NAME}} --stdin"
85
+ },
86
+ {
87
+ "type": "command",
88
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; mulch learn"
89
+ }
90
+ ]
91
+ }
92
+ ],
93
+ "PreCompact": [
94
+ {
95
+ "matcher": "",
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "[ -z \"$OVERSTORY_AGENT_NAME\" ] && exit 0; overstory prime --agent {{AGENT_NAME}} --compact"
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ }
105
+ }