@gakim-digital/dexter-bridge 0.11.19 → 0.11.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/agent.js +54 -11
- package/src/cli.js +42 -13
- package/src/config.js +64 -3
- package/src/protocol.js +1 -0
- package/src/providers/cursorAcp.js +557 -0
- package/src/providers/index.js +12 -6
- package/src/runtimeProfiles.js +41 -6
package/README.md
CHANGED
|
@@ -166,11 +166,18 @@ for support and automation:
|
|
|
166
166
|
npx @gakim-digital/dexter-bridge pair 123456 --api http://localhost:3800/iwm-api/0.0.1
|
|
167
167
|
npx @gakim-digital/dexter-bridge start --agent claude-code
|
|
168
168
|
npx @gakim-digital/dexter-bridge start --agent codex
|
|
169
|
+
npx @gakim-digital/dexter-bridge start --agent cursor
|
|
169
170
|
npx @gakim-digital/dexter-bridge status
|
|
170
171
|
npx @gakim-digital/dexter-bridge doctor
|
|
171
172
|
npx @gakim-digital/dexter-bridge logout
|
|
172
173
|
```
|
|
173
174
|
|
|
175
|
+
Cursor needs the Cursor CLI signed in on the same computer
|
|
176
|
+
(`curl https://cursor.com/install -fsS | bash`, then `cursor-agent login`).
|
|
177
|
+
The bridge runs `cursor-agent acp` with Cursor's shell, file, and web tools
|
|
178
|
+
denied, so Cursor can use only Dexter's Framer tools. Set
|
|
179
|
+
`DEXTER_BRIDGE_CURSOR_BIN` if `cursor-agent` is installed elsewhere.
|
|
180
|
+
|
|
174
181
|
On Windows, the bridge checks the native Claude Code install at
|
|
175
182
|
`%USERPROFILE%\.local\bin\claude.exe` in addition to `PATH`. Before pairing,
|
|
176
183
|
verify the same PowerShell window can run:
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from './nativeSkills.js';
|
|
53
53
|
import { createLocalAgentAdapter } from './providers/index.js';
|
|
54
54
|
import { discoverClaudeCodeModels } from './providers/claudeAgentSdk.js';
|
|
55
|
+
import { cursorInstallCommand, cursorLauncher, readCursorVersion } from './providers/cursorAcp.js';
|
|
55
56
|
import {
|
|
56
57
|
collectOutcomeWorkspaceChanges,
|
|
57
58
|
materializeModelTurnImages,
|
|
@@ -174,6 +175,15 @@ export const AGENT_DEFINITIONS = {
|
|
|
174
175
|
model: 'codex:gpt-5.5',
|
|
175
176
|
statusStage: 'codex_start',
|
|
176
177
|
},
|
|
178
|
+
cursor: {
|
|
179
|
+
id: 'cursor',
|
|
180
|
+
label: 'Cursor',
|
|
181
|
+
commandEnv: 'DEXTER_BRIDGE_CURSOR_BIN',
|
|
182
|
+
fallbackCommand: 'cursor-agent',
|
|
183
|
+
promptMode: 'stdin',
|
|
184
|
+
model: 'cursor:grok-4.7',
|
|
185
|
+
statusStage: 'cursor_start',
|
|
186
|
+
},
|
|
177
187
|
};
|
|
178
188
|
|
|
179
189
|
export const AGENT_AUTHENTICATION_REQUIRED_CODE = 'DEXTER_AGENT_AUTHENTICATION_REQUIRED';
|
|
@@ -887,7 +897,15 @@ export function processEnvWithCliPath(baseEnv = process.env, platform = process.
|
|
|
887
897
|
baseEnv.APPDATA ? pathApi.join(baseEnv.APPDATA, 'npm') : '',
|
|
888
898
|
baseEnv.LOCALAPPDATA ? pathApi.join(baseEnv.LOCALAPPDATA, 'Programs') : '',
|
|
889
899
|
]
|
|
890
|
-
: [
|
|
900
|
+
: [
|
|
901
|
+
homeDirectory ? pathApi.join(homeDirectory, '.local', 'bin') : '',
|
|
902
|
+
'/opt/homebrew/bin',
|
|
903
|
+
'/usr/local/bin',
|
|
904
|
+
'/usr/bin',
|
|
905
|
+
'/bin',
|
|
906
|
+
'/usr/sbin',
|
|
907
|
+
'/sbin',
|
|
908
|
+
];
|
|
891
909
|
const shellEnv = platform === 'win32' ? {} : loginShellEnv();
|
|
892
910
|
const existingPath = String(baseEnv.PATH || '');
|
|
893
911
|
const mergedPath = mergePathEntries([shellEnv.PATH, existingPath, ...fallbackPath], platform);
|
|
@@ -1416,6 +1434,7 @@ function definitionForRuntimeProfile(profile, fallbackAgent) {
|
|
|
1416
1434
|
if (!profile) return definitionForAgent(fallbackAgent);
|
|
1417
1435
|
if (profile.driver === 'claude-code-cli') return AGENT_DEFINITIONS['claude-code'];
|
|
1418
1436
|
if (profile.driver === 'codex-app-server') return AGENT_DEFINITIONS.codex;
|
|
1437
|
+
if (profile.driver === 'cursor-acp') return AGENT_DEFINITIONS.cursor;
|
|
1419
1438
|
return {
|
|
1420
1439
|
id: profile.id || profile.driver,
|
|
1421
1440
|
label: profile.label || profile.driver || 'Local runtime',
|
|
@@ -1716,6 +1735,26 @@ async function callProviderAdapter(
|
|
|
1716
1735
|
}
|
|
1717
1736
|
}
|
|
1718
1737
|
|
|
1738
|
+
// Cursor starts through its own launcher (on Windows, the bundled node.exe
|
|
1739
|
+
// behind cursor-agent.cmd), so its version is read through that launcher.
|
|
1740
|
+
async function resolveCursorRuntime(options = {}) {
|
|
1741
|
+
const platform = options.platform || process.platform;
|
|
1742
|
+
const launcher = cursorLauncher({
|
|
1743
|
+
env: processEnvWithCliPath(options.env || process.env, platform),
|
|
1744
|
+
platform,
|
|
1745
|
+
});
|
|
1746
|
+
const { error, output } = await readCursorVersion(launcher);
|
|
1747
|
+
if (error) {
|
|
1748
|
+
return {
|
|
1749
|
+
ok: false,
|
|
1750
|
+
command: launcher?.command || 'cursor-agent',
|
|
1751
|
+
code: 'DEXTER_AGENT_NOT_FOUND',
|
|
1752
|
+
error: `Cursor CLI is not installed. Run \`${cursorInstallCommand(platform)}\`, then \`cursor-agent login\`.`,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
return { ok: true, command: launcher.command, output, version: parseAgentVersion(output) };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1719
1758
|
export async function resolveAgentRuntime(definition, modelDefinition, options = {}) {
|
|
1720
1759
|
const platform = options.platform || process.platform;
|
|
1721
1760
|
const childEnv = processEnvWithCliPath(options.env || process.env, platform);
|
|
@@ -3144,7 +3183,8 @@ export async function executeRun(
|
|
|
3144
3183
|
});
|
|
3145
3184
|
const runtimeProfile = run?.runtimeProfile || suppliedRuntimeProfile || null;
|
|
3146
3185
|
const normalizedAgent = normalizeAgentName(
|
|
3147
|
-
run?.companion?.agent
|
|
3186
|
+
run?.companion?.agent
|
|
3187
|
+
|| ({ 'claude-code-cli': 'claude-code', 'cursor-acp': 'cursor' }[runtimeProfile?.driver] || agent),
|
|
3148
3188
|
);
|
|
3149
3189
|
const runModel = runtimeProfile?.model?.id || run?.companion?.model?.id || run?.model || selectedModel;
|
|
3150
3190
|
const usageAccumulator = createCompanionUsageAccumulator(normalizedAgent);
|
|
@@ -3312,12 +3352,14 @@ export async function checkAgentAvailability(agent, model, options = {}) {
|
|
|
3312
3352
|
}
|
|
3313
3353
|
const definition = definitionForAgent(normalizedAgent);
|
|
3314
3354
|
const selectedModel = model ? companionModelDefinition(model?.id || model, normalizedAgent) : undefined;
|
|
3315
|
-
const runtime =
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3355
|
+
const runtime = normalizedAgent === 'cursor' && !options.inspectRuntime
|
|
3356
|
+
? await resolveCursorRuntime(options)
|
|
3357
|
+
: await resolveAgentRuntime(definition, selectedModel, {
|
|
3358
|
+
env: options.env,
|
|
3359
|
+
platform: options.platform,
|
|
3360
|
+
existsSync: options.existsSync,
|
|
3361
|
+
inspect: options.inspectRuntime,
|
|
3362
|
+
});
|
|
3321
3363
|
const supportedModels = companionModelsForAgent(normalizedAgent).filter((candidate) =>
|
|
3322
3364
|
modelSupportsAgentVersion(candidate, runtime.version),
|
|
3323
3365
|
);
|
|
@@ -3335,10 +3377,10 @@ export async function checkAgentAvailability(agent, model, options = {}) {
|
|
|
3335
3377
|
modelCatalogRefreshedAt,
|
|
3336
3378
|
};
|
|
3337
3379
|
}
|
|
3338
|
-
if (normalizedAgent === 'codex') {
|
|
3380
|
+
if (normalizedAgent === 'codex' || normalizedAgent === 'cursor') {
|
|
3339
3381
|
const adapter =
|
|
3340
3382
|
options.providerAdapter
|
|
3341
|
-
|| createLocalAgentAdapter(
|
|
3383
|
+
|| createLocalAgentAdapter(normalizedAgent, {
|
|
3342
3384
|
env: options.env,
|
|
3343
3385
|
});
|
|
3344
3386
|
try {
|
|
@@ -3358,7 +3400,7 @@ export async function checkAgentAvailability(agent, model, options = {}) {
|
|
|
3358
3400
|
code: detection.installed === false ? 'DEXTER_AGENT_NOT_FOUND' : AGENT_AUTHENTICATION_REQUIRED_CODE,
|
|
3359
3401
|
error:
|
|
3360
3402
|
detection.error
|
|
3361
|
-
||
|
|
3403
|
+
|| `${definition.label} is not signed in. Sign in on this computer, then run the bridge command again.`,
|
|
3362
3404
|
modelCatalogSource: 'fallback',
|
|
3363
3405
|
modelCatalogRefreshedAt,
|
|
3364
3406
|
};
|
|
@@ -3438,6 +3480,7 @@ export async function checkAllAgents(options = {}) {
|
|
|
3438
3480
|
const agents = await Promise.all([
|
|
3439
3481
|
checkAgentAvailability('claude-code', undefined, options),
|
|
3440
3482
|
checkAgentAvailability('codex', undefined, options),
|
|
3483
|
+
checkAgentAvailability('cursor', undefined, options),
|
|
3441
3484
|
]);
|
|
3442
3485
|
return {
|
|
3443
3486
|
agents,
|
package/src/cli.js
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
resolveCompanionModelName,
|
|
17
17
|
saveConfigPatch,
|
|
18
18
|
} from './config.js';
|
|
19
|
-
import { checkAgentAvailability, checkAllAgents, executeRun } from './agent.js';
|
|
19
|
+
import { checkAgentAvailability, checkAllAgents, executeRun, processEnvWithCliPath } from './agent.js';
|
|
20
|
+
import { setUpCursorCli } from './providers/cursorAcp.js';
|
|
20
21
|
import {
|
|
21
22
|
claimPairing,
|
|
22
23
|
heartbeat,
|
|
@@ -55,11 +56,12 @@ function usage() {
|
|
|
55
56
|
'Environment:',
|
|
56
57
|
' DEXTER_API_URL API base URL, e.g. https://api.example.com/iwm-api/0.0.1',
|
|
57
58
|
' DEXTER_BRIDGE_RUNTIME codex, claude-code, or opencode',
|
|
58
|
-
' DEXTER_BRIDGE_AGENT claude-code, codex, opencode, or dry-run',
|
|
59
|
+
' DEXTER_BRIDGE_AGENT claude-code, codex, cursor, opencode, or dry-run',
|
|
59
60
|
' DEXTER_BRIDGE_MODEL Companion model, e.g. claude-code:sonnet or codex:gpt-5.5',
|
|
60
61
|
' DEXTER_BRIDGE_CLAUDE_BIN Claude CLI binary name/path',
|
|
61
62
|
' DEXTER_BRIDGE_CLAUDE_ARGS Claude CLI args, default: -p',
|
|
62
63
|
' DEXTER_BRIDGE_CODEX_BIN Codex CLI binary name/path',
|
|
64
|
+
' DEXTER_BRIDGE_CURSOR_BIN Cursor CLI binary name/path, default: cursor-agent',
|
|
63
65
|
' DEXTER_BRIDGE_CODEX_MAX_THREADS Maximum retained App Server threads, default: 32',
|
|
64
66
|
' DEXTER_BRIDGE_LOG_DIR Log directory, default: ~/.dexter-bridge/logs',
|
|
65
67
|
].join('\n');
|
|
@@ -134,6 +136,28 @@ function requireDeviceToken(config) {
|
|
|
134
136
|
return config.deviceToken;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
async function confirmPrompt(question) {
|
|
140
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
141
|
+
try {
|
|
142
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
143
|
+
return answer === '' || answer === 'y' || answer === 'yes';
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
} finally {
|
|
147
|
+
rl.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One `connect --agent cursor` command installs and signs in to the Cursor CLI
|
|
152
|
+
// when needed, so the Cursor setup has no separate steps.
|
|
153
|
+
async function prepareRequestedAgent(flags, config) {
|
|
154
|
+
if (normalizeAgentName(flags.agent || config.agent) !== 'cursor') return;
|
|
155
|
+
await setUpCursorCli({
|
|
156
|
+
env: processEnvWithCliPath(agentEnvironment(config)),
|
|
157
|
+
confirm: process.stdin.isTTY ? confirmPrompt : null,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
137
161
|
async function promptForPairingCode() {
|
|
138
162
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
139
163
|
try {
|
|
@@ -309,6 +333,9 @@ function agentEnvironment(config = {}, baseEnv = process.env) {
|
|
|
309
333
|
if (!env.DEXTER_BRIDGE_CODEX_BIN && commands.codex) {
|
|
310
334
|
env.DEXTER_BRIDGE_CODEX_BIN = commands.codex;
|
|
311
335
|
}
|
|
336
|
+
if (!env.DEXTER_BRIDGE_CURSOR_BIN && commands.cursor) {
|
|
337
|
+
env.DEXTER_BRIDGE_CURSOR_BIN = commands.cursor;
|
|
338
|
+
}
|
|
312
339
|
return env;
|
|
313
340
|
}
|
|
314
341
|
|
|
@@ -316,11 +343,11 @@ function modelCatalogStatus(runtimeProfiles = []) {
|
|
|
316
343
|
return Object.fromEntries(
|
|
317
344
|
runtimeProfiles.flatMap((profile) => {
|
|
318
345
|
const agent =
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
346
|
+
{
|
|
347
|
+
'codex-app-server': 'codex',
|
|
348
|
+
'claude-code-cli': 'claude-code',
|
|
349
|
+
'cursor-acp': 'cursor',
|
|
350
|
+
}[profile.driver] || null;
|
|
324
351
|
return agent ? [[agent, profile.modelCatalog]] : [];
|
|
325
352
|
}),
|
|
326
353
|
);
|
|
@@ -466,7 +493,8 @@ function requireAvailableAgent(availability, agent, platform = process.platform)
|
|
|
466
493
|
const check = selectedAgentCheck(availability, agent);
|
|
467
494
|
if (check?.ok) return check;
|
|
468
495
|
|
|
469
|
-
const label =
|
|
496
|
+
const label = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor' }[agent]
|
|
497
|
+
|| 'The selected agent';
|
|
470
498
|
let message = check?.error || `${label} is not installed or could not be started.`;
|
|
471
499
|
if (agent === 'claude-code' && check?.status === 'authentication_required') {
|
|
472
500
|
message = check.error || 'Claude Code is not signed in. Run `claude auth login`, then try again.';
|
|
@@ -778,11 +806,11 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
778
806
|
const runAgent = normalizeAgentName(
|
|
779
807
|
poll.run?.companion?.agent
|
|
780
808
|
|| (
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
:
|
|
784
|
-
|
|
785
|
-
|
|
809
|
+
{
|
|
810
|
+
'claude-code-cli': 'claude-code',
|
|
811
|
+
'cursor-acp': 'cursor',
|
|
812
|
+
opencode: 'opencode',
|
|
813
|
+
}[runtimeProfile?.driver] || 'codex'
|
|
786
814
|
),
|
|
787
815
|
);
|
|
788
816
|
await executeClaimedRun({
|
|
@@ -865,6 +893,7 @@ export async function runCli(argv) {
|
|
|
865
893
|
// The one-command production path: claim the pairing code (when given or
|
|
866
894
|
// not yet paired), save config, and go straight into the polling loop.
|
|
867
895
|
case 'connect':
|
|
896
|
+
await prepareRequestedAgent(parsed.flags, config);
|
|
868
897
|
if (parsed.args[0]) {
|
|
869
898
|
await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
|
|
870
899
|
}
|
package/src/config.js
CHANGED
|
@@ -31,6 +31,7 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
|
|
|
31
31
|
'./providers/directByok.js',
|
|
32
32
|
'./providers/openCode.js',
|
|
33
33
|
'./providers/acp.js',
|
|
34
|
+
'./providers/cursorAcp.js',
|
|
34
35
|
].map((relativePath) => {
|
|
35
36
|
const url = new URL(relativePath, import.meta.url);
|
|
36
37
|
return `${relativePath}\u0000${fs.readFileSync(url, 'utf8')}`;
|
|
@@ -52,6 +53,7 @@ export const BRIDGE_CAPABILITIES = [
|
|
|
52
53
|
'native-skills-v1',
|
|
53
54
|
'model-progress-v1',
|
|
54
55
|
'model-catalog-refresh-v1',
|
|
56
|
+
'cursor-acp-v1',
|
|
55
57
|
];
|
|
56
58
|
export const BRIDGE_PRODUCTS = {
|
|
57
59
|
dexter: {
|
|
@@ -69,7 +71,7 @@ export const BRIDGE_PRODUCTS = {
|
|
|
69
71
|
// user explicitly connects the locally installed CLI and chooses one of its
|
|
70
72
|
// reported models.
|
|
71
73
|
export const DEFAULT_BRIDGE_AGENT = 'codex';
|
|
72
|
-
export const BRIDGE_AGENTS = ['claude-code', 'codex', 'opencode', 'dry-run'];
|
|
74
|
+
export const BRIDGE_AGENTS = ['claude-code', 'codex', 'cursor', 'opencode', 'dry-run'];
|
|
73
75
|
export const COMPANION_MODEL_DEFINITIONS = [
|
|
74
76
|
{
|
|
75
77
|
id: 'claude-code:fable',
|
|
@@ -162,6 +164,63 @@ export const COMPANION_MODEL_DEFINITIONS = [
|
|
|
162
164
|
costTier: '$',
|
|
163
165
|
description: 'Low-latency Codex model for quick coding iterations.',
|
|
164
166
|
},
|
|
167
|
+
// Cursor lists every model it offers, whatever the user's plan; Dexter
|
|
168
|
+
// shows frontier models plus Composer. Paid Cursor plans include Grok and
|
|
169
|
+
// Composer; Claude, GPT and Gemini are billed from Cursor credits.
|
|
170
|
+
{
|
|
171
|
+
id: 'cursor:grok-4.7',
|
|
172
|
+
agent: 'cursor',
|
|
173
|
+
provider: 'cursor',
|
|
174
|
+
displayName: 'Grok 4.7',
|
|
175
|
+
invocationName: 'grok-4.7',
|
|
176
|
+
costTier: '$$$',
|
|
177
|
+
description: 'Grok 4.7, included in paid Cursor plans.',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'cursor:composer-2.5',
|
|
181
|
+
agent: 'cursor',
|
|
182
|
+
provider: 'cursor',
|
|
183
|
+
displayName: 'Composer 2.5',
|
|
184
|
+
invocationName: 'composer-2.5',
|
|
185
|
+
costTier: '$$',
|
|
186
|
+
description: "Cursor's fast model, included in paid Cursor plans.",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'cursor:claude-opus-5-5',
|
|
190
|
+
agent: 'cursor',
|
|
191
|
+
provider: 'cursor',
|
|
192
|
+
displayName: 'Claude Opus 5.5',
|
|
193
|
+
invocationName: 'claude-opus-5-5',
|
|
194
|
+
costTier: '$$$',
|
|
195
|
+
description: 'Claude Opus 5.5 via your Cursor plan.',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'cursor:claude-fable-5-1',
|
|
199
|
+
agent: 'cursor',
|
|
200
|
+
provider: 'cursor',
|
|
201
|
+
displayName: 'Claude Fable 5.1',
|
|
202
|
+
invocationName: 'claude-fable-5-1',
|
|
203
|
+
costTier: '$$$',
|
|
204
|
+
description: 'Claude Fable 5.1 via your Cursor plan.',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 'cursor:gpt-5.6-sol',
|
|
208
|
+
agent: 'cursor',
|
|
209
|
+
provider: 'cursor',
|
|
210
|
+
displayName: 'GPT-5.6 Sol',
|
|
211
|
+
invocationName: 'gpt-5.6-sol',
|
|
212
|
+
costTier: '$$$',
|
|
213
|
+
description: 'GPT-5.6 Sol via your Cursor plan.',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: 'cursor:gemini-4-argon',
|
|
217
|
+
agent: 'cursor',
|
|
218
|
+
provider: 'cursor',
|
|
219
|
+
displayName: 'Gemini 4 Argon',
|
|
220
|
+
invocationName: 'gemini-4-argon',
|
|
221
|
+
costTier: '$$$',
|
|
222
|
+
description: 'Gemini 4 Argon via your Cursor plan.',
|
|
223
|
+
},
|
|
165
224
|
{
|
|
166
225
|
id: 'dry-run:default',
|
|
167
226
|
agent: 'dry-run',
|
|
@@ -176,6 +235,7 @@ export const COMPANION_MODEL_DEFINITIONS = [
|
|
|
176
235
|
const DEFAULT_MODEL_BY_AGENT = {
|
|
177
236
|
'claude-code': 'claude-code:sonnet',
|
|
178
237
|
codex: 'codex:gpt-5.5',
|
|
238
|
+
cursor: 'cursor:grok-4.7',
|
|
179
239
|
'dry-run': 'dry-run:default',
|
|
180
240
|
};
|
|
181
241
|
|
|
@@ -237,6 +297,7 @@ export function normalizeAgentName(value, fallback = DEFAULT_BRIDGE_AGENT) {
|
|
|
237
297
|
const raw = String(value || '').trim().toLowerCase();
|
|
238
298
|
if (raw === 'claude' || raw === 'claude_code' || raw === 'claude-code') return 'claude-code';
|
|
239
299
|
if (raw === 'openai-codex' || raw === 'openai_codex' || raw === 'codex') return 'codex';
|
|
300
|
+
if (raw === 'cursor' || raw === 'cursor-agent' || raw === 'cursor_agent') return 'cursor';
|
|
240
301
|
if (raw === 'open-code' || raw === 'open_code' || raw === 'opencode') return 'opencode';
|
|
241
302
|
if (raw === 'dryrun' || raw === 'dry_run' || raw === 'dry-run') return 'dry-run';
|
|
242
303
|
return BRIDGE_AGENTS.includes(fallback) ? fallback : DEFAULT_BRIDGE_AGENT;
|
|
@@ -269,11 +330,11 @@ export function companionModelDefinition(value, agent = DEFAULT_BRIDGE_AGENT) {
|
|
|
269
330
|
return {
|
|
270
331
|
id: `${normalizedAgent}:${invocationName}`,
|
|
271
332
|
agent: normalizedAgent,
|
|
272
|
-
provider: normalizedAgent === 'codex' ? 'openai' : normalizedAgent === 'claude-code' ? 'anthropic' : 'companion',
|
|
333
|
+
provider: normalizedAgent === 'codex' ? 'openai' : normalizedAgent === 'claude-code' ? 'anthropic' : normalizedAgent === 'cursor' ? 'cursor' : 'companion',
|
|
273
334
|
displayName: invocationName,
|
|
274
335
|
invocationName,
|
|
275
336
|
costTier: '$$',
|
|
276
|
-
description: `${
|
|
337
|
+
description: `${{ codex: 'Codex', cursor: 'Cursor' }[normalizedAgent] || 'Claude Code'} model reported by the connected bridge.`,
|
|
277
338
|
};
|
|
278
339
|
}
|
|
279
340
|
}
|
package/src/protocol.js
CHANGED
|
@@ -496,6 +496,7 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
|
|
|
496
496
|
'You decide when the run is complete. Avoid repeated inspection of unchanged evidence and stop when the requested outcome has been implemented and verified.',
|
|
497
497
|
'Dexter will not perform a second design verification after you finish. Structural verification must be described honestly and must never be called runtime-tested unless an interaction was actually exercised.',
|
|
498
498
|
'If Framer authorization or a required user decision is missing, return blocked with a concise explanation. Otherwise return completed only after you have finished and verified the work.',
|
|
499
|
+
'A greeting, thanks, or question that needs no project change is not blocked: answer it in summary without reading or editing the project and return completed.',
|
|
499
500
|
'When finished, return exactly one JSON object and no markdown.',
|
|
500
501
|
'{"status":"completed|blocked|failed","summary":"one or two short, non-technical sentences for the designer","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null}',
|
|
501
502
|
doctrine
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createJsonRpcClient } from './jsonRpcClient.js';
|
|
6
|
+
|
|
7
|
+
// Cursor runs its built-in tools (shell, file read/write, web fetch) inside
|
|
8
|
+
// `cursor-agent` itself and ignores the ACP client capabilities, so every
|
|
9
|
+
// session runs in a workspace whose Cursor permissions deny them and allow
|
|
10
|
+
// only the InstaWebAI MCP server. Verified against cursor-agent 2026.10.16.
|
|
11
|
+
export const CURSOR_WORKSPACE_PERMISSIONS = {
|
|
12
|
+
permissions: {
|
|
13
|
+
allow: ['Mcp(instawebai:*)'],
|
|
14
|
+
deny: ['Shell(*)', 'Read(**)', 'Write(**)', 'WebFetch(*)'],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const CURSOR_PLAN_REQUIRED_CODE = 'CURSOR_PLAN_REQUIRED';
|
|
19
|
+
// Cursor reports plan and billing refusals as a short assistant reply that
|
|
20
|
+
// ends the turn, e.g. "Upgrade your plan to continue" or "Add a payment
|
|
21
|
+
// method to continue".
|
|
22
|
+
const PLAN_LIMIT_PATTERN =
|
|
23
|
+
/upgrade your plan|payment method|billing|free plans can only use auto|named models unavailable|usage limit|spend limit/i;
|
|
24
|
+
|
|
25
|
+
export function isCursorPlanRefusal(text) {
|
|
26
|
+
const reply = String(text || '').trim();
|
|
27
|
+
return reply.length > 0 && reply.length < 400 && !reply.includes('{') && PLAN_LIMIT_PATTERN.test(reply);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const IMAGE_MIME_TYPES = {
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.webp': 'image/webp',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function cursorBaseModelId(modelId) {
|
|
39
|
+
return String(modelId || '').split('[')[0].trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeCursorWorkspacePermissions(directory) {
|
|
43
|
+
fs.mkdirSync(path.join(directory, '.cursor'), { recursive: true });
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(directory, '.cursor', 'cli.json'),
|
|
46
|
+
`${JSON.stringify(CURSOR_WORKSPACE_PERMISSIONS, null, 2)}\n`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cursorMcpServers(mcpServer) {
|
|
51
|
+
if (!mcpServer?.command) return [];
|
|
52
|
+
return [{
|
|
53
|
+
name: 'instawebai',
|
|
54
|
+
command: mcpServer.command,
|
|
55
|
+
args: mcpServer.args || [],
|
|
56
|
+
env: Object.entries(mcpServer.environment || {}).map(([name, value]) => ({
|
|
57
|
+
name,
|
|
58
|
+
value: String(value),
|
|
59
|
+
})),
|
|
60
|
+
}];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function imageBlocks(imageFiles = []) {
|
|
64
|
+
return imageFiles.flatMap((file) => {
|
|
65
|
+
const mimeType = IMAGE_MIME_TYPES[path.extname(file).toLowerCase()];
|
|
66
|
+
if (!mimeType) return [];
|
|
67
|
+
try {
|
|
68
|
+
return [{ type: 'image', mimeType, data: fs.readFileSync(file).toString('base64') }];
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function withOutputContract(prompt, outputSchema) {
|
|
76
|
+
if (!outputSchema) return String(prompt || '');
|
|
77
|
+
return [
|
|
78
|
+
String(prompt || ''),
|
|
79
|
+
'',
|
|
80
|
+
'Your final message must be exactly one JSON object, with no markdown, that matches this JSON Schema:',
|
|
81
|
+
JSON.stringify(outputSchema),
|
|
82
|
+
].join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isOwnTool(toolCall) {
|
|
86
|
+
return /instawebai/i.test(JSON.stringify(toolCall || {}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toolName(update) {
|
|
90
|
+
return update?.rawInput?.toolName || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function planRequiredError(model, reply) {
|
|
94
|
+
return Object.assign(
|
|
95
|
+
new Error(
|
|
96
|
+
`Cursor would not run ${model || 'this model'}${reply ? `: "${reply}"` : '.'} Cursor bills Claude, GPT and Gemini models from credits or additional usage, not your plan's included usage. Buy credits or set an additional usage limit in Cursor's billing settings, or pick a model your plan includes.`,
|
|
97
|
+
),
|
|
98
|
+
{ code: CURSOR_PLAN_REQUIRED_CODE, retryable: false },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseCursorStatus(output) {
|
|
103
|
+
const text = String(output || '');
|
|
104
|
+
const match = text.match(/logged in as\s+(\S+)/i);
|
|
105
|
+
return {
|
|
106
|
+
signedIn: Boolean(match) && !/not logged in/i.test(text),
|
|
107
|
+
account: match ? match[1] : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const CURSOR_INSTALL_COMMAND = 'curl https://cursor.com/install -fsS | bash';
|
|
112
|
+
export const CURSOR_WINDOWS_INSTALL_COMMAND = "irm 'https://cursor.com/install?win32=true' | iex";
|
|
113
|
+
|
|
114
|
+
export function cursorInstallCommand(platform = process.platform) {
|
|
115
|
+
return platform === 'win32' ? CURSOR_WINDOWS_INSTALL_COMMAND : CURSOR_INSTALL_COMMAND;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const CURSOR_VERSION_DIRECTORY = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-\d{2}-\d{2}-\d{2})?-[a-f0-9]+$/;
|
|
119
|
+
|
|
120
|
+
function cursorVersionRank(name) {
|
|
121
|
+
const [, year, month, day] = name.match(CURSOR_VERSION_DIRECTORY);
|
|
122
|
+
return Number(`${year}${month.padStart(2, '0')}${day.padStart(2, '0')}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Same choice as Cursor's own cursor-agent.ps1: a build next to the launcher,
|
|
126
|
+
// otherwise the newest build under versions\.
|
|
127
|
+
function newestCursorBuild(root, fileSystem) {
|
|
128
|
+
const hasBuild = (directory) =>
|
|
129
|
+
fileSystem.existsSync(path.win32.join(directory, 'node.exe'))
|
|
130
|
+
&& fileSystem.existsSync(path.win32.join(directory, 'index.js'));
|
|
131
|
+
if (hasBuild(root)) return root;
|
|
132
|
+
let names = [];
|
|
133
|
+
try {
|
|
134
|
+
names = fileSystem.readdirSync(path.win32.join(root, 'versions'));
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return names
|
|
139
|
+
.filter((name) => CURSOR_VERSION_DIRECTORY.test(name))
|
|
140
|
+
.sort((left, right) => cursorVersionRank(right) - cursorVersionRank(left) || right.localeCompare(left))
|
|
141
|
+
.map((name) => path.win32.join(root, 'versions', name))
|
|
142
|
+
.find(hasBuild) || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The command and leading arguments that start the Cursor CLI, or null when
|
|
147
|
+
* it is not installed. On Windows the CLI is a cursor-agent.cmd shim that runs
|
|
148
|
+
* PowerShell, which runs the node.exe bundled with the newest installed build.
|
|
149
|
+
* Node can start a .cmd only through a shell, and stopping that shell leaves
|
|
150
|
+
* Cursor running, so the bridge starts the bundled node.exe directly, as the
|
|
151
|
+
* shim does. The installer's folder is checked by path because a bridge that
|
|
152
|
+
* was already running does not see the PATH change the installer makes.
|
|
153
|
+
*/
|
|
154
|
+
export function cursorLauncher({ env = process.env, platform = process.platform, fileSystem = fs } = {}) {
|
|
155
|
+
const override = String(env.DEXTER_BRIDGE_CURSOR_BIN || '').trim();
|
|
156
|
+
if (platform !== 'win32') return { command: override || 'cursor-agent', args: [], env };
|
|
157
|
+
if (override && !/\.(cmd|bat|ps1)$/i.test(override)) return { command: override, args: [], env };
|
|
158
|
+
const roots = [
|
|
159
|
+
...(override ? [path.win32.dirname(override)] : []),
|
|
160
|
+
...String(env.PATH || env.Path || '')
|
|
161
|
+
.split(';')
|
|
162
|
+
.filter((directory) => directory && fileSystem.existsSync(path.win32.join(directory, 'cursor-agent.cmd'))),
|
|
163
|
+
...(env.LOCALAPPDATA ? [path.win32.join(env.LOCALAPPDATA, 'cursor-agent')] : []),
|
|
164
|
+
];
|
|
165
|
+
for (const root of new Set(roots)) {
|
|
166
|
+
const build = newestCursorBuild(root, fileSystem);
|
|
167
|
+
if (build) {
|
|
168
|
+
return {
|
|
169
|
+
command: path.win32.join(build, 'node.exe'),
|
|
170
|
+
args: [path.win32.join(build, 'index.js')],
|
|
171
|
+
env: { ...env, CURSOR_INVOKED_AS: 'cursor-agent.cmd' },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function notInstalledError(code = 'ENOENT') {
|
|
179
|
+
return Object.assign(new Error('Cursor CLI not found.'), { code });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function runCommand(launcher, args, timeoutMs = 15_000) {
|
|
183
|
+
if (!launcher) return Promise.resolve({ error: notInstalledError(), output: '' });
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
execFile(
|
|
186
|
+
launcher.command,
|
|
187
|
+
[...launcher.args, ...args],
|
|
188
|
+
{ env: launcher.env, timeout: timeoutMs, windowsHide: true },
|
|
189
|
+
(error, stdout, stderr) => {
|
|
190
|
+
resolve({ error, output: `${stdout || ''}${stderr || ''}`.trim() });
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function readCursorVersion(launcher) {
|
|
197
|
+
return runCommand(launcher, ['--version']);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function runInteractive(command, args, env, spawnImpl) {
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
const child = spawnImpl(command, args, { env, stdio: 'inherit' });
|
|
203
|
+
child.once('error', () => resolve(false));
|
|
204
|
+
child.once('exit', (code) => resolve(code === 0));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function installCursorCli(platform, env, spawnImpl) {
|
|
209
|
+
return platform === 'win32'
|
|
210
|
+
? runInteractive(
|
|
211
|
+
'powershell.exe',
|
|
212
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', CURSOR_WINDOWS_INSTALL_COMMAND],
|
|
213
|
+
env,
|
|
214
|
+
spawnImpl,
|
|
215
|
+
)
|
|
216
|
+
: runInteractive('bash', ['-c', CURSOR_INSTALL_COMMAND], env, spawnImpl);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function setupError(message) {
|
|
220
|
+
return Object.assign(new Error(message), { code: 'CURSOR_SETUP_INCOMPLETE', exitCode: 2 });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Gets the Cursor CLI installed and signed in so one `connect` command is the
|
|
225
|
+
* whole Cursor setup. Installing asks first; sign-in opens Cursor's own
|
|
226
|
+
* browser login. Does nothing when Cursor is already ready.
|
|
227
|
+
*/
|
|
228
|
+
export async function setUpCursorCli({
|
|
229
|
+
env = process.env,
|
|
230
|
+
confirm,
|
|
231
|
+
log = console.log,
|
|
232
|
+
platform = process.platform,
|
|
233
|
+
findCursor = () => cursorLauncher({ env, platform }),
|
|
234
|
+
run = runCommand,
|
|
235
|
+
spawnImpl = spawn,
|
|
236
|
+
} = {}) {
|
|
237
|
+
const installCommand = cursorInstallCommand(platform);
|
|
238
|
+
let launcher = null;
|
|
239
|
+
const readStatus = async () => {
|
|
240
|
+
launcher = findCursor();
|
|
241
|
+
const result = await run(launcher, ['status']);
|
|
242
|
+
if (!launcher || result.error?.code === 'ENOENT') return { installed: false, signedIn: false };
|
|
243
|
+
return { installed: true, ...parseCursorStatus(result.output) };
|
|
244
|
+
};
|
|
245
|
+
let status = await readStatus();
|
|
246
|
+
if (!status.installed) {
|
|
247
|
+
if (!confirm) {
|
|
248
|
+
throw setupError(`Cursor CLI is not installed. Run \`${installCommand}\`, then run this command again.`);
|
|
249
|
+
}
|
|
250
|
+
if (!(await confirm('Cursor CLI is not installed. Install it now? (Y/n) '))) {
|
|
251
|
+
throw setupError('Cursor CLI is required to connect Cursor to Dexter.');
|
|
252
|
+
}
|
|
253
|
+
if (!(await installCursorCli(platform, env, spawnImpl))) {
|
|
254
|
+
throw setupError(`Cursor CLI install failed. Run \`${installCommand}\` yourself, then run this command again.`);
|
|
255
|
+
}
|
|
256
|
+
status = await readStatus();
|
|
257
|
+
if (!status.installed) {
|
|
258
|
+
throw setupError('Cursor CLI was installed but could not be found. Open a new terminal and run this command again.');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!status.signedIn) {
|
|
262
|
+
if (!confirm) {
|
|
263
|
+
throw setupError('Cursor is not signed in. Run `cursor-agent login`, then run this command again.');
|
|
264
|
+
}
|
|
265
|
+
log('Sign in to Cursor in your browser…');
|
|
266
|
+
await runInteractive(launcher.command, [...launcher.args, 'login'], launcher.env, spawnImpl);
|
|
267
|
+
status = await readStatus();
|
|
268
|
+
if (!status.signedIn) {
|
|
269
|
+
throw setupError('Cursor sign-in did not finish. Run this command again to retry.');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
log(`Cursor is ready${status.account ? ` (${status.account})` : ''}.`);
|
|
273
|
+
return status;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function createCursorAcpAdapter({
|
|
277
|
+
env = process.env,
|
|
278
|
+
trace,
|
|
279
|
+
platform = process.platform,
|
|
280
|
+
findCursor = () => cursorLauncher({ env, platform }),
|
|
281
|
+
models = [],
|
|
282
|
+
createClient = createJsonRpcClient,
|
|
283
|
+
} = {}) {
|
|
284
|
+
const activeTurns = new Map();
|
|
285
|
+
const notInstalledMessage =
|
|
286
|
+
`Cursor CLI is not installed. Run \`${cursorInstallCommand(platform)}\`, then \`cursor-agent login\`.`;
|
|
287
|
+
|
|
288
|
+
async function detect() {
|
|
289
|
+
const launcher = findCursor();
|
|
290
|
+
const version = await runCommand(launcher, ['--version']);
|
|
291
|
+
if (version.error) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
installed: false,
|
|
295
|
+
signedIn: false,
|
|
296
|
+
agent: 'cursor',
|
|
297
|
+
error: notInstalledMessage,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const status = parseCursorStatus((await runCommand(launcher, ['status'])).output);
|
|
301
|
+
return {
|
|
302
|
+
ok: status.signedIn,
|
|
303
|
+
installed: true,
|
|
304
|
+
signedIn: status.signedIn,
|
|
305
|
+
agent: 'cursor',
|
|
306
|
+
version: version.output || null,
|
|
307
|
+
...(status.signedIn
|
|
308
|
+
? {}
|
|
309
|
+
: { error: 'Cursor is not signed in. Run `cursor-agent login` on this computer, then try again.' }),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function runTurn({
|
|
314
|
+
runId,
|
|
315
|
+
sessionId,
|
|
316
|
+
prompt,
|
|
317
|
+
model,
|
|
318
|
+
cwd,
|
|
319
|
+
outputSchema,
|
|
320
|
+
imageFiles = [],
|
|
321
|
+
mcpServer = null,
|
|
322
|
+
timeoutMs = 10 * 60_000,
|
|
323
|
+
maxDurationMs = 20 * 60_000,
|
|
324
|
+
onProgress,
|
|
325
|
+
}) {
|
|
326
|
+
const turnKey = sessionId || runId;
|
|
327
|
+
if (!turnKey) throw new Error('A Cursor turn requires a session id.');
|
|
328
|
+
const launcher = findCursor();
|
|
329
|
+
if (!launcher) {
|
|
330
|
+
throw Object.assign(new Error(notInstalledMessage), { code: 'DEXTER_AGENT_NOT_FOUND', retryable: false });
|
|
331
|
+
}
|
|
332
|
+
const temporaryWorkspace = cwd
|
|
333
|
+
? null
|
|
334
|
+
: fs.mkdtempSync(path.join(os.tmpdir(), 'dexter-cursor-'));
|
|
335
|
+
const workspace = cwd || temporaryWorkspace;
|
|
336
|
+
writeCursorWorkspacePermissions(workspace);
|
|
337
|
+
|
|
338
|
+
let segment = '';
|
|
339
|
+
let narrationIndex = 0;
|
|
340
|
+
let inactivityTimer = null;
|
|
341
|
+
let deadlineTimer = null;
|
|
342
|
+
let failTurn;
|
|
343
|
+
const failure = new Promise((_, reject) => {
|
|
344
|
+
failTurn = reject;
|
|
345
|
+
});
|
|
346
|
+
const timeOut = (code, message) =>
|
|
347
|
+
failTurn(Object.assign(new Error(message), { code, retryable: true }));
|
|
348
|
+
const touch = () => {
|
|
349
|
+
clearTimeout(inactivityTimer);
|
|
350
|
+
inactivityTimer = setTimeout(
|
|
351
|
+
() => timeOut('APP_AGENT_TURN_INACTIVITY_TIMEOUT', `Cursor was inactive for ${timeoutMs}ms.`),
|
|
352
|
+
timeoutMs,
|
|
353
|
+
);
|
|
354
|
+
inactivityTimer.unref?.();
|
|
355
|
+
};
|
|
356
|
+
const flushNarration = () => {
|
|
357
|
+
const message = segment.trim();
|
|
358
|
+
segment = '';
|
|
359
|
+
if (!message) return;
|
|
360
|
+
narrationIndex += 1;
|
|
361
|
+
onProgress?.({
|
|
362
|
+
kind: 'output_delta',
|
|
363
|
+
provider: 'cursor',
|
|
364
|
+
occurredAt: new Date().toISOString(),
|
|
365
|
+
message,
|
|
366
|
+
activity: `assistant:${turnKey}:${narrationIndex}`,
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const client = createClient({
|
|
371
|
+
command: launcher.command,
|
|
372
|
+
args: [...launcher.args, 'acp'],
|
|
373
|
+
env: launcher.env,
|
|
374
|
+
cwd: workspace,
|
|
375
|
+
onServerRequest: async (message) => {
|
|
376
|
+
if (message.method === 'session/request_permission') {
|
|
377
|
+
const options = message.params?.options || [];
|
|
378
|
+
const kind = isOwnTool(message.params?.toolCall) ? 'allow_once' : 'reject_once';
|
|
379
|
+
const option = options.find((candidate) => candidate.kind === kind);
|
|
380
|
+
return option
|
|
381
|
+
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
382
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
383
|
+
}
|
|
384
|
+
throw new Error(`Cursor request "${message.method}" is disabled for Dexter runs.`);
|
|
385
|
+
},
|
|
386
|
+
onNotification: (message) => {
|
|
387
|
+
if (message.method !== 'session/update') return;
|
|
388
|
+
touch();
|
|
389
|
+
const update = message.params?.update || {};
|
|
390
|
+
switch (update.sessionUpdate) {
|
|
391
|
+
case 'agent_message_chunk':
|
|
392
|
+
segment += update.content?.text || '';
|
|
393
|
+
break;
|
|
394
|
+
case 'agent_thought_chunk':
|
|
395
|
+
onProgress?.({ kind: 'reasoning_delta', provider: 'cursor', occurredAt: new Date().toISOString() });
|
|
396
|
+
break;
|
|
397
|
+
case 'tool_call':
|
|
398
|
+
flushNarration();
|
|
399
|
+
break;
|
|
400
|
+
case 'tool_call_update': {
|
|
401
|
+
const name = toolName(update);
|
|
402
|
+
if (name) {
|
|
403
|
+
onProgress?.({
|
|
404
|
+
kind: 'item_started',
|
|
405
|
+
provider: 'cursor',
|
|
406
|
+
occurredAt: new Date().toISOString(),
|
|
407
|
+
name,
|
|
408
|
+
phase: 'started',
|
|
409
|
+
});
|
|
410
|
+
} else if (update.status === 'completed' || update.status === 'failed') {
|
|
411
|
+
onProgress?.({
|
|
412
|
+
kind: 'item_completed',
|
|
413
|
+
provider: 'cursor',
|
|
414
|
+
occurredAt: new Date().toISOString(),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
default:
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const turn = { client, nativeSessionId: null };
|
|
426
|
+
activeTurns.set(turnKey, turn);
|
|
427
|
+
deadlineTimer = setTimeout(
|
|
428
|
+
() => timeOut('APP_AGENT_TURN_HARD_TIMEOUT', `The Cursor turn exceeded the ${maxDurationMs}ms safety limit.`),
|
|
429
|
+
maxDurationMs,
|
|
430
|
+
);
|
|
431
|
+
deadlineTimer.unref?.();
|
|
432
|
+
touch();
|
|
433
|
+
|
|
434
|
+
let restoreModelId = null;
|
|
435
|
+
try {
|
|
436
|
+
const run = async () => {
|
|
437
|
+
await client.request('initialize', {
|
|
438
|
+
protocolVersion: 1,
|
|
439
|
+
clientCapabilities: {
|
|
440
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
441
|
+
terminal: false,
|
|
442
|
+
},
|
|
443
|
+
clientInfo: { name: 'instawebai-dexter-bridge', version: '1' },
|
|
444
|
+
}, { timeoutMs: 30_000 });
|
|
445
|
+
const session = await client.request('session/new', {
|
|
446
|
+
cwd: workspace,
|
|
447
|
+
mcpServers: cursorMcpServers(mcpServer),
|
|
448
|
+
}, { timeoutMs: 60_000 });
|
|
449
|
+
turn.nativeSessionId = session?.sessionId;
|
|
450
|
+
if (!turn.nativeSessionId) throw new Error('Cursor did not return a session id.');
|
|
451
|
+
|
|
452
|
+
// Selecting a model in ACP also changes the user's default Cursor
|
|
453
|
+
// model, so the previous one is restored after the turn.
|
|
454
|
+
const requested = cursorBaseModelId(model);
|
|
455
|
+
if (requested) {
|
|
456
|
+
const available = session.models?.availableModels || [];
|
|
457
|
+
const variant = available.find((entry) => cursorBaseModelId(entry.modelId) === requested);
|
|
458
|
+
if (!variant) {
|
|
459
|
+
throw Object.assign(
|
|
460
|
+
new Error(`Cursor does not offer ${requested} on this account. Pick another Cursor model.`),
|
|
461
|
+
{ code: 'CURSOR_MODEL_UNAVAILABLE', retryable: false },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const current = session.models?.currentModelId || null;
|
|
465
|
+
if (variant.modelId !== current) {
|
|
466
|
+
await client.request('session/set_model', {
|
|
467
|
+
sessionId: turn.nativeSessionId,
|
|
468
|
+
modelId: variant.modelId,
|
|
469
|
+
}, { timeoutMs: 30_000 });
|
|
470
|
+
restoreModelId = current;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
onProgress?.({ kind: 'turn_started', provider: 'cursor', occurredAt: new Date().toISOString() });
|
|
475
|
+
const result = await client.request('session/prompt', {
|
|
476
|
+
sessionId: turn.nativeSessionId,
|
|
477
|
+
prompt: [
|
|
478
|
+
{ type: 'text', text: withOutputContract(prompt, outputSchema) },
|
|
479
|
+
...imageBlocks(imageFiles),
|
|
480
|
+
],
|
|
481
|
+
}, { timeoutMs: 0 });
|
|
482
|
+
const text = segment.trim();
|
|
483
|
+
if (result?.stopReason === 'cancelled') {
|
|
484
|
+
throw Object.assign(new Error('The Cursor turn was cancelled.'), { code: 'RUN_CANCELLED' });
|
|
485
|
+
}
|
|
486
|
+
if (isCursorPlanRefusal(text)) {
|
|
487
|
+
throw planRequiredError(requested, text);
|
|
488
|
+
}
|
|
489
|
+
if (result?.stopReason && result.stopReason !== 'end_turn') {
|
|
490
|
+
throw Object.assign(
|
|
491
|
+
new Error(`Cursor stopped before finishing (${result.stopReason}).`),
|
|
492
|
+
{ code: 'CURSOR_TURN_INCOMPLETE', retryable: result.stopReason === 'max_tokens' },
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
text,
|
|
497
|
+
sessionId: turn.nativeSessionId,
|
|
498
|
+
usageAvailable: false,
|
|
499
|
+
usageSource: 'cursor',
|
|
500
|
+
usageAccuracy: 'unavailable',
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
return await Promise.race([run(), failure]);
|
|
504
|
+
} finally {
|
|
505
|
+
clearTimeout(inactivityTimer);
|
|
506
|
+
clearTimeout(deadlineTimer);
|
|
507
|
+
activeTurns.delete(turnKey);
|
|
508
|
+
if (restoreModelId && turn.nativeSessionId && !client.closed) {
|
|
509
|
+
await client.request('session/set_model', {
|
|
510
|
+
sessionId: turn.nativeSessionId,
|
|
511
|
+
modelId: restoreModelId,
|
|
512
|
+
}, { timeoutMs: 10_000 }).catch((error) => {
|
|
513
|
+
trace?.warn?.('cursor_model_restore_failed', { error: error?.message });
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
client.close();
|
|
517
|
+
if (temporaryWorkspace) {
|
|
518
|
+
fs.rmSync(temporaryWorkspace, { recursive: true, force: true });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function runModelTurn(input = {}) {
|
|
524
|
+
return runTurn(input);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function runOutcome(input = {}) {
|
|
528
|
+
return runTurn({
|
|
529
|
+
...input,
|
|
530
|
+
mcpServer: input.harnessTools?.mcpServer || null,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function cancel(sessionId) {
|
|
535
|
+
const turn = activeTurns.get(sessionId);
|
|
536
|
+
if (!turn || turn.client.closed || !turn.nativeSessionId) return;
|
|
537
|
+
turn.client.notify('session/cancel', { sessionId: turn.nativeSessionId });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function close() {
|
|
541
|
+
for (const turn of activeTurns.values()) turn.client.close();
|
|
542
|
+
activeTurns.clear();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
id: 'cursor',
|
|
547
|
+
driverKind: 'cursor-acp',
|
|
548
|
+
label: 'Cursor',
|
|
549
|
+
detect,
|
|
550
|
+
models: async () => models,
|
|
551
|
+
runModelTurn,
|
|
552
|
+
runOutcome,
|
|
553
|
+
cancel,
|
|
554
|
+
resetSession: async () => {},
|
|
555
|
+
close,
|
|
556
|
+
};
|
|
557
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -2,14 +2,15 @@ import { createCodexAppServerAdapter } from './codexAppServer.js';
|
|
|
2
2
|
import { createDirectByokAdapter } from './directByok.js';
|
|
3
3
|
import { createOpenCodeAdapter } from './openCode.js';
|
|
4
4
|
import { createAcpAdapter } from './acp.js';
|
|
5
|
+
import { createCursorAcpAdapter } from './cursorAcp.js';
|
|
6
|
+
import { companionModelsForAgent } from '../config.js';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Local agent adapters.
|
|
8
10
|
*
|
|
9
11
|
* The server-owned agent loop and the `model_turn` relay protocol are unchanged;
|
|
10
|
-
* an adapter only decides *how* one local model turn is executed.
|
|
11
|
-
*
|
|
12
|
-
* enforced.
|
|
12
|
+
* an adapter only decides *how* one local model turn is executed. Codex runs through
|
|
13
|
+
* its App Server and Cursor through `cursor-agent acp`.
|
|
13
14
|
*
|
|
14
15
|
* @typedef {Object} ProviderStatus
|
|
15
16
|
* @property {boolean} ok
|
|
@@ -48,6 +49,13 @@ export const DRIVER_FACTORIES = {
|
|
|
48
49
|
createOpenCodeAdapter({ ...options, profile }),
|
|
49
50
|
acp: (profile, options) =>
|
|
50
51
|
createAcpAdapter({ ...options, profile }),
|
|
52
|
+
'cursor-acp': (profile, options) =>
|
|
53
|
+
createCursorAcpAdapter({ ...options, models: companionModelsForAgent('cursor') }),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const BUILT_IN_AGENT_PROFILES = {
|
|
57
|
+
codex: { id: 'codex:default', driver: 'codex-app-server' },
|
|
58
|
+
cursor: { id: 'cursor:default', driver: 'cursor-acp' },
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -56,9 +64,7 @@ export const DRIVER_FACTORIES = {
|
|
|
56
64
|
export function createLocalAgentAdapter(profileOrAgent, options = {}) {
|
|
57
65
|
const profile =
|
|
58
66
|
typeof profileOrAgent === 'string'
|
|
59
|
-
? profileOrAgent
|
|
60
|
-
? { id: 'codex:default', driver: 'codex-app-server' }
|
|
61
|
-
: null
|
|
67
|
+
? BUILT_IN_AGENT_PROFILES[profileOrAgent] || null
|
|
62
68
|
: profileOrAgent;
|
|
63
69
|
if (!profile?.driver) return null;
|
|
64
70
|
const factory = DRIVER_FACTORIES[profile.driver];
|
package/src/runtimeProfiles.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { createLocalAgentAdapter } from './providers/index.js';
|
|
3
|
+
import { cursorInstallCommand } from './providers/cursorAcp.js';
|
|
3
4
|
|
|
4
5
|
const DRIVER_LABELS = {
|
|
5
6
|
'codex-app-server': 'Codex',
|
|
6
7
|
'claude-code-cli': 'Claude Code',
|
|
8
|
+
'cursor-acp': 'Cursor',
|
|
7
9
|
'direct-byok': 'Local API key',
|
|
8
10
|
opencode: 'OpenCode',
|
|
9
11
|
acp: 'ACP agent',
|
|
@@ -171,9 +173,11 @@ export function diagnosticsFromAgentCheck(check, env = process.env) {
|
|
|
171
173
|
'error',
|
|
172
174
|
'cli_missing',
|
|
173
175
|
check?.error || `${label} is not installed on this computer.`,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
:
|
|
176
|
+
{
|
|
177
|
+
codex: 'Install Codex (npm i -g @openai/codex), then restart the bridge.',
|
|
178
|
+
cursor: `Install the Cursor CLI (${cursorInstallCommand()}), then restart the bridge.`,
|
|
179
|
+
}[check?.agent]
|
|
180
|
+
|| 'Install Claude Code (npm i -g @anthropic-ai/claude-code), then restart the bridge.',
|
|
177
181
|
);
|
|
178
182
|
} else {
|
|
179
183
|
add(
|
|
@@ -186,9 +190,11 @@ export function diagnosticsFromAgentCheck(check, env = process.env) {
|
|
|
186
190
|
'error',
|
|
187
191
|
'sign_in_required',
|
|
188
192
|
check.error || `${label} is not signed in.`,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
: 'Run `
|
|
193
|
+
{
|
|
194
|
+
codex: 'Run `codex login` in a terminal, then restart the bridge.',
|
|
195
|
+
cursor: 'Run `cursor-agent login` in a terminal, then restart the bridge.',
|
|
196
|
+
}[check.agent]
|
|
197
|
+
|| 'Run `claude` in a terminal and sign in, then restart the bridge.',
|
|
192
198
|
);
|
|
193
199
|
} else {
|
|
194
200
|
const method = text(check.authMethod || check.authMode, 80);
|
|
@@ -251,8 +257,37 @@ function normalizeDiagnostics(value) {
|
|
|
251
257
|
};
|
|
252
258
|
}
|
|
253
259
|
|
|
260
|
+
// Cursor runs only Dexter's Framer tools: its own shell and file tools are
|
|
261
|
+
// denied, so it cannot take App Builder workspace outcomes.
|
|
262
|
+
function cursorProfileFromAgentCheck(check, env) {
|
|
263
|
+
return normalizeRuntimeProfile({
|
|
264
|
+
id: 'cursor:default',
|
|
265
|
+
instanceId: 'default',
|
|
266
|
+
label: DRIVER_LABELS['cursor-acp'],
|
|
267
|
+
driver: 'cursor-acp',
|
|
268
|
+
providerId: 'cursor',
|
|
269
|
+
authState: check.signedIn === false ? 'required' : 'ready',
|
|
270
|
+
capabilities: {
|
|
271
|
+
tools: true,
|
|
272
|
+
vision: true,
|
|
273
|
+
structuredOutput: true,
|
|
274
|
+
sessionResume: false,
|
|
275
|
+
progress: true,
|
|
276
|
+
cancellation: true,
|
|
277
|
+
},
|
|
278
|
+
models: check.modelDetails || [],
|
|
279
|
+
modelCatalog: {
|
|
280
|
+
source: check.modelCatalogSource || 'fallback',
|
|
281
|
+
refreshedAt: check.modelCatalogRefreshedAt,
|
|
282
|
+
modelCount: check.modelDetails?.length || 0,
|
|
283
|
+
},
|
|
284
|
+
diagnostics: diagnosticsFromAgentCheck(check, env),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
254
288
|
function profileFromAgentCheck(check, env) {
|
|
255
289
|
if (!check || check.installed === false) return null;
|
|
290
|
+
if (check.agent === 'cursor') return cursorProfileFromAgentCheck(check, env);
|
|
256
291
|
const driver =
|
|
257
292
|
check.agent === 'codex' ? 'codex-app-server' : check.agent === 'claude-code' ? 'claude-code-cli' : null;
|
|
258
293
|
if (!driver) return null;
|