@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 +101 -1
- package/package.json +1 -1
- package/src/commands/coordinator.test.ts +131 -2
- package/src/commands/coordinator.ts +40 -9
- package/src/commands/costs.test.ts +5 -0
- package/src/commands/costs.ts +1 -1
- package/src/commands/log.ts +2 -0
- package/src/commands/sling.test.ts +63 -1
- package/src/commands/sling.ts +37 -2
- package/src/config.test.ts +68 -0
- package/src/config.ts +16 -0
- package/src/index.ts +2 -1
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/store.test.ts +227 -0
- package/src/metrics/store.ts +40 -5
- package/src/schema-consistency.test.ts +1 -0
- package/src/types.ts +8 -0
- package/src/worktree/tmux.test.ts +49 -0
- package/src/worktree/tmux.ts +33 -0
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.
|
|
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?: {
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
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();
|
package/src/commands/costs.ts
CHANGED
|
@@ -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", {
|
package/src/commands/log.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/sling.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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));
|