@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|