@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 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.21",
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
@@ -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
- : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
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 || (runtimeProfile?.driver === 'claude-code-cli' ? 'claude-code' : 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 = await resolveAgentRuntime(definition, selectedModel, {
3316
- env: options.env,
3317
- platform: options.platform,
3318
- existsSync: options.existsSync,
3319
- inspect: options.inspectRuntime,
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('codex', {
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
- || 'Codex is not signed in. Run `codex login` on this computer, then run the bridge command again.',
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
- 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,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
+ }
@@ -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];
@@ -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
- 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.',
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
- 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.',
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;