@katyella/legio 0.1.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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Tmux session management for legio agent workers.
3
+ *
4
+ * All operations use child_process.spawn to call the tmux CLI directly.
5
+ * Session naming convention: `legio-{projectName}-{agentName}`.
6
+ * The project name prefix prevents cross-project tmux session collisions
7
+ * and enables project-scoped cleanup (legio-pcef).
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { mkdir, readFile, stat } from "node:fs/promises";
12
+ import { dirname, resolve } from "node:path";
13
+ import { setTimeout } from "node:timers/promises";
14
+ import { AgentError } from "../errors.ts";
15
+
16
+ /**
17
+ * Run a shell command and capture its output.
18
+ */
19
+ async function runCommand(
20
+ cmd: string[],
21
+ cwd?: string,
22
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
23
+ const [command, ...args] = cmd;
24
+ if (!command) throw new Error("Empty command");
25
+ return new Promise(
26
+ (resolve: (value: { stdout: string; stderr: string; exitCode: number }) => void, reject) => {
27
+ const proc = spawn(command, args, {
28
+ cwd,
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ });
31
+ const chunks: { stdout: Buffer[]; stderr: Buffer[] } = { stdout: [], stderr: [] };
32
+ proc.stdout.on("data", (data: Buffer) => chunks.stdout.push(data));
33
+ proc.stderr.on("data", (data: Buffer) => chunks.stderr.push(data));
34
+ proc.on("error", reject);
35
+ proc.on("close", (code) => {
36
+ resolve({
37
+ stdout: Buffer.concat(chunks.stdout).toString(),
38
+ stderr: Buffer.concat(chunks.stderr).toString(),
39
+ exitCode: code ?? 1,
40
+ });
41
+ });
42
+ },
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Detect the directory containing the legio binary.
48
+ *
49
+ * Checks process.argv[0] first (the node executable path won't help,
50
+ * but process.argv[1] is the script path for `npm run`), then falls back
51
+ * to `which legio` to find it on the current PATH.
52
+ *
53
+ * Returns null if detection fails.
54
+ */
55
+ async function detectLegioBinDir(): Promise<string | null> {
56
+ // process.argv[1] is the script entry point (e.g., /path/to/legio/src/index.ts)
57
+ // The legio binary resolves to a bin dir
58
+ // Try `which legio` for the most reliable result
59
+ try {
60
+ const result = await runCommand(["which", "legio"]);
61
+ if (result.exitCode === 0) {
62
+ const binPath = result.stdout.trim();
63
+ if (binPath.length > 0) {
64
+ return dirname(resolve(binPath));
65
+ }
66
+ }
67
+ } catch {
68
+ // which not available or legio not on PATH
69
+ }
70
+
71
+ // Fallback: if process.argv[1] points to legio's own entry point (src/index.ts),
72
+ // derive the bin dir from the node binary that's running it
73
+ const scriptPath = process.argv[1];
74
+ if (scriptPath?.includes("legio")) {
75
+ const nodePath = process.argv[0];
76
+ if (nodePath) {
77
+ return dirname(resolve(nodePath));
78
+ }
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Create a new detached tmux session running the given command.
86
+ *
87
+ * @param name - Session name (e.g., "legio-myproject-auth-login")
88
+ * @param cwd - Working directory for the session
89
+ * @param command - Command to execute inside the session
90
+ * @param env - Optional environment variables to export in the session
91
+ * @returns The PID of the tmux server process for this session
92
+ * @throws AgentError if tmux is not installed or session creation fails
93
+ */
94
+ export async function createSession(
95
+ name: string,
96
+ cwd: string,
97
+ command: string,
98
+ env?: Record<string, string>,
99
+ ): Promise<number> {
100
+ // Clear Claude Code nesting detection so child Claude Code instances
101
+ // don't refuse to start with "cannot be launched inside another session".
102
+ // This MUST be part of the shell command (not tmux -e) because we need
103
+ // to *unset* vars inherited from the parent process environment.
104
+ const shellPrefix = "unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT";
105
+ const wrappedCommand = `${shellPrefix} && ${command}`;
106
+
107
+ // Build tmux args. Environment variables are passed via `-e KEY=VALUE`
108
+ // flags (tmux 3.2+) instead of shell `export` commands to avoid
109
+ // ERR_STREAM_DESTROYED in Claude Code v2.1.50, which is triggered when
110
+ // shell variable expansion (e.g., $PATH) produces very long command
111
+ // strings that corrupt the internal TUI stream.
112
+ const tmuxArgs = ["new-session", "-d", "-s", name, "-c", cwd];
113
+
114
+ // Ensure PATH includes the legio binary directory so hooks can find `legio`
115
+ const legioBinDir = await detectLegioBinDir();
116
+ if (legioBinDir) {
117
+ const currentPath = process.env.PATH ?? "";
118
+ tmuxArgs.push("-e", `PATH=${legioBinDir}:${currentPath}`);
119
+ }
120
+
121
+ // Pass additional environment variables
122
+ if (env) {
123
+ for (const [key, value] of Object.entries(env)) {
124
+ tmuxArgs.push("-e", `${key}=${value}`);
125
+ }
126
+ }
127
+
128
+ tmuxArgs.push(wrappedCommand);
129
+
130
+ const { exitCode, stderr } = await runCommand(["tmux", ...tmuxArgs], cwd);
131
+
132
+ if (exitCode !== 0) {
133
+ throw new AgentError(`Failed to create tmux session "${name}": ${stderr.trim()}`, {
134
+ agentName: name,
135
+ });
136
+ }
137
+
138
+ // Retrieve the actual PID of the process running inside the tmux pane
139
+ const pidResult = await runCommand(["tmux", "list-panes", "-t", name, "-F", "#{pane_pid}"]);
140
+
141
+ if (pidResult.exitCode !== 0) {
142
+ throw new AgentError(
143
+ `Created tmux session "${name}" but failed to retrieve PID: ${pidResult.stderr.trim()}`,
144
+ { agentName: name },
145
+ );
146
+ }
147
+
148
+ const pidStr = pidResult.stdout.trim().split("\n")[0];
149
+ if (pidStr) {
150
+ const pid = Number.parseInt(pidStr, 10);
151
+ if (!Number.isNaN(pid)) {
152
+ return pid;
153
+ }
154
+ }
155
+
156
+ throw new AgentError(`Created tmux session "${name}" but could not find its pane PID`, {
157
+ agentName: name,
158
+ });
159
+ }
160
+
161
+ /**
162
+ * List all active tmux sessions.
163
+ *
164
+ * @returns Array of session name/pid pairs
165
+ * @throws AgentError if tmux is not installed
166
+ */
167
+ export async function listSessions(): Promise<Array<{ name: string; pid: number }>> {
168
+ const { exitCode, stdout, stderr } = await runCommand([
169
+ "tmux",
170
+ "list-sessions",
171
+ "-F",
172
+ "#{session_name}:#{pid}",
173
+ ]);
174
+
175
+ // Exit code 1 with "no server running" means no sessions exist — not an error
176
+ if (exitCode !== 0) {
177
+ if (stderr.includes("no server running") || stderr.includes("no sessions")) {
178
+ return [];
179
+ }
180
+ throw new AgentError(`Failed to list tmux sessions: ${stderr.trim()}`);
181
+ }
182
+
183
+ const sessions: Array<{ name: string; pid: number }> = [];
184
+ const lines = stdout.trim().split("\n");
185
+
186
+ for (const line of lines) {
187
+ if (line.trim() === "") continue;
188
+ const sepIndex = line.indexOf(":");
189
+ if (sepIndex === -1) continue;
190
+
191
+ const name = line.slice(0, sepIndex);
192
+ const pidStr = line.slice(sepIndex + 1);
193
+ if (name && pidStr) {
194
+ const pid = Number.parseInt(pidStr, 10);
195
+ if (!Number.isNaN(pid)) {
196
+ sessions.push({ name, pid });
197
+ }
198
+ }
199
+ }
200
+
201
+ return sessions;
202
+ }
203
+
204
+ /**
205
+ * Grace period (ms) between SIGTERM and SIGKILL during process cleanup.
206
+ */
207
+ const KILL_GRACE_PERIOD_MS = 2000;
208
+
209
+ /**
210
+ * Get the pane PID for a tmux session.
211
+ *
212
+ * @param name - Tmux session name
213
+ * @returns The PID of the process running in the session's pane, or null if
214
+ * the session doesn't exist or the PID can't be determined
215
+ */
216
+ export async function getPanePid(name: string): Promise<number | null> {
217
+ const { exitCode, stdout } = await runCommand([
218
+ "tmux",
219
+ "display-message",
220
+ "-p",
221
+ "-t",
222
+ name,
223
+ "#{pane_pid}",
224
+ ]);
225
+
226
+ if (exitCode !== 0) {
227
+ return null;
228
+ }
229
+
230
+ const pidStr = stdout.trim();
231
+ if (pidStr.length === 0) {
232
+ return null;
233
+ }
234
+
235
+ const pid = Number.parseInt(pidStr, 10);
236
+ return Number.isNaN(pid) ? null : pid;
237
+ }
238
+
239
+ /**
240
+ * Recursively collect all descendant PIDs of a given process.
241
+ *
242
+ * Uses `pgrep -P <pid>` to find direct children, then recurses into each child.
243
+ * Returns PIDs in depth-first order (deepest descendants first), which is the
244
+ * correct order for sending signals — kill children before parents so processes
245
+ * don't get reparented to init (PID 1).
246
+ *
247
+ * @param pid - The root process PID to walk from
248
+ * @returns Array of descendant PIDs, deepest-first
249
+ */
250
+ export async function getDescendantPids(pid: number): Promise<number[]> {
251
+ const { exitCode, stdout } = await runCommand(["pgrep", "-P", String(pid)]);
252
+
253
+ // pgrep exits 1 when no children found — not an error
254
+ if (exitCode !== 0 || stdout.trim().length === 0) {
255
+ return [];
256
+ }
257
+
258
+ const childPids: number[] = [];
259
+ for (const line of stdout.trim().split("\n")) {
260
+ const childPid = Number.parseInt(line.trim(), 10);
261
+ if (!Number.isNaN(childPid)) {
262
+ childPids.push(childPid);
263
+ }
264
+ }
265
+
266
+ // Recurse into each child to get their descendants first (depth-first)
267
+ const allDescendants: number[] = [];
268
+ for (const childPid of childPids) {
269
+ const grandchildren = await getDescendantPids(childPid);
270
+ allDescendants.push(...grandchildren);
271
+ }
272
+
273
+ // Append the direct children after their descendants (deepest-first order)
274
+ allDescendants.push(...childPids);
275
+
276
+ return allDescendants;
277
+ }
278
+
279
+ /**
280
+ * Check if a process is still alive.
281
+ *
282
+ * @param pid - Process ID to check
283
+ * @returns true if the process exists, false otherwise
284
+ */
285
+ export function isProcessAlive(pid: number): boolean {
286
+ try {
287
+ // signal 0 doesn't send a signal but checks if the process exists
288
+ process.kill(pid, 0);
289
+ return true;
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Kill a process tree: SIGTERM deepest-first, wait grace period, SIGKILL survivors.
297
+ *
298
+ * Follows gastown's KillSessionWithProcesses pattern:
299
+ * 1. Walk descendant tree from the root PID
300
+ * 2. Send SIGTERM to all descendants (deepest-first so children die before parents)
301
+ * 3. Wait a grace period for processes to clean up
302
+ * 4. Send SIGKILL to any survivors
303
+ *
304
+ * Handles edge cases:
305
+ * - Already-dead processes (ESRCH) — silently ignored
306
+ * - Reparented processes (PPID=1) — caught in the initial tree walk
307
+ * - Permission errors — silently ignored (process belongs to another user)
308
+ *
309
+ * @param rootPid - The root PID whose descendants should be killed
310
+ * @param gracePeriodMs - Time to wait between SIGTERM and SIGKILL (default 2000ms)
311
+ */
312
+ export async function killProcessTree(
313
+ rootPid: number,
314
+ gracePeriodMs: number = KILL_GRACE_PERIOD_MS,
315
+ ): Promise<void> {
316
+ const descendants = await getDescendantPids(rootPid);
317
+
318
+ if (descendants.length === 0) {
319
+ // No descendants — just try to kill the root process
320
+ sendSignal(rootPid, "SIGTERM");
321
+ return;
322
+ }
323
+
324
+ // Phase 1: SIGTERM all descendants (deepest-first, then root)
325
+ for (const pid of descendants) {
326
+ sendSignal(pid, "SIGTERM");
327
+ }
328
+ sendSignal(rootPid, "SIGTERM");
329
+
330
+ // Phase 2: Wait grace period for processes to clean up
331
+ await setTimeout(gracePeriodMs);
332
+
333
+ // Phase 3: SIGKILL any survivors (same order: deepest-first, then root)
334
+ for (const pid of descendants) {
335
+ if (isProcessAlive(pid)) {
336
+ sendSignal(pid, "SIGKILL");
337
+ }
338
+ }
339
+ if (isProcessAlive(rootPid)) {
340
+ sendSignal(rootPid, "SIGKILL");
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Send a signal to a process, ignoring errors for already-dead or inaccessible processes.
346
+ *
347
+ * @param pid - Process ID to signal
348
+ * @param signal - Signal name (e.g., "SIGTERM", "SIGKILL")
349
+ */
350
+ function sendSignal(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
351
+ try {
352
+ process.kill(pid, signal);
353
+ } catch {
354
+ // Process already dead (ESRCH), permission denied (EPERM), or invalid PID — all OK
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Kill a tmux session by name, with proper process tree cleanup.
360
+ *
361
+ * Before killing the tmux session, walks the descendant process tree from the
362
+ * pane PID, sends SIGTERM to all descendants (deepest-first), waits a grace
363
+ * period, then sends SIGKILL to survivors. This ensures child processes
364
+ * (git, npm test, biome, etc.) are properly cleaned up rather than being
365
+ * orphaned or reparented to init.
366
+ *
367
+ * @param name - Session name to kill
368
+ * @throws AgentError if the tmux session cannot be killed (process cleanup
369
+ * failures are silently handled since the goal is best-effort cleanup)
370
+ */
371
+ export async function killSession(name: string): Promise<void> {
372
+ // Step 1: Get the pane PID before killing the tmux session
373
+ const panePid = await getPanePid(name);
374
+
375
+ // Step 2: If we have a pane PID, walk and kill the process tree
376
+ if (panePid !== null) {
377
+ await killProcessTree(panePid);
378
+ }
379
+
380
+ // Step 2b: Stop pipe-pane streaming (best-effort, ignore errors)
381
+ await stopPipePane(name);
382
+
383
+ // Step 3: Kill the tmux session itself
384
+ const { exitCode, stderr } = await runCommand(["tmux", "kill-session", "-t", name]);
385
+
386
+ if (exitCode !== 0) {
387
+ // If the session is already gone (e.g., died during process cleanup), that's fine
388
+ if (stderr.includes("session not found") || stderr.includes("can't find session")) {
389
+ return;
390
+ }
391
+ throw new AgentError(`Failed to kill tmux session "${name}": ${stderr.trim()}`, {
392
+ agentName: name,
393
+ });
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Detect the current tmux session name.
399
+ *
400
+ * Returns the session name if running inside tmux, null otherwise.
401
+ * Used by `legio prime` to register the orchestrator's tmux session
402
+ * so agents can nudge the orchestrator when they have results.
403
+ */
404
+ export async function getCurrentSessionName(): Promise<string | null> {
405
+ if (!process.env.TMUX) {
406
+ return null;
407
+ }
408
+ const { exitCode, stdout } = await runCommand([
409
+ "tmux",
410
+ "display-message",
411
+ "-p",
412
+ "#{session_name}",
413
+ ]);
414
+ if (exitCode !== 0) {
415
+ return null;
416
+ }
417
+ const name = stdout.trim();
418
+ return name.length > 0 ? name : null;
419
+ }
420
+
421
+ /**
422
+ * Check whether a tmux session is still alive.
423
+ *
424
+ * @param name - Session name to check
425
+ * @returns true if the session exists, false otherwise
426
+ */
427
+ export async function isSessionAlive(name: string): Promise<boolean> {
428
+ const { exitCode } = await runCommand(["tmux", "has-session", "-t", name]);
429
+ return exitCode === 0;
430
+ }
431
+
432
+ /**
433
+ * Throw a typed AgentError for send-keys failures, differentiating known tmux errors.
434
+ */
435
+ function throwSendKeysError(name: string, stderr: string): never {
436
+ if (stderr.includes("no server running")) {
437
+ throw new AgentError(`Tmux server not running — cannot send keys to session "${name}"`, {
438
+ agentName: name,
439
+ });
440
+ }
441
+ if (stderr.includes("session not found") || stderr.includes("can't find session")) {
442
+ throw new AgentError(`Session "${name}" not found — cannot send keys`, { agentName: name });
443
+ }
444
+ throw new AgentError(`Failed to send keys to tmux session "${name}": ${stderr.trim()}`, {
445
+ agentName: name,
446
+ });
447
+ }
448
+
449
+ /**
450
+ * Send keys to a tmux session.
451
+ *
452
+ * Uses two separate tmux calls: first sends text with the `-l` (literal) flag
453
+ * so tmux treats the content as literal characters rather than key names, then
454
+ * sends Enter separately to trigger submission. This prevents tmux from
455
+ * interpreting key names embedded in the text (e.g. "Enter", "Escape") and
456
+ * ensures the TUI has received the full text before the submit signal arrives.
457
+ *
458
+ * When `keys` is empty (follow-up submission), skips the text step and only
459
+ * sends Enter.
460
+ *
461
+ * @param name - Session name to send keys to
462
+ * @param keys - The keys/text to send
463
+ * @throws AgentError if the session does not exist or send fails
464
+ */
465
+ export async function sendKeys(name: string, keys: string): Promise<void> {
466
+ // Flatten newlines to spaces — multiline text via tmux send-keys causes
467
+ // Claude Code's TUI to receive embedded Enter keystrokes which prevent
468
+ // the final "Enter" from triggering message submission (legio-y2ob).
469
+ const flatKeys = keys.replace(/\n/g, " ");
470
+
471
+ // Step 1: Send text with -l (literal) flag so tmux treats it as literal
472
+ // characters, not key names. Skip this step for empty strings (follow-up
473
+ // submissions that only need Enter).
474
+ if (flatKeys.length > 0) {
475
+ const textResult = await runCommand(["tmux", "send-keys", "-t", name, "-l", flatKeys]);
476
+ if (textResult.exitCode !== 0) {
477
+ throwSendKeysError(name, textResult.stderr);
478
+ }
479
+ }
480
+
481
+ // Step 2: Send Enter separately to trigger submission.
482
+ const enterResult = await runCommand(["tmux", "send-keys", "-t", name, "Enter"]);
483
+ if (enterResult.exitCode !== 0) {
484
+ throwSendKeysError(name, enterResult.stderr);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Capture the current visible content of a tmux pane.
490
+ *
491
+ * @param name - Session name to capture from
492
+ * @returns The current pane content, or empty string if the session does not exist or capture fails
493
+ */
494
+ export async function capturePaneContent(name: string): Promise<string> {
495
+ const { exitCode, stdout } = await runCommand(["tmux", "capture-pane", "-t", name, "-p"]);
496
+ if (exitCode !== 0) {
497
+ return "";
498
+ }
499
+ return stdout;
500
+ }
501
+
502
+ /**
503
+ * Start continuous terminal output streaming for a tmux session via pipe-pane.
504
+ *
505
+ * Runs `tmux pipe-pane -t sessionName 'cat >> logPath'` to append all pane
506
+ * output to a log file. Creates the log directory if it does not exist.
507
+ *
508
+ * @param sessionName - Tmux session name to pipe output from
509
+ * @param logPath - Absolute path to the log file to write output to
510
+ * @throws AgentError if pipe-pane fails to start
511
+ */
512
+ export async function startPipePane(sessionName: string, logPath: string): Promise<void> {
513
+ await mkdir(dirname(logPath), { recursive: true });
514
+ const { exitCode, stderr } = await runCommand([
515
+ "tmux",
516
+ "pipe-pane",
517
+ "-t",
518
+ sessionName,
519
+ `cat >> ${logPath}`,
520
+ ]);
521
+ if (exitCode !== 0) {
522
+ throw new AgentError(
523
+ `Failed to start pipe-pane for session "${sessionName}": ${stderr.trim()}`,
524
+ { agentName: sessionName },
525
+ );
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Stop continuous terminal output streaming for a tmux session.
531
+ *
532
+ * Runs `tmux pipe-pane -t sessionName` with no command to close any active
533
+ * pipe. Errors are silently ignored — the session may not have an active
534
+ * pipe or may already be gone.
535
+ *
536
+ * @param sessionName - Tmux session name to stop piping output from
537
+ */
538
+ export async function stopPipePane(sessionName: string): Promise<void> {
539
+ // tmux pipe-pane with no command closes any active pipe
540
+ await runCommand(["tmux", "pipe-pane", "-t", sessionName]);
541
+ }
542
+
543
+ /**
544
+ * Read the last N lines from a terminal log file.
545
+ *
546
+ * Returns null if the log file does not exist. Returns the full content
547
+ * if tailLines is not specified.
548
+ *
549
+ * @param logPath - Absolute path to the terminal log file
550
+ * @param tailLines - Number of trailing lines to return (default: all lines)
551
+ * @returns The log content, or null if the file does not exist
552
+ */
553
+ export async function readTerminalLog(logPath: string, tailLines?: number): Promise<string | null> {
554
+ try {
555
+ await stat(logPath);
556
+ } catch {
557
+ return null;
558
+ }
559
+ const content = await readFile(logPath, "utf-8");
560
+ if (tailLines === undefined) {
561
+ return content;
562
+ }
563
+ // Split lines, accounting for optional trailing newline so the empty string
564
+ // after a trailing \n doesn't count as an extra line.
565
+ const hasTrailingNewline = content.endsWith("\n");
566
+ const lines = content.split("\n");
567
+ const effectiveLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
568
+ if (effectiveLines.length <= tailLines) {
569
+ return content;
570
+ }
571
+ const tailed = effectiveLines.slice(-tailLines).join("\n");
572
+ return hasTrailingNewline ? `${tailed}\n` : tailed;
573
+ }
574
+
575
+ /**
576
+ * Strings that indicate Claude Code's TUI is rendered and ready.
577
+ *
578
+ * These markers appear in the pane once Claude Code has initialized its TUI:
579
+ * - Permission mode prompt text
580
+ * - Box-drawing characters used in the TUI chrome (separators, borders)
581
+ *
582
+ * Checking for these avoids false-positive ready detection on the bare shell
583
+ * prompt that appears before Claude Code starts.
584
+ */
585
+ const TUI_READY_MARKERS = [
586
+ "bypass permissions", // Permission mode prompt
587
+ "─", // Unicode box-drawing horizontal line (TUI separator)
588
+ "━", // Bold box-drawing horizontal line (TUI separator variant)
589
+ "╭", // Box-drawing top-left corner
590
+ "╰", // Box-drawing bottom-left corner
591
+ ];
592
+
593
+ /**
594
+ * Check whether pane content contains Claude Code TUI markers.
595
+ *
596
+ * @param content - Pane content to check
597
+ * @returns true if any TUI marker is present
598
+ */
599
+ export function hasTuiMarkers(content: string): boolean {
600
+ return TUI_READY_MARKERS.some((marker) => content.includes(marker));
601
+ }
602
+
603
+ /**
604
+ * Poll a tmux pane until Claude Code's TUI has rendered its chrome, indicating
605
+ * it is ready to accept input.
606
+ *
607
+ * Waits for TUI-specific markers (box-drawing characters, permission prompts)
608
+ * rather than any non-empty content, preventing false-positive ready detection
609
+ * on the shell prompt that appears before Claude Code initializes.
610
+ *
611
+ * After markers are detected, an additional `postReadyDelay` (default 500ms)
612
+ * is applied as defense in depth to let the TUI finish initialization.
613
+ *
614
+ * If the timeout expires before markers appear, a warning is emitted to stderr
615
+ * and the function resolves normally (graceful fallback — the caller proceeds).
616
+ *
617
+ * @param sessionName - Tmux session name to poll
618
+ * @param opts.timeout - Maximum time to wait in ms (default: 15_000)
619
+ * @param opts.interval - Polling interval in ms (default: 500)
620
+ * @param opts.postReadyDelay - Extra delay after marker detection in ms (default: 500)
621
+ */
622
+ export async function waitForTuiReady(
623
+ sessionName: string,
624
+ opts?: { timeout?: number; interval?: number; postReadyDelay?: number },
625
+ ): Promise<void> {
626
+ const timeoutMs = opts?.timeout ?? 15_000;
627
+ const intervalMs = opts?.interval ?? 500;
628
+ const postReadyDelayMs = opts?.postReadyDelay ?? 500;
629
+ const deadline = Date.now() + timeoutMs;
630
+
631
+ while (Date.now() < deadline) {
632
+ const content = await capturePaneContent(sessionName);
633
+ if (hasTuiMarkers(content)) {
634
+ // Defense in depth: wait for TUI to finish initialization
635
+ await setTimeout(postReadyDelayMs);
636
+ return;
637
+ }
638
+ await setTimeout(intervalMs);
639
+ }
640
+
641
+ process.stderr.write(
642
+ `⚠️ Warning: TUI for session "${sessionName}" did not become ready within ${timeoutMs}ms — proceeding anyway\n`,
643
+ );
644
+ }