@os-eco/overstory-cli 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,230 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
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, ".overstory");
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: "overstory/test-agent/task-1",
49
+ beadId: "task-1",
50
+ tmuxSession: "overstory-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 .overstory dir but no sessions.db — SessionStore will be created empty
114
+ mkdirSync(join(tempDir, ".overstory"), { 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, ".overstory"), { recursive: true });
125
+ await Bun.write(
126
+ join(tempDir, ".overstory", "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, ".overstory"), { 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: "overstory-orchestrator",
153
+ state: "working",
154
+ }),
155
+ ]);
156
+ await Bun.write(
157
+ join(tempDir, ".overstory", "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("overstory-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, ".overstory", "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 Bun.write(join(tempDir, ".overstory", "current-run.txt"), runId);
209
+
210
+ const { nudgeAgent } = await importNudge();
211
+ await nudgeAgent(tempDir, "test-agent");
212
+
213
+ const eventsDbPath = join(tempDir, ".overstory", "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,372 @@
1
+ /**
2
+ * CLI command: overstory 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 { join } from "node:path";
13
+ import { AgentError, ValidationError } from "../errors.ts";
14
+ import { createEventStore } from "../events/store.ts";
15
+ import { openSessionStore } from "../sessions/compat.ts";
16
+ import type { EventStore } from "../types.ts";
17
+ import { isSessionAlive, sendKeys } from "../worktree/tmux.ts";
18
+
19
+ const DEFAULT_MESSAGE = "Check your mail inbox for new messages.";
20
+ const MAX_RETRIES = 3;
21
+ const RETRY_DELAY_MS = 500;
22
+ const DEBOUNCE_MS = 500;
23
+
24
+ /**
25
+ * Parse a named flag value from args.
26
+ */
27
+ function getFlag(args: string[], flag: string): string | undefined {
28
+ const idx = args.indexOf(flag);
29
+ if (idx === -1 || idx + 1 >= args.length) {
30
+ return undefined;
31
+ }
32
+ return args[idx + 1];
33
+ }
34
+
35
+ /** Boolean flags that do NOT consume the next arg. */
36
+ const BOOLEAN_FLAGS = new Set(["--json", "--force", "--help", "-h"]);
37
+
38
+ /**
39
+ * Extract positional arguments, skipping flag-value pairs.
40
+ */
41
+ function getPositionalArgs(args: string[]): string[] {
42
+ const positional: string[] = [];
43
+ let i = 0;
44
+ while (i < args.length) {
45
+ const arg = args[i];
46
+ if (arg?.startsWith("-")) {
47
+ if (BOOLEAN_FLAGS.has(arg)) {
48
+ i += 1;
49
+ } else {
50
+ i += 2;
51
+ }
52
+ } else {
53
+ if (arg !== undefined) {
54
+ positional.push(arg);
55
+ }
56
+ i += 1;
57
+ }
58
+ }
59
+ return positional;
60
+ }
61
+
62
+ /**
63
+ * Load the orchestrator's registered tmux session name.
64
+ *
65
+ * Written by `overstory prime` at SessionStart when the orchestrator
66
+ * is running inside tmux. Enables agents to nudge the orchestrator
67
+ * even though it's not tracked in the SessionStore.
68
+ */
69
+ async function loadOrchestratorTmuxSession(projectRoot: string): Promise<string | null> {
70
+ const regPath = join(projectRoot, ".overstory", "orchestrator-tmux.json");
71
+ const file = Bun.file(regPath);
72
+ if (!(await file.exists())) {
73
+ return null;
74
+ }
75
+ try {
76
+ const text = await file.text();
77
+ const reg = JSON.parse(text) as { tmuxSession?: string };
78
+ return reg.tmuxSession ?? null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolve the tmux session name for an agent.
86
+ *
87
+ * For regular agents, looks up the SessionStore.
88
+ * For "orchestrator", falls back to the orchestrator-tmux.json registration
89
+ * file written by `overstory prime`.
90
+ */
91
+ async function resolveTargetSession(
92
+ projectRoot: string,
93
+ agentName: string,
94
+ ): Promise<string | null> {
95
+ const overstoryDir = join(projectRoot, ".overstory");
96
+ const { store } = openSessionStore(overstoryDir);
97
+ try {
98
+ const session = store.getByName(agentName);
99
+ if (session && session.state !== "zombie" && session.state !== "completed") {
100
+ return session.tmuxSession;
101
+ }
102
+ } finally {
103
+ store.close();
104
+ }
105
+
106
+ // Fallback for orchestrator: check orchestrator-tmux.json
107
+ if (agentName === "orchestrator") {
108
+ return await loadOrchestratorTmuxSession(projectRoot);
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Check debounce state for an agent. Returns true if a nudge was sent
116
+ * within the debounce window and should be skipped.
117
+ */
118
+ async function isDebounced(statePath: string, agentName: string): Promise<boolean> {
119
+ const file = Bun.file(statePath);
120
+ if (!(await file.exists())) {
121
+ return false;
122
+ }
123
+ try {
124
+ const text = await file.text();
125
+ const state = JSON.parse(text) as Record<string, number>;
126
+ const lastNudge = state[agentName];
127
+ if (lastNudge === undefined) {
128
+ return false;
129
+ }
130
+ return Date.now() - lastNudge < DEBOUNCE_MS;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Record a nudge timestamp for debounce tracking.
138
+ */
139
+ async function recordNudge(statePath: string, agentName: string): Promise<void> {
140
+ let state: Record<string, number> = {};
141
+ const file = Bun.file(statePath);
142
+ if (await file.exists()) {
143
+ try {
144
+ const text = await file.text();
145
+ state = JSON.parse(text) as Record<string, number>;
146
+ } catch {
147
+ // Corrupt state file — start fresh
148
+ }
149
+ }
150
+ state[agentName] = Date.now();
151
+ await Bun.write(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 (overstory-t62v).
168
+ // Same workaround as sling.ts and coordinator.ts.
169
+ await Bun.sleep(500);
170
+ await sendKeys(tmuxSession, "");
171
+ return true;
172
+ } catch {
173
+ if (attempt < MAX_RETRIES) {
174
+ await Bun.sleep(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(overstoryDir: string): Promise<string | null> {
185
+ const path = join(overstoryDir, "current-run.txt");
186
+ const file = Bun.file(path);
187
+ if (!(await file.exists())) {
188
+ return null;
189
+ }
190
+ try {
191
+ const text = await file.text();
192
+ const trimmed = text.trim();
193
+ return trimmed.length > 0 ? trimmed : null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Fire-and-forget: record a nudge event to EventStore. Never throws.
201
+ */
202
+ function recordNudgeEvent(
203
+ eventStore: EventStore,
204
+ opts: {
205
+ runId: string | null;
206
+ agentName: string;
207
+ from: string;
208
+ message: string;
209
+ delivered: boolean;
210
+ },
211
+ ): void {
212
+ try {
213
+ eventStore.insert({
214
+ runId: opts.runId,
215
+ agentName: opts.agentName,
216
+ sessionId: null,
217
+ eventType: "custom",
218
+ toolName: null,
219
+ toolArgs: null,
220
+ toolDurationMs: null,
221
+ level: "info",
222
+ data: JSON.stringify({
223
+ type: "nudge",
224
+ from: opts.from,
225
+ message: opts.message,
226
+ delivered: opts.delivered,
227
+ }),
228
+ });
229
+ } catch {
230
+ // Fire-and-forget: event recording must never break nudge delivery
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Core nudge function. Exported for use by mail send auto-nudge.
236
+ *
237
+ * @param projectRoot - Absolute path to the project root
238
+ * @param agentName - Name of the agent to nudge
239
+ * @param message - Text to send (defaults to mail check prompt)
240
+ * @param force - Skip debounce check
241
+ * @returns Object with delivery status
242
+ */
243
+ export async function nudgeAgent(
244
+ projectRoot: string,
245
+ agentName: string,
246
+ message: string = DEFAULT_MESSAGE,
247
+ force = false,
248
+ ): Promise<{ delivered: boolean; reason?: string }> {
249
+ let result: { delivered: boolean; reason?: string };
250
+
251
+ // Resolve tmux session (SessionStore for agents, orchestrator-tmux.json for orchestrator)
252
+ const tmuxSessionName = await resolveTargetSession(projectRoot, agentName);
253
+
254
+ if (!tmuxSessionName) {
255
+ result = { delivered: false, reason: `No active session for agent "${agentName}"` };
256
+ } else {
257
+ // Check debounce (unless forced)
258
+ let debounced = false;
259
+ if (!force) {
260
+ const statePath = join(projectRoot, ".overstory", "nudge-state.json");
261
+ debounced = await isDebounced(statePath, agentName);
262
+ }
263
+
264
+ if (debounced) {
265
+ result = { delivered: false, reason: "Debounced: nudge sent too recently" };
266
+ } else {
267
+ // Verify tmux session is alive
268
+ const alive = await isSessionAlive(tmuxSessionName);
269
+ if (!alive) {
270
+ result = {
271
+ delivered: false,
272
+ reason: `Tmux session "${tmuxSessionName}" is not alive`,
273
+ };
274
+ } else {
275
+ // Send with retry
276
+ const delivered = await sendNudgeWithRetry(tmuxSessionName, message);
277
+
278
+ if (delivered) {
279
+ // Record nudge for debounce tracking
280
+ const statePath = join(projectRoot, ".overstory", "nudge-state.json");
281
+ await recordNudge(statePath, agentName);
282
+ result = { delivered: true };
283
+ } else {
284
+ result = {
285
+ delivered: false,
286
+ reason: `Failed to send after ${MAX_RETRIES} attempts`,
287
+ };
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // Record event to EventStore (fire-and-forget)
294
+ try {
295
+ const overstoryDir = join(projectRoot, ".overstory");
296
+ const eventsDbPath = join(overstoryDir, "events.db");
297
+ const eventStore = createEventStore(eventsDbPath);
298
+ try {
299
+ const runId = await readCurrentRunId(overstoryDir);
300
+ recordNudgeEvent(eventStore, {
301
+ runId,
302
+ agentName,
303
+ from: "orchestrator",
304
+ message,
305
+ delivered: result.delivered,
306
+ });
307
+ } finally {
308
+ eventStore.close();
309
+ }
310
+ } catch {
311
+ // Event recording failure is non-fatal
312
+ }
313
+
314
+ return result;
315
+ }
316
+
317
+ /**
318
+ * Entry point for `overstory nudge <agent-name> [message]`.
319
+ */
320
+ const NUDGE_HELP = `overstory nudge — Send a text nudge to an agent
321
+
322
+ Usage: overstory nudge <agent-name> [message]
323
+
324
+ Arguments:
325
+ <agent-name> Name of the agent to nudge
326
+ [message] Text to send (default: "${DEFAULT_MESSAGE}")
327
+
328
+ Options:
329
+ --from <name> Sender name for the nudge prefix (default: orchestrator)
330
+ --force Skip debounce check
331
+ --json Output result as JSON
332
+ --help, -h Show this help`;
333
+
334
+ export async function nudgeCommand(args: string[]): Promise<void> {
335
+ if (args.includes("--help") || args.includes("-h")) {
336
+ process.stdout.write(`${NUDGE_HELP}\n`);
337
+ return;
338
+ }
339
+
340
+ const positional = getPositionalArgs(args);
341
+ const agentName = positional[0];
342
+ if (!agentName || agentName.trim().length === 0) {
343
+ throw new ValidationError("Agent name is required: overstory nudge <agent-name> [message]", {
344
+ field: "agentName",
345
+ });
346
+ }
347
+
348
+ const from = getFlag(args, "--from") ?? "orchestrator";
349
+ const force = args.includes("--force");
350
+ const json = args.includes("--json");
351
+
352
+ // Build the nudge message: prefix with sender, use custom or default text
353
+ const customMessage = positional.slice(1).join(" ");
354
+ const rawMessage = customMessage.length > 0 ? customMessage : DEFAULT_MESSAGE;
355
+ const message = `[NUDGE from ${from}] ${rawMessage}`;
356
+
357
+ // Resolve project root
358
+ const { resolveProjectRoot } = await import("../config.ts");
359
+ const projectRoot = await resolveProjectRoot(process.cwd());
360
+
361
+ const result = await nudgeAgent(projectRoot, agentName, message, force);
362
+
363
+ if (json) {
364
+ process.stdout.write(
365
+ `${JSON.stringify({ agentName, delivered: result.delivered, reason: result.reason })}\n`,
366
+ );
367
+ } else if (result.delivered) {
368
+ process.stdout.write(`📢 Nudged "${agentName}"\n`);
369
+ } else {
370
+ throw new AgentError(`Nudge failed: ${result.reason}`, { agentName });
371
+ }
372
+ }