@os-eco/overstory-cli 0.7.9 → 0.8.2
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 +16 -7
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/dashboard.test.ts +86 -0
- package/src/commands/dashboard.ts +8 -4
- package/src/commands/feed.test.ts +8 -0
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.ts +218 -121
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +134 -0
- package/src/commands/stop.ts +41 -11
- package/src/commands/trace.test.ts +8 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/connections.test.ts +74 -0
- package/src/runtimes/connections.ts +34 -0
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/sapling.test.ts +1237 -0
- package/src/runtimes/sapling.ts +698 -0
- package/src/runtimes/types.ts +45 -0
- package/src/types.ts +5 -1
- package/src/watchdog/daemon.ts +34 -0
- package/src/watchdog/health.test.ts +102 -0
- package/src/watchdog/health.ts +140 -69
- package/src/worktree/process.test.ts +101 -0
- package/src/worktree/process.ts +111 -0
- package/src/worktree/tmux.ts +5 -0
|
@@ -21,6 +21,8 @@ import { loadConfig } from "../config.ts";
|
|
|
21
21
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
22
22
|
import { jsonOutput } from "../json.ts";
|
|
23
23
|
import { printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
24
|
+
import { createMailClient } from "../mail/client.ts";
|
|
25
|
+
import { createMailStore } from "../mail/store.ts";
|
|
24
26
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
25
27
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
26
28
|
import { createRunStore } from "../sessions/store.ts";
|
|
@@ -29,6 +31,7 @@ import type { AgentSession } from "../types.ts";
|
|
|
29
31
|
import { isProcessRunning } from "../watchdog/health.ts";
|
|
30
32
|
import type { SessionState } from "../worktree/tmux.ts";
|
|
31
33
|
import {
|
|
34
|
+
capturePaneContent,
|
|
32
35
|
checkSessionState,
|
|
33
36
|
createSession,
|
|
34
37
|
ensureTmuxAvailable,
|
|
@@ -37,11 +40,18 @@ import {
|
|
|
37
40
|
sendKeys,
|
|
38
41
|
waitForTuiReady,
|
|
39
42
|
} from "../worktree/tmux.ts";
|
|
43
|
+
import { nudgeAgent } from "./nudge.ts";
|
|
40
44
|
import { isRunningAsRoot } from "./sling.ts";
|
|
41
45
|
|
|
42
46
|
/** Default coordinator agent name. */
|
|
43
47
|
const COORDINATOR_NAME = "coordinator";
|
|
44
48
|
|
|
49
|
+
/** Poll interval for the ask subcommand reply loop. */
|
|
50
|
+
const ASK_POLL_INTERVAL_MS = 2_000;
|
|
51
|
+
|
|
52
|
+
/** Default timeout in seconds for the ask subcommand. */
|
|
53
|
+
const ASK_DEFAULT_TIMEOUT_S = 120;
|
|
54
|
+
|
|
45
55
|
/**
|
|
46
56
|
* Build the tmux session name for the coordinator.
|
|
47
57
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
@@ -81,6 +91,15 @@ export interface CoordinatorDeps {
|
|
|
81
91
|
stop: () => Promise<boolean>;
|
|
82
92
|
isRunning: () => Promise<boolean>;
|
|
83
93
|
};
|
|
94
|
+
_nudge?: (
|
|
95
|
+
projectRoot: string,
|
|
96
|
+
agentName: string,
|
|
97
|
+
message: string,
|
|
98
|
+
force: boolean,
|
|
99
|
+
) => Promise<{ delivered: boolean; reason?: string }>;
|
|
100
|
+
_capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
|
|
101
|
+
/** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
|
|
102
|
+
_pollIntervalMs?: number;
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
/**
|
|
@@ -738,6 +757,303 @@ async function statusCoordinator(
|
|
|
738
757
|
}
|
|
739
758
|
}
|
|
740
759
|
|
|
760
|
+
/**
|
|
761
|
+
* Send a fire-and-forget message to the running coordinator.
|
|
762
|
+
*
|
|
763
|
+
* Sends a mail message (from: operator, type: dispatch) and auto-nudges the
|
|
764
|
+
* coordinator via tmux sendKeys. Replaces the two-step `ov mail send + ov nudge` pattern.
|
|
765
|
+
*/
|
|
766
|
+
async function sendToCoordinator(
|
|
767
|
+
body: string,
|
|
768
|
+
opts: { subject: string; json: boolean },
|
|
769
|
+
deps: CoordinatorDeps = {},
|
|
770
|
+
): Promise<void> {
|
|
771
|
+
const tmux = deps._tmux ?? {
|
|
772
|
+
createSession,
|
|
773
|
+
isSessionAlive,
|
|
774
|
+
checkSessionState,
|
|
775
|
+
killSession,
|
|
776
|
+
sendKeys,
|
|
777
|
+
waitForTuiReady,
|
|
778
|
+
ensureTmuxAvailable,
|
|
779
|
+
};
|
|
780
|
+
const nudge = deps._nudge ?? nudgeAgent;
|
|
781
|
+
|
|
782
|
+
const { subject, json } = opts;
|
|
783
|
+
const cwd = process.cwd();
|
|
784
|
+
const config = await loadConfig(cwd);
|
|
785
|
+
const projectRoot = config.project.root;
|
|
786
|
+
|
|
787
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
788
|
+
const { store } = openSessionStore(overstoryDir);
|
|
789
|
+
try {
|
|
790
|
+
const session = store.getByName(COORDINATOR_NAME);
|
|
791
|
+
|
|
792
|
+
if (
|
|
793
|
+
!session ||
|
|
794
|
+
session.capability !== "coordinator" ||
|
|
795
|
+
session.state === "completed" ||
|
|
796
|
+
session.state === "zombie"
|
|
797
|
+
) {
|
|
798
|
+
throw new AgentError("No active coordinator session found", {
|
|
799
|
+
agentName: COORDINATOR_NAME,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
804
|
+
if (!alive) {
|
|
805
|
+
store.updateState(COORDINATOR_NAME, "zombie");
|
|
806
|
+
store.updateLastActivity(COORDINATOR_NAME);
|
|
807
|
+
throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
|
|
808
|
+
agentName: COORDINATOR_NAME,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Send mail
|
|
813
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
814
|
+
const mailStore = createMailStore(mailDbPath);
|
|
815
|
+
const mailClient = createMailClient(mailStore);
|
|
816
|
+
let id: string;
|
|
817
|
+
try {
|
|
818
|
+
id = mailClient.send({
|
|
819
|
+
from: "operator",
|
|
820
|
+
to: COORDINATOR_NAME,
|
|
821
|
+
subject,
|
|
822
|
+
body,
|
|
823
|
+
type: "dispatch",
|
|
824
|
+
priority: "normal",
|
|
825
|
+
});
|
|
826
|
+
} finally {
|
|
827
|
+
mailClient.close();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Auto-nudge (fire-and-forget)
|
|
831
|
+
const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
|
|
832
|
+
let nudged = false;
|
|
833
|
+
try {
|
|
834
|
+
const nudgeResult = await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
|
|
835
|
+
nudged = nudgeResult.delivered;
|
|
836
|
+
} catch {
|
|
837
|
+
// Nudge is fire-and-forget — silently ignore errors
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (json) {
|
|
841
|
+
jsonOutput("coordinator send", { id, nudged });
|
|
842
|
+
} else {
|
|
843
|
+
printSuccess("Sent to coordinator", id);
|
|
844
|
+
}
|
|
845
|
+
} finally {
|
|
846
|
+
store.close();
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Send a synchronous request to the coordinator and wait for a reply.
|
|
852
|
+
*
|
|
853
|
+
* Sends a mail message (from: operator, type: dispatch) with a correlationId,
|
|
854
|
+
* auto-nudges the coordinator via tmux, then polls mail.db for a reply in the
|
|
855
|
+
* same thread. Prints the reply body (or structured JSON) and exits.
|
|
856
|
+
* Throws AgentError if no reply arrives before the timeout.
|
|
857
|
+
*/
|
|
858
|
+
export async function askCoordinator(
|
|
859
|
+
body: string,
|
|
860
|
+
opts: { subject: string; timeout: number; json: boolean },
|
|
861
|
+
deps: CoordinatorDeps = {},
|
|
862
|
+
): Promise<void> {
|
|
863
|
+
const tmux = deps._tmux ?? {
|
|
864
|
+
createSession,
|
|
865
|
+
isSessionAlive,
|
|
866
|
+
checkSessionState,
|
|
867
|
+
killSession,
|
|
868
|
+
sendKeys,
|
|
869
|
+
waitForTuiReady,
|
|
870
|
+
ensureTmuxAvailable,
|
|
871
|
+
};
|
|
872
|
+
const nudge = deps._nudge ?? nudgeAgent;
|
|
873
|
+
const pollIntervalMs = deps._pollIntervalMs ?? ASK_POLL_INTERVAL_MS;
|
|
874
|
+
|
|
875
|
+
const { subject, timeout, json } = opts;
|
|
876
|
+
const cwd = process.cwd();
|
|
877
|
+
const config = await loadConfig(cwd);
|
|
878
|
+
const projectRoot = config.project.root;
|
|
879
|
+
|
|
880
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
881
|
+
const { store } = openSessionStore(overstoryDir);
|
|
882
|
+
try {
|
|
883
|
+
const session = store.getByName(COORDINATOR_NAME);
|
|
884
|
+
|
|
885
|
+
if (
|
|
886
|
+
!session ||
|
|
887
|
+
session.capability !== "coordinator" ||
|
|
888
|
+
session.state === "completed" ||
|
|
889
|
+
session.state === "zombie"
|
|
890
|
+
) {
|
|
891
|
+
throw new AgentError("No active coordinator session found", {
|
|
892
|
+
agentName: COORDINATOR_NAME,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
897
|
+
if (!alive) {
|
|
898
|
+
store.updateState(COORDINATOR_NAME, "zombie");
|
|
899
|
+
store.updateLastActivity(COORDINATOR_NAME);
|
|
900
|
+
throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
|
|
901
|
+
agentName: COORDINATOR_NAME,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Generate correlation ID for tracking this request/response pair
|
|
906
|
+
const correlationId = crypto.randomUUID();
|
|
907
|
+
|
|
908
|
+
// Send mail with correlationId in payload
|
|
909
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
910
|
+
const mailStore = createMailStore(mailDbPath);
|
|
911
|
+
const mailClient = createMailClient(mailStore);
|
|
912
|
+
let sentId: string;
|
|
913
|
+
try {
|
|
914
|
+
sentId = mailClient.send({
|
|
915
|
+
from: "operator",
|
|
916
|
+
to: COORDINATOR_NAME,
|
|
917
|
+
subject,
|
|
918
|
+
body,
|
|
919
|
+
type: "dispatch",
|
|
920
|
+
priority: "normal",
|
|
921
|
+
payload: JSON.stringify({ correlationId }),
|
|
922
|
+
});
|
|
923
|
+
} finally {
|
|
924
|
+
mailClient.close();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Auto-nudge (fire-and-forget)
|
|
928
|
+
const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
|
|
929
|
+
try {
|
|
930
|
+
await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
|
|
931
|
+
} catch {
|
|
932
|
+
// Nudge is fire-and-forget — silently ignore errors
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Poll for a reply in the same thread
|
|
936
|
+
const deadline = Date.now() + timeout * 1000;
|
|
937
|
+
while (Date.now() < deadline) {
|
|
938
|
+
await Bun.sleep(pollIntervalMs);
|
|
939
|
+
// Open a fresh store connection each cycle so we see the latest committed writes
|
|
940
|
+
const pollStore = createMailStore(mailDbPath);
|
|
941
|
+
let reply: import("../types.ts").MailMessage | undefined;
|
|
942
|
+
try {
|
|
943
|
+
const replies = pollStore.getByThread(sentId);
|
|
944
|
+
reply = replies.find((m) => m.from === COORDINATOR_NAME && m.to === "operator");
|
|
945
|
+
} finally {
|
|
946
|
+
pollStore.close();
|
|
947
|
+
}
|
|
948
|
+
if (reply) {
|
|
949
|
+
if (json) {
|
|
950
|
+
jsonOutput("coordinator ask", {
|
|
951
|
+
correlationId,
|
|
952
|
+
sentId,
|
|
953
|
+
replyId: reply.id,
|
|
954
|
+
subject: reply.subject,
|
|
955
|
+
body: reply.body,
|
|
956
|
+
payload: reply.payload,
|
|
957
|
+
});
|
|
958
|
+
} else {
|
|
959
|
+
process.stdout.write(`${reply.body}\n`);
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
throw new AgentError(
|
|
966
|
+
`Timed out after ${timeout}s waiting for coordinator reply (correlationId: ${correlationId})`,
|
|
967
|
+
{ agentName: COORDINATOR_NAME },
|
|
968
|
+
);
|
|
969
|
+
} finally {
|
|
970
|
+
store.close();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Show recent coordinator tmux pane content without attaching.
|
|
976
|
+
*
|
|
977
|
+
* Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
|
|
978
|
+
*/
|
|
979
|
+
async function outputCoordinator(
|
|
980
|
+
opts: { follow: boolean; lines: number; interval: number; json: boolean },
|
|
981
|
+
deps: CoordinatorDeps = {},
|
|
982
|
+
): Promise<void> {
|
|
983
|
+
const tmux = deps._tmux ?? {
|
|
984
|
+
createSession,
|
|
985
|
+
isSessionAlive,
|
|
986
|
+
checkSessionState,
|
|
987
|
+
killSession,
|
|
988
|
+
sendKeys,
|
|
989
|
+
waitForTuiReady,
|
|
990
|
+
ensureTmuxAvailable,
|
|
991
|
+
};
|
|
992
|
+
const capturePane = deps._capturePaneContent ?? capturePaneContent;
|
|
993
|
+
|
|
994
|
+
const { follow, lines, interval, json } = opts;
|
|
995
|
+
const cwd = process.cwd();
|
|
996
|
+
const config = await loadConfig(cwd);
|
|
997
|
+
const projectRoot = config.project.root;
|
|
998
|
+
|
|
999
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
1000
|
+
const { store } = openSessionStore(overstoryDir);
|
|
1001
|
+
try {
|
|
1002
|
+
const session = store.getByName(COORDINATOR_NAME);
|
|
1003
|
+
|
|
1004
|
+
if (
|
|
1005
|
+
!session ||
|
|
1006
|
+
session.capability !== "coordinator" ||
|
|
1007
|
+
session.state === "completed" ||
|
|
1008
|
+
session.state === "zombie"
|
|
1009
|
+
) {
|
|
1010
|
+
throw new AgentError("No active coordinator session found", {
|
|
1011
|
+
agentName: COORDINATOR_NAME,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
1016
|
+
if (!alive) {
|
|
1017
|
+
store.updateState(COORDINATOR_NAME, "zombie");
|
|
1018
|
+
store.updateLastActivity(COORDINATOR_NAME);
|
|
1019
|
+
throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
|
|
1020
|
+
agentName: COORDINATOR_NAME,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const tmuxSession = session.tmuxSession;
|
|
1025
|
+
|
|
1026
|
+
if (follow) {
|
|
1027
|
+
// Set up SIGINT handler for clean exit
|
|
1028
|
+
let running = true;
|
|
1029
|
+
process.once("SIGINT", () => {
|
|
1030
|
+
running = false;
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
while (running) {
|
|
1034
|
+
const content = await capturePane(tmuxSession, lines);
|
|
1035
|
+
if (json) {
|
|
1036
|
+
jsonOutput("coordinator output", { content, lines });
|
|
1037
|
+
} else {
|
|
1038
|
+
process.stdout.write(content ?? "");
|
|
1039
|
+
}
|
|
1040
|
+
if (running) {
|
|
1041
|
+
await Bun.sleep(interval);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
} else {
|
|
1045
|
+
const content = await capturePane(tmuxSession, lines);
|
|
1046
|
+
if (json) {
|
|
1047
|
+
jsonOutput("coordinator output", { content, lines });
|
|
1048
|
+
} else {
|
|
1049
|
+
process.stdout.write(content ?? "");
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
} finally {
|
|
1053
|
+
store.close();
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
741
1057
|
/**
|
|
742
1058
|
* Create the Commander command for `ov coordinator`.
|
|
743
1059
|
*/
|
|
@@ -784,6 +1100,56 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
|
|
|
784
1100
|
await statusCoordinator({ json: opts.json ?? false }, deps);
|
|
785
1101
|
});
|
|
786
1102
|
|
|
1103
|
+
cmd
|
|
1104
|
+
.command("send")
|
|
1105
|
+
.description("Send a message to the coordinator (fire-and-forget)")
|
|
1106
|
+
.requiredOption("--body <text>", "Message body")
|
|
1107
|
+
.option("--subject <text>", "Message subject", "operator dispatch")
|
|
1108
|
+
.option("--json", "Output as JSON")
|
|
1109
|
+
.action(async (opts: { body: string; subject: string; json?: boolean }) => {
|
|
1110
|
+
await sendToCoordinator(opts.body, { subject: opts.subject, json: opts.json ?? false }, deps);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
cmd
|
|
1114
|
+
.command("ask")
|
|
1115
|
+
.description("Send a request to the coordinator and wait for a reply")
|
|
1116
|
+
.requiredOption("--body <text>", "Message body")
|
|
1117
|
+
.option("--subject <text>", "Message subject", "operator request")
|
|
1118
|
+
.option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
|
|
1119
|
+
.option("--json", "Output as JSON")
|
|
1120
|
+
.action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
|
|
1121
|
+
await askCoordinator(
|
|
1122
|
+
opts.body,
|
|
1123
|
+
{
|
|
1124
|
+
subject: opts.subject,
|
|
1125
|
+
timeout: Number.parseInt(opts.timeout ?? String(ASK_DEFAULT_TIMEOUT_S), 10),
|
|
1126
|
+
json: opts.json ?? false,
|
|
1127
|
+
},
|
|
1128
|
+
deps,
|
|
1129
|
+
);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
cmd
|
|
1133
|
+
.command("output")
|
|
1134
|
+
.description("Show recent coordinator output (tmux pane content)")
|
|
1135
|
+
.option("--follow, -f", "Continuously poll for new output")
|
|
1136
|
+
.option("--lines <n>", "Number of lines to capture", "50")
|
|
1137
|
+
.option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
|
|
1138
|
+
.option("--json", "Output as JSON")
|
|
1139
|
+
.action(
|
|
1140
|
+
async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
|
|
1141
|
+
await outputCoordinator(
|
|
1142
|
+
{
|
|
1143
|
+
follow: opts.follow ?? false,
|
|
1144
|
+
lines: Number.parseInt(opts.lines ?? "50", 10),
|
|
1145
|
+
interval: Number.parseInt(opts.interval ?? "2000", 10),
|
|
1146
|
+
json: opts.json ?? false,
|
|
1147
|
+
},
|
|
1148
|
+
deps,
|
|
1149
|
+
);
|
|
1150
|
+
},
|
|
1151
|
+
);
|
|
1152
|
+
|
|
787
1153
|
return cmd;
|
|
788
1154
|
}
|
|
789
1155
|
|
|
@@ -409,6 +409,92 @@ describe("renderAgentPanel", () => {
|
|
|
409
409
|
// dimBox.vertical is a dimmed ANSI string — present in output
|
|
410
410
|
expect(out).toContain(dimBox.vertical);
|
|
411
411
|
});
|
|
412
|
+
|
|
413
|
+
test("renders Live column header (not Tmux)", () => {
|
|
414
|
+
const data = makeDashboardData({});
|
|
415
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
416
|
+
expect(out).toContain("Live");
|
|
417
|
+
expect(out).not.toContain("Tmux");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("shows green dot for headless agent with alive PID", () => {
|
|
421
|
+
const alivePid = process.pid; // own PID — guaranteed alive
|
|
422
|
+
const data = {
|
|
423
|
+
...makeDashboardData({}),
|
|
424
|
+
status: {
|
|
425
|
+
currentRunId: null,
|
|
426
|
+
agents: [
|
|
427
|
+
{
|
|
428
|
+
id: "sess-h1",
|
|
429
|
+
agentName: "headless-worker",
|
|
430
|
+
capability: "builder",
|
|
431
|
+
worktreePath: "/tmp/wt/headless",
|
|
432
|
+
branchName: "overstory/headless/task-1",
|
|
433
|
+
taskId: "task-h1",
|
|
434
|
+
tmuxSession: "", // headless
|
|
435
|
+
state: "working" as const,
|
|
436
|
+
pid: alivePid,
|
|
437
|
+
parentAgent: null,
|
|
438
|
+
depth: 0,
|
|
439
|
+
runId: null,
|
|
440
|
+
startedAt: new Date(Date.now() - 10_000).toISOString(),
|
|
441
|
+
lastActivity: new Date().toISOString(),
|
|
442
|
+
escalationLevel: 0,
|
|
443
|
+
stalledSince: null,
|
|
444
|
+
transcriptPath: null,
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
worktrees: [],
|
|
448
|
+
tmuxSessions: [], // no tmux sessions
|
|
449
|
+
unreadMailCount: 0,
|
|
450
|
+
mergeQueueCount: 0,
|
|
451
|
+
recentMetricsCount: 0,
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
455
|
+
// Green ">" for alive headless agent
|
|
456
|
+
expect(out).toContain(">");
|
|
457
|
+
expect(out).toContain("headless-worker");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("shows red dot for headless agent with dead PID", () => {
|
|
461
|
+
const deadPid = 2_147_483_647;
|
|
462
|
+
const data = {
|
|
463
|
+
...makeDashboardData({}),
|
|
464
|
+
status: {
|
|
465
|
+
currentRunId: null,
|
|
466
|
+
agents: [
|
|
467
|
+
{
|
|
468
|
+
id: "sess-h2",
|
|
469
|
+
agentName: "dead-headless", // short enough to not be truncated
|
|
470
|
+
capability: "builder",
|
|
471
|
+
worktreePath: "/tmp/wt/dead-headless",
|
|
472
|
+
branchName: "overstory/dead-headless/task-2",
|
|
473
|
+
taskId: "task-h2",
|
|
474
|
+
tmuxSession: "", // headless
|
|
475
|
+
state: "working" as const,
|
|
476
|
+
pid: deadPid,
|
|
477
|
+
parentAgent: null,
|
|
478
|
+
depth: 0,
|
|
479
|
+
runId: null,
|
|
480
|
+
startedAt: new Date(Date.now() - 10_000).toISOString(),
|
|
481
|
+
lastActivity: new Date().toISOString(),
|
|
482
|
+
escalationLevel: 0,
|
|
483
|
+
stalledSince: null,
|
|
484
|
+
transcriptPath: null,
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
worktrees: [],
|
|
488
|
+
tmuxSessions: [],
|
|
489
|
+
unreadMailCount: 0,
|
|
490
|
+
mergeQueueCount: 0,
|
|
491
|
+
recentMetricsCount: 0,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
const out = renderAgentPanel(data, 100, 12, 3);
|
|
495
|
+
expect(out).toContain("x");
|
|
496
|
+
expect(out).toContain("dead-headless");
|
|
497
|
+
});
|
|
412
498
|
});
|
|
413
499
|
|
|
414
500
|
describe("openDashboardStores", () => {
|
|
@@ -42,6 +42,7 @@ import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
|
|
|
42
42
|
import type { TrackerIssue } from "../tracker/types.ts";
|
|
43
43
|
import type { EventStore, MailMessage, StoredEvent } from "../types.ts";
|
|
44
44
|
import { evaluateHealth } from "../watchdog/health.ts";
|
|
45
|
+
import { isProcessAlive } from "../worktree/tmux.ts";
|
|
45
46
|
import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
|
|
46
47
|
|
|
47
48
|
const pkgPath = resolve(import.meta.dir, "../../package.json");
|
|
@@ -555,7 +556,7 @@ export function renderAgentPanel(
|
|
|
555
556
|
output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
|
|
556
557
|
|
|
557
558
|
// Column headers
|
|
558
|
-
const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration
|
|
559
|
+
const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Live `;
|
|
559
560
|
const colPadding = " ".repeat(
|
|
560
561
|
Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
|
|
561
562
|
);
|
|
@@ -595,10 +596,13 @@ export function renderAgentPanel(
|
|
|
595
596
|
: now;
|
|
596
597
|
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
597
598
|
const durationPadded = pad(duration, 9);
|
|
598
|
-
const
|
|
599
|
-
const
|
|
599
|
+
const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
|
|
600
|
+
const alive = isHeadless
|
|
601
|
+
? agent.pid !== null && isProcessAlive(agent.pid)
|
|
602
|
+
: data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
|
|
603
|
+
const aliveDot = alive ? color.green(">") : color.red("x");
|
|
600
604
|
|
|
601
|
-
const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${
|
|
605
|
+
const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
|
|
602
606
|
const linePadding = " ".repeat(
|
|
603
607
|
Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
|
|
604
608
|
);
|
|
@@ -469,6 +469,10 @@ describe("feedCommand", () => {
|
|
|
469
469
|
"spawn",
|
|
470
470
|
"error",
|
|
471
471
|
"custom",
|
|
472
|
+
"turn_start",
|
|
473
|
+
"turn_end",
|
|
474
|
+
"progress",
|
|
475
|
+
"result",
|
|
472
476
|
] as const;
|
|
473
477
|
for (const eventType of eventTypes) {
|
|
474
478
|
store.insert(
|
|
@@ -494,6 +498,10 @@ describe("feedCommand", () => {
|
|
|
494
498
|
expect(out).toContain("SPAWN");
|
|
495
499
|
expect(out).toContain("ERROR");
|
|
496
500
|
expect(out).toContain("CUSTM");
|
|
501
|
+
expect(out).toContain("TURN+");
|
|
502
|
+
expect(out).toContain("TURN-");
|
|
503
|
+
expect(out).toContain("PROG ");
|
|
504
|
+
expect(out).toContain("RSULT");
|
|
497
505
|
});
|
|
498
506
|
});
|
|
499
507
|
|
|
@@ -26,6 +26,7 @@ const AGENT_DEF_FILES = [
|
|
|
26
26
|
"merger.md",
|
|
27
27
|
"coordinator.md",
|
|
28
28
|
"monitor.md",
|
|
29
|
+
"orchestrator.md",
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
/** Resolve the source agents directory (same logic as init.ts). */
|
|
@@ -52,7 +53,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
52
53
|
await cleanupTempDir(tempDir);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
test("creates .overstory/agent-defs/ with all
|
|
56
|
+
test("creates .overstory/agent-defs/ with all 8 agent definition files (supervisor deprecated)", async () => {
|
|
56
57
|
await initCommand({ _spawner: noopSpawner });
|
|
57
58
|
|
|
58
59
|
const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
|
package/src/commands/init.ts
CHANGED
|
@@ -324,7 +324,7 @@ function formatYamlValue(value: unknown): string {
|
|
|
324
324
|
/**
|
|
325
325
|
* Build the starter agent manifest.
|
|
326
326
|
*/
|
|
327
|
-
function buildAgentManifest(): AgentManifest {
|
|
327
|
+
export function buildAgentManifest(): AgentManifest {
|
|
328
328
|
const agents: AgentManifest["agents"] = {
|
|
329
329
|
scout: {
|
|
330
330
|
file: "scout.md",
|
|
@@ -407,7 +407,7 @@ function buildAgentManifest(): AgentManifest {
|
|
|
407
407
|
* {{AGENT_NAME}} placeholders and space indentation). Uses tab indentation
|
|
408
408
|
* to match Biome formatting rules.
|
|
409
409
|
*/
|
|
410
|
-
function buildHooksJson(): string {
|
|
410
|
+
export function buildHooksJson(): string {
|
|
411
411
|
// Tool name extraction: reads hook stdin JSON and extracts tool_name field.
|
|
412
412
|
// Claude Code sends {"tool_name":"Bash","tool_input":{...}} on stdin for
|
|
413
413
|
// PreToolUse/PostToolUse hooks.
|