@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.
@@ -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
- * 2. Killing its tmux session (if alive)
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
- // Kill tmux session if alive
88
- const alive = await tmux.isSessionAlive(session.tmuxSession);
89
- if (alive) {
90
- await tmux.killSession(session.tmuxSession);
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: alive,
139
+ tmuxKilled,
140
+ pidKilled,
119
141
  worktreeRemoved,
120
142
  force,
121
143
  });
122
144
  } else {
123
145
  printSuccess("Agent stopped", agentName);
124
- if (alive) {
125
- process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
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
- process.stdout.write(` Tmux session was already dead\n`);
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.0";
52
+ export const VERSION = "0.8.2";
53
53
 
54
54
  const rawArgs = process.argv.slice(2);
55
55
 
@@ -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
 
@@ -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
  /**