@os-eco/overstory-cli 0.8.0 → 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 +3 -1
- package/package.json +1 -1
- 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/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/index.ts +1 -1
- 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
package/src/commands/stop.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Explicitly terminates a running agent by:
|
|
5
5
|
* 1. Looking up the agent session by name
|
|
6
|
-
*
|
|
6
|
+
* 2a. For TUI agents: killing its tmux session (if alive)
|
|
7
|
+
* 2b. For headless agents (tmuxSession === ''): sending SIGTERM to the process tree
|
|
7
8
|
* 3. Marking it as completed in the SessionStore
|
|
8
9
|
* 4. Optionally removing its worktree (--clean-worktree)
|
|
9
10
|
*/
|
|
@@ -15,7 +16,7 @@ import { jsonOutput } from "../json.ts";
|
|
|
15
16
|
import { printSuccess, printWarning } from "../logging/color.ts";
|
|
16
17
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
17
18
|
import { removeWorktree } from "../worktree/manager.ts";
|
|
18
|
-
import { isSessionAlive, killSession } from "../worktree/tmux.ts";
|
|
19
|
+
import { isProcessAlive, isSessionAlive, killProcessTree, killSession } from "../worktree/tmux.ts";
|
|
19
20
|
|
|
20
21
|
export interface StopOptions {
|
|
21
22
|
force?: boolean;
|
|
@@ -36,6 +37,10 @@ export interface StopDeps {
|
|
|
36
37
|
options?: { force?: boolean; forceBranch?: boolean },
|
|
37
38
|
) => Promise<void>;
|
|
38
39
|
};
|
|
40
|
+
_process?: {
|
|
41
|
+
isAlive: (pid: number) => boolean;
|
|
42
|
+
killTree: (pid: number) => Promise<void>;
|
|
43
|
+
};
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -43,7 +48,7 @@ export interface StopDeps {
|
|
|
43
48
|
*
|
|
44
49
|
* @param agentName - Name of the agent to stop
|
|
45
50
|
* @param opts - Command options
|
|
46
|
-
* @param deps - Optional dependency injection for testing (tmux, worktree)
|
|
51
|
+
* @param deps - Optional dependency injection for testing (tmux, worktree, process)
|
|
47
52
|
*/
|
|
48
53
|
export async function stopCommand(
|
|
49
54
|
agentName: string,
|
|
@@ -63,6 +68,7 @@ export async function stopCommand(
|
|
|
63
68
|
|
|
64
69
|
const tmux = deps._tmux ?? { isSessionAlive, killSession };
|
|
65
70
|
const worktree = deps._worktree ?? { remove: removeWorktree };
|
|
71
|
+
const proc = deps._process ?? { isAlive: isProcessAlive, killTree: killProcessTree };
|
|
66
72
|
|
|
67
73
|
const cwd = process.cwd();
|
|
68
74
|
const config = await loadConfig(cwd);
|
|
@@ -84,10 +90,25 @@ export async function stopCommand(
|
|
|
84
90
|
throw new AgentError(`Agent "${agentName}" is already zombie (dead)`, { agentName });
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
const isHeadless = session.tmuxSession === "" && session.pid !== null;
|
|
94
|
+
|
|
95
|
+
let tmuxKilled = false;
|
|
96
|
+
let pidKilled = false;
|
|
97
|
+
|
|
98
|
+
if (isHeadless && session.pid !== null) {
|
|
99
|
+
// Headless agent: kill via process tree instead of tmux
|
|
100
|
+
const alive = proc.isAlive(session.pid);
|
|
101
|
+
if (alive) {
|
|
102
|
+
await proc.killTree(session.pid);
|
|
103
|
+
pidKilled = true;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// TUI agent: kill via tmux session
|
|
107
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
108
|
+
if (alive) {
|
|
109
|
+
await tmux.killSession(session.tmuxSession);
|
|
110
|
+
tmuxKilled = true;
|
|
111
|
+
}
|
|
91
112
|
}
|
|
92
113
|
|
|
93
114
|
// Mark session as completed
|
|
@@ -115,16 +136,25 @@ export async function stopCommand(
|
|
|
115
136
|
agentName,
|
|
116
137
|
sessionId: session.id,
|
|
117
138
|
capability: session.capability,
|
|
118
|
-
tmuxKilled
|
|
139
|
+
tmuxKilled,
|
|
140
|
+
pidKilled,
|
|
119
141
|
worktreeRemoved,
|
|
120
142
|
force,
|
|
121
143
|
});
|
|
122
144
|
} else {
|
|
123
145
|
printSuccess("Agent stopped", agentName);
|
|
124
|
-
if (
|
|
125
|
-
|
|
146
|
+
if (isHeadless) {
|
|
147
|
+
if (pidKilled) {
|
|
148
|
+
process.stdout.write(` Process tree killed: PID ${session.pid}\n`);
|
|
149
|
+
} else {
|
|
150
|
+
process.stdout.write(` Process was already dead (PID ${session.pid})\n`);
|
|
151
|
+
}
|
|
126
152
|
} else {
|
|
127
|
-
|
|
153
|
+
if (tmuxKilled) {
|
|
154
|
+
process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
|
|
155
|
+
} else {
|
|
156
|
+
process.stdout.write(` Tmux session was already dead\n`);
|
|
157
|
+
}
|
|
128
158
|
}
|
|
129
159
|
if (cleanWorktree && worktreeRemoved) {
|
|
130
160
|
process.stdout.write(` Worktree removed: ${session.worktreePath}\n`);
|
|
@@ -639,6 +639,10 @@ describe("traceCommand", () => {
|
|
|
639
639
|
"spawn",
|
|
640
640
|
"error",
|
|
641
641
|
"custom",
|
|
642
|
+
"turn_start",
|
|
643
|
+
"turn_end",
|
|
644
|
+
"progress",
|
|
645
|
+
"result",
|
|
642
646
|
] as const;
|
|
643
647
|
for (const eventType of eventTypes) {
|
|
644
648
|
store.insert(
|
|
@@ -664,6 +668,10 @@ describe("traceCommand", () => {
|
|
|
664
668
|
expect(out).toContain("SPAWN");
|
|
665
669
|
expect(out).toContain("ERROR");
|
|
666
670
|
expect(out).toContain("CUSTOM");
|
|
671
|
+
expect(out).toContain("TURN START");
|
|
672
|
+
expect(out).toContain("TURN END");
|
|
673
|
+
expect(out).toContain("PROGRESS");
|
|
674
|
+
expect(out).toContain("RESULT");
|
|
667
675
|
});
|
|
668
676
|
|
|
669
677
|
test("long data values are truncated", async () => {
|
package/src/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
|
49
49
|
import { jsonError } from "./json.ts";
|
|
50
50
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
51
51
|
|
|
52
|
-
export const VERSION = "0.8.
|
|
52
|
+
export const VERSION = "0.8.2";
|
|
53
53
|
|
|
54
54
|
const rawArgs = process.argv.slice(2);
|
|
55
55
|
|
package/src/logging/theme.ts
CHANGED
|
@@ -66,6 +66,10 @@ const EVENT_LABELS: Record<EventType, EventLabel> = {
|
|
|
66
66
|
spawn: { compact: "SPAWN", full: "SPAWN ", color: color.magenta },
|
|
67
67
|
error: { compact: "ERROR", full: "ERROR ", color: color.red },
|
|
68
68
|
custom: { compact: "CUSTM", full: "CUSTOM ", color: color.gray },
|
|
69
|
+
turn_start: { compact: "TURN+", full: "TURN START", color: color.green },
|
|
70
|
+
turn_end: { compact: "TURN-", full: "TURN END ", color: color.yellow },
|
|
71
|
+
progress: { compact: "PROG ", full: "PROGRESS ", color: color.cyan },
|
|
72
|
+
result: { compact: "RSULT", full: "RESULT ", color: color.green },
|
|
69
73
|
};
|
|
70
74
|
|
|
71
75
|
/** Returns the EventLabel for a given event type. */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { getConnection, removeConnection, setConnection } from "./connections.ts";
|
|
3
|
+
import type { ConnectionState, RuntimeConnection } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Minimal RuntimeConnection stub for testing the registry. */
|
|
6
|
+
function makeConn(onClose?: () => void): RuntimeConnection {
|
|
7
|
+
return {
|
|
8
|
+
sendPrompt: async (_text: string) => {},
|
|
9
|
+
followUp: async (_text: string) => {},
|
|
10
|
+
abort: async () => {},
|
|
11
|
+
getState: async (): Promise<ConnectionState> => ({ status: "idle" }),
|
|
12
|
+
close: () => {
|
|
13
|
+
if (onClose) onClose();
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("connection registry", () => {
|
|
19
|
+
// Reset registry between tests by removing any entries set during each test.
|
|
20
|
+
// We track names used so we can clean up without affecting other entries.
|
|
21
|
+
const usedNames: string[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const name of usedNames.splice(0)) {
|
|
25
|
+
const conn = getConnection(name);
|
|
26
|
+
if (conn) {
|
|
27
|
+
removeConnection(name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("set and get returns the registered connection", () => {
|
|
33
|
+
const conn = makeConn();
|
|
34
|
+
usedNames.push("agent-alpha");
|
|
35
|
+
setConnection("agent-alpha", conn);
|
|
36
|
+
expect(getConnection("agent-alpha")).toBe(conn);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("get unknown returns undefined", () => {
|
|
40
|
+
expect(getConnection("does-not-exist-xyz")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("removeConnection calls close() on the connection", () => {
|
|
44
|
+
let closed = false;
|
|
45
|
+
const conn = makeConn(() => {
|
|
46
|
+
closed = true;
|
|
47
|
+
});
|
|
48
|
+
usedNames.push("agent-beta");
|
|
49
|
+
setConnection("agent-beta", conn);
|
|
50
|
+
removeConnection("agent-beta");
|
|
51
|
+
expect(closed).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("removeConnection deletes the entry (get returns undefined after)", () => {
|
|
55
|
+
const conn = makeConn();
|
|
56
|
+
usedNames.push("agent-gamma");
|
|
57
|
+
setConnection("agent-gamma", conn);
|
|
58
|
+
removeConnection("agent-gamma");
|
|
59
|
+
expect(getConnection("agent-gamma")).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("removeConnection on unknown name is a no-op (does not throw)", () => {
|
|
63
|
+
expect(() => removeConnection("never-registered-xyz")).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("setConnection overwrites an existing entry", () => {
|
|
67
|
+
const conn1 = makeConn();
|
|
68
|
+
const conn2 = makeConn();
|
|
69
|
+
usedNames.push("agent-delta");
|
|
70
|
+
setConnection("agent-delta", conn1);
|
|
71
|
+
setConnection("agent-delta", conn2);
|
|
72
|
+
expect(getConnection("agent-delta")).toBe(conn2);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level connection registry for active RuntimeConnection instances.
|
|
3
|
+
*
|
|
4
|
+
* Tracks RPC connections to headless agent processes (e.g., Sapling).
|
|
5
|
+
* Keyed by agent name — same namespace as AgentSession.agentName.
|
|
6
|
+
*
|
|
7
|
+
* Thread safety: single-threaded Bun runtime; no locking needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RuntimeConnection } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const connections = new Map<string, RuntimeConnection>();
|
|
13
|
+
|
|
14
|
+
/** Retrieve the active connection for a given agent, or undefined if none. */
|
|
15
|
+
export function getConnection(agentName: string): RuntimeConnection | undefined {
|
|
16
|
+
return connections.get(agentName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Register a connection for a given agent. Overwrites any existing entry. */
|
|
20
|
+
export function setConnection(agentName: string, conn: RuntimeConnection): void {
|
|
21
|
+
connections.set(agentName, conn);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Remove the connection for a given agent, calling close() first.
|
|
26
|
+
* Safe to call if no connection exists (no-op).
|
|
27
|
+
*/
|
|
28
|
+
export function removeConnection(agentName: string): void {
|
|
29
|
+
const conn = connections.get(agentName);
|
|
30
|
+
if (conn) {
|
|
31
|
+
conn.close();
|
|
32
|
+
connections.delete(agentName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -22,7 +22,7 @@ describe("getRuntime", () => {
|
|
|
22
22
|
|
|
23
23
|
it("throws with a helpful message for an unknown runtime", () => {
|
|
24
24
|
expect(() => getRuntime("unknown-runtime")).toThrow(
|
|
25
|
-
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini',
|
|
25
|
+
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling',
|
|
26
26
|
);
|
|
27
27
|
});
|
|
28
28
|
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { CodexRuntime } from "./codex.ts";
|
|
|
7
7
|
import { CopilotRuntime } from "./copilot.ts";
|
|
8
8
|
import { GeminiRuntime } from "./gemini.ts";
|
|
9
9
|
import { PiRuntime } from "./pi.ts";
|
|
10
|
+
import { SaplingRuntime } from "./sapling.ts";
|
|
10
11
|
import type { AgentRuntime } from "./types.ts";
|
|
11
12
|
|
|
12
13
|
/** Registry of config-independent runtime adapters (name → factory). */
|
|
@@ -16,6 +17,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
|
|
|
16
17
|
["pi", () => new PiRuntime()],
|
|
17
18
|
["copilot", () => new CopilotRuntime()],
|
|
18
19
|
["gemini", () => new GeminiRuntime()],
|
|
20
|
+
["sapling", () => new SaplingRuntime()],
|
|
19
21
|
]);
|
|
20
22
|
|
|
21
23
|
/**
|