@os-eco/overstory-cli 0.9.2 → 0.9.4

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.
@@ -149,6 +149,39 @@ async function onboardTool(
149
149
  }
150
150
  }
151
151
 
152
+ /**
153
+ * Known runtime CLI candidates in detection priority order.
154
+ * First installed runtime wins.
155
+ */
156
+ const RUNTIME_CANDIDATES: Array<{ name: string; cli: string }> = [
157
+ { name: "claude", cli: "claude" },
158
+ { name: "copilot", cli: "copilot" },
159
+ { name: "gemini", cli: "gemini" },
160
+ { name: "opencode", cli: "opencode" },
161
+ { name: "sapling", cli: "sp" },
162
+ { name: "pi", cli: "pi" },
163
+ ];
164
+
165
+ /**
166
+ * Detect the default runtime by checking which coding agent CLIs are installed.
167
+ *
168
+ * Uses `which <cli>` via the spawner abstraction so detection is testable
169
+ * without real binaries on PATH. Returns the first installed runtime by
170
+ * priority order, or "claude" as the safe fallback.
171
+ *
172
+ * @param spawner - Spawner abstraction (defaults to Bun.spawn wrapper)
173
+ * @returns Runtime name suitable for config.runtime.default
174
+ */
175
+ export async function detectDefaultRuntime(spawner: Spawner): Promise<string> {
176
+ for (const { name, cli } of RUNTIME_CANDIDATES) {
177
+ const result = await spawner(["which", cli]);
178
+ if (result.exitCode === 0) {
179
+ return name;
180
+ }
181
+ }
182
+ return "claude";
183
+ }
184
+
152
185
  /**
153
186
  * Set up .gitattributes with merge=union entries for JSONL files.
154
187
  *
@@ -739,6 +772,12 @@ export async function initCommand(opts: InitOptions): Promise<void> {
739
772
  // 2. Detect project info
740
773
  const projectName = opts.name ?? (await detectProjectName(projectRoot));
741
774
  const canonicalBranch = await detectCanonicalBranch(projectRoot);
775
+ let defaultRuntime = "claude";
776
+ try {
777
+ defaultRuntime = await detectDefaultRuntime(spawner);
778
+ } catch {
779
+ // Non-fatal: fall back to claude if runtime detection fails
780
+ }
742
781
 
743
782
  process.stdout.write(`Initializing overstory for "${projectName}"...\n\n`);
744
783
 
@@ -775,6 +814,9 @@ export async function initCommand(opts: InitOptions): Promise<void> {
775
814
  config.project.name = projectName;
776
815
  config.project.root = projectRoot;
777
816
  config.project.canonicalBranch = canonicalBranch;
817
+ if (config.runtime) {
818
+ config.runtime.default = defaultRuntime;
819
+ }
778
820
 
779
821
  const configYaml = serializeConfigToYaml(config);
780
822
  const configPath = join(overstoryPath, "config.yaml");
@@ -18,6 +18,7 @@ import { renderHeader, separator, stateIconColored } from "../logging/theme.ts";
18
18
  import { createMetricsStore } from "../metrics/store.ts";
19
19
  import { openSessionStore } from "../sessions/compat.ts";
20
20
  import type { AgentSession, StoredEvent, ToolStats } from "../types.ts";
21
+ import { TMUX_SOCKET } from "../worktree/tmux.ts";
21
22
 
22
23
  /**
23
24
  * Extract current file from most recent Edit/Write/Read tool_start event.
@@ -72,10 +73,13 @@ function summarizeArgs(toolArgs: string | null): string {
72
73
  */
73
74
  async function captureTmux(sessionName: string, lines: number): Promise<string | null> {
74
75
  try {
75
- const proc = Bun.spawn(["tmux", "capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`], {
76
- stdout: "pipe",
77
- stderr: "pipe",
78
- });
76
+ const proc = Bun.spawn(
77
+ ["tmux", "-L", TMUX_SOCKET, "capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`],
78
+ {
79
+ stdout: "pipe",
80
+ stderr: "pipe",
81
+ },
82
+ );
79
83
  const exitCode = await proc.exited;
80
84
  if (exitCode !== 0) {
81
85
  return null;
@@ -25,7 +25,14 @@ import { printHint, printSuccess } from "../logging/color.ts";
25
25
  import { getRuntime } from "../runtimes/registry.ts";
26
26
  import { openSessionStore } from "../sessions/compat.ts";
27
27
  import type { AgentSession } from "../types.ts";
28
- import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
28
+ import {
29
+ createSession,
30
+ isSessionAlive,
31
+ killSession,
32
+ sanitizeTmuxName,
33
+ sendKeys,
34
+ TMUX_SOCKET,
35
+ } from "../worktree/tmux.ts";
29
36
  import { isRunningAsRoot } from "./sling.ts";
30
37
 
31
38
  /** Default monitor agent name. */
@@ -36,7 +43,7 @@ const MONITOR_NAME = "monitor";
36
43
  * Includes the project name to prevent cross-project collisions (overstory-pcef).
37
44
  */
38
45
  function monitorTmuxSession(projectName: string): string {
39
- return `overstory-${projectName}-${MONITOR_NAME}`;
46
+ return `overstory-${sanitizeTmuxName(projectName)}-${MONITOR_NAME}`;
40
47
  }
41
48
 
42
49
  /**
@@ -215,7 +222,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
215
222
  }
216
223
 
217
224
  if (shouldAttach) {
218
- Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
225
+ Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
219
226
  stdio: ["inherit", "inherit", "inherit"],
220
227
  });
221
228
  }
@@ -1038,6 +1038,18 @@ describe("sling provider env injection building blocks", () => {
1038
1038
  expect(combined.OVERSTORY_TASK_ID).toBe("overstory-1234");
1039
1039
  });
1040
1040
 
1041
+ test("env dict includes OVERSTORY_PROJECT_ROOT", () => {
1042
+ const env = { MODEL_KEY: "value" };
1043
+ const combined = {
1044
+ ...env,
1045
+ OVERSTORY_AGENT_NAME: "test-builder",
1046
+ OVERSTORY_WORKTREE_PATH: "/path/to/wt",
1047
+ OVERSTORY_TASK_ID: "task-1",
1048
+ OVERSTORY_PROJECT_ROOT: "/path/to/project",
1049
+ };
1050
+ expect(combined.OVERSTORY_PROJECT_ROOT).toBe("/path/to/project");
1051
+ });
1052
+
1041
1053
  test("resolveModel returns no env for native anthropic provider", () => {
1042
1054
  const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
1043
1055
  const manifest = makeManifest();
@@ -48,6 +48,7 @@ import {
48
48
  ensureTmuxAvailable,
49
49
  isSessionAlive,
50
50
  killSession,
51
+ sanitizeTmuxName,
51
52
  sendKeys,
52
53
  waitForTuiReady,
53
54
  } from "../worktree/tmux.ts";
@@ -806,6 +807,11 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
806
807
  // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
807
808
  const runtime = getRuntime(opts.runtime, config, capability);
808
809
 
810
+ // Runtime-specific worktree preparation (e.g., Copilot folder trust)
811
+ if (runtime.prepareWorktree) {
812
+ await runtime.prepareWorktree(worktreePath);
813
+ }
814
+
809
815
  const overlayConfig: OverlayConfig = {
810
816
  agentName: name,
811
817
  taskId: taskId,
@@ -919,6 +925,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
919
925
  OVERSTORY_AGENT_NAME: name,
920
926
  OVERSTORY_WORKTREE_PATH: worktreePath,
921
927
  OVERSTORY_TASK_ID: taskId,
928
+ OVERSTORY_PROJECT_ROOT: config.project.root,
922
929
  };
923
930
  const argv = runtime.buildDirectSpawn({
924
931
  cwd: worktreePath,
@@ -999,7 +1006,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
999
1006
  await ensureTmuxAvailable();
1000
1007
 
1001
1008
  // 12. Create tmux session running claude in interactive mode
1002
- const tmuxSessionName = `overstory-${config.project.name}-${name}`;
1009
+ const tmuxSessionName = `overstory-${sanitizeTmuxName(config.project.name)}-${name}`;
1003
1010
  const spawnCmd = runtime.buildSpawnCommand({
1004
1011
  model: resolvedModel.model,
1005
1012
  permissionMode: "bypass",
@@ -1010,6 +1017,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1010
1017
  OVERSTORY_AGENT_NAME: name,
1011
1018
  OVERSTORY_WORKTREE_PATH: worktreePath,
1012
1019
  OVERSTORY_TASK_ID: taskId,
1020
+ OVERSTORY_PROJECT_ROOT: config.project.root,
1013
1021
  },
1014
1022
  });
1015
1023
  const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
@@ -1017,6 +1025,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1017
1025
  OVERSTORY_AGENT_NAME: name,
1018
1026
  OVERSTORY_WORKTREE_PATH: worktreePath,
1019
1027
  OVERSTORY_TASK_ID: taskId,
1028
+ OVERSTORY_PROJECT_ROOT: config.project.root,
1020
1029
  });
1021
1030
 
1022
1031
  // 13. Record session BEFORE sending the beacon so that hook-triggered
@@ -29,6 +29,7 @@ import {
29
29
  createSession,
30
30
  isSessionAlive,
31
31
  killSession,
32
+ sanitizeTmuxName,
32
33
  sendKeys,
33
34
  waitForTuiReady,
34
35
  } from "../worktree/tmux.ts";
@@ -170,7 +171,7 @@ async function startSupervisor(opts: {
170
171
  // Spawn tmux session at project root with Claude Code (interactive mode).
171
172
  // Inject the supervisor base definition via --append-system-prompt.
172
173
  // Pass file path (not content) to avoid tmux "command too long" (overstory#45).
173
- const tmuxSession = `overstory-${config.project.name}-supervisor-${opts.name}`;
174
+ const tmuxSession = `overstory-${sanitizeTmuxName(config.project.name)}-supervisor-${opts.name}`;
174
175
  const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "supervisor.md");
175
176
  const agentDefFile = Bun.file(agentDefPath);
176
177
  let appendSystemPromptFile: string | undefined;
@@ -27,7 +27,6 @@ describe("watchCommand", () => {
27
27
  let originalStderrWrite: typeof process.stderr.write;
28
28
  let tempDir: string;
29
29
  let originalCwd: string;
30
- let originalExitCode: string | number | null | undefined;
31
30
 
32
31
  beforeEach(async () => {
33
32
  // Spy on stdout
@@ -46,8 +45,6 @@ describe("watchCommand", () => {
46
45
  return true;
47
46
  }) as typeof process.stderr.write;
48
47
 
49
- // Save original exitCode
50
- originalExitCode = process.exitCode;
51
48
  process.exitCode = 0;
52
49
 
53
50
  // Create temp dir with .overstory/config.yaml structure
@@ -66,7 +63,12 @@ describe("watchCommand", () => {
66
63
  afterEach(async () => {
67
64
  process.stdout.write = originalWrite;
68
65
  process.stderr.write = originalStderrWrite;
69
- process.exitCode = originalExitCode;
66
+ // Unconditionally clear to 0 rather than restoring a captured "original" that
67
+ // could itself have been polluted by a parallel test file in the same bun
68
+ // process. `watchCommand` sets `process.exitCode = 1` as a side effect, so
69
+ // without this clear the 1 can leak all the way to bun test's shutdown and
70
+ // turn a fully-green run into exit 1.
71
+ process.exitCode = 0;
70
72
  process.chdir(originalCwd);
71
73
  await cleanupTempDir(tempDir);
72
74
  });
@@ -1,13 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { existsSync, realpathSync } from "node:fs";
3
- import { mkdir } from "node:fs/promises";
2
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
3
+ import { mkdir, mkdtemp } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { ValidationError } from "../errors.ts";
6
7
  import { createSessionStore } from "../sessions/store.ts";
7
8
  import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
8
9
  import type { AgentSession } from "../types.ts";
9
10
  import { createWorktree } from "../worktree/manager.ts";
10
- import { worktreeCommand } from "./worktree.ts";
11
+ import { checkLiveChildren, worktreeCommand } from "./worktree.ts";
11
12
 
12
13
  /**
13
14
  * Tests for `overstory worktree` command.
@@ -974,5 +975,320 @@ describe("worktreeCommand", () => {
974
975
  expect(parsed.cleaned).toContain(branch);
975
976
  expect(parsed.seedsPreserved).toContain(branch);
976
977
  });
978
+
979
+ describe("live-children guard", () => {
980
+ /**
981
+ * Write sessions into a nested .overstory/sessions.db inside a worktree.
982
+ * Simulates a lead worktree that has spawned builder children.
983
+ */
984
+ function writeNestedSessions(worktreePath: string, sessions: AgentSession[]): void {
985
+ const nestedOverstory = join(worktreePath, ".overstory");
986
+ mkdirSync(nestedOverstory, { recursive: true });
987
+ const store = createSessionStore(join(nestedOverstory, "sessions.db"));
988
+ for (const s of sessions) {
989
+ store.upsert(s);
990
+ }
991
+ store.close();
992
+ }
993
+
994
+ test("clean skipped when live children present (no --force)", async () => {
995
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
996
+ await mkdir(worktreesDir, { recursive: true });
997
+
998
+ const { path: wtPath } = await createWorktree({
999
+ repoRoot: tempDir,
1000
+ baseDir: worktreesDir,
1001
+ agentName: "lead-with-children",
1002
+ baseBranch: "main",
1003
+ taskId: "task-lead",
1004
+ });
1005
+
1006
+ // Parent session is completed
1007
+ writeSessionsToStore([
1008
+ makeSession({
1009
+ id: "session-lead",
1010
+ agentName: "lead-with-children",
1011
+ capability: "lead",
1012
+ worktreePath: wtPath,
1013
+ branchName: "overstory/lead-with-children/task-lead",
1014
+ taskId: "task-lead",
1015
+ state: "completed",
1016
+ }),
1017
+ ]);
1018
+
1019
+ // Nested session with process.pid (guaranteed alive)
1020
+ writeNestedSessions(wtPath, [
1021
+ {
1022
+ id: "nested-builder",
1023
+ agentName: "nested-builder",
1024
+ capability: "builder",
1025
+ worktreePath: join(wtPath, ".overstory", "worktrees", "nested-builder"),
1026
+ branchName: "overstory/nested-builder/task-child",
1027
+ taskId: "task-child",
1028
+ tmuxSession: "overstory-nested-builder-fake",
1029
+ state: "working",
1030
+ pid: process.pid, // current process — guaranteed alive
1031
+ parentAgent: "lead-with-children",
1032
+ depth: 2,
1033
+ runId: null,
1034
+ startedAt: new Date().toISOString(),
1035
+ lastActivity: new Date().toISOString(),
1036
+ escalationLevel: 0,
1037
+ stalledSince: null,
1038
+ transcriptPath: null,
1039
+ },
1040
+ ]);
1041
+
1042
+ await worktreeCommand(["clean", "--completed", "--json"]);
1043
+ const out = output();
1044
+
1045
+ const parsed = JSON.parse(out.trim()) as {
1046
+ cleaned: string[];
1047
+ blockedByChildren: string[];
1048
+ };
1049
+
1050
+ expect(parsed.cleaned).toEqual([]);
1051
+ expect(parsed.blockedByChildren).toContain("overstory/lead-with-children/task-lead");
1052
+ // Worktree still exists
1053
+ expect(existsSync(wtPath)).toBe(true);
1054
+ });
1055
+
1056
+ test("clean proceeds when nested sessions are dead (pid unreachable)", async () => {
1057
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
1058
+ await mkdir(worktreesDir, { recursive: true });
1059
+
1060
+ const { path: wtPath } = await createWorktree({
1061
+ repoRoot: tempDir,
1062
+ baseDir: worktreesDir,
1063
+ agentName: "lead-dead-children",
1064
+ baseBranch: "main",
1065
+ taskId: "task-dead",
1066
+ });
1067
+
1068
+ writeSessionsToStore([
1069
+ makeSession({
1070
+ id: "session-lead-dead",
1071
+ agentName: "lead-dead-children",
1072
+ capability: "lead",
1073
+ worktreePath: wtPath,
1074
+ branchName: "overstory/lead-dead-children/task-dead",
1075
+ taskId: "task-dead",
1076
+ state: "completed",
1077
+ }),
1078
+ ]);
1079
+
1080
+ // Nested session with a dead pid (extremely high, will not exist)
1081
+ writeNestedSessions(wtPath, [
1082
+ {
1083
+ id: "nested-dead",
1084
+ agentName: "nested-dead",
1085
+ capability: "builder",
1086
+ worktreePath: join(wtPath, ".overstory", "worktrees", "nested-dead"),
1087
+ branchName: "overstory/nested-dead/task-dead-child",
1088
+ taskId: "task-dead-child",
1089
+ tmuxSession: "overstory-nested-dead-fake",
1090
+ state: "working",
1091
+ pid: 999999999, // dead pid
1092
+ parentAgent: "lead-dead-children",
1093
+ depth: 2,
1094
+ runId: null,
1095
+ startedAt: new Date().toISOString(),
1096
+ lastActivity: new Date().toISOString(),
1097
+ escalationLevel: 0,
1098
+ stalledSince: null,
1099
+ transcriptPath: null,
1100
+ },
1101
+ ]);
1102
+
1103
+ await worktreeCommand(["clean", "--completed", "--json"]);
1104
+ const out = output();
1105
+
1106
+ const parsed = JSON.parse(out.trim()) as {
1107
+ cleaned: string[];
1108
+ blockedByChildren: string[];
1109
+ };
1110
+
1111
+ expect(parsed.cleaned).toContain("overstory/lead-dead-children/task-dead");
1112
+ expect(parsed.blockedByChildren).toEqual([]);
1113
+ expect(existsSync(wtPath)).toBe(false);
1114
+ });
1115
+
1116
+ test("--force removes worktree even with live children", async () => {
1117
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
1118
+ await mkdir(worktreesDir, { recursive: true });
1119
+
1120
+ const { path: wtPath } = await createWorktree({
1121
+ repoRoot: tempDir,
1122
+ baseDir: worktreesDir,
1123
+ agentName: "lead-force",
1124
+ baseBranch: "main",
1125
+ taskId: "task-force-children",
1126
+ });
1127
+
1128
+ writeSessionsToStore([
1129
+ makeSession({
1130
+ id: "session-lead-force",
1131
+ agentName: "lead-force",
1132
+ capability: "lead",
1133
+ worktreePath: wtPath,
1134
+ branchName: "overstory/lead-force/task-force-children",
1135
+ taskId: "task-force-children",
1136
+ state: "completed",
1137
+ }),
1138
+ ]);
1139
+
1140
+ // Use a dead pid — avoids actually killing any live process,
1141
+ // but still exercises the --force code path.
1142
+ writeNestedSessions(wtPath, [
1143
+ {
1144
+ id: "nested-force",
1145
+ agentName: "nested-force",
1146
+ capability: "builder",
1147
+ worktreePath: join(wtPath, ".overstory", "worktrees", "nested-force"),
1148
+ branchName: "overstory/nested-force/task-force-child",
1149
+ taskId: "task-force-child",
1150
+ tmuxSession: "overstory-nested-force-fake",
1151
+ state: "working",
1152
+ pid: 999999999, // dead pid, safe to kill
1153
+ parentAgent: "lead-force",
1154
+ depth: 2,
1155
+ runId: null,
1156
+ startedAt: new Date().toISOString(),
1157
+ lastActivity: new Date().toISOString(),
1158
+ escalationLevel: 0,
1159
+ stalledSince: null,
1160
+ transcriptPath: null,
1161
+ },
1162
+ ]);
1163
+
1164
+ await worktreeCommand(["clean", "--force", "--json"]);
1165
+ const out = output();
1166
+
1167
+ const parsed = JSON.parse(out.trim()) as {
1168
+ cleaned: string[];
1169
+ blockedByChildren: string[];
1170
+ };
1171
+
1172
+ // Should be cleaned (not blocked) even though nested sessions existed
1173
+ expect(parsed.cleaned).toContain("overstory/lead-force/task-force-children");
1174
+ expect(existsSync(wtPath)).toBe(false);
1175
+ });
1176
+
1177
+ test("no nested .overstory — treated as no live children, clean proceeds", async () => {
1178
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
1179
+ await mkdir(worktreesDir, { recursive: true });
1180
+
1181
+ const { path: wtPath } = await createWorktree({
1182
+ repoRoot: tempDir,
1183
+ baseDir: worktreesDir,
1184
+ agentName: "lead-no-nested",
1185
+ baseBranch: "main",
1186
+ taskId: "task-no-nested",
1187
+ });
1188
+
1189
+ writeSessionsToStore([
1190
+ makeSession({
1191
+ id: "session-lead-no-nested",
1192
+ agentName: "lead-no-nested",
1193
+ capability: "lead",
1194
+ worktreePath: wtPath,
1195
+ branchName: "overstory/lead-no-nested/task-no-nested",
1196
+ taskId: "task-no-nested",
1197
+ state: "completed",
1198
+ }),
1199
+ ]);
1200
+
1201
+ // No nested .overstory/ directory written
1202
+
1203
+ await worktreeCommand(["clean", "--completed", "--json"]);
1204
+ const out = output();
1205
+
1206
+ const parsed = JSON.parse(out.trim()) as {
1207
+ cleaned: string[];
1208
+ blockedByChildren: string[];
1209
+ };
1210
+
1211
+ expect(parsed.cleaned).toContain("overstory/lead-no-nested/task-no-nested");
1212
+ expect(parsed.blockedByChildren).toEqual([]);
1213
+ expect(existsSync(wtPath)).toBe(false);
1214
+ });
1215
+ });
1216
+ });
1217
+ });
1218
+
1219
+ describe("checkLiveChildren", () => {
1220
+ let tempDir: string;
1221
+
1222
+ beforeEach(async () => {
1223
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-checkchildren-"));
1224
+ });
1225
+
1226
+ afterEach(async () => {
1227
+ await cleanupTempDir(tempDir);
1228
+ });
1229
+
1230
+ test("returns empty array when no nested .overstory/sessions.db", async () => {
1231
+ const result = await checkLiveChildren(tempDir);
1232
+ expect(result).toEqual([]);
1233
+ });
1234
+
1235
+ test("returns empty array when all sessions are completed", async () => {
1236
+ const nestedOverstory = join(tempDir, ".overstory");
1237
+ mkdirSync(nestedOverstory, { recursive: true });
1238
+ const store = createSessionStore(join(nestedOverstory, "sessions.db"));
1239
+ store.upsert({
1240
+ id: "s1",
1241
+ agentName: "done-agent",
1242
+ capability: "builder",
1243
+ worktreePath: "/fake/wt",
1244
+ branchName: "overstory/done/task",
1245
+ taskId: "task",
1246
+ tmuxSession: "",
1247
+ state: "completed",
1248
+ pid: process.pid,
1249
+ parentAgent: null,
1250
+ depth: 2,
1251
+ runId: null,
1252
+ startedAt: new Date().toISOString(),
1253
+ lastActivity: new Date().toISOString(),
1254
+ escalationLevel: 0,
1255
+ stalledSince: null,
1256
+ transcriptPath: null,
1257
+ });
1258
+ store.close();
1259
+
1260
+ const result = await checkLiveChildren(tempDir);
1261
+ expect(result).toEqual([]);
1262
+ });
1263
+
1264
+ test("returns live children when working session with alive pid exists", async () => {
1265
+ const nestedOverstory = join(tempDir, ".overstory");
1266
+ mkdirSync(nestedOverstory, { recursive: true });
1267
+ const store = createSessionStore(join(nestedOverstory, "sessions.db"));
1268
+ store.upsert({
1269
+ id: "s1",
1270
+ agentName: "live-agent",
1271
+ capability: "builder",
1272
+ worktreePath: "/fake/wt",
1273
+ branchName: "overstory/live/task",
1274
+ taskId: "task",
1275
+ tmuxSession: "",
1276
+ state: "working",
1277
+ pid: process.pid, // current process — alive
1278
+ parentAgent: null,
1279
+ depth: 2,
1280
+ runId: null,
1281
+ startedAt: new Date().toISOString(),
1282
+ lastActivity: new Date().toISOString(),
1283
+ escalationLevel: 0,
1284
+ stalledSince: null,
1285
+ transcriptPath: null,
1286
+ });
1287
+ store.close();
1288
+
1289
+ const result = await checkLiveChildren(tempDir);
1290
+ expect(result).toHaveLength(1);
1291
+ expect(result[0]?.agentName).toBe("live-agent");
1292
+ expect(result[0]?.pid).toBe(process.pid);
977
1293
  });
978
1294
  });