@os-eco/overstory-cli 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,10 @@
2
2
  // Implements the AgentRuntime contract for the OpenAI `codex` CLI.
3
3
  //
4
4
  // Key differences from Claude/Pi adapters:
5
- // - Headless: `codex exec` exits on completion (no persistent TUI)
5
+ // - Interactive: `codex` (without `exec`) stays alive in tmux for orchestration
6
6
  // - Instruction file: AGENTS.md (not .claude/CLAUDE.md)
7
7
  // - No hooks: Codex uses OS-level sandbox (Seatbelt/Landlock)
8
- // - Events: NDJSON stream to stdout (parsed for token usage)
8
+ // - One-shot calls still use `codex exec` (buildPrintCommand)
9
9
 
10
10
  import { mkdir } from "node:fs/promises";
11
11
  import { dirname, join } from "node:path";
@@ -22,9 +22,9 @@ import type {
22
22
  /**
23
23
  * Codex runtime adapter.
24
24
  *
25
- * Implements AgentRuntime for the OpenAI `codex` CLI. Codex agents run in
26
- * headless mode (`codex exec`) they process a task and exit, rather than
27
- * maintaining a persistent TUI like Claude Code or Pi.
25
+ * Implements AgentRuntime for the OpenAI `codex` CLI. Tmux-spawned Codex
26
+ * agents run in interactive mode (`codex`) so sessions stay alive and can be
27
+ * nudged via tmux.
28
28
  *
29
29
  * Security is enforced via Codex's OS-level sandbox (Seatbelt on macOS,
30
30
  * Landlock on Linux) rather than hook-based guards. The `--full-auto` flag
@@ -40,11 +40,17 @@ export class CodexRuntime implements AgentRuntime {
40
40
  /** Relative path to the instruction file within a worktree. */
41
41
  readonly instructionPath = "AGENTS.md";
42
42
 
43
+ /**
44
+ * Anthropic aliases used by overstory manifests that Codex CLI does not
45
+ * accept as --model values.
46
+ */
47
+ private static readonly MANIFEST_ALIASES = new Set(["sonnet", "opus", "haiku"]);
48
+
43
49
  /**
44
50
  * Build the shell command string to spawn a Codex agent in a tmux pane.
45
51
  *
46
- * Uses `codex exec` (headless mode) with `--full-auto` for workspace-write
47
- * sandbox + automatic approvals, and `--json` for NDJSON event output.
52
+ * Uses interactive `codex` with `--full-auto` for workspace-write sandbox +
53
+ * automatic approvals.
48
54
  *
49
55
  * The prompt directs the agent to read AGENTS.md for its full instructions.
50
56
  * If `appendSystemPrompt` or `appendSystemPromptFile` is provided, the
@@ -56,7 +62,12 @@ export class CodexRuntime implements AgentRuntime {
56
62
  * @returns Shell command string suitable for tmux new-session -c
57
63
  */
58
64
  buildSpawnCommand(opts: SpawnOpts): string {
59
- let cmd = `codex exec --full-auto --json --model ${opts.model}`;
65
+ // When model comes from default manifest aliases (sonnet/opus/haiku),
66
+ // omit --model so Codex uses the user's configured default model.
67
+ let cmd = "codex --full-auto";
68
+ if (!CodexRuntime.MANIFEST_ALIASES.has(opts.model)) {
69
+ cmd += ` --model ${opts.model}`;
70
+ }
60
71
 
61
72
  if (opts.appendSystemPromptFile) {
62
73
  // Read role definition from file at shell expansion time — avoids tmux
@@ -128,11 +139,7 @@ export class CodexRuntime implements AgentRuntime {
128
139
  }
129
140
 
130
141
  /**
131
- * Codex exec is headless always ready.
132
- *
133
- * Unlike Claude Code and Pi which maintain persistent TUI sessions,
134
- * `codex exec` starts processing immediately and exits on completion.
135
- * No TUI readiness detection is needed.
142
+ * Codex interactive startup is treated as ready once a pane exists.
136
143
  *
137
144
  * @param _paneContent - Captured tmux pane content (unused)
138
145
  * @returns Always `{ phase: "ready" }`
@@ -144,9 +151,7 @@ export class CodexRuntime implements AgentRuntime {
144
151
  /**
145
152
  * Codex does not require beacon verification/resend.
146
153
  *
147
- * The beacon verification loop exists because Claude Code's TUI sometimes
148
- * swallows the initial Enter during late initialization. Codex exec is
149
- * headless — it processes the prompt immediately with no TUI startup delay.
154
+ * Codex accepts startup input reliably once spawned.
150
155
  */
151
156
  requiresBeaconVerification(): boolean {
152
157
  return false;
@@ -225,4 +230,9 @@ export class CodexRuntime implements AgentRuntime {
225
230
  buildEnv(model: ResolvedModel): Record<string, string> {
226
231
  return model.env ?? {};
227
232
  }
233
+
234
+ /** Codex does not produce transcript files. */
235
+ getTranscriptDir(_projectRoot: string): string | null {
236
+ return null;
237
+ }
228
238
  }
@@ -223,4 +223,9 @@ export class CopilotRuntime implements AgentRuntime {
223
223
  buildEnv(model: ResolvedModel): Record<string, string> {
224
224
  return model.env ?? {};
225
225
  }
226
+
227
+ /** Copilot does not produce transcript files. */
228
+ getTranscriptDir(_projectRoot: string): string | null {
229
+ return null;
230
+ }
226
231
  }
@@ -232,4 +232,9 @@ export class GeminiRuntime implements AgentRuntime {
232
232
  buildEnv(model: ResolvedModel): Record<string, string> {
233
233
  return model.env ?? {};
234
234
  }
235
+
236
+ /** Gemini does not produce transcript files. */
237
+ getTranscriptDir(_projectRoot: string): string | null {
238
+ return null;
239
+ }
235
240
  }
@@ -245,4 +245,9 @@ export class PiRuntime implements AgentRuntime {
245
245
  buildEnv(model: ResolvedModel): Record<string, string> {
246
246
  return model.env ?? {};
247
247
  }
248
+
249
+ /** Pi uses RPC — no transcript files. */
250
+ getTranscriptDir(_projectRoot: string): string | null {
251
+ return null;
252
+ }
248
253
  }
@@ -117,4 +117,40 @@ describe("getRuntime", () => {
117
117
  expect(runtime).toBeInstanceOf(GeminiRuntime);
118
118
  expect(runtime.id).toBe("gemini");
119
119
  });
120
+
121
+ describe("capability routing", () => {
122
+ it("resolves capability-specific runtime from config", () => {
123
+ const config = {
124
+ runtime: { default: "claude", capabilities: { builder: "gemini" } },
125
+ } as unknown as OverstoryConfig;
126
+ const runtime = getRuntime(undefined, config, "builder");
127
+ expect(runtime).toBeInstanceOf(GeminiRuntime);
128
+ expect(runtime.id).toBe("gemini");
129
+ });
130
+
131
+ it("falls back to default when capability has no override", () => {
132
+ const config = {
133
+ runtime: { default: "codex", capabilities: { builder: "gemini" } },
134
+ } as unknown as OverstoryConfig;
135
+ const runtime = getRuntime(undefined, config, "scout");
136
+ expect(runtime).toBeInstanceOf(CodexRuntime);
137
+ expect(runtime.id).toBe("codex");
138
+ });
139
+
140
+ it("explicit name overrides capability routing", () => {
141
+ const config = {
142
+ runtime: { default: "claude", capabilities: { builder: "gemini" } },
143
+ } as unknown as OverstoryConfig;
144
+ const runtime = getRuntime("copilot", config, "builder");
145
+ expect(runtime).toBeInstanceOf(CopilotRuntime);
146
+ expect(runtime.id).toBe("copilot");
147
+ });
148
+
149
+ it("works when capabilities is undefined", () => {
150
+ const config = { runtime: { default: "claude" } } as OverstoryConfig;
151
+ const runtime = getRuntime(undefined, config, "coordinator");
152
+ expect(runtime).toBeInstanceOf(ClaudeRuntime);
153
+ expect(runtime.id).toBe("claude");
154
+ });
155
+ });
120
156
  });
@@ -20,24 +20,54 @@ const runtimes = new Map<string, () => AgentRuntime>([
20
20
  ["sapling", () => new SaplingRuntime()],
21
21
  ]);
22
22
 
23
+ /**
24
+ * Return all registered runtime adapter instances.
25
+ *
26
+ * Used by callers that need to enumerate all runtimes (e.g. to build a
27
+ * dynamic list of known instruction file paths from each runtime's
28
+ * `instructionPath` property).
29
+ *
30
+ * @returns Array of one fresh instance per registered runtime.
31
+ */
32
+ export function getAllRuntimes(): AgentRuntime[] {
33
+ return [
34
+ new ClaudeRuntime(),
35
+ new CodexRuntime(),
36
+ new PiRuntime(),
37
+ new CopilotRuntime(),
38
+ new GeminiRuntime(),
39
+ new SaplingRuntime(),
40
+ ];
41
+ }
42
+
23
43
  /**
24
44
  * Resolve a runtime adapter by name.
25
45
  *
26
46
  * Lookup order:
27
47
  * 1. Explicit `name` argument (if provided)
28
- * 2. `config.runtime.default` (if config is provided)
29
- * 3. `"claude"` (hardcoded fallback)
48
+ * 2. `config.runtime.capabilities[capability]` (if capability provided)
49
+ * 3. `config.runtime.default` (if config is provided)
50
+ * 4. `"claude"` (hardcoded fallback)
30
51
  *
31
52
  * Special cases:
32
53
  * - Pi runtime receives `config.runtime.pi` for model alias expansion.
33
54
  *
34
55
  * @param name - Runtime name to resolve (e.g. "claude"). Omit to use config default.
35
56
  * @param config - Overstory config for reading the default runtime.
57
+ * @param capability - Agent capability (e.g. "coordinator", "builder") for per-capability routing.
36
58
  * @throws {Error} If the resolved runtime name is not registered.
37
59
  * @returns A fresh AgentRuntime instance.
38
60
  */
39
- export function getRuntime(name?: string, config?: OverstoryConfig): AgentRuntime {
40
- const runtimeName = name ?? config?.runtime?.default ?? "claude";
61
+ export function getRuntime(
62
+ name?: string,
63
+ config?: OverstoryConfig,
64
+ capability?: string,
65
+ ): AgentRuntime {
66
+ const capabilityRuntime =
67
+ capability && config?.runtime?.capabilities
68
+ ? config.runtime.capabilities[capability]
69
+ : undefined;
70
+ const runtimeName = name ?? capabilityRuntime ?? config?.runtime?.default ?? "claude";
41
71
 
42
72
  // Pi runtime needs config for model alias expansion.
43
73
  if (runtimeName === "pi") {
@@ -695,4 +695,9 @@ export class SaplingRuntime implements AgentRuntime {
695
695
 
696
696
  return env;
697
697
  }
698
+
699
+ /** Sapling uses NDJSON event streaming — no transcript files. */
700
+ getTranscriptDir(_projectRoot: string): string | null {
701
+ return null;
702
+ }
698
703
  }
@@ -184,6 +184,15 @@ export interface AgentRuntime {
184
184
  */
185
185
  parseTranscript(path: string): Promise<TranscriptSummary | null>;
186
186
 
187
+ /**
188
+ * Return the directory containing session transcript files for this runtime,
189
+ * or null if transcript discovery is not supported.
190
+ *
191
+ * @param projectRoot - Absolute path to the project root
192
+ * @returns Absolute path to the transcript directory, or null
193
+ */
194
+ getTranscriptDir(projectRoot: string): string | null;
195
+
187
196
  /**
188
197
  * Build runtime-specific environment variables for model/provider routing.
189
198
  * Claude Code uses ANTHROPIC_API_KEY; Codex uses OPENAI_API_KEY; Pi passes
package/src/types.ts CHANGED
@@ -97,6 +97,11 @@ export interface OverstoryConfig {
97
97
  runtime?: {
98
98
  /** Default runtime adapter name (default: "claude"). */
99
99
  default: string;
100
+ /**
101
+ * Per-capability runtime overrides. Maps capability names (e.g. "coordinator", "builder")
102
+ * to runtime adapter names. Lookup chain: explicit --runtime flag > capabilities[cap] > default > "claude".
103
+ */
104
+ capabilities?: Partial<Record<string, string>>;
100
105
  /**
101
106
  * Runtime adapter for headless one-shot AI calls (--print mode).
102
107
  * Used by merge/resolver.ts and watchdog/triage.ts.
@@ -343,6 +348,8 @@ export interface OverlayConfig {
343
348
  trackerName?: string; // "seeds" or "beads"
344
349
  /** Quality gate commands for the agent overlay. Falls back to defaults if undefined. */
345
350
  qualityGates?: QualityGate[];
351
+ /** Relative path to the instruction file within the worktree (runtime-specific). Defaults to .claude/CLAUDE.md. */
352
+ instructionPath?: string;
346
353
  }
347
354
 
348
355
  // === Merge Queue ===