@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.
- package/README.md +21 -6
- package/agents/coordinator.md +34 -10
- package/agents/lead.md +11 -1
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +18 -4
- package/src/beads/client.ts +31 -3
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +228 -125
- package/src/commands/dashboard.ts +50 -10
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.ts +50 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/monitor.ts +8 -2
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +8 -3
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +4 -79
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +2 -1
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +4 -1
- package/src/mail/store.test.ts +110 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +33 -9
- package/src/runtimes/pi.ts +10 -10
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +9 -2
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/types.ts +4 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +46 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- package/src/watchdog/triage.ts +21 -5
- package/src/worktree/tmux.test.ts +166 -49
- package/src/worktree/tmux.ts +36 -37
- 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", "&", 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("
|
|
680
|
-
|
|
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");
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -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(
|
|
82
|
-
cmd += ` --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
|
-
|
|
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;
|