@gakim-digital/dexter-bridge 0.11.19 → 0.11.20

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.11.19",
3
+ "version": "0.11.20",
4
4
  "description": "Local bridge for InstaWebAI and Dexter — runs Codex, Claude Code, or OpenCode on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -174,6 +174,15 @@ export const AGENT_DEFINITIONS = {
174
174
  model: 'codex:gpt-5.5',
175
175
  statusStage: 'codex_start',
176
176
  },
177
+ cursor: {
178
+ id: 'cursor',
179
+ label: 'Cursor',
180
+ commandEnv: 'DEXTER_BRIDGE_CURSOR_BIN',
181
+ fallbackCommand: 'cursor-agent',
182
+ promptMode: 'stdin',
183
+ model: 'cursor:grok-4.7',
184
+ statusStage: 'cursor_start',
185
+ },
177
186
  };
178
187
 
179
188
  export const AGENT_AUTHENTICATION_REQUIRED_CODE = 'DEXTER_AGENT_AUTHENTICATION_REQUIRED';
@@ -887,7 +896,15 @@ export function processEnvWithCliPath(baseEnv = process.env, platform = process.
887
896
  baseEnv.APPDATA ? pathApi.join(baseEnv.APPDATA, 'npm') : '',
888
897
  baseEnv.LOCALAPPDATA ? pathApi.join(baseEnv.LOCALAPPDATA, 'Programs') : '',
889
898
  ]
890
- : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
899
+ : [
900
+ homeDirectory ? pathApi.join(homeDirectory, '.local', 'bin') : '',
901
+ '/opt/homebrew/bin',
902
+ '/usr/local/bin',
903
+ '/usr/bin',
904
+ '/bin',
905
+ '/usr/sbin',
906
+ '/sbin',
907
+ ];
891
908
  const shellEnv = platform === 'win32' ? {} : loginShellEnv();
892
909
  const existingPath = String(baseEnv.PATH || '');
893
910
  const mergedPath = mergePathEntries([shellEnv.PATH, existingPath, ...fallbackPath], platform);
@@ -1416,6 +1433,7 @@ function definitionForRuntimeProfile(profile, fallbackAgent) {
1416
1433
  if (!profile) return definitionForAgent(fallbackAgent);
1417
1434
  if (profile.driver === 'claude-code-cli') return AGENT_DEFINITIONS['claude-code'];
1418
1435
  if (profile.driver === 'codex-app-server') return AGENT_DEFINITIONS.codex;
1436
+ if (profile.driver === 'cursor-acp') return AGENT_DEFINITIONS.cursor;
1419
1437
  return {
1420
1438
  id: profile.id || profile.driver,
1421
1439
  label: profile.label || profile.driver || 'Local runtime',
@@ -3144,7 +3162,8 @@ export async function executeRun(
3144
3162
  });
3145
3163
  const runtimeProfile = run?.runtimeProfile || suppliedRuntimeProfile || null;
3146
3164
  const normalizedAgent = normalizeAgentName(
3147
- run?.companion?.agent || (runtimeProfile?.driver === 'claude-code-cli' ? 'claude-code' : agent),
3165
+ run?.companion?.agent
3166
+ || ({ 'claude-code-cli': 'claude-code', 'cursor-acp': 'cursor' }[runtimeProfile?.driver] || agent),
3148
3167
  );
3149
3168
  const runModel = runtimeProfile?.model?.id || run?.companion?.model?.id || run?.model || selectedModel;
3150
3169
  const usageAccumulator = createCompanionUsageAccumulator(normalizedAgent);
@@ -3335,10 +3354,10 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3335
3354
  modelCatalogRefreshedAt,
3336
3355
  };
3337
3356
  }
3338
- if (normalizedAgent === 'codex') {
3357
+ if (normalizedAgent === 'codex' || normalizedAgent === 'cursor') {
3339
3358
  const adapter =
3340
3359
  options.providerAdapter
3341
- || createLocalAgentAdapter('codex', {
3360
+ || createLocalAgentAdapter(normalizedAgent, {
3342
3361
  env: options.env,
3343
3362
  });
3344
3363
  try {
@@ -3358,7 +3377,7 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3358
3377
  code: detection.installed === false ? 'DEXTER_AGENT_NOT_FOUND' : AGENT_AUTHENTICATION_REQUIRED_CODE,
3359
3378
  error:
3360
3379
  detection.error
3361
- || 'Codex is not signed in. Run `codex login` on this computer, then run the bridge command again.',
3380
+ || `${definition.label} is not signed in. Sign in on this computer, then run the bridge command again.`,
3362
3381
  modelCatalogSource: 'fallback',
3363
3382
  modelCatalogRefreshedAt,
3364
3383
  };
@@ -3438,6 +3457,7 @@ export async function checkAllAgents(options = {}) {
3438
3457
  const agents = await Promise.all([
3439
3458
  checkAgentAvailability('claude-code', undefined, options),
3440
3459
  checkAgentAvailability('codex', undefined, options),
3460
+ checkAgentAvailability('cursor', undefined, options),
3441
3461
  ]);
3442
3462
  return {
3443
3463
  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
- profile.driver === 'codex-app-server'
320
- ? 'codex'
321
- : profile.driver === 'claude-code-cli'
322
- ? 'claude-code'
323
- : null;
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 = agent === 'claude-code' ? 'Claude Code' : agent === 'codex' ? 'Codex' : 'The selected agent';
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
- runtimeProfile?.driver === 'claude-code-cli'
782
- ? 'claude-code'
783
- : runtimeProfile?.driver === 'opencode'
784
- ? 'opencode'
785
- : 'codex'
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: `${normalizedAgent === 'codex' ? 'Codex' : 'Claude Code'} model reported by the connected bridge.`,
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,456 @@
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
+ function runCommand(command, args, env, timeoutMs = 15_000) {
112
+ return new Promise((resolve) => {
113
+ execFile(command, args, { env, timeout: timeoutMs }, (error, stdout, stderr) => {
114
+ resolve({ error, output: `${stdout || ''}${stderr || ''}`.trim() });
115
+ });
116
+ });
117
+ }
118
+
119
+ export const CURSOR_INSTALL_COMMAND = 'curl https://cursor.com/install -fsS | bash';
120
+
121
+ function runInteractive(command, args, env, spawnImpl) {
122
+ return new Promise((resolve) => {
123
+ const child = spawnImpl(command, args, { env, stdio: 'inherit' });
124
+ child.once('error', () => resolve(false));
125
+ child.once('exit', (code) => resolve(code === 0));
126
+ });
127
+ }
128
+
129
+ function setupError(message) {
130
+ return Object.assign(new Error(message), { code: 'CURSOR_SETUP_INCOMPLETE', exitCode: 2 });
131
+ }
132
+
133
+ /**
134
+ * Gets the Cursor CLI installed and signed in so one `connect` command is the
135
+ * whole Cursor setup. Installing asks first; sign-in opens Cursor's own
136
+ * browser login. Does nothing when Cursor is already ready.
137
+ */
138
+ export async function setUpCursorCli({
139
+ env = process.env,
140
+ command = env.DEXTER_BRIDGE_CURSOR_BIN || 'cursor-agent',
141
+ confirm,
142
+ log = console.log,
143
+ platform = process.platform,
144
+ run = runCommand,
145
+ spawnImpl = spawn,
146
+ } = {}) {
147
+ const readStatus = async () => {
148
+ const result = await run(command, ['status'], env);
149
+ if (result.error?.code === 'ENOENT') return { installed: false, signedIn: false };
150
+ return { installed: true, ...parseCursorStatus(result.output) };
151
+ };
152
+ let status = await readStatus();
153
+ if (!status.installed) {
154
+ if (platform === 'win32' || !confirm) {
155
+ throw setupError(`Cursor CLI is not installed. Run \`${CURSOR_INSTALL_COMMAND}\`, then run this command again.`);
156
+ }
157
+ if (!(await confirm('Cursor CLI is not installed. Install it now? (Y/n) '))) {
158
+ throw setupError('Cursor CLI is required to connect Cursor to Dexter.');
159
+ }
160
+ if (!(await runInteractive('bash', ['-c', CURSOR_INSTALL_COMMAND], env, spawnImpl))) {
161
+ throw setupError(`Cursor CLI install failed. Run \`${CURSOR_INSTALL_COMMAND}\` yourself, then run this command again.`);
162
+ }
163
+ status = await readStatus();
164
+ if (!status.installed) {
165
+ throw setupError('Cursor CLI was installed but could not be found. Open a new terminal and run this command again.');
166
+ }
167
+ }
168
+ if (!status.signedIn) {
169
+ if (!confirm) {
170
+ throw setupError('Cursor is not signed in. Run `cursor-agent login`, then run this command again.');
171
+ }
172
+ log('Sign in to Cursor in your browser…');
173
+ await runInteractive(command, ['login'], env, spawnImpl);
174
+ status = await readStatus();
175
+ if (!status.signedIn) {
176
+ throw setupError('Cursor sign-in did not finish. Run this command again to retry.');
177
+ }
178
+ }
179
+ log(`Cursor is ready${status.account ? ` (${status.account})` : ''}.`);
180
+ return status;
181
+ }
182
+
183
+ export function createCursorAcpAdapter({
184
+ env = process.env,
185
+ trace,
186
+ command = env.DEXTER_BRIDGE_CURSOR_BIN || 'cursor-agent',
187
+ models = [],
188
+ createClient = createJsonRpcClient,
189
+ } = {}) {
190
+ const activeTurns = new Map();
191
+
192
+ async function detect() {
193
+ const version = await runCommand(command, ['--version'], env);
194
+ if (version.error) {
195
+ return {
196
+ ok: false,
197
+ installed: false,
198
+ signedIn: false,
199
+ agent: 'cursor',
200
+ error: 'Cursor CLI is not installed. Run `curl https://cursor.com/install -fsS | bash`, then `cursor-agent login`.',
201
+ };
202
+ }
203
+ const status = parseCursorStatus((await runCommand(command, ['status'], env)).output);
204
+ return {
205
+ ok: status.signedIn,
206
+ installed: true,
207
+ signedIn: status.signedIn,
208
+ agent: 'cursor',
209
+ version: version.output || null,
210
+ ...(status.signedIn
211
+ ? {}
212
+ : { error: 'Cursor is not signed in. Run `cursor-agent login` on this computer, then try again.' }),
213
+ };
214
+ }
215
+
216
+ async function runTurn({
217
+ runId,
218
+ sessionId,
219
+ prompt,
220
+ model,
221
+ cwd,
222
+ outputSchema,
223
+ imageFiles = [],
224
+ mcpServer = null,
225
+ timeoutMs = 10 * 60_000,
226
+ maxDurationMs = 20 * 60_000,
227
+ onProgress,
228
+ }) {
229
+ const turnKey = sessionId || runId;
230
+ if (!turnKey) throw new Error('A Cursor turn requires a session id.');
231
+ const temporaryWorkspace = cwd
232
+ ? null
233
+ : fs.mkdtempSync(path.join(os.tmpdir(), 'dexter-cursor-'));
234
+ const workspace = cwd || temporaryWorkspace;
235
+ writeCursorWorkspacePermissions(workspace);
236
+
237
+ let segment = '';
238
+ let narrationIndex = 0;
239
+ let inactivityTimer = null;
240
+ let deadlineTimer = null;
241
+ let failTurn;
242
+ const failure = new Promise((_, reject) => {
243
+ failTurn = reject;
244
+ });
245
+ const timeOut = (code, message) =>
246
+ failTurn(Object.assign(new Error(message), { code, retryable: true }));
247
+ const touch = () => {
248
+ clearTimeout(inactivityTimer);
249
+ inactivityTimer = setTimeout(
250
+ () => timeOut('APP_AGENT_TURN_INACTIVITY_TIMEOUT', `Cursor was inactive for ${timeoutMs}ms.`),
251
+ timeoutMs,
252
+ );
253
+ inactivityTimer.unref?.();
254
+ };
255
+ const flushNarration = () => {
256
+ const message = segment.trim();
257
+ segment = '';
258
+ if (!message) return;
259
+ narrationIndex += 1;
260
+ onProgress?.({
261
+ kind: 'output_delta',
262
+ provider: 'cursor',
263
+ occurredAt: new Date().toISOString(),
264
+ message,
265
+ activity: `assistant:${turnKey}:${narrationIndex}`,
266
+ });
267
+ };
268
+
269
+ const client = createClient({
270
+ command,
271
+ args: ['acp'],
272
+ env,
273
+ cwd: workspace,
274
+ onServerRequest: async (message) => {
275
+ if (message.method === 'session/request_permission') {
276
+ const options = message.params?.options || [];
277
+ const kind = isOwnTool(message.params?.toolCall) ? 'allow_once' : 'reject_once';
278
+ const option = options.find((candidate) => candidate.kind === kind);
279
+ return option
280
+ ? { outcome: { outcome: 'selected', optionId: option.optionId } }
281
+ : { outcome: { outcome: 'cancelled' } };
282
+ }
283
+ throw new Error(`Cursor request "${message.method}" is disabled for Dexter runs.`);
284
+ },
285
+ onNotification: (message) => {
286
+ if (message.method !== 'session/update') return;
287
+ touch();
288
+ const update = message.params?.update || {};
289
+ switch (update.sessionUpdate) {
290
+ case 'agent_message_chunk':
291
+ segment += update.content?.text || '';
292
+ break;
293
+ case 'agent_thought_chunk':
294
+ onProgress?.({ kind: 'reasoning_delta', provider: 'cursor', occurredAt: new Date().toISOString() });
295
+ break;
296
+ case 'tool_call':
297
+ flushNarration();
298
+ break;
299
+ case 'tool_call_update': {
300
+ const name = toolName(update);
301
+ if (name) {
302
+ onProgress?.({
303
+ kind: 'item_started',
304
+ provider: 'cursor',
305
+ occurredAt: new Date().toISOString(),
306
+ name,
307
+ phase: 'started',
308
+ });
309
+ } else if (update.status === 'completed' || update.status === 'failed') {
310
+ onProgress?.({
311
+ kind: 'item_completed',
312
+ provider: 'cursor',
313
+ occurredAt: new Date().toISOString(),
314
+ });
315
+ }
316
+ break;
317
+ }
318
+ default:
319
+ break;
320
+ }
321
+ },
322
+ });
323
+
324
+ const turn = { client, nativeSessionId: null };
325
+ activeTurns.set(turnKey, turn);
326
+ deadlineTimer = setTimeout(
327
+ () => timeOut('APP_AGENT_TURN_HARD_TIMEOUT', `The Cursor turn exceeded the ${maxDurationMs}ms safety limit.`),
328
+ maxDurationMs,
329
+ );
330
+ deadlineTimer.unref?.();
331
+ touch();
332
+
333
+ let restoreModelId = null;
334
+ try {
335
+ const run = async () => {
336
+ await client.request('initialize', {
337
+ protocolVersion: 1,
338
+ clientCapabilities: {
339
+ fs: { readTextFile: false, writeTextFile: false },
340
+ terminal: false,
341
+ },
342
+ clientInfo: { name: 'instawebai-dexter-bridge', version: '1' },
343
+ }, { timeoutMs: 30_000 });
344
+ const session = await client.request('session/new', {
345
+ cwd: workspace,
346
+ mcpServers: cursorMcpServers(mcpServer),
347
+ }, { timeoutMs: 60_000 });
348
+ turn.nativeSessionId = session?.sessionId;
349
+ if (!turn.nativeSessionId) throw new Error('Cursor did not return a session id.');
350
+
351
+ // Selecting a model in ACP also changes the user's default Cursor
352
+ // model, so the previous one is restored after the turn.
353
+ const requested = cursorBaseModelId(model);
354
+ if (requested) {
355
+ const available = session.models?.availableModels || [];
356
+ const variant = available.find((entry) => cursorBaseModelId(entry.modelId) === requested);
357
+ if (!variant) {
358
+ throw Object.assign(
359
+ new Error(`Cursor does not offer ${requested} on this account. Pick another Cursor model.`),
360
+ { code: 'CURSOR_MODEL_UNAVAILABLE', retryable: false },
361
+ );
362
+ }
363
+ const current = session.models?.currentModelId || null;
364
+ if (variant.modelId !== current) {
365
+ await client.request('session/set_model', {
366
+ sessionId: turn.nativeSessionId,
367
+ modelId: variant.modelId,
368
+ }, { timeoutMs: 30_000 });
369
+ restoreModelId = current;
370
+ }
371
+ }
372
+
373
+ onProgress?.({ kind: 'turn_started', provider: 'cursor', occurredAt: new Date().toISOString() });
374
+ const result = await client.request('session/prompt', {
375
+ sessionId: turn.nativeSessionId,
376
+ prompt: [
377
+ { type: 'text', text: withOutputContract(prompt, outputSchema) },
378
+ ...imageBlocks(imageFiles),
379
+ ],
380
+ }, { timeoutMs: 0 });
381
+ const text = segment.trim();
382
+ if (result?.stopReason === 'cancelled') {
383
+ throw Object.assign(new Error('The Cursor turn was cancelled.'), { code: 'RUN_CANCELLED' });
384
+ }
385
+ if (isCursorPlanRefusal(text)) {
386
+ throw planRequiredError(requested, text);
387
+ }
388
+ if (result?.stopReason && result.stopReason !== 'end_turn') {
389
+ throw Object.assign(
390
+ new Error(`Cursor stopped before finishing (${result.stopReason}).`),
391
+ { code: 'CURSOR_TURN_INCOMPLETE', retryable: result.stopReason === 'max_tokens' },
392
+ );
393
+ }
394
+ return {
395
+ text,
396
+ sessionId: turn.nativeSessionId,
397
+ usageAvailable: false,
398
+ usageSource: 'cursor',
399
+ usageAccuracy: 'unavailable',
400
+ };
401
+ };
402
+ return await Promise.race([run(), failure]);
403
+ } finally {
404
+ clearTimeout(inactivityTimer);
405
+ clearTimeout(deadlineTimer);
406
+ activeTurns.delete(turnKey);
407
+ if (restoreModelId && turn.nativeSessionId && !client.closed) {
408
+ await client.request('session/set_model', {
409
+ sessionId: turn.nativeSessionId,
410
+ modelId: restoreModelId,
411
+ }, { timeoutMs: 10_000 }).catch((error) => {
412
+ trace?.warn?.('cursor_model_restore_failed', { error: error?.message });
413
+ });
414
+ }
415
+ client.close();
416
+ if (temporaryWorkspace) {
417
+ fs.rmSync(temporaryWorkspace, { recursive: true, force: true });
418
+ }
419
+ }
420
+ }
421
+
422
+ async function runModelTurn(input = {}) {
423
+ return runTurn(input);
424
+ }
425
+
426
+ async function runOutcome(input = {}) {
427
+ return runTurn({
428
+ ...input,
429
+ mcpServer: input.harnessTools?.mcpServer || null,
430
+ });
431
+ }
432
+
433
+ async function cancel(sessionId) {
434
+ const turn = activeTurns.get(sessionId);
435
+ if (!turn || turn.client.closed || !turn.nativeSessionId) return;
436
+ turn.client.notify('session/cancel', { sessionId: turn.nativeSessionId });
437
+ }
438
+
439
+ function close() {
440
+ for (const turn of activeTurns.values()) turn.client.close();
441
+ activeTurns.clear();
442
+ }
443
+
444
+ return {
445
+ id: 'cursor',
446
+ driverKind: 'cursor-acp',
447
+ label: 'Cursor',
448
+ detect,
449
+ models: async () => models,
450
+ runModelTurn,
451
+ runOutcome,
452
+ cancel,
453
+ resetSession: async () => {},
454
+ close,
455
+ };
456
+ }
@@ -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. Today's shipping
11
- * Codex App Server is the only Codex path so model-only isolation is always
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 === 'codex'
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];
@@ -4,6 +4,7 @@ import { createLocalAgentAdapter } from './providers/index.js';
4
4
  const DRIVER_LABELS = {
5
5
  'codex-app-server': 'Codex',
6
6
  'claude-code-cli': 'Claude Code',
7
+ 'cursor-acp': 'Cursor',
7
8
  'direct-byok': 'Local API key',
8
9
  opencode: 'OpenCode',
9
10
  acp: 'ACP agent',
@@ -171,9 +172,11 @@ export function diagnosticsFromAgentCheck(check, env = process.env) {
171
172
  'error',
172
173
  'cli_missing',
173
174
  check?.error || `${label} is not installed on this computer.`,
174
- check?.agent === 'codex'
175
- ? 'Install Codex (npm i -g @openai/codex), then restart the bridge.'
176
- : 'Install Claude Code (npm i -g @anthropic-ai/claude-code), then restart the bridge.',
175
+ {
176
+ codex: 'Install Codex (npm i -g @openai/codex), then restart the bridge.',
177
+ cursor: 'Install the Cursor CLI (curl https://cursor.com/install -fsS | bash), then restart the bridge.',
178
+ }[check?.agent]
179
+ || 'Install Claude Code (npm i -g @anthropic-ai/claude-code), then restart the bridge.',
177
180
  );
178
181
  } else {
179
182
  add(
@@ -186,9 +189,11 @@ export function diagnosticsFromAgentCheck(check, env = process.env) {
186
189
  'error',
187
190
  'sign_in_required',
188
191
  check.error || `${label} is not signed in.`,
189
- check.agent === 'codex'
190
- ? 'Run `codex login` in a terminal, then restart the bridge.'
191
- : 'Run `claude` in a terminal and sign in, then restart the bridge.',
192
+ {
193
+ codex: 'Run `codex login` in a terminal, then restart the bridge.',
194
+ cursor: 'Run `cursor-agent login` in a terminal, then restart the bridge.',
195
+ }[check.agent]
196
+ || 'Run `claude` in a terminal and sign in, then restart the bridge.',
192
197
  );
193
198
  } else {
194
199
  const method = text(check.authMethod || check.authMode, 80);
@@ -251,8 +256,37 @@ function normalizeDiagnostics(value) {
251
256
  };
252
257
  }
253
258
 
259
+ // Cursor runs only Dexter's Framer tools: its own shell and file tools are
260
+ // denied, so it cannot take App Builder workspace outcomes.
261
+ function cursorProfileFromAgentCheck(check, env) {
262
+ return normalizeRuntimeProfile({
263
+ id: 'cursor:default',
264
+ instanceId: 'default',
265
+ label: DRIVER_LABELS['cursor-acp'],
266
+ driver: 'cursor-acp',
267
+ providerId: 'cursor',
268
+ authState: check.signedIn === false ? 'required' : 'ready',
269
+ capabilities: {
270
+ tools: true,
271
+ vision: true,
272
+ structuredOutput: true,
273
+ sessionResume: false,
274
+ progress: true,
275
+ cancellation: true,
276
+ },
277
+ models: check.modelDetails || [],
278
+ modelCatalog: {
279
+ source: check.modelCatalogSource || 'fallback',
280
+ refreshedAt: check.modelCatalogRefreshedAt,
281
+ modelCount: check.modelDetails?.length || 0,
282
+ },
283
+ diagnostics: diagnosticsFromAgentCheck(check, env),
284
+ });
285
+ }
286
+
254
287
  function profileFromAgentCheck(check, env) {
255
288
  if (!check || check.installed === false) return null;
289
+ if (check.agent === 'cursor') return cursorProfileFromAgentCheck(check, env);
256
290
  const driver =
257
291
  check.agent === 'codex' ? 'codex-app-server' : check.agent === 'claude-code' ? 'claude-code-cli' : null;
258
292
  if (!driver) return null;