@os-eco/overstory-cli 0.9.4 → 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 +47 -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 +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- 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/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 +73 -1
- package/src/commands/sling.ts +149 -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/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- 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 +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/commands/clean.ts
CHANGED
|
@@ -124,6 +124,7 @@ async function logSyntheticSessionEndEvents(overstoryDir: string): Promise<numbe
|
|
|
124
124
|
interface CleanResult {
|
|
125
125
|
sessionEndEventsLogged: number;
|
|
126
126
|
tmuxKilled: number;
|
|
127
|
+
orphanPidsReaped: number;
|
|
127
128
|
worktreesCleaned: number;
|
|
128
129
|
branchesDeleted: number;
|
|
129
130
|
mailWiped: boolean;
|
|
@@ -217,6 +218,45 @@ function loadRegisteredTmuxNames(overstoryDir: string): Set<string> | null {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
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
|
+
|
|
220
260
|
/**
|
|
221
261
|
* Remove all overstory worktrees (force remove with branch deletion).
|
|
222
262
|
*/
|
|
@@ -568,6 +608,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
568
608
|
const result: CleanResult = {
|
|
569
609
|
sessionEndEventsLogged: 0,
|
|
570
610
|
tmuxKilled: 0,
|
|
611
|
+
orphanPidsReaped: 0,
|
|
571
612
|
worktreesCleaned: 0,
|
|
572
613
|
branchesDeleted: 0,
|
|
573
614
|
mailWiped: false,
|
|
@@ -609,6 +650,14 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
609
650
|
result.tmuxKilled = await killAllTmuxSessions(overstoryDir, config.project.name);
|
|
610
651
|
}
|
|
611
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
|
+
|
|
612
661
|
// 3. Remove worktrees
|
|
613
662
|
if (doWorktrees) {
|
|
614
663
|
result.worktreesCleaned = await cleanAllWorktrees(root);
|
|
@@ -670,6 +719,11 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
670
719
|
if (result.tmuxKilled > 0) {
|
|
671
720
|
lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
|
|
672
721
|
}
|
|
722
|
+
if (result.orphanPidsReaped > 0) {
|
|
723
|
+
lines.push(
|
|
724
|
+
`Reaped ${result.orphanPidsReaped} orphaned spawn process${result.orphanPidsReaped === 1 ? "" : "es"}`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
673
727
|
if (result.worktreesCleaned > 0) {
|
|
674
728
|
lines.push(
|
|
675
729
|
`Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
|
|
@@ -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,
|
|
@@ -46,7 +49,7 @@ import { nudgeAgent } from "./nudge.ts";
|
|
|
46
49
|
import { isRunningAsRoot } from "./sling.ts";
|
|
47
50
|
|
|
48
51
|
/** Default coordinator agent name. */
|
|
49
|
-
const COORDINATOR_NAME = "coordinator";
|
|
52
|
+
export const COORDINATOR_NAME = "coordinator";
|
|
50
53
|
|
|
51
54
|
export interface PersistentAgentSpec {
|
|
52
55
|
commandName: string;
|
|
@@ -120,6 +123,15 @@ export interface CoordinatorDeps {
|
|
|
120
123
|
_capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
|
|
121
124
|
/** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
|
|
122
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
|
+
}>;
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
/**
|
|
@@ -332,6 +344,13 @@ export interface CoordinatorSessionOptions {
|
|
|
332
344
|
displayName?: string;
|
|
333
345
|
/** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
|
|
334
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;
|
|
335
354
|
}
|
|
336
355
|
|
|
337
356
|
/**
|
|
@@ -365,6 +384,7 @@ export async function startCoordinatorSession(
|
|
|
365
384
|
agentDefFile: agentDefFileOpt,
|
|
366
385
|
displayName: displayNameOpt,
|
|
367
386
|
beaconBuilder: beaconBuilderOpt,
|
|
387
|
+
headless: headlessFlag,
|
|
368
388
|
} = opts;
|
|
369
389
|
|
|
370
390
|
const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
|
|
@@ -459,6 +479,157 @@ export async function startCoordinatorSession(
|
|
|
459
479
|
});
|
|
460
480
|
}
|
|
461
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
|
+
|
|
462
633
|
// Preflight: verify tmux is installed before attempting to spawn.
|
|
463
634
|
// Without this check, a missing tmux leads to cryptic errors later.
|
|
464
635
|
await tmux.ensureTmuxAvailable();
|
|
@@ -629,6 +800,7 @@ export async function startCoordinatorSession(
|
|
|
629
800
|
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
630
801
|
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
631
802
|
process.stdout.write(` PID: ${pid}\n`);
|
|
803
|
+
printHint("Open the UI: `ov serve` then http://localhost:7321 — primary operator surface");
|
|
632
804
|
}
|
|
633
805
|
|
|
634
806
|
if (shouldAttach) {
|
|
@@ -679,6 +851,18 @@ function isActivePersistentAgentSession(
|
|
|
679
851
|
* 3. Mark session as completed in SessionStore
|
|
680
852
|
* 4. Auto-complete the active run (if current-run.txt exists)
|
|
681
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
|
+
|
|
682
866
|
async function stopPersistentAgent(
|
|
683
867
|
spec: PersistentAgentSpec,
|
|
684
868
|
opts: { json: boolean },
|
|
@@ -712,10 +896,24 @@ async function stopPersistentAgent(
|
|
|
712
896
|
});
|
|
713
897
|
}
|
|
714
898
|
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
if (
|
|
718
|
-
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
|
+
}
|
|
719
917
|
}
|
|
720
918
|
|
|
721
919
|
// Always attempt to stop watchdog
|