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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/runtime/lib/harnesses.js +61 -162
  3. package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
  4. package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
  5. package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
  6. package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
  7. package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
  8. package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
  9. package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
  10. package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
  11. package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
  13. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
  14. package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
  15. package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
  16. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  17. package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
  18. package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
  19. package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
  20. package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
  21. package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
  22. package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
  24. package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
  25. package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
  26. package/runtime/scripts/chat-core/adapters/pi.js +302 -0
  27. package/runtime/scripts/chat-core/auth.js +20 -3
  28. package/runtime/scripts/chat-core/chat-payload.js +2 -0
  29. package/runtime/scripts/chat-core/contract.js +56 -1
  30. package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
  31. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
  32. package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
  33. package/runtime/scripts/chat-core/server.js +4 -0
  34. package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
  35. package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
  36. package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
  37. 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
- : '/openai/v1';
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 || (harness === 'claude_code' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1');
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.