From 6cc5405740c432c31b819185c9d832836263f1b6 Mon Sep 17 00:00:00 2001 From: Dev Goswami <101167658+builtbyd3v@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:03:10 -0400 Subject: [PATCH 01/44] feat(agents): add Cursor Agent CLI as a builtin agent Launches cursor-agent; a prompt rides behind the agent subcommand because the CLI ignores -- for subcommand dispatch (measured on 2026.09.23). Joins no capability list yet: spawn, terminal title and process status. --- src/renderer/canvas/Canvas.tsx | 1 + src/shared/agents/config.capabilities.test.ts | 7 ++--- src/shared/agents/config.ts | 25 ++++++++++++++++- src/shared/agents/cursor-launch.test.ts | 27 +++++++++++++++++++ src/shared/agents/pane-owner-predicate.ts | 3 ++- src/shared/keybindings.test.ts | 4 ++- src/shared/keybindings.ts | 3 +++ 7 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 src/shared/agents/cursor-launch.test.ts diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index a3d49d9b3..951f7fcd5 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -8470,6 +8470,7 @@ export function Canvas() { 'node.newAgent.grok': () => { addAgentNode('grok'); return true }, 'node.newAgent.copilot': () => { addAgentNode('copilot'); return true }, 'node.newAgent.antigravity': () => { addAgentNode('antigravity'); return true }, + 'node.newAgent.cursor': () => { addAgentNode('cursor'); return true }, 'node.newSticky': () => { addSticky(); return true }, 'node.newBrowser': () => { addBrowser(); return true }, // Opening the URL prompt IS claiming the chord — a cancelled prompt creates nothing, but the diff --git a/src/shared/agents/config.capabilities.test.ts b/src/shared/agents/config.capabilities.test.ts index 0fbc48743..2ccb851aa 100644 --- a/src/shared/agents/config.capabilities.test.ts +++ b/src/shared/agents/config.capabilities.test.ts @@ -172,10 +172,11 @@ describe('grok capabilities', () => { expect(AGENT_CONFIG.grok.argvPromptSeparator).toBe('--') }) - it('is the ONLY agent that asks for a separator', () => { + it('is the only agent besides cursor that asks for a separator', () => { // claude takes a positional too, but has no subcommand a one-word prompt could shadow — and - // adding `--` there would change a command line that works today. - for (const id of BUILTIN_AGENT_IDS.filter((a) => a !== 'grok')) { + // adding `--` there would change a command line that works today. cursor needs one for the + // same reason as grok, but its CLI ignores `--` (see the cursor block below). + for (const id of BUILTIN_AGENT_IDS.filter((a) => a !== 'grok' && a !== 'cursor')) { expect(AGENT_CONFIG[id].argvPromptSeparator, id).toBeUndefined() } }) diff --git a/src/shared/agents/config.ts b/src/shared/agents/config.ts index 0ea95f66a..47bad7d0a 100644 --- a/src/shared/agents/config.ts +++ b/src/shared/agents/config.ts @@ -12,6 +12,7 @@ export type BuiltinAgentId = | 'grok' | 'copilot' | 'antigravity' + | 'cursor' // Open type — custom agents are any string ('custom:'). Never restrict the set. export type AgentId = BuiltinAgentId | (string & {}) @@ -89,7 +90,8 @@ export const BUILTIN_AGENT_IDS: readonly BuiltinAgentId[] = [ 'gemini', 'opencode', 'grok', - 'copilot' + 'copilot', + 'cursor' ] export const AGENT_CONFIG: Record = { @@ -171,6 +173,27 @@ export const AGENT_CONFIG: Record = { promptInjectionMode: 'flag-interactive', promptFlag: '--prompt-interactive', expectedProcess: 'agy' + }, + cursor: { + // Cursor Agent CLI. Measured on `cursor-agent` 2026.09.23-86fc751 (macOS). + label: 'Cursor', + color: '#6b7280', + // Not `agent`: the CLI installs that alias too, and grok ships a binary of the same name. + launchCmd: 'cursor-agent', + // Usage is `agent [options] [command] [prompt...]`, so a one-word prompt collides with a + // subcommand (`login`, `update`, `ls`, ...). Unlike grok, `--` does NOT help: measured, + // `cursor-agent -- whoami` still runs `whoami`. The `agent` subcommand ("Start the Cursor + // Agent") takes the prompt with no subcommands of its own: `cursor-agent agent whoami` opens + // the TUI with "whoami" as the prompt. `stdin-after-start` is out: a first launch in a folder + // shows a workspace-trust dialog that a typed prompt + Enter would answer. + // UNVERIFIED: whether root flags placed before `agent` reach the session. Moot while cursor + // joins no flag-emitting capability list; measure before it does. + promptInjectionMode: 'argv', + argvPromptSeparator: 'agent', + // The wrapper script runs `exec -a "$0" node index.js`, so ps shows argv0 `cursor-agent`. + expectedProcess: 'cursor-agent', + // The two env overrides `cursor-agent --help` names for auth and endpoint. + vanillaEnvPattern: '^CURSOR_API_(KEY|ENDPOINT)$' } } diff --git a/src/shared/agents/cursor-launch.test.ts b/src/shared/agents/cursor-launch.test.ts new file mode 100644 index 000000000..a0869db51 --- /dev/null +++ b/src/shared/agents/cursor-launch.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_BINARIES } from './pane-owner-predicate' +import { AGENT_CONFIG, BUILTIN_AGENT_IDS } from './config' +import { assembleLaunchCommand } from './launch' + +// cursor-agent's usage is `agent [options] [command] [prompt...]`, and measured on 2026.09.23 its +// parser ignores `--` for subcommand dispatch (`cursor-agent -- whoami` still runs whoami). The +// prompt therefore rides behind the `agent` subcommand, which has no subcommands of its own. +describe('cursor launch line', () => { + const launch = (initialPrompt?: string) => + assembleLaunchCommand({ agentId: 'cursor', initialPrompt }, {}).command + + it('is offered as a builtin that runs cursor-agent, never the ambiguous `agent` alias', () => { + expect(BUILTIN_AGENT_IDS).toContain('cursor') + expect(AGENT_CONFIG.cursor.launchCmd).toBe('cursor-agent') + expect(AGENT_BINARIES.cursor).toEqual(['cursor-agent']) + }) + + it('puts a one-word prompt that names a subcommand behind `agent`', () => { + expect(launch('login')).toBe("cursor-agent agent 'login'") + expect(launch('update')).toBe("cursor-agent agent 'update'") + }) + + it('launches bare with no prompt', () => { + expect(launch()).toBe('cursor-agent') + }) +}) diff --git a/src/shared/agents/pane-owner-predicate.ts b/src/shared/agents/pane-owner-predicate.ts index 2273cbabc..d4b0436a2 100644 --- a/src/shared/agents/pane-owner-predicate.ts +++ b/src/shared/agents/pane-owner-predicate.ts @@ -68,7 +68,8 @@ export const AGENT_BINARIES: Record = { gemini: ['gemini'], opencode: ['opencode'], grok: ['grok'], - antigravity: ['agy'] + antigravity: ['agy'], + cursor: ['cursor-agent'] } /** diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 5e96d5448..ec4d24a5b 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -127,6 +127,8 @@ describe('registry invariants', () => { scope: 'canvas', darwin: [], other: [] }, { id: 'node.newAgent.antigravity', title: 'New Antigravity node', group: 'Nodes', scope: 'canvas', darwin: [], other: [] }, + { id: 'node.newAgent.cursor', title: 'New Cursor node', group: 'Nodes', scope: 'canvas', + darwin: [], other: [] }, { id: 'node.newSticky', title: 'New sticky note', group: 'Nodes', scope: 'canvas', darwin: [], other: [] }, { id: 'node.newBrowser', title: 'New browser node', group: 'Nodes', scope: 'canvas', @@ -217,7 +219,7 @@ describe('registry invariants', () => { 'node.newDino', 'node.newFile' ] - expect(added).toHaveLength(12) + expect(added).toHaveLength(13) for (const id of added) { const d = COMMANDS_BY_ID.get(id as CommandId) expect(d, id).toBeDefined() diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 405e7c687..925182ba7 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -78,6 +78,7 @@ export type CommandId = | 'node.newAgent.grok' | 'node.newAgent.copilot' | 'node.newAgent.antigravity' + | 'node.newAgent.cursor' | 'node.newSticky' | 'node.newBrowser' | 'node.newWebView' @@ -190,6 +191,8 @@ export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = [ scope: 'canvas', defaultBindings: both() }, { id: 'node.newAgent.antigravity', title: `New ${AGENT_CONFIG.antigravity.label} node`, group: 'Nodes', scope: 'canvas', defaultBindings: both() }, + { id: 'node.newAgent.cursor', title: `New ${AGENT_CONFIG.cursor.label} node`, group: 'Nodes', + scope: 'canvas', defaultBindings: both() }, { id: 'node.newSticky', title: 'New sticky note', group: 'Nodes', scope: 'canvas', defaultBindings: both() }, { id: 'node.newBrowser', title: 'New browser node', group: 'Nodes', scope: 'canvas', From 2aa95db230f5728e40efa6c7d8f179192238dbad Mon Sep 17 00:00:00 2001 From: Dev Goswami <101167658+builtbyd3v@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:04:04 -0400 Subject: [PATCH 02/44] feat(agents): draw the Cursor brand mark inline Monochrome mark from @lobehub/icons-static-svg 1.95.1 (MIT), inlined in currentColor like grok and copilot so it follows the theme in menus, node headers, the RUNNING badge and the notch HUD. --- src/renderer/hud/main.ts | 5 ++++- src/renderer/lib/agentIcons.tsx | 28 ++++++++++++++++++++++++++ src/renderer/lib/brandPulse.test.tsx | 1 + src/renderer/lib/brandPulse.ts | 2 +- src/renderer/lib/cursorMark.ts | 30 ++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/renderer/lib/cursorMark.ts diff --git a/src/renderer/hud/main.ts b/src/renderer/hud/main.ts index eb0af244c..0aefc2430 100644 --- a/src/renderer/hud/main.ts +++ b/src/renderer/hud/main.ts @@ -8,6 +8,7 @@ import { CLAUDE_MASCOT, CODEX_MASCOT, DONE_BLOB } from '../lib/mascot' import { HUD_BRAND_PULSE_CLASS, brandPulseBackground, brandPulsePlan } from '../lib/brandPulse' import { createGrokMarkSvg } from '../lib/grokMark' import { createCopilotMarkSvg } from '../lib/copilotMark' +import { createCursorMarkSvg } from '../lib/cursorMark' import { buildIndicator, orderIndicatorAgents } from './indicator' import { HUD_ROW_CAP, overflowLabel, splitPanelRows } from './panel-rows' import { percentText } from '../lib/usageFormat' @@ -212,7 +213,9 @@ function workingMascot(agentId?: string): Element { return plan.kind === 'inline' ? plan.mark === 'copilot' ? createCopilotMarkSvg(plan.size, HUD_BRAND_PULSE_CLASS) - : createGrokMarkSvg(plan.size, HUD_BRAND_PULSE_CLASS) + : plan.mark === 'cursor' + ? createCursorMarkSvg(plan.size, HUD_BRAND_PULSE_CLASS) + : createGrokMarkSvg(plan.size, HUD_BRAND_PULSE_CLASS) : brandPulseMascot(plan.src, plan.size) } const dot = document.createElement('span') diff --git a/src/renderer/lib/agentIcons.tsx b/src/renderer/lib/agentIcons.tsx index ebbfb7d06..4f98de1d1 100644 --- a/src/renderer/lib/agentIcons.tsx +++ b/src/renderer/lib/agentIcons.tsx @@ -3,6 +3,7 @@ import { IconTerminal } from '../components/icons' import { BRAND_PULSE_CLASS, brandLogoSrc, brandPulsePlan } from './brandPulse' import { GROK_MARK_PATH, GROK_MARK_VIEWBOX } from './grokMark' import { COPILOT_MARK_PATHS, COPILOT_MARK_VIEWBOX } from './copilotMark' +import { CURSOR_MARK_PATH, CURSOR_MARK_VIEWBOX } from './cursorMark' // The logo map itself lives in the REACT-FREE lib/brandPulse.ts, because the notch HUD needs the // same assets and must not import React. Re-exported here so React callers have one import for @@ -76,6 +77,30 @@ export function CopilotMark({ ) } +/** The Cursor mark, inlined like `CopilotMark` — geometry from `lib/cursorMark.ts`. */ +export function CursorMark({ + size, + className +}: { + size: number + className?: string +}): React.JSX.Element { + return ( + + + + ) +} + export function BrandPulse({ agentId, size }: { agentId?: AgentId; size: number }): React.JSX.Element | null { const plan = brandPulsePlan(agentId, size) if (!plan) return null @@ -84,6 +109,8 @@ export function BrandPulse({ agentId, size }: { agentId?: AgentId; size: number if (plan.kind === 'inline') return plan.mark === 'copilot' ? ( + ) : plan.mark === 'cursor' ? ( + ) : ( ) @@ -101,6 +128,7 @@ export function AgentIcon({ agentId, size = 16 }: { agentId: AgentId; size?: num const iconAgentId = capabilityAgentId(agentId) if (iconAgentId === 'grok') return if (iconAgentId === 'copilot') return + if (iconAgentId === 'cursor') return // `brandLogoSrc`, not `AGENT_LOGO[iconAgentId]`: the id can be anything a hand-edited // project.json says, and a prototype key ('constructor') would hand back a Function as the image // source. diff --git a/src/renderer/lib/brandPulse.test.tsx b/src/renderer/lib/brandPulse.test.tsx index 9bf2bede6..2a76cb39c 100644 --- a/src/renderer/lib/brandPulse.test.tsx +++ b/src/renderer/lib/brandPulse.test.tsx @@ -41,6 +41,7 @@ describe('brandPulsePlan', () => { // The generalized inline plan carries WHICH mark so each surface draws the right geometry. expect(brandPulsePlan('grok', 16)).toEqual({ kind: 'inline', mark: 'grok', size: 16 }) expect(brandPulsePlan('copilot', 16)).toEqual({ kind: 'inline', mark: 'copilot', size: 16 }) + expect(brandPulsePlan('cursor', 16)).toEqual({ kind: 'inline', mark: 'cursor', size: 16 }) }) it('answers nothing for an agent with no mark, so the caller falls back to the dot', () => { diff --git a/src/renderer/lib/brandPulse.ts b/src/renderer/lib/brandPulse.ts index 56a56a1d2..e98bc53e5 100644 --- a/src/renderer/lib/brandPulse.ts +++ b/src/renderer/lib/brandPulse.ts @@ -44,7 +44,7 @@ export const brandLogoSrc = (agentId: AgentId): string | undefined => /** Does this agent have a brand mark to draw? grok is inlined rather than an asset, so it is not in * AGENT_LOGO — ask through here rather than testing that map directly. */ -export const INLINE_MARK_AGENTS = ['grok', 'copilot'] as const +export const INLINE_MARK_AGENTS = ['grok', 'copilot', 'cursor'] as const export type InlineMark = (typeof INLINE_MARK_AGENTS)[number] export const inlineMarkFor = (agentId: AgentId): InlineMark | null => (INLINE_MARK_AGENTS as readonly string[]).includes(agentId) ? (agentId as InlineMark) : null diff --git a/src/renderer/lib/cursorMark.ts b/src/renderer/lib/cursorMark.ts new file mode 100644 index 000000000..bf728570b --- /dev/null +++ b/src/renderer/lib/cursorMark.ts @@ -0,0 +1,30 @@ +// The Cursor mark, as raw geometry so BOTH renderers can draw it: the React `AgentIcon` / +// `BrandPulse` in the canvas, and the plain-DOM notch HUD, which builds its nodes imperatively and +// cannot use JSX. +// +// Geometry from `@lobehub/icons-static-svg` 1.95.1 `icons/cursor.svg` (MIT). MONOCHROME like grok's +// and copilot's, so it is inlined with `fill="currentColor"` and follows the theme. + +/** The mark's own aspect box (24×24). */ +export const CURSOR_MARK_VIEWBOX = '0 0 24 24' + +/** One evenodd path: the hexagonal cube with its cut-out face. */ +export const CURSOR_MARK_PATH = + 'M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z' + +/** Build the mark as a real SVG element, for the imperative renderer (the notch HUD). */ +export function createCursorMarkSvg(size: number, className: string): SVGSVGElement { + const NS = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(NS, 'svg') + svg.setAttribute('width', String(size)) + svg.setAttribute('height', String(size)) + svg.setAttribute('viewBox', CURSOR_MARK_VIEWBOX) + svg.setAttribute('fill', 'currentColor') + svg.setAttribute('aria-hidden', 'true') + svg.setAttribute('class', className) + const path = document.createElementNS(NS, 'path') + path.setAttribute('fill-rule', 'evenodd') + path.setAttribute('d', CURSOR_MARK_PATH) + svg.appendChild(path) + return svg +} From 21b93865b3ee1773442239a5f575b2c24c060a96 Mon Sep 17 00:00:00 2001 From: Dev Goswami <101167658+builtbyd3v@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:40:58 -0400 Subject: [PATCH 03/44] feat(agents): cursor joins model switch and permission mode Flags go before the agent subcommand (measured: root options reach the session from either side). plan -> --mode plan, bypass -> --force; auto and acceptEdits emit nothing so the default never widens. Cursor and grok share OWN_MODEL_CATALOGUE so gateway models and the gateway default model never reach them. --- docs/cursor-agent.md | 51 ++++++++++++++++ src/core/agent-launch.test.ts | 25 ++++++++ src/renderer/canvas/Canvas.tsx | 6 +- src/renderer/lib/transferItems.tsx | 15 +++-- src/shared/agents/approval-mode.test.ts | 58 ++++++++++++++++++- src/shared/agents/approval-mode.ts | 41 ++++++++++++- src/shared/agents/config.capabilities.test.ts | 25 ++++++++ src/shared/agents/config.ts | 27 +++++++-- src/shared/agents/cursor-launch.test.ts | 26 ++++++++- src/shared/agents/model-gateway.test.ts | 16 +++++ src/shared/agents/model-gateway.ts | 8 ++- 11 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 docs/cursor-agent.md diff --git a/docs/cursor-agent.md b/docs/cursor-agent.md new file mode 100644 index 000000000..078f28a63 --- /dev/null +++ b/docs/cursor-agent.md @@ -0,0 +1,51 @@ +# Cursor Agent (`cursor-agent`) + +## Model and permission flags + +Measured on `cursor-agent` 2026.09.28-64d2043 (macOS). Cursor is in `MODEL_SWITCH_CAPABLE` and +`PERMISSION_MODE_CAPABLE`; the translation lives in `src/shared/agents/approval-mode.ts` +(`CURSOR_MODES`) and `withAgentModel` in `model-gateway.ts`. + +### Where the flags go + +The composed line is `cursor-agent [flags] agent ''`, flags BEFORE `agent`. `agent` is a +commander subcommand with no options of its own; its action reads the root program's options +(`Ie()` is `root.opts()` in the bundle), and root parses `--model`, `--force`, `--mode` wherever they +sit. Measured in the TUI (no prompt, so no model cost) both `cursor-agent --model X agent` and +`cursor-agent agent --model X` show model X; the same for `--force` ("Run Everything"), `--mode plan` +and `--auto-review`. Both composers (`assembleLaunchCommand`, `core/agent-launch.ts`) are pinned by +tests. + +### Mode table + +| nodeterm mode | emitted | why | +|---|---|---| +| Ask each time (`manual`) | nothing | cursor's default is `approvalMode: allowlist`; a shell command off the allowlist stops at "Run this command? Not in allowlist" (measured) | +| Auto (`auto`, the default) | nothing | `--auto-review` would auto-run calls a bare cursor prompts for: widens every existing node at upgrade | +| Accept edits | nothing | no flag means "auto-approve edits, prompt for shell" | +| Plan | `--mode plan` | measured: a "create a file" prompt produced a written plan ("Ready to build?"), no file | +| Bypass all | `--force` | measured: the shell command ran with no prompt, footer "Run Everything" | + +`auto` and `acceptEdits` show in `unsupportedModesNote`. `--sandbox` is a separate axis and is not +touched (cursor's sandbox is off by default, so the Bypass caveat does not claim one). `--mode ask` +has no nodeterm mode. + +### Models + +`--model `, quoted by `withAgentModel`. The catalogue is `cursor-agent models` (about 250 +account-scoped ids, ` -