@os-eco/overstory-cli 0.9.1 → 0.9.3

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.
Files changed (101) hide show
  1. package/README.md +21 -6
  2. package/agents/coordinator.md +34 -10
  3. package/agents/lead.md +11 -1
  4. package/package.json +1 -1
  5. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  6. package/src/agents/copilot-hooks-deployer.ts +93 -0
  7. package/src/agents/hooks-deployer.test.ts +9 -1
  8. package/src/agents/hooks-deployer.ts +2 -1
  9. package/src/agents/overlay.test.ts +26 -0
  10. package/src/agents/overlay.ts +18 -4
  11. package/src/beads/client.ts +31 -3
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +228 -125
  19. package/src/commands/dashboard.ts +50 -10
  20. package/src/commands/doctor.ts +3 -1
  21. package/src/commands/ecosystem.test.ts +126 -1
  22. package/src/commands/ecosystem.ts +7 -53
  23. package/src/commands/feed.test.ts +117 -2
  24. package/src/commands/feed.ts +46 -30
  25. package/src/commands/group.test.ts +274 -155
  26. package/src/commands/group.ts +11 -5
  27. package/src/commands/init.ts +50 -0
  28. package/src/commands/inspect.ts +8 -4
  29. package/src/commands/log.test.ts +35 -0
  30. package/src/commands/log.ts +10 -6
  31. package/src/commands/logs.test.ts +423 -1
  32. package/src/commands/logs.ts +99 -104
  33. package/src/commands/monitor.ts +8 -2
  34. package/src/commands/orchestrator.ts +42 -0
  35. package/src/commands/prime.test.ts +177 -2
  36. package/src/commands/prime.ts +4 -2
  37. package/src/commands/sling.ts +8 -3
  38. package/src/commands/upgrade.test.ts +2 -0
  39. package/src/commands/upgrade.ts +1 -17
  40. package/src/commands/watch.test.ts +67 -1
  41. package/src/commands/watch.ts +4 -79
  42. package/src/config.test.ts +250 -0
  43. package/src/config.ts +43 -0
  44. package/src/doctor/agents.test.ts +72 -5
  45. package/src/doctor/agents.ts +10 -10
  46. package/src/doctor/consistency.test.ts +35 -0
  47. package/src/doctor/consistency.ts +7 -3
  48. package/src/doctor/dependencies.test.ts +58 -1
  49. package/src/doctor/dependencies.ts +4 -2
  50. package/src/doctor/providers.test.ts +41 -5
  51. package/src/doctor/types.ts +2 -1
  52. package/src/doctor/version.test.ts +106 -2
  53. package/src/doctor/version.ts +4 -2
  54. package/src/doctor/watchdog.test.ts +167 -0
  55. package/src/doctor/watchdog.ts +158 -0
  56. package/src/e2e/init-sling-lifecycle.test.ts +2 -1
  57. package/src/errors.test.ts +350 -0
  58. package/src/events/tailer.test.ts +25 -0
  59. package/src/events/tailer.ts +8 -1
  60. package/src/index.ts +4 -1
  61. package/src/mail/store.test.ts +110 -0
  62. package/src/runtimes/aider.test.ts +124 -0
  63. package/src/runtimes/aider.ts +147 -0
  64. package/src/runtimes/amp.test.ts +164 -0
  65. package/src/runtimes/amp.ts +154 -0
  66. package/src/runtimes/claude.test.ts +4 -2
  67. package/src/runtimes/codex.test.ts +38 -1
  68. package/src/runtimes/codex.ts +22 -3
  69. package/src/runtimes/copilot.test.ts +213 -13
  70. package/src/runtimes/copilot.ts +93 -11
  71. package/src/runtimes/goose.test.ts +133 -0
  72. package/src/runtimes/goose.ts +157 -0
  73. package/src/runtimes/pi-guards.ts +2 -1
  74. package/src/runtimes/pi.test.ts +33 -9
  75. package/src/runtimes/pi.ts +10 -10
  76. package/src/runtimes/registry.test.ts +1 -1
  77. package/src/runtimes/registry.ts +13 -4
  78. package/src/runtimes/sapling.ts +2 -1
  79. package/src/runtimes/types.ts +9 -2
  80. package/src/tracker/factory.test.ts +10 -0
  81. package/src/tracker/factory.ts +3 -2
  82. package/src/types.ts +4 -0
  83. package/src/utils/bin.test.ts +10 -0
  84. package/src/utils/bin.ts +37 -0
  85. package/src/utils/fs.test.ts +119 -0
  86. package/src/utils/fs.ts +62 -0
  87. package/src/utils/pid.test.ts +68 -0
  88. package/src/utils/pid.ts +45 -0
  89. package/src/utils/time.test.ts +43 -0
  90. package/src/utils/time.ts +37 -0
  91. package/src/utils/version.test.ts +33 -0
  92. package/src/utils/version.ts +70 -0
  93. package/src/watchdog/daemon.test.ts +255 -1
  94. package/src/watchdog/daemon.ts +46 -9
  95. package/src/watchdog/health.test.ts +15 -1
  96. package/src/watchdog/health.ts +1 -1
  97. package/src/watchdog/triage.test.ts +49 -9
  98. package/src/watchdog/triage.ts +21 -5
  99. package/src/worktree/tmux.test.ts +166 -49
  100. package/src/worktree/tmux.ts +36 -37
  101. package/templates/copilot-hooks.json.tmpl +13 -0
@@ -0,0 +1,147 @@
1
+ // Aider runtime adapter for overstory's AgentRuntime interface.
2
+ // Implements the AgentRuntime contract for the `aider` CLI (Paul Gauthier's AI pair programming tool).
3
+ //
4
+ // Key differences from Claude/Pi adapters:
5
+ // - Interactive: `aider` stays alive in tmux as a REPL-like session
6
+ // - Instruction file: .aider.conf.yml or CONVENTIONS.md (we use CONVENTIONS.md for overlay)
7
+ // - No hooks: Aider has no PreToolUse/PostToolUse hook system
8
+ // - One-shot calls use `aider --message <prompt> --yes-always`
9
+ // - Model is passed via `--model <model>` (supports litellm model strings)
10
+
11
+ import { mkdir } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import type { ResolvedModel } from "../types.ts";
14
+ import type {
15
+ AgentRuntime,
16
+ HooksDef,
17
+ OverlayContent,
18
+ ReadyState,
19
+ SpawnOpts,
20
+ TranscriptSummary,
21
+ } from "./types.ts";
22
+
23
+ /**
24
+ * Aider runtime adapter.
25
+ *
26
+ * Implements AgentRuntime for Paul Gauthier's `aider` CLI. Tmux-spawned
27
+ * Aider agents run in interactive mode with `--yes-always` for automatic
28
+ * confirmation of file edits.
29
+ *
30
+ * Security relies on Aider's built-in file-scope limiting — it only edits
31
+ * files explicitly added to its context. No OS-level sandbox or hook guards.
32
+ */
33
+ export class AiderRuntime implements AgentRuntime {
34
+ readonly id = "aider";
35
+
36
+ /** Experimental — community-contributed adapter, not yet battle-tested in production. */
37
+ readonly stability = "experimental" as const;
38
+
39
+ /**
40
+ * Aider reads CONVENTIONS.md from the repo root for project-level instructions.
41
+ * We write the overlay here so Aider picks it up natively.
42
+ */
43
+ readonly instructionPath = "CONVENTIONS.md";
44
+
45
+ /**
46
+ * Build the shell command string to spawn an Aider agent in a tmux pane.
47
+ *
48
+ * Uses `--yes-always` for automatic approval of file edits and
49
+ * `--no-auto-commits` so overstory controls git operations.
50
+ *
51
+ * @param opts - Spawn options
52
+ * @returns Shell command string suitable for tmux new-session
53
+ */
54
+ buildSpawnCommand(opts: SpawnOpts): string {
55
+ let cmd = "aider --yes-always --no-auto-commits";
56
+
57
+ // Aider accepts litellm model strings: provider/model-name
58
+ cmd += ` --model ${opts.model}`;
59
+
60
+ if (opts.appendSystemPromptFile) {
61
+ const escaped = opts.appendSystemPromptFile.replace(/'/g, "'\\''");
62
+ cmd += ` --read '${escaped}'`;
63
+ } else if (opts.appendSystemPrompt) {
64
+ const escaped = opts.appendSystemPrompt.replace(/'/g, "'\\''");
65
+ cmd += ` --message '${escaped} Read CONVENTIONS.md for your task assignment and begin.'`;
66
+ } else {
67
+ cmd += ` --message 'Read CONVENTIONS.md for your task assignment and begin immediately.'`;
68
+ }
69
+
70
+ return cmd;
71
+ }
72
+
73
+ /**
74
+ * Build argv for a headless one-shot Aider invocation.
75
+ *
76
+ * Uses `--message` for the prompt with `--yes-always` for non-interactive mode.
77
+ *
78
+ * @param prompt - The prompt to pass
79
+ * @param model - Optional model override
80
+ * @returns Argv array for Bun.spawn
81
+ */
82
+ buildPrintCommand(prompt: string, model?: string): string[] {
83
+ const cmd = ["aider", "--message", prompt, "--yes-always", "--no-auto-commits"];
84
+ if (model !== undefined) {
85
+ cmd.push("--model", model);
86
+ }
87
+ return cmd;
88
+ }
89
+
90
+ /**
91
+ * Deploy per-agent instructions to a worktree.
92
+ *
93
+ * Writes the overlay to CONVENTIONS.md (Aider's native conventions file).
94
+ * No hooks or guard extensions — Aider has no hook system.
95
+ *
96
+ * @param worktreePath - Absolute path to the agent's git worktree
97
+ * @param overlay - Overlay content, or undefined for no-op
98
+ * @param _hooks - Unused — Aider has no hook system
99
+ */
100
+ async deployConfig(
101
+ worktreePath: string,
102
+ overlay: OverlayContent | undefined,
103
+ _hooks: HooksDef,
104
+ ): Promise<void> {
105
+ if (!overlay) return;
106
+ await mkdir(worktreePath, { recursive: true });
107
+ await Bun.write(join(worktreePath, this.instructionPath), overlay.content);
108
+ }
109
+
110
+ /**
111
+ * Detect Aider TUI readiness from tmux pane content.
112
+ *
113
+ * Aider shows a prompt like "aider>" or "> " when ready for input.
114
+ *
115
+ * @param paneContent - Captured tmux pane content
116
+ * @returns Readiness phase
117
+ */
118
+ detectReady(paneContent: string): ReadyState {
119
+ // Aider shows its prompt when ready: "aider> " or "> "
120
+ if (/(?:aider)?>\s*$/.test(paneContent)) {
121
+ return { phase: "ready" };
122
+ }
123
+ return { phase: "loading" };
124
+ }
125
+
126
+ /** Aider does not require beacon verification — accepts input reliably. */
127
+ requiresBeaconVerification(): boolean {
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * Aider does not produce machine-readable transcripts.
133
+ * Returns null — cost tracking relies on provider billing.
134
+ */
135
+ async parseTranscript(_path: string): Promise<TranscriptSummary | null> {
136
+ return null;
137
+ }
138
+
139
+ buildEnv(model: ResolvedModel): Record<string, string> {
140
+ return model.env ?? {};
141
+ }
142
+
143
+ /** Aider logs to .aider.chat.history.md but not in a parseable transcript format. */
144
+ getTranscriptDir(_projectRoot: string): string | null {
145
+ return null;
146
+ }
147
+ }
@@ -0,0 +1,164 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AmpRuntime } from "./amp.ts";
6
+
7
+ describe("AmpRuntime", () => {
8
+ const runtime = new AmpRuntime();
9
+ let testDir: string;
10
+
11
+ beforeEach(async () => {
12
+ testDir = await mkdtemp(join(tmpdir(), "overstory-amp-test-"));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(testDir, { recursive: true });
17
+ });
18
+
19
+ it("has correct id and instruction path", () => {
20
+ expect(runtime.id).toBe("amp");
21
+ expect(runtime.instructionPath).toBe(".amp/AGENT.md");
22
+ });
23
+
24
+ it("buildSpawnCommand includes --model and --yes", () => {
25
+ const cmd = runtime.buildSpawnCommand({
26
+ model: "anthropic/claude-sonnet-4-6",
27
+ permissionMode: "bypass",
28
+ cwd: "/tmp/test",
29
+ env: {},
30
+ });
31
+ expect(cmd).toContain("amp --model anthropic/claude-sonnet-4-6 --yes");
32
+ });
33
+
34
+ it("buildSpawnCommand includes append system prompt as --prompt", () => {
35
+ const cmd = runtime.buildSpawnCommand({
36
+ model: "sonnet",
37
+ permissionMode: "bypass",
38
+ appendSystemPrompt: "You are a reviewer.",
39
+ cwd: "/tmp/test",
40
+ env: {},
41
+ });
42
+ expect(cmd).toContain("--prompt");
43
+ expect(cmd).toContain("You are a reviewer.");
44
+ });
45
+
46
+ it("buildSpawnCommand uses cat for appendSystemPromptFile", () => {
47
+ const cmd = runtime.buildSpawnCommand({
48
+ model: "sonnet",
49
+ permissionMode: "bypass",
50
+ appendSystemPromptFile: "/tmp/role.md",
51
+ cwd: "/tmp/test",
52
+ env: {},
53
+ });
54
+ expect(cmd).toContain("--prompt");
55
+ expect(cmd).toContain("cat '/tmp/role.md'");
56
+ });
57
+
58
+ it("buildSpawnCommand includes default prompt when no append", () => {
59
+ const cmd = runtime.buildSpawnCommand({
60
+ model: "sonnet",
61
+ permissionMode: "bypass",
62
+ cwd: "/tmp/test",
63
+ env: {},
64
+ });
65
+ expect(cmd).toContain("--prompt");
66
+ expect(cmd).toContain("Read .amp/AGENT.md");
67
+ });
68
+
69
+ it("buildPrintCommand returns correct argv", () => {
70
+ const argv = runtime.buildPrintCommand("review the diff");
71
+ expect(argv[0]).toBe("amp");
72
+ expect(argv).toContain("--prompt");
73
+ expect(argv).toContain("review the diff");
74
+ expect(argv).toContain("--no-input");
75
+ expect(argv).toContain("--yes");
76
+ });
77
+
78
+ it("buildPrintCommand includes model when provided", () => {
79
+ const argv = runtime.buildPrintCommand("review the diff", "gpt-4o");
80
+ expect(argv).toContain("--model");
81
+ expect(argv).toContain("gpt-4o");
82
+ });
83
+
84
+ it("deployConfig writes .amp/AGENT.md", async () => {
85
+ await runtime.deployConfig(
86
+ testDir,
87
+ { content: "# Reviewer instructions" },
88
+ {
89
+ agentName: "reviewer-1",
90
+ capability: "reviewer",
91
+ worktreePath: testDir,
92
+ },
93
+ );
94
+ const content = await readFile(join(testDir, ".amp", "AGENT.md"), "utf-8");
95
+ expect(content).toBe("# Reviewer instructions");
96
+ });
97
+
98
+ it("deployConfig creates parent .amp directory", async () => {
99
+ await runtime.deployConfig(
100
+ testDir,
101
+ { content: "# Test" },
102
+ {
103
+ agentName: "test",
104
+ capability: "scout",
105
+ worktreePath: testDir,
106
+ },
107
+ );
108
+ const file = Bun.file(join(testDir, ".amp", "AGENT.md"));
109
+ expect(await file.exists()).toBe(true);
110
+ });
111
+
112
+ it("deployConfig is no-op when overlay is undefined", async () => {
113
+ await runtime.deployConfig(testDir, undefined, {
114
+ agentName: "test",
115
+ capability: "scout",
116
+ worktreePath: testDir,
117
+ });
118
+ const file = Bun.file(join(testDir, ".amp", "AGENT.md"));
119
+ expect(await file.exists()).toBe(false);
120
+ });
121
+
122
+ it("detectReady requires both prompt AND branding (AND logic)", () => {
123
+ // Both prompt and branding → ready
124
+ expect(runtime.detectReady("some output\namp> ").phase).toBe("ready");
125
+ expect(runtime.detectReady("amp v1.0.0\n> ").phase).toBe("ready");
126
+ expect(runtime.detectReady("AMP CLI\n> ").phase).toBe("ready");
127
+
128
+ // Prompt only (no branding) → loading
129
+ expect(runtime.detectReady("some output\n> ").phase).toBe("loading");
130
+
131
+ // Branding only (no prompt) → loading
132
+ expect(runtime.detectReady("amp v1.0.0").phase).toBe("loading");
133
+ expect(runtime.detectReady("amp v1.2.3 starting...").phase).toBe("loading");
134
+
135
+ // Neither → loading
136
+ expect(runtime.detectReady("Initializing...").phase).toBe("loading");
137
+
138
+ // Substring false-positive prevention: "amp" inside other words must NOT match branding
139
+ expect(runtime.detectReady("this is an example output\n> ").phase).toBe("loading");
140
+ expect(runtime.detectReady("stamped result\n> ").phase).toBe("loading");
141
+ });
142
+
143
+ it("does not require beacon verification", () => {
144
+ expect(runtime.requiresBeaconVerification()).toBe(false);
145
+ });
146
+
147
+ it("parseTranscript returns null", async () => {
148
+ expect(await runtime.parseTranscript("/nonexistent")).toBeNull();
149
+ });
150
+
151
+ it("buildEnv returns model env vars", () => {
152
+ expect(runtime.buildEnv({ model: "sonnet", env: { SRC_ACCESS_TOKEN: "token" } })).toEqual({
153
+ SRC_ACCESS_TOKEN: "token",
154
+ });
155
+ });
156
+
157
+ it("buildEnv returns empty object when no env", () => {
158
+ expect(runtime.buildEnv({ model: "sonnet" })).toEqual({});
159
+ });
160
+
161
+ it("getTranscriptDir returns null", () => {
162
+ expect(runtime.getTranscriptDir("/tmp/project")).toBeNull();
163
+ });
164
+ });
@@ -0,0 +1,154 @@
1
+ // Amp runtime adapter for overstory's AgentRuntime interface.
2
+ // Implements the AgentRuntime contract for Sourcegraph's `amp` CLI (AI coding agent).
3
+ //
4
+ // Key differences from Claude/Pi adapters:
5
+ // - Interactive: `amp` runs as an interactive chat session in tmux
6
+ // - Instruction file: .amp/AGENT.md (Amp's native instruction file)
7
+ // - No hooks: Amp manages permissions via its own approval system
8
+ // - One-shot calls use `amp --prompt <prompt> --no-input`
9
+ // - Model is passed via `--model <model>`
10
+
11
+ import { mkdir } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import type { ResolvedModel } from "../types.ts";
14
+ import type {
15
+ AgentRuntime,
16
+ HooksDef,
17
+ OverlayContent,
18
+ ReadyState,
19
+ SpawnOpts,
20
+ TranscriptSummary,
21
+ } from "./types.ts";
22
+
23
+ /**
24
+ * Amp runtime adapter.
25
+ *
26
+ * Implements AgentRuntime for Sourcegraph's `amp` CLI. Amp agents run
27
+ * as interactive chat sessions with configurable models and tools.
28
+ *
29
+ * Security is managed by Amp's built-in approval system for file
30
+ * modifications and command execution.
31
+ */
32
+ export class AmpRuntime implements AgentRuntime {
33
+ readonly id = "amp";
34
+
35
+ /** Experimental — community-contributed adapter, not yet battle-tested in production. */
36
+ readonly stability = "experimental" as const;
37
+
38
+ /**
39
+ * Amp reads .amp/AGENT.md from the repo for project-level instructions.
40
+ */
41
+ readonly instructionPath = ".amp/AGENT.md";
42
+
43
+ /**
44
+ * Build the shell command string to spawn an Amp agent in a tmux pane.
45
+ *
46
+ * Uses `amp` in interactive mode with `--model` for model selection
47
+ * and `--yes` for automatic approval.
48
+ *
49
+ * @param opts - Spawn options
50
+ * @returns Shell command string suitable for tmux new-session
51
+ */
52
+ buildSpawnCommand(opts: SpawnOpts): string {
53
+ let cmd = `amp --model ${opts.model} --yes`;
54
+
55
+ if (opts.appendSystemPromptFile) {
56
+ const escaped = opts.appendSystemPromptFile.replace(/'/g, "'\\''");
57
+ cmd += ` --prompt "$(cat '${escaped}') Read .amp/AGENT.md for your task assignment."`;
58
+ } else if (opts.appendSystemPrompt) {
59
+ const escaped =
60
+ `${opts.appendSystemPrompt}\n\nRead .amp/AGENT.md for your task assignment and begin.`.replace(
61
+ /'/g,
62
+ "'\\''",
63
+ );
64
+ cmd += ` --prompt '${escaped}'`;
65
+ } else {
66
+ cmd += ` --prompt 'Read .amp/AGENT.md for your task assignment and begin immediately.'`;
67
+ }
68
+
69
+ return cmd;
70
+ }
71
+
72
+ /**
73
+ * Build argv for a headless one-shot Amp invocation.
74
+ *
75
+ * Uses `amp --prompt <prompt> --no-input --yes` for non-interactive execution.
76
+ *
77
+ * @param prompt - The prompt to pass
78
+ * @param model - Optional model override
79
+ * @returns Argv array for Bun.spawn
80
+ */
81
+ buildPrintCommand(prompt: string, model?: string): string[] {
82
+ const cmd = ["amp", "--prompt", prompt, "--no-input", "--yes"];
83
+ if (model !== undefined) {
84
+ cmd.push("--model", model);
85
+ }
86
+ return cmd;
87
+ }
88
+
89
+ /**
90
+ * Deploy per-agent instructions to a worktree.
91
+ *
92
+ * Writes the overlay to .amp/AGENT.md (Amp's native instruction file).
93
+ * No hooks — Amp manages approvals internally.
94
+ *
95
+ * @param worktreePath - Absolute path to the agent's git worktree
96
+ * @param overlay - Overlay content, or undefined for no-op
97
+ * @param _hooks - Unused — Amp has no hook system
98
+ */
99
+ async deployConfig(
100
+ worktreePath: string,
101
+ overlay: OverlayContent | undefined,
102
+ _hooks: HooksDef,
103
+ ): Promise<void> {
104
+ if (!overlay) return;
105
+ const agentPath = join(worktreePath, this.instructionPath);
106
+ await mkdir(dirname(agentPath), { recursive: true });
107
+ await Bun.write(agentPath, overlay.content);
108
+ }
109
+
110
+ /**
111
+ * Detect Amp TUI readiness from tmux pane content.
112
+ *
113
+ * Amp shows a prompt indicator when ready for input.
114
+ *
115
+ * @param paneContent - Captured tmux pane content
116
+ * @returns Readiness phase
117
+ */
118
+ detectReady(paneContent: string): ReadyState {
119
+ const lower = paneContent.toLowerCase();
120
+
121
+ // Prompt indicator: ">" or "amp>" at end of a line
122
+ const hasPrompt = /(?:amp)?>\s*$/.test(paneContent);
123
+
124
+ // Branding indicator: "amp" as a standalone word (word boundary prevents
125
+ // matching inside "example", "stamp", "&amp;", etc.)
126
+ const hasBranding = /\bamp\b/.test(lower);
127
+
128
+ // Both required (AND logic) to prevent premature ready detection
129
+ // during startup messages like "amp v1.2.3 starting..."
130
+ if (hasPrompt && hasBranding) {
131
+ return { phase: "ready" };
132
+ }
133
+ return { phase: "loading" };
134
+ }
135
+
136
+ /** Amp does not require beacon verification. */
137
+ requiresBeaconVerification(): boolean {
138
+ return false;
139
+ }
140
+
141
+ /** Amp does not produce machine-readable transcripts. */
142
+ async parseTranscript(_path: string): Promise<TranscriptSummary | null> {
143
+ return null;
144
+ }
145
+
146
+ buildEnv(model: ResolvedModel): Record<string, string> {
147
+ return model.env ?? {};
148
+ }
149
+
150
+ /** Amp does not expose a transcript directory. */
151
+ getTranscriptDir(_projectRoot: string): string | null {
152
+ return null;
153
+ }
154
+ }
@@ -676,7 +676,9 @@ describe("ClaudeRuntime integration: registry resolves 'claude' as default", ()
676
676
 
677
677
  test("getRuntime rejects unknown runtimes", async () => {
678
678
  const { getRuntime } = await import("./registry.ts");
679
- expect(() => getRuntime("aider")).toThrow('Unknown runtime: "aider"');
680
- expect(() => getRuntime("nonexistent")).toThrow('Unknown runtime: "nonexistent"');
679
+ expect(() => getRuntime("nonexistent-runtime")).toThrow(
680
+ 'Unknown runtime: "nonexistent-runtime"',
681
+ );
682
+ expect(() => getRuntime("does-not-exist")).toThrow('Unknown runtime: "does-not-exist"');
681
683
  });
682
684
  });
@@ -203,7 +203,7 @@ describe("CodexRuntime", () => {
203
203
  expect(cmd1).toBe(cmd2);
204
204
  });
205
205
 
206
- test("all model names pass through unchanged", () => {
206
+ test("all bare model names pass through unchanged", () => {
207
207
  for (const model of ["gpt-5-codex", "gpt-4o", "o3", "custom-model-v2"]) {
208
208
  const opts: SpawnOpts = {
209
209
  model,
@@ -216,6 +216,30 @@ describe("CodexRuntime", () => {
216
216
  }
217
217
  });
218
218
 
219
+ test("provider-prefixed model strips prefix (openai/gpt-5.4 → gpt-5.4)", () => {
220
+ const opts: SpawnOpts = {
221
+ model: "openai/gpt-5.4",
222
+ permissionMode: "bypass",
223
+ cwd: "/tmp",
224
+ env: {},
225
+ };
226
+ const cmd = runtime.buildSpawnCommand(opts);
227
+ expect(cmd).toContain("--model gpt-5.4");
228
+ expect(cmd).not.toContain("openai/");
229
+ });
230
+
231
+ test("provider-prefixed model with other providers strips prefix", () => {
232
+ const opts: SpawnOpts = {
233
+ model: "azure/gpt-4o",
234
+ permissionMode: "bypass",
235
+ cwd: "/tmp",
236
+ env: {},
237
+ };
238
+ const cmd = runtime.buildSpawnCommand(opts);
239
+ expect(cmd).toContain("--model gpt-4o");
240
+ expect(cmd).not.toContain("azure/");
241
+ });
242
+
219
243
  test("systemPrompt field is ignored", () => {
220
244
  const opts: SpawnOpts = {
221
245
  model: "gpt-5-codex",
@@ -248,6 +272,19 @@ describe("CodexRuntime", () => {
248
272
  ]);
249
273
  });
250
274
 
275
+ test("provider-prefixed model strips prefix (openai/gpt-5.4 → gpt-5.4)", () => {
276
+ const argv = runtime.buildPrintCommand("Classify this error", "openai/gpt-5.4");
277
+ expect(argv).toEqual([
278
+ "codex",
279
+ "exec",
280
+ "--full-auto",
281
+ "--ephemeral",
282
+ "--model",
283
+ "gpt-5.4",
284
+ "Classify this error",
285
+ ]);
286
+ });
287
+
251
288
  test("model undefined omits --model flag", () => {
252
289
  const argv = runtime.buildPrintCommand("Hello", undefined);
253
290
  expect(argv).not.toContain("--model");
@@ -49,6 +49,21 @@ export class CodexRuntime implements AgentRuntime {
49
49
  */
50
50
  private static readonly MANIFEST_ALIASES = new Set(["sonnet", "opus", "haiku"]);
51
51
 
52
+ /**
53
+ * Strip a provider prefix from a model ID.
54
+ *
55
+ * Codex CLI expects bare model names. The orchestrator may resolve a model to
56
+ * a provider-qualified form (e.g. `"openai/gpt-5.4"`) — strip the `"openai/"`
57
+ * prefix before passing to the CLI.
58
+ *
59
+ * @param model - Possibly provider-qualified model ID
60
+ * @returns Bare model name (everything after the first `/`, or unchanged if no `/`)
61
+ */
62
+ private static stripProviderPrefix(model: string): string {
63
+ const slashIdx = model.indexOf("/");
64
+ return slashIdx !== -1 ? model.slice(slashIdx + 1) : model;
65
+ }
66
+
52
67
  /**
53
68
  * Escape a directory path for use in a single-quoted shell argument.
54
69
  *
@@ -75,11 +90,14 @@ export class CodexRuntime implements AgentRuntime {
75
90
  * @returns Shell command string suitable for tmux new-session -c
76
91
  */
77
92
  buildSpawnCommand(opts: SpawnOpts): string {
93
+ // Strip provider prefix before alias check and model flag injection.
94
+ // Codex CLI expects bare model names (e.g. "gpt-5.4", not "openai/gpt-5.4").
95
+ const bareModel = CodexRuntime.stripProviderPrefix(opts.model);
78
96
  // When model comes from default manifest aliases (sonnet/opus/haiku),
79
97
  // omit --model so Codex uses the user's configured default model.
80
98
  let cmd = "codex --full-auto";
81
- if (!CodexRuntime.MANIFEST_ALIASES.has(opts.model)) {
82
- cmd += ` --model ${opts.model}`;
99
+ if (!CodexRuntime.MANIFEST_ALIASES.has(bareModel)) {
100
+ cmd += ` --model ${bareModel}`;
83
101
  }
84
102
  for (const dir of opts.sharedWritableDirs ?? []) {
85
103
  cmd += ` --add-dir '${CodexRuntime.shellEscape(dir)}'`;
@@ -119,7 +137,8 @@ export class CodexRuntime implements AgentRuntime {
119
137
  buildPrintCommand(prompt: string, model?: string): string[] {
120
138
  const cmd = ["codex", "exec", "--full-auto", "--ephemeral"];
121
139
  if (model !== undefined) {
122
- cmd.push("--model", model);
140
+ // Strip provider prefix — Codex CLI expects bare model names.
141
+ cmd.push("--model", CodexRuntime.stripProviderPrefix(model));
123
142
  }
124
143
  cmd.push(prompt);
125
144
  return cmd;