@os-eco/overstory-cli 0.7.6 → 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 +104 -2
- 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/dashboard.test.ts +101 -10
- package/src/commands/dashboard.ts +95 -61
- 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/runtimes/claude.test.ts +1 -1
- package/src/runtimes/codex.test.ts +741 -0
- package/src/runtimes/codex.ts +228 -0
- package/src/runtimes/pi.test.ts +1 -1
- package/src/runtimes/registry.test.ts +6 -6
- package/src/runtimes/registry.ts +2 -0
- 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
|
@@ -17,6 +17,7 @@ Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agen
|
|
|
17
17
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` CLI)
|
|
18
18
|
- [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) (`pi` CLI)
|
|
19
19
|
- [GitHub Copilot](https://github.com/features/copilot) (`copilot` CLI)
|
|
20
|
+
- [Codex](https://github.com/openai/codex) (`codex` CLI)
|
|
20
21
|
|
|
21
22
|
```bash
|
|
22
23
|
bun install -g @os-eco/overstory-cli
|
|
@@ -79,7 +80,7 @@ Every command supports `--json` where noted. Global flags: `-q`/`--quiet`, `--ti
|
|
|
79
80
|
| Command | Description |
|
|
80
81
|
|---------|-------------|
|
|
81
82
|
| `ov init` | Initialize `.overstory/` and bootstrap os-eco tools (`--yes`, `--name`, `--tools`, `--skip-mulch`, `--skip-seeds`, `--skip-canopy`, `--skip-onboard`, `--json`) |
|
|
82
|
-
| `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`) |
|
|
83
84
|
| `ov stop <agent-name>` | Terminate a running agent (`--clean-worktree`, `--json`) |
|
|
84
85
|
| `ov prime` | Load context for orchestrator/agent (`--agent`, `--compact`) |
|
|
85
86
|
| `ov spec write <task-id>` | Write a task specification (`--body`) |
|
|
@@ -173,6 +174,7 @@ Overstory is runtime-agnostic. The `AgentRuntime` interface (`src/runtimes/types
|
|
|
173
174
|
| Claude Code | `claude` | `settings.local.json` hooks | Stable |
|
|
174
175
|
| Pi | `pi` | `.pi/extensions/` guard extension | Active development |
|
|
175
176
|
| Copilot | `copilot` | (none — `--allow-all-tools`) | Active development |
|
|
177
|
+
| Codex | `codex` | OS-level sandbox (Seatbelt/Landlock) | Active development |
|
|
176
178
|
|
|
177
179
|
## How It Works
|
|
178
180
|
|
|
@@ -269,7 +271,7 @@ overstory/
|
|
|
269
271
|
metrics/ SQLite metrics + pricing + transcript parsing
|
|
270
272
|
doctor/ Health check modules (11 checks)
|
|
271
273
|
insights/ Session insight analyzer for auto-expertise
|
|
272
|
-
runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot)
|
|
274
|
+
runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex)
|
|
273
275
|
tracker/ Pluggable task tracker (beads + seeds backends)
|
|
274
276
|
mulch/ mulch client (programmatic API + CLI wrapper)
|
|
275
277
|
e2e/ End-to-end lifecycle tests
|
|
@@ -277,6 +279,106 @@ overstory/
|
|
|
277
279
|
templates/ Templates for overlays and hooks
|
|
278
280
|
```
|
|
279
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
|
+
|
|
280
382
|
## Part of os-eco
|
|
281
383
|
|
|
282
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", {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
computeAgentPanelHeight,
|
|
22
22
|
dashboardCommand,
|
|
23
23
|
dimBox,
|
|
24
|
+
EventBuffer,
|
|
24
25
|
filterAgentsByRun,
|
|
25
26
|
horizontalLine,
|
|
26
27
|
openDashboardStores,
|
|
@@ -242,28 +243,28 @@ describe("dimBox", () => {
|
|
|
242
243
|
|
|
243
244
|
describe("computeAgentPanelHeight", () => {
|
|
244
245
|
test("0 agents: clamps to minimum 8", () => {
|
|
245
|
-
// max(8, min(floor(30*0.
|
|
246
|
+
// max(8, min(floor(30*0.35)=10, 0+4)) = max(8, min(10,4)) = max(8,4) = 8
|
|
246
247
|
expect(computeAgentPanelHeight(30, 0)).toBe(8);
|
|
247
248
|
});
|
|
248
249
|
|
|
249
250
|
test("4 agents: still clamps to minimum 8", () => {
|
|
250
|
-
// max(8, min(
|
|
251
|
+
// max(8, min(10, 4+4)) = max(8, 8) = 8
|
|
251
252
|
expect(computeAgentPanelHeight(30, 4)).toBe(8);
|
|
252
253
|
});
|
|
253
254
|
|
|
254
|
-
test("20 agents with height 30: clamps to floor(height*0.
|
|
255
|
-
// max(8, min(
|
|
256
|
-
expect(computeAgentPanelHeight(30, 20)).toBe(
|
|
255
|
+
test("20 agents with height 30: clamps to floor(height*0.35)", () => {
|
|
256
|
+
// max(8, min(floor(30*0.35)=10, 24)) = max(8,10) = 10
|
|
257
|
+
expect(computeAgentPanelHeight(30, 20)).toBe(10);
|
|
257
258
|
});
|
|
258
259
|
|
|
259
260
|
test("10 agents with height 30: grows with agent count", () => {
|
|
260
|
-
// max(8, min(
|
|
261
|
-
expect(computeAgentPanelHeight(30, 10)).toBe(
|
|
261
|
+
// max(8, min(10, 14)) = max(8,10) = 10
|
|
262
|
+
expect(computeAgentPanelHeight(30, 10)).toBe(10);
|
|
262
263
|
});
|
|
263
264
|
|
|
264
|
-
test("small height: respects
|
|
265
|
-
// height=20: max(8, min(
|
|
266
|
-
expect(computeAgentPanelHeight(20, 20)).toBe(
|
|
265
|
+
test("small height: respects 35% cap", () => {
|
|
266
|
+
// height=20: max(8, min(floor(20*0.35)=7, 24)) = max(8,7) = 8
|
|
267
|
+
expect(computeAgentPanelHeight(20, 20)).toBe(8);
|
|
267
268
|
});
|
|
268
269
|
});
|
|
269
270
|
|
|
@@ -302,6 +303,7 @@ function makeDashboardData(
|
|
|
302
303
|
metrics: { totalSessions: 0, avgDuration: 0, byCapability: {} },
|
|
303
304
|
tasks: overrides.tasks ?? [],
|
|
304
305
|
recentEvents: (overrides.recentEvents as never[]) ?? [],
|
|
306
|
+
feedColorMap: new Map(),
|
|
305
307
|
};
|
|
306
308
|
}
|
|
307
309
|
|
|
@@ -366,6 +368,7 @@ describe("renderFeedPanel", () => {
|
|
|
366
368
|
const data = makeDashboardData({ recentEvents: [] });
|
|
367
369
|
const out = renderFeedPanel(data, 1, 80, 8, 1);
|
|
368
370
|
expect(out).toContain("Feed");
|
|
371
|
+
expect(out).toContain("(live)");
|
|
369
372
|
});
|
|
370
373
|
|
|
371
374
|
test("renders event agent name when events are present", () => {
|
|
@@ -554,6 +557,94 @@ describe("closeDashboardStores", () => {
|
|
|
554
557
|
});
|
|
555
558
|
});
|
|
556
559
|
|
|
560
|
+
describe("EventBuffer", () => {
|
|
561
|
+
let tempDir: string;
|
|
562
|
+
|
|
563
|
+
beforeEach(async () => {
|
|
564
|
+
tempDir = await mkdtemp(join(tmpdir(), "event-buffer-test-"));
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
afterEach(async () => {
|
|
568
|
+
await cleanupTempDir(tempDir);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
function makeEvent(agentName: string) {
|
|
572
|
+
return {
|
|
573
|
+
agentName,
|
|
574
|
+
eventType: "tool_end" as const,
|
|
575
|
+
level: "info" as const,
|
|
576
|
+
runId: null,
|
|
577
|
+
sessionId: null,
|
|
578
|
+
toolName: null,
|
|
579
|
+
toolArgs: null,
|
|
580
|
+
toolDurationMs: null,
|
|
581
|
+
data: null,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
test("starts empty", () => {
|
|
586
|
+
const buf = new EventBuffer();
|
|
587
|
+
expect(buf.size).toBe(0);
|
|
588
|
+
expect(buf.getEvents()).toEqual([]);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("poll adds events from event store", async () => {
|
|
592
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
593
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
594
|
+
const store = createEventStore(join(overstoryDir, "events.db"));
|
|
595
|
+
store.insert(makeEvent("agent-a"));
|
|
596
|
+
|
|
597
|
+
const buf = new EventBuffer();
|
|
598
|
+
buf.poll(store);
|
|
599
|
+
expect(buf.size).toBe(1);
|
|
600
|
+
store.close();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("deduplicates by lastSeenId (double poll returns same count)", async () => {
|
|
604
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
605
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
606
|
+
const store = createEventStore(join(overstoryDir, "events.db"));
|
|
607
|
+
store.insert(makeEvent("agent-a"));
|
|
608
|
+
|
|
609
|
+
const buf = new EventBuffer();
|
|
610
|
+
buf.poll(store);
|
|
611
|
+
buf.poll(store); // second poll should not duplicate
|
|
612
|
+
expect(buf.size).toBe(1);
|
|
613
|
+
store.close();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("trims to maxSize keeping most recent events", async () => {
|
|
617
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
618
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
619
|
+
const store = createEventStore(join(overstoryDir, "events.db"));
|
|
620
|
+
for (let i = 0; i < 5; i++) {
|
|
621
|
+
store.insert(makeEvent(`agent-${i}`));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const buf = new EventBuffer(3);
|
|
625
|
+
buf.poll(store);
|
|
626
|
+
expect(buf.size).toBe(3);
|
|
627
|
+
store.close();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("builds color map across polls", async () => {
|
|
631
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
632
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
633
|
+
const store = createEventStore(join(overstoryDir, "events.db"));
|
|
634
|
+
store.insert(makeEvent("agent-x"));
|
|
635
|
+
|
|
636
|
+
const buf = new EventBuffer();
|
|
637
|
+
buf.poll(store);
|
|
638
|
+
expect(buf.getColorMap().has("agent-x")).toBe(true);
|
|
639
|
+
|
|
640
|
+
store.insert(makeEvent("agent-y"));
|
|
641
|
+
buf.poll(store);
|
|
642
|
+
expect(buf.getColorMap().has("agent-x")).toBe(true);
|
|
643
|
+
expect(buf.getColorMap().has("agent-y")).toBe(true);
|
|
644
|
+
store.close();
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
557
648
|
// Type check: DashboardStores includes eventStore
|
|
558
649
|
test("DashboardStores type includes eventStore field", () => {
|
|
559
650
|
const stores: DashboardStores = {
|