@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,230 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { createEventStore } from "../events/store.ts";
7
+ import { createSessionStore } from "../sessions/store.ts";
8
+ import type { AgentSession, StoredEvent } from "../types.ts";
9
+
10
+ /**
11
+ * Tests for the nudge command's debounce and session lookup logic.
12
+ *
13
+ * We test the pure/file-based functions directly rather than the full
14
+ * nudgeCommand (which requires real tmux sessions). Tmux interaction
15
+ * is tested via E2E.
16
+ */
17
+
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "nudge-test-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ /**
29
+ * Helper to write sessions to SessionStore (sessions.db) for testing.
30
+ */
31
+ function writeSessionsToStore(projectRoot: string, sessions: AgentSession[]): void {
32
+ const dir = join(projectRoot, ".legio");
33
+ mkdirSync(dir, { recursive: true });
34
+ const dbPath = join(dir, "sessions.db");
35
+ const store = createSessionStore(dbPath);
36
+ for (const session of sessions) {
37
+ store.upsert(session);
38
+ }
39
+ store.close();
40
+ }
41
+
42
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
43
+ return {
44
+ id: "session-123-test-agent",
45
+ agentName: "test-agent",
46
+ capability: "builder",
47
+ worktreePath: "/tmp/wt",
48
+ branchName: "legio/test-agent/task-1",
49
+ beadId: "task-1",
50
+ tmuxSession: "legio-test-agent",
51
+ state: "working",
52
+ pid: 12345,
53
+ parentAgent: null,
54
+ depth: 0,
55
+ runId: null,
56
+ startedAt: new Date().toISOString(),
57
+ lastActivity: new Date().toISOString(),
58
+ escalationLevel: 0,
59
+ stalledSince: null,
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ describe("nudgeAgent", () => {
65
+ // We dynamically import to avoid circular issues
66
+ async function importNudge() {
67
+ return await import("./nudge.ts");
68
+ }
69
+
70
+ test("returns error when no active session exists", async () => {
71
+ writeSessionsToStore(tempDir, []);
72
+ const { nudgeAgent } = await importNudge();
73
+ const result = await nudgeAgent(tempDir, "nonexistent-agent");
74
+ expect(result.delivered).toBe(false);
75
+ expect(result.reason).toContain("No active session");
76
+ });
77
+
78
+ test("returns error when agent is zombie", async () => {
79
+ writeSessionsToStore(tempDir, [makeSession({ state: "zombie" })]);
80
+ const { nudgeAgent } = await importNudge();
81
+ const result = await nudgeAgent(tempDir, "test-agent");
82
+ expect(result.delivered).toBe(false);
83
+ expect(result.reason).toContain("No active session");
84
+ });
85
+
86
+ test("returns error when agent is completed", async () => {
87
+ writeSessionsToStore(tempDir, [makeSession({ state: "completed" })]);
88
+ const { nudgeAgent } = await importNudge();
89
+ const result = await nudgeAgent(tempDir, "test-agent");
90
+ expect(result.delivered).toBe(false);
91
+ expect(result.reason).toContain("No active session");
92
+ });
93
+
94
+ test("finds active agent in working state", async () => {
95
+ writeSessionsToStore(tempDir, [makeSession({ state: "working" })]);
96
+ const { nudgeAgent } = await importNudge();
97
+ // This will fail on sendKeys (no real tmux) but should get past session lookup
98
+ const result = await nudgeAgent(tempDir, "test-agent");
99
+ // Will fail because tmux session doesn't exist, but we validated session lookup works
100
+ expect(result.delivered).toBe(false);
101
+ expect(result.reason).toContain("not alive");
102
+ });
103
+
104
+ test("finds active agent in booting state", async () => {
105
+ writeSessionsToStore(tempDir, [makeSession({ state: "booting" })]);
106
+ const { nudgeAgent } = await importNudge();
107
+ const result = await nudgeAgent(tempDir, "test-agent");
108
+ expect(result.delivered).toBe(false);
109
+ expect(result.reason).toContain("not alive");
110
+ });
111
+
112
+ test("handles missing sessions.db gracefully", async () => {
113
+ // Create .legio dir but no sessions.db — SessionStore will be created empty
114
+ mkdirSync(join(tempDir, ".legio"), { recursive: true });
115
+ const { nudgeAgent } = await importNudge();
116
+ const result = await nudgeAgent(tempDir, "test-agent");
117
+ expect(result.delivered).toBe(false);
118
+ expect(result.reason).toContain("No active session");
119
+ });
120
+
121
+ test("resolves orchestrator from orchestrator-tmux.json fallback", async () => {
122
+ // No sessions.db, but orchestrator-tmux.json exists
123
+ const { mkdir } = await import("node:fs/promises");
124
+ await mkdir(join(tempDir, ".legio"), { recursive: true });
125
+ await writeFile(
126
+ join(tempDir, ".legio", "orchestrator-tmux.json"),
127
+ `${JSON.stringify({ tmuxSession: "my-session", registeredAt: new Date().toISOString() }, null, "\t")}\n`,
128
+ );
129
+
130
+ const { nudgeAgent } = await importNudge();
131
+ const result = await nudgeAgent(tempDir, "orchestrator");
132
+ // Will fail at tmux alive check (no real tmux), but should get past resolution
133
+ expect(result.delivered).toBe(false);
134
+ expect(result.reason).toContain("not alive");
135
+ });
136
+
137
+ test("returns error when orchestrator has no tmux registration", async () => {
138
+ const { mkdir } = await import("node:fs/promises");
139
+ await mkdir(join(tempDir, ".legio"), { recursive: true });
140
+ // No orchestrator-tmux.json and no sessions.db entry
141
+ const { nudgeAgent } = await importNudge();
142
+ const result = await nudgeAgent(tempDir, "orchestrator");
143
+ expect(result.delivered).toBe(false);
144
+ expect(result.reason).toContain("No active session");
145
+ });
146
+
147
+ test("prefers sessions.db over orchestrator-tmux.json for orchestrator", async () => {
148
+ // If orchestrator somehow appears in sessions.db, use that
149
+ writeSessionsToStore(tempDir, [
150
+ makeSession({
151
+ agentName: "orchestrator",
152
+ tmuxSession: "legio-orchestrator",
153
+ state: "working",
154
+ }),
155
+ ]);
156
+ await writeFile(
157
+ join(tempDir, ".legio", "orchestrator-tmux.json"),
158
+ `${JSON.stringify({ tmuxSession: "fallback-session" }, null, "\t")}\n`,
159
+ );
160
+
161
+ const { nudgeAgent } = await importNudge();
162
+ const result = await nudgeAgent(tempDir, "orchestrator");
163
+ // Should use sessions.db entry, fail at tmux alive check
164
+ expect(result.delivered).toBe(false);
165
+ expect(result.reason).toContain("legio-orchestrator");
166
+ });
167
+
168
+ test("records nudge event to EventStore after delivery attempt", async () => {
169
+ // Agent exists in SessionStore but tmux is not alive — nudge fails
170
+ // but the event should still be recorded
171
+ writeSessionsToStore(tempDir, [makeSession({ state: "working" })]);
172
+
173
+ const { nudgeAgent } = await importNudge();
174
+ const result = await nudgeAgent(tempDir, "test-agent");
175
+ // Nudge fails because tmux session is not alive
176
+ expect(result.delivered).toBe(false);
177
+
178
+ // Verify event was recorded to events.db
179
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
180
+ const store = createEventStore(eventsDbPath);
181
+ try {
182
+ const events: StoredEvent[] = store.getTimeline({
183
+ since: "2000-01-01T00:00:00Z",
184
+ });
185
+ const nudgeEvent = events.find((e) => {
186
+ if (!e.data) return false;
187
+ const data = JSON.parse(e.data) as Record<string, unknown>;
188
+ return data.type === "nudge";
189
+ });
190
+ expect(nudgeEvent).toBeDefined();
191
+ expect(nudgeEvent?.eventType).toBe("custom");
192
+ expect(nudgeEvent?.level).toBe("info");
193
+ expect(nudgeEvent?.agentName).toBe("test-agent");
194
+
195
+ const data = JSON.parse(nudgeEvent?.data ?? "{}") as Record<string, unknown>;
196
+ expect(data.delivered).toBe(false);
197
+ expect(data.from).toBe("orchestrator");
198
+ } finally {
199
+ store.close();
200
+ }
201
+ });
202
+
203
+ test("nudge event includes run_id when current-run.txt exists", async () => {
204
+ writeSessionsToStore(tempDir, [makeSession({ state: "working" })]);
205
+
206
+ // Write a current-run.txt
207
+ const runId = "run-test-123";
208
+ await writeFile(join(tempDir, ".legio", "current-run.txt"), runId);
209
+
210
+ const { nudgeAgent } = await importNudge();
211
+ await nudgeAgent(tempDir, "test-agent");
212
+
213
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
214
+ const store = createEventStore(eventsDbPath);
215
+ try {
216
+ const events: StoredEvent[] = store.getTimeline({
217
+ since: "2000-01-01T00:00:00Z",
218
+ });
219
+ const nudgeEvent = events.find((e) => {
220
+ if (!e.data) return false;
221
+ const data = JSON.parse(e.data) as Record<string, unknown>;
222
+ return data.type === "nudge";
223
+ });
224
+ expect(nudgeEvent).toBeDefined();
225
+ expect(nudgeEvent?.runId).toBe(runId);
226
+ } finally {
227
+ store.close();
228
+ }
229
+ });
230
+ });
@@ -0,0 +1,373 @@
1
+ /**
2
+ * CLI command: legio nudge <agent-name> [message]
3
+ *
4
+ * Sends a text nudge to an agent's interactive Claude Code session via
5
+ * tmux send-keys. Used to notify agents of new mail or relay urgent
6
+ * instructions mid-conversation.
7
+ *
8
+ * Includes retry logic (3 attempts) and debounce (500ms) to prevent
9
+ * rapid-fire nudges to the same agent.
10
+ */
11
+
12
+ import { access, readFile, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { AgentError, ValidationError } from "../errors.ts";
15
+ import { createEventStore } from "../events/store.ts";
16
+ import { openSessionStore } from "../sessions/compat.ts";
17
+ import type { EventStore } from "../types.ts";
18
+ import { isSessionAlive, sendKeys } from "../worktree/tmux.ts";
19
+
20
+ const DEFAULT_MESSAGE = "Check your mail inbox for new messages.";
21
+ const MAX_RETRIES = 3;
22
+ const RETRY_DELAY_MS = 500;
23
+ const DEBOUNCE_MS = 500;
24
+
25
+ /**
26
+ * Parse a named flag value from args.
27
+ */
28
+ function getFlag(args: string[], flag: string): string | undefined {
29
+ const idx = args.indexOf(flag);
30
+ if (idx === -1 || idx + 1 >= args.length) {
31
+ return undefined;
32
+ }
33
+ return args[idx + 1];
34
+ }
35
+
36
+ /** Boolean flags that do NOT consume the next arg. */
37
+ const BOOLEAN_FLAGS = new Set(["--json", "--force", "--help", "-h"]);
38
+
39
+ /**
40
+ * Extract positional arguments, skipping flag-value pairs.
41
+ */
42
+ function getPositionalArgs(args: string[]): string[] {
43
+ const positional: string[] = [];
44
+ let i = 0;
45
+ while (i < args.length) {
46
+ const arg = args[i];
47
+ if (arg?.startsWith("-")) {
48
+ if (BOOLEAN_FLAGS.has(arg)) {
49
+ i += 1;
50
+ } else {
51
+ i += 2;
52
+ }
53
+ } else {
54
+ if (arg !== undefined) {
55
+ positional.push(arg);
56
+ }
57
+ i += 1;
58
+ }
59
+ }
60
+ return positional;
61
+ }
62
+
63
+ /**
64
+ * Load the orchestrator's registered tmux session name.
65
+ *
66
+ * Written by `legio prime` at SessionStart when the orchestrator
67
+ * is running inside tmux. Enables agents to nudge the orchestrator
68
+ * even though it's not tracked in the SessionStore.
69
+ */
70
+ async function loadOrchestratorTmuxSession(projectRoot: string): Promise<string | null> {
71
+ const regPath = join(projectRoot, ".legio", "orchestrator-tmux.json");
72
+ try {
73
+ await access(regPath);
74
+ } catch {
75
+ return null;
76
+ }
77
+ try {
78
+ const text = await readFile(regPath, "utf-8");
79
+ const reg = JSON.parse(text) as { tmuxSession?: string };
80
+ return reg.tmuxSession ?? null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve the tmux session name for an agent.
88
+ *
89
+ * For regular agents, looks up the SessionStore.
90
+ * For "orchestrator", falls back to the orchestrator-tmux.json registration
91
+ * file written by `legio prime`.
92
+ */
93
+ async function resolveTargetSession(
94
+ projectRoot: string,
95
+ agentName: string,
96
+ ): Promise<string | null> {
97
+ const legioDir = join(projectRoot, ".legio");
98
+ const { store } = openSessionStore(legioDir);
99
+ try {
100
+ const session = store.getByName(agentName);
101
+ if (session && session.state !== "zombie" && session.state !== "completed") {
102
+ return session.tmuxSession;
103
+ }
104
+ } finally {
105
+ store.close();
106
+ }
107
+
108
+ // Fallback for orchestrator: check orchestrator-tmux.json
109
+ if (agentName === "orchestrator") {
110
+ return await loadOrchestratorTmuxSession(projectRoot);
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Check debounce state for an agent. Returns true if a nudge was sent
118
+ * within the debounce window and should be skipped.
119
+ */
120
+ async function isDebounced(statePath: string, agentName: string): Promise<boolean> {
121
+ try {
122
+ await access(statePath);
123
+ } catch {
124
+ return false;
125
+ }
126
+ try {
127
+ const text = await readFile(statePath, "utf-8");
128
+ const state = JSON.parse(text) as Record<string, number>;
129
+ const lastNudge = state[agentName];
130
+ if (lastNudge === undefined) {
131
+ return false;
132
+ }
133
+ return Date.now() - lastNudge < DEBOUNCE_MS;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Record a nudge timestamp for debounce tracking.
141
+ */
142
+ async function recordNudge(statePath: string, agentName: string): Promise<void> {
143
+ let state: Record<string, number> = {};
144
+ try {
145
+ const text = await readFile(statePath, "utf-8");
146
+ state = JSON.parse(text) as Record<string, number>;
147
+ } catch {
148
+ // File does not exist or corrupt state — start fresh
149
+ }
150
+ state[agentName] = Date.now();
151
+ await writeFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
152
+ }
153
+
154
+ /**
155
+ * Send a nudge to an agent's tmux session with retry logic.
156
+ *
157
+ * @param tmuxSession - The tmux session name
158
+ * @param message - The text to send
159
+ * @returns true if the nudge was delivered, false if all retries failed
160
+ */
161
+ async function sendNudgeWithRetry(tmuxSession: string, message: string): Promise<boolean> {
162
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
163
+ try {
164
+ await sendKeys(tmuxSession, message);
165
+ // Follow-up Enter after a short delay to ensure submission.
166
+ // Claude Code's TUI may consume the first Enter during re-render/focus
167
+ // events, leaving text visible but unsubmitted (legio-t62v).
168
+ // Same workaround as sling.ts and coordinator.ts.
169
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
170
+ await sendKeys(tmuxSession, "");
171
+ return true;
172
+ } catch {
173
+ if (attempt < MAX_RETRIES) {
174
+ await new Promise<void>((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
175
+ }
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /**
182
+ * Read the current run ID from current-run.txt, or null if no active run.
183
+ */
184
+ async function readCurrentRunId(legioDir: string): Promise<string | null> {
185
+ const path = join(legioDir, "current-run.txt");
186
+ try {
187
+ await access(path);
188
+ } catch {
189
+ return null;
190
+ }
191
+ try {
192
+ const text = await readFile(path, "utf-8");
193
+ const trimmed = text.trim();
194
+ return trimmed.length > 0 ? trimmed : null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Fire-and-forget: record a nudge event to EventStore. Never throws.
202
+ */
203
+ function recordNudgeEvent(
204
+ eventStore: EventStore,
205
+ opts: {
206
+ runId: string | null;
207
+ agentName: string;
208
+ from: string;
209
+ message: string;
210
+ delivered: boolean;
211
+ },
212
+ ): void {
213
+ try {
214
+ eventStore.insert({
215
+ runId: opts.runId,
216
+ agentName: opts.agentName,
217
+ sessionId: null,
218
+ eventType: "custom",
219
+ toolName: null,
220
+ toolArgs: null,
221
+ toolDurationMs: null,
222
+ level: "info",
223
+ data: JSON.stringify({
224
+ type: "nudge",
225
+ from: opts.from,
226
+ message: opts.message,
227
+ delivered: opts.delivered,
228
+ }),
229
+ });
230
+ } catch {
231
+ // Fire-and-forget: event recording must never break nudge delivery
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Core nudge function. Exported for use by mail send auto-nudge.
237
+ *
238
+ * @param projectRoot - Absolute path to the project root
239
+ * @param agentName - Name of the agent to nudge
240
+ * @param message - Text to send (defaults to mail check prompt)
241
+ * @param force - Skip debounce check
242
+ * @returns Object with delivery status
243
+ */
244
+ export async function nudgeAgent(
245
+ projectRoot: string,
246
+ agentName: string,
247
+ message: string = DEFAULT_MESSAGE,
248
+ force = false,
249
+ ): Promise<{ delivered: boolean; reason?: string }> {
250
+ let result: { delivered: boolean; reason?: string };
251
+
252
+ // Resolve tmux session (SessionStore for agents, orchestrator-tmux.json for orchestrator)
253
+ const tmuxSessionName = await resolveTargetSession(projectRoot, agentName);
254
+
255
+ if (!tmuxSessionName) {
256
+ result = { delivered: false, reason: `No active session for agent "${agentName}"` };
257
+ } else {
258
+ // Check debounce (unless forced)
259
+ let debounced = false;
260
+ if (!force) {
261
+ const statePath = join(projectRoot, ".legio", "nudge-state.json");
262
+ debounced = await isDebounced(statePath, agentName);
263
+ }
264
+
265
+ if (debounced) {
266
+ result = { delivered: false, reason: "Debounced: nudge sent too recently" };
267
+ } else {
268
+ // Verify tmux session is alive
269
+ const alive = await isSessionAlive(tmuxSessionName);
270
+ if (!alive) {
271
+ result = {
272
+ delivered: false,
273
+ reason: `Tmux session "${tmuxSessionName}" is not alive`,
274
+ };
275
+ } else {
276
+ // Send with retry
277
+ const delivered = await sendNudgeWithRetry(tmuxSessionName, message);
278
+
279
+ if (delivered) {
280
+ // Record nudge for debounce tracking
281
+ const statePath = join(projectRoot, ".legio", "nudge-state.json");
282
+ await recordNudge(statePath, agentName);
283
+ result = { delivered: true };
284
+ } else {
285
+ result = {
286
+ delivered: false,
287
+ reason: `Failed to send after ${MAX_RETRIES} attempts`,
288
+ };
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ // Record event to EventStore (fire-and-forget)
295
+ try {
296
+ const legioDir = join(projectRoot, ".legio");
297
+ const eventsDbPath = join(legioDir, "events.db");
298
+ const eventStore = createEventStore(eventsDbPath);
299
+ try {
300
+ const runId = await readCurrentRunId(legioDir);
301
+ recordNudgeEvent(eventStore, {
302
+ runId,
303
+ agentName,
304
+ from: "orchestrator",
305
+ message,
306
+ delivered: result.delivered,
307
+ });
308
+ } finally {
309
+ eventStore.close();
310
+ }
311
+ } catch {
312
+ // Event recording failure is non-fatal
313
+ }
314
+
315
+ return result;
316
+ }
317
+
318
+ /**
319
+ * Entry point for `legio nudge <agent-name> [message]`.
320
+ */
321
+ const NUDGE_HELP = `legio nudge — Send a text nudge to an agent
322
+
323
+ Usage: legio nudge <agent-name> [message]
324
+
325
+ Arguments:
326
+ <agent-name> Name of the agent to nudge
327
+ [message] Text to send (default: "${DEFAULT_MESSAGE}")
328
+
329
+ Options:
330
+ --from <name> Sender name for the nudge prefix (default: orchestrator)
331
+ --force Skip debounce check
332
+ --json Output result as JSON
333
+ --help, -h Show this help`;
334
+
335
+ export async function nudgeCommand(args: string[]): Promise<void> {
336
+ if (args.includes("--help") || args.includes("-h")) {
337
+ process.stdout.write(`${NUDGE_HELP}\n`);
338
+ return;
339
+ }
340
+
341
+ const positional = getPositionalArgs(args);
342
+ const agentName = positional[0];
343
+ if (!agentName || agentName.trim().length === 0) {
344
+ throw new ValidationError("Agent name is required: legio nudge <agent-name> [message]", {
345
+ field: "agentName",
346
+ });
347
+ }
348
+
349
+ const from = getFlag(args, "--from") ?? "orchestrator";
350
+ const force = args.includes("--force");
351
+ const json = args.includes("--json");
352
+
353
+ // Build the nudge message: prefix with sender, use custom or default text
354
+ const customMessage = positional.slice(1).join(" ");
355
+ const rawMessage = customMessage.length > 0 ? customMessage : DEFAULT_MESSAGE;
356
+ const message = `[NUDGE from ${from}] ${rawMessage}`;
357
+
358
+ // Resolve project root
359
+ const { resolveProjectRoot } = await import("../config.ts");
360
+ const projectRoot = await resolveProjectRoot(process.cwd());
361
+
362
+ const result = await nudgeAgent(projectRoot, agentName, message, force);
363
+
364
+ if (json) {
365
+ process.stdout.write(
366
+ `${JSON.stringify({ agentName, delivered: result.delivered, reason: result.reason })}\n`,
367
+ );
368
+ } else if (result.delivered) {
369
+ process.stdout.write(`📢 Nudged "${agentName}"\n`);
370
+ } else {
371
+ throw new AgentError(`Nudge failed: ${result.reason}`, { agentName });
372
+ }
373
+ }