Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ pub fn bundled_manifests() -> Vec<Manifest> {
.expect("goose manifest"),
serde_json::from_str(include_str!("../../../src/bundled/pi/manifest.json"))
.expect("pi manifest"),
serde_json::from_str(include_str!("../../../src/bundled/amp/manifest.json"))
.expect("amp manifest"),
serde_json::from_str(include_str!("../../../src/bundled/workflows/manifest.json"))
.expect("workflows manifest"),
serde_json::from_str(include_str!("../../../src/bundled/feedback/manifest.json"))
Expand Down
7 changes: 6 additions & 1 deletion crates/plugin-manager/tests/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,7 +1134,12 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() {
let id = plugin.manifest.id.as_str();
let default = !matches!(
id,
"buzz.bestie" | "buzz.pi" | "buzz.todos" | "buzz.usage" | "buzz.relay-notes"
"buzz.amp"
| "buzz.bestie"
| "buzz.pi"
| "buzz.todos"
| "buzz.usage"
| "buzz.relay-notes"
);
assert_eq!(plugin.enabled, default, "{id}");
if id == "buzz.channels" {
Expand Down
1 change: 1 addition & 0 deletions src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
assert.equal(
plugin.enabledByDefault,
![
"buzz.amp",
"buzz.bestie",
"buzz.pi",
"buzz.todos",
Expand Down
58 changes: 58 additions & 0 deletions src/bundled/amp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Amp

Create agents that run Amp on your computer and answer you in Buzz. Each conversation continues its own Amp thread.

## Getting started

1. Install [Amp](https://ampcode.com) and run `amp login` once.
2. Turn on **Amp** in **Settings** → **Plugins**.
3. Choose **New agent** → **Create a new agent** and pick **Amp** as the **Harness**.
4. Mention your agent in a conversation to give it work.

---

# Amp for Agents

A desktop plugin, off until turned on in **Settings → Plugins**. It runs the
`amp` CLI found on this computer's PATH (on macOS, `~/.local/bin` too), signed
in as `amp login` left it. Agents owns the identity, admission, configuration,
and signed Buzz actions; this plugin owns Amp work after delivery.

- Each conversation is one Amp thread, saved on this device: a Buzz thread,
or the whole channel when **Sessions** says so, and always a DM or Solo
chat. Each turn runs `amp threads continue <thread> --execute --stream-json`
once, in the agent's **Workspace** (default `~/.buzz`), and its turns run
one at a time. A thread Amp can no longer continue is started again.
Threads are kept unarchived and titled `Agent · #channel`, so they also show
in Amp's thread list.
- The Buzz tools are a loopback MCP server the app answers for each turn
(`--mcp-config`), signed by the agent's native-held key; the `amp` process
holds no Buzz key. Amp reaches them with its own `tool_search`. Continuing a
thread, Amp connects once as it starts and again once the thread loads, but
the server admits one session, so the Buzz tools refuse `initialize` until
Amp reports the thread open. Amp retries and connects.
- Amp has no system prompt option, so Buzz's prompt opens each new thread, and
opens the next turn again after it changes.
- Only the owner's events, or ones the owner delegated, start work. Amp runs
with `--dangerously-allow-all`, like Claude's `bypassPermissions`. Watches
and timers run as one-off Amp threads, which Amp archives.
- **Mode** picks Amp's agent mode (`--mode`); the composer's model picker
offers the modes for one turn.
- **Stop** on a turn's activity stops its `amp` process, and posts nothing.
- A turn that answered in text and never called `send` has its answer posted in
the thread. A turn that fails posts `⚠️ I couldn't finish that: …` and tags
the owner.
- `BUZZ_MANAGED_AGENT=1` is set for `amp`, so Amp plugins that mirror threads
to Buzz, such as amp-buzz, stay out of the way.

Not yet supported: steering a running turn (a newer mention waits for it), an
Activity transcript, usage, compact and new-session controls, resuming turns a
reload stopped, and Windows.

## Checks

```sh
bin/pnpm exec vitest run src/bundled/amp
# Against the real `amp`, using Amp credits:
AMP_LIVE=1 bin/pnpm exec vitest run src/bundled/amp/amp.live.test.ts
```
109 changes: 109 additions & 0 deletions src/bundled/amp/amp-testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// A fake desktop host for Amp tests: each `amp` it starts runs `script` with
// its arguments, its prompt and the turn's MCP handler, and prints what that
// returns.
import type {
HostMcpServer,
HostProcess,
HostProcessOptions,
McpHandler,
} from "../../features/host/service";
import type { AmpHost } from "./amp";

export type Script = (
run: Readonly<{
args: readonly string[];
prompt: string;
mcp: McpHandler;
killed: Promise<void>;
}>,
) => Promise<Readonly<{ stdout?: string[]; stderr?: string; code?: number }>>;

export type Started = {
args: readonly string[];
cwd?: string;
env?: HostProcessOptions["env"];
prompt?: string;
killed: boolean;
};

export const line = (value: unknown) => JSON.stringify(value);
export const init = (thread: string) =>
line({ type: "system", subtype: "init", session_id: thread });
export const result = (thread: string, text: string, isError = false) =>
line({
type: "result",
subtype: isError ? "error" : "success",
is_error: isError,
result: text,
session_id: thread,
});

export function fakeAmp(script: Script) {
const started: Started[] = [];
const servers: { closed: boolean }[] = [];
let handler: McpHandler = async () => undefined;
const host: AmpHost = {
serve: async (next) => {
handler = next;
const server = { closed: false };
servers.push(server);
return {
url: "http://127.0.0.1:1/mcp",
headers: [{ name: "Authorization", value: "Bearer token" }],
close: async () => {
server.closed = true;
},
closed: new Promise(() => {}),
} satisfies HostMcpServer;
},
spawn: async (_id, options = {}) => {
const run: Started = {
args: options.args ?? [],
...(options.cwd ? { cwd: options.cwd } : {}),
...(options.env ? { env: options.env } : {}),
killed: false,
};
started.push(run);
const mcp = handler;
let kill = () => {};
const killed = new Promise<void>((resolve) => {
kill = resolve;
});
let input: (prompt: string) => void = () => {};
const prompt = new Promise<string>((resolve) => {
input = resolve;
});
const exited = prompt.then(async (text) => {
const output: Awaited<ReturnType<Script>> = await Promise.race([
script({ args: run.args, prompt: text, mcp, killed }),
killed.then(() => ({ code: 143 })),
]);
for (const item of output.stdout ?? []) options.onStdout?.(`${item}\n`);
if (output.stderr) options.onStderr?.(output.stderr);
return output.code ?? 0;
});
return {
write: async () => {},
end: async (data = "") => {
run.prompt = data;
input(data);
},
kill: async () => {
run.killed = true;
kill();
},
exited,
} satisfies HostProcess;
},
};
return { host, started, servers };
}

/** A `tools/call` of the Buzz tool `name`, as Amp sends one. */
export const call = (mcp: McpHandler, name: string, id = 1) =>
mcp({
jsonrpc: "2.0",
id,
method: "tools/call",
params: { name, arguments: {} },
});
187 changes: 187 additions & 0 deletions src/bundled/amp/amp.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// @vitest-environment node
// Drives the real `amp` on PATH, signed in as `amp login` left it: run with
// AMP_LIVE=1. The Buzz tools are listed as the app lists them, from a loopback
// HTTP MCP server that follows the app's native one; their calls are recorded,
// not sent to a relay. Each run uses Amp credits.
import { spawn as spawnChild } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtempSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { respond } from "../../buzz-mcp/rpc";
import { answerMcp } from "../../features/host/service";
import { systemPrompt } from "../claude-code/prompt";
import { type AmpHost, runTurn, type ToolServer } from "./amp";

const LIVE = process.env.AMP_LIVE === "1";

const spawn: AmpHost["spawn"] = async (_id, options = {}) => {
const env: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(options.env ?? {}))
if (value === null) delete env[key];
else env[key] = value;
const child = spawnChild("amp", [...(options.args ?? [])], {
cwd: options.cwd,
env,
stdio: ["pipe", "pipe", "pipe"],
});
child.stdout
.setEncoding("utf8")
.on("data", (data) => options.onStdout?.(data));
child.stderr
.setEncoding("utf8")
.on("data", (data) => options.onStderr?.(data));
const exited = new Promise<number | null>((resolve) =>
child.on("exit", (code) => resolve(code)),
);
return {
write: (data) =>
new Promise<void>((resolve, reject) =>
child.stdin.write(data, (error) => (error ? reject(error) : resolve())),
),
end: async (data) => {
child.stdin.end(data);
},
kill: async () => {
child.kill();
},
exited,
};
};

/** The HTTP requests the MCP servers received, by method and status. */
const hits: { method: string; rpc: unknown; status: number }[] = [];

/** What the app's native listener does: a token per server, 404 for a wrong
* one, 405 for `GET`, and a JSON reply or 202 for each `POST /mcp`. The first
* `initialize` answered with a result claims the server's one session, which
* later requests name; another after that stops the server. */
const serve: AmpHost["serve"] = async (handler) => {
const token = randomBytes(32).toString("base64url");
const session = randomBytes(32).toString("base64url");
let claim: "unclaimed" | "claiming" | "claimed" = "unclaimed";
let stopped = false;
const server = createServer((request, response) => {
let rpc: unknown;
const end = (status: number, body?: string) => {
hits.push({ method: request.method ?? "", rpc, status });
response.writeHead(status, {
...(claim === "claimed" && (status === 200 || status === 202)
? { "Mcp-Session-Id": session }
: {}),
...(body ? { "Content-Type": "application/json" } : {}),
});
response.end(body);
};
if (
request.url?.split("?")[0] !== "/mcp" ||
request.headers.authorization !== `Bearer ${token}`
)
return end(404);
if (request.method !== "POST") return end(405);
const named = request.headers["mcp-session-id"];
if (named !== undefined && (named !== session || claim !== "claimed"))
return end(404);
let body = "";
request.setEncoding("utf8").on("data", (data) => (body += data));
request.on("end", async () => {
try {
rpc = (JSON.parse(body) as { method?: unknown }).method;
} catch {}
if (stopped) return end(404);
if (named === undefined) {
if (rpc !== "initialize") return end(404);
if (claim !== "unclaimed") {
stopped = true;
return end(404);
}
claim = "claiming";
}
const reply = await answerMcp(handler, body);
if (claim === "claiming")
claim =
(reply as { result?: unknown } | undefined)?.result === undefined
? "unclaimed"
: "claimed";
end(reply === undefined ? 202 : 200, JSON.stringify(reply));
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", () => resolve()),
);
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
const closed = new Promise<void>((resolve) =>
server.on("close", () => resolve()),
);
return {
url: `http://127.0.0.1:${port}/mcp`,
headers: [{ name: "Authorization", value: `Bearer ${token}` }],
close: async () => {
server.closeAllConnections();
server.close();
},
closed,
};
};

type Send = { name?: string; arguments?: { text?: string } };

describe.skipIf(!LIVE)("amp, live", () => {
it("answers with the Buzz tools, then continues its thread", {
timeout: 300_000,
}, async () => {
const sends: Send[] = [];
const tools: ToolServer = async (message) => {
if (message.method === "tools/call") {
sends.push(message.params as Send);
return {
jsonrpc: "2.0",
id: message.id,
result: { content: [{ type: "text", text: "sent 1234" }] },
};
}
return respond(undefined, {}, message);
};
const cwd = mkdtempSync(join(tmpdir(), "buzz-amp-"));
const system = systemPrompt({ scope: "thread", cwd });
const first = await runTurn(
{ spawn, serve },
{
cwd,
prompt: `<buzz-system-prompt>\n${system}\n</buzz-system-prompt>\n\nRemember the word "heron". Reply in Buzz with send, audience "everyone", text "ready", final true.`,
keep: true,
title: "Buzz live test",
mode: "low",
tools,
},
);
expect(first).toMatchObject({ ok: true });
expect(first.text).toBeUndefined();
expect(sends.at(-1)).toMatchObject({
name: "send",
arguments: { text: "ready" },
});
// Amp opens one MCP session and never claims the server twice.
expect(
hits.filter((hit) => hit.rpc === "initialize" && hit.status === 200),
).toHaveLength(1);
expect(hits.filter((hit) => hit.status === 404)).toEqual([]);

const second = await runTurn(
{ spawn, serve },
{
cwd,
prompt:
'Which word did I ask you to remember? Reply with send, audience "everyone", the word as text, final true.',
...(first.thread ? { thread: first.thread } : {}),
mode: "low",
tools,
},
);
expect(second).toMatchObject({ ok: true, thread: first.thread });
expect(sends.at(-1)?.arguments?.text?.toLowerCase()).toContain("heron");
});
});
Loading
Loading