amp-acp 0.1.0

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 ADDED
@@ -0,0 +1,45 @@
1
+ # ACP adapter for Amp
2
+
3
+ Use [Amp](https://ampcode.com) from ACP-compatible clients such as [Zed](https://zed.dev).
4
+
5
+ ## Prerequisites
6
+
7
+ - [Amp CLI](https://ampcode.com) installed and authenticated (`amp login`)
8
+ - Node.js (for running the adapter)
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install -g amp-acp
14
+ ```
15
+
16
+ Or run locally:
17
+
18
+ ```bash
19
+ npm install
20
+ npm start
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ 1. Start the adapter:
26
+ ```bash
27
+ amp-acp
28
+ ```
29
+
30
+ 2. In Zed:
31
+ - Open Settings → External Agents (ACP)
32
+ - Connect to the adapter
33
+ - Start a new Amp thread
34
+
35
+ ## How it Works
36
+
37
+ - Streams Amp's Claude Code–compatible JSON over ACP
38
+ - Renders Amp messages and interactions in Zed
39
+ - Tool permissions are handled by Amp (no additional configuration needed)
40
+
41
+ ## Troubleshooting
42
+
43
+ **Connection fails**: Ensure `amp login` was successful and the CLI is in your PATH.
44
+
45
+ **Adapter not found**: Check that `amp-acp` is running before connecting from Zed.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "amp-acp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "amp-acp": "./src/index.js"
8
+ },
9
+ "description": "ACP adapter that bridges Amp CLI to Agent Client Protocol (Zed external agent)",
10
+ "license": "Apache-2.0",
11
+ "scripts": {
12
+ "start": "node src/index.js",
13
+ "lint": "echo 'no lint'",
14
+ "test": "node -e 'console.log(\"OK\")'"
15
+ },
16
+ "dependencies": {
17
+ "@agentclientprotocol/sdk": "0.4.8"
18
+ }
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // stdout is reserved for ACP stream. Redirect logs to stderr.
3
+ console.log = console.error;
4
+ console.info = console.error;
5
+ console.warn = console.error;
6
+ console.debug = console.error;
7
+
8
+ import { runAcp } from './run-acp.js';
9
+
10
+ runAcp();
11
+
12
+ // Keep process alive
13
+ process.stdin.resume();
package/src/run-acp.js ADDED
@@ -0,0 +1,10 @@
1
+ import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk';
2
+ import { nodeToWebReadable, nodeToWebWritable } from './utils.js';
3
+ import { AmpAcpAgent } from './server.js';
4
+
5
+ export function runAcp() {
6
+ const input = nodeToWebWritable(process.stdout);
7
+ const output = nodeToWebReadable(process.stdin);
8
+ const stream = ndJsonStream(input, output);
9
+ new AgentSideConnection((client) => new AmpAcpAgent(client), stream);
10
+ }
package/src/server.js ADDED
@@ -0,0 +1,215 @@
1
+ import { RequestError } from '@agentclientprotocol/sdk';
2
+ import { spawn } from 'node:child_process';
3
+ import readline from 'node:readline';
4
+ import { toAcpNotifications } from './to-acp.js';
5
+
6
+ export class AmpAcpAgent {
7
+ constructor(client) {
8
+ this.client = client;
9
+ this.sessions = new Map();
10
+ }
11
+
12
+ async initialize(request) {
13
+ this.clientCapabilities = request.clientCapabilities;
14
+ return {
15
+ protocolVersion: 1,
16
+ agentCapabilities: {
17
+ promptCapabilities: { image: true, embeddedContext: true },
18
+ },
19
+ authMethods: [],
20
+ };
21
+ }
22
+
23
+ async newSession(params) {
24
+ const sessionId = `S-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
25
+
26
+ this.sessions.set(sessionId, {
27
+ proc: null,
28
+ rl: null,
29
+ queue: null,
30
+ cancelled: false,
31
+ active: false,
32
+ });
33
+
34
+ return {
35
+ sessionId,
36
+ models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: 'Default', description: 'Amp default' }] },
37
+ modes: {
38
+ currentModeId: 'default',
39
+ availableModes: [
40
+ { id: 'default', name: 'Always Ask', description: 'Prompts for permission on first use of each tool' },
41
+ { id: 'acceptEdits', name: 'Accept Edits', description: 'Automatically accepts file edit permissions for the session' },
42
+ { id: 'bypassPermissions', name: 'Bypass Permissions', description: 'Skips all permission prompts' },
43
+ { id: 'plan', name: 'Plan Mode', description: 'Analyze but not modify files or execute commands' },
44
+ ],
45
+ },
46
+ };
47
+ }
48
+
49
+ async authenticate(_params) {
50
+ throw RequestError.authRequired();
51
+ }
52
+
53
+ async prompt(params) {
54
+ const s = this.sessions.get(params.sessionId);
55
+ if (!s) throw new Error('Session not found');
56
+ s.cancelled = false;
57
+ s.active = true;
58
+
59
+ // Start a fresh Amp process per turn (amp -x is single-turn). Avoid reusing prior proc to prevent races.
60
+ const proc = spawn('amp', ['-x', '--stream-json', '--stream-json-input'], {
61
+ cwd: params.cwd || process.cwd(),
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ env: process.env,
64
+ });
65
+ const rl = readline.createInterface({ input: proc.stdout });
66
+ const queue = createJsonQueue(rl);
67
+ // Capture exit to clean state
68
+ proc.on('close', () => {
69
+ // Do not null out queue while a turn may still await next(); just signal end
70
+ try { queue?.end?.(); } catch {}
71
+ });
72
+ // Optionally log stderr (redirected to our stderr by default)
73
+ proc.stderr?.on('data', (d) => {
74
+ console.error(`[amp] ${d.toString()}`.trim());
75
+ });
76
+ s.proc = proc;
77
+ s.rl = rl;
78
+ s.queue = queue;
79
+ // Don't wait for init; amp will emit it before assistant/user events
80
+
81
+ // Build Amp user message JSON line from ACP prompt chunks
82
+ const content = [];
83
+ for (const chunk of params.prompt) {
84
+ switch (chunk.type) {
85
+ case 'text':
86
+ content.push({ type: 'text', text: chunk.text });
87
+ break;
88
+ case 'resource_link':
89
+ content.push({ type: 'text', text: chunk.uri });
90
+ break;
91
+ case 'resource':
92
+ if ('text' in chunk.resource) {
93
+ content.push({ type: 'text', text: chunk.resource.uri });
94
+ content.push({ type: 'text', text: `\n<context ref="${chunk.resource.uri}">\n${chunk.resource.text}\n</context>` });
95
+ }
96
+ break;
97
+ case 'image':
98
+ if (chunk.data) {
99
+ content.push({ type: 'image', source: { type: 'base64', data: chunk.data, media_type: chunk.mimeType } });
100
+ } else if (chunk.uri && chunk.uri.startsWith('http')) {
101
+ content.push({ type: 'image', source: { type: 'url', url: chunk.uri } });
102
+ }
103
+ break;
104
+ default:
105
+ break;
106
+ }
107
+ }
108
+
109
+ const userMsg = {
110
+ type: 'user',
111
+ message: { role: 'user', content },
112
+ parent_tool_use_id: null,
113
+ session_id: params.sessionId,
114
+ };
115
+
116
+ s.proc.stdin.write(JSON.stringify(userMsg) + '\n');
117
+
118
+ try {
119
+ while (true) {
120
+ const msg = await s.queue.next();
121
+ if (msg == null) {
122
+ return { stopReason: s.cancelled ? 'cancelled' : 'refusal' };
123
+ }
124
+ switch (msg.type) {
125
+ case 'system':
126
+ // ignore init/compact/etc
127
+ break;
128
+ case 'assistant': {
129
+ const notifs = toAcpNotifications(msg, params.sessionId);
130
+ for (const n of notifs) await this.client.sessionUpdate(n);
131
+ break;
132
+ }
133
+ case 'user': {
134
+ // Skip echoing user messages to avoid duplicates in the client UI
135
+ break;
136
+ }
137
+ case 'result': {
138
+ if (msg.subtype === 'success') return { stopReason: 'end_turn' };
139
+ if (msg.subtype === 'error_max_turns') return { stopReason: 'max_turn_requests' };
140
+ return { stopReason: 'refusal' };
141
+ }
142
+ default:
143
+ break;
144
+ }
145
+ }
146
+ throw new Error('Session did not end in result');
147
+ } finally {
148
+ s.active = false;
149
+ s.cancelled = false;
150
+ }
151
+ }
152
+
153
+ async cancel(params) {
154
+ const s = this.sessions.get(params.sessionId);
155
+ if (!s) return {};
156
+ if (s.active && s.proc) {
157
+ s.cancelled = true;
158
+ try { s.proc.kill('SIGINT'); } catch {}
159
+ // ensure readers unblock
160
+ try { s.queue?.end?.(); } catch {}
161
+ }
162
+ return {};
163
+ }
164
+
165
+ async setSessionModel(_params) { return {}; }
166
+
167
+ async setSessionMode(_params) { return {}; }
168
+
169
+ async readTextFile(params) { return this.client.readTextFile(params); }
170
+ async writeTextFile(params) { return this.client.writeTextFile(params); }
171
+ }
172
+
173
+ function createJsonQueue(rl) {
174
+ const buf = [];
175
+ const waiters = [];
176
+ rl.on('line', (line) => {
177
+ let obj;
178
+ try {
179
+ obj = JSON.parse(line);
180
+ } catch {
181
+ return; // ignore non-JSON
182
+ }
183
+ if (waiters.length) {
184
+ const resolve = waiters.shift();
185
+ resolve(obj);
186
+ } else {
187
+ buf.push(obj);
188
+ }
189
+ });
190
+ let ended = false;
191
+ function end() {
192
+ if (ended) return;
193
+ ended = true;
194
+ while (waiters.length) {
195
+ const resolve = waiters.shift();
196
+ resolve(null);
197
+ }
198
+ }
199
+ rl.on('close', end);
200
+ rl.on('SIGINT', end);
201
+ return {
202
+ next() {
203
+ return new Promise((resolve) => {
204
+ if (buf.length) {
205
+ resolve(buf.shift());
206
+ } else if (ended) {
207
+ resolve(null);
208
+ } else {
209
+ waiters.push(resolve);
210
+ }
211
+ });
212
+ },
213
+ end,
214
+ };
215
+ }
package/src/to-acp.js ADDED
@@ -0,0 +1,91 @@
1
+ // Minimal conversion from Amp stream JSON events (Claude Code compatible)
2
+ // into ACP sessionUpdate notifications. Based on zed-industries/claude-code-acp.
3
+
4
+ export function toAcpNotifications(message, sessionId) {
5
+ const content = message.message?.content;
6
+ if (typeof content === 'string') {
7
+ return [
8
+ {
9
+ sessionId,
10
+ update: {
11
+ sessionUpdate: message.type === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
12
+ content: { type: 'text', text: content },
13
+ },
14
+ },
15
+ ];
16
+ }
17
+ const output = [];
18
+ if (!Array.isArray(content)) return output;
19
+ for (const chunk of content) {
20
+ let update = null;
21
+ switch (chunk.type) {
22
+ case 'text':
23
+ update = {
24
+ sessionUpdate: message.type === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
25
+ content: { type: 'text', text: chunk.text },
26
+ };
27
+ break;
28
+ case 'image':
29
+ update = {
30
+ sessionUpdate: message.type === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
31
+ content: {
32
+ type: 'image',
33
+ data: chunk.source?.type === 'base64' ? chunk.source.data : '',
34
+ mimeType: chunk.source?.type === 'base64' ? chunk.source.media_type : '',
35
+ uri: chunk.source?.type === 'url' ? chunk.source.url : undefined,
36
+ },
37
+ };
38
+ break;
39
+ case 'thinking':
40
+ update = {
41
+ sessionUpdate: 'agent_thought_chunk',
42
+ content: { type: 'text', text: chunk.thinking },
43
+ };
44
+ break;
45
+ case 'tool_use':
46
+ update = {
47
+ toolCallId: chunk.id,
48
+ sessionUpdate: 'tool_call',
49
+ rawInput: safeJson(chunk.input),
50
+ status: 'pending',
51
+ title: chunk.name || 'Tool',
52
+ kind: 'other',
53
+ content: [],
54
+ };
55
+ break;
56
+ case 'tool_result':
57
+ update = {
58
+ toolCallId: chunk.tool_use_id,
59
+ sessionUpdate: 'tool_call_update',
60
+ status: chunk.is_error ? 'failed' : 'completed',
61
+ content: toAcpContentArray(chunk.content, chunk.is_error),
62
+ };
63
+ break;
64
+ default:
65
+ break;
66
+ }
67
+ if (update) output.push({ sessionId, update });
68
+ }
69
+ return output;
70
+ }
71
+
72
+ function toAcpContentArray(content, isError = false) {
73
+ if (Array.isArray(content) && content.length > 0) {
74
+ return content.map((c) => ({
75
+ type: 'content',
76
+ content: c.type === 'text' ? { type: 'text', text: isError ? wrapCode(c.text) : c.text } : c,
77
+ }));
78
+ }
79
+ if (typeof content === 'string' && content.length > 0) {
80
+ return [{ type: 'content', content: { type: 'text', text: isError ? wrapCode(content) : content } }];
81
+ }
82
+ return [];
83
+ }
84
+
85
+ function wrapCode(t) {
86
+ return '```\n' + t + '\n```';
87
+ }
88
+
89
+ function safeJson(x) {
90
+ try { return JSON.parse(JSON.stringify(x)); } catch { return undefined; }
91
+ }
package/src/utils.js ADDED
@@ -0,0 +1,27 @@
1
+ import { WritableStream, ReadableStream } from 'node:stream/web';
2
+
3
+ export function nodeToWebWritable(nodeStream) {
4
+ return new WritableStream({
5
+ write(chunk) {
6
+ return new Promise((resolve, reject) => {
7
+ nodeStream.write(Buffer.from(chunk), (err) => {
8
+ if (err) reject(err); else resolve();
9
+ });
10
+ });
11
+ },
12
+ });
13
+ }
14
+
15
+ export function nodeToWebReadable(nodeStream) {
16
+ return new ReadableStream({
17
+ start(controller) {
18
+ nodeStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
19
+ nodeStream.on('end', () => controller.close());
20
+ nodeStream.on('error', (err) => controller.error(err));
21
+ },
22
+ });
23
+ }
24
+
25
+ export function unreachable(x) {
26
+ throw new Error(`unreachable: ${JSON.stringify(x)}`);
27
+ }