@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|