openpencil/apps/web/server/api/ai/connect-agent.ts
Kayshen Xu 8472d4ac04 V0.5.0 (#67)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 09:44:04 +08:00

844 lines
34 KiB
TypeScript

import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { serverLog } from '../../utils/server-logger'
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
/** Windows npm global installs may create .cmd or .ps1 wrappers — try both */
function winNpmCandidates(dir: string, name: string): string[] {
return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]
}
/**
* On Windows, `where` may return an extensionless Unix shell script (e.g. `…/npm/opencode`).
* This file exists but can't be executed. Prefer `.cmd` or `.ps1` wrapper at the same location.
*/
function resolveWinExtension(binPath: string): string {
if (process.platform !== 'win32') return binPath
// Already has a usable extension
if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath
// Try .cmd then .ps1
for (const ext of ['.cmd', '.ps1']) {
if (existsSync(binPath + ext)) return binPath + ext
}
return binPath
}
/** Build a shell command to invoke a resolved binary (handles .ps1 on Windows) */
function buildExecCmd(binPath: string, args: string): string {
if (binPath.endsWith('.ps1')) {
return `powershell -ExecutionPolicy Bypass -File "${binPath}" ${args}`
}
return `"${binPath}" ${args}`
}
interface ConnectBody {
agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'
}
interface ConnectResult {
connected: boolean
models: GroupedModel[]
error?: string
warning?: string
notInstalled?: boolean
/** Human-readable connection status, e.g. "Connected via API key" */
connectionInfo?: string
/** Config file path for the hint (client renders localized text) */
hintPath?: string
}
/**
* POST /api/ai/connect-agent
* Actively connects to a local CLI tool and fetches its supported models.
*/
export default defineEventHandler(async (event) => {
const body = await readBody<ConnectBody>(event)
setResponseHeaders(event, { 'Content-Type': 'application/json' })
if (!body?.agent) {
return { connected: false, models: [], error: 'Missing agent field' } satisfies ConnectResult
}
if (body.agent === 'claude-code') {
return connectClaudeCode()
}
if (body.agent === 'codex-cli') {
return connectCodexCli()
}
if (body.agent === 'opencode') {
return connectOpenCode()
}
if (body.agent === 'copilot') {
return connectCopilot()
}
if (body.agent === 'gemini-cli') {
return connectGeminiCli()
}
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
})
/**
* Fallback models when supportedModels() fails.
* Used with third-party API proxies (e.g. Claude Router) that don't support
* the model-listing endpoint. Covers common model IDs routers typically expose.
*/
const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [
{ value: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', description: '', provider: 'anthropic' },
{ value: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', description: '', provider: 'anthropic' },
{ value: 'claude-sonnet-4-5-20250514', displayName: 'Claude Sonnet 4.5', description: '', provider: 'anthropic' },
{ value: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', description: '', provider: 'anthropic' },
{ value: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet', description: '', provider: 'anthropic' },
{ value: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet', description: '', provider: 'anthropic' },
{ value: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku', description: '', provider: 'anthropic' },
]
/** Connect to Claude Code via Agent SDK and fetch real supported models */
async function connectClaudeCode(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Claude Code...')
const claudePath = resolveClaudeCli()
serverLog.info(`[connect-agent] resolved claude path: ${claudePath ?? 'NOT FOUND'}`)
if (!claudePath) {
return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' }
}
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
serverLog.info(`[connect-agent] claude env keys: ${Object.keys(env).join(', ')}`)
serverLog.info(`[connect-agent] claude debugFile: ${debugFile ?? 'none'}`)
const q = query({
prompt: '',
options: {
maxTurns: 1,
tools: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}),
},
})
serverLog.info('[connect-agent] querying supportedModels...')
const raw = await q.supportedModels()
// Fetch account info (email, org, subscription type)
let account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null = null
try {
account = await q.accountInfo()
serverLog.info(`[connect-agent] claude account: email=${account?.email ?? 'n/a'}, type=${account?.subscriptionType ?? 'n/a'}, source=${account?.apiKeySource ?? 'n/a'}`)
} catch {
serverLog.info('[connect-agent] accountInfo() not available')
}
q.close()
const models: GroupedModel[] = raw.map((m) => ({
value: m.value,
displayName: m.displayName,
description: m.description,
provider: 'anthropic' as const,
}))
serverLog.info(`[connect-agent] claude connected, ${models.length} models found`)
const claudeInfo = buildClaudeConnectionInfo(env, account)
return { connected: true, models, ...claudeInfo }
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] claude connection error: ${msg}`)
// Third-party API proxies often don't support the supportedModels() call,
// causing "query closed before response". Fall back to a default model list
// so users can still connect and choose a model.
if (/closed before|closed early|query closed/i.test(msg)) {
serverLog.info('[connect-agent] using fallback model list (proxy detected)')
const fallbackEnv = buildClaudeAgentEnv()
const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null)
return { connected: true, models: FALLBACK_CLAUDE_MODELS, ...claudeInfo }
}
return { connected: false, models: [], error: friendlyClaudeError(msg) }
}
}
/** Resolve config file path (cross-platform) */
function configPath(unixPath: string, winPath: string): string {
return process.platform === 'win32' ? winPath : unixPath
}
/** Build Claude connection info from env + SDK account info */
function buildClaudeConnectionInfo(
env: Record<string, string | undefined>,
account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null,
): { connectionInfo: string; hintPath?: string } {
const hp = configPath('~/.claude/settings.json', '%USERPROFILE%\\.claude\\settings.json')
const apiKey = env.ANTHROPIC_API_KEY
const baseUrl = env.ANTHROPIC_BASE_URL
if (account?.email) {
const sub = account.subscriptionType ?? 'subscription'
return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp }
}
if (apiKey && baseUrl) {
return { connectionInfo: 'Connected via API key (custom endpoint)', hintPath: hp }
}
if (apiKey) {
const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***'
return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }
}
return { connectionInfo: 'Connected via subscription', hintPath: hp }
}
/** Decode a JWT payload (no verification — just base64url decode the middle part) */
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
// base64url → base64 → Buffer → JSON
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'))
} catch {
return null
}
}
/** Build Codex CLI connection info by reading ~/.codex/auth.json + JWT tokens */
async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> {
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const hp = configPath('~/.codex/config.json', '%USERPROFILE%\\.codex\\config.json')
if (process.env.OPENAI_API_KEY) {
const key = process.env.OPENAI_API_KEY
const masked = key.length > 12 ? `${key.slice(0, 8)}...` : '***'
return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }
}
try {
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
const authPath = join(codexHome, 'auth.json')
const raw = await readFile(authPath, 'utf-8')
const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } }
const idToken = auth.tokens?.id_token
if (idToken) {
const payload = decodeJwtPayload(idToken)
if (payload) {
const email = payload.email as string | undefined
const authClaims = payload['https://api.openai.com/auth'] as Record<string, unknown> | undefined
const plan = authClaims?.chatgpt_plan_type as string | undefined
serverLog.info(`[connect-agent] codex JWT: email=${email ?? 'n/a'}, plan=${plan ?? 'n/a'}`)
if (email) {
const label = plan ?? auth.auth_mode ?? 'subscription'
return { connectionInfo: `Connected via ${label} (${email})`, hintPath: hp }
}
}
}
if (auth.auth_mode) {
return { connectionInfo: `Connected via ${auth.auth_mode}`, hintPath: hp }
}
} catch { /* auth.json not found */ }
return { connectionInfo: 'Connected via Codex CLI', hintPath: hp }
}
/** Map raw Agent SDK errors to user-friendly messages */
function friendlyClaudeError(raw: string): string {
if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) {
return 'Claude Code exited with code 1. Run "claude login" to authenticate, or set ANTHROPIC_API_KEY in ~/.claude/settings.json.'
}
if (/exited with code/i.test(raw)) {
return 'Unable to connect. Claude Code process exited unexpectedly.'
}
if (/not found|ENOENT/i.test(raw)) {
return 'Claude Code CLI not found. Please install it first.'
}
if (/timed?\s*out/i.test(raw)) {
return 'Connection timed out. Please try again.'
}
return raw
}
/**
* Fallback: parse model IDs from Codex's bundled latest-model.md when
* models_cache.json is missing (e.g. fresh Windows install).
* Only includes text/reasoning models (skips image, audio, video, embedding, moderation).
*/
async function parseCodexLatestModelMd(codexHome: string): Promise<GroupedModel[]> {
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const mdPath = join(codexHome, 'skills', '.system', 'openai-docs', 'references', 'latest-model.md')
try {
const content = await readFile(mdPath, 'utf-8')
const models: GroupedModel[] = []
// Match markdown table rows: | `model-id` | description |
const rowRe = /^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|/gm
const skipRe = /image|audio|tts|transcribe|realtime|sora|video|embedding|moderation/i
let match: RegExpExecArray | null
const seen = new Set<string>()
while ((match = rowRe.exec(content)) !== null) {
const slug = match[1]
const desc = match[2].trim()
if (skipRe.test(slug) || skipRe.test(desc) || seen.has(slug)) continue
seen.add(slug)
models.push({
value: slug,
displayName: slug,
description: desc,
provider: 'openai' as const,
})
}
return models
} catch {
return []
}
}
/** Connect to Codex CLI and fetch its supported models from the local cache */
async function connectCodexCli(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Codex CLI...')
try {
const { execSync } = await import('node:child_process')
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const isWin = process.platform === 'win32'
// Check if codex binary exists — PATH, npm prefix, then common locations
let which = ''
// 1. PATH lookup
try {
const whichCmd = isWin ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'
serverLog.info(`[connect-agent] codex PATH lookup: ${whichCmd}`)
const result = execSync(whichCmd, {
encoding: 'utf-8',
timeout: 5000,
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
if (result && existsSync(result)) which = resolveWinExtension(result)
serverLog.info(`[connect-agent] codex PATH result: "${result}" resolved="${which}" (exists=${result ? existsSync(result) : false})`)
} catch (err) {
serverLog.info(`[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. npm prefix -g (Windows: npm global creates .cmd or .ps1 wrappers)
if (!which && isWin) {
try {
serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g')
const prefix = execSync('npm.cmd prefix -g', {
encoding: 'utf-8',
timeout: 5000,
}).trim()
serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`)
if (prefix) {
for (const bin of winNpmCandidates(prefix, 'codex')) {
serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) { which = bin; break }
}
}
} catch (err) {
serverLog.info(`[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
}
// 3. Common install locations
if (!which && isWin) {
const candidates = [
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'codex'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'codex'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'codex'),
]
for (const c of candidates) {
const exists = c ? existsSync(c) : false
serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`)
if (c && exists) { which = c; break }
}
}
if (!which) {
serverLog.warn('[connect-agent] codex not found')
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }
}
serverLog.info(`[connect-agent] codex resolved: "${which}"`)
// Verify codex is responsive — always use the resolved path
const versionCmd = buildExecCmd(which, '--version') + ' 2>&1'
try {
const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
serverLog.info(`[connect-agent] codex version: ${ver}`)
} catch (err) {
serverLog.error(`[connect-agent] codex --version failed: ${err instanceof Error ? err.message : err}`)
return { connected: false, models: [], error: 'Codex CLI not responding' }
}
// Read models from Codex CLI's local models cache (best-effort)
let models: GroupedModel[] = []
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
const cachePath = join(codexHome, 'models_cache.json')
try {
const raw = await readFile(cachePath, 'utf-8')
const cache = JSON.parse(raw) as {
models?: Array<{
slug: string
display_name: string
description: string
visibility: string
priority: number
}>
}
if (cache.models && Array.isArray(cache.models)) {
models = cache.models
.filter((m) => m.visibility === 'list')
.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999))
.map((m) => ({
value: m.slug,
displayName: m.display_name,
description: m.description ?? '',
provider: 'openai' as const,
}))
}
} catch {
serverLog.info(`[connect-agent] codex models cache not available`)
}
// Fallback: parse models from Codex's bundled latest-model.md reference
if (models.length === 0) {
models = await parseCodexLatestModelMd(codexHome)
if (models.length > 0) {
serverLog.info(`[connect-agent] codex models loaded from latest-model.md: ${models.length}`)
}
}
serverLog.info(`[connect-agent] codex connected, ${models.length} models found`)
const codexInfo = await buildCodexConnectionInfo()
const warning = models.length === 0 ? 'No models found. Try running codex once to populate the model cache.' : undefined
return { connected: true, models, warning, ...codexInfo }
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] codex connection error: ${msg}`)
return { connected: false, models: [], error: msg }
}
}
/** Resolve the opencode binary path, checking PATH then common install locations. */
async function resolveOpencodeBinary(): Promise<string | undefined> {
const { execSync } = await import('node:child_process')
const { existsSync } = await import('node:fs')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const isWin = process.platform === 'win32'
serverLog.info(`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}`)
// 1. Try PATH lookup
try {
const cmd = isWin ? 'where opencode 2>nul' : 'which opencode 2>/dev/null'
serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`)
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim()
serverLog.info(`[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`)
if (result && existsSync(result)) return resolveWinExtension(result)
} catch (err) {
serverLog.info(`[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. Try `npm prefix -g` to find actual npm global bin directory
// On Windows, must use `npm.cmd` since Electron spawns cmd.exe
try {
const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g'
serverLog.info(`[resolve-opencode] npm prefix lookup: ${npmCmd}`)
const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`)
if (prefix) {
if (isWin) {
for (const bin of winNpmCandidates(prefix, 'opencode')) {
serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
} else {
const bin = join(prefix, 'bin', 'opencode')
serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
}
} catch (err) {
serverLog.info(`[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
// 3. Common install locations
// npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux)
// curl installer → ~/.opencode/bin (macOS/Linux)
// Homebrew → /usr/local/bin or /opt/homebrew/bin (macOS)
const home = homedir()
const candidates = isWin
? [
// npm global (.cmd + .ps1)
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'opencode'),
...winNpmCandidates(join(process.env.ProgramFiles || '', 'nodejs'), 'opencode'),
// nvm-windows / fnm
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'opencode'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'opencode'),
// Scoop
join(home, 'scoop', 'shims', 'opencode.exe'),
join(process.env.LOCALAPPDATA || '', 'Programs', 'opencode', 'opencode.exe'),
]
: [
// curl installer (https://opencode.ai/install)
join(home, '.opencode', 'bin', 'opencode'),
// npm global
join(home, '.npm-global', 'bin', 'opencode'),
'/usr/local/bin/opencode',
// Homebrew
'/opt/homebrew/bin/opencode',
join(home, '.local', 'bin', 'opencode'),
]
for (const c of candidates) {
const exists = c ? existsSync(c) : false
serverLog.info(`[resolve-opencode] candidate: "${c}" (exists=${exists})`)
if (c && exists) return c
}
serverLog.info('[resolve-opencode] no opencode binary found')
return undefined
}
/** Connect to OpenCode and fetch its configured providers/models. */
async function connectOpenCode(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to OpenCode...')
try {
const binaryPath = await resolveOpencodeBinary()
serverLog.info(`[connect-agent] resolved opencode path: ${binaryPath ?? 'NOT FOUND'}`)
if (!binaryPath) {
return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' }
}
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
serverLog.info('[connect-agent] creating opencode client...')
const { client, server } = await getOpencodeClient()
serverLog.info('[connect-agent] fetching opencode providers...')
const { data, error } = await client.config.providers()
releaseOpencodeServer(server)
if (error) {
serverLog.error(`[connect-agent] opencode providers error: ${JSON.stringify(error)}`)
return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' }
}
const models: GroupedModel[] = []
for (const provider of data?.providers ?? []) {
if (!provider.models) continue
for (const [, model] of Object.entries(provider.models)) {
models.push({
value: `${provider.id}/${model.id}`,
displayName: model.name || model.id,
description: `via ${provider.name || provider.id}`,
provider: 'opencode' as const,
})
}
}
if (models.length === 0) {
serverLog.info('[connect-agent] opencode: no models found')
return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' }
}
const providerNames = (data?.providers ?? []).map((p) => p.name || p.id).filter(Boolean)
const providerSummary = providerNames.length > 0
? `Connected (${providerNames.slice(0, 3).join(', ')}${providerNames.length > 3 ? ` +${providerNames.length - 3}` : ''})`
: 'Connected via OpenCode server'
serverLog.info(`[connect-agent] opencode connected, ${models.length} models found`)
return {
connected: true, models,
connectionInfo: providerSummary,
hintPath: configPath('~/.opencode/config.json', '%USERPROFILE%\\.opencode\\config.json'),
}
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] opencode connection error: ${raw}`)
return { connected: false, models: [], error: friendlyOpenCodeError(raw) }
}
}
/** Connect to GitHub Copilot CLI via @github/copilot-sdk and fetch available models. */
async function connectCopilot(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Copilot...')
// Use standalone copilot binary to avoid Bun's node:sqlite issue
const { resolveCopilotCli } = await import('../../utils/copilot-client')
const cliPath = resolveCopilotCli()
serverLog.info(`[connect-agent] resolved copilot path: ${cliPath ?? 'NOT FOUND'}`)
if (!cliPath) {
return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' }
}
try {
const { CopilotClient } = await import('@github/copilot-sdk')
const client = new CopilotClient({ autoStart: true, cliPath })
serverLog.info('[connect-agent] starting copilot client...')
await client.start()
let models: GroupedModel[] = []
try {
serverLog.info('[connect-agent] listing copilot models...')
const modelList = await client.listModels()
models = modelList
.filter((m) => !m.policy || m.policy.state === 'enabled')
.map((m) => ({
value: m.id,
displayName: m.name,
description: m.capabilities?.supports?.vision ? 'vision' : '',
provider: 'copilot' as const,
}))
} catch (listErr) {
const msg = listErr instanceof Error ? listErr.message : 'Failed to list models'
serverLog.error(`[connect-agent] copilot listModels error: ${msg}`)
await client.stop().catch(() => {})
return { connected: false, models: [], error: friendlyCopilotError(msg) }
}
// Try to get auth status for user info
const copilotHintPath = configPath('~/.config/github-copilot/config.json', '%USERPROFILE%\\.config\\github-copilot\\config.json')
let copilotInfo: { connectionInfo: string; hintPath?: string } = { connectionInfo: 'Connected via GitHub', hintPath: copilotHintPath }
try {
const authStatus = await client.getAuthStatus()
serverLog.info(`[connect-agent] copilot auth: ${JSON.stringify(authStatus)}`)
if (authStatus?.login) {
const method = authStatus.authType ? ` (${authStatus.authType})` : ''
copilotInfo = { connectionInfo: `Connected as @${authStatus.login}${method}`, hintPath: copilotHintPath }
} else if (authStatus?.statusMessage) {
copilotInfo = { connectionInfo: authStatus.statusMessage, hintPath: copilotHintPath }
}
} catch (authErr) {
serverLog.warn(`[connect-agent] copilot getAuthStatus failed: ${authErr instanceof Error ? authErr.message : authErr}`)
}
await client.stop()
if (models.length === 0) {
serverLog.info('[connect-agent] copilot: no models found')
return { connected: false, models: [], error: 'No models found. Run "copilot login" to authenticate first.' }
}
serverLog.info(`[connect-agent] copilot connected, ${models.length} models found`)
return { connected: true, models, ...copilotInfo }
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] copilot connection error: ${raw}`)
return { connected: false, models: [], error: friendlyCopilotError(raw) }
}
}
/** Map Copilot SDK errors to user-friendly messages */
function friendlyCopilotError(raw: string): string {
if (/not found|ENOENT/i.test(raw)) {
return 'GitHub Copilot CLI not found. Install it from https://docs.github.com/copilot/how-tos/copilot-cli'
}
if (/not authenticated|authenticate first|auth|unauthenticated|login/i.test(raw)) {
return 'Not authenticated. Run "copilot login" in your terminal first.'
}
if (/timed?\s*out/i.test(raw)) {
return 'Connection timed out. Please try again.'
}
return raw
}
/** Map OpenCode connection errors to user-friendly messages */
function friendlyOpenCodeError(raw: string): string {
if (/ECONNREFUSED/i.test(raw)) {
return 'OpenCode server not running. Start it with "opencode" in your terminal first.'
}
if (/not found|ENOENT/i.test(raw)) {
return 'OpenCode CLI not found. Please install it first.'
}
if (/timed?\s*out/i.test(raw)) {
return 'Connection timed out. Please try again.'
}
return raw
}
/** Fallback model list when dynamic fetch fails */
const FALLBACK_GEMINI_MODELS: GroupedModel[] = [
{ value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro', description: 'Most capable', provider: 'gemini' },
{ value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash', description: 'Fast + capable', provider: 'gemini' },
{ value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', description: 'Thinking model', provider: 'gemini' },
{ value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', description: 'Fast + thinking', provider: 'gemini' },
{ value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', description: 'Fast model', provider: 'gemini' },
]
/** Fetch available models from Gemini API using local auth credentials */
async function fetchGeminiModels(): Promise<GroupedModel[]> {
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
// Build auth header — try API key first, then OAuth token
let authUrl: (base: string) => string
let headers: Record<string, string> = {}
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
if (envKey) {
authUrl = (base) => `${base}?key=${envKey}`
} else {
// Read OAuth token
const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json')
const raw = await readFile(oauthPath, 'utf-8')
const creds = JSON.parse(raw) as { access_token?: string; expiry_date?: number }
if (!creds.access_token) throw new Error('No access token')
if (creds.expiry_date && Date.now() > creds.expiry_date - 60_000) throw new Error('Token expired')
authUrl = (base) => base
headers = { Authorization: `Bearer ${creds.access_token}` }
}
const res = await fetch(authUrl('https://generativelanguage.googleapis.com/v1beta/models'), { headers })
if (!res.ok) throw new Error(`API ${res.status}`)
const data = await res.json() as {
models?: Array<{
name?: string
displayName?: string
description?: string
supportedGenerationMethods?: string[]
}>
}
const models: GroupedModel[] = []
const seen = new Set<string>()
for (const m of data.models ?? []) {
// Only include models that support generateContent (text generation)
if (!m.supportedGenerationMethods?.includes('generateContent')) continue
const id = m.name?.replace('models/', '') ?? ''
if (!id || seen.has(id)) continue
// Skip embedding, AQA, and legacy models
if (/embed|aqa|^chat-bison|^text-bison|^gemini-1\.0/i.test(id)) continue
seen.add(id)
models.push({
value: id,
displayName: m.displayName ?? id,
description: m.description?.slice(0, 60) ?? '',
provider: 'gemini' as const,
})
}
// Sort: gemini-3 first, then 2.5, then others
models.sort((a, b) => {
const order = (v: string) => {
if (v.includes('gemini-3')) return 0
if (v.includes('gemini-2.5-pro')) return 1
if (v.includes('gemini-2.5-flash')) return 2
if (v.includes('gemini-2.0')) return 3
return 4
}
return order(a.value) - order(b.value)
})
return models
}
/** Connect to Gemini CLI and return available models. */
async function connectGeminiCli(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Gemini CLI...')
try {
const { resolveGeminiCli } = await import('../../utils/resolve-gemini-cli')
const binPath = resolveGeminiCli()
serverLog.info(`[connect-agent] resolved gemini path: ${binPath ?? 'NOT FOUND'}`)
if (!binPath) {
return { connected: false, models: [], notInstalled: true, error: 'Gemini CLI not found' }
}
// Verify binary responds
const { execSync } = await import('node:child_process')
const versionCmd = buildExecCmd(binPath, '--version')
try {
const ver = execSync(`${versionCmd} 2>&1`, { encoding: 'utf-8', timeout: 10000 }).trim()
serverLog.info(`[connect-agent] gemini version: ${ver}`)
} catch (err) {
serverLog.error(`[connect-agent] gemini --version failed: ${err instanceof Error ? err.message : err}`)
return { connected: false, models: [], error: 'Gemini CLI not responding' }
}
// Dynamically fetch models, fallback to hardcoded list
let models: GroupedModel[]
try {
models = await fetchGeminiModels()
serverLog.info(`[connect-agent] gemini: fetched ${models.length} models from API`)
} catch (err) {
serverLog.info(`[connect-agent] gemini: model fetch failed (${err instanceof Error ? err.message : err}), using fallback`)
models = FALLBACK_GEMINI_MODELS
}
const geminiInfo = await buildGeminiConnectionInfo()
const warning = models.length === 0 ? 'No models found. Try running "gemini" once to authenticate.' : undefined
if (models.length === 0) models = FALLBACK_GEMINI_MODELS
serverLog.info(`[connect-agent] gemini connected, ${models.length} models`)
return { connected: true, models, warning, ...geminiInfo }
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] gemini connection error: ${raw}`)
return { connected: false, models: [], error: friendlyGeminiError(raw) }
}
}
/** Build Gemini CLI connection info from local config files */
async function buildGeminiConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> {
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const hp = configPath('~/.gemini/settings.json', '%USERPROFILE%\\.gemini\\settings.json')
// Check env for API key
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
if (envKey) {
const masked = envKey.length > 12 ? `${envKey.slice(0, 8)}...` : '***'
return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }
}
// Check OAuth creds (Gemini CLI login)
try {
const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json')
await readFile(oauthPath, 'utf-8') // Check existence
// Try to get account email
try {
const accountsPath = join(homedir(), '.gemini', 'google_accounts.json')
const accountsRaw = await readFile(accountsPath, 'utf-8')
const accounts = JSON.parse(accountsRaw) as { active?: string }
if (accounts.active) {
return { connectionInfo: `Connected via Google (${accounts.active})`, hintPath: hp }
}
} catch { /* no accounts file */ }
return { connectionInfo: 'Connected via Google OAuth', hintPath: hp }
} catch { /* no OAuth creds */ }
return { connectionInfo: 'Connected via Gemini CLI', hintPath: hp }
}
/** Map Gemini CLI errors to user-friendly messages */
function friendlyGeminiError(raw: string): string {
if (/not found|ENOENT/i.test(raw)) {
return 'Gemini CLI not found. Install it with: npm install -g @anthropic-ai/gemini-cli'
}
if (/not authenticated|authenticate|auth|login/i.test(raw)) {
return 'Not authenticated. Run "gemini" in your terminal first to set up authentication.'
}
if (/timed?\s*out/i.test(raw)) {
return 'Connection timed out. Please try again.'
}
return raw
}