diff --git a/app/src/main/engineModelCache.ts b/app/src/main/engineModelCache.ts new file mode 100644 index 00000000..ecc3a565 --- /dev/null +++ b/app/src/main/engineModelCache.ts @@ -0,0 +1,120 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { EngineModelList } from './hl/engines/types'; + +const ENGINE_MODEL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +type CachedEngineModelList = EngineModelList & { + cachedAt: number; + expiresAt: number; +}; + +interface EngineModelCacheFile { + version: 1; + entries: Record; +} + +interface EngineModelCacheLogger { + info?: (event: string, fields: Record) => void; + warn: (event: string, fields: Record) => void; +} + +interface StoreOptions { + expectedVersion?: number; +} + +export interface EngineModelCache { + currentVersion(engineId: string): number; + getCached(engineId: string): CachedEngineModelList | null; + invalidate(engineId: string): boolean; + store(engineId: string, list: EngineModelList, opts?: StoreOptions): EngineModelList; +} + +export function createEngineModelCache({ + cachePath, + logger, +}: { + cachePath: () => string; + logger: EngineModelCacheLogger; +}): EngineModelCache { + let cache: EngineModelCacheFile | null = null; + const versions = new Map(); + + const currentVersion = (engineId: string): number => versions.get(engineId) ?? 0; + + const read = (): EngineModelCacheFile => { + if (cache) return cache; + try { + const raw = fs.readFileSync(cachePath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + if (parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') { + cache = { version: 1, entries: parsed.entries as Record }; + return cache; + } + } catch { + // Missing or corrupt cache is non-fatal; model listing can repopulate it. + } + cache = { version: 1, entries: {} }; + return cache; + }; + + const write = (next: EngineModelCacheFile): void => { + cache = next; + try { + const resolved = cachePath(); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, JSON.stringify(next, null, 2)); + } catch (err) { + logger.warn('engineModelCache.writeFailed', { error: (err as Error).message }); + } + }; + + const stamp = (list: EngineModelList): CachedEngineModelList => { + const now = Date.now(); + return { + ...list, + cached: false, + cachedAt: now, + expiresAt: now + ENGINE_MODEL_CACHE_TTL_MS, + }; + }; + + return { + currentVersion, + + getCached(engineId: string): CachedEngineModelList | null { + const entry = read().entries[engineId]; + if (!entry) return null; + if (Date.now() >= entry.expiresAt) return null; + return entry; + }, + + invalidate(engineId: string): boolean { + versions.set(engineId, currentVersion(engineId) + 1); + const current = read(); + if (!(engineId in current.entries)) return false; + delete current.entries[engineId]; + write(current); + return true; + }, + + store(engineId: string, list: EngineModelList, opts?: StoreOptions): EngineModelList { + const stamped = stamp(list); + const expectedVersion = opts?.expectedVersion; + if (expectedVersion != null && currentVersion(engineId) !== expectedVersion) { + logger.info?.('engineModelCache.skipStaleWrite', { + engineId, + expectedVersion, + currentVersion: currentVersion(engineId), + }); + return stamped; + } + if (list.models.length > 0 && list.source !== 'fallback' && !list.error) { + const current = read(); + current.entries[engineId] = stamped; + write(current); + } + return stamped; + }, + }; +} diff --git a/app/src/main/hl/engines/claude-code/adapter.ts b/app/src/main/hl/engines/claude-code/adapter.ts index d0d4658c..c5972601 100644 --- a/app/src/main/hl/engines/claude-code/adapter.ts +++ b/app/src/main/hl/engines/claude-code/adapter.ts @@ -18,6 +18,7 @@ import { runCliCapture, spawnCli } from '../cliSpawn'; import type { AuthProbe, EngineAdapter, + EngineModelList, InstallProbe, ParseContext, ParseResult, @@ -29,6 +30,41 @@ const ID = 'claude-code'; const DISPLAY = 'Claude Code'; const BIN = 'claude'; +function claudeModelList(): EngineModelList { + const models: EngineModelList['models'] = [ + { + id: 'sonnet', + displayName: 'Sonnet', + description: 'Claude Code Sonnet alias', + source: 'static', + }, + { + id: 'opus', + displayName: 'Opus', + description: 'Claude Code Opus alias', + source: 'static', + }, + { + id: 'haiku', + displayName: 'Haiku', + description: 'Claude Code Haiku alias', + source: 'static', + }, + ]; + + const custom = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim(); + if (custom && !models.some((m) => m.id === custom)) { + models.push({ + id: custom, + displayName: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME?.trim() || custom, + description: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION?.trim() || 'Custom Claude Code model', + source: 'env', + }); + } + + return { engineId: ID, models, source: custom ? 'env' : 'static' }; +} + // ── helpers: prompt shaping ───────────────────────────────────────────────── function stringifyToolInput(name: string, input: Record): string { @@ -115,6 +151,10 @@ const claudeCodeAdapter: EngineAdapter = { }); }, + async listModels(): Promise { + return claudeModelList(); + }, + wrapPrompt(ctx: SpawnContext): string { const lines: string[] = [ 'You are driving a specific Chromium browser view on this machine.', @@ -145,6 +185,7 @@ const claudeCodeAdapter: EngineAdapter = { '--verbose', '--dangerously-skip-permissions', ]; + if (_ctx.model) args.push('--model', _ctx.model); if (_ctx.resumeSessionId) args.push('--resume', _ctx.resumeSessionId); args.push(wrappedPrompt); return args; diff --git a/app/src/main/hl/engines/codex/adapter.ts b/app/src/main/hl/engines/codex/adapter.ts index edc40dd0..b3c24e4e 100644 --- a/app/src/main/hl/engines/codex/adapter.ts +++ b/app/src/main/hl/engines/codex/adapter.ts @@ -18,15 +18,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import { mainLogger } from '../../../logger'; import { register } from '../registry'; import { applyBrowserHarnessEnv } from '../browserHarnessEnv'; import { enrichedEnv } from '../pathEnrich'; -import { runCliCapture } from '../cliSpawn'; +import { runCliCapture, spawnCli } from '../cliSpawn'; import { runCodexDeviceLogin } from '../../../identity/codexLogin'; import type { AuthProbe, EngineAdapter, + EngineModelList, InstallProbe, ParseContext, ParseResult, @@ -40,6 +42,166 @@ const DISPLAY = 'Codex'; const BIN = 'codex'; const BYPASS_APPROVALS_FLAG = '--dangerously-bypass-approvals-and-sandbox'; +const CODEX_FALLBACK_MODELS: EngineModelList['models'] = [ + { + id: 'gpt-5.5', + displayName: 'GPT-5.5', + description: 'Frontier Codex model', + source: 'fallback', + }, + { + id: 'gpt-5.4', + displayName: 'GPT-5.4', + description: 'Strong everyday coding model', + source: 'fallback', + }, + { + id: 'gpt-5.4-mini', + displayName: 'GPT-5.4 Mini', + description: 'Fast, cost-efficient coding model', + source: 'fallback', + }, + { + id: 'gpt-5.3-codex', + displayName: 'GPT-5.3 Codex', + description: 'Codex coding model', + source: 'fallback', + }, +]; + +function normalizeCodexModels(raw: unknown): EngineModelList['models'] { + const data = raw && typeof raw === 'object' ? (raw as { data?: unknown }).data : undefined; + if (!Array.isArray(data)) return []; + return data.flatMap((item): EngineModelList['models'] => { + if (!item || typeof item !== 'object') return []; + const m = item as Record; + const id = typeof m.model === 'string' ? m.model : typeof m.id === 'string' ? m.id : null; + if (!id) return []; + const efforts = Array.isArray(m.supportedReasoningEfforts) + ? m.supportedReasoningEfforts + .map((e) => e && typeof e === 'object' && typeof (e as Record).reasoningEffort === 'string' + ? String((e as Record).reasoningEffort) + : null) + .filter((e): e is string => Boolean(e)) + : undefined; + return [{ + id, + displayName: typeof m.displayName === 'string' ? m.displayName : id, + description: typeof m.description === 'string' ? m.description : undefined, + source: 'app-server', + hidden: typeof m.hidden === 'boolean' ? m.hidden : undefined, + isDefault: typeof m.isDefault === 'boolean' ? m.isDefault : undefined, + supportedReasoningEfforts: efforts && efforts.length > 0 ? efforts : undefined, + }]; + }); +} + +function listCodexModelsViaAppServer(timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + let child: ChildProcessWithoutNullStreams | undefined; + let settled = false; + let stdoutBuf = ''; + let stderrBuf = ''; + const settle = (result: EngineModelList) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child?.kill('SIGTERM'); } catch { /* already closed */ } + resolve(result); + }; + const fallback = (error: string): EngineModelList => ({ + engineId: ID, + source: 'fallback', + error, + models: CODEX_FALLBACK_MODELS, + }); + const send = (payload: unknown) => { + try { child?.stdin.write(`${JSON.stringify(payload)}\n`); } + catch { /* close handler will return fallback */ } + }; + const handleMessage = (msg: Record) => { + if (msg.id === 1) { + if (msg.error) { + const error = msg.error && typeof msg.error === 'object' && typeof (msg.error as Record).message === 'string' + ? String((msg.error as Record).message) + : 'Codex app-server initialize failed'; + settle(fallback(error)); + return; + } + send({ method: 'initialized' }); + send({ id: 2, method: 'model/list', params: { includeHidden: false } }); + return; + } + if (msg.id === 2) { + if (msg.error) { + const error = msg.error && typeof msg.error === 'object' && typeof (msg.error as Record).message === 'string' + ? String((msg.error as Record).message) + : 'Codex app-server model/list failed'; + settle(fallback(error)); + return; + } + const models = normalizeCodexModels(msg.result); + settle({ + engineId: ID, + source: models.length > 0 ? 'app-server' : 'fallback', + error: models.length > 0 ? undefined : 'Codex app-server returned no models', + models: models.length > 0 ? models : CODEX_FALLBACK_MODELS, + }); + } + }; + + const timer = setTimeout(() => { + settle(fallback('Codex app-server model/list timed out')); + }, timeoutMs); + + try { + const env = enrichedEnv(); + child = spawnCli(BIN, ['app-server', '--listen', 'stdio://'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (err) { + settle(fallback((err as Error).message)); + return; + } + + child.stdout.on('data', (d) => { + stdoutBuf += String(d); + let idx; + while ((idx = stdoutBuf.indexOf('\n')) >= 0) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + handleMessage(parsed); + } catch { + // Ignore non-protocol noise defensively. + } + } + }); + child.stderr.on('data', (d) => { + stderrBuf += String(d); + if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096); + }); + child.on('spawn', () => { + send({ + id: 1, + method: 'initialize', + params: { + clientInfo: { name: 'browser-use-desktop', title: 'Browser Use', version: '0.0.30' }, + capabilities: { experimentalApi: true }, + }, + }); + }); + child.on('error', (err) => { + settle(fallback(err.message)); + }); + child.on('close', (code) => { + if (!settled) { + settle(fallback(stderrBuf.trim() || `Codex app-server exited before model/list completed (${code})`)); + } + }); + }); +} + function codexAuthFilePath(): string { const home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); return path.join(home, 'auth.json'); @@ -106,6 +268,10 @@ const codexAdapter: EngineAdapter = { return runCodexDeviceLogin(opts); }, + async listModels(): Promise { + return listCodexModelsViaAppServer(); + }, + wrapPrompt(ctx: SpawnContext): string { const lines: string[] = [ 'You are driving a specific Chromium browser view on this machine.', @@ -134,10 +300,11 @@ const codexAdapter: EngineAdapter = { // getStdinPayload below for why we never pass the prompt via argv. // The bypass flag skips sandbox + approvals, mirroring Claude Code's // --dangerously-skip-permissions for this app-managed harness. + const modelArgs = ctx.model ? ['--model', ctx.model] : []; if (ctx.resumeSessionId) { - return ['exec', 'resume', '--json', BYPASS_APPROVALS_FLAG, ctx.resumeSessionId, '-']; + return ['exec', 'resume', '--json', BYPASS_APPROVALS_FLAG, ...modelArgs, ctx.resumeSessionId, '-']; } - return ['exec', '--json', BYPASS_APPROVALS_FLAG, '-']; + return ['exec', '--json', BYPASS_APPROVALS_FLAG, ...modelArgs, '-']; }, getStdinPayload(_ctx: SpawnContext, wrappedPrompt: string): string { diff --git a/app/src/main/hl/engines/cursor-agent/adapter.ts b/app/src/main/hl/engines/cursor-agent/adapter.ts new file mode 100644 index 00000000..7c20a983 --- /dev/null +++ b/app/src/main/hl/engines/cursor-agent/adapter.ts @@ -0,0 +1,409 @@ +/** + * Cursor Agent engine adapter — wraps `agent -p --output-format stream-json`. + * CLI: https://docs.cursor.com/en/cli/overview (binary name: `agent`). + * + * Stream-json shape (similar to Claude Code but distinct): + * system/init → captures session_id (for --resume) and model + * user → echo of the prompt; ignored + * assistant (delta) → message.content[].text — partial chunks carry + * a `timestamp_ms`; emit each as `thinking` + * assistant (final) → same shape but no `timestamp_ms`; ignored to + * avoid duplicating the streamed deltas + * tool_call started → tool_call.ToolCall.args + * tool_call completed → tool_call.ToolCall.result.{success|error} + * result → done / error + usage (no cost field, estimated) + */ + +import { spawn } from 'node:child_process'; +import { mainLogger } from '../../../logger'; +import { register } from '../registry'; +import { enrichedEnv, resolveCliSpawn } from '../pathEnrich'; +import type { + AuthProbe, + EngineAdapter, + EngineModelList, + InstallProbe, + ParseContext, + ParseResult, + SpawnContext, +} from '../types'; +import type { HlEvent } from '../../../../shared/session-schemas'; + +const ID = 'cursor-agent'; +const DISPLAY = 'Cursor Agent'; +const BIN = 'agent'; + +function parseCursorModels(stdout: string): EngineModelList['models'] { + const models: EngineModelList['models'] = []; + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line === 'Available models' || line.startsWith('Tip:')) continue; + const match = line.match(/^(\S+)\s+-\s+(.+)$/); + if (!match) continue; + const id = match[1]; + const rawName = match[2].trim(); + const isDefault = /\(default\)/i.test(rawName); + const isCurrent = /\(current\)/i.test(rawName); + const displayName = rawName.replace(/\s+\((?:default|current)\)/gi, '').trim(); + models.push({ + id, + displayName: displayName || id, + source: 'cli', + isDefault, + isCurrent, + }); + } + return models; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +function runCli(args: string[], timeoutMs = 5000): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return new Promise((resolve) => { + let child; + try { + const env = enrichedEnv(); + const resolved = resolveCliSpawn(BIN, args, { env }); + child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions }); + } + catch { resolve({ ok: false, stdout: '', stderr: 'spawn failed' }); return; } + let stdout = ''; let stderr = ''; + child.stdout.on('data', (d) => (stdout += String(d))); + child.stderr.on('data', (d) => (stderr += String(d))); + const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs); + child.on('error', () => { clearTimeout(timer); resolve({ ok: false, stdout, stderr }); }); + child.on('close', (code) => { clearTimeout(timer); resolve({ ok: code === 0, stdout, stderr }); }); + }); +} + +/** Map cursor's `ToolCall` wrapper key to a Claude-Code-style tool name + * the rest of the UI/postprocessor already understands. Unknown wrappers fall + * through with the `ToolCall` suffix stripped. */ +function wrapperToToolName(wrapperKey: string): string { + if (wrapperKey === 'shellToolCall') return 'Bash'; + if (wrapperKey === 'readToolCall') return 'Read'; + if (wrapperKey === 'writeToolCall') return 'Write'; + if (wrapperKey === 'editToolCall' || wrapperKey === 'patchToolCall') return 'Edit'; + if (wrapperKey === 'lsToolCall') return 'LS'; + if (wrapperKey === 'globToolCall') return 'Glob'; + if (wrapperKey === 'grepToolCall') return 'Grep'; + if (wrapperKey === 'webSearchToolCall') return 'WebSearch'; + if (wrapperKey === 'webFetchToolCall') return 'WebFetch'; + // Fallback: drop trailing "ToolCall", capitalize first letter so it renders + // nicely in the agent pane (e.g. `taskToolCall` → `Task`). + const stripped = wrapperKey.replace(/ToolCall$/, ''); + return stripped ? stripped.charAt(0).toUpperCase() + stripped.slice(1) : wrapperKey; +} + +/** Pull a meaningful args object out of cursor's wrapped tool_call payload. + * Different tools nest things differently — shell puts the command at + * `args.command`, read at `args.path`, etc. Forward the raw `args` plus a + * `preview` string so the renderer can show something inline. */ +function extractToolArgs(name: string, raw: Record | undefined): Record { + const args = (raw?.args as Record | undefined) ?? {}; + const out: Record = { ...args }; + let preview: string; + if (name === 'Bash' && typeof args.command === 'string') { + preview = args.command as string; + } else if (typeof args.path === 'string') { + preview = args.path as string; + } else if (typeof args.file_path === 'string') { + preview = args.file_path as string; + } else { + preview = JSON.stringify(args); + } + out.preview = preview; + // Surface `path`/`file_path` interchangeably so the harness post-processor + // (which detects helpers.js / AGENTS.md edits) catches cursor reads/writes. + if (typeof args.path === 'string' && typeof out.file_path !== 'string') { + out.file_path = args.path; + } + return out; +} + +function extractToolResult(raw: Record | undefined): { text: string; ok: boolean } { + const result = raw?.result as Record | undefined; + if (!result) return { text: '', ok: true }; + if (result.error) { + const err = result.error as Record | string; + if (typeof err === 'string') return { text: err, ok: false }; + return { text: JSON.stringify(err), ok: false }; + } + const success = result.success as Record | undefined; + if (!success) return { text: JSON.stringify(result), ok: true }; + // Shell tools: prefer stdout, fall back to stderr. + if (typeof success.stdout === 'string' || typeof success.stderr === 'string') { + const stdout = (success.stdout as string | undefined) ?? ''; + const stderr = (success.stderr as string | undefined) ?? ''; + const exitCode = typeof success.exitCode === 'number' ? (success.exitCode as number) : 0; + return { text: stdout || stderr, ok: exitCode === 0 }; + } + // File tools: `content` for read, `path`/`bytesWritten` for write. + if (typeof success.content === 'string') return { text: success.content as string, ok: true }; + return { text: JSON.stringify(success), ok: true }; +} + +// ── adapter ───────────────────────────────────────────────────────────────── + +const cursorAgentAdapter: EngineAdapter = { + id: ID, + displayName: DISPLAY, + binaryName: BIN, + + async probeInstalled(): Promise { + const r = await runCli(['--version']); + if (!r.ok) return { installed: false, error: r.stderr || 'agent not found on PATH' }; + const m = r.stdout.match(/(\d{4}\.\d{2}\.\d{2}[\w.-]*)/) ?? r.stdout.match(/(\d+\.\d+\.\d+)/); + return { installed: true, version: m?.[1] }; + }, + + async probeAuthed(): Promise { + // `agent status` exits 0 in both states; discriminate on output text. + const r = await runCli(['status']); + const text = `${r.stdout}\n${r.stderr}`; + if (/logged in as/i.test(text)) return { authed: true }; + if (/not logged in/i.test(text)) return { authed: false, error: 'not logged in' }; + if (!r.ok) return { authed: false, error: r.stderr || r.stdout || 'agent status failed' }; + return { authed: false, error: 'unknown auth state' }; + }, + + async openLoginInTerminal(): Promise<{ opened: boolean; error?: string }> { + // `agent login` opens the system browser to the OAuth flow and waits for + // the callback. We spawn with stdio pipes so the child stays alive after + // this Promise resolves; the EnginePicker polls probeAuthed() to detect + // completion. + return new Promise((resolve) => { + let child; + try { + const env = enrichedEnv(); + const resolved = resolveCliSpawn(BIN, ['login'], { env }); + child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions }); + } catch (err) { + resolve({ opened: false, error: (err as Error).message }); + return; + } + let stderrBuf = ''; + let stdoutBuf = ''; + let settled = false; + const finish = (result: { opened: boolean; error?: string }) => { + if (settled) return; + settled = true; + resolve(result); + }; + const timer = setTimeout(() => { + mainLogger.warn('cursor-agent.login.timeout'); + try { child.kill('SIGTERM'); } catch { /* already closed */ } + }, 5 * 60 * 1000); + + child.stdout.on('data', (d) => { stdoutBuf += String(d); if (stdoutBuf.length > 4096) stdoutBuf = stdoutBuf.slice(-4096); }); + child.stderr.on('data', (d) => { stderrBuf += String(d); if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096); }); + child.on('spawn', () => { + mainLogger.info('cursor-agent.login.spawn'); + finish({ opened: true }); + }); + child.on('error', (err) => { + clearTimeout(timer); + mainLogger.warn('cursor-agent.login.error', { error: err.message }); + finish({ opened: false, error: err.message }); + }); + child.on('close', (code) => { + clearTimeout(timer); + mainLogger.info('cursor-agent.login.close', { code, stderr: stderrBuf.slice(-400) }); + if (code !== 0 && !settled) { + finish({ opened: false, error: stderrBuf.trim() || stdoutBuf.trim() || `agent login exit ${code}` }); + } + }); + }); + }, + + async listModels(): Promise { + const r = await runCli(['--list-models'], 10_000); + if (!r.ok) { + return { + engineId: ID, + source: 'fallback', + error: r.stderr || r.stdout || 'Unable to list Cursor Agent models', + models: [ + { id: 'auto', displayName: 'Auto', source: 'fallback' }, + { id: 'composer-2-fast', displayName: 'Composer 2 Fast', source: 'fallback' }, + { id: 'composer-2', displayName: 'Composer 2', source: 'fallback' }, + ], + }; + } + const models = parseCursorModels(r.stdout); + return { + engineId: ID, + source: 'cli', + models, + }; + }, + + wrapPrompt(ctx: SpawnContext): string { + const lines: string[] = [ + 'You are driving a specific Chromium browser view on this machine.', + `Your target is CDP target_id=${ctx.targetId} on port ${ctx.cdpPort} (env BU_TARGET_ID / BU_CDP_PORT).`, + 'Read `./AGENTS.md` for how to drive the browser in this harness.', + 'Always read `./helpers.js` before writing scripts — that is where the functions live. Edit it if a helper is missing.', + ]; + if (ctx.attachmentRefs.length > 0) { + lines.push('', 'The user attached these files for this task. Read each with your Read tool before acting:'); + for (const a of ctx.attachmentRefs) lines.push(` - ${a.relPath} (${a.mime}, ${a.size} bytes)`); + } + lines.push( + '', + `When the user asks you to produce a file (a report, CSV, screenshot, transcript, etc.), save it to \`./outputs/${ctx.sessionId}/\`. Mention the filename in your final answer.`, + '', + `Task: ${ctx.prompt}`, + ); + return lines.join('\n'); + }, + + buildSpawnArgs(ctx: SpawnContext, wrappedPrompt: string): string[] { + // --print: headless mode; --output-format stream-json: NDJSON we parse; + // --stream-partial-output: emit text deltas as separate events so the UI + // streams thinking instead of dumping the final message at the end; + // --force / --yolo: skip approvals (browser is already scoped by env); + // --trust: trust the harness cwd without prompting (only valid with -p); + // --sandbox disabled: helpers.js makes outbound CDP/network calls; the + // default sandbox can break those in headless mode. + const args: string[] = [ + '-p', + '--output-format', 'stream-json', + '--stream-partial-output', + '--force', + '--trust', + '--sandbox', 'disabled', + ]; + if (ctx.model) args.push('--model', ctx.model); + if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId); + args.push(wrappedPrompt); + return args; + }, + + buildEnv(ctx: SpawnContext, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = enrichedEnv(baseEnv); + // Strip any pre-existing CURSOR_API_KEY so OAuth (`agent login`) wins by + // default. If the user saved an explicit API key in the app, inject it. + delete env.CURSOR_API_KEY; + if (ctx.savedApiKey) env.CURSOR_API_KEY = ctx.savedApiKey; + env.BU_TARGET_ID = ctx.targetId; + env.BU_CDP_PORT = String(ctx.cdpPort); + return env; + }, + + parseLine(line: string, ctx: ParseContext): ParseResult { + let evt: unknown; + try { evt = JSON.parse(line); } catch { return { events: [] }; } + if (!evt || typeof evt !== 'object') return { events: [] }; + const e = evt as Record; + const type = e.type as string | undefined; + const events: HlEvent[] = []; + let capturedSessionId: string | undefined; + let terminalDone = false; + let terminalError: string | undefined; + + if (type === 'system') { + const subtype = e.subtype as string | undefined; + if (subtype === 'init') { + mainLogger.info('cursor-agent.init', { model: e.model, session_id: e.session_id, apiKeySource: e.apiKeySource }); + if (typeof e.session_id === 'string') capturedSessionId = e.session_id; + if (typeof e.model === 'string') ctx.currentModel = e.model; + } + return { events, capturedSessionId }; + } + + if (type === 'user') { + // Echo of the user's own prompt — nothing to surface. + return { events }; + } + + if (type === 'assistant') { + // Cursor emits two flavors of assistant message: + // - streamed deltas (have `timestamp_ms`) — small text fragments that + // should each become a `thinking` event so the UI streams them. + // - one final consolidated message (no `timestamp_ms`) — duplicates + // the concatenated deltas; skip to avoid double-rendering. + const isDelta = typeof e.timestamp_ms === 'number'; + if (!isDelta) return { events }; + const msg = e.message as Record | undefined; + const content = msg?.content as Array> | undefined; + if (!Array.isArray(content)) return { events }; + for (const block of content) { + if (block?.type !== 'text') continue; + const txt = typeof block.text === 'string' ? (block.text as string) : ''; + if (txt.length > 0) { + events.push({ type: 'thinking', text: txt }); + if (txt.trim()) ctx.lastNarrative = txt; + } + } + return { events }; + } + + if (type === 'tool_call') { + const subtype = e.subtype as string | undefined; + const callId = e.call_id as string | undefined; + const wrapper = e.tool_call as Record | undefined; + if (!callId || !wrapper) return { events }; + const wrapperKeys = Object.keys(wrapper); + const wrapperKey = wrapperKeys[0]; + if (!wrapperKey) return { events }; + const inner = wrapper[wrapperKey] as Record | undefined; + const name = wrapperToToolName(wrapperKey); + + if (subtype === 'started') { + ctx.iter++; + const args = extractToolArgs(name, inner); + ctx.pendingTools.set(callId, { name, startedAt: Date.now(), iter: ctx.iter }); + events.push({ type: 'tool_call', name, args, iteration: ctx.iter }); + return { events }; + } + if (subtype === 'completed') { + const match = ctx.pendingTools.get(callId); + const { text, ok } = extractToolResult(inner); + const ms = match ? Date.now() - match.startedAt : 0; + const resolvedName = match?.name ?? name; + events.push({ type: 'tool_result', name: resolvedName, ok, preview: text.slice(0, 2000), ms }); + ctx.pendingTools.delete(callId); + return { events }; + } + return { events }; + } + + if (type === 'result') { + // Cursor's result has `usage` but no cost field — surface the tokens + // with cost=0 so the session totals at least reflect token consumption. + const usage = e.usage as Record | undefined; + if (usage) { + const inputTokens = typeof usage.inputTokens === 'number' ? (usage.inputTokens as number) : 0; + const outputTokens = typeof usage.outputTokens === 'number' ? (usage.outputTokens as number) : 0; + const cacheRead = typeof usage.cacheReadTokens === 'number' ? (usage.cacheReadTokens as number) : 0; + const cacheWrite = typeof usage.cacheWriteTokens === 'number' ? (usage.cacheWriteTokens as number) : 0; + events.push({ + type: 'turn_usage', + inputTokens, + outputTokens, + cachedInputTokens: cacheRead + cacheWrite, + costUsd: 0, + model: ctx.currentModel, + source: 'estimated', + }); + mainLogger.info('cursor-agent.turnUsage', { inputTokens, outputTokens, cacheRead, cacheWrite, model: ctx.currentModel }); + } + + const isError = e.is_error === true; + const subtype = e.subtype as string | undefined; + const resultText = (e.result as string | undefined) ?? ''; + if (isError || (subtype && subtype !== 'success')) { + terminalError = `cursor_agent_error: ${subtype ?? 'error'} ${resultText}`.trim(); + events.push({ type: 'error', message: terminalError }); + } else { + terminalDone = true; + events.push({ type: 'done', summary: resultText || ctx.lastNarrative || '(done)', iterations: ctx.iter }); + } + } + + return { events, capturedSessionId, terminalDone, terminalError }; + }, +}; + +register(cursorAgentAdapter); diff --git a/app/src/main/hl/engines/index.ts b/app/src/main/hl/engines/index.ts index 1191366b..ffa928a1 100644 --- a/app/src/main/hl/engines/index.ts +++ b/app/src/main/hl/engines/index.ts @@ -6,6 +6,7 @@ // Adapters (side-effect register()): import './claude-code/adapter'; import './codex/adapter'; +import './cursor-agent/adapter'; import './browsercode/adapter'; export { runEngine } from './runEngine'; diff --git a/app/src/main/hl/engines/runEngine.ts b/app/src/main/hl/engines/runEngine.ts index 93c4912b..66fd62f1 100644 --- a/app/src/main/hl/engines/runEngine.ts +++ b/app/src/main/hl/engines/runEngine.ts @@ -131,7 +131,7 @@ export async function runEngine(opts: RunEngineOptions): Promise { // Anthropic key to OpenAI (or vice versa). let savedApiKey: string | undefined; let providerId: string | undefined; - let model: string | undefined; + let model: string | undefined = opts.model; let cliAuthed = false; try { if (adapter.id === 'codex') { @@ -142,7 +142,7 @@ export async function runEngine(opts: RunEngineOptions): Promise { const cfg = await loadBrowserCodeConfig(); if (cfg?.apiKey) savedApiKey = cfg.apiKey; if (cfg?.providerId) providerId = cfg.providerId; - if (cfg?.model) model = cfg.model; + if (!model && cfg?.model) model = cfg.model; // BrowserCode is configured exclusively through provider API keys in // Settings. Do not classify a saved provider key as CLI-managed OAuth. cliAuthed = false; diff --git a/app/src/main/hl/engines/types.ts b/app/src/main/hl/engines/types.ts index 22bff6f4..59666ff8 100644 --- a/app/src/main/hl/engines/types.ts +++ b/app/src/main/hl/engines/types.ts @@ -96,6 +96,8 @@ export interface EngineAdapter { * can't use the default localhost-callback OAuth. Callers should poll * `probeAuthed()` to detect when auth.json / OAuth creds appear. */ openLoginInTerminal(opts?: { deviceAuth?: boolean }): Promise<{ opened: boolean; error?: string; verificationUrl?: string; deviceCode?: string }>; + /** List selectable models when the engine supports it. Static fallbacks are OK. */ + listModels?(): Promise; // Execution /** Produce the argv for spawning this engine in headless mode. */ @@ -122,6 +124,8 @@ export interface EngineAdapter { export interface RunEngineOptions { engineId: string; prompt: string; + /** Optional model id selected by the user. Undefined means use the engine default. */ + model?: string; sessionId: string; webContents: WebContents; cdpPort: number; @@ -137,3 +141,24 @@ export interface RunEngineOptions { * can stamp the session with the mode that actually ran it. */ onAuthResolved?: (info: { authMode: 'apiKey' | 'subscription' | null; subscriptionType: string | null }) => void; } + +export interface EngineModelInfo { + id: string; + displayName: string; + description?: string; + source: 'cli' | 'app-server' | 'static' | 'env' | 'fallback'; + isDefault?: boolean; + isCurrent?: boolean; + hidden?: boolean; + supportedReasoningEfforts?: string[]; +} + +export interface EngineModelList { + engineId: string; + models: EngineModelInfo[]; + source: EngineModelInfo['source']; + error?: string; + cached?: boolean; + cachedAt?: number; + expiresAt?: number; +} diff --git a/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts b/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts index 30f31c18..e1b4ed5e 100644 --- a/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts +++ b/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts @@ -5104,7 +5104,7 @@ export namespace Preload { * Source text of JSON representing the rule set. If it comes from * `