amalgm 0.1.135 → 0.1.136
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/package.json +1 -1
- package/runtime/lib/harnesses.js +61 -162
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
- package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
- package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
- package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
- package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
- package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
- package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
- package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
- package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
- package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
- package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
- package/runtime/scripts/chat-core/adapters/pi.js +302 -0
- package/runtime/scripts/chat-core/auth.js +20 -3
- package/runtime/scripts/chat-core/chat-payload.js +2 -0
- package/runtime/scripts/chat-core/contract.js +56 -1
- package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
- package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
- package/runtime/scripts/chat-core/server.js +4 -0
- package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
- package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
- package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
- package/runtime/scripts/chat-core/usage.js +20 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn, spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { done, errorEvent } = require('../events');
|
|
8
|
+
const { promptText } = require('../input');
|
|
9
|
+
const { normalizePiEvent, piSessionStatsUsage } = require('../normalizers/pi');
|
|
10
|
+
const { recordNativeEvent } = require('../recorder');
|
|
11
|
+
const { executableExists, findOnPath } = require('../tooling/native-binaries');
|
|
12
|
+
const { prepareHarnessRuntime } = require('../tooling/runtime-home');
|
|
13
|
+
const { composeSystemPrompt } = require('../tooling/system-prompt');
|
|
14
|
+
|
|
15
|
+
const GATEWAY_PROVIDER = 'vercel-ai-gateway';
|
|
16
|
+
|
|
17
|
+
// Pi's RPC protocol is JSONL over stdio, but NOT JSON-RPC: commands are
|
|
18
|
+
// {id?, type, ...}, responses are {id?, type:'response', command, success, data},
|
|
19
|
+
// and events are un-id'd JSON lines. Framing is LF-only by spec.
|
|
20
|
+
class PiRpc {
|
|
21
|
+
constructor({ binary, args, cwd, env }) {
|
|
22
|
+
this.id = 1;
|
|
23
|
+
this.pending = new Map();
|
|
24
|
+
this.eventHandlers = new Set();
|
|
25
|
+
this.buffer = '';
|
|
26
|
+
this.closed = false;
|
|
27
|
+
this.closeError = null;
|
|
28
|
+
this.child = spawn(binary, args, {
|
|
29
|
+
cwd,
|
|
30
|
+
env,
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
detached: process.platform !== 'win32',
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
});
|
|
35
|
+
this.child.stdout.setEncoding('utf8');
|
|
36
|
+
this.child.stdout.on('data', (chunk) => this.onData(chunk));
|
|
37
|
+
this.child.stderr.setEncoding('utf8');
|
|
38
|
+
this.child.stderr.on('data', (chunk) => {
|
|
39
|
+
const text = String(chunk || '').trim();
|
|
40
|
+
if (text && process.env.CHAT_CORE_DEBUG) console.warn('[Pi]', text.slice(0, 1000));
|
|
41
|
+
});
|
|
42
|
+
this.child.on('error', (err) => this.failAll(err));
|
|
43
|
+
this.child.on('exit', (code, signal) => {
|
|
44
|
+
this.failAll(new Error(`pi rpc agent exited (${code ?? signal ?? 'unknown'})`));
|
|
45
|
+
});
|
|
46
|
+
this.child.stdin.on('error', (err) => this.failAll(err));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
failAll(err) {
|
|
50
|
+
this.closed = true;
|
|
51
|
+
this.closeError = err;
|
|
52
|
+
for (const pending of this.pending.values()) pending.reject(err);
|
|
53
|
+
this.pending.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onData(chunk) {
|
|
57
|
+
this.buffer += chunk;
|
|
58
|
+
const lines = this.buffer.split('\n');
|
|
59
|
+
this.buffer = lines.pop() || '';
|
|
60
|
+
for (const rawLine of lines) {
|
|
61
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
let msg;
|
|
64
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
65
|
+
if (process.env.CHAT_CORE_DEBUG_PI === '1') {
|
|
66
|
+
console.log('[Pi:recv]', JSON.stringify(msg).slice(0, 4000));
|
|
67
|
+
}
|
|
68
|
+
if (msg.type === 'response') {
|
|
69
|
+
const pending = msg.id !== undefined ? this.pending.get(msg.id) : null;
|
|
70
|
+
if (pending) {
|
|
71
|
+
this.pending.delete(msg.id);
|
|
72
|
+
if (msg.success === false) {
|
|
73
|
+
pending.reject(new Error(msg.error || `pi ${msg.command} failed`));
|
|
74
|
+
} else {
|
|
75
|
+
pending.resolve(msg.data ?? {});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const handler of this.eventHandlers) handler(msg);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
request(command, params = {}, timeoutMs = 180000) {
|
|
85
|
+
if (this.closeError) return Promise.reject(this.closeError);
|
|
86
|
+
if (this.closed || this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
87
|
+
return Promise.reject(new Error('pi rpc agent is not running'));
|
|
88
|
+
}
|
|
89
|
+
const id = `req-${this.id++}`;
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timer = timeoutMs > 0
|
|
92
|
+
? setTimeout(() => {
|
|
93
|
+
this.pending.delete(id);
|
|
94
|
+
reject(new Error(`pi ${command} timed out`));
|
|
95
|
+
}, timeoutMs)
|
|
96
|
+
: null;
|
|
97
|
+
this.pending.set(id, {
|
|
98
|
+
resolve: (value) => { if (timer) clearTimeout(timer); resolve(value); },
|
|
99
|
+
reject: (err) => { if (timer) clearTimeout(timer); reject(err); },
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
this.child.stdin.write(`${JSON.stringify({ id, type: command, ...params })}\n`, (err) => {
|
|
103
|
+
if (err) this.failAll(err);
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
this.failAll(err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
onEvent(handler) {
|
|
112
|
+
this.eventHandlers.add(handler);
|
|
113
|
+
return () => this.eventHandlers.delete(handler);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
close() {
|
|
117
|
+
try { this.child.stdin.end(); } catch {}
|
|
118
|
+
try {
|
|
119
|
+
if (process.platform !== 'win32' && this.child.pid) process.kill(-this.child.pid, 'SIGTERM');
|
|
120
|
+
else this.child.kill('SIGTERM');
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveBinary() {
|
|
126
|
+
const binaryName = process.platform === 'win32' ? 'pi.cmd' : 'pi';
|
|
127
|
+
const homes = [...new Set([process.env.AMALGM_NATIVE_HOME, os.homedir()].filter(Boolean))];
|
|
128
|
+
const candidates = [
|
|
129
|
+
process.env.PI_BINARY,
|
|
130
|
+
...homes.map((home) => path.join(home, '.npm-global', 'bin', binaryName)),
|
|
131
|
+
'/opt/homebrew/bin/pi',
|
|
132
|
+
'/usr/local/bin/pi',
|
|
133
|
+
findOnPath('pi'),
|
|
134
|
+
].filter(Boolean);
|
|
135
|
+
const failures = [];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
if (!executableExists(candidate)) {
|
|
138
|
+
failures.push(`${candidate} (missing or not executable)`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const res = spawnSync(candidate, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
|
|
142
|
+
if (res.status === 0) return candidate;
|
|
143
|
+
failures.push(`${candidate} (--version failed: ${res.error?.message || res.status || res.signal || 'unknown'})`);
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`Pi CLI is not available. Checked: ${failures.join(', ') || 'no candidates'}. Install it with: npm install -g @mariozechner/pi-coding-agent`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Under amalgm auth every gateway request must flow through the local egress
|
|
149
|
+
// proxy. Pi's provider baseUrl is overridden by a tiny extension; the URL rides
|
|
150
|
+
// in on an env var so the extension file itself is static.
|
|
151
|
+
function ensureEgressExtension(runtimeHome) {
|
|
152
|
+
const file = path.join(runtimeHome, '.pi', 'amalgm-egress-extension.js');
|
|
153
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
154
|
+
const source = [
|
|
155
|
+
'// Generated by amalgm chat-core: routes the Vercel AI Gateway provider',
|
|
156
|
+
'// through the per-session amalgm egress proxy for billing and audit.',
|
|
157
|
+
'export default function (pi) {',
|
|
158
|
+
' const baseUrl = process.env.AMALGM_PI_GATEWAY_BASE_URL;',
|
|
159
|
+
` if (baseUrl) pi.registerProvider(${JSON.stringify(GATEWAY_PROVIDER)}, { baseUrl });`,
|
|
160
|
+
'}',
|
|
161
|
+
'',
|
|
162
|
+
].join('\n');
|
|
163
|
+
fs.writeFileSync(file, source, { mode: 0o600 });
|
|
164
|
+
return file;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function thinkingLevelFor(contract) {
|
|
168
|
+
const effort = String(contract.reasoningEffort || '').toLowerCase();
|
|
169
|
+
return ['minimal', 'low', 'medium', 'high', 'xhigh'].includes(effort) ? effort : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildArgs(contract, runtimeHome) {
|
|
173
|
+
const args = ['--mode', 'rpc', '--provider', GATEWAY_PROVIDER, '--model', contract.cliModel];
|
|
174
|
+
const thinking = thinkingLevelFor(contract);
|
|
175
|
+
if (thinking) args.push('--thinking', thinking);
|
|
176
|
+
const systemPrompt = composeSystemPrompt(contract);
|
|
177
|
+
if (systemPrompt) args.push('--system-prompt', systemPrompt);
|
|
178
|
+
if (contract.providerSessionId) args.push('--session', contract.providerSessionId);
|
|
179
|
+
if (contract.authMethod === 'amalgm') args.push('--extension', ensureEgressExtension(runtimeHome));
|
|
180
|
+
return args;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class PiAdapter {
|
|
184
|
+
async create(contract) {
|
|
185
|
+
const runtime = prepareHarnessRuntime(contract);
|
|
186
|
+
const binary = resolveBinary();
|
|
187
|
+
const env = { ...runtime.env };
|
|
188
|
+
if (contract.authMethod === 'amalgm' && contract.auth.baseUrl) {
|
|
189
|
+
env.AMALGM_PI_GATEWAY_BASE_URL = contract.auth.baseUrl;
|
|
190
|
+
}
|
|
191
|
+
const client = new PiRpc({
|
|
192
|
+
binary,
|
|
193
|
+
args: buildArgs(contract, runtime.runtimeHome),
|
|
194
|
+
cwd: contract.cwd,
|
|
195
|
+
env,
|
|
196
|
+
});
|
|
197
|
+
// Sessions persist as files under the managed home; the file path is the
|
|
198
|
+
// durable resume handle (pi's --session flag takes a path).
|
|
199
|
+
const stats = await client.request('get_session_stats', {}, 60000);
|
|
200
|
+
return {
|
|
201
|
+
sessionId: contract.sessionId,
|
|
202
|
+
providerSessionId: stats.sessionFile || contract.providerSessionId || null,
|
|
203
|
+
client,
|
|
204
|
+
cancelRequested: false,
|
|
205
|
+
activePrompt: null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async resume(session, contract) {
|
|
210
|
+
if (session.client && !session.client.closed) return session;
|
|
211
|
+
return this.create({ ...contract, providerSessionId: session.providerSessionId || contract.providerSessionId });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async stop(session) {
|
|
215
|
+
session.cancelRequested = true;
|
|
216
|
+
if (!session.activePrompt) return;
|
|
217
|
+
await session.client.request('abort', {}, 15000).catch(() => {});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async destroy(session) {
|
|
221
|
+
session.client.close();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async *prompt(session, input, contract) {
|
|
225
|
+
const queue = [];
|
|
226
|
+
let complete = false;
|
|
227
|
+
let wake = null;
|
|
228
|
+
const wakeLoop = () => { if (wake) { wake(); wake = null; } };
|
|
229
|
+
// Unlike the other adapters, an `error` event does NOT complete the turn:
|
|
230
|
+
// pi still emits agent_end afterwards, and finish() owns the done event.
|
|
231
|
+
const push = (e) => {
|
|
232
|
+
if (e.type === 'done') complete = true;
|
|
233
|
+
queue.push(e);
|
|
234
|
+
wakeLoop();
|
|
235
|
+
};
|
|
236
|
+
const state = { providerSessionId: session.providerSessionId, tools: new Set() };
|
|
237
|
+
let sawError = false;
|
|
238
|
+
session.cancelRequested = false;
|
|
239
|
+
session.activePrompt = state;
|
|
240
|
+
const finish = async () => {
|
|
241
|
+
// Attach the post-turn context snapshot before done so the context circle
|
|
242
|
+
// lands on the final number even if per-message events raced.
|
|
243
|
+
const stats = await session.client.request('get_session_stats', {}, 30000).catch(() => null);
|
|
244
|
+
recordNativeEvent('pi.rpc.session_stats', stats, {
|
|
245
|
+
providerSessionId: session.providerSessionId,
|
|
246
|
+
sessionId: contract.sessionId,
|
|
247
|
+
assistantMessageId: contract.assistantMessageId,
|
|
248
|
+
});
|
|
249
|
+
const snapshot = stats ? piSessionStatsUsage(stats, { providerSessionId: session.providerSessionId }) : null;
|
|
250
|
+
if (snapshot) push(snapshot);
|
|
251
|
+
push(done({
|
|
252
|
+
providerSessionId: session.providerSessionId,
|
|
253
|
+
stopReason: session.cancelRequested ? 'cancelled' : sawError ? 'error' : 'end_turn',
|
|
254
|
+
}));
|
|
255
|
+
};
|
|
256
|
+
const off = session.client.onEvent((event) => {
|
|
257
|
+
recordNativeEvent('pi.rpc.event', event, {
|
|
258
|
+
providerSessionId: session.providerSessionId,
|
|
259
|
+
sessionId: contract.sessionId,
|
|
260
|
+
assistantMessageId: contract.assistantMessageId,
|
|
261
|
+
eventType: event?.type,
|
|
262
|
+
});
|
|
263
|
+
if (event?.type === 'agent_end') {
|
|
264
|
+
finish().catch(() => push(done({ providerSessionId: session.providerSessionId, stopReason: 'end_turn' })));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const e of normalizePiEvent(event, state)) {
|
|
268
|
+
if (e.type === 'error') sawError = true;
|
|
269
|
+
push(e);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
session.client.request('prompt', { message: promptText(input, contract) }, 0).catch((err) => {
|
|
273
|
+
if (session.cancelRequested) {
|
|
274
|
+
push(done({ providerSessionId: session.providerSessionId, stopReason: 'cancelled' }));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
push(errorEvent(err.message, { providerSessionId: session.providerSessionId }));
|
|
278
|
+
push(done({ providerSessionId: session.providerSessionId, stopReason: 'error' }));
|
|
279
|
+
});
|
|
280
|
+
try {
|
|
281
|
+
while (!complete || queue.length) {
|
|
282
|
+
if (!queue.length) await new Promise((resolve) => { wake = resolve; });
|
|
283
|
+
else yield queue.shift();
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
off();
|
|
287
|
+
session.activePrompt = null;
|
|
288
|
+
session.cancelRequested = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
PiAdapter,
|
|
295
|
+
__private: {
|
|
296
|
+
PiRpc,
|
|
297
|
+
buildArgs,
|
|
298
|
+
ensureEgressExtension,
|
|
299
|
+
resolveBinary,
|
|
300
|
+
thinkingLevelFor,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
@@ -10,6 +10,8 @@ const AUTH_BY_HARNESS = {
|
|
|
10
10
|
claude_code: ['amalgm', 'byok', 'provider_auth'],
|
|
11
11
|
codex: ['amalgm', 'byok', 'provider_auth'],
|
|
12
12
|
opencode: ['amalgm', 'byok', 'provider_auth'],
|
|
13
|
+
cursor: ['provider_auth'],
|
|
14
|
+
pi: ['amalgm', 'byok', 'provider_auth'],
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
function shellValue(raw) {
|
|
@@ -35,6 +37,7 @@ function readByokFile(amalgmDir) {
|
|
|
35
37
|
function byokKeyFor(harness, values) {
|
|
36
38
|
if (harness === 'claude_code') return values.ANTHROPIC_API_KEY || '';
|
|
37
39
|
if (harness === 'codex') return values.OPENAI_API_KEY || values.CODEX_API_KEY || '';
|
|
40
|
+
if (harness === 'pi') return values.AI_GATEWAY_API_KEY || '';
|
|
38
41
|
return '';
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -53,7 +56,7 @@ function resolveByokCredential(amalgmDir, { credentialId, harness, modelId }) {
|
|
|
53
56
|
if (credential) return credential;
|
|
54
57
|
if (preferred === 'ai_gateway') return null;
|
|
55
58
|
}
|
|
56
|
-
if (harness === 'opencode' && preferred !== 'ai_gateway') {
|
|
59
|
+
if ((harness === 'opencode' || harness === 'pi') && preferred !== 'ai_gateway') {
|
|
57
60
|
const gatewayCredential = resolveCredential(amalgmDir, { harness, providerKind: 'ai_gateway' });
|
|
58
61
|
if (gatewayCredential) return gatewayCredential;
|
|
59
62
|
}
|
|
@@ -155,7 +158,11 @@ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken
|
|
|
155
158
|
? '/anthropic'
|
|
156
159
|
: harness === 'opencode'
|
|
157
160
|
? '/ai_gateway/v3/ai'
|
|
158
|
-
|
|
161
|
+
// Pi's provider client appends its own API paths (/v1/messages or
|
|
162
|
+
// /v1/chat/completions), which the proxy serves under /ai_gateway.
|
|
163
|
+
: harness === 'pi'
|
|
164
|
+
? '/ai_gateway'
|
|
165
|
+
: '/openai/v1';
|
|
159
166
|
forwardBaseUrl = `${String(proxyBaseUrl || '').replace(/\/$/, '')}${providerPath}`;
|
|
160
167
|
baseUrl = `${String(localBaseUrl || '').replace(/\/$/, '')}/egress/${encodeURIComponent(sessionId)}${providerPath}`;
|
|
161
168
|
tokenRef = `session-local-${fingerprint(`${sessionId}:${proxyToken}`) || 'token'}`;
|
|
@@ -167,7 +174,13 @@ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken
|
|
|
167
174
|
throw new Error(`No BYOK credential configured for ${harness}`);
|
|
168
175
|
}
|
|
169
176
|
tokenFingerprint = fingerprint(tokenRef);
|
|
170
|
-
baseUrl = credential?.baseUrl || (
|
|
177
|
+
baseUrl = credential?.baseUrl || (
|
|
178
|
+
harness === 'claude_code'
|
|
179
|
+
? 'https://api.anthropic.com'
|
|
180
|
+
: harness === 'pi'
|
|
181
|
+
? 'https://ai-gateway.vercel.sh'
|
|
182
|
+
: 'https://api.openai.com/v1'
|
|
183
|
+
);
|
|
171
184
|
} else if (method === 'provider_auth') {
|
|
172
185
|
tokenFingerprint = providerAuthFingerprint(harness);
|
|
173
186
|
}
|
|
@@ -229,6 +242,10 @@ function runtimeEnv(contract, baseEnv = process.env) {
|
|
|
229
242
|
} else if (contract.harness === 'opencode') {
|
|
230
243
|
env.AI_GATEWAY_API_KEY = contract.auth.tokenRef || '';
|
|
231
244
|
if (contract.auth.baseUrl) env.AI_GATEWAY_BASE_URL = contract.auth.baseUrl;
|
|
245
|
+
} else if (contract.harness === 'pi') {
|
|
246
|
+
// Pi keys the vercel-ai-gateway provider on this env var; base-URL
|
|
247
|
+
// overrides ride through the generated egress extension instead.
|
|
248
|
+
env.AI_GATEWAY_API_KEY = contract.auth.tokenRef || '';
|
|
232
249
|
} else {
|
|
233
250
|
env.OPENAI_API_KEY = contract.auth.tokenRef || '';
|
|
234
251
|
if (contract.auth.baseUrl) env.OPENAI_BASE_URL = contract.auth.baseUrl;
|
|
@@ -25,6 +25,8 @@ function uniqueStrings(value) {
|
|
|
25
25
|
function harnessToAgent(harness) {
|
|
26
26
|
if (harness === 'codex') return 'codex';
|
|
27
27
|
if (harness === 'opencode') return 'opencode';
|
|
28
|
+
if (harness === 'cursor') return 'cursor';
|
|
29
|
+
if (harness === 'pi') return 'pi';
|
|
28
30
|
return 'claude';
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -14,10 +14,52 @@ function agentToHarness(agent) {
|
|
|
14
14
|
if (clean === 'claude') return 'claude_code';
|
|
15
15
|
if (clean === 'codex') return 'codex';
|
|
16
16
|
if (clean === 'opencode') return 'opencode';
|
|
17
|
+
if (clean === 'cursor') return 'cursor';
|
|
18
|
+
if (clean === 'pi') return 'pi';
|
|
17
19
|
if (clean === 'claude_code') return 'claude_code';
|
|
18
20
|
return clean || 'claude_code';
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// First-party providers on the Vercel AI Gateway (multi-word first so the
|
|
24
|
+
// dash-separated legacy ids match longest-prefix).
|
|
25
|
+
const GATEWAY_PROVIDERS = [
|
|
26
|
+
'prime-intellect',
|
|
27
|
+
'arcee-ai',
|
|
28
|
+
'alibaba',
|
|
29
|
+
'amazon',
|
|
30
|
+
'anthropic',
|
|
31
|
+
'bytedance',
|
|
32
|
+
'cohere',
|
|
33
|
+
'cursor',
|
|
34
|
+
'deepseek',
|
|
35
|
+
'google',
|
|
36
|
+
'inception',
|
|
37
|
+
'kwaipilot',
|
|
38
|
+
'meituan',
|
|
39
|
+
'meta',
|
|
40
|
+
'minimax',
|
|
41
|
+
'mistral',
|
|
42
|
+
'moonshotai',
|
|
43
|
+
'morph',
|
|
44
|
+
'nvidia',
|
|
45
|
+
'openai',
|
|
46
|
+
'perplexity',
|
|
47
|
+
'xiaomi',
|
|
48
|
+
'xai',
|
|
49
|
+
'zai',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// 'pi-anthropic-claude-sonnet-4.6' / 'vercel/anthropic/x' -> 'anthropic/claude-sonnet-4.6'
|
|
53
|
+
function gatewayModelId(raw) {
|
|
54
|
+
let bare = String(raw || '').replace(/^pi-/, '').replace(/^vercel\//, '');
|
|
55
|
+
if (bare.includes('/')) return bare;
|
|
56
|
+
for (const provider of GATEWAY_PROVIDERS) {
|
|
57
|
+
const prefix = `${provider}-`;
|
|
58
|
+
if (bare.startsWith(prefix)) return `${provider}/${bare.slice(prefix.length)}`;
|
|
59
|
+
}
|
|
60
|
+
return bare;
|
|
61
|
+
}
|
|
62
|
+
|
|
21
63
|
function canonicalModel(modelId, harness) {
|
|
22
64
|
const raw = String(modelId || '').trim();
|
|
23
65
|
if (!raw) {
|
|
@@ -102,6 +144,11 @@ function canonicalModel(modelId, harness) {
|
|
|
102
144
|
}
|
|
103
145
|
return raw;
|
|
104
146
|
}
|
|
147
|
+
// Cursor is provider_auth-only and reports no usage, so the model id is a
|
|
148
|
+
// display/runtime identity, not a billing identity; keep it as sent.
|
|
149
|
+
if (harness === 'cursor') return raw;
|
|
150
|
+
// Pi bills against the gateway model identity ('anthropic/claude-sonnet-4.6').
|
|
151
|
+
if (harness === 'pi') return gatewayModelId(raw);
|
|
105
152
|
if (raw.startsWith('opencode-openai-')) return `openai/${raw.slice('opencode-openai-'.length)}`;
|
|
106
153
|
if (raw.startsWith('codex-openai-')) return `openai/${raw.slice('codex-openai-'.length)}`;
|
|
107
154
|
if (raw.startsWith('claude-')) return `anthropic/${raw}`;
|
|
@@ -148,6 +195,14 @@ function cliModelFor({ harness, modelId, cliModel, reasoningEffort }) {
|
|
|
148
195
|
}
|
|
149
196
|
return effort ? `${base}/${effort}` : base;
|
|
150
197
|
}
|
|
198
|
+
if (harness === 'cursor') {
|
|
199
|
+
// The adapter matches this against cursor-agent's ACP availableModels.
|
|
200
|
+
return clean.replace(/^cursor\//, '');
|
|
201
|
+
}
|
|
202
|
+
if (harness === 'pi') {
|
|
203
|
+
// Pi's vercel-ai-gateway provider takes bare '<provider>/<model>' ids.
|
|
204
|
+
return gatewayModelId(clean);
|
|
205
|
+
}
|
|
151
206
|
if (harness === 'opencode') {
|
|
152
207
|
const bare = clean.replace(/^opencode-/, '');
|
|
153
208
|
const gatewayProviders = [
|
|
@@ -398,7 +453,7 @@ function createContract(payload, options = {}) {
|
|
|
398
453
|
const assistantMessageId = payload.assistantMessageId;
|
|
399
454
|
if (!assistantMessageId) throw new Error('assistantMessageId is required');
|
|
400
455
|
const harness = agentToHarness(payload.harness || payload.agentId || payload.agent);
|
|
401
|
-
if (!['claude_code', 'codex', 'opencode'].includes(harness)) {
|
|
456
|
+
if (!['claude_code', 'codex', 'opencode', 'cursor', 'pi'].includes(harness)) {
|
|
402
457
|
throw new Error(`Unsupported harness for clean chat core: ${harness}`);
|
|
403
458
|
}
|
|
404
459
|
const authMethod = coerceAuth(harness, payload.authMethod || 'amalgm');
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
errorEvent,
|
|
5
|
+
reasoningDelta,
|
|
6
|
+
textDelta,
|
|
7
|
+
toolCompleted,
|
|
8
|
+
toolStarted,
|
|
9
|
+
toolUpdated,
|
|
10
|
+
usageFinal,
|
|
11
|
+
} = require('../events');
|
|
12
|
+
|
|
13
|
+
// ACP tool kinds -> amalgm tool kinds (tool-shape.js vocabulary).
|
|
14
|
+
const ACP_TOOL_KINDS = {
|
|
15
|
+
read: 'file_read',
|
|
16
|
+
edit: 'file_edit',
|
|
17
|
+
delete: 'file_edit',
|
|
18
|
+
move: 'file_write',
|
|
19
|
+
search: 'search',
|
|
20
|
+
execute: 'bash',
|
|
21
|
+
fetch: 'browser',
|
|
22
|
+
think: 'unknown',
|
|
23
|
+
switch_mode: 'unknown',
|
|
24
|
+
other: 'unknown',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ACP PromptResponse stopReason -> amalgm done stopReason.
|
|
28
|
+
function cursorStopReason(stopReason) {
|
|
29
|
+
const clean = String(stopReason || '').trim();
|
|
30
|
+
if (!clean || clean === 'end_turn') return 'end_turn';
|
|
31
|
+
if (clean === 'cancelled') return 'cancelled';
|
|
32
|
+
if (clean === 'refusal') return 'refusal';
|
|
33
|
+
if (clean === 'max_tokens' || clean === 'max_turn_requests') return clean;
|
|
34
|
+
return 'end_turn';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function contentText(content) {
|
|
38
|
+
if (!content || typeof content !== 'object') return '';
|
|
39
|
+
if (typeof content.text === 'string') return content.text;
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ACP ToolCallContent[] -> a renderable output value.
|
|
44
|
+
function toolOutput(content) {
|
|
45
|
+
if (!Array.isArray(content) || content.length === 0) return undefined;
|
|
46
|
+
const texts = [];
|
|
47
|
+
const diffs = [];
|
|
48
|
+
for (const item of content) {
|
|
49
|
+
if (!item || typeof item !== 'object') continue;
|
|
50
|
+
if (item.type === 'diff') {
|
|
51
|
+
diffs.push({ path: item.path, oldText: item.oldText ?? null, newText: item.newText ?? '' });
|
|
52
|
+
} else if (item.type === 'content') {
|
|
53
|
+
const text = contentText(item.content);
|
|
54
|
+
if (text) texts.push(text);
|
|
55
|
+
} else if (item.type === 'terminal' && item.terminalId) {
|
|
56
|
+
texts.push(`[terminal ${item.terminalId}]`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (diffs.length && texts.length) return { diffs, text: texts.join('\n') };
|
|
60
|
+
if (diffs.length) return diffs.length === 1 ? diffs[0] : { diffs };
|
|
61
|
+
if (texts.length) return texts.join('\n');
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rawOutputValue(rawOutput) {
|
|
66
|
+
if (rawOutput === undefined || rawOutput === null) return undefined;
|
|
67
|
+
if (typeof rawOutput !== 'object') return rawOutput;
|
|
68
|
+
if (typeof rawOutput.stdout === 'string' || typeof rawOutput.stderr === 'string') {
|
|
69
|
+
const text = [rawOutput.stdout, rawOutput.stderr].filter(Boolean).join('\n').trimEnd();
|
|
70
|
+
return {
|
|
71
|
+
...(text ? { text } : {}),
|
|
72
|
+
...(rawOutput.exitCode !== undefined ? { exitCode: rawOutput.exitCode } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return rawOutput;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toolData(state, toolCallId, update = {}) {
|
|
79
|
+
const known = state.tools.get(toolCallId) || {};
|
|
80
|
+
const next = {
|
|
81
|
+
toolName: update.title || known.toolName || 'Tool',
|
|
82
|
+
kind: ACP_TOOL_KINDS[update.kind] || known.kind || undefined,
|
|
83
|
+
input: update.rawInput !== undefined ? update.rawInput : known.input,
|
|
84
|
+
};
|
|
85
|
+
state.tools.set(toolCallId, next);
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One ACP session/update notification -> amalgm events. `state` carries
|
|
90
|
+
// providerSessionId and per-turn tool bookkeeping; it is created per prompt.
|
|
91
|
+
function normalizeCursorUpdate(update, state) {
|
|
92
|
+
const base = { providerSessionId: state.providerSessionId };
|
|
93
|
+
const kind = update?.sessionUpdate;
|
|
94
|
+
if (kind === 'agent_message_chunk') {
|
|
95
|
+
const text = contentText(update.content);
|
|
96
|
+
return text ? [textDelta(text, base)] : [];
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'agent_thought_chunk') {
|
|
99
|
+
const text = contentText(update.content);
|
|
100
|
+
return text ? [reasoningDelta(text, base)] : [];
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'tool_call') {
|
|
103
|
+
const data = toolData(state, update.toolCallId, update);
|
|
104
|
+
return [toolStarted(update.toolCallId, { ...base, ...data })];
|
|
105
|
+
}
|
|
106
|
+
if (kind === 'tool_call_update') {
|
|
107
|
+
const data = toolData(state, update.toolCallId, update);
|
|
108
|
+
// Shell/execute results arrive as rawOutput ({exitCode, stdout, stderr});
|
|
109
|
+
// file edits arrive as content diff items.
|
|
110
|
+
const output = toolOutput(update.content) ?? rawOutputValue(update.rawOutput);
|
|
111
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
112
|
+
state.tools.delete(update.toolCallId);
|
|
113
|
+
return [toolCompleted(update.toolCallId, {
|
|
114
|
+
...base,
|
|
115
|
+
...data,
|
|
116
|
+
...(output !== undefined ? { output } : {}),
|
|
117
|
+
isError: update.status === 'failed',
|
|
118
|
+
...(update.status === 'failed' ? { error: output || 'Tool call failed' } : {}),
|
|
119
|
+
})];
|
|
120
|
+
}
|
|
121
|
+
return [toolUpdated(update.toolCallId, {
|
|
122
|
+
...base,
|
|
123
|
+
toolName: data.toolName,
|
|
124
|
+
kind: data.kind,
|
|
125
|
+
...(update.rawInput !== undefined ? { inputDelta: update.rawInput } : {}),
|
|
126
|
+
...(output !== undefined ? { output } : {}),
|
|
127
|
+
})];
|
|
128
|
+
}
|
|
129
|
+
if (kind === 'usage_update') {
|
|
130
|
+
// Cursor does not emit these today; mapped so context tracking lights up
|
|
131
|
+
// the moment the agent ships the ACP session-usage extension.
|
|
132
|
+
return [usageFinal({
|
|
133
|
+
usedTokens: update.used,
|
|
134
|
+
maxTokens: update.size,
|
|
135
|
+
costUsd: update.cost?.amount ?? null,
|
|
136
|
+
operation: 'context_snapshot',
|
|
137
|
+
billable: false,
|
|
138
|
+
exact: false,
|
|
139
|
+
source: 'cursor_acp_usage_update',
|
|
140
|
+
}, base)];
|
|
141
|
+
}
|
|
142
|
+
if (kind === 'error') {
|
|
143
|
+
return [errorEvent(update.message || 'Cursor agent error', base)];
|
|
144
|
+
}
|
|
145
|
+
// Intentionally ignored: user_message_chunk (session/load replay),
|
|
146
|
+
// session_info_update (amalgm owns titles), available_commands_update,
|
|
147
|
+
// current_mode_update, config_options_update, plan.
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ACP PromptResponse.usage (session-usage RFD) -> exact turn usage. Cursor does
|
|
152
|
+
// not populate this today; the mapping is here for when it does.
|
|
153
|
+
function cursorPromptUsage(usage, base = {}) {
|
|
154
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
155
|
+
const total = Number(usage.total_tokens ?? usage.totalTokens);
|
|
156
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
157
|
+
return usageFinal({
|
|
158
|
+
inputTokens: usage.input_tokens ?? usage.inputTokens,
|
|
159
|
+
outputTokens: usage.output_tokens ?? usage.outputTokens,
|
|
160
|
+
cacheReadTokens: usage.cached_read_tokens ?? usage.cacheReadTokens,
|
|
161
|
+
cacheWriteTokens: usage.cached_write_tokens ?? usage.cacheWriteTokens,
|
|
162
|
+
thoughtTokens: usage.thought_tokens ?? usage.thoughtTokens,
|
|
163
|
+
operation: 'chat_turn',
|
|
164
|
+
billable: true,
|
|
165
|
+
exact: true,
|
|
166
|
+
source: 'cursor_acp_prompt_usage',
|
|
167
|
+
}, base);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
cursorPromptUsage,
|
|
172
|
+
cursorStopReason,
|
|
173
|
+
normalizeCursorUpdate,
|
|
174
|
+
};
|
|
@@ -21,6 +21,44 @@ Cell legend: ✅ handled · ⚠️ handled, with gaps · ❌ dropped · `TBD` no
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## Cursor (ACP) — added 2026-07-01
|
|
25
|
+
|
|
26
|
+
Cursor rides the generic ACP client (`../adapters/acp-client.js`) + `cursor.js` in this dir. Because amalgm's event vocabulary already mirrors ACP `session/update`, the normalizer is a thin shim rather than a per-event translation table:
|
|
27
|
+
|
|
28
|
+
| ACP | Amalgm | Notes |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `agent_message_chunk` | `text.delta` | ✅ |
|
|
31
|
+
| `agent_thought_chunk` | `reasoning.delta` | ✅ (composer-2.5 emits these) |
|
|
32
|
+
| `tool_call` / `tool_call_update` | `tool.started/updated/completed` | ✅ ACP kinds map to amalgm kinds (`execute`→`bash`, `edit`→`file_edit`, …); shell results arrive in `rawOutput` `{exitCode, stdout, stderr}`, file edits as `content` diff items |
|
|
33
|
+
| `session/request_permission` | auto-answered `allow_always` (parity with bypassPermissions) | ⚠️ not yet surfaced as a first-class `permission_request` event |
|
|
34
|
+
| `PromptResponse.stopReason` | `done` | ✅ `end_turn`/`cancelled`/`refusal`/`max_tokens` |
|
|
35
|
+
| `usage_update` / `PromptResponse.usage` | `usage.final` | ⚠️ mapped but **cursor emits neither today** — no token usage exists for this harness; snapshots are `billable:false, exact:false`, prompt usage would be `exact:true chat_turn` the day Cursor ships the ACP session-usage extension |
|
|
36
|
+
| `session_info_update`, `available_commands_update`, `plan`, `current_mode_update`, `user_message_chunk` (session/load replay) | dropped | ❌ intentional |
|
|
37
|
+
|
|
38
|
+
Sessions: one `cursor-agent acp` process per amalgm session; resume = `session/load` (full history replay is discarded — nothing subscribes during `create`). Model selection: `session/set_model` accepts ONLY exact ids from `availableModels` (base + bracket params); `matchAcpModel` resolves amalgm cliModels (bare base names, legacy effort/speed suffixes stripped) to those ids. Auth: provider_auth only — the login token lives in the macOS Keychain, so the managed home gets a `Library/Keychains` alias (`syncCursorProviderAuth`).
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Pi (native RPC) — added 2026-07-01
|
|
43
|
+
|
|
44
|
+
Pi speaks its own JSONL RPC (`pi --mode rpc`, NOT JSON-RPC: commands `{id, type}`, responses `{type:'response'}`, un-id'd events). Adapter: `../adapters/pi.js` (`PiRpc` mirrors codex's `JsonLineRpc` pattern); normalizer: `pi.js` in this dir.
|
|
45
|
+
|
|
46
|
+
| Pi RPC | Amalgm | Notes |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `message_update` `text_delta` / `thinking_delta` | `text.delta` / `reasoning.delta` | ✅ |
|
|
49
|
+
| `message_update` error (reason ≠ aborted) | `error` | ✅ from `message.errorMessage` |
|
|
50
|
+
| `tool_execution_start/update/end` | `tool.started/updated/completed` | ✅ update carries accumulated output, not deltas |
|
|
51
|
+
| `message_end` (assistant) | `usage.final` `chat_step` `exact:true` | ✅ **best usage of any harness**: input/output/cacheRead/cacheWrite + native USD `cost.total` per message |
|
|
52
|
+
| `get_session_stats` after `agent_end` | `usage.final` `context_snapshot` `billable:false` | ✅ context circle from `contextUsage.tokens/contextWindow` |
|
|
53
|
+
| `compaction_start/end` | `compaction.started/finished` | ✅ `tokensBefore` → preTokens |
|
|
54
|
+
| `auto_retry_start` / `extension_error` | `warning` | ✅ |
|
|
55
|
+
| `agent_end` | `done` | ✅ adapter-driven so the final context snapshot lands first |
|
|
56
|
+
| `toolcall_*` deltas, turn/queue events | dropped | ❌ intentional (steer/follow_up unused so far) |
|
|
57
|
+
|
|
58
|
+
Billing: `finalizeTurnUsage` has a pi branch — turn = sum of exact `chat_step` records, native gateway cost summed into `costUsd`/`marketCostUsd` (no catalog re-derivation). Sessions: append-only files under `<home>/.pi/agent/sessions/`; providerSessionId = session FILE PATH, resume = spawn with `--session <path>`. Models: built-in `vercel-ai-gateway` provider (`AI_GATEWAY_API_KEY`), cliModel = bare `<provider>/<model>` gateway id. Auth: all three — amalgm rides a generated extension (`.pi/amalgm-egress-extension.js`) that re-points the provider baseUrl at `/egress/<sid>/ai_gateway` (pi appends `/v1/messages` itself, which the proxy parses for usage); byok uses ai_gateway credentials; provider_auth copies `~/.pi/agent/{auth,models,settings}.json`. No MCP: pi's tools are built-in read/bash/edit/write (+ extensions) — MCP relay does not apply yet.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
24
62
|
## 1. Text parts
|
|
25
63
|
|
|
26
64
|
Streamed assistant text. The user-visible response.
|