@os-eco/overstory-cli 0.9.3 → 0.10.3
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/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/commands/clean.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
killProcessTree,
|
|
38
38
|
killSession,
|
|
39
39
|
listSessions,
|
|
40
|
+
sanitizeTmuxName,
|
|
40
41
|
} from "../worktree/tmux.ts";
|
|
41
42
|
|
|
42
43
|
export interface CleanOptions {
|
|
@@ -123,6 +124,7 @@ async function logSyntheticSessionEndEvents(overstoryDir: string): Promise<numbe
|
|
|
123
124
|
interface CleanResult {
|
|
124
125
|
sessionEndEventsLogged: number;
|
|
125
126
|
tmuxKilled: number;
|
|
127
|
+
orphanPidsReaped: number;
|
|
126
128
|
worktreesCleaned: number;
|
|
127
129
|
branchesDeleted: number;
|
|
128
130
|
mailWiped: boolean;
|
|
@@ -155,7 +157,7 @@ interface CleanResult {
|
|
|
155
157
|
*/
|
|
156
158
|
async function killAllTmuxSessions(overstoryDir: string, projectName: string): Promise<number> {
|
|
157
159
|
let killed = 0;
|
|
158
|
-
const projectPrefix = `overstory-${projectName}-`;
|
|
160
|
+
const projectPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
|
|
159
161
|
try {
|
|
160
162
|
const tmuxSessions = await listSessions();
|
|
161
163
|
const overStorySessions = tmuxSessions.filter((s) => s.name.startsWith(projectPrefix));
|
|
@@ -216,6 +218,45 @@ function loadRegisteredTmuxNames(overstoryDir: string): Set<string> | null {
|
|
|
216
218
|
}
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Reap any spawn PIDs in sessions.db that survived tmux teardown.
|
|
223
|
+
*
|
|
224
|
+
* `killAllTmuxSessions` walks descendants of the live tmux pane PID and is
|
|
225
|
+
* sufficient for sessions whose tmux container is still up. This handles the
|
|
226
|
+
* leftover case: a stored pid that is still alive but its tmux session is
|
|
227
|
+
* gone (claude was reparented to init when its bash wrapper got SIGHUP) or
|
|
228
|
+
* the session is in a terminal state but the spawn never exited. Best-effort.
|
|
229
|
+
* (overstory-505d)
|
|
230
|
+
*/
|
|
231
|
+
async function reapOrphanedPids(overstoryDir: string): Promise<number> {
|
|
232
|
+
let reaped = 0;
|
|
233
|
+
try {
|
|
234
|
+
const dbPath = join(overstoryDir, "sessions.db");
|
|
235
|
+
const jsonPath = join(overstoryDir, "sessions.json");
|
|
236
|
+
if (!existsSync(dbPath) && !existsSync(jsonPath)) {
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
const { store } = openSessionStore(overstoryDir);
|
|
240
|
+
try {
|
|
241
|
+
for (const session of store.getAll()) {
|
|
242
|
+
if (session.pid === null) continue;
|
|
243
|
+
if (!isProcessAlive(session.pid)) continue;
|
|
244
|
+
try {
|
|
245
|
+
await killProcessTree(session.pid);
|
|
246
|
+
reaped++;
|
|
247
|
+
} catch {
|
|
248
|
+
// Best effort
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
store.close();
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// Best effort
|
|
256
|
+
}
|
|
257
|
+
return reaped;
|
|
258
|
+
}
|
|
259
|
+
|
|
219
260
|
/**
|
|
220
261
|
* Remove all overstory worktrees (force remove with branch deletion).
|
|
221
262
|
*/
|
|
@@ -567,6 +608,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
567
608
|
const result: CleanResult = {
|
|
568
609
|
sessionEndEventsLogged: 0,
|
|
569
610
|
tmuxKilled: 0,
|
|
611
|
+
orphanPidsReaped: 0,
|
|
570
612
|
worktreesCleaned: 0,
|
|
571
613
|
branchesDeleted: 0,
|
|
572
614
|
mailWiped: false,
|
|
@@ -608,6 +650,14 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
608
650
|
result.tmuxKilled = await killAllTmuxSessions(overstoryDir, config.project.name);
|
|
609
651
|
}
|
|
610
652
|
|
|
653
|
+
// 2b. Reap any orphaned spawn PIDs that survived tmux teardown.
|
|
654
|
+
// Must run after killAllTmuxSessions (which collects descendants of live
|
|
655
|
+
// panes) but before sessions.db is wiped (we need pid records to find
|
|
656
|
+
// orphans). (overstory-505d)
|
|
657
|
+
if (doWorktrees || all) {
|
|
658
|
+
result.orphanPidsReaped = await reapOrphanedPids(overstoryDir);
|
|
659
|
+
}
|
|
660
|
+
|
|
611
661
|
// 3. Remove worktrees
|
|
612
662
|
if (doWorktrees) {
|
|
613
663
|
result.worktreesCleaned = await cleanAllWorktrees(root);
|
|
@@ -669,6 +719,11 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
669
719
|
if (result.tmuxKilled > 0) {
|
|
670
720
|
lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
|
|
671
721
|
}
|
|
722
|
+
if (result.orphanPidsReaped > 0) {
|
|
723
|
+
lines.push(
|
|
724
|
+
`Reaped ${result.orphanPidsReaped} orphaned spawn process${result.orphanPidsReaped === 1 ? "" : "es"}`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
672
727
|
if (result.worktreesCleaned > 0) {
|
|
673
728
|
lines.push(
|
|
674
729
|
`Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
|
|
@@ -12,7 +12,10 @@ import {
|
|
|
12
12
|
} from "./completions.ts";
|
|
13
13
|
|
|
14
14
|
afterEach(() => {
|
|
15
|
-
|
|
15
|
+
// Use 0 not undefined — Bun doesn't reliably clear a nonzero exitCode when
|
|
16
|
+
// reassigned to undefined (see prior fix f3fde1a). If the 1 from completion
|
|
17
|
+
// tests leaks to bun test's shutdown, the suite exits 1 with 0 test failures.
|
|
18
|
+
process.exitCode = 0;
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
describe("COMMANDS array", () => {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
coordinatorCommand,
|
|
29
29
|
createCoordinatorCommand,
|
|
30
30
|
resolveAttach,
|
|
31
|
+
startCoordinatorSession,
|
|
31
32
|
} from "./coordinator.ts";
|
|
32
33
|
import {
|
|
33
34
|
buildOrchestratorBeacon,
|
|
@@ -2665,3 +2666,129 @@ describe("checkComplete", () => {
|
|
|
2665
2666
|
expect(subcommandNames).toContain("check-complete");
|
|
2666
2667
|
});
|
|
2667
2668
|
});
|
|
2669
|
+
|
|
2670
|
+
describe("startCoordinatorSession headless", () => {
|
|
2671
|
+
test("with headless: true, calls spawnHeadlessAgent and skips tmux", async () => {
|
|
2672
|
+
const { tmux, calls: tmuxCalls } = makeFakeTmux();
|
|
2673
|
+
const { watchdog } = makeFakeWatchdog();
|
|
2674
|
+
const { monitor } = makeFakeMonitor();
|
|
2675
|
+
|
|
2676
|
+
const spawnCalls: Array<{
|
|
2677
|
+
argv: string[];
|
|
2678
|
+
cwd: string;
|
|
2679
|
+
agentName?: string;
|
|
2680
|
+
}> = [];
|
|
2681
|
+
const writes: string[] = [];
|
|
2682
|
+
|
|
2683
|
+
const fakeSpawn = async (
|
|
2684
|
+
argv: string[],
|
|
2685
|
+
opts: { cwd: string; env: Record<string, string>; agentName?: string },
|
|
2686
|
+
): Promise<{
|
|
2687
|
+
pid: number;
|
|
2688
|
+
stdin: { write(data: string | Uint8Array): number | Promise<number> };
|
|
2689
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
2690
|
+
}> => {
|
|
2691
|
+
spawnCalls.push({ argv, cwd: opts.cwd, agentName: opts.agentName });
|
|
2692
|
+
return {
|
|
2693
|
+
pid: 55555,
|
|
2694
|
+
stdin: {
|
|
2695
|
+
write(data: string | Uint8Array): number {
|
|
2696
|
+
writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
2697
|
+
return 0;
|
|
2698
|
+
},
|
|
2699
|
+
},
|
|
2700
|
+
stdout: null,
|
|
2701
|
+
};
|
|
2702
|
+
};
|
|
2703
|
+
|
|
2704
|
+
const deps: CoordinatorDeps = {
|
|
2705
|
+
_tmux: tmux,
|
|
2706
|
+
_watchdog: watchdog,
|
|
2707
|
+
_monitor: monitor,
|
|
2708
|
+
_spawnHeadless: fakeSpawn,
|
|
2709
|
+
};
|
|
2710
|
+
|
|
2711
|
+
await captureStdout(async () => {
|
|
2712
|
+
await startCoordinatorSession(
|
|
2713
|
+
{
|
|
2714
|
+
json: true,
|
|
2715
|
+
attach: false,
|
|
2716
|
+
watchdog: false,
|
|
2717
|
+
monitor: false,
|
|
2718
|
+
headless: true,
|
|
2719
|
+
},
|
|
2720
|
+
deps,
|
|
2721
|
+
);
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
// spawnHeadlessAgent was called exactly once with agentName: "coordinator"
|
|
2725
|
+
expect(spawnCalls.length).toBe(1);
|
|
2726
|
+
expect(spawnCalls[0]?.agentName).toBe("coordinator");
|
|
2727
|
+
expect(spawnCalls[0]?.cwd).toBe(tempDir);
|
|
2728
|
+
|
|
2729
|
+
// initial stdin prompt was written
|
|
2730
|
+
expect(writes.length).toBeGreaterThanOrEqual(1);
|
|
2731
|
+
|
|
2732
|
+
// tmux helpers were never called for the headless path
|
|
2733
|
+
expect(tmuxCalls.createSession.length).toBe(0);
|
|
2734
|
+
expect(tmuxCalls.sendKeys.length).toBe(0);
|
|
2735
|
+
expect(tmuxCalls.waitForTuiReady.length).toBe(0);
|
|
2736
|
+
expect(tmuxCalls.ensureTmuxAvailable).toBe(0);
|
|
2737
|
+
|
|
2738
|
+
// Session row records empty tmuxSession + the headless spawn pid
|
|
2739
|
+
const sessions = loadSessionsFromDb();
|
|
2740
|
+
expect(sessions.length).toBe(1);
|
|
2741
|
+
expect(sessions[0]?.agentName).toBe("coordinator");
|
|
2742
|
+
expect(sessions[0]?.tmuxSession).toBe("");
|
|
2743
|
+
expect(sessions[0]?.pid).toBe(55555);
|
|
2744
|
+
expect(sessions[0]?.state).toBe("booting");
|
|
2745
|
+
|
|
2746
|
+
// current-run.txt was written for downstream consumers
|
|
2747
|
+
const runFile = Bun.file(join(overstoryDir, "current-run.txt"));
|
|
2748
|
+
expect(await runFile.exists()).toBe(true);
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
test("rejects when runtime has no buildDirectSpawn", async () => {
|
|
2752
|
+
// Override config to route the coordinator capability to a runtime that
|
|
2753
|
+
// lacks buildDirectSpawn (e.g. cursor). The headless path must reject.
|
|
2754
|
+
await Bun.write(
|
|
2755
|
+
join(overstoryDir, "config.yaml"),
|
|
2756
|
+
[
|
|
2757
|
+
"project:",
|
|
2758
|
+
" name: test-project",
|
|
2759
|
+
` root: ${tempDir}`,
|
|
2760
|
+
" canonicalBranch: main",
|
|
2761
|
+
"watchdog:",
|
|
2762
|
+
" tier2Enabled: true",
|
|
2763
|
+
"runtime:",
|
|
2764
|
+
" capabilities:",
|
|
2765
|
+
" coordinator: cursor",
|
|
2766
|
+
].join("\n"),
|
|
2767
|
+
);
|
|
2768
|
+
|
|
2769
|
+
const { tmux } = makeFakeTmux();
|
|
2770
|
+
const { watchdog } = makeFakeWatchdog();
|
|
2771
|
+
const { monitor } = makeFakeMonitor();
|
|
2772
|
+
const deps: CoordinatorDeps = {
|
|
2773
|
+
_tmux: tmux,
|
|
2774
|
+
_watchdog: watchdog,
|
|
2775
|
+
_monitor: monitor,
|
|
2776
|
+
_spawnHeadless: async () => {
|
|
2777
|
+
throw new Error("should not be called");
|
|
2778
|
+
},
|
|
2779
|
+
};
|
|
2780
|
+
|
|
2781
|
+
await expect(
|
|
2782
|
+
startCoordinatorSession(
|
|
2783
|
+
{
|
|
2784
|
+
json: true,
|
|
2785
|
+
attach: false,
|
|
2786
|
+
watchdog: false,
|
|
2787
|
+
monitor: false,
|
|
2788
|
+
headless: true,
|
|
2789
|
+
},
|
|
2790
|
+
deps,
|
|
2791
|
+
),
|
|
2792
|
+
).rejects.toThrow(ValidationError);
|
|
2793
|
+
});
|
|
2794
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { mkdir, unlink } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { Command } from "commander";
|
|
18
|
+
import { buildInitialHeadlessPrompt, formatMailSection } from "../agents/headless-prompt.ts";
|
|
18
19
|
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
19
20
|
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
20
21
|
import { loadConfig } from "../config.ts";
|
|
@@ -29,6 +30,8 @@ import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
|
29
30
|
import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
30
31
|
import type { AgentSession } from "../types.ts";
|
|
31
32
|
import { isProcessRunning } from "../watchdog/health.ts";
|
|
33
|
+
import type { SpawnHeadlessOptions } from "../worktree/process.ts";
|
|
34
|
+
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
32
35
|
import type { SessionState } from "../worktree/tmux.ts";
|
|
33
36
|
import {
|
|
34
37
|
capturePaneContent,
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
ensureTmuxAvailable,
|
|
38
41
|
isSessionAlive,
|
|
39
42
|
killSession,
|
|
43
|
+
sanitizeTmuxName,
|
|
40
44
|
sendKeys,
|
|
41
45
|
TMUX_SOCKET,
|
|
42
46
|
waitForTuiReady,
|
|
@@ -45,7 +49,7 @@ import { nudgeAgent } from "./nudge.ts";
|
|
|
45
49
|
import { isRunningAsRoot } from "./sling.ts";
|
|
46
50
|
|
|
47
51
|
/** Default coordinator agent name. */
|
|
48
|
-
const COORDINATOR_NAME = "coordinator";
|
|
52
|
+
export const COORDINATOR_NAME = "coordinator";
|
|
49
53
|
|
|
50
54
|
export interface PersistentAgentSpec {
|
|
51
55
|
commandName: string;
|
|
@@ -76,7 +80,7 @@ const ASK_DEFAULT_TIMEOUT_S = 120;
|
|
|
76
80
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
77
81
|
*/
|
|
78
82
|
function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
|
|
79
|
-
return `overstory-${projectName}-${name}`;
|
|
83
|
+
return `overstory-${sanitizeTmuxName(projectName)}-${name}`;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
/** Dependency injection for testing. Uses real implementations when omitted. */
|
|
@@ -119,6 +123,15 @@ export interface CoordinatorDeps {
|
|
|
119
123
|
_capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
|
|
120
124
|
/** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
|
|
121
125
|
_pollIntervalMs?: number;
|
|
126
|
+
/** Override headless spawn (used by tests to avoid forking real subprocesses). */
|
|
127
|
+
_spawnHeadless?: (
|
|
128
|
+
argv: string[],
|
|
129
|
+
opts: SpawnHeadlessOptions,
|
|
130
|
+
) => Promise<{
|
|
131
|
+
pid: number;
|
|
132
|
+
stdin: { write(data: string | Uint8Array): number | Promise<number> };
|
|
133
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
134
|
+
}>;
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
/**
|
|
@@ -331,6 +344,13 @@ export interface CoordinatorSessionOptions {
|
|
|
331
344
|
displayName?: string;
|
|
332
345
|
/** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
|
|
333
346
|
beaconBuilder?: (trackerCli: string) => string;
|
|
347
|
+
/**
|
|
348
|
+
* When true, spawn the coordinator headless (no tmux pane). The runtime must
|
|
349
|
+
* implement buildDirectSpawn(). The CLI command `ov coordinator start` does
|
|
350
|
+
* not yet pass this flag — it is consumed by the headless start path used by
|
|
351
|
+
* the web UI's POST /api/coordinator/start endpoint.
|
|
352
|
+
*/
|
|
353
|
+
headless?: boolean;
|
|
334
354
|
}
|
|
335
355
|
|
|
336
356
|
/**
|
|
@@ -364,6 +384,7 @@ export async function startCoordinatorSession(
|
|
|
364
384
|
agentDefFile: agentDefFileOpt,
|
|
365
385
|
displayName: displayNameOpt,
|
|
366
386
|
beaconBuilder: beaconBuilderOpt,
|
|
387
|
+
headless: headlessFlag,
|
|
367
388
|
} = opts;
|
|
368
389
|
|
|
369
390
|
const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
|
|
@@ -458,6 +479,157 @@ export async function startCoordinatorSession(
|
|
|
458
479
|
});
|
|
459
480
|
}
|
|
460
481
|
|
|
482
|
+
// Headless start path: bypass tmux entirely and spawn the coordinator
|
|
483
|
+
// process directly via runtime.buildDirectSpawn(). Same hooks, identity,
|
|
484
|
+
// and run-tracking as the tmux path — only the spawn mechanism differs.
|
|
485
|
+
if (headlessFlag === true) {
|
|
486
|
+
if (!runtime.buildDirectSpawn) {
|
|
487
|
+
throw new ValidationError(
|
|
488
|
+
`Headless coordinator start requires a runtime with buildDirectSpawn (got: ${runtime.id})`,
|
|
489
|
+
{ field: "runtime", value: runtime.id },
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const spawnHeadless = deps._spawnHeadless ?? spawnHeadlessAgent;
|
|
494
|
+
const directEnv: Record<string, string> = {
|
|
495
|
+
...runtime.buildEnv(resolvedModel),
|
|
496
|
+
OVERSTORY_AGENT_NAME: coordinatorName,
|
|
497
|
+
OVERSTORY_PROJECT_ROOT: projectRoot,
|
|
498
|
+
...(profileFlag ? { OVERSTORY_PROFILE: profileFlag } : {}),
|
|
499
|
+
};
|
|
500
|
+
const argv = runtime.buildDirectSpawn({
|
|
501
|
+
cwd: projectRoot,
|
|
502
|
+
env: directEnv,
|
|
503
|
+
...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
|
|
504
|
+
instructionPath: runtime.instructionPath,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Per-session log dir mirrors sling.ts headless path.
|
|
508
|
+
const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
509
|
+
const headlessLogDir = join(overstoryDir, "logs", "coordinator", logTimestamp);
|
|
510
|
+
await mkdir(headlessLogDir, { recursive: true });
|
|
511
|
+
|
|
512
|
+
const headlessProc = await spawnHeadless(argv, {
|
|
513
|
+
cwd: projectRoot,
|
|
514
|
+
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
515
|
+
stdoutFile: join(headlessLogDir, "stdout.log"),
|
|
516
|
+
stderrFile: join(headlessLogDir, "stderr.log"),
|
|
517
|
+
agentName: coordinatorName,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Build the initial stdin prompt from agent definition + pending dispatch
|
|
521
|
+
// mail + activation beacon. Replaces SessionStart hooks (no-op headless).
|
|
522
|
+
const agentDefPath = join(projectRoot, ".overstory", "agent-defs", agentDefFile);
|
|
523
|
+
const agentDefHandle = Bun.file(agentDefPath);
|
|
524
|
+
const primeContext = (await agentDefHandle.exists()) ? await agentDefHandle.text() : "";
|
|
525
|
+
|
|
526
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
527
|
+
const pendingMailStore = createMailStore(mailDbPath);
|
|
528
|
+
let mailSection = "";
|
|
529
|
+
try {
|
|
530
|
+
const pendingMailClient = createMailClient(pendingMailStore);
|
|
531
|
+
const pendingMessages = pendingMailClient.check(coordinatorName);
|
|
532
|
+
mailSection = formatMailSection(pendingMessages);
|
|
533
|
+
} finally {
|
|
534
|
+
pendingMailStore.close();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
|
|
538
|
+
const trackerCli = trackerCliName(resolvedBackend);
|
|
539
|
+
const beacon = beaconBuilder(trackerCli);
|
|
540
|
+
const initialPrompt = buildInitialHeadlessPrompt(
|
|
541
|
+
primeContext || undefined,
|
|
542
|
+
mailSection || undefined,
|
|
543
|
+
beacon,
|
|
544
|
+
);
|
|
545
|
+
await headlessProc.stdin.write(initialPrompt);
|
|
546
|
+
|
|
547
|
+
// Create run record + current-run.txt + session row.
|
|
548
|
+
const sessionId = `session-${Date.now()}-${coordinatorName}`;
|
|
549
|
+
const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
550
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
551
|
+
try {
|
|
552
|
+
runStore.createRun({
|
|
553
|
+
id: runId,
|
|
554
|
+
startedAt: new Date().toISOString(),
|
|
555
|
+
coordinatorSessionId: sessionId,
|
|
556
|
+
coordinatorName,
|
|
557
|
+
status: "active",
|
|
558
|
+
});
|
|
559
|
+
} finally {
|
|
560
|
+
runStore.close();
|
|
561
|
+
}
|
|
562
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
563
|
+
|
|
564
|
+
const session: AgentSession = {
|
|
565
|
+
id: sessionId,
|
|
566
|
+
agentName: coordinatorName,
|
|
567
|
+
capability,
|
|
568
|
+
worktreePath: projectRoot,
|
|
569
|
+
branchName: config.project.canonicalBranch,
|
|
570
|
+
taskId: "",
|
|
571
|
+
tmuxSession: "", // headless: no tmux pane
|
|
572
|
+
state: "booting",
|
|
573
|
+
pid: headlessProc.pid,
|
|
574
|
+
parentAgent: null,
|
|
575
|
+
depth: 0,
|
|
576
|
+
runId,
|
|
577
|
+
startedAt: new Date().toISOString(),
|
|
578
|
+
lastActivity: new Date().toISOString(),
|
|
579
|
+
escalationLevel: 0,
|
|
580
|
+
stalledSince: null,
|
|
581
|
+
transcriptPath: null,
|
|
582
|
+
};
|
|
583
|
+
store.upsert(session);
|
|
584
|
+
|
|
585
|
+
// Auto-start watchdog / monitor (same as tmux path).
|
|
586
|
+
let watchdogPid: number | undefined;
|
|
587
|
+
if (watchdogFlag) {
|
|
588
|
+
const watchdogResult = await watchdog.start();
|
|
589
|
+
if (watchdogResult) {
|
|
590
|
+
watchdogPid = watchdogResult.pid;
|
|
591
|
+
if (!json) printHint("Watchdog started");
|
|
592
|
+
} else {
|
|
593
|
+
if (!json) printWarning("Watchdog failed to start");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
let monitorPid: number | undefined;
|
|
597
|
+
if (monitorFlag) {
|
|
598
|
+
if (!config.watchdog.tier2Enabled) {
|
|
599
|
+
if (!json) printWarning("Monitor skipped", "watchdog.tier2Enabled is false");
|
|
600
|
+
} else {
|
|
601
|
+
const monitorResult = await monitor.start([]);
|
|
602
|
+
if (monitorResult) {
|
|
603
|
+
monitorPid = monitorResult.pid;
|
|
604
|
+
if (!json) printHint("Monitor started");
|
|
605
|
+
} else {
|
|
606
|
+
if (!json) printWarning("Monitor failed to start");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const output = {
|
|
612
|
+
agentName: coordinatorName,
|
|
613
|
+
capability,
|
|
614
|
+
tmuxSession: "",
|
|
615
|
+
projectRoot,
|
|
616
|
+
pid: headlessProc.pid,
|
|
617
|
+
headless: true,
|
|
618
|
+
watchdog: watchdogFlag ? watchdogPid !== undefined : false,
|
|
619
|
+
monitor: monitorFlag ? monitorPid !== undefined : false,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
if (json) {
|
|
623
|
+
jsonOutput(`${capability} start`, output);
|
|
624
|
+
} else {
|
|
625
|
+
printSuccess(`${displayName} started (headless)`);
|
|
626
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
627
|
+
process.stdout.write(` PID: ${headlessProc.pid}\n`);
|
|
628
|
+
process.stdout.write(` Logs: ${headlessLogDir}\n`);
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
461
633
|
// Preflight: verify tmux is installed before attempting to spawn.
|
|
462
634
|
// Without this check, a missing tmux leads to cryptic errors later.
|
|
463
635
|
await tmux.ensureTmuxAvailable();
|
|
@@ -628,6 +800,7 @@ export async function startCoordinatorSession(
|
|
|
628
800
|
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
629
801
|
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
630
802
|
process.stdout.write(` PID: ${pid}\n`);
|
|
803
|
+
printHint("Open the UI: `ov serve` then http://localhost:7321 — primary operator surface");
|
|
631
804
|
}
|
|
632
805
|
|
|
633
806
|
if (shouldAttach) {
|
|
@@ -678,6 +851,18 @@ function isActivePersistentAgentSession(
|
|
|
678
851
|
* 3. Mark session as completed in SessionStore
|
|
679
852
|
* 4. Auto-complete the active run (if current-run.txt exists)
|
|
680
853
|
*/
|
|
854
|
+
/**
|
|
855
|
+
* Stop the default coordinator. Handles both tmux and headless sessions.
|
|
856
|
+
* Exposed for callers outside the CLI command surface (e.g. the web-UI POST
|
|
857
|
+
* /api/coordinator/stop endpoint, which lives in coordinator-actions.ts).
|
|
858
|
+
*/
|
|
859
|
+
export async function stopCoordinatorSession(
|
|
860
|
+
opts: { json: boolean },
|
|
861
|
+
deps: CoordinatorDeps = {},
|
|
862
|
+
): Promise<void> {
|
|
863
|
+
await stopPersistentAgent(COORDINATOR_SPEC, opts, deps);
|
|
864
|
+
}
|
|
865
|
+
|
|
681
866
|
async function stopPersistentAgent(
|
|
682
867
|
spec: PersistentAgentSpec,
|
|
683
868
|
opts: { json: boolean },
|
|
@@ -711,10 +896,24 @@ async function stopPersistentAgent(
|
|
|
711
896
|
});
|
|
712
897
|
}
|
|
713
898
|
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
if (
|
|
717
|
-
await
|
|
899
|
+
// Headless sessions have no tmux pane (tmuxSession === ""). Tear down via
|
|
900
|
+
// the connection registry (SIGTERM-with-SIGKILL-escalation) and skip tmux.
|
|
901
|
+
if (session.tmuxSession === "") {
|
|
902
|
+
const { removeConnection } = await import("../runtimes/connections.ts");
|
|
903
|
+
removeConnection(spec.agentName);
|
|
904
|
+
if (session.pid !== null && isProcessRunning(session.pid)) {
|
|
905
|
+
try {
|
|
906
|
+
process.kill(session.pid, "SIGTERM");
|
|
907
|
+
} catch {
|
|
908
|
+
// process may have exited between the check and the signal
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
// Kill tmux session with process tree cleanup
|
|
913
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
914
|
+
if (alive) {
|
|
915
|
+
await tmux.killSession(session.tmuxSession);
|
|
916
|
+
}
|
|
718
917
|
}
|
|
719
918
|
|
|
720
919
|
// Always attempt to stop watchdog
|