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
|
@@ -53,6 +53,10 @@ function writeReleaseFiles(lane, version, mtimeOffsetMs = 0) {
|
|
|
53
53
|
|
|
54
54
|
test('desktop release server resolves stable bridge download targets from metadata', () => {
|
|
55
55
|
const artifacts = [
|
|
56
|
+
'amalgm-thin-0.1.1-arm64-mac.zip',
|
|
57
|
+
'amalgm-thin-0.1.1-arm64-mac.zip.blockmap',
|
|
58
|
+
'amalgm-thin-0.1.1-arm64.dmg',
|
|
59
|
+
'amalgm-thin-0.1.1-arm64.dmg.blockmap',
|
|
56
60
|
'amalgm-0.1.1-arm64-mac.zip',
|
|
57
61
|
'amalgm-0.1.1-arm64-mac.zip.blockmap',
|
|
58
62
|
'amalgm-0.1.1-arm64.dmg',
|
|
@@ -65,10 +69,10 @@ test('desktop release server resolves stable bridge download targets from metada
|
|
|
65
69
|
assert.deepEqual(parseDownloadPath('/main/download/zip'), { lane: 'main', kind: 'zip' });
|
|
66
70
|
assert.deepEqual(parseDownloadPath('/main/latest.dmg'), { lane: 'main', kind: 'dmg' });
|
|
67
71
|
assert.equal(parseDownloadPath('/main/latest-mac.yml'), null);
|
|
68
|
-
assert.equal(latestArtifactName({ artifacts }, 'dmg'), 'amalgm-0.1.1-arm64.dmg');
|
|
72
|
+
assert.equal(latestArtifactName({ artifacts }, 'dmg'), 'amalgm-thin-0.1.1-arm64.dmg');
|
|
69
73
|
assert.deepEqual(latestDownloadTarget('main', 'zip'), {
|
|
70
74
|
metadata: { lane: 'main', artifacts },
|
|
71
|
-
artifactName: 'amalgm-0.1.1-arm64-mac.zip',
|
|
75
|
+
artifactName: 'amalgm-thin-0.1.1-arm64-mac.zip',
|
|
72
76
|
});
|
|
73
77
|
});
|
|
74
78
|
|
|
@@ -81,6 +85,10 @@ test('desktop release server groups versioned release artifacts', () => {
|
|
|
81
85
|
releaseKeyForArtifact('amalgm-preview-0.1.139882282-preview.1-arm64.dmg'),
|
|
82
86
|
'amalgm-preview@0.1.139882282-preview.1',
|
|
83
87
|
);
|
|
88
|
+
assert.equal(
|
|
89
|
+
releaseKeyForArtifact('amalgm-preview-thin-0.1.139882282-preview.1-arm64.dmg'),
|
|
90
|
+
'amalgm-preview-thin@0.1.139882282-preview.1',
|
|
91
|
+
);
|
|
84
92
|
assert.equal(releaseKeyForArtifact('latest-mac.yml'), '');
|
|
85
93
|
});
|
|
86
94
|
|
|
@@ -113,6 +121,8 @@ test('desktop release server prunes stale lane artifacts after publish', () => {
|
|
|
113
121
|
|
|
114
122
|
test('desktop release server redirects public bridge download URLs to current assets', async () => {
|
|
115
123
|
writeReleaseMetadata('preview', [
|
|
124
|
+
'amalgm-preview-thin-0.1.1-preview.1-arm64.zip',
|
|
125
|
+
'amalgm-preview-thin-0.1.1-preview.1-arm64.dmg',
|
|
116
126
|
'amalgm-preview-0.1.1-preview.1-arm64.zip',
|
|
117
127
|
'amalgm-preview-0.1.1-preview.1-arm64.dmg',
|
|
118
128
|
'preview-mac.yml',
|
|
@@ -127,7 +137,7 @@ test('desktop release server redirects public bridge download URLs to current as
|
|
|
127
137
|
assert.equal(response.status, 302);
|
|
128
138
|
assert.equal(
|
|
129
139
|
response.headers.get('location'),
|
|
130
|
-
`http://127.0.0.1:${port}/preview/amalgm-preview-0.1.1-preview.1-arm64.dmg`,
|
|
140
|
+
`http://127.0.0.1:${port}/preview/amalgm-preview-thin-0.1.1-preview.1-arm64.dmg`,
|
|
131
141
|
);
|
|
132
142
|
|
|
133
143
|
const zipResponse = await fetch(`http://127.0.0.1:${port}/preview/latest.zip`, {
|
|
@@ -137,7 +147,7 @@ test('desktop release server redirects public bridge download URLs to current as
|
|
|
137
147
|
assert.equal(zipResponse.status, 302);
|
|
138
148
|
assert.equal(
|
|
139
149
|
zipResponse.headers.get('location'),
|
|
140
|
-
`http://127.0.0.1:${port}/preview/amalgm-preview-0.1.1-preview.1-arm64.zip`,
|
|
150
|
+
`http://127.0.0.1:${port}/preview/amalgm-preview-thin-0.1.1-preview.1-arm64.zip`,
|
|
141
151
|
);
|
|
142
152
|
|
|
143
153
|
const forwardedResponse = await fetch(`http://127.0.0.1:${port}/preview/download`, {
|
|
@@ -150,7 +160,7 @@ test('desktop release server redirects public bridge download URLs to current as
|
|
|
150
160
|
assert.equal(forwardedResponse.status, 302);
|
|
151
161
|
assert.equal(
|
|
152
162
|
forwardedResponse.headers.get('location'),
|
|
153
|
-
'https://amalgm-desktop-releases.fly.dev/preview/amalgm-preview-0.1.1-preview.1-arm64.dmg',
|
|
163
|
+
'https://amalgm-desktop-releases.fly.dev/preview/amalgm-preview-thin-0.1.1-preview.1-arm64.dmg',
|
|
154
164
|
);
|
|
155
165
|
} finally {
|
|
156
166
|
await new Promise((resolve) => server.close(resolve));
|
|
@@ -83,6 +83,41 @@ test('full entity embed uses chrome identity only, not centered email-only body
|
|
|
83
83
|
assert.doesNotMatch(html, /text-transform:uppercase/);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test('attached video cards use the notification deep link when provided', () => {
|
|
87
|
+
const html = renderMediaEmbed(
|
|
88
|
+
'QA recording',
|
|
89
|
+
'attached:qa-pass.webm',
|
|
90
|
+
'cid:poster-1',
|
|
91
|
+
{ defaultOpenUrl: 'https://amalgm.ai/session/sess-123' },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert.match(html, /https:\/\/amalgm\.ai\/session\/sess-123/);
|
|
95
|
+
assert.doesNotMatch(html, /href="https:\/\/amalgm\.ai"(?!\/session)/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('standalone entity embeds derive canonical session routes from tile ids', () => {
|
|
99
|
+
const html = markdownToEmailHtml('[QA run](tile:chat:sess-123)');
|
|
100
|
+
|
|
101
|
+
assert.match(html, /https:\/\/amalgm\.ai\/session\/sess-123/);
|
|
102
|
+
assert.doesNotMatch(html, /href="https:\/\/amalgm\.ai"(?!\/session)/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('relative app links become absolute email links', () => {
|
|
106
|
+
const html = markdownToEmailHtml('Open [details](/session/sess-123)');
|
|
107
|
+
|
|
108
|
+
assert.match(html, /href="https:\/\/amalgm\.ai\/session\/sess-123"/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('standalone attached media lines keep the provided fallback deep link', () => {
|
|
112
|
+
const html = markdownToEmailHtml(
|
|
113
|
+
'',
|
|
114
|
+
{ defaultOpenUrl: 'https://amalgm.ai/apps/app-03n045jhko' },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
assert.match(html, /https:\/\/amalgm\.ai\/apps\/app-03n045jhko/);
|
|
118
|
+
assert.doesNotMatch(html, /href="https:\/\/amalgm\.ai"(?!\/apps)/);
|
|
119
|
+
});
|
|
120
|
+
|
|
86
121
|
test('file identity prefers explicit filename over path and url', () => {
|
|
87
122
|
const identity = resolveFileEmbedIdentity({
|
|
88
123
|
name: 'renamed.mov',
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal Agent Client Protocol (ACP) client: JSON-RPC 2.0 over the stdio of a
|
|
7
|
+
* spawned agent process. This is the amalgm *client* side; the CLI (cursor-agent,
|
|
8
|
+
* gemini, ...) is the ACP agent. One instance per native agent process.
|
|
9
|
+
*
|
|
10
|
+
* The reader is tolerant of non-JSON stdout lines because young ACP servers are
|
|
11
|
+
* known to corrupt stdout with log output.
|
|
12
|
+
*/
|
|
13
|
+
class AcpClient {
|
|
14
|
+
constructor({ binary, args = [], cwd, env, label = 'acp' }) {
|
|
15
|
+
this.label = label;
|
|
16
|
+
this.id = 1;
|
|
17
|
+
this.pending = new Map();
|
|
18
|
+
this.notificationHandlers = new Set();
|
|
19
|
+
this.requestHandler = null;
|
|
20
|
+
this.buffer = '';
|
|
21
|
+
this.closed = false;
|
|
22
|
+
this.closeError = null;
|
|
23
|
+
this.child = spawn(binary, args, {
|
|
24
|
+
cwd,
|
|
25
|
+
env,
|
|
26
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
27
|
+
detached: process.platform !== 'win32',
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
});
|
|
30
|
+
this.child.stdout.setEncoding('utf8');
|
|
31
|
+
this.child.stdout.on('data', (chunk) => this.onData(chunk));
|
|
32
|
+
this.child.stderr.setEncoding('utf8');
|
|
33
|
+
this.child.stderr.on('data', (chunk) => {
|
|
34
|
+
const text = String(chunk || '').trim();
|
|
35
|
+
if (text && process.env.CHAT_CORE_DEBUG) console.warn(`[${this.label}]`, text.slice(0, 1000));
|
|
36
|
+
});
|
|
37
|
+
this.child.on('error', (err) => this.failAll(err));
|
|
38
|
+
this.child.on('exit', (code, signal) => {
|
|
39
|
+
this.failAll(new Error(`${this.label} agent exited (${code ?? signal ?? 'unknown'})`));
|
|
40
|
+
});
|
|
41
|
+
this.child.stdin.on('error', (err) => this.failAll(err));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
failAll(err) {
|
|
45
|
+
this.closed = true;
|
|
46
|
+
this.closeError = err;
|
|
47
|
+
for (const pending of this.pending.values()) pending.reject(err);
|
|
48
|
+
this.pending.clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onData(chunk) {
|
|
52
|
+
this.buffer += chunk;
|
|
53
|
+
const lines = this.buffer.split('\n');
|
|
54
|
+
this.buffer = lines.pop() || '';
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
let msg;
|
|
58
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
59
|
+
if (process.env.CHAT_CORE_DEBUG_ACP === '1') {
|
|
60
|
+
console.log(`[${this.label}:recv]`, JSON.stringify(msg).slice(0, 4000));
|
|
61
|
+
}
|
|
62
|
+
this.dispatch(msg);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
dispatch(msg) {
|
|
67
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
68
|
+
const pending = this.pending.get(msg.id);
|
|
69
|
+
if (!pending) return;
|
|
70
|
+
this.pending.delete(msg.id);
|
|
71
|
+
if (msg.error) {
|
|
72
|
+
const err = new Error(msg.error.data?.message || msg.error.message || JSON.stringify(msg.error));
|
|
73
|
+
err.code = msg.error.code;
|
|
74
|
+
pending.reject(err);
|
|
75
|
+
} else {
|
|
76
|
+
pending.resolve(msg.result);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (msg.id !== undefined && msg.method) {
|
|
81
|
+
this.handleServerRequest(msg);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (msg.method) {
|
|
85
|
+
for (const handler of this.notificationHandlers) handler(msg);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async handleServerRequest(msg) {
|
|
90
|
+
if (!this.requestHandler) {
|
|
91
|
+
this.write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not supported: ${msg.method}` } });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const result = await this.requestHandler(msg.method, msg.params || {});
|
|
96
|
+
this.write({ jsonrpc: '2.0', id: msg.id, result: result ?? {} });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
this.write({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: err.message || 'Internal error' } });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
write(payload) {
|
|
103
|
+
if (this.closed || this.child.exitCode !== null || this.child.signalCode !== null) return;
|
|
104
|
+
try {
|
|
105
|
+
this.child.stdin.write(`${JSON.stringify(payload)}\n`, (err) => {
|
|
106
|
+
if (err) this.failAll(err);
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.failAll(err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
request(method, params = {}, timeoutMs = 180000) {
|
|
114
|
+
if (this.closeError) return Promise.reject(this.closeError);
|
|
115
|
+
if (this.closed || this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
116
|
+
return Promise.reject(new Error(`${this.label} agent is not running`));
|
|
117
|
+
}
|
|
118
|
+
const id = this.id++;
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const timer = timeoutMs > 0
|
|
121
|
+
? setTimeout(() => {
|
|
122
|
+
this.pending.delete(id);
|
|
123
|
+
reject(new Error(`${method} timed out`));
|
|
124
|
+
}, timeoutMs)
|
|
125
|
+
: null;
|
|
126
|
+
this.pending.set(id, {
|
|
127
|
+
resolve: (value) => { if (timer) clearTimeout(timer); resolve(value); },
|
|
128
|
+
reject: (err) => { if (timer) clearTimeout(timer); reject(err); },
|
|
129
|
+
});
|
|
130
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
notify(method, params) {
|
|
135
|
+
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onNotification(handler) {
|
|
139
|
+
this.notificationHandlers.add(handler);
|
|
140
|
+
return () => this.notificationHandlers.delete(handler);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
onRequest(handler) {
|
|
144
|
+
this.requestHandler = handler;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
close() {
|
|
148
|
+
try { this.child.stdin.end(); } catch {}
|
|
149
|
+
try {
|
|
150
|
+
if (process.platform !== 'win32' && this.child.pid) process.kill(-this.child.pid, 'SIGTERM');
|
|
151
|
+
else this.child.kill('SIGTERM');
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { AcpClient };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { done, errorEvent, warningEvent } = require('../events');
|
|
7
|
+
const { promptText } = require('../input');
|
|
8
|
+
const { cursorPromptUsage, cursorStopReason, normalizeCursorUpdate } = require('../normalizers/cursor');
|
|
9
|
+
const { recordNativeEvent } = require('../recorder');
|
|
10
|
+
const { relayedMcpServers } = require('../tooling/mcp-bundle');
|
|
11
|
+
const { executableExists, findOnPath } = require('../tooling/native-binaries');
|
|
12
|
+
const { prepareHarnessRuntime } = require('../tooling/runtime-home');
|
|
13
|
+
const { composeSystemPrompt } = require('../tooling/system-prompt');
|
|
14
|
+
const { AcpClient } = require('./acp-client');
|
|
15
|
+
|
|
16
|
+
const PROTOCOL_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
function resolveBinary() {
|
|
19
|
+
const binaryName = process.platform === 'win32' ? 'cursor-agent.exe' : 'cursor-agent';
|
|
20
|
+
const homes = [...new Set([process.env.AMALGM_NATIVE_HOME, os.homedir()].filter(Boolean))];
|
|
21
|
+
const candidates = [
|
|
22
|
+
process.env.CURSOR_AGENT_BINARY,
|
|
23
|
+
...homes.map((home) => path.join(home, '.local', 'bin', binaryName)),
|
|
24
|
+
'/opt/homebrew/bin/cursor-agent',
|
|
25
|
+
'/usr/local/bin/cursor-agent',
|
|
26
|
+
findOnPath('cursor-agent'),
|
|
27
|
+
].filter(Boolean);
|
|
28
|
+
const failures = [];
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
if (!executableExists(candidate)) {
|
|
31
|
+
failures.push(`${candidate} (missing or not executable)`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const res = spawnSync(candidate, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
|
|
35
|
+
if (res.status === 0) return candidate;
|
|
36
|
+
failures.push(`${candidate} (--version failed: ${res.error?.message || res.status || res.signal || 'unknown'})`);
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Cursor Agent CLI is not available. Checked: ${failures.join(', ') || 'no candidates'}. Install it with: curl https://cursor.com/install -fsS | bash, then run cursor-agent login.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// cursor-agent's session/set_model only accepts EXACT ids from availableModels
|
|
42
|
+
// (base name + bracket parameters, e.g. "gpt-5.3-codex[reasoning=medium,fast=false]").
|
|
43
|
+
// Amalgm cliModels are bare base names, so match by base and return the exact id.
|
|
44
|
+
function matchAcpModel(availableModels, requested) {
|
|
45
|
+
const models = Array.isArray(availableModels) ? availableModels : [];
|
|
46
|
+
const baseOf = (id) => String(id || '').split('[')[0].trim();
|
|
47
|
+
let clean = String(requested || '').trim().replace(/^cursor\//, '').split('[')[0];
|
|
48
|
+
if (!clean) return null;
|
|
49
|
+
if (clean === 'auto' || clean === 'default') clean = 'default';
|
|
50
|
+
const findBase = (base) => models.find((m) => baseOf(m.modelId) === base)
|
|
51
|
+
|| models.find((m) => String(m.name || '').toLowerCase() === base.toLowerCase());
|
|
52
|
+
let candidate = clean;
|
|
53
|
+
// Legacy amalgm cursor cliModels carry effort/speed suffixes ("gpt-5.3-codex-high",
|
|
54
|
+
// "composer-2.5-fast") that ACP model ids do not; strip them until a base matches.
|
|
55
|
+
for (let i = 0; i < 6; i += 1) {
|
|
56
|
+
const match = findBase(candidate);
|
|
57
|
+
if (match) return match;
|
|
58
|
+
const stripped = candidate.replace(/-(?:fast|thinking|low|medium|high|xhigh|max)$/, '');
|
|
59
|
+
if (stripped === candidate) return null;
|
|
60
|
+
candidate = stripped;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toAcpMcpServers(contract) {
|
|
66
|
+
return relayedMcpServers(contract).map((server) => ({
|
|
67
|
+
type: server.type === 'sse' ? 'sse' : 'http',
|
|
68
|
+
name: server.name,
|
|
69
|
+
url: server.url,
|
|
70
|
+
headers: (server.headers || []).map((header) => ({ name: header.name, value: header.value })),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickPermissionOption(options = []) {
|
|
75
|
+
const byKind = (kind) => options.find((option) => option?.kind === kind);
|
|
76
|
+
return byKind('allow_always') || byKind('allow_once') || options[0] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class CursorAdapter {
|
|
80
|
+
async create(contract) {
|
|
81
|
+
const runtime = prepareHarnessRuntime(contract);
|
|
82
|
+
const binary = resolveBinary();
|
|
83
|
+
const client = new AcpClient({
|
|
84
|
+
binary,
|
|
85
|
+
args: ['acp'],
|
|
86
|
+
cwd: contract.cwd,
|
|
87
|
+
env: runtime.env,
|
|
88
|
+
label: 'Cursor',
|
|
89
|
+
});
|
|
90
|
+
const session = {
|
|
91
|
+
sessionId: contract.sessionId,
|
|
92
|
+
providerSessionId: contract.providerSessionId || null,
|
|
93
|
+
client,
|
|
94
|
+
needsSystemPrompt: false,
|
|
95
|
+
modelWarning: null,
|
|
96
|
+
activePrompt: null,
|
|
97
|
+
cancelRequested: false,
|
|
98
|
+
};
|
|
99
|
+
client.onRequest((method, params) => this.handleAgentRequest(session, method, params));
|
|
100
|
+
await client.request('initialize', {
|
|
101
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
102
|
+
clientInfo: { name: 'amalgm-chat-core', version: '1.0.0' },
|
|
103
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
104
|
+
});
|
|
105
|
+
const sessionParams = { cwd: contract.cwd, mcpServers: toAcpMcpServers(contract) };
|
|
106
|
+
let acpSession = null;
|
|
107
|
+
if (session.providerSessionId) {
|
|
108
|
+
// session/load replays history as session/update notifications; nothing is
|
|
109
|
+
// subscribed yet, so the replay is discarded and the turn starts clean.
|
|
110
|
+
acpSession = await client.request('session/load', {
|
|
111
|
+
sessionId: session.providerSessionId,
|
|
112
|
+
...sessionParams,
|
|
113
|
+
}, 600000).catch((err) => {
|
|
114
|
+
console.warn('[Cursor] Failed to load native session; starting a new one:', err.message);
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!acpSession) {
|
|
119
|
+
acpSession = await client.request('session/new', sessionParams);
|
|
120
|
+
session.providerSessionId = acpSession.sessionId;
|
|
121
|
+
session.needsSystemPrompt = true;
|
|
122
|
+
}
|
|
123
|
+
await this.selectModel(session, acpSession, contract);
|
|
124
|
+
return session;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async selectModel(session, acpSession, contract) {
|
|
128
|
+
const requested = contract.cliModel || contract.modelId;
|
|
129
|
+
const models = acpSession?.models || {};
|
|
130
|
+
const match = matchAcpModel(models.availableModels, requested);
|
|
131
|
+
if (!match) {
|
|
132
|
+
session.modelWarning = `Cursor Agent does not offer "${requested}"; continuing with ${models.currentModelId || 'its default model'}`;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (match.modelId === models.currentModelId) return;
|
|
136
|
+
try {
|
|
137
|
+
await session.client.request('session/set_model', {
|
|
138
|
+
sessionId: session.providerSessionId,
|
|
139
|
+
modelId: match.modelId,
|
|
140
|
+
}, 30000);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
session.modelWarning = `Cursor Agent rejected model "${match.modelId}": ${err.message}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async handleAgentRequest(session, method, params) {
|
|
147
|
+
recordNativeEvent('cursor.acp.request', { method, params }, {
|
|
148
|
+
providerSessionId: session.providerSessionId,
|
|
149
|
+
sessionId: session.sessionId,
|
|
150
|
+
});
|
|
151
|
+
if (method === 'session/request_permission') {
|
|
152
|
+
if (session.cancelRequested) return { outcome: { outcome: 'cancelled' } };
|
|
153
|
+
const pick = pickPermissionOption(params.options);
|
|
154
|
+
if (!pick) return { outcome: { outcome: 'cancelled' } };
|
|
155
|
+
return { outcome: { outcome: 'selected', optionId: pick.optionId } };
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`Method not supported: ${method}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async resume(session, contract) {
|
|
161
|
+
if (session.client && !session.client.closed) return session;
|
|
162
|
+
return this.create({ ...contract, providerSessionId: session.providerSessionId || contract.providerSessionId });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async stop(session) {
|
|
166
|
+
session.cancelRequested = true;
|
|
167
|
+
if (!session.activePrompt) return;
|
|
168
|
+
session.client.notify('session/cancel', { sessionId: session.providerSessionId });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async destroy(session) {
|
|
172
|
+
session.client.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async *prompt(session, input, contract) {
|
|
176
|
+
const queue = [];
|
|
177
|
+
let complete = false;
|
|
178
|
+
let wake = null;
|
|
179
|
+
const wakeLoop = () => { if (wake) { wake(); wake = null; } };
|
|
180
|
+
const push = (e) => {
|
|
181
|
+
if (e.type === 'done' || e.type === 'error') complete = true;
|
|
182
|
+
queue.push(e);
|
|
183
|
+
wakeLoop();
|
|
184
|
+
};
|
|
185
|
+
const state = { providerSessionId: session.providerSessionId, tools: new Map() };
|
|
186
|
+
session.cancelRequested = false;
|
|
187
|
+
session.activePrompt = state;
|
|
188
|
+
if (session.modelWarning) {
|
|
189
|
+
push(warningEvent(session.modelWarning, { providerSessionId: session.providerSessionId }));
|
|
190
|
+
session.modelWarning = null;
|
|
191
|
+
}
|
|
192
|
+
const off = session.client.onNotification((msg) => {
|
|
193
|
+
if (msg.method !== 'session/update') return;
|
|
194
|
+
const params = msg.params || {};
|
|
195
|
+
if (params.sessionId && params.sessionId !== session.providerSessionId) return;
|
|
196
|
+
recordNativeEvent('cursor.acp.update', msg, {
|
|
197
|
+
providerSessionId: session.providerSessionId,
|
|
198
|
+
sessionId: contract.sessionId,
|
|
199
|
+
assistantMessageId: contract.assistantMessageId,
|
|
200
|
+
updateKind: params.update?.sessionUpdate,
|
|
201
|
+
});
|
|
202
|
+
for (const e of normalizeCursorUpdate(params.update || {}, state)) push(e);
|
|
203
|
+
});
|
|
204
|
+
let text = promptText(input, contract);
|
|
205
|
+
if (session.needsSystemPrompt) {
|
|
206
|
+
// ACP has no system-prompt slot; a fresh native session gets the composed
|
|
207
|
+
// instructions as a preamble on its first prompt only.
|
|
208
|
+
const systemPrompt = composeSystemPrompt(contract);
|
|
209
|
+
if (systemPrompt) text = `<system-instructions>\n${systemPrompt}\n</system-instructions>\n\n${text}`;
|
|
210
|
+
session.needsSystemPrompt = false;
|
|
211
|
+
}
|
|
212
|
+
session.client.request('session/prompt', {
|
|
213
|
+
sessionId: session.providerSessionId,
|
|
214
|
+
prompt: [{ type: 'text', text }],
|
|
215
|
+
}, 0).then((result) => {
|
|
216
|
+
recordNativeEvent('cursor.acp.prompt_result', result, {
|
|
217
|
+
providerSessionId: session.providerSessionId,
|
|
218
|
+
sessionId: contract.sessionId,
|
|
219
|
+
assistantMessageId: contract.assistantMessageId,
|
|
220
|
+
});
|
|
221
|
+
const usage = cursorPromptUsage(result?.usage, { providerSessionId: session.providerSessionId });
|
|
222
|
+
if (usage) push(usage);
|
|
223
|
+
const stopReason = session.cancelRequested ? 'cancelled' : cursorStopReason(result?.stopReason);
|
|
224
|
+
push(done({ providerSessionId: session.providerSessionId, stopReason }));
|
|
225
|
+
}).catch((err) => {
|
|
226
|
+
if (session.cancelRequested) {
|
|
227
|
+
push(done({ providerSessionId: session.providerSessionId, stopReason: 'cancelled' }));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
push(errorEvent(err.message, { providerSessionId: session.providerSessionId }));
|
|
231
|
+
push(done({ providerSessionId: session.providerSessionId, stopReason: 'error' }));
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
while (!complete || queue.length) {
|
|
235
|
+
if (!queue.length) await new Promise((resolve) => { wake = resolve; });
|
|
236
|
+
else yield queue.shift();
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
off();
|
|
240
|
+
session.activePrompt = null;
|
|
241
|
+
session.cancelRequested = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
CursorAdapter,
|
|
248
|
+
__private: {
|
|
249
|
+
matchAcpModel,
|
|
250
|
+
pickPermissionOption,
|
|
251
|
+
resolveBinary,
|
|
252
|
+
toAcpMcpServers,
|
|
253
|
+
},
|
|
254
|
+
};
|