@os-eco/overstory-cli 0.8.4 → 0.8.5
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 +2 -1
- package/package.json +1 -1
- package/src/agents/manifest.test.ts +33 -8
- package/src/agents/manifest.ts +4 -3
- package/src/commands/inspect.test.ts +398 -1
- package/src/commands/inspect.ts +234 -0
- package/src/commands/sling.ts +1 -1
- package/src/events/tailer.test.ts +461 -0
- package/src/events/tailer.ts +235 -0
- package/src/index.ts +1 -1
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +185 -0
- package/src/runtimes/pi.test.ts +1 -1
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.test.ts +30 -0
- package/src/runtimes/sapling.ts +27 -24
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +2 -0
- package/src/watchdog/daemon.ts +57 -0
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agen
|
|
|
20
20
|
- [Codex](https://github.com/openai/codex) (`codex` CLI)
|
|
21
21
|
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` CLI)
|
|
22
22
|
- [Sapling](https://github.com/jayminwest/sapling) (`sp` CLI)
|
|
23
|
+
- [OpenCode](https://opencode.ai) (`opencode` CLI)
|
|
23
24
|
|
|
24
25
|
```bash
|
|
25
26
|
bun install -g @os-eco/overstory-cli
|
|
@@ -282,7 +283,7 @@ overstory/
|
|
|
282
283
|
metrics/ SQLite metrics + pricing + transcript parsing
|
|
283
284
|
doctor/ Health check modules (11 checks)
|
|
284
285
|
insights/ Session insight analyzer for auto-expertise
|
|
285
|
-
runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex, Gemini, Sapling)
|
|
286
|
+
runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex, Gemini, Sapling, OpenCode)
|
|
286
287
|
tracker/ Pluggable task tracker (beads + seeds backends)
|
|
287
288
|
mulch/ mulch client (programmatic API + CLI wrapper)
|
|
288
289
|
e2e/ End-to-end lifecycle tests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@os-eco/overstory-cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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",
|
|
@@ -551,13 +551,17 @@ describe("resolveModel", () => {
|
|
|
551
551
|
|
|
552
552
|
test("returns manifest model when no config override", () => {
|
|
553
553
|
const config = makeConfig();
|
|
554
|
-
expect(resolveModel(config, baseManifest, "coordinator", "haiku")).toEqual({
|
|
554
|
+
expect(resolveModel(config, baseManifest, "coordinator", "haiku")).toEqual({
|
|
555
|
+
model: "opus",
|
|
556
|
+
isExplicitOverride: false,
|
|
557
|
+
});
|
|
555
558
|
});
|
|
556
559
|
|
|
557
560
|
test("config override takes precedence over manifest", () => {
|
|
558
561
|
const config = makeConfig({ coordinator: "sonnet" });
|
|
559
562
|
expect(resolveModel(config, baseManifest, "coordinator", "haiku")).toEqual({
|
|
560
563
|
model: "sonnet",
|
|
564
|
+
isExplicitOverride: true,
|
|
561
565
|
});
|
|
562
566
|
});
|
|
563
567
|
|
|
@@ -565,12 +569,16 @@ describe("resolveModel", () => {
|
|
|
565
569
|
const config = makeConfig();
|
|
566
570
|
expect(resolveModel(config, baseManifest, "unknown-role", "haiku")).toEqual({
|
|
567
571
|
model: "haiku",
|
|
572
|
+
isExplicitOverride: false,
|
|
568
573
|
});
|
|
569
574
|
});
|
|
570
575
|
|
|
571
576
|
test("config override works for roles not in manifest", () => {
|
|
572
577
|
const config = makeConfig({ supervisor: "opus" });
|
|
573
|
-
expect(resolveModel(config, baseManifest, "supervisor", "sonnet")).toEqual({
|
|
578
|
+
expect(resolveModel(config, baseManifest, "supervisor", "sonnet")).toEqual({
|
|
579
|
+
model: "opus",
|
|
580
|
+
isExplicitOverride: true,
|
|
581
|
+
});
|
|
574
582
|
});
|
|
575
583
|
|
|
576
584
|
test("returns gateway env for provider-prefixed model", () => {
|
|
@@ -592,6 +600,7 @@ describe("resolveModel", () => {
|
|
|
592
600
|
ANTHROPIC_API_KEY: "",
|
|
593
601
|
ANTHROPIC_DEFAULT_SONNET_MODEL: "openai/gpt-5.3",
|
|
594
602
|
},
|
|
603
|
+
isExplicitOverride: true,
|
|
595
604
|
});
|
|
596
605
|
});
|
|
597
606
|
|
|
@@ -618,6 +627,7 @@ describe("resolveModel", () => {
|
|
|
618
627
|
ANTHROPIC_DEFAULT_SONNET_MODEL: "openai/gpt-5.3",
|
|
619
628
|
ANTHROPIC_AUTH_TOKEN: "test-token-123",
|
|
620
629
|
},
|
|
630
|
+
isExplicitOverride: true,
|
|
621
631
|
});
|
|
622
632
|
} finally {
|
|
623
633
|
if (savedEnv === undefined) {
|
|
@@ -631,7 +641,7 @@ describe("resolveModel", () => {
|
|
|
631
641
|
test("unknown provider falls through to model as-is", () => {
|
|
632
642
|
const config = makeConfig({ coordinator: "unknown-provider/some-model" });
|
|
633
643
|
const result = resolveModel(config, baseManifest, "coordinator", "opus");
|
|
634
|
-
expect(result).toEqual({ model: "unknown-provider/some-model" });
|
|
644
|
+
expect(result).toEqual({ model: "unknown-provider/some-model", isExplicitOverride: true });
|
|
635
645
|
});
|
|
636
646
|
|
|
637
647
|
test("native provider returns model string without env", () => {
|
|
@@ -640,7 +650,7 @@ describe("resolveModel", () => {
|
|
|
640
650
|
{ "native-gw": { type: "native" } },
|
|
641
651
|
);
|
|
642
652
|
const result = resolveModel(config, baseManifest, "coordinator", "opus");
|
|
643
|
-
expect(result).toEqual({ model: "native-gw/claude-3-5-sonnet" });
|
|
653
|
+
expect(result).toEqual({ model: "native-gw/claude-3-5-sonnet", isExplicitOverride: true });
|
|
644
654
|
});
|
|
645
655
|
|
|
646
656
|
test("handles deeply nested model ID (slashes in model name)", () => {
|
|
@@ -676,6 +686,18 @@ describe("resolveModel", () => {
|
|
|
676
686
|
expect(result.model).toBe("sonnet");
|
|
677
687
|
expect(result.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("org/model/version");
|
|
678
688
|
});
|
|
689
|
+
|
|
690
|
+
test("resolveModel sets isExplicitOverride true when config.models has override", () => {
|
|
691
|
+
const config = makeConfig({ builder: "opus" });
|
|
692
|
+
const result = resolveModel(config, baseManifest, "builder", "haiku");
|
|
693
|
+
expect(result.isExplicitOverride).toBe(true);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("resolveModel sets isExplicitOverride false when using manifest default", () => {
|
|
697
|
+
const config = makeConfig();
|
|
698
|
+
const result = resolveModel(config, baseManifest, "coordinator", "haiku");
|
|
699
|
+
expect(result.isExplicitOverride).toBe(false);
|
|
700
|
+
});
|
|
679
701
|
});
|
|
680
702
|
|
|
681
703
|
describe("expandAliasFromEnv", () => {
|
|
@@ -783,7 +805,10 @@ describe("resolveModel env var expansion", () => {
|
|
|
783
805
|
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "us.anthropic.claude-3-5-haiku-20241022-v1:0";
|
|
784
806
|
try {
|
|
785
807
|
const result = resolveModel(makeConfig(), baseManifest, "scout", "sonnet");
|
|
786
|
-
expect(result).toEqual({
|
|
808
|
+
expect(result).toEqual({
|
|
809
|
+
model: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
810
|
+
isExplicitOverride: false,
|
|
811
|
+
});
|
|
787
812
|
} finally {
|
|
788
813
|
if (saved === undefined) {
|
|
789
814
|
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
@@ -798,7 +823,7 @@ describe("resolveModel env var expansion", () => {
|
|
|
798
823
|
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
799
824
|
try {
|
|
800
825
|
const result = resolveModel(makeConfig(), baseManifest, "scout", "sonnet");
|
|
801
|
-
expect(result).toEqual({ model: "haiku" });
|
|
826
|
+
expect(result).toEqual({ model: "haiku", isExplicitOverride: false });
|
|
802
827
|
} finally {
|
|
803
828
|
if (saved !== undefined) {
|
|
804
829
|
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = saved;
|
|
@@ -813,7 +838,7 @@ describe("resolveModel env var expansion", () => {
|
|
|
813
838
|
// Config overrides to a direct model string (not an alias)
|
|
814
839
|
const config = makeConfig({ builder: "claude-3-5-sonnet-20241022" });
|
|
815
840
|
const result = resolveModel(config, baseManifest, "builder", "haiku");
|
|
816
|
-
expect(result).toEqual({ model: "claude-3-5-sonnet-20241022" });
|
|
841
|
+
expect(result).toEqual({ model: "claude-3-5-sonnet-20241022", isExplicitOverride: true });
|
|
817
842
|
} finally {
|
|
818
843
|
if (saved === undefined) {
|
|
819
844
|
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
@@ -829,7 +854,7 @@ describe("resolveModel env var expansion", () => {
|
|
|
829
854
|
try {
|
|
830
855
|
const config = makeConfig({ scout: "opus" });
|
|
831
856
|
const result = resolveModel(config, baseManifest, "scout", "haiku");
|
|
832
|
-
expect(result).toEqual({ model: "bedrock-opus-id" });
|
|
857
|
+
expect(result).toEqual({ model: "bedrock-opus-id", isExplicitOverride: true });
|
|
833
858
|
} finally {
|
|
834
859
|
if (saved === undefined) {
|
|
835
860
|
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
package/src/agents/manifest.ts
CHANGED
|
@@ -353,10 +353,11 @@ export function resolveModel(
|
|
|
353
353
|
): ResolvedModel {
|
|
354
354
|
const configModel = config.models[role];
|
|
355
355
|
const rawModel = configModel ?? manifest.agents[role]?.model ?? fallback;
|
|
356
|
+
const isExplicitOverride = configModel !== undefined;
|
|
356
357
|
|
|
357
358
|
// Simple alias — expand via env var if set (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL)
|
|
358
359
|
if (MODEL_ALIASES.has(rawModel)) {
|
|
359
|
-
return { model: expandAliasFromEnv(rawModel) };
|
|
360
|
+
return { model: expandAliasFromEnv(rawModel), isExplicitOverride };
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
// Provider-prefixed: split on first "/" to get provider name and model ID
|
|
@@ -366,10 +367,10 @@ export function resolveModel(
|
|
|
366
367
|
const modelId = rawModel.substring(slashIdx + 1);
|
|
367
368
|
const providerEnv = resolveProviderEnv(providerName, modelId, config.providers);
|
|
368
369
|
if (providerEnv) {
|
|
369
|
-
return { model: DEFAULT_GATEWAY_ALIAS, env: providerEnv };
|
|
370
|
+
return { model: DEFAULT_GATEWAY_ALIAS, env: providerEnv, isExplicitOverride };
|
|
370
371
|
}
|
|
371
372
|
}
|
|
372
373
|
|
|
373
374
|
// Unknown format — return as-is (may be a direct model string)
|
|
374
|
-
return { model: rawModel };
|
|
375
|
+
return { model: rawModel, isExplicitOverride };
|
|
375
376
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
-
import { mkdtemp } from "node:fs/promises";
|
|
12
|
+
import { mkdir, mkdtemp } from "node:fs/promises";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { ValidationError } from "../errors.ts";
|
|
@@ -672,6 +672,7 @@ describe("inspectCommand", () => {
|
|
|
672
672
|
toolStats: [],
|
|
673
673
|
tokenUsage: null,
|
|
674
674
|
tmuxOutput: null,
|
|
675
|
+
headlessTurnInfo: null,
|
|
675
676
|
};
|
|
676
677
|
|
|
677
678
|
printInspectData(data);
|
|
@@ -710,6 +711,7 @@ describe("inspectCommand", () => {
|
|
|
710
711
|
toolStats: [],
|
|
711
712
|
tokenUsage: null,
|
|
712
713
|
tmuxOutput: "[Headless agent — showing recent tool events]",
|
|
714
|
+
headlessTurnInfo: null,
|
|
713
715
|
};
|
|
714
716
|
|
|
715
717
|
printInspectData(data);
|
|
@@ -720,6 +722,401 @@ describe("inspectCommand", () => {
|
|
|
720
722
|
});
|
|
721
723
|
});
|
|
722
724
|
|
|
725
|
+
// === stdout.log fallback (headless agents) ===
|
|
726
|
+
|
|
727
|
+
describe("stdout.log fallback", () => {
|
|
728
|
+
/** Create a headless session in SessionStore and return the overstoryDir. */
|
|
729
|
+
async function setupHeadlessSession(
|
|
730
|
+
agentName: string,
|
|
731
|
+
worktreePathVal = "/tmp/wt",
|
|
732
|
+
): Promise<string> {
|
|
733
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
734
|
+
const sessionsDbPath = join(overstoryDir, "sessions.db");
|
|
735
|
+
const store = createSessionStore(sessionsDbPath);
|
|
736
|
+
store.upsert({
|
|
737
|
+
id: `sess-${agentName}`,
|
|
738
|
+
agentName,
|
|
739
|
+
capability: "builder",
|
|
740
|
+
worktreePath: worktreePathVal,
|
|
741
|
+
branchName: `overstory/headless/${agentName}`,
|
|
742
|
+
taskId: "overstory-h10",
|
|
743
|
+
tmuxSession: "", // headless
|
|
744
|
+
state: "working",
|
|
745
|
+
pid: process.pid,
|
|
746
|
+
parentAgent: null,
|
|
747
|
+
depth: 0,
|
|
748
|
+
runId: null,
|
|
749
|
+
startedAt: new Date().toISOString(),
|
|
750
|
+
lastActivity: new Date().toISOString(),
|
|
751
|
+
escalationLevel: 0,
|
|
752
|
+
stalledSince: null,
|
|
753
|
+
transcriptPath: null,
|
|
754
|
+
});
|
|
755
|
+
store.close();
|
|
756
|
+
return overstoryDir;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Write NDJSON events to stdout.log in the agent's log dir. */
|
|
760
|
+
async function writeStdoutLog(
|
|
761
|
+
overstoryDir: string,
|
|
762
|
+
agentName: string,
|
|
763
|
+
events: Record<string, unknown>[],
|
|
764
|
+
): Promise<void> {
|
|
765
|
+
const logDir = join(overstoryDir, "logs", agentName, "2026-03-05T14-30-00-000Z");
|
|
766
|
+
await mkdir(logDir, { recursive: true });
|
|
767
|
+
const ndjson = `${events.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
768
|
+
await Bun.write(join(logDir, "stdout.log"), ndjson);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
test("populates recentToolCalls from stdout.log when events.db is empty", async () => {
|
|
772
|
+
const overstoryDir = await setupHeadlessSession("stdout-tools");
|
|
773
|
+
await writeStdoutLog(overstoryDir, "stdout-tools", [
|
|
774
|
+
{
|
|
775
|
+
type: "tool_start",
|
|
776
|
+
timestamp: "2026-03-05T14:30:01.000Z",
|
|
777
|
+
toolName: "Read",
|
|
778
|
+
argsSummary: "src/index.ts",
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
type: "tool_end",
|
|
782
|
+
timestamp: "2026-03-05T14:30:01.050Z",
|
|
783
|
+
toolName: "Read",
|
|
784
|
+
success: true,
|
|
785
|
+
durationMs: 50,
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
type: "tool_start",
|
|
789
|
+
timestamp: "2026-03-05T14:30:02.000Z",
|
|
790
|
+
toolName: "Edit",
|
|
791
|
+
argsSummary: "src/commands/inspect.ts",
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
type: "tool_end",
|
|
795
|
+
timestamp: "2026-03-05T14:30:02.200Z",
|
|
796
|
+
toolName: "Edit",
|
|
797
|
+
success: true,
|
|
798
|
+
durationMs: 200,
|
|
799
|
+
},
|
|
800
|
+
]);
|
|
801
|
+
|
|
802
|
+
const data = await gatherInspectData(tempDir, "stdout-tools", { noTmux: true });
|
|
803
|
+
|
|
804
|
+
expect(data.recentToolCalls.length).toBe(2);
|
|
805
|
+
expect(data.recentToolCalls[0]?.toolName).toBe("Read");
|
|
806
|
+
expect(data.recentToolCalls[0]?.durationMs).toBe(50);
|
|
807
|
+
expect(data.recentToolCalls[0]?.args).toBe("src/index.ts");
|
|
808
|
+
expect(data.recentToolCalls[1]?.toolName).toBe("Edit");
|
|
809
|
+
expect(data.recentToolCalls[1]?.durationMs).toBe(200);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("populates tokenUsage from turn_end events when metrics.db is absent", async () => {
|
|
813
|
+
const overstoryDir = await setupHeadlessSession("stdout-tokens");
|
|
814
|
+
await writeStdoutLog(overstoryDir, "stdout-tokens", [
|
|
815
|
+
{
|
|
816
|
+
type: "turn_start",
|
|
817
|
+
timestamp: "2026-03-05T14:30:00.000Z",
|
|
818
|
+
turn: 1,
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
type: "turn_end",
|
|
822
|
+
timestamp: "2026-03-05T14:30:05.000Z",
|
|
823
|
+
inputTokens: 1000,
|
|
824
|
+
outputTokens: 500,
|
|
825
|
+
cacheReadTokens: 200,
|
|
826
|
+
model: "claude-sonnet-4-6",
|
|
827
|
+
contextUtilization: 0.3,
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
type: "turn_start",
|
|
831
|
+
timestamp: "2026-03-05T14:30:06.000Z",
|
|
832
|
+
turn: 2,
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
type: "turn_end",
|
|
836
|
+
timestamp: "2026-03-05T14:30:10.000Z",
|
|
837
|
+
inputTokens: 800,
|
|
838
|
+
outputTokens: 300,
|
|
839
|
+
cacheReadTokens: 150,
|
|
840
|
+
model: "claude-sonnet-4-6",
|
|
841
|
+
contextUtilization: 0.45,
|
|
842
|
+
},
|
|
843
|
+
]);
|
|
844
|
+
|
|
845
|
+
const data = await gatherInspectData(tempDir, "stdout-tokens", { noTmux: true });
|
|
846
|
+
|
|
847
|
+
// Token usage should be cumulative across turn_end events
|
|
848
|
+
expect(data.tokenUsage).not.toBeNull();
|
|
849
|
+
expect(data.tokenUsage?.inputTokens).toBe(1800);
|
|
850
|
+
expect(data.tokenUsage?.outputTokens).toBe(800);
|
|
851
|
+
expect(data.tokenUsage?.cacheReadTokens).toBe(350);
|
|
852
|
+
expect(data.tokenUsage?.modelUsed).toBe("claude-sonnet-4-6");
|
|
853
|
+
expect(data.tokenUsage?.cacheCreationTokens).toBe(0);
|
|
854
|
+
expect(data.tokenUsage?.estimatedCostUsd).toBeNull();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("populates headlessTurnInfo with turn number, context utilization, and isMidTool", async () => {
|
|
858
|
+
const overstoryDir = await setupHeadlessSession("stdout-turn-info");
|
|
859
|
+
await writeStdoutLog(overstoryDir, "stdout-turn-info", [
|
|
860
|
+
{
|
|
861
|
+
type: "turn_start",
|
|
862
|
+
timestamp: "2026-03-05T14:30:00.000Z",
|
|
863
|
+
turn: 3,
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
type: "tool_start",
|
|
867
|
+
timestamp: "2026-03-05T14:30:01.000Z",
|
|
868
|
+
toolName: "Bash",
|
|
869
|
+
argsSummary: "bun test",
|
|
870
|
+
},
|
|
871
|
+
// No tool_end — still mid-tool
|
|
872
|
+
]);
|
|
873
|
+
|
|
874
|
+
const data = await gatherInspectData(tempDir, "stdout-turn-info", { noTmux: true });
|
|
875
|
+
|
|
876
|
+
expect(data.headlessTurnInfo).not.toBeNull();
|
|
877
|
+
expect(data.headlessTurnInfo?.currentTurn).toBe(3);
|
|
878
|
+
expect(data.headlessTurnInfo?.isMidTool).toBe(true);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("isMidTool is false when last event is not tool_start", async () => {
|
|
882
|
+
const overstoryDir = await setupHeadlessSession("stdout-between-turns");
|
|
883
|
+
await writeStdoutLog(overstoryDir, "stdout-between-turns", [
|
|
884
|
+
{
|
|
885
|
+
type: "turn_start",
|
|
886
|
+
timestamp: "2026-03-05T14:30:00.000Z",
|
|
887
|
+
turn: 2,
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
type: "turn_end",
|
|
891
|
+
timestamp: "2026-03-05T14:30:05.000Z",
|
|
892
|
+
inputTokens: 500,
|
|
893
|
+
outputTokens: 200,
|
|
894
|
+
cacheReadTokens: 0,
|
|
895
|
+
model: "claude-sonnet-4-6",
|
|
896
|
+
contextUtilization: 0.2,
|
|
897
|
+
},
|
|
898
|
+
]);
|
|
899
|
+
|
|
900
|
+
const data = await gatherInspectData(tempDir, "stdout-between-turns", { noTmux: true });
|
|
901
|
+
|
|
902
|
+
expect(data.headlessTurnInfo?.isMidTool).toBe(false);
|
|
903
|
+
expect(data.headlessTurnInfo?.contextUtilization).toBeCloseTo(0.2);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("does not overwrite tokenUsage from metrics.db with stdout.log data", async () => {
|
|
907
|
+
const overstoryDir = await setupHeadlessSession("stdout-no-override");
|
|
908
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
909
|
+
|
|
910
|
+
// Metrics DB has authoritative data
|
|
911
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
912
|
+
metricsStore.recordSession(
|
|
913
|
+
makeMetrics({
|
|
914
|
+
agentName: "stdout-no-override",
|
|
915
|
+
inputTokens: 9999,
|
|
916
|
+
outputTokens: 8888,
|
|
917
|
+
modelUsed: "claude-opus-4-6",
|
|
918
|
+
}),
|
|
919
|
+
);
|
|
920
|
+
metricsStore.close();
|
|
921
|
+
|
|
922
|
+
// stdout.log also has token data
|
|
923
|
+
await writeStdoutLog(overstoryDir, "stdout-no-override", [
|
|
924
|
+
{
|
|
925
|
+
type: "turn_end",
|
|
926
|
+
timestamp: "2026-03-05T14:30:05.000Z",
|
|
927
|
+
inputTokens: 100,
|
|
928
|
+
outputTokens: 50,
|
|
929
|
+
cacheReadTokens: 0,
|
|
930
|
+
model: "claude-sonnet-4-6",
|
|
931
|
+
contextUtilization: 0.1,
|
|
932
|
+
},
|
|
933
|
+
]);
|
|
934
|
+
|
|
935
|
+
const data = await gatherInspectData(tempDir, "stdout-no-override", { noTmux: true });
|
|
936
|
+
|
|
937
|
+
// metrics.db data wins
|
|
938
|
+
expect(data.tokenUsage?.inputTokens).toBe(9999);
|
|
939
|
+
expect(data.tokenUsage?.modelUsed).toBe("claude-opus-4-6");
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("gracefully handles missing stdout.log", async () => {
|
|
943
|
+
const overstoryDir = await setupHeadlessSession("stdout-missing");
|
|
944
|
+
// Create log dir but no stdout.log inside it
|
|
945
|
+
await mkdir(join(overstoryDir, "logs", "stdout-missing", "2026-03-05T14-30-00-000Z"), {
|
|
946
|
+
recursive: true,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const data = await gatherInspectData(tempDir, "stdout-missing", { noTmux: true });
|
|
950
|
+
|
|
951
|
+
expect(data.recentToolCalls).toEqual([]);
|
|
952
|
+
expect(data.tokenUsage).toBeNull();
|
|
953
|
+
expect(data.headlessTurnInfo).toBeNull();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("gracefully handles no log dir at all", async () => {
|
|
957
|
+
await setupHeadlessSession("stdout-no-log-dir");
|
|
958
|
+
// Don't create any log dir
|
|
959
|
+
|
|
960
|
+
const data = await gatherInspectData(tempDir, "stdout-no-log-dir", { noTmux: true });
|
|
961
|
+
|
|
962
|
+
expect(data.recentToolCalls).toEqual([]);
|
|
963
|
+
expect(data.headlessTurnInfo).toBeNull();
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("respects limit when populating recentToolCalls from stdout.log", async () => {
|
|
967
|
+
const overstoryDir = await setupHeadlessSession("stdout-limit");
|
|
968
|
+
const events: Record<string, unknown>[] = [];
|
|
969
|
+
for (let i = 0; i < 10; i++) {
|
|
970
|
+
events.push({
|
|
971
|
+
type: "tool_start",
|
|
972
|
+
timestamp: `2026-03-05T14:30:0${i}.000Z`,
|
|
973
|
+
toolName: "Read",
|
|
974
|
+
argsSummary: `src/file${i}.ts`,
|
|
975
|
+
});
|
|
976
|
+
events.push({
|
|
977
|
+
type: "tool_end",
|
|
978
|
+
timestamp: `2026-03-05T14:30:0${i}.050Z`,
|
|
979
|
+
toolName: "Read",
|
|
980
|
+
success: true,
|
|
981
|
+
durationMs: 50,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
await writeStdoutLog(overstoryDir, "stdout-limit", events);
|
|
985
|
+
|
|
986
|
+
const data = await gatherInspectData(tempDir, "stdout-limit", {
|
|
987
|
+
noTmux: true,
|
|
988
|
+
limit: 3,
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
expect(data.recentToolCalls.length).toBe(3);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("printInspectData shows Turn Progress section when headlessTurnInfo is set", () => {
|
|
995
|
+
const data = {
|
|
996
|
+
session: {
|
|
997
|
+
id: "sess-tp",
|
|
998
|
+
agentName: "headless-turn-progress",
|
|
999
|
+
capability: "builder",
|
|
1000
|
+
worktreePath: "/tmp/wt",
|
|
1001
|
+
branchName: "overstory/headless/tp",
|
|
1002
|
+
taskId: "overstory-tp",
|
|
1003
|
+
tmuxSession: "",
|
|
1004
|
+
state: "working" as const,
|
|
1005
|
+
pid: 12345,
|
|
1006
|
+
parentAgent: null,
|
|
1007
|
+
depth: 0,
|
|
1008
|
+
runId: null,
|
|
1009
|
+
startedAt: new Date().toISOString(),
|
|
1010
|
+
lastActivity: new Date().toISOString(),
|
|
1011
|
+
escalationLevel: 0,
|
|
1012
|
+
stalledSince: null,
|
|
1013
|
+
transcriptPath: null,
|
|
1014
|
+
},
|
|
1015
|
+
timeSinceLastActivity: 1000,
|
|
1016
|
+
recentToolCalls: [],
|
|
1017
|
+
currentFile: null,
|
|
1018
|
+
toolStats: [],
|
|
1019
|
+
tokenUsage: null,
|
|
1020
|
+
tmuxOutput: null,
|
|
1021
|
+
headlessTurnInfo: {
|
|
1022
|
+
currentTurn: 5,
|
|
1023
|
+
contextUtilization: 0.625,
|
|
1024
|
+
isMidTool: false,
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
printInspectData(data);
|
|
1029
|
+
|
|
1030
|
+
const out = output();
|
|
1031
|
+
expect(out).toContain("Turn Progress");
|
|
1032
|
+
expect(out).toContain("5");
|
|
1033
|
+
expect(out).toContain("62.5%");
|
|
1034
|
+
expect(out).toContain("between turns");
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("printInspectData shows executing tool status when isMidTool is true", () => {
|
|
1038
|
+
const data = {
|
|
1039
|
+
session: {
|
|
1040
|
+
id: "sess-mid",
|
|
1041
|
+
agentName: "headless-mid-tool",
|
|
1042
|
+
capability: "builder",
|
|
1043
|
+
worktreePath: "/tmp/wt",
|
|
1044
|
+
branchName: "overstory/headless/mid",
|
|
1045
|
+
taskId: "overstory-mid",
|
|
1046
|
+
tmuxSession: "",
|
|
1047
|
+
state: "working" as const,
|
|
1048
|
+
pid: 12345,
|
|
1049
|
+
parentAgent: null,
|
|
1050
|
+
depth: 0,
|
|
1051
|
+
runId: null,
|
|
1052
|
+
startedAt: new Date().toISOString(),
|
|
1053
|
+
lastActivity: new Date().toISOString(),
|
|
1054
|
+
escalationLevel: 0,
|
|
1055
|
+
stalledSince: null,
|
|
1056
|
+
transcriptPath: null,
|
|
1057
|
+
},
|
|
1058
|
+
timeSinceLastActivity: 500,
|
|
1059
|
+
recentToolCalls: [],
|
|
1060
|
+
currentFile: null,
|
|
1061
|
+
toolStats: [],
|
|
1062
|
+
tokenUsage: null,
|
|
1063
|
+
tmuxOutput: null,
|
|
1064
|
+
headlessTurnInfo: {
|
|
1065
|
+
currentTurn: 2,
|
|
1066
|
+
contextUtilization: null,
|
|
1067
|
+
isMidTool: true,
|
|
1068
|
+
},
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
printInspectData(data);
|
|
1072
|
+
|
|
1073
|
+
const out = output();
|
|
1074
|
+
expect(out).toContain("Turn Progress");
|
|
1075
|
+
expect(out).toContain("executing tool");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("uses latest log dir when multiple exist", async () => {
|
|
1079
|
+
const overstoryDir = await setupHeadlessSession("stdout-multi-dir");
|
|
1080
|
+
const agentLogsDir = join(overstoryDir, "logs", "stdout-multi-dir");
|
|
1081
|
+
|
|
1082
|
+
// Create two log dirs — the later one has the important data
|
|
1083
|
+
const oldDir = join(agentLogsDir, "2026-03-05T10-00-00-000Z");
|
|
1084
|
+
const newDir = join(agentLogsDir, "2026-03-05T14-30-00-000Z");
|
|
1085
|
+
await mkdir(oldDir, { recursive: true });
|
|
1086
|
+
await mkdir(newDir, { recursive: true });
|
|
1087
|
+
|
|
1088
|
+
// Old dir: no useful data
|
|
1089
|
+
await Bun.write(join(oldDir, "stdout.log"), "");
|
|
1090
|
+
|
|
1091
|
+
// New dir: has turn data
|
|
1092
|
+
const events = [
|
|
1093
|
+
{
|
|
1094
|
+
type: "turn_start",
|
|
1095
|
+
timestamp: "2026-03-05T14:30:00.000Z",
|
|
1096
|
+
turn: 7,
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
type: "turn_end",
|
|
1100
|
+
timestamp: "2026-03-05T14:30:05.000Z",
|
|
1101
|
+
inputTokens: 2000,
|
|
1102
|
+
outputTokens: 700,
|
|
1103
|
+
cacheReadTokens: 300,
|
|
1104
|
+
model: "claude-sonnet-4-6",
|
|
1105
|
+
contextUtilization: 0.55,
|
|
1106
|
+
},
|
|
1107
|
+
];
|
|
1108
|
+
await Bun.write(
|
|
1109
|
+
join(newDir, "stdout.log"),
|
|
1110
|
+
`${events.map((e) => JSON.stringify(e)).join("\n")}\n`,
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
const data = await gatherInspectData(tempDir, "stdout-multi-dir", { noTmux: true });
|
|
1114
|
+
|
|
1115
|
+
expect(data.headlessTurnInfo?.currentTurn).toBe(7);
|
|
1116
|
+
expect(data.tokenUsage?.inputTokens).toBe(2000);
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
|
|
723
1120
|
// === Human-readable output ===
|
|
724
1121
|
|
|
725
1122
|
describe("human-readable output", () => {
|