@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.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -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 +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- 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
|
+
}
|