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 +45 -0
- package/package.json +19 -0
- package/src/index.js +13 -0
- package/src/run-acp.js +10 -0
- package/src/server.js +215 -0
- package/src/to-acp.js +91 -0
- package/src/utils.js +27 -0
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
|
+
}
|