@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,700 @@
1
+ /**
2
+ * CLI command: legio sling <task-id>
3
+ *
4
+ * CRITICAL PATH. Orchestrates a full agent spawn:
5
+ * 1. Load config + manifest
6
+ * 2. Validate (depth limit, hierarchy)
7
+ * 3. Load manifest + validate capability
8
+ * 4. Resolve or create run_id (current-run.txt)
9
+ * 5. Check name uniqueness + concurrency limit
10
+ * 6. Validate bead exists
11
+ * 7. Create worktree
12
+ * 8. Generate + write overlay CLAUDE.md
13
+ * 9. Deploy hooks config
14
+ * 10. Claim beads issue
15
+ * 11. Create agent identity
16
+ * 12. Create tmux session running claude
17
+ * 13. Record session in SessionStore + increment run agent count
18
+ * 14. Return AgentSession
19
+ */
20
+
21
+ import { spawn } from "node:child_process";
22
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
23
+ import { join, resolve } from "node:path";
24
+ import { deployHooks } from "../agents/hooks-deployer.ts";
25
+ import { createIdentity, loadIdentity } from "../agents/identity.ts";
26
+ import { createManifestLoader } from "../agents/manifest.ts";
27
+ import { writeOverlay } from "../agents/overlay.ts";
28
+ import type { BeadIssue } from "../beads/client.ts";
29
+ import { createBeadsClient } from "../beads/client.ts";
30
+ import { collectProviderEnv, loadConfig } from "../config.ts";
31
+ import { AgentError, HierarchyError, isRunningAsRoot, ValidationError } from "../errors.ts";
32
+ import { createMailStore } from "../mail/store.ts";
33
+ import { createMulchClient, inferDomainsFromFiles } from "../mulch/client.ts";
34
+ import { openSessionStore } from "../sessions/compat.ts";
35
+ import { createRunStore } from "../sessions/store.ts";
36
+ import type { AgentSession, OverlayConfig } from "../types.ts";
37
+ import { createWorktree } from "../worktree/manager.ts";
38
+ import { createSession, sendKeys, startPipePane, waitForTuiReady } from "../worktree/tmux.ts";
39
+
40
+ /**
41
+ * Calculate how many milliseconds to sleep before spawning a new agent,
42
+ * based on the configured stagger delay and when the most recent active
43
+ * session was started.
44
+ *
45
+ * Returns 0 if no sleep is needed (no active sessions, delay is 0, or
46
+ * enough time has already elapsed).
47
+ *
48
+ * @param staggerDelayMs - The configured minimum delay between spawns
49
+ * @param activeSessions - Currently active (non-zombie) sessions
50
+ * @param now - Current timestamp in ms (defaults to Date.now(), injectable for testing)
51
+ */
52
+ export function calculateStaggerDelay(
53
+ staggerDelayMs: number,
54
+ activeSessions: ReadonlyArray<{ startedAt: string }>,
55
+ now: number = Date.now(),
56
+ ): number {
57
+ if (staggerDelayMs <= 0 || activeSessions.length === 0) {
58
+ return 0;
59
+ }
60
+
61
+ const mostRecent = activeSessions.reduce((latest, s) => {
62
+ return new Date(s.startedAt).getTime() > new Date(latest.startedAt).getTime() ? s : latest;
63
+ });
64
+ const elapsed = now - new Date(mostRecent.startedAt).getTime();
65
+ const remaining = staggerDelayMs - elapsed;
66
+ return remaining > 0 ? remaining : 0;
67
+ }
68
+
69
+ /**
70
+ * Parse a named flag value from an args array.
71
+ */
72
+ function getFlag(args: string[], flag: string): string | undefined {
73
+ const idx = args.indexOf(flag);
74
+ if (idx === -1 || idx + 1 >= args.length) {
75
+ return undefined;
76
+ }
77
+ return args[idx + 1];
78
+ }
79
+
80
+ /**
81
+ * Options for building the structured startup beacon.
82
+ */
83
+ export interface BeaconOptions {
84
+ agentName: string;
85
+ capability: string;
86
+ taskId: string;
87
+ parentAgent: string | null;
88
+ depth: number;
89
+ }
90
+
91
+ /**
92
+ * Build a structured startup beacon for an agent.
93
+ *
94
+ * The beacon is the first user message sent to a Claude Code agent via
95
+ * tmux send-keys. It provides identity context and a numbered startup
96
+ * protocol so the agent knows exactly what to do on boot.
97
+ *
98
+ * Format:
99
+ * [LEGIO] <agent-name> (<capability>) <ISO timestamp> task:<bead-id>
100
+ * Depth: <n> | Parent: <parent-name|none>
101
+ * Startup protocol:
102
+ * 1. Read your assignment in .claude/CLAUDE.md
103
+ * 2. Load expertise: mulch prime
104
+ * 3. Check mail: legio mail check --agent <name>
105
+ * 4. Begin working on task <bead-id>
106
+ */
107
+ export function buildBeacon(opts: BeaconOptions): string {
108
+ const timestamp = new Date().toISOString();
109
+ const parent = opts.parentAgent ?? "none";
110
+ const parts = [
111
+ `[LEGIO] ${opts.agentName} (${opts.capability}) ${timestamp} task:${opts.taskId}`,
112
+ `Depth: ${opts.depth} | Parent: ${parent}`,
113
+ `Startup: read .claude/CLAUDE.md, run mulch prime, check mail (legio mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
114
+ ];
115
+ return parts.join(" — ");
116
+ }
117
+
118
+ /**
119
+ * Build the auto-dispatch mail message object that sling writes to mail.db
120
+ * before creating the tmux session. This guarantees the dispatch mail exists
121
+ * when the agent's SessionStart hook fires `legio mail check`.
122
+ *
123
+ * Pure function — no side effects, easily testable.
124
+ */
125
+ export function buildAutoDispatch(opts: {
126
+ parentAgent: string | null;
127
+ agentName: string;
128
+ taskId: string;
129
+ specPath: string | null;
130
+ branchName: string;
131
+ }): { from: string; to: string; subject: string; body: string; type: string; priority: string } {
132
+ const from = opts.parentAgent ?? "orchestrator";
133
+ const specDisplay = opts.specPath ?? "none";
134
+ return {
135
+ from,
136
+ to: opts.agentName,
137
+ subject: `dispatch: ${opts.taskId}`,
138
+ body: `Assigned task ${opts.taskId}. Spec: ${specDisplay}. Branch: ${opts.branchName}. Begin immediately.`,
139
+ type: "dispatch",
140
+ priority: "normal",
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Check if a parent agent has spawned any scouts.
146
+ * Returns true if the parent has at least one scout child in the session history.
147
+ */
148
+ export function parentHasScouts(
149
+ sessions: ReadonlyArray<{ parentAgent: string | null; capability: string }>,
150
+ parentAgent: string,
151
+ ): boolean {
152
+ return sessions.some((s) => s.parentAgent === parentAgent && s.capability === "scout");
153
+ }
154
+
155
+ /**
156
+ * Validate hierarchy constraints: the coordinator (no parent) may only spawn leads and scouts.
157
+ *
158
+ * When parentAgent is null, the caller is the coordinator or a human.
159
+ * Only "lead" and "scout" capabilities are allowed in that case. All other capabilities
160
+ * (builder, scout, reviewer, merger) must be spawned by a lead or supervisor
161
+ * that passes --parent.
162
+ *
163
+ * @param parentAgent - The --parent flag value (null = coordinator/human)
164
+ * @param capability - The requested agent capability
165
+ * @param name - The agent name (for error context)
166
+ * @param depth - The requested hierarchy depth
167
+ * @param forceHierarchy - If true, bypass the check (for debugging)
168
+ * @throws HierarchyError if the constraint is violated
169
+ */
170
+ export function validateHierarchy(
171
+ parentAgent: string | null,
172
+ capability: string,
173
+ name: string,
174
+ _depth: number,
175
+ forceHierarchy: boolean,
176
+ ): void {
177
+ if (forceHierarchy) {
178
+ return;
179
+ }
180
+
181
+ if (parentAgent === null && capability !== "lead" && capability !== "scout") {
182
+ throw new HierarchyError(
183
+ `Coordinator cannot spawn "${capability}" directly. Only "lead" and "scout" are allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
184
+ { agentName: name, requestedCapability: capability },
185
+ );
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Check if a parent agent has reached its child agent budget ceiling.
191
+ * @throws AgentError if the parent already has maxAgentsPerLead active children.
192
+ */
193
+ export function checkParentAgentLimit(
194
+ sessions: ReadonlyArray<{ parentAgent: string | null; state: string }>,
195
+ parentAgent: string,
196
+ maxAgentsPerLead: number,
197
+ agentName: string,
198
+ ): void {
199
+ const activeChildren = sessions.filter(
200
+ (s) => s.parentAgent === parentAgent && s.state !== "zombie" && s.state !== "completed",
201
+ );
202
+ if (activeChildren.length >= maxAgentsPerLead) {
203
+ throw new AgentError(
204
+ `Parent "${parentAgent}" has reached its child agent limit: ${activeChildren.length}/${maxAgentsPerLead} active children`,
205
+ { agentName },
206
+ );
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Check if a lead agent is already active for the given task ID.
212
+ * Prevents two leads from concurrently working the same issue.
213
+ * @throws AgentError if a lead is already active for this task.
214
+ */
215
+ export function checkDuplicateLead(
216
+ sessions: ReadonlyArray<{ beadId: string; capability: string; state: string; agentName: string }>,
217
+ taskId: string,
218
+ capability: string,
219
+ agentName: string,
220
+ ): void {
221
+ if (capability !== "lead") return; // Only applies to leads
222
+
223
+ const existingLead = sessions.find(
224
+ (s) =>
225
+ s.beadId === taskId &&
226
+ s.capability === "lead" &&
227
+ s.state !== "zombie" &&
228
+ s.state !== "completed",
229
+ );
230
+ if (existingLead) {
231
+ throw new AgentError(
232
+ `Lead already active for task "${taskId}": ${existingLead.agentName}. Cannot spawn duplicate lead "${agentName}".`,
233
+ { agentName },
234
+ );
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Entry point for `legio sling <task-id> [flags]`.
240
+ *
241
+ * Flags:
242
+ * --capability <type> builder | scout | reviewer | lead | merger
243
+ * --name <name> Unique agent name
244
+ * --spec <path> Path to task spec file
245
+ * --files <f1,f2,...> Exclusive file scope
246
+ * --parent <agent-name> Parent agent (for hierarchy tracking)
247
+ * --depth <n> Current hierarchy depth (default 0)
248
+ * --force-hierarchy Bypass hierarchy validation (debugging only)
249
+ */
250
+ const SLING_HELP = `legio sling — Spawn a worker agent
251
+
252
+ Usage: legio sling <task-id> [flags]
253
+
254
+ Arguments:
255
+ <task-id> Beads task ID to assign
256
+
257
+ Options:
258
+ --capability <type> Agent type: builder | scout | reviewer | lead | merger | cto (default: builder)
259
+ --name <name> Unique agent name (required)
260
+ --spec <path> Path to task spec file
261
+ --files <f1,f2,...> Exclusive file scope (comma-separated)
262
+ --parent <agent-name> Parent agent for hierarchy tracking
263
+ --depth <n> Current hierarchy depth (default: 0)
264
+ --force-hierarchy Bypass hierarchy validation (debugging only)
265
+ --skip-review Skip reviewer spawn (lead self-verifies)
266
+ --json Output result as JSON
267
+ --help, -h Show this help`;
268
+
269
+ export async function slingCommand(args: string[]): Promise<void> {
270
+ if (args.includes("--help") || args.includes("-h")) {
271
+ process.stdout.write(`${SLING_HELP}\n`);
272
+ return;
273
+ }
274
+
275
+ if (isRunningAsRoot()) {
276
+ throw new ValidationError(
277
+ "legio must not run as root — agent processes execute arbitrary code",
278
+ {
279
+ field: "uid",
280
+ },
281
+ );
282
+ }
283
+
284
+ const taskId = args.find((a) => !a.startsWith("--"));
285
+ if (!taskId) {
286
+ throw new ValidationError("Task ID is required: legio sling <task-id>", {
287
+ field: "taskId",
288
+ });
289
+ }
290
+
291
+ const capability = getFlag(args, "--capability") ?? "builder";
292
+ const name = getFlag(args, "--name");
293
+ const specPath = getFlag(args, "--spec") ?? null;
294
+ const filesRaw = getFlag(args, "--files");
295
+ const parentAgent = getFlag(args, "--parent") ?? null;
296
+ const depthStr = getFlag(args, "--depth");
297
+ const depth = depthStr !== undefined ? Number.parseInt(depthStr, 10) : 0;
298
+ const forceHierarchy = args.includes("--force-hierarchy");
299
+ const skipReview = args.includes("--skip-review");
300
+
301
+ if (!name || name.trim().length === 0) {
302
+ throw new ValidationError("--name is required for sling", { field: "name" });
303
+ }
304
+
305
+ if (Number.isNaN(depth) || depth < 0) {
306
+ throw new ValidationError("--depth must be a non-negative integer", {
307
+ field: "depth",
308
+ value: depthStr,
309
+ });
310
+ }
311
+
312
+ // Validate that spec file exists if provided, and resolve to absolute path
313
+ // so agents in worktrees can access it (worktrees don't have .legio/)
314
+ let absoluteSpecPath: string | null = null;
315
+ if (specPath !== null) {
316
+ absoluteSpecPath = resolve(specPath);
317
+ let specExists = false;
318
+ try {
319
+ await access(absoluteSpecPath);
320
+ specExists = true;
321
+ } catch {
322
+ specExists = false;
323
+ }
324
+ if (!specExists) {
325
+ throw new ValidationError(`Spec file not found: ${specPath}`, {
326
+ field: "spec",
327
+ value: specPath,
328
+ });
329
+ }
330
+ }
331
+
332
+ const fileScope = filesRaw
333
+ ? filesRaw
334
+ .split(",")
335
+ .map((f) => f.trim())
336
+ .filter((f) => f.length > 0)
337
+ : [];
338
+
339
+ // 1. Load config
340
+ const cwd = process.cwd();
341
+ const config = await loadConfig(cwd);
342
+
343
+ // 2. Validate depth limit
344
+ // Hierarchy: orchestrator(0) -> lead(1) -> specialist(2)
345
+ // With maxDepth=2, depth=2 is the deepest allowed leaf, so reject only depth > maxDepth
346
+ if (depth > config.agents.maxDepth) {
347
+ throw new AgentError(
348
+ `Depth limit exceeded: depth ${depth} > maxDepth ${config.agents.maxDepth}`,
349
+ { agentName: name },
350
+ );
351
+ }
352
+
353
+ // 2b. Validate hierarchy: coordinator (no --parent) can only spawn leads and scouts
354
+ validateHierarchy(parentAgent, capability, name, depth, forceHierarchy);
355
+
356
+ // 3. Load manifest and validate capability
357
+ const manifestLoader = createManifestLoader(
358
+ join(config.project.root, config.agents.manifestPath),
359
+ join(config.project.root, config.agents.baseDir),
360
+ );
361
+ const manifest = await manifestLoader.load();
362
+
363
+ const agentDef = manifest.agents[capability];
364
+ if (!agentDef) {
365
+ throw new AgentError(
366
+ `Unknown capability "${capability}". Available: ${Object.keys(manifest.agents).join(", ")}`,
367
+ { agentName: name, capability },
368
+ );
369
+ }
370
+
371
+ // 4. Resolve or create run_id for this spawn
372
+ const legioDir = join(config.project.root, ".legio");
373
+ const currentRunPath = join(legioDir, "current-run.txt");
374
+ let runId: string;
375
+
376
+ let currentRunExists = false;
377
+ try {
378
+ await access(currentRunPath);
379
+ currentRunExists = true;
380
+ } catch {
381
+ currentRunExists = false;
382
+ }
383
+ if (currentRunExists) {
384
+ runId = (await readFile(currentRunPath, "utf-8")).trim();
385
+ } else {
386
+ runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
387
+ const runStore = createRunStore(join(legioDir, "sessions.db"));
388
+ try {
389
+ runStore.createRun({
390
+ id: runId,
391
+ startedAt: new Date().toISOString(),
392
+ coordinatorSessionId: null,
393
+ status: "active",
394
+ });
395
+ } finally {
396
+ runStore.close();
397
+ }
398
+ await writeFile(currentRunPath, runId);
399
+ }
400
+
401
+ // 5. Check name uniqueness and concurrency limit against active sessions
402
+ const { store } = openSessionStore(legioDir);
403
+ try {
404
+ const activeSessions = store.getActive();
405
+ if (activeSessions.length >= config.agents.maxConcurrent) {
406
+ throw new AgentError(
407
+ `Max concurrent agent limit reached: ${activeSessions.length}/${config.agents.maxConcurrent} active agents`,
408
+ { agentName: name },
409
+ );
410
+ }
411
+
412
+ const existing = store.getByName(name);
413
+ if (existing && existing.state !== "zombie" && existing.state !== "completed") {
414
+ throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
415
+ agentName: name,
416
+ });
417
+ }
418
+
419
+ // 5b. Enforce stagger delay between agent spawns
420
+ const staggerMs = calculateStaggerDelay(config.agents.staggerDelayMs, activeSessions);
421
+ if (staggerMs > 0) {
422
+ await new Promise<void>((resolve) => setTimeout(resolve, staggerMs));
423
+ }
424
+
425
+ // 5c. Structural enforcement: warn when a lead spawns a builder without prior scouts.
426
+ // This is a non-blocking warning — it does not prevent the spawn, but surfaces
427
+ // the scout-skip pattern so agents and operators can see it happening.
428
+ if (capability === "builder" && parentAgent && !parentHasScouts(store.getAll(), parentAgent)) {
429
+ process.stderr.write(
430
+ `⚠️ Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
431
+ );
432
+ process.stderr.write(
433
+ " Leads should spawn scouts in Phase 1 before building. See agents/lead.md.\n",
434
+ );
435
+ }
436
+
437
+ // 5d. Enforce per-lead agent budget ceiling
438
+ if (parentAgent !== null) {
439
+ checkParentAgentLimit(activeSessions, parentAgent, config.agents.maxAgentsPerLead ?? 5, name);
440
+ }
441
+
442
+ // 5e. Prevent duplicate leads on the same task
443
+ checkDuplicateLead(activeSessions, taskId, capability, name);
444
+
445
+ // 6. Validate bead exists and is in a workable state (if beads enabled)
446
+ const beads = createBeadsClient(config.project.root);
447
+ if (config.beads.enabled) {
448
+ let issue: BeadIssue;
449
+ try {
450
+ issue = await beads.show(taskId);
451
+ } catch (err) {
452
+ throw new AgentError(`Bead task "${taskId}" not found or inaccessible`, {
453
+ agentName: name,
454
+ cause: err instanceof Error ? err : undefined,
455
+ });
456
+ }
457
+
458
+ const workableStatuses = ["open", "in_progress"];
459
+ if (!workableStatuses.includes(issue.status)) {
460
+ throw new ValidationError(
461
+ `Bead task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned.`,
462
+ { field: "taskId", value: taskId },
463
+ );
464
+ }
465
+ }
466
+
467
+ // 7. Create worktree
468
+ const worktreeBaseDir = join(config.project.root, config.worktrees.baseDir);
469
+ await mkdir(worktreeBaseDir, { recursive: true });
470
+
471
+ const { path: worktreePath, branch: branchName } = await createWorktree({
472
+ repoRoot: config.project.root,
473
+ baseDir: worktreeBaseDir,
474
+ agentName: name,
475
+ baseBranch: config.project.canonicalBranch,
476
+ beadId: taskId,
477
+ });
478
+
479
+ // 8. Generate + write overlay CLAUDE.md
480
+ const agentDefPath = join(config.project.root, config.agents.baseDir, agentDef.file);
481
+ const baseDefinition = await readFile(agentDefPath, "utf-8");
482
+
483
+ // 8a. Infer mulch domains from file scope and fetch expertise
484
+ const customDomainMap = config.mulch.domainMap;
485
+ const inferredDomains = inferDomainsFromFiles(
486
+ fileScope,
487
+ customDomainMap && Object.keys(customDomainMap).length > 0 ? customDomainMap : undefined,
488
+ );
489
+ let mulchExpertise: string | undefined;
490
+ if (config.mulch.enabled && fileScope.length > 0) {
491
+ try {
492
+ const mulch = createMulchClient(config.project.root);
493
+ if (inferredDomains.length > 0) {
494
+ mulchExpertise = await mulch.prime(inferredDomains);
495
+ } else {
496
+ mulchExpertise = await mulch.prime(undefined, undefined, { files: fileScope });
497
+ }
498
+ } catch {
499
+ // Non-fatal: mulch expertise is supplementary context
500
+ mulchExpertise = undefined;
501
+ }
502
+ }
503
+
504
+ const overlayConfig: OverlayConfig = {
505
+ agentName: name,
506
+ beadId: taskId,
507
+ specPath: absoluteSpecPath,
508
+ branchName,
509
+ worktreePath,
510
+ fileScope,
511
+ mulchDomains: config.mulch.enabled
512
+ ? inferredDomains.length > 0
513
+ ? inferredDomains
514
+ : config.mulch.domains
515
+ : [],
516
+ parentAgent: parentAgent,
517
+ depth,
518
+ canSpawn: agentDef.canSpawn,
519
+ capability,
520
+ baseDefinition,
521
+ mulchExpertise,
522
+ canonicalRoot: config.project.root,
523
+ skipReview,
524
+ };
525
+
526
+ try {
527
+ await writeOverlay(worktreePath, overlayConfig, config.project.root);
528
+ } catch (err) {
529
+ // Clean up the orphaned worktree created in step 7 (legio-p4st)
530
+ try {
531
+ await new Promise<void>((res) => {
532
+ const p = spawn("git", ["worktree", "remove", "--force", worktreePath], {
533
+ cwd: config.project.root,
534
+ stdio: "ignore",
535
+ });
536
+ p.on("close", () => res());
537
+ p.on("error", () => res());
538
+ });
539
+ } catch {
540
+ // Best-effort cleanup; the original error is more important
541
+ }
542
+ throw err;
543
+ }
544
+
545
+ // 9. Deploy hooks config (capability-specific guards)
546
+ await deployHooks(worktreePath, name, capability);
547
+
548
+ // 10. Claim beads issue
549
+ if (config.beads.enabled) {
550
+ try {
551
+ await beads.claim(taskId);
552
+ } catch {
553
+ // Non-fatal: issue may already be claimed
554
+ }
555
+ }
556
+
557
+ // 11. Create agent identity (if new)
558
+ const identityBaseDir = join(config.project.root, ".legio", "agents");
559
+ const existingIdentity = await loadIdentity(identityBaseDir, name);
560
+ if (!existingIdentity) {
561
+ await createIdentity(identityBaseDir, {
562
+ name,
563
+ capability,
564
+ created: new Date().toISOString(),
565
+ sessionsCompleted: 0,
566
+ expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
567
+ recentTasks: [],
568
+ });
569
+ }
570
+
571
+ // 11b. Write dispatch mail BEFORE creating the tmux session so the mail
572
+ // exists when the agent's SessionStart hook fires `legio mail check`.
573
+ // Without this, there is a race: the agent boots, checks mail, finds nothing,
574
+ // and idles without an assignment.
575
+ const dispatchMsg = buildAutoDispatch({
576
+ parentAgent,
577
+ agentName: name,
578
+ taskId,
579
+ specPath: absoluteSpecPath,
580
+ branchName,
581
+ });
582
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
583
+ try {
584
+ mailStore.insert({
585
+ id: `dispatch-${Date.now()}-${name}`,
586
+ from: dispatchMsg.from,
587
+ to: dispatchMsg.to,
588
+ subject: dispatchMsg.subject,
589
+ body: dispatchMsg.body,
590
+ type: dispatchMsg.type as "dispatch",
591
+ priority: dispatchMsg.priority as "normal",
592
+ threadId: null,
593
+ });
594
+ } finally {
595
+ mailStore.close();
596
+ }
597
+
598
+ // 12. Create tmux session running claude in interactive mode
599
+ // Write a settings JSON file to skip the bypass-permissions dialog.
600
+ const settingsPath = join(legioDir, `settings-${name}.json`);
601
+ await writeFile(
602
+ settingsPath,
603
+ JSON.stringify({ skipDangerousModePermissionPrompt: true }),
604
+ "utf-8",
605
+ );
606
+ const tmuxSessionName = `legio-${config.project.name}-${name}`;
607
+ const claudeCmd = `claude --model ${agentDef.model} --dangerously-skip-permissions --settings ${settingsPath}`;
608
+ const pid = await createSession(tmuxSessionName, worktreePath, claudeCmd, {
609
+ ...collectProviderEnv(),
610
+ LEGIO_AGENT_NAME: name,
611
+ LEGIO_WORKTREE_PATH: worktreePath,
612
+ });
613
+
614
+ // 13. Record session BEFORE sending the beacon so that hook-triggered
615
+ // updateLastActivity() can find the entry and transition booting->working.
616
+ // Without this, a race exists: hooks fire before the session is persisted,
617
+ // leaving the agent stuck in "booting" (legio-036f).
618
+ const terminalLogPath = join(legioDir, "logs", name, "terminal.log");
619
+ const session: AgentSession = {
620
+ id: `session-${Date.now()}-${name}`,
621
+ agentName: name,
622
+ capability,
623
+ worktreePath,
624
+ branchName,
625
+ beadId: taskId,
626
+ tmuxSession: tmuxSessionName,
627
+ state: "booting",
628
+ pid,
629
+ parentAgent: parentAgent,
630
+ depth,
631
+ runId,
632
+ startedAt: new Date().toISOString(),
633
+ lastActivity: new Date().toISOString(),
634
+ escalationLevel: 0,
635
+ stalledSince: null,
636
+ terminalLogPath,
637
+ };
638
+
639
+ store.upsert(session);
640
+
641
+ // Increment agent count for the run
642
+ const runStore = createRunStore(join(legioDir, "sessions.db"));
643
+ try {
644
+ runStore.incrementAgentCount(runId);
645
+ } finally {
646
+ runStore.close();
647
+ }
648
+
649
+ // 13b. Send beacon prompt via tmux send-keys
650
+ // Wait for Claude Code's TUI to render before sending input.
651
+ // Polls pane content until non-empty (replaces hardcoded 3s sleep).
652
+ await waitForTuiReady(tmuxSessionName);
653
+
654
+ // 13c. Start pipe-pane streaming to terminal log (non-fatal)
655
+ try {
656
+ await startPipePane(tmuxSessionName, terminalLogPath);
657
+ } catch {
658
+ // Non-fatal: pipe-pane unavailable or session not yet ready
659
+ }
660
+ const beacon = buildBeacon({
661
+ agentName: name,
662
+ capability,
663
+ taskId,
664
+ parentAgent,
665
+ depth,
666
+ });
667
+ await sendKeys(tmuxSessionName, beacon);
668
+
669
+ // 13d. Send a follow-up Enter after a short delay to ensure submission.
670
+ // Claude Code's TUI may consume the first Enter during initialization,
671
+ // leaving the beacon text visible but unsubmitted (legio-yhv6).
672
+ // A redundant Enter on an empty input line is harmless.
673
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
674
+ await sendKeys(tmuxSessionName, "");
675
+
676
+ // 14. Output result
677
+ const output = {
678
+ agentName: name,
679
+ capability,
680
+ taskId,
681
+ branch: branchName,
682
+ worktree: worktreePath,
683
+ tmuxSession: tmuxSessionName,
684
+ pid,
685
+ };
686
+
687
+ if (args.includes("--json")) {
688
+ process.stdout.write(`${JSON.stringify(output)}\n`);
689
+ } else {
690
+ process.stdout.write(`🚀 Agent "${name}" launched!\n`);
691
+ process.stdout.write(` Task: ${taskId}\n`);
692
+ process.stdout.write(` Branch: ${branchName}\n`);
693
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
694
+ process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
695
+ process.stdout.write(` PID: ${pid}\n`);
696
+ }
697
+ } finally {
698
+ store.close();
699
+ }
700
+ }