@os-eco/overstory-cli 0.7.7 → 0.7.8

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 CHANGED
@@ -80,7 +80,7 @@ Every command supports `--json` where noted. Global flags: `-q`/`--quiet`, `--ti
80
80
  | Command | Description |
81
81
  |---------|-------------|
82
82
  | `ov init` | Initialize `.overstory/` and bootstrap os-eco tools (`--yes`, `--name`, `--tools`, `--skip-mulch`, `--skip-seeds`, `--skip-canopy`, `--skip-onboard`, `--json`) |
83
- | `ov sling <task-id>` | Spawn a worker agent (`--capability`, `--name`, `--spec`, `--files`, `--parent`, `--depth`, `--skip-scout`, `--skip-review`, `--max-agents`, `--dispatch-max-agents`, `--skip-task-check`, `--no-scout-check`, `--runtime`, `--json`) |
83
+ | `ov sling <task-id>` | Spawn a worker agent (`--capability`, `--name`, `--spec`, `--files`, `--parent`, `--depth`, `--skip-scout`, `--skip-review`, `--max-agents`, `--dispatch-max-agents`, `--skip-task-check`, `--no-scout-check`, `--runtime`, `--base-branch`, `--json`) |
84
84
  | `ov stop <agent-name>` | Terminate a running agent (`--clean-worktree`, `--json`) |
85
85
  | `ov prime` | Load context for orchestrator/agent (`--agent`, `--compact`) |
86
86
  | `ov spec write <task-id>` | Write a task specification (`--body`) |
@@ -279,6 +279,106 @@ overstory/
279
279
  templates/ Templates for overlays and hooks
280
280
  ```
281
281
 
282
+ ## Configuration
283
+
284
+ ### Gateway Providers
285
+
286
+ Route agent API calls through custom gateway endpoints (z.ai, OpenRouter, self-hosted proxies). Configure providers in `.overstory/config.yaml`:
287
+
288
+ ```yaml
289
+ providers:
290
+ anthropic:
291
+ type: native
292
+ zai:
293
+ type: gateway
294
+ baseUrl: https://api.z.ai/v1
295
+ authTokenEnv: ZAI_API_KEY
296
+ openrouter:
297
+ type: gateway
298
+ baseUrl: https://openrouter.ai/api/v1
299
+ authTokenEnv: OPENROUTER_API_KEY
300
+ models:
301
+ builder: zai/claude-sonnet-4-6
302
+ scout: openrouter/openai/gpt-4o
303
+ ```
304
+
305
+ **How it works:** Model refs use `provider/model-id` format. Overstory sets `ANTHROPIC_BASE_URL` to the gateway `baseUrl`, `ANTHROPIC_AUTH_TOKEN` from the env var named in `authTokenEnv`, and `ANTHROPIC_API_KEY=""` to prevent direct Anthropic calls. The agent receives `"sonnet"` as a model alias and Claude Code routes via env vars.
306
+
307
+ **Environment variable notes:**
308
+ - `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` are mutually exclusive per-agent
309
+ - Gateway agents get `ANTHROPIC_API_KEY=""` and `ANTHROPIC_AUTH_TOKEN` from provider config
310
+ - Direct Anthropic API calls (merge resolver, watchdog triage) still need `ANTHROPIC_API_KEY` in the orchestrator env
311
+
312
+ **Validation:** `ov doctor --category providers` checks reachability, auth tokens, model-provider refs, and tool-use compatibility.
313
+
314
+ **`ProviderConfig` fields:**
315
+
316
+ | Field | Type | Required | Description |
317
+ |-------|------|----------|-------------|
318
+ | `type` | `native` or `gateway` | Yes | Provider type |
319
+ | `baseUrl` | string | Gateway only | API endpoint URL |
320
+ | `authTokenEnv` | string | Gateway only | Env var name holding auth token |
321
+
322
+ ## Troubleshooting
323
+
324
+ ### Coordinator died during startup
325
+
326
+ This error means the coordinator tmux session exited before the TUI became ready. The most common cause is slow shell initialization.
327
+
328
+ **Step 1: Measure shell startup time**
329
+
330
+ ```bash
331
+ time zsh -i -c exit # For zsh
332
+ time bash -i -c exit # For bash
333
+ ```
334
+
335
+ If startup takes more than 1 second, slow shell init is likely the cause.
336
+
337
+ **Step 2: Common slow-startup causes**
338
+
339
+ | Cause | Typical delay | Fix |
340
+ |-------|---------------|-----|
341
+ | oh-my-zsh with many plugins | 1-5s | Reduce plugins, switch to lighter framework (zinit with lazy loading) |
342
+ | nvm (Node Version Manager) | 1-3s | Use `--no-use` + lazy-load nvm, or switch to fnm/volta |
343
+ | pyenv init | 0.5-2s | Lazy-load pyenv |
344
+ | rbenv init | 0.5-1s | Lazy-load rbenv |
345
+ | starship prompt | 0.5-1s | Check starship timings |
346
+ | conda auto-activate | 1-3s | `auto_activate_base: false` in `.condarc` |
347
+ | Homebrew shellenv | 0.5-1s | Cache output instead of evaluating every shell start |
348
+
349
+ **Step 3: Configure `shellInitDelayMs`** in `.overstory/config.yaml`:
350
+
351
+ ```yaml
352
+ runtime:
353
+ shellInitDelayMs: 3000
354
+ ```
355
+
356
+ - Default: `0` (no delay)
357
+ - Typical values: `1000`–`5000` depending on shell startup time
358
+ - Values above `30000` (30s) trigger a warning
359
+ - Inserts a delay between tmux session creation and TUI readiness polling
360
+
361
+ **Step 4: Optimization examples**
362
+
363
+ Lazy-load nvm (add to `~/.zshrc` or `~/.bashrc`):
364
+
365
+ ```bash
366
+ # Lazy-load nvm — only activates when you first call nvm/node/npm
367
+ export NVM_DIR="$HOME/.nvm"
368
+ nvm() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; nvm "$@"; }
369
+ node() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; node "$@"; }
370
+ npm() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; npm "$@"; }
371
+ npx() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; npx "$@"; }
372
+ ```
373
+
374
+ Reduce oh-my-zsh plugins (edit `~/.zshrc`):
375
+
376
+ ```bash
377
+ # Before: plugins=(git zsh-autosuggestions zsh-syntax-highlighting node npm python ruby rbenv pyenv ...)
378
+ # After: keep only what you use regularly
379
+ plugins=(git)
380
+ ```
381
+
282
382
  ## Part of os-eco
283
383
 
284
384
  Overstory is part of the [os-eco](https://github.com/jayminwest/os-eco) AI agent tooling ecosystem.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@ interface TmuxCallTracker {
38
38
  env?: Record<string, string>;
39
39
  }>;
40
40
  isSessionAlive: Array<{ name: string; result: boolean }>;
41
+ checkSessionState: Array<{ name: string; result: "alive" | "dead" | "no_server" }>;
41
42
  killSession: Array<{ name: string }>;
42
43
  sendKeys: Array<{ name: string; keys: string }>;
43
44
  waitForTuiReady: Array<{ name: string }>;
@@ -68,6 +69,7 @@ function makeFakeTmux(
68
69
  options: {
69
70
  waitForTuiReadyResult?: boolean;
70
71
  ensureTmuxAvailableError?: Error;
72
+ checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
71
73
  } = {},
72
74
  ): {
73
75
  tmux: NonNullable<CoordinatorDeps["_tmux"]>;
@@ -76,6 +78,7 @@ function makeFakeTmux(
76
78
  const calls: TmuxCallTracker = {
77
79
  createSession: [],
78
80
  isSessionAlive: [],
81
+ checkSessionState: [],
79
82
  killSession: [],
80
83
  sendKeys: [],
81
84
  waitForTuiReady: [],
@@ -97,6 +100,13 @@ function makeFakeTmux(
97
100
  calls.isSessionAlive.push({ name, result: alive });
98
101
  return alive;
99
102
  },
103
+ checkSessionState: async (name: string): Promise<"alive" | "dead" | "no_server"> => {
104
+ const stateMap = options.checkSessionStateMap ?? {};
105
+ // Default: derive from sessionAliveMap for backwards compat
106
+ const state = stateMap[name] ?? (sessionAliveMap[name] ? "alive" : "dead");
107
+ calls.checkSessionState.push({ name, result: state });
108
+ return state;
109
+ },
100
110
  killSession: async (name: string): Promise<void> => {
101
111
  calls.killSession.push({ name });
102
112
  },
@@ -325,7 +335,11 @@ function makeDeps(
325
335
  sessionAliveMap: Record<string, boolean> = {},
326
336
  watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
327
337
  monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
328
- tmuxOptions?: { waitForTuiReadyResult?: boolean; ensureTmuxAvailableError?: Error },
338
+ tmuxOptions?: {
339
+ waitForTuiReadyResult?: boolean;
340
+ ensureTmuxAvailableError?: Error;
341
+ checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
342
+ },
329
343
  ): {
330
344
  deps: CoordinatorDeps;
331
345
  calls: TmuxCallTracker;
@@ -606,7 +620,7 @@ describe("startCoordinator", () => {
606
620
 
607
621
  test("rejects duplicate when coordinator is already running", async () => {
608
622
  // Write an existing active coordinator session
609
- const existing = makeCoordinatorSession({ state: "working" });
623
+ const existing = makeCoordinatorSession({ state: "working", pid: process.pid });
610
624
  saveSessionsToDb([existing]);
611
625
 
612
626
  // Mock tmux as alive for the existing session
@@ -623,6 +637,29 @@ describe("startCoordinator", () => {
623
637
  }
624
638
  });
625
639
 
640
+ test("rejects duplicate when pid is null but tmux session is alive", async () => {
641
+ // Session has null pid (e.g. migrated from older schema) but tmux is alive.
642
+ // Cannot prove it's a zombie without a pid, so treat as active.
643
+ const existing = makeCoordinatorSession({ state: "working", pid: null });
644
+ saveSessionsToDb([existing]);
645
+
646
+ const { deps } = makeDeps(
647
+ { "overstory-test-project-coordinator": true },
648
+ undefined,
649
+ undefined,
650
+ { checkSessionStateMap: { "overstory-test-project-coordinator": "alive" } },
651
+ );
652
+
653
+ try {
654
+ await coordinatorCommand(["start"], deps);
655
+ expect(true).toBe(false); // Should have thrown
656
+ } catch (err) {
657
+ expect(err).toBeInstanceOf(AgentError);
658
+ const ae = err as AgentError;
659
+ expect(ae.message).toContain("already running");
660
+ }
661
+ });
662
+
626
663
  test("cleans up dead session and starts new one", async () => {
627
664
  // Write an existing session that claims to be working
628
665
  const deadSession = makeCoordinatorSession({
@@ -656,6 +693,98 @@ describe("startCoordinator", () => {
656
693
  expect(newSession?.id).not.toBe("session-dead-coordinator");
657
694
  });
658
695
 
696
+ test("cleans up zombie session when tmux alive but PID dead", async () => {
697
+ // Session is "working" in DB, tmux session exists, but the PID is dead
698
+ const zombieSession = makeCoordinatorSession({
699
+ id: "session-zombie-coordinator",
700
+ state: "working",
701
+ pid: 999999, // Non-existent PID
702
+ });
703
+ saveSessionsToDb([zombieSession]);
704
+
705
+ // Tmux session is alive (pane exists) but PID 999999 is not running
706
+ const { deps } = makeDeps(
707
+ { "overstory-test-project-coordinator": true },
708
+ undefined,
709
+ undefined,
710
+ { checkSessionStateMap: { "overstory-test-project-coordinator": "alive" } },
711
+ );
712
+
713
+ const originalSleep = Bun.sleep;
714
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
715
+
716
+ try {
717
+ await captureStdout(() => coordinatorCommand(["start"], deps));
718
+ } finally {
719
+ Bun.sleep = originalSleep;
720
+ }
721
+
722
+ // Zombie session should be cleaned up and new one created
723
+ const sessions = loadSessionsFromDb();
724
+ expect(sessions).toHaveLength(1);
725
+ const newSession = sessions[0];
726
+ expect(newSession?.state).toBe("booting");
727
+ expect(newSession?.id).not.toBe("session-zombie-coordinator");
728
+ });
729
+
730
+ test("cleans up stale session when tmux server is not running", async () => {
731
+ // Session is "booting" in DB but tmux server crashed
732
+ const staleSession = makeCoordinatorSession({
733
+ id: "session-stale-coordinator",
734
+ state: "booting",
735
+ });
736
+ saveSessionsToDb([staleSession]);
737
+
738
+ // checkSessionState returns no_server
739
+ const { deps } = makeDeps(
740
+ { "overstory-test-project-coordinator": false },
741
+ undefined,
742
+ undefined,
743
+ { checkSessionStateMap: { "overstory-test-project-coordinator": "no_server" } },
744
+ );
745
+
746
+ const originalSleep = Bun.sleep;
747
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
748
+
749
+ try {
750
+ await captureStdout(() => coordinatorCommand(["start"], deps));
751
+ } finally {
752
+ Bun.sleep = originalSleep;
753
+ }
754
+
755
+ // Stale session cleaned up, new one created
756
+ const sessions = loadSessionsFromDb();
757
+ expect(sessions).toHaveLength(1);
758
+ const newSession = sessions[0];
759
+ expect(newSession?.state).toBe("booting");
760
+ expect(newSession?.id).not.toBe("session-stale-coordinator");
761
+ });
762
+
763
+ test("respects shellInitDelayMs config before polling TUI readiness", async () => {
764
+ // Append shellInitDelayMs to existing config (preserve tier2Enabled etc.)
765
+ const configPath = join(tempDir, ".overstory", "config.yaml");
766
+ const existing = await Bun.file(configPath).text();
767
+ await Bun.write(configPath, `${existing}\nruntime:\n shellInitDelayMs: 500\n`);
768
+
769
+ const { deps } = makeDeps();
770
+
771
+ const sleepCalls: number[] = [];
772
+ const originalSleep = Bun.sleep;
773
+ Bun.sleep = ((ms: number | Date) => {
774
+ if (typeof ms === "number") sleepCalls.push(ms);
775
+ return Promise.resolve();
776
+ }) as typeof Bun.sleep;
777
+
778
+ try {
779
+ await captureStdout(() => coordinatorCommand(["start"], deps));
780
+ } finally {
781
+ Bun.sleep = originalSleep;
782
+ }
783
+
784
+ // The 500ms shell init delay should appear in the sleep calls
785
+ expect(sleepCalls).toContain(500);
786
+ });
787
+
659
788
  test("throws AgentError when tmux is not available", async () => {
660
789
  const { deps } = makeDeps({}, undefined, undefined, {
661
790
  ensureTmuxAvailableError: new AgentError(
@@ -27,7 +27,9 @@ import { createRunStore } from "../sessions/store.ts";
27
27
  import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
28
28
  import type { AgentSession } from "../types.ts";
29
29
  import { isProcessRunning } from "../watchdog/health.ts";
30
+ import type { SessionState } from "../worktree/tmux.ts";
30
31
  import {
32
+ checkSessionState,
31
33
  createSession,
32
34
  ensureTmuxAvailable,
33
35
  isSessionAlive,
@@ -58,6 +60,7 @@ export interface CoordinatorDeps {
58
60
  env?: Record<string, string>,
59
61
  ) => Promise<number>;
60
62
  isSessionAlive: (name: string) => Promise<boolean>;
63
+ checkSessionState: (name: string) => Promise<SessionState>;
61
64
  killSession: (name: string) => Promise<void>;
62
65
  sendKeys: (name: string, keys: string) => Promise<void>;
63
66
  waitForTuiReady: (
@@ -275,6 +278,7 @@ async function startCoordinator(
275
278
  const tmux = deps._tmux ?? {
276
279
  createSession,
277
280
  isSessionAlive,
281
+ checkSessionState,
278
282
  killSession,
279
283
  sendKeys,
280
284
  waitForTuiReady,
@@ -308,15 +312,29 @@ async function startCoordinator(
308
312
  existing.state !== "completed" &&
309
313
  existing.state !== "zombie"
310
314
  ) {
311
- const alive = await tmux.isSessionAlive(existing.tmuxSession);
312
- if (alive) {
313
- throw new AgentError(
314
- `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
315
- { agentName: COORDINATOR_NAME },
316
- );
315
+ const sessionState = await tmux.checkSessionState(existing.tmuxSession);
316
+
317
+ if (sessionState === "alive") {
318
+ // Tmux session exists -- but is the process inside still running?
319
+ // A crashed Claude Code leaves a zombie tmux pane that blocks retries.
320
+ if (existing.pid !== null && !isProcessRunning(existing.pid)) {
321
+ // Zombie: tmux pane exists but agent process has exited.
322
+ // Kill the empty session and reclaim the slot.
323
+ await tmux.killSession(existing.tmuxSession);
324
+ store.updateState(COORDINATOR_NAME, "completed");
325
+ } else {
326
+ // Either the process is genuinely running (pid alive), or pid is null
327
+ // (e.g. sessions migrated from an older schema). In both cases we
328
+ // cannot prove the session is a zombie, so treat it as active.
329
+ throw new AgentError(
330
+ `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
331
+ { agentName: COORDINATOR_NAME },
332
+ );
333
+ }
334
+ } else {
335
+ // Session is dead or tmux server is not running -- clean up stale DB entry.
336
+ store.updateState(COORDINATOR_NAME, "completed");
317
337
  }
318
- // Session recorded but tmux is dead — mark as completed and continue
319
- store.updateState(COORDINATOR_NAME, "completed");
320
338
  }
321
339
 
322
340
  // Resolve model and runtime early (needed for deployConfig and spawn)
@@ -413,6 +431,12 @@ async function startCoordinator(
413
431
 
414
432
  store.upsert(session);
415
433
 
434
+ // Give slow shells time to finish initializing before polling for TUI readiness.
435
+ const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
436
+ if (shellDelay > 0) {
437
+ await Bun.sleep(shellDelay);
438
+ }
439
+
416
440
  // Wait for Claude Code TUI to render before sending input
417
441
  const tuiReady = await tmux.waitForTuiReady(tmuxSession, (content) =>
418
442
  runtime.detectReady(content),
@@ -423,8 +447,13 @@ async function startCoordinator(
423
447
  if (!alive) {
424
448
  // Clean up the stale session record
425
449
  store.updateState(COORDINATOR_NAME, "completed");
450
+ const sessionState = await tmux.checkSessionState(tmuxSession);
451
+ const detail =
452
+ sessionState === "no_server"
453
+ ? "The tmux server is no longer running. It may have crashed or been killed externally."
454
+ : "The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.";
426
455
  throw new AgentError(
427
- `Coordinator tmux session "${tmuxSession}" died during startup. The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.`,
456
+ `Coordinator tmux session "${tmuxSession}" died during startup. ${detail}`,
428
457
  { agentName: COORDINATOR_NAME },
429
458
  );
430
459
  }
@@ -512,6 +541,7 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
512
541
  const tmux = deps._tmux ?? {
513
542
  createSession,
514
543
  isSessionAlive,
544
+ checkSessionState,
515
545
  killSession,
516
546
  sendKeys,
517
547
  waitForTuiReady,
@@ -626,6 +656,7 @@ async function statusCoordinator(
626
656
  const tmux = deps._tmux ?? {
627
657
  createSession,
628
658
  isSessionAlive,
659
+ checkSessionState,
629
660
  killSession,
630
661
  sendKeys,
631
662
  waitForTuiReady,
@@ -796,6 +796,7 @@ describe("costsCommand", () => {
796
796
  cacheCreationTokens: 100,
797
797
  estimatedCostUsd: 0.15,
798
798
  modelUsed: "claude-sonnet-4-5",
799
+ runId: null,
799
800
  createdAt: new Date().toISOString(),
800
801
  });
801
802
  metricsStore.close();
@@ -853,6 +854,7 @@ describe("costsCommand", () => {
853
854
  cacheCreationTokens: 100,
854
855
  estimatedCostUsd: 0.15,
855
856
  modelUsed: "claude-sonnet-4-5",
857
+ runId: null,
856
858
  createdAt: new Date().toISOString(),
857
859
  });
858
860
  metricsStore.close();
@@ -937,6 +939,7 @@ describe("costsCommand", () => {
937
939
  cacheCreationTokens: 0,
938
940
  estimatedCostUsd: 0.15,
939
941
  modelUsed: "claude-sonnet-4-5",
942
+ runId: null,
940
943
  createdAt: new Date().toISOString(),
941
944
  });
942
945
  metricsStore.recordSnapshot({
@@ -947,6 +950,7 @@ describe("costsCommand", () => {
947
950
  cacheCreationTokens: 0,
948
951
  estimatedCostUsd: 0.25,
949
952
  modelUsed: "claude-sonnet-4-5",
953
+ runId: null,
950
954
  createdAt: new Date().toISOString(),
951
955
  });
952
956
  metricsStore.close();
@@ -997,6 +1001,7 @@ describe("costsCommand", () => {
997
1001
  cacheCreationTokens: 0,
998
1002
  estimatedCostUsd: 0.3,
999
1003
  modelUsed: "claude-sonnet-4-5",
1004
+ runId: null,
1000
1005
  createdAt: new Date().toISOString(),
1001
1006
  });
1002
1007
  metricsStore.close();
@@ -367,7 +367,7 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
367
367
  const { store: sessionStore } = openSessionStore(overstoryDir);
368
368
 
369
369
  try {
370
- const snapshots = metricsStore.getLatestSnapshots();
370
+ const snapshots = metricsStore.getLatestSnapshots(runId ?? undefined);
371
371
  if (snapshots.length === 0) {
372
372
  if (json) {
373
373
  jsonOutput("costs", {
@@ -583,6 +583,7 @@ async function runLog(opts: {
583
583
  const cost = estimateCost(usage);
584
584
  const metricsDbPath = join(config.project.root, ".overstory", "metrics.db");
585
585
  const metricsStore = createMetricsStore(metricsDbPath);
586
+ const agentSession = getAgentSession(config.project.root, opts.agent);
586
587
  metricsStore.recordSnapshot({
587
588
  agentName: opts.agent,
588
589
  inputTokens: usage.inputTokens,
@@ -591,6 +592,7 @@ async function runLog(opts: {
591
592
  cacheCreationTokens: usage.cacheCreationTokens,
592
593
  estimatedCostUsd: cost,
593
594
  modelUsed: usage.modelUsed,
595
+ runId: agentSession?.runId ?? null,
594
596
  createdAt: new Date().toISOString(),
595
597
  });
596
598
  metricsStore.close();
@@ -1,8 +1,13 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { realpathSync } from "node:fs";
3
+ import { mkdtemp } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
2
6
  import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
3
7
  import { HierarchyError } from "../errors.ts";
4
8
  import { ClaudeRuntime } from "../runtimes/claude.ts";
5
9
  import { getRuntime } from "../runtimes/registry.ts";
10
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
6
11
  import type { AgentManifest, OverstoryConfig } from "../types.ts";
7
12
  import {
8
13
  type AutoDispatchOptions,
@@ -15,6 +20,7 @@ import {
15
20
  checkRunSessionLimit,
16
21
  checkTaskLock,
17
22
  extractMulchRecordIds,
23
+ getCurrentBranch,
18
24
  inferDomainsFromFiles,
19
25
  isRunningAsRoot,
20
26
  parentHasScouts,
@@ -1274,3 +1280,59 @@ describe("extractMulchRecordIds", () => {
1274
1280
  expect(result).toContainEqual({ id: "mx-2ce43d", domain: "typescript" });
1275
1281
  });
1276
1282
  });
1283
+
1284
+ describe("getCurrentBranch", () => {
1285
+ let repoDir: string;
1286
+
1287
+ beforeEach(async () => {
1288
+ repoDir = realpathSync(await createTempGitRepo());
1289
+ });
1290
+
1291
+ afterEach(async () => {
1292
+ await cleanupTempDir(repoDir);
1293
+ });
1294
+
1295
+ test("returns the current branch name", async () => {
1296
+ const branch = await getCurrentBranch(repoDir);
1297
+ expect(branch).toMatch(/^(main|master)$/);
1298
+ });
1299
+
1300
+ test("returns feature branch name after checkout", async () => {
1301
+ const proc = Bun.spawn(["git", "checkout", "-b", "feature/test-branch"], {
1302
+ cwd: repoDir,
1303
+ stdout: "pipe",
1304
+ stderr: "pipe",
1305
+ });
1306
+ await proc.exited;
1307
+ const branch = await getCurrentBranch(repoDir);
1308
+ expect(branch).toBe("feature/test-branch");
1309
+ });
1310
+
1311
+ test("returns null for detached HEAD", async () => {
1312
+ const hashProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
1313
+ cwd: repoDir,
1314
+ stdout: "pipe",
1315
+ stderr: "pipe",
1316
+ });
1317
+ const hash = (await new Response(hashProc.stdout).text()).trim();
1318
+ await hashProc.exited;
1319
+ const proc = Bun.spawn(["git", "checkout", hash], {
1320
+ cwd: repoDir,
1321
+ stdout: "pipe",
1322
+ stderr: "pipe",
1323
+ });
1324
+ await proc.exited;
1325
+ const branch = await getCurrentBranch(repoDir);
1326
+ expect(branch).toBeNull();
1327
+ });
1328
+
1329
+ test("returns null for non-git directory", async () => {
1330
+ const tmpDir = realpathSync(await mkdtemp(join(tmpdir(), "overstory-notgit-")));
1331
+ try {
1332
+ const branch = await getCurrentBranch(tmpDir);
1333
+ expect(branch).toBeNull();
1334
+ } finally {
1335
+ await cleanupTempDir(tmpDir);
1336
+ }
1337
+ });
1338
+ });
@@ -124,6 +124,7 @@ export interface SlingOptions {
124
124
  dispatchMaxAgents?: string;
125
125
  runtime?: string;
126
126
  noScoutCheck?: boolean;
127
+ baseBranch?: string;
127
128
  }
128
129
 
129
130
  export interface AutoDispatchOptions {
@@ -389,6 +390,28 @@ export function extractMulchRecordIds(primeText: string): Array<{ id: string; do
389
390
  return results;
390
391
  }
391
392
 
393
+ /**
394
+ * Get the current git branch name for the repo at the given path.
395
+ *
396
+ * Returns null if in detached HEAD state, the directory is not a git repo,
397
+ * or git exits non-zero.
398
+ *
399
+ * @param repoRoot - Absolute path to the git repository root
400
+ */
401
+ export async function getCurrentBranch(repoRoot: string): Promise<string | null> {
402
+ const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
403
+ cwd: repoRoot,
404
+ stdout: "pipe",
405
+ stderr: "pipe",
406
+ });
407
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
408
+ if (exitCode !== 0) return null;
409
+ const branch = stdout.trim();
410
+ // "HEAD" is returned when in detached HEAD state
411
+ if (branch === "HEAD" || branch === "") return null;
412
+ return branch;
413
+ }
414
+
392
415
  /**
393
416
  * Entry point for `ov sling <task-id> [flags]`.
394
417
  *
@@ -658,11 +681,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
658
681
  const worktreeBaseDir = join(config.project.root, config.worktrees.baseDir);
659
682
  await mkdir(worktreeBaseDir, { recursive: true });
660
683
 
684
+ // Resolve base branch: --base-branch flag > current HEAD > config.project.canonicalBranch
685
+ const baseBranch =
686
+ opts.baseBranch ??
687
+ (await getCurrentBranch(config.project.root)) ??
688
+ config.project.canonicalBranch;
689
+
661
690
  const { path: worktreePath, branch: branchName } = await createWorktree({
662
691
  repoRoot: config.project.root,
663
692
  baseDir: worktreeBaseDir,
664
693
  agentName: name,
665
- baseBranch: config.project.canonicalBranch,
694
+ baseBranch,
666
695
  taskId: taskId,
667
696
  });
668
697
 
@@ -862,7 +891,13 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
862
891
  runStore.close();
863
892
  }
864
893
 
865
- // 13b. Wait for Claude Code TUI to render before sending input.
894
+ // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
895
+ const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
896
+ if (shellDelay > 0) {
897
+ await Bun.sleep(shellDelay);
898
+ }
899
+
900
+ // Wait for Claude Code TUI to render before sending input.
866
901
  // Polling capture-pane is more reliable than a fixed sleep because
867
902
  // TUI init time varies by machine load and model state.
868
903
  await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));