@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.
@@ -6,6 +6,7 @@
6
6
  * Logs are never auto-deleted.
7
7
  */
8
8
 
9
+ import { existsSync } from "node:fs";
9
10
  import { join } from "node:path";
10
11
  import { Command } from "commander";
11
12
  import { loadConfig } from "../config.ts";
@@ -14,6 +15,7 @@ import { jsonOutput } from "../json.ts";
14
15
  import { printHint, printSuccess, printWarning } from "../logging/color.ts";
15
16
  import { createMailStore } from "../mail/store.ts";
16
17
  import { openSessionStore } from "../sessions/compat.ts";
18
+ import { createSessionStore } from "../sessions/store.ts";
17
19
  import type { AgentSession } from "../types.ts";
18
20
  import {
19
21
  isBranchMerged,
@@ -23,6 +25,51 @@ import {
23
25
  } from "../worktree/manager.ts";
24
26
  import { isSessionAlive, killSession } from "../worktree/tmux.ts";
25
27
 
28
+ /**
29
+ * Check for live child sessions nested inside a worktree's own .overstory/sessions.db.
30
+ *
31
+ * Returns the live children (agentName + pid). Empty array if no nested DB or no live children.
32
+ */
33
+ export async function checkLiveChildren(
34
+ worktreePath: string,
35
+ ): Promise<{ agentName: string; pid: number }[]> {
36
+ const nestedDb = join(worktreePath, ".overstory", "sessions.db");
37
+ if (!existsSync(nestedDb)) {
38
+ return [];
39
+ }
40
+
41
+ const store = createSessionStore(nestedDb);
42
+ let sessions: AgentSession[];
43
+ try {
44
+ sessions = store.getAll();
45
+ } finally {
46
+ store.close();
47
+ }
48
+
49
+ const deadStates = new Set(["completed", "zombie"]);
50
+ const liveChildren: { agentName: string; pid: number }[] = [];
51
+
52
+ for (const session of sessions) {
53
+ if (deadStates.has(session.state)) continue;
54
+ if (session.pid === null) continue;
55
+
56
+ // process.kill(pid, 0) throws if the process is dead (ESRCH)
57
+ let alive = false;
58
+ try {
59
+ process.kill(session.pid, 0);
60
+ alive = true;
61
+ } catch {
62
+ // ESRCH — process is dead
63
+ }
64
+
65
+ if (alive) {
66
+ liveChildren.push({ agentName: session.agentName, pid: session.pid });
67
+ }
68
+ }
69
+
70
+ return liveChildren;
71
+ }
72
+
26
73
  /**
27
74
  * Handle `ov worktree list`.
28
75
  */
@@ -100,6 +147,7 @@ async function handleClean(
100
147
  const failed: string[] = [];
101
148
  const skipped: string[] = [];
102
149
  const seedsPreserved: string[] = [];
150
+ const blockedByChildren: string[] = [];
103
151
 
104
152
  try {
105
153
  for (const wt of overstoryWts) {
@@ -129,6 +177,33 @@ async function handleClean(
129
177
  }
130
178
  }
131
179
 
180
+ // Live-children guard: refuse to remove a worktree that has active nested agents.
181
+ // Nested sessions live in {wt.path}/.overstory/sessions.db (lead worktrees).
182
+ const liveChildren = await checkLiveChildren(wt.path);
183
+ if (liveChildren.length > 0) {
184
+ if (!force) {
185
+ blockedByChildren.push(wt.branch);
186
+ continue;
187
+ }
188
+ // --force: SIGTERM each live child, wait briefly, then proceed.
189
+ if (!json) {
190
+ printWarning(
191
+ `Force-terminating ${liveChildren.length} live child${liveChildren.length === 1 ? "" : "ren"} in ${wt.branch}`,
192
+ );
193
+ for (const child of liveChildren) {
194
+ process.stdout.write(` ${child.agentName} (pid ${child.pid})\n`);
195
+ }
196
+ }
197
+ for (const child of liveChildren) {
198
+ try {
199
+ process.kill(child.pid, "SIGTERM");
200
+ } catch {
201
+ // Best effort
202
+ }
203
+ }
204
+ await Bun.sleep(500);
205
+ }
206
+
132
207
  // If --all, clean everything
133
208
  // Kill tmux session if still alive
134
209
  if (session?.tmuxSession) {
@@ -243,6 +318,7 @@ async function handleClean(
243
318
  cleaned,
244
319
  failed,
245
320
  skipped,
321
+ blockedByChildren,
246
322
  pruned: pruneCount,
247
323
  mailPurged,
248
324
  seedsPreserved,
@@ -252,6 +328,7 @@ async function handleClean(
252
328
  pruneCount === 0 &&
253
329
  failed.length === 0 &&
254
330
  skipped.length === 0 &&
331
+ blockedByChildren.length === 0 &&
255
332
  seedsPreserved.length === 0
256
333
  ) {
257
334
  printHint("No worktrees to clean");
@@ -286,6 +363,15 @@ async function handleClean(
286
363
  }
287
364
  printHint("Use --force to delete unmerged branches");
288
365
  }
366
+ if (blockedByChildren.length > 0) {
367
+ printWarning(
368
+ `Skipped ${blockedByChildren.length} worktree${blockedByChildren.length === 1 ? "" : "s"} with live child agents`,
369
+ );
370
+ for (const branch of blockedByChildren) {
371
+ process.stdout.write(` ${branch}\n`);
372
+ }
373
+ printHint("Use --force to cascade-terminate live children");
374
+ }
289
375
  }
290
376
  } finally {
291
377
  store.close();
@@ -1177,6 +1177,84 @@ describe("resolveProjectRoot", () => {
1177
1177
  });
1178
1178
  });
1179
1179
 
1180
+ describe("resolveProjectRoot — env var and walk-up", () => {
1181
+ let tempDir: string;
1182
+ let savedEnv: string | undefined;
1183
+
1184
+ beforeEach(async () => {
1185
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-envtest-"));
1186
+ savedEnv = process.env.OVERSTORY_PROJECT_ROOT;
1187
+ delete process.env.OVERSTORY_PROJECT_ROOT;
1188
+ clearProjectRootOverride();
1189
+ });
1190
+
1191
+ afterEach(async () => {
1192
+ if (savedEnv !== undefined) {
1193
+ process.env.OVERSTORY_PROJECT_ROOT = savedEnv;
1194
+ } else {
1195
+ delete process.env.OVERSTORY_PROJECT_ROOT;
1196
+ }
1197
+ clearProjectRootOverride();
1198
+ await cleanupTempDir(tempDir);
1199
+ });
1200
+
1201
+ test("OVERSTORY_PROJECT_ROOT env var is returned immediately", async () => {
1202
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
1203
+ await Bun.write(
1204
+ join(tempDir, ".overstory", "config.yaml"),
1205
+ "project:\n canonicalBranch: main\n",
1206
+ );
1207
+ process.env.OVERSTORY_PROJECT_ROOT = tempDir;
1208
+ const result = await resolveProjectRoot("/some/unrelated/path");
1209
+ expect(result).toBe(tempDir);
1210
+ });
1211
+
1212
+ test("env var beats walk-up worktree resolution", async () => {
1213
+ // Set up a parent root with config.yaml
1214
+ const parentRoot = tempDir;
1215
+ await mkdir(join(parentRoot, ".overstory", "worktrees", "some-agent"), { recursive: true });
1216
+ await Bun.write(
1217
+ join(parentRoot, ".overstory", "config.yaml"),
1218
+ "project:\n canonicalBranch: main\n",
1219
+ );
1220
+ const worktreePath = join(parentRoot, ".overstory", "worktrees", "some-agent");
1221
+ // Even though walk-up would resolve parentRoot, env var pointing elsewhere wins
1222
+ const envTarget = await mkdtemp(join(tmpdir(), "overstory-envtarget-"));
1223
+ try {
1224
+ process.env.OVERSTORY_PROJECT_ROOT = envTarget;
1225
+ const result = await resolveProjectRoot(worktreePath);
1226
+ expect(result).toBe(envTarget);
1227
+ } finally {
1228
+ await cleanupTempDir(envTarget);
1229
+ }
1230
+ });
1231
+
1232
+ test("walk-up resolves submodule path without git", async () => {
1233
+ // Simulate a submodule worktree: {tempDir}/.overstory/worktrees/my-agent/sub
1234
+ // config.yaml exists at {tempDir}/.overstory/config.yaml
1235
+ const worktreeBase = join(tempDir, ".overstory", "worktrees", "my-agent");
1236
+ const subDir = join(worktreeBase, "sub");
1237
+ await mkdir(subDir, { recursive: true });
1238
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
1239
+ await Bun.write(
1240
+ join(tempDir, ".overstory", "config.yaml"),
1241
+ "project:\n canonicalBranch: main\n",
1242
+ );
1243
+ const result = await resolveProjectRoot(subDir);
1244
+ expect(result).toBe(tempDir);
1245
+ });
1246
+
1247
+ test("walk-up is skipped when parent has no config.yaml", async () => {
1248
+ // Same path structure but NO config.yaml at parentRoot
1249
+ const worktreeBase = join(tempDir, ".overstory", "worktrees", "my-agent");
1250
+ await mkdir(worktreeBase, { recursive: true });
1251
+ // No config.yaml written — walk-up guard should prevent false resolution
1252
+ const result = await resolveProjectRoot(worktreeBase);
1253
+ // Falls through to startDir fallback
1254
+ expect(result).toBe(worktreeBase);
1255
+ });
1256
+ });
1257
+
1180
1258
  describe("projectRootOverride", () => {
1181
1259
  let tempDir: string;
1182
1260
 
package/src/config.ts CHANGED
@@ -905,7 +905,26 @@ export async function resolveProjectRoot(startDir: string): Promise<string> {
905
905
 
906
906
  const { existsSync } = require("node:fs") as typeof import("node:fs");
907
907
 
908
- // Check git worktree FIRST. When running from an agent worktree
908
+ // Check OVERSTORY_PROJECT_ROOT env var. Zero-heuristic injected by ov sling
909
+ // into agent environments so submodule topology doesn't matter.
910
+ const envRoot = process.env.OVERSTORY_PROJECT_ROOT;
911
+ if (envRoot && envRoot.length > 0) {
912
+ return envRoot;
913
+ }
914
+
915
+ // Walk-up worktree-path detection. Topology-independent submodule fix:
916
+ // if startDir contains /.overstory/worktrees/ as a path segment, the
917
+ // substring before it is the project root — verify with config.yaml.
918
+ const WT_SEGMENT = `/${OVERSTORY_DIR}/worktrees/`;
919
+ const idx = startDir.indexOf(WT_SEGMENT);
920
+ if (idx > 0) {
921
+ const parentRoot = startDir.slice(0, idx);
922
+ if (existsSync(join(parentRoot, OVERSTORY_DIR, CONFIG_FILENAME))) {
923
+ return parentRoot;
924
+ }
925
+ }
926
+
927
+ // Check git worktree. When running from an agent worktree
909
928
  // (e.g., .overstory/worktrees/{name}/), the worktree may contain
910
929
  // tracked copies of .overstory/config.yaml. We must resolve to the
911
930
  // main repository root so runtime state (mail.db, metrics.db, etc.)
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { openSessionStore } from "../sessions/compat.ts";
4
4
  import type { AgentSession, OverstoryConfig } from "../types.ts";
5
5
  import { listWorktrees } from "../worktree/manager.ts";
6
- import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
6
+ import { isProcessAlive, listSessions, sanitizeTmuxName } from "../worktree/tmux.ts";
7
7
  import type { DoctorCheck } from "./types.ts";
8
8
 
9
9
  /**
@@ -134,7 +134,7 @@ export async function checkConsistency(
134
134
 
135
135
  // 5. Check for orphaned tmux sessions (tmux session exists but no SessionStore entry)
136
136
  const projectName = config.project.name;
137
- const overstoryTmuxPrefix = `overstory-${projectName}-`;
137
+ const overstoryTmuxPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
138
138
  const overstoryTmuxSessions = tmuxSessions.filter((s) => s.name.startsWith(overstoryTmuxPrefix));
139
139
  const storeTmuxNames = new Set(storeSessions.map((s) => s.tmuxSession));
140
140
 
package/src/index.ts CHANGED
@@ -51,7 +51,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
51
51
  import { jsonError } from "./json.ts";
52
52
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
53
53
 
54
- export const VERSION = "0.9.2";
54
+ export const VERSION = "0.9.4";
55
55
 
56
56
  const rawArgs = process.argv.slice(2);
57
57
 
@@ -203,7 +203,7 @@ describe("CodexRuntime", () => {
203
203
  expect(cmd1).toBe(cmd2);
204
204
  });
205
205
 
206
- test("all model names pass through unchanged", () => {
206
+ test("all bare model names pass through unchanged", () => {
207
207
  for (const model of ["gpt-5-codex", "gpt-4o", "o3", "custom-model-v2"]) {
208
208
  const opts: SpawnOpts = {
209
209
  model,
@@ -216,6 +216,30 @@ describe("CodexRuntime", () => {
216
216
  }
217
217
  });
218
218
 
219
+ test("provider-prefixed model strips prefix (openai/gpt-5.4 → gpt-5.4)", () => {
220
+ const opts: SpawnOpts = {
221
+ model: "openai/gpt-5.4",
222
+ permissionMode: "bypass",
223
+ cwd: "/tmp",
224
+ env: {},
225
+ };
226
+ const cmd = runtime.buildSpawnCommand(opts);
227
+ expect(cmd).toContain("--model gpt-5.4");
228
+ expect(cmd).not.toContain("openai/");
229
+ });
230
+
231
+ test("provider-prefixed model with other providers strips prefix", () => {
232
+ const opts: SpawnOpts = {
233
+ model: "azure/gpt-4o",
234
+ permissionMode: "bypass",
235
+ cwd: "/tmp",
236
+ env: {},
237
+ };
238
+ const cmd = runtime.buildSpawnCommand(opts);
239
+ expect(cmd).toContain("--model gpt-4o");
240
+ expect(cmd).not.toContain("azure/");
241
+ });
242
+
219
243
  test("systemPrompt field is ignored", () => {
220
244
  const opts: SpawnOpts = {
221
245
  model: "gpt-5-codex",
@@ -248,6 +272,19 @@ describe("CodexRuntime", () => {
248
272
  ]);
249
273
  });
250
274
 
275
+ test("provider-prefixed model strips prefix (openai/gpt-5.4 → gpt-5.4)", () => {
276
+ const argv = runtime.buildPrintCommand("Classify this error", "openai/gpt-5.4");
277
+ expect(argv).toEqual([
278
+ "codex",
279
+ "exec",
280
+ "--full-auto",
281
+ "--ephemeral",
282
+ "--model",
283
+ "gpt-5.4",
284
+ "Classify this error",
285
+ ]);
286
+ });
287
+
251
288
  test("model undefined omits --model flag", () => {
252
289
  const argv = runtime.buildPrintCommand("Hello", undefined);
253
290
  expect(argv).not.toContain("--model");
@@ -49,6 +49,21 @@ export class CodexRuntime implements AgentRuntime {
49
49
  */
50
50
  private static readonly MANIFEST_ALIASES = new Set(["sonnet", "opus", "haiku"]);
51
51
 
52
+ /**
53
+ * Strip a provider prefix from a model ID.
54
+ *
55
+ * Codex CLI expects bare model names. The orchestrator may resolve a model to
56
+ * a provider-qualified form (e.g. `"openai/gpt-5.4"`) — strip the `"openai/"`
57
+ * prefix before passing to the CLI.
58
+ *
59
+ * @param model - Possibly provider-qualified model ID
60
+ * @returns Bare model name (everything after the first `/`, or unchanged if no `/`)
61
+ */
62
+ private static stripProviderPrefix(model: string): string {
63
+ const slashIdx = model.indexOf("/");
64
+ return slashIdx !== -1 ? model.slice(slashIdx + 1) : model;
65
+ }
66
+
52
67
  /**
53
68
  * Escape a directory path for use in a single-quoted shell argument.
54
69
  *
@@ -75,11 +90,14 @@ export class CodexRuntime implements AgentRuntime {
75
90
  * @returns Shell command string suitable for tmux new-session -c
76
91
  */
77
92
  buildSpawnCommand(opts: SpawnOpts): string {
93
+ // Strip provider prefix before alias check and model flag injection.
94
+ // Codex CLI expects bare model names (e.g. "gpt-5.4", not "openai/gpt-5.4").
95
+ const bareModel = CodexRuntime.stripProviderPrefix(opts.model);
78
96
  // When model comes from default manifest aliases (sonnet/opus/haiku),
79
97
  // omit --model so Codex uses the user's configured default model.
80
98
  let cmd = "codex --full-auto";
81
- if (!CodexRuntime.MANIFEST_ALIASES.has(opts.model)) {
82
- cmd += ` --model ${opts.model}`;
99
+ if (!CodexRuntime.MANIFEST_ALIASES.has(bareModel)) {
100
+ cmd += ` --model ${bareModel}`;
83
101
  }
84
102
  for (const dir of opts.sharedWritableDirs ?? []) {
85
103
  cmd += ` --add-dir '${CodexRuntime.shellEscape(dir)}'`;
@@ -119,7 +137,8 @@ export class CodexRuntime implements AgentRuntime {
119
137
  buildPrintCommand(prompt: string, model?: string): string[] {
120
138
  const cmd = ["codex", "exec", "--full-auto", "--ephemeral"];
121
139
  if (model !== undefined) {
122
- cmd.push("--model", model);
140
+ // Strip provider prefix — Codex CLI expects bare model names.
141
+ cmd.push("--model", CodexRuntime.stripProviderPrefix(model));
123
142
  }
124
143
  cmd.push(prompt);
125
144
  return cmd;