diff --git a/public/api-docs.html b/public/api-docs.html index c2d60173e0..91ba926f58 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -524,7 +524,7 @@

Request Body Parameters

provider string Optional - claude, cursor, codex, or opencode (default: claude) + claude, cursor, codex, opencode, or qoder (default: claude) stream @@ -544,7 +544,7 @@

Request Body Parameters

effort string Optional - Reasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use default or omit it to let the provider decide. + Reasoning effort for Claude, Codex, OpenCode, and Qoder models that expose effort metadata. Use default or omit it to let the provider decide. cleanup @@ -839,6 +839,7 @@

Branch & PR Response

{ id: 'codex', name: 'OpenAI' }, { id: 'cursor', name: 'Cursor' }, { id: 'opencode', name: 'OpenCode' }, + { id: 'qoder', name: 'Qoder' }, ]; async function populateModels() { diff --git a/server/index.ts b/server/index.ts index fac4734363..3a8858821f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -83,6 +83,7 @@ const queryClaude = providerRuntimeService.getRunner('claude'); const queryCursor = providerRuntimeService.getRunner('cursor'); const queryCodex = providerRuntimeService.getRunner('codex'); const queryOpenCode = providerRuntimeService.getRunner('opencode'); +const queryQoder = providerRuntimeService.getRunner('qoder'); const gitRoutes = createGitModule({ queryClaude, queryCursor, @@ -92,6 +93,7 @@ const agentRoutes = createAgentModule({ queryCursor, queryCodex, queryOpenCode, + queryQoder, }); // Single WebSocket server that handles chat, shell, and plugin proxy paths. diff --git a/server/modules/agent/agent.module.ts b/server/modules/agent/agent.module.ts index 144781162c..5b2b7e1599 100644 --- a/server/modules/agent/agent.module.ts +++ b/server/modules/agent/agent.module.ts @@ -17,7 +17,7 @@ import { createAgentRouter } from './agent.routes.js'; type AgentExternalDependencies = Pick< Parameters[0], - 'queryClaude' | 'queryCursor' | 'queryCodex' | 'queryOpenCode' + 'queryClaude' | 'queryCursor' | 'queryCodex' | 'queryOpenCode' | 'queryQoder' >; /** diff --git a/server/modules/agent/agent.routes.ts b/server/modules/agent/agent.routes.ts index 214d21f4cc..e331235cd0 100644 --- a/server/modules/agent/agent.routes.ts +++ b/server/modules/agent/agent.routes.ts @@ -22,6 +22,7 @@ type AgentRouterDependencies = { queryCursor: ProviderRunFunction; queryCodex: ProviderRunFunction; queryOpenCode: ProviderRunFunction; + queryQoder: ProviderRunFunction; GithubClient: typeof import('@octokit/rest').Octokit; }; @@ -44,6 +45,7 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres const spawnCursor = dependencies.queryCursor; const queryCodex = dependencies.queryCodex; const spawnOpenCode = dependencies.queryOpenCode; + const spawnQoder = dependencies.queryQoder; const Octokit = dependencies.GithubClient; const router = express.Router(); @@ -662,7 +664,7 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres * - Source for auto-generated branch names (if createBranch=true and no branchName) * - Fallback for PR title if no commits are made * - * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode' + * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode' | 'qoder' * Default: 'claude' * * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. @@ -785,7 +787,7 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres * Input Validations (400 Bad Request): * - Either githubUrl OR projectPath must be provided (not neither) * - message must be non-empty string - * - provider must be 'claude', 'cursor', 'codex', or 'opencode' + * - provider must be 'claude', 'cursor', 'codex', 'opencode', or 'qoder' * - createBranch/createPR requires githubUrl OR projectPath (not neither) * - branchName must pass Git naming rules (if provided) * @@ -896,8 +898,8 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres return res.status(400).json({ error: 'message is required' }); } - if (!['claude', 'cursor', 'codex', 'opencode'].includes(provider)) { - return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "opencode"' }); + if (!['claude', 'cursor', 'codex', 'opencode', 'qoder'].includes(provider)) { + return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "opencode", or "qoder"' }); } // Validate GitHub branch/PR creation requirements @@ -1026,6 +1028,18 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres effort, permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above }, writer); + } else if (provider === 'qoder') { + console.log('Starting Qoder CLI session'); + + const qoderModels = (await providerModelsService.getProviderModels('qoder')).models; + await spawnQoder(message.trim(), { + projectPath: finalProjectPath, + cwd: finalProjectPath, + sessionId: sessionId || null, + model: model || qoderModels.DEFAULT, + effort, + permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above + }, writer); } // Handle GitHub branch and PR creation after successful agent completion diff --git a/server/modules/agent/tests/agent.routes.test.ts b/server/modules/agent/tests/agent.routes.test.ts index eeeb4cbe8d..543ef10a38 100644 --- a/server/modules/agent/tests/agent.routes.test.ts +++ b/server/modules/agent/tests/agent.routes.test.ts @@ -34,6 +34,7 @@ function createDependencies( queryCursor: unexpectedProviderCall as AgentDependencies['queryCursor'], queryCodex: unexpectedProviderCall as AgentDependencies['queryCodex'], queryOpenCode: unexpectedProviderCall as AgentDependencies['queryOpenCode'], + queryQoder: unexpectedProviderCall as AgentDependencies['queryQoder'], GithubClient: class {} as unknown as AgentDependencies['GithubClient'], ...overrides, }; diff --git a/server/modules/commands/commands.routes.ts b/server/modules/commands/commands.routes.ts index f51415b80b..ffe291e9b1 100644 --- a/server/modules/commands/commands.routes.ts +++ b/server/modules/commands/commands.routes.ts @@ -28,13 +28,14 @@ const providerModelsService = dependencies.models; const process = dependencies.runtime; const router = express.Router(); -const MODEL_PROVIDERS = ["claude", "cursor", "codex", "opencode"]; +const MODEL_PROVIDERS = ["claude", "cursor", "codex", "opencode", "qoder"]; const MODEL_PROVIDER_LABELS = { claude: "Claude", cursor: "Cursor", codex: "Codex", opencode: "OpenCode", + qoder: "Qoder", }; const readModelProvider = (value) => { diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md index 643bb2bb94..f87fd8e5d4 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -45,6 +45,7 @@ Current provider ids in this repo are: - `codex` - `cursor` - `opencode` +- `qoder` Those ids are mirrored in backend unions and frontend provider constants. If adding a new provider, update every place that hardcodes this list. @@ -65,7 +66,7 @@ server/modules/providers/list// -session-synchronizer.provider.ts ``` -The existing provider folders are `claude`, `codex`, `cursor`, and `opencode`. +The existing provider folders are `claude`, `codex`, `cursor`, `opencode`, and `qoder`. Each provider wrapper owns its SDK/CLI runtime alongside its auth, model, and session facets. Runtime adapters receive registry-backed model and session @@ -143,6 +144,7 @@ Current MCP formats in this repo are: | Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` | | Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` | | OpenCode | `~/.config/opencode/opencode.json` or `/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` | +| Qoder | `~/.qoder/settings.json` (user) or `.mcp.json` (project) | `user`, `project` | `stdio`, `http` | 5. Implement skills. @@ -163,6 +165,7 @@ Current skill discovery roots are: | Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. | | Cursor | `~/.cursor/skills` | `/.cursor/skills`, `/.agents/skills` | `/` | Uses slash-style commands. | | OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. | +| Qoder | `~/.qoder/security/skills`, `~/.qoder/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.qoder/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses Qoder, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. | Command forms currently used by the providers are: @@ -171,6 +174,7 @@ Command forms currently used by the providers are: - Codex skills: `$skill-name` - Cursor skills: `/skill-name` - OpenCode skills: `/skill-name` +- Qoder skills: `/skill-name` 6. Implement sessions. @@ -208,6 +212,7 @@ Current session sync roots are: | Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. | | Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. | | OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. | +| Qoder | `~/.qoder/projects/**/*.jsonl` | Encodes the working directory with `/` → `-`; every row carries a `sessionId`. Title priority: last `ai-title` row > first user prompt > last assistant text. Sidechain (`isSidechain` or `parentUuid`) rows are skipped. | 8. Register the provider. @@ -357,6 +362,11 @@ Useful tests in this repo: - `server/modules/providers/tests/mcp.test.ts` - `server/modules/providers/tests/skills.test.ts` - `server/modules/providers/tests/opencode-sessions.test.ts` +- `server/modules/providers/tests/qoder-models.test.ts` +- `server/modules/providers/tests/qoder-permissions.test.ts` +- `server/modules/providers/tests/qoder-sessions.test.ts` +- `server/modules/providers/tests/qoder-session-synchronizer.test.ts` +- `server/modules/providers/tests/qoder-args.test.ts` If you touch sessions or session synchronization, add or update focused tests alongside the implementation. diff --git a/server/modules/providers/list/opencode/opencode-mcp.provider.ts b/server/modules/providers/list/opencode/opencode-mcp.provider.ts index f202038257..0f58a5f1c1 100644 --- a/server/modules/providers/list/opencode/opencode-mcp.provider.ts +++ b/server/modules/providers/list/opencode/opencode-mcp.provider.ts @@ -1,4 +1,4 @@ -import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -6,6 +6,7 @@ import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; import { AppError, + fileExists, readObjectRecord, readOptionalString, readStringArray, @@ -17,15 +18,6 @@ type OpenCodeConfigPath = { exists: boolean; }; -const fileExists = async (filePath: string): Promise => { - try { - await access(filePath); - return true; - } catch { - return false; - } -}; - /** * Removes JSONC comments without touching comment-like text inside strings. */ diff --git a/server/modules/providers/list/qoder/qoder-auth.provider.ts b/server/modules/providers/list/qoder/qoder-auth.provider.ts new file mode 100644 index 0000000000..3a1e5e9d42 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-auth.provider.ts @@ -0,0 +1,65 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { spawn } from 'node:child_process'; + +import type { IProviderAuth } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; +import { fileExists, getQoderHome } from '@/shared/utils.js'; + +/** + * Qoder persists its auth state as an encrypted blob under `~/.qoder/.auth/user` + * (600 perms). Presence of the file means the CLI has completed at least one + * `qodercli login` and can issue authenticated requests. The blob itself is + * opaque to the server, so only existence is checked. + */ +const QODER_AUTH_FILE = '.auth/user'; + +export class QoderProviderAuth implements IProviderAuth { + /** + * Checks whether the Qoder CLI is available to the server process. + * Uses a non-blocking spawn with a 5 s timeout to avoid stalling the event loop. + */ + private checkInstalled(): Promise { + return new Promise((resolve) => { + const proc = spawn('qodercli', ['--version'], { stdio: 'ignore' }); + const timer = setTimeout(() => { proc.kill(); resolve(false); }, 5000); + proc.on('close', (code) => { clearTimeout(timer); resolve(code === 0); }); + proc.on('error', () => { clearTimeout(timer); resolve(false); }); + }); + } + + /** + * Returns Qoder CLI installation and credential status. + */ + async getStatus(): Promise { + const installed = await this.checkInstalled(); + const authenticated = await this.checkCredentials(); + + return { + installed, + provider: 'qoder', + authenticated, + email: authenticated ? 'qoder credentials' : null, + method: authenticated ? 'credentials_file' : null, + error: authenticated ? undefined : 'Not authenticated', + }; + } + + /** + * Reads Qoder's auth blob and reports whether a login exists. + */ + private async checkCredentials(): Promise { + try { + const authPath = path.join(getQoderHome(), QODER_AUTH_FILE); + if (!(await fileExists(authPath))) { + return false; + } + + const content = await readFile(authPath, 'utf8'); + return content.trim().length > 0; + } catch { + return false; + } + } +} diff --git a/server/modules/providers/list/qoder/qoder-mcp.provider.ts b/server/modules/providers/list/qoder/qoder-mcp.provider.ts new file mode 100644 index 0000000000..e147a064f6 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-mcp.provider.ts @@ -0,0 +1,124 @@ +import path from 'node:path'; + +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { + AppError, + getQoderHome, + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/shared/utils.js'; + +const resolveQoderConfigPath = (scope: McpScope, workspacePath: string): string => { + // Qoder reads MCP servers from the user-level settings.json (Claude-style + // `mcpServers` key) and from a project-level `.mcp.json`. + return scope === 'user' + ? path.join(getQoderHome(), 'settings.json') + : path.join(workspacePath, '.mcp.json'); +}; + +export class QoderMcpProvider extends McpProvider { + constructor() { + // Qoder supports user and project scoped MCP servers; `local` scope (like + // Claude Code's project-local launch) is not a Qoder concept. + super('qoder', ['user', 'project'], ['stdio', 'http']); + } + + protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { + const config = await readJsonConfig(resolveQoderConfigPath(scope, workspacePath)); + return readObjectRecord(config.mcpServers) ?? {}; + } + + protected async writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise { + const filePath = resolveQoderConfigPath(scope, workspacePath); + const config = await readJsonConfig(filePath); + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + } + + protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { + if (input.transport === 'stdio') { + if (!input.command?.trim()) { + throw new AppError('command is required for stdio MCP servers.', { + code: 'MCP_COMMAND_REQUIRED', + statusCode: 400, + }); + } + + // Qoder (Claude-compatible) config shape: command is a plain string. + return { + command: input.command, + args: input.args ?? [], + env: input.env ?? {}, + }; + } + + if (!input.url?.trim()) { + throw new AppError('url is required for http MCP servers.', { + code: 'MCP_URL_REQUIRED', + statusCode: 400, + }); + } + + return { + type: 'http', + url: input.url, + headers: input.headers ?? {}, + }; + } + + protected normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null { + const config = readObjectRecord(rawConfig); + if (!config) { + return null; + } + + // stdio: Qoder config uses a plain-string command + args array. + if (config.type === 'local' || config.command !== undefined) { + const command = readOptionalString(config.command); + if (!command) { + return null; + } + + return { + provider: 'qoder', + name, + scope, + transport: 'stdio', + command, + args: readStringArray(config.args) ?? [], + env: readStringRecord(config.env), + }; + } + + if (config.type === 'http' || typeof config.url === 'string') { + const url = readOptionalString(config.url); + if (!url) { + return null; + } + + return { + provider: 'qoder', + name, + scope, + transport: 'http', + url, + headers: readStringRecord(config.headers), + }; + } + + return null; + } +} diff --git a/server/modules/providers/list/qoder/qoder-models.provider.ts b/server/modules/providers/list/qoder/qoder-models.provider.ts new file mode 100644 index 0000000000..b2e44a4051 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-models.provider.ts @@ -0,0 +1,343 @@ +import fs from 'node:fs'; + +import crossSpawn from 'cross-spawn'; + +import { sessionsDb } from '@/modules/database/index.js'; +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { + ProviderCurrentActiveModel, + ProviderModelsDefinition, +} from '@/shared/types.js'; +import { + buildDefaultProviderCurrentActiveModel, + readJsonRecord, + readJsonlEntries, + readOptionalString, + resolveQoderTranscriptPath, + unwrapJsonStringLiteral, +} from '@/shared/utils.js'; + +export const QODER_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { + value: 'Auto', + label: 'Auto', + description: 'Qoder - Auto', + }, + { + value: 'Ultimate', + label: 'Ultimate', + description: 'Qoder - Ultimate', + }, + { + value: 'Performance', + label: 'Performance', + description: 'Qoder - Performance', + }, + { + value: 'Efficient', + label: 'Efficient', + description: 'Qoder - Efficient', + }, + { + value: 'Lite', + label: 'Lite', + description: 'Qoder - Lite', + }, + { + value: 'Cantus', + label: 'Cantus', + description: 'Qoder - Cantus', + }, + ], + DEFAULT: 'Auto', +}; + +const QODER_MODELS_TIMEOUT_MS = 20_000; + +const runQoderModelsCommand = (): Promise => new Promise((resolve, reject) => { + const qoderProcess = crossSpawn('qodercli', ['--list-models'], { + cwd: process.cwd(), + env: { ...process.env }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + qoderProcess.kill('SIGTERM'); + if (!settled) { + settled = true; + reject(new Error('qodercli --list-models timed out')); + } + }, QODER_MODELS_TIMEOUT_MS); + + const finish = (error: Error | null, output: string) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + + if (error) { + reject(error); + return; + } + + resolve(output); + }; + + qoderProcess.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + qoderProcess.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + qoderProcess.on('error', (error) => { + finish(error instanceof Error ? error : new Error(String(error)), ''); + }); + + qoderProcess.on('close', (code) => { + if (code !== 0) { + finish(new Error(stderr.trim() || `qodercli --list-models exited with code ${code}`), ''); + return; + } + + finish(null, stdout); + }); +}); + +/** + * A row of `qodercli --list-models` output that names a usable model. + * + * Verified against v1.1.13, rows come in exactly two shapes: a bare id, or an + * id followed by a parenthesized alias (`Peach-07-17-DogFooding + * (qwen3.8-max-preview)`). Matching the whole line — rather than just its first + * token — is what lets free-form output such as `Available models:` be rejected. + */ +const QODER_MODEL_ROW_PATTERN = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s+\([^)]*\))?$/; + +// Consumed by getSupportedModels below and by tests/qoder-models.test.ts, which +// pins the parser against captured `qodercli --list-models` output. +export const parseQoderModelsStdout = (stdout: string): string[] => { + const ids: string[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + // `qodercli --list-models` prints a literal `MODEL` header row before the + // model ids. It must not surface as a selectable model — picking it would + // pass `-m MODEL` and qodercli rejects it with exit 42 ("Invalid model"). + if (!line || line === 'MODEL') { + continue; + } + + // Only the id is usable: `-m "Peach-07-17-DogFooding (qwen3.8-max-preview)"` + // fails with `Invalid model "..."`. + const id = line.match(QODER_MODEL_ROW_PATTERN)?.[1]; + if (!id) { + // Banners, warnings and JSON diagnostics land here. Offering them as + // models would give the user a selection that can only fail. + console.warn(`[Qoder] Ignoring unrecognized --list-models row: ${line}`); + continue; + } + + ids.push(id); + } + + return [...new Set(ids)]; +}; + +/** + * Reads the model Qoder recorded for one session from its transcript. + * + * Qoder writes a `runtime-config` row at session start carrying the resolved + * model name (for example `auto`), and every assistant message repeats the + * model on `message.model`. The last non-empty value wins so a model switch + * mid-session is reflected. + */ +type SessionModelCacheEntry = { model: string | null; expiresAt: number }; +const sessionModelCache = new Map(); +const SESSION_MODEL_CACHE_TTL_MS = 30_000; + +const cleanupExpiredSessionModelCache = (): void => { + const now = Date.now(); + for (const [key, entry] of sessionModelCache) { + if (entry.expiresAt <= now) { + sessionModelCache.delete(key); + } + } +}; + +const readQoderSessionModel = async (jsonlPath: string): Promise => { + cleanupExpiredSessionModelCache(); + const cached = sessionModelCache.get(jsonlPath); + if (cached && cached.expiresAt > Date.now()) { + return cached.model; + } + + let model: string | null = null; + + // Streamed rather than read whole: transcripts grow without bound, and this + // runs on every active-model query. + for await (const data of readJsonlEntries(jsonlPath)) { + const entryType = readOptionalString(data.type); + if (entryType === 'runtime-config') { + const runtimeModel = readOptionalString(data.model); + if (runtimeModel) { + model = unwrapJsonStringLiteral(runtimeModel); + } + continue; + } + + if (entryType === 'assistant') { + const message = readJsonRecord(data.message); + const messageModel = readOptionalString(message?.model); + if (messageModel) { + model = unwrapJsonStringLiteral(messageModel); + } + } + } + + sessionModelCache.set(jsonlPath, { + model, + expiresAt: Date.now() + SESSION_MODEL_CACHE_TTL_MS, + }); + return model; +}; + +/** + * Normalize a model name read from a Qoder transcript against the supported + * model catalog. + * + * Transcripts may record names with different casing (`auto`) or with a + * parenthesized alias (`Peach-07-17-DogFooding (qwen3.8-max-preview)`). This + * returns the canonical catalog value when a match is found, or `null` when + * the name cannot be reconciled so the caller can fall back to the default. + */ +const normalizeQoderModelName = ( + rawModel: string, + supportedModels: ProviderModelsDefinition, +): string | null => { + const options = supportedModels.OPTIONS; + if (options.length === 0) { + return rawModel; + } + + const candidate = rawModel.replace(/\s+\([^)]*\)\s*$/, '').trim(); + if (!candidate) { + return null; + } + + // Exact match. + const exact = options.find((option) => option.value === candidate); + if (exact) { + return exact.value; + } + + // Case-insensitive value match. + const lowerCandidate = candidate.toLowerCase(); + const byValue = options.find((option) => option.value.toLowerCase() === lowerCandidate); + if (byValue) { + return byValue.value; + } + + // Case-insensitive label match. + const byLabel = options.find((option) => option.label.toLowerCase() === lowerCandidate); + if (byLabel) { + return byLabel.value; + } + + return null; +}; + +const resolveQoderJsonlPath = ( + sessionId: string, + providerSessionId: string, + projectPath?: string, +): string | null => { + // Prefer the transcript path recorded by the synchronizer so cwd encoding + // stays consistent with what the CLI actually wrote on disk. Look up by the + // app session id, and verify the path still exists before trusting it. + const storedPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; + if (storedPath && fs.existsSync(storedPath)) { + return storedPath; + } + + // Fall back to the canonical layout when the row is not indexed yet. The + // shared resolver owns the cwd encoding and rejects session ids that could + // traverse out of the projects directory. + return resolveQoderTranscriptPath({ cwd: projectPath, sessionId: providerSessionId }); +}; + +const QODER_MODELS_MEMO_TTL_MS = 5 * 60 * 1000; + +/** + * Process-local memo for the catalog. + * + * `getSupportedModels` spawns `qodercli --list-models`, and + * `getCurrentActiveModel` falls back to it whenever a session has no recorded + * model — that path bypasses providerModelsService's on-disk cache, so without + * this memo a burst of active-model queries becomes a burst of CLI spawns, each + * with a 20s timeout. Failures are not memoized so the next call retries. + */ +let cachedModels: { models: ProviderModelsDefinition; expiresAt: number } | null = null; + +export class QoderProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + if (cachedModels && cachedModels.expiresAt > Date.now()) { + return cachedModels.models; + } + + try { + const stdout = await runQoderModelsCommand(); + const ids = parseQoderModelsStdout(stdout); + if (ids.length === 0) { + return QODER_FALLBACK_MODELS; + } + + const models: ProviderModelsDefinition = { + OPTIONS: ids.map((value) => ({ + value, + label: value, + description: `Qoder - ${value}`, + })), + DEFAULT: ids.includes(QODER_FALLBACK_MODELS.DEFAULT) + ? QODER_FALLBACK_MODELS.DEFAULT + : (ids[0] ?? QODER_FALLBACK_MODELS.DEFAULT), + }; + cachedModels = { models, expiresAt: Date.now() + QODER_MODELS_MEMO_TTL_MS }; + return models; + } catch { + return QODER_FALLBACK_MODELS; + } + } + + async getCurrentActiveModel(sessionId?: string): Promise { + if (!sessionId?.trim()) { + return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); + } + + // Qoder transcripts are keyed by the provider-native session id; app-created + // sessions must be translated through the mapping first. + const sessionRow = sessionsDb.getSessionById(sessionId); + const providerSessionId = sessionRow?.provider_session_id ?? sessionId; + const jsonlPath = resolveQoderJsonlPath(sessionId, providerSessionId, sessionRow?.project_path ?? undefined); + + if (jsonlPath) { + const rawModel = await readQoderSessionModel(jsonlPath); + if (rawModel) { + const supportedModels = await this.getSupportedModels(); + const model = normalizeQoderModelName(rawModel, supportedModels); + if (model) { + return { model }; + } + } + } + + return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); + } +} diff --git a/server/modules/providers/list/qoder/qoder-permissions.provider.ts b/server/modules/providers/list/qoder/qoder-permissions.provider.ts new file mode 100644 index 0000000000..5b48db590c --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-permissions.provider.ts @@ -0,0 +1,84 @@ +/** + * Maps the UI permission settings onto qodercli's non-interactive controls + * (verified against qodercli v1.1.13). + * + * Consumed by the qoder runtime (`qoder-runtime.provider.js`) when spawning + * qodercli for a chat run, and by `tests/qoder-permissions.test.ts`. + */ + +/** + * Permission settings persisted by the Settings > Qoder > Permissions panel + * under the `qoder-settings` localStorage key, and forwarded by the chat + * composer as `toolsSettings` on every send. + */ +export type QoderToolsSettings = { + skipPermissions?: boolean; + allowedTools?: string[]; + disallowedTools?: string[]; + restrictedTools?: string[]; +}; + +/** CLI arguments to apply to the spawned qodercli process. */ +export type QoderPermissionOptions = { + args: string[]; + /** + * Set once `--tools` is emitted. qodercli declares it variadic + * (`--tools `), so it keeps consuming bare arguments and swallows + * the trailing prompt; the caller must emit `--` before the prompt. + */ + requiresPromptSeparator: boolean; +}; + +/** + * Maps the UI permission mode plus the persisted tools settings onto + * qodercli's non-interactive controls: + * - skipPermissions or `bypassPermissions` → `--permission-mode + * bypass_permissions`, auto-approving every permission prompt. Without it, + * a non-interactive run that hits a prompt fails with "Permission + * confirmation required but no interactive handler is available." + * - `acceptEdits` → `--permission-mode accept_edits`, auto-accepting file + * edits while still prompting for other tools. + * - `default` (and anything else, e.g. an unsupported plan mode) → no mode + * flag; qoder's own settings.json governs. + * - allowedTools / disallowedTools map 1:1 onto repeated `--allowed-tools` / + * `--disallowed-tools` flags. Both are approval hints rather than a sandbox: + * an allow entry only skips the confirmation prompt and never narrows the + * tool set, and qodercli 1.1.13 does not enforce the `Bash(cmd:*)` + * sub-command scope, so only bare tool names behave predictably. + * - restrictedTools maps onto the variadic `--tools`, the only flag that + * actually removes tools from the session. Emitting it forces the caller to + * separate the prompt with `--`. + */ +export function resolveQoderPermissionOptions( + permissionMode?: string | null, + toolsSettings?: QoderToolsSettings | null, +): QoderPermissionOptions { + const args: string[] = []; + + if (toolsSettings?.skipPermissions || permissionMode === 'bypassPermissions') { + args.push('--permission-mode', 'bypass_permissions'); + } else if (permissionMode === 'acceptEdits') { + args.push('--permission-mode', 'accept_edits'); + } + + for (const tool of toolsSettings?.allowedTools ?? []) { + if (tool.trim()) { + args.push('--allowed-tools', tool); + } + } + + for (const tool of toolsSettings?.disallowedTools ?? []) { + if (tool.trim()) { + args.push('--disallowed-tools', tool); + } + } + + // `--tools` takes all of its values as one variadic list, so it has to come + // last in this arg block and be followed by `--` before the prompt. + const restrictedTools = (toolsSettings?.restrictedTools ?? []).filter((tool) => tool.trim()); + if (restrictedTools.length > 0) { + args.push('--tools', ...restrictedTools); + } + + return { args, requiresPromptSeparator: restrictedTools.length > 0 }; +} diff --git a/server/modules/providers/list/qoder/qoder-runtime.provider.js b/server/modules/providers/list/qoder/qoder-runtime.provider.js new file mode 100644 index 0000000000..a90bc2a599 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-runtime.provider.js @@ -0,0 +1,436 @@ +import crossSpawn from 'cross-spawn'; + +import { + appendFilesInputTag, + appendImagesInputTag, + normalizeAttachmentDescriptors +} from '@/shared/image-attachments.js'; +import { notifyRunFailed, notifyRunStopped } from '@/modules/notifications/index.js'; +import { + aggregateQoderTranscriptTokenUsage, + createCompleteMessage, + createNormalizedMessage, + flattenPromptForWindowsShell, + readJsonlEntries, + resolveQoderTranscriptPath +} from '@/shared/utils.js'; + +import { resolveQoderPermissionOptions } from './qoder-permissions.provider.js'; + +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; + +const activeQoderProcesses = new Map(); + +function resolveQoderEffort(model, effort, modelsDefinition) { + const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model); + const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || []; + return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort) + ? effort + : undefined; +} + +/** + * Builds the qodercli argv array from resolved model/effort/session inputs. + * + * Extracted from spawnQoder so the ordering invariant — `--attachment` flags + * before the permission block (which may end with the variadic `--tools`), + * then `--` separator, then the prompt — can be pinned by tests without + * spawning the CLI. Consumed by spawnQoder below and by + * tests/qoder-args.test.ts. + */ +export function buildQoderArgs(options) { + const { + resolvedModel, + resolvedEffort, + workingDir, + providerSessionId, + images, + files, + command, + permissionMode, + toolsSettings, + } = options; + + // qodercli rejects the literal model name 'default' with + // "Invalid model 'default'" (exit 42), so the fallback only kicks in + // when the caller did not resolve a real model name. + const resolvedModelArg = resolvedModel && resolvedModel !== 'default' ? resolvedModel : undefined; + const args = ['-p', '--output-format', 'stream-json']; + if (workingDir) { + args.push('-w', workingDir); + } + if (providerSessionId) { + // Resume the existing qoder transcript instead of asking qodercli to + // create a session with an id that already exists on disk, which it + // rejects with "Session ID ... is already in use." + args.push('--resume', providerSessionId); + } + if (resolvedModelArg) { + args.push('-m', resolvedModelArg); + } + if (resolvedEffort) { + args.push('--reasoning-effort', resolvedEffort); + } + + // Attachments come before the permission block on purpose: that block can + // end with the variadic `--tools`, which keeps consuming bare arguments. + // Emitting every other flag first means the only thing that can follow + // `--tools` is the `--` separator, so correctness does not depend on the + // CLI's "stop at the next dash-prefixed token" parsing detail. + const imageDescriptors = normalizeAttachmentDescriptors(images); + for (const descriptor of imageDescriptors) { + if (descriptor.path) { + args.push('--attachment', descriptor.path); + } + } + const fileDescriptors = normalizeAttachmentDescriptors(files); + for (const descriptor of fileDescriptors) { + if (descriptor.path) { + args.push('--attachment', descriptor.path); + } + } + const permissionOptions = resolveQoderPermissionOptions(permissionMode, toolsSettings); + args.push(...permissionOptions.args); + if ((command && command.trim()) || imageDescriptors.length > 0 || fileDescriptors.length > 0) { + // Images ride along as an path list appended to the prompt + // (the session history reader strips the tag back out), so the attachment + // list and the transcript stay consistent. Files use the same pattern via + // appendFilesInputTag. + const promptWithImages = appendImagesInputTag(command?.trim() || '', images); + const promptWithAttachments = appendFilesInputTag(promptWithImages, files); + if (permissionOptions.requiresPromptSeparator) { + args.push('--'); + } + args.push(flattenPromptForWindowsShell(promptWithAttachments)); + } + + return args; +} + +function readQoderSessionId(event) { + if (!event || typeof event !== 'object') { + return null; + } + + // Qoder emits the provider-native session id under camelCase on transcript + // rows, but the live `system/init` control event may carry it as snake_case + // `session_id` (Claude-style). Read every spelling so the runtime — the + // single owner of `session_created` — captures the id on whichever event + // arrives first, preventing a duplicate emission downstream. + return event.sessionID || event.sessionId || event.session_id || null; +} + +/** + * Qoder persists every run to `~/.qoder/projects//.jsonl` + * (Claude-style JSONL; each line carries a `sessionId`). Assistant rows carry a + * `usage` object, so token accounting is read straight from the transcript after + * the process exits. + * + * qodercli 1.1.13 leaves every token field at 0 and measures spend as `credits` + * plus a `context_usage_ratio`, which is why `supportsTokenUsage` is declared + * false for this provider. The reader is kept because it costs one streamed pass + * and starts producing real numbers the moment the CLI populates those fields. + */ +async function readQoderTokenUsage(sessionId, workingDir) { + const jsonlPath = resolveQoderTranscriptPath({ + cwd: workingDir || process.cwd(), + sessionId, + }); + if (!jsonlPath) { + return null; + } + + return aggregateQoderTranscriptTokenUsage(readJsonlEntries(jsonlPath)); +} + +async function spawnQoder(command, options = {}, ws, context) { + return new Promise((resolve, reject) => { + const { + sessionId, + projectPath, + cwd, + model, + effort, + sessionSummary, + images, + files, + permissionMode, + toolsSettings + } = options; + // Callers pass the stable app session id; the CLI resumes with the + // provider-native id recorded on the session row. + const providerSessionId = context.resolveProviderSessionId(sessionId); + const workingDir = cwd || projectPath || process.cwd(); + // Process-map key: the app session id when the caller supplied one, so + // abort-by-app-id always works. + const processKey = sessionId || Date.now().toString(); + let capturedSessionId = providerSessionId; + let sessionCreatedSent = false; + let stdoutLineBuffer = ''; + let terminalNotificationSent = false; + let qoderProcess = null; + // Unified lifecycle contract: exactly one terminal `complete` per run + // (close and error handlers can both fire for spawn failures). + let completeSent = false; + + const notifyTerminalState = ({ code = null, error = null } = {}) => { + if (terminalNotificationSent) { + return; + } + + terminalNotificationSent = true; + // Notifications are app-facing, so they carry the app session id. + const finalSessionId = sessionId || capturedSessionId || processKey; + if (code === 0 && !error) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'qoder', + sessionId: finalSessionId, + sessionName: sessionSummary, + stopReason: 'completed', + }); + return; + } + + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'qoder', + sessionId: finalSessionId, + sessionName: sessionSummary, + error: error || `Qoder CLI exited with code ${code}`, + }); + }; + + const registerSession = (nextSessionId) => { + if (!nextSessionId || capturedSessionId === nextSessionId) { + return; + } + + capturedSessionId = nextSessionId; + // Legacy/direct callers without an app session id re-key the process + // under the provider-native id once it is known. + if (!sessionId && processKey !== capturedSessionId && qoderProcess) { + activeQoderProcesses.delete(processKey); + activeQoderProcesses.set(capturedSessionId, qoderProcess); + } + if (qoderProcess) { + qoderProcess.sessionId = capturedSessionId; + } + + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + + if (!providerSessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + ws.send(createNormalizedMessage({ + kind: 'session_created', + newSessionId: capturedSessionId, + sessionId: capturedSessionId, + provider: 'qoder', + })); + } + }; + + const processQoderOutputLine = (line) => { + if (!line || !line.trim()) { + return; + } + + let response; + try { + response = JSON.parse(line); + } catch { + ws.send(createNormalizedMessage({ + kind: 'stream_delta', + content: line, + sessionId: capturedSessionId || sessionId || null, + provider: 'qoder', + })); + return; + } + + try { + registerSession(readQoderSessionId(response)); + const normalized = context.normalizeMessage(response, capturedSessionId || sessionId || null); + for (const msg of normalized) { + ws.send(msg); + } + } catch (error) { + const errorContent = error instanceof Error ? error.message : String(error); + console.error('[Qoder] Failed to process JSON output:', errorContent); + ws.send(createNormalizedMessage({ + kind: 'error', + content: errorContent, + sessionId: capturedSessionId || sessionId || null, + provider: 'qoder', + })); + } + }; + + void context.resolveResumeModel(sessionId, model).then(async (resolvedModel) => { + let effortModels = null; + try { + effortModels = await context.getProviderModels(); + } catch (error) { + console.warn('[Qoder] Unable to load provider models for effort validation:', error); + } + + const resolvedEffort = resolveQoderEffort(resolvedModel, effort, effortModels); + + const args = buildQoderArgs({ + resolvedModel, + resolvedEffort, + workingDir, + providerSessionId, + images, + files, + command, + permissionMode, + toolsSettings, + }); + + qoderProcess = spawnFunction('qodercli', args, { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + activeQoderProcesses.set(processKey, qoderProcess); + qoderProcess.sessionId = processKey; + qoderProcess.stdin.end(); + + qoderProcess.stdout.on('data', (data) => { + stdoutLineBuffer += data.toString(); + const completeLines = stdoutLineBuffer.split(/\r?\n/); + stdoutLineBuffer = completeLines.pop() || ''; + + completeLines.forEach((line) => { + processQoderOutputLine(line.trim()); + }); + }); + + let stderrBuffer = ''; + + qoderProcess.stderr.on('data', (data) => { + stderrBuffer += data.toString(); + }); + + qoderProcess.on('close', async (code) => { + const finalSessionId = sessionId || capturedSessionId || processKey; + activeQoderProcesses.delete(finalSessionId); + activeQoderProcesses.delete(processKey); + + if (stdoutLineBuffer.trim()) { + processQoderOutputLine(stdoutLineBuffer.trim()); + stdoutLineBuffer = ''; + } + + if (code !== 0 && stderrBuffer.trim()) { + ws.send(createNormalizedMessage({ + kind: 'error', + content: stderrBuffer.trim(), + sessionId: finalSessionId, + provider: 'qoder', + })); + } + stderrBuffer = ''; + + // Qoder's own transcript is keyed by the provider-native id under the + // encoded working directory. + const tokenBudget = await readQoderTokenUsage(capturedSessionId, workingDir); + if (tokenBudget) { + ws.send(createNormalizedMessage({ + kind: 'status', + text: 'token_budget', + tokenBudget, + sessionId: finalSessionId, + provider: 'qoder', + })); + } + + // Terminal complete — skipped for aborted runs (abort-session + // already sent the aborted complete on this run's behalf). + if (!completeSent && !qoderProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'qoder', sessionId: finalSessionId, exitCode: code })); + } + + if (code === 0) { + notifyTerminalState({ code }); + resolve(); + return; + } + + if (qoderProcess.aborted) { + // The abort handler already sent the terminal state; do not report failure. + resolve(); + return; + } + + if (code === 127 || code === null) { + const installed = await context.isProviderInstalled(); + if (!installed) { + ws.send(createNormalizedMessage({ + kind: 'error', + content: 'qodercli is not installed. Install it with: npm install -g @qoder-ai/qodercli', + sessionId: finalSessionId, + provider: 'qoder', + })); + } + } + + notifyTerminalState({ code }); + reject(new Error(code === null ? 'Qoder CLI process was terminated' : `Qoder CLI exited with code ${code}`)); + }); + + qoderProcess.on('error', async (error) => { + const finalSessionId = sessionId || capturedSessionId || processKey; + activeQoderProcesses.delete(finalSessionId); + activeQoderProcesses.delete(processKey); + + const installed = await context.isProviderInstalled(); + const errorContent = !installed + ? 'qodercli is not installed. Install it with: npm install -g @qoder-ai/qodercli' + : error.message; + + ws.send(createNormalizedMessage({ + kind: 'error', + content: errorContent, + sessionId: finalSessionId, + provider: 'qoder', + })); + if (!completeSent && !qoderProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'qoder', sessionId: finalSessionId, exitCode: 1 })); + } + notifyTerminalState({ error }); + reject(error); + }); + }).catch(reject); + }); +} + +function abortQoderSession(sessionId) { + // Named `qoderProcess` rather than `process` so it cannot shadow the global. + const qoderProcess = activeQoderProcesses.get(sessionId); + if (!qoderProcess) { + return false; + } + + // The abort handler sends the terminal complete (aborted: true); flag the + // process so its close handler does not emit a second one. + qoderProcess.aborted = true; + qoderProcess.kill('SIGTERM'); + activeQoderProcesses.delete(sessionId); + return true; +} + +// The provider's only runtime contract, consumed by qoder.provider.ts and +// resolved through providerRegistry by providerRuntimeService. +export const qoderRuntime = { + run: spawnQoder, + abort: abortQoderSession, +}; diff --git a/server/modules/providers/list/qoder/qoder-session-synchronizer.provider.ts b/server/modules/providers/list/qoder/qoder-session-synchronizer.provider.ts new file mode 100644 index 0000000000..e099f959b5 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-session-synchronizer.provider.ts @@ -0,0 +1,256 @@ +import { sessionsDb } from '@/modules/database/index.js'; +import { + extractFirstValidJsonlData, + findFilesRecursivelyCreatedAfter, + getQoderProjectsDir, + normalizeSessionName, + readFileTimestamps, + readJsonlEntries, + readObjectRecord, +} from '@/shared/utils.js'; +import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; +import type { AnyRecord } from '@/shared/types.js'; + +/** Placeholder title for transcripts that carry no usable name yet. */ +const UNTITLED_SESSION_NAME = 'Untitled Qoder Session'; + +type ParsedSession = { + sessionId: string; + projectPath: string; + sessionName?: string; +}; + +/** + * Session indexer for Qoder transcript artifacts. + * + * Qoder persists conversations as Claude-compatible JSONL under + * `~/.qoder/projects//.jsonl`, where the directory + * name encodes the working directory with every `/` replaced by `-` + * (e.g. `/home/admin` → `-home-admin`). Every line carries a top-level + * `sessionId`; `workspace-directories` rows carry the real project path. + */ +export class QoderSessionSynchronizer implements IProviderSessionSynchronizer { + private readonly provider = 'qoder' as const; + + /** + * Scans ~/.qoder/projects and upserts discovered sessions into DB. + */ + async synchronize(since?: Date): Promise { + const projectsDir = getQoderProjectsDir(); + const files = await findFilesRecursivelyCreatedAfter( + projectsDir, + '.jsonl', + since ?? null + ); + + let processed = 0; + for (const filePath of files) { + const parsed = await this.processSessionFile(filePath); + if (!parsed) { + continue; + } + + // createSession upserts and writes `parsed.sessionName` over the existing + // custom_name, and processSessionFile already preserves a user-assigned + // name, so no separate rename step is needed here. + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.projectPath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath + ); + processed += 1; + } + + return processed; + } + + /** + * Parses and upserts one Qoder session JSONL file. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.jsonl')) { + return null; + } + + const parsed = await this.processSessionFile(filePath); + if (!parsed) { + return null; + } + + const timestamps = await readFileTimestamps(filePath); + return sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.projectPath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath + ); + } + + /** + * Extracts session metadata from one Qoder JSONL session file. + */ + private async processSessionFile(filePath: string): Promise { + const parsed = await extractFirstValidJsonlData(filePath, (rawData) => { + const data = readObjectRecord(rawData); + if (!data) { + return null; + } + + // Every Qoder JSONL row carries the provider-native session id. + const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; + // Sidechain (sub-agent) conversations are tagged on their rows; they + // must not surface in the sidebar. + const isSubagent = data.isSidechain === true + || typeof data.parentUuid === 'string'; + if (!sessionId || isSubagent) { + return null; + } + + const projectPath = this.extractProjectPath(data); + if (!projectPath) { + return null; + } + + return { sessionId, projectPath }; + }); + + if (!parsed) { + return null; + } + + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); + const existingSessionName = existingSession?.custom_name; + if (existingSessionName && existingSessionName !== UNTITLED_SESSION_NAME) { + return { + ...parsed, + sessionName: normalizeSessionName(existingSessionName, UNTITLED_SESSION_NAME), + }; + } + + // Freshest AI-assigned title first, then the prompt the user typed, then + // whatever the assistant last said. + const candidates = await this.collectSessionTitleCandidates(filePath); + const sessionName = candidates.aiTitle + ?? candidates.firstUserText + ?? candidates.lastAssistantText; + + return { + ...parsed, + sessionName: normalizeSessionName(sessionName, UNTITLED_SESSION_NAME), + }; + } + + /** + * Pulls the real working directory out of a Qoder JSONL row: + * - `workspace-directories` rows carry the canonical `directories` array. + * - `user` rows carry the `cwd` the CLI was launched from. + */ + private extractProjectPath(data: AnyRecord): string | undefined { + if (data.type === 'workspace-directories' && Array.isArray(data.directories)) { + for (const dir of data.directories) { + if (typeof dir === 'string' && dir.trim()) { + return dir; + } + } + } + + if (data.type === 'user' && typeof data.cwd === 'string' && data.cwd.trim()) { + return data.cwd; + } + + return undefined; + } + + /** + * Collects every title candidate in one streamed pass. + * + * Reading the transcript once matters on the watcher's hot path: this was + * previously three separate whole-file reads per session. + */ + private async collectSessionTitleCandidates(filePath: string): Promise<{ + aiTitle?: string; + firstUserText?: string; + lastAssistantText?: string; + }> { + let aiTitle: string | undefined; + let firstUserText: string | undefined; + let lastAssistantText: string | undefined; + + for await (const data of readJsonlEntries(filePath)) { + // Qoder writes an `ai-title` row each time it renames the conversation, so + // the last one in the file is the freshest. + if (data.type === 'ai-title' && typeof data.aiTitle === 'string' && data.aiTitle.trim()) { + aiTitle = data.aiTitle; + continue; + } + + if (!firstUserText) { + firstUserText = this.extractUserText(data); + if (firstUserText) { + continue; + } + } + + lastAssistantText = this.extractAssistantText(data) ?? lastAssistantText; + } + + return { aiTitle, firstUserText, lastAssistantText }; + } + + /** + * Extracts the plain text payload of a `user` row. + */ + private extractUserText(data: AnyRecord): string | undefined { + if (data.type !== 'user') { + return undefined; + } + + const message = readObjectRecord(data.message); + return message?.role === 'user' ? this.extractMessageText(message) : undefined; + } + + /** + * Extracts the plain text payload of an `assistant` row. + */ + private extractAssistantText(data: AnyRecord): string | undefined { + if (data.type !== 'assistant') { + return undefined; + } + + const message = readObjectRecord(data.message); + return message?.role === 'assistant' ? this.extractMessageText(message) : undefined; + } + + /** + * Reads a Claude-style `message.content`, which is either a plain string or an + * array of typed parts. Only `text` parts carry titleable content, so + * `tool_result` and `thinking` blocks are skipped. + */ + private extractMessageText(message: AnyRecord): string | undefined { + if (typeof message.content === 'string' && message.content.trim()) { + return message.content; + } + + if (Array.isArray(message.content)) { + for (const part of message.content) { + const block = readObjectRecord(part); + if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + return block.text; + } + } + } + + return undefined; + } +} diff --git a/server/modules/providers/list/qoder/qoder-sessions.provider.ts b/server/modules/providers/list/qoder/qoder-sessions.provider.ts new file mode 100644 index 0000000000..e7f30829b4 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-sessions.provider.ts @@ -0,0 +1,428 @@ +import fs from 'node:fs'; + +import type { IProviderSessions } from '@/shared/interfaces.js'; +import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; +import { parseFilesInputTag, parseImagesInputTag } from '@/shared/image-attachments.js'; +import { + createNormalizedMessage, + generateMessageId, + readJsonlEntries, + readJsonRecord, + readObjectRecord, + resolveQoderTranscriptPath, + sliceTailPage, + type QoderTranscriptTokenUsage, +} from '@/shared/utils.js'; +import { sessionsDb } from '@/modules/database/index.js'; + +const PROVIDER = 'qoder'; + +type QoderToolResult = { + content: unknown; + isError: boolean; +}; + +/** + * Resolves the on-disk transcript for a session. + * + * Qoder persists every conversation to + * `~/.qoder/projects//.jsonl`; the app-side + * sessions row records the exact path in `jsonl_path` when the synchronizer + * imported the session. Sessions created live by the runtime are not indexed + * yet, so their path is derived from the row's `project_path` through the shared + * resolver — the same derivation the models provider uses. + */ +function resolveQoderJsonlPath( + sessionId: string, + providerSessionId: string, +): string | null { + const sessionRow = sessionsDb.getSessionById(sessionId); + const known = sessionRow?.jsonl_path; + if (known && fs.existsSync(known)) { + return known; + } + + return resolveQoderTranscriptPath({ + cwd: sessionRow?.project_path, + sessionId: providerSessionId, + }); +} + +function formatToolContent(value: unknown): string { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function safeCount(value: unknown): number { + const n = Number(value ?? 0); + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +/** + * Normalizes one Qoder stream-json event (live) or JSONL entry (history). + * + * The on-disk format is Claude-compatible: `user` / `assistant` rows carry a + * `message` with a `content` array of `text` / `thinking` / `tool_use` / + * `tool_result` parts, plus a `usage` object on assistant rows. Internal meta + * rows (`workspace-directories`, `runtime-config`, `ai-title`, ...) have no + * message role and are dropped naturally. + */ +export class QoderSessionsProvider implements IProviderSessions { + normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { + const raw = readObjectRecord(rawMessage); + if (!raw) { + return []; + } + + const ts = typeof raw.timestamp === 'string' ? raw.timestamp : new Date().toISOString(); + const baseId = typeof raw.uuid === 'string' ? raw.uuid : generateMessageId('qoder'); + const messages: NormalizedMessage[] = []; + + // Live stream events. `session_created` is intentionally NOT emitted here: + // the qoder runtime (readQoderSessionId) is the single owner of that event + // and reads the provider session id under every spelling (sessionID / + // sessionId / session_id). Emitting it here too would double-notify the + // client. The init control row carries no renderable content. + if (raw.type === 'system' && raw.subtype === 'init') { + return messages; + } + + if (raw.type === 'content_block_delta' && raw.delta?.text) { + return [createNormalizedMessage({ + kind: 'stream_delta', + content: raw.delta.text, + sessionId, + provider: PROVIDER, + })]; + } + if (raw.type === 'content_block_stop') { + return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })]; + } + if (raw.type === 'result') { + // qodercli 1.1.13 emits the final answer as a plain string + // (`{"type":"result","subtype":"success","result":"OK"}`). Claude-style + // payloads carry a `content` part array instead, so both shapes are read: + // dropping either one loses the entire answer while the run still reports + // success. + if (typeof raw.result === 'string' && raw.result.trim()) { + return [createNormalizedMessage({ + id: `${baseId}_result`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: raw.result, + })]; + } + + if (Array.isArray(raw.result?.content)) { + let partIndex = 0; + for (const part of raw.result.content) { + if (part?.type === 'text' && part.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_result_${partIndex++}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: part.text, + })); + } + } + } + + return messages; + } + + const role = raw.message?.role; + + if (role === 'user' && raw.message?.content && raw.isMeta !== true) { + if (Array.isArray(raw.message.content)) { + let imagesAttached = false; + let filesAttached = false; + + for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) { + const part = raw.message.content[partIndex]; + if (!part || typeof part !== 'object') { + continue; + } + if (part.type === 'tool_result') { + messages.push(createNormalizedMessage({ + id: `${baseId}_tr_${part.tool_use_id ?? partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: part.tool_use_id, + content: formatToolContent(part.content), + isError: Boolean(part.is_error), + })); + } else if (part.type === 'text' && part.text) { + const parsedImages = parseImagesInputTag(part.text); + const parsedFiles = parseFilesInputTag(parsedImages.text); + const cleanText = parsedFiles.text; + const hasContent = cleanText.trim() || parsedImages.attachments.length > 0 || parsedFiles.attachments.length > 0; + if (hasContent) { + messages.push(createNormalizedMessage({ + id: `${baseId}_text_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: cleanText, + images: !imagesAttached && parsedImages.attachments.length > 0 + ? parsedImages.attachments + : undefined, + files: !filesAttached && parsedFiles.attachments.length > 0 + ? parsedFiles.attachments + : undefined, + })); + imagesAttached = imagesAttached || parsedImages.attachments.length > 0; + filesAttached = filesAttached || parsedFiles.attachments.length > 0; + } + } + } + + if (messages.length === 0) { + const textParts = raw.message.content + .filter((part: AnyRecord) => part?.type === 'text' && typeof part.text === 'string') + .map((part: AnyRecord) => part.text) + .join('\n'); + if (textParts.trim()) { + messages.push(createNormalizedMessage({ + id: `${baseId}_text`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: textParts, + })); + } + } + } else if (typeof raw.message.content === 'string') { + const parsedFiles = parseFilesInputTag(raw.message.content); + if (parsedFiles.text.trim() || parsedFiles.attachments.length > 0) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: parsedFiles.text, + files: parsedFiles.attachments.length > 0 ? parsedFiles.attachments : undefined, + })); + } + } + return messages; + } + + if (role === 'assistant' && raw.message?.content) { + if (Array.isArray(raw.message.content)) { + let partIndex = 0; + for (const part of raw.message.content) { + if (!part || typeof part !== 'object') { + partIndex += 1; + continue; + } + if (part.type === 'text' && part.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: part.text, + })); + } else if (part.type === 'tool_use') { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: part.name ?? 'Tool', + toolInput: part.input ?? {}, + toolId: part.id ?? `${baseId}_tool_${partIndex}`, + })); + } else if (part.type === 'thinking' && part.thinking) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: part.thinking, + })); + } + partIndex += 1; + } + } else if (typeof raw.message.content === 'string' && raw.message.content.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: raw.message.content, + })); + } + return messages; + } + + if (raw.type === 'tool_use' && raw.toolName) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.toolName, + toolInput: raw.toolInput ?? {}, + toolId: raw.toolCallId ?? baseId, + })); + return messages; + } + + if (raw.type === 'error') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'error', + content: raw.error?.message ?? raw.error ?? String(raw.message ?? 'Qoder error'), + })]; + } + + // Anything left is an internal meta row (workspace-directories, + // runtime-config, ai-title, last-prompt) with no renderable content. + return messages; + } + + /** + * Loads Qoder JSONL history for a session and returns normalized messages, + * mirroring Claude's pagination semantics (tool_results ride on their + * tool_use parent, so they do not count toward `total`). + */ + async fetchHistory( + sessionId: string, + options: FetchHistoryOptions = {}, + ): Promise { + const { limit = null, offset = 0 } = options; + const providerSessionId = options.providerSessionId ?? sessionId; + + const jsonlPath = resolveQoderJsonlPath(sessionId, providerSessionId); + if (!jsonlPath) { + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + const rawMessages: AnyRecord[] = []; + for await (const entry of readJsonlEntries(jsonlPath)) { + if (entry.sessionId === providerSessionId) { + rawMessages.push(entry); + } + } + + rawMessages.sort( + (a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(), + ); + + // First pass: collect tool results so assistant tool_use parts can carry + // their result inline (Claude JSONL semantics). + const toolResultMap = new Map(); + for (const raw of rawMessages) { + if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) { + for (const part of raw.message.content) { + if (part?.type === 'tool_result' && part.tool_use_id) { + toolResultMap.set(part.tool_use_id, { + content: part.content, + isError: Boolean(part.is_error), + }); + } + } + } + } + + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + + const normalized: NormalizedMessage[] = []; + for (const raw of rawMessages) { + normalized.push(...this.normalizeMessage(raw, sessionId)); + + // Accumulate token usage during the same pass so we do not iterate the + // buffered transcript again. qodercli 1.1.13 writes zeros into these + // fields (it measures `credits` and a `context_usage_ratio`), so this is + // normally undefined — see `supportsTokenUsage` for this provider. + if (raw.type === 'assistant') { + const usage = readJsonRecord(readJsonRecord(raw.message)?.usage); + if (usage) { + inputTokens += safeCount(usage.input_tokens); + outputTokens += safeCount(usage.output_tokens); + cacheReadTokens += safeCount(usage.cache_read_input_tokens); + cacheCreationTokens += safeCount(usage.cache_creation_input_tokens); + } + } + } + + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const toolResult = toolResultMap.get(msg.toolId); + if (!toolResult) { + continue; + } + msg.toolResult = { + content: formatToolContent(toolResult.content), + isError: toolResult.isError, + }; + } + } + + const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; + const tokenUsage: QoderTranscriptTokenUsage | undefined = used > 0 + ? { + used, + inputTokens: inputTokens + cacheReadTokens, + outputTokens, + breakdown: { input: inputTokens + cacheReadTokens, output: outputTokens }, + } + : undefined; + + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } + const normalizedOffset = Math.max(0, offset); + const normalizedLimit = limit === null ? null : Math.max(0, limit); + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); + + return { + messages: page, + total, + hasMore, + offset: normalizedOffset, + limit: normalizedLimit, + tokenUsage, + }; + } +} diff --git a/server/modules/providers/list/qoder/qoder-skills.provider.ts b/server/modules/providers/list/qoder/qoder-skills.provider.ts new file mode 100644 index 0000000000..cf9abd1c7d --- /dev/null +++ b/server/modules/providers/list/qoder/qoder-skills.provider.ts @@ -0,0 +1,79 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; +import type { ProviderSkillSource } from '@/shared/types.js'; +import { + addUniqueProviderSkillSource, + findTopmostGitRoot, +} from '@/shared/utils.js'; + +const QODER_PROJECT_SKILL_DIRS = [ + ['.qoder', 'skills'], + ['.claude', 'skills'], + ['.agents', 'skills'], +]; + +const QODER_USER_SKILL_DIRS = [ + ['.qoder', 'security', 'skills'], + ['.qoder', 'skills'], + ['.claude', 'skills'], + ['.agents', 'skills'], +]; + +export class QoderSkillsProvider extends SkillsProvider { + constructor() { + super('qoder'); + } + + protected async getSkillSources(workspacePath: string): Promise { + const sources: ProviderSkillSource[] = []; + const seenRootDirs = new Set(); + const repoRoot = await findTopmostGitRoot(workspacePath); + + for (const projectRoot of this.getProjectSearchRoots(workspacePath, repoRoot)) { + for (const skillDir of QODER_PROJECT_SKILL_DIRS) { + // Qoder intentionally reads Claude and Agents skill folders so users + // can reuse the same skill libraries across compatible coding agents. + addUniqueProviderSkillSource(sources, seenRootDirs, { + scope: 'project', + rootDir: path.join(projectRoot, ...skillDir), + commandPrefix: '/', + }); + } + } + + for (const skillDir of QODER_USER_SKILL_DIRS) { + addUniqueProviderSkillSource(sources, seenRootDirs, { + scope: 'user', + rootDir: path.join(os.homedir(), ...skillDir), + commandPrefix: '/', + }); + } + + return sources; + } + + private getProjectSearchRoots(workspacePath: string, repoRoot: string | null): string[] { + const roots: string[] = []; + const normalizedWorkspacePath = path.resolve(workspacePath); + const normalizedRepoRoot = repoRoot ? path.resolve(repoRoot) : null; + let currentPath = normalizedWorkspacePath; + + while (true) { + roots.push(currentPath); + if (!normalizedRepoRoot || currentPath === normalizedRepoRoot) { + break; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + break; + } + + currentPath = parentPath; + } + + return roots; + } +} diff --git a/server/modules/providers/list/qoder/qoder.provider.ts b/server/modules/providers/list/qoder/qoder.provider.ts new file mode 100644 index 0000000000..fab7c06193 --- /dev/null +++ b/server/modules/providers/list/qoder/qoder.provider.ts @@ -0,0 +1,30 @@ +import { QoderProviderAuth } from '@/modules/providers/list/qoder/qoder-auth.provider.js'; +import { QoderProviderModels } from '@/modules/providers/list/qoder/qoder-models.provider.js'; +import { qoderRuntime } from '@/modules/providers/list/qoder/qoder-runtime.provider.js'; +import { QoderMcpProvider } from '@/modules/providers/list/qoder/qoder-mcp.provider.js'; +import { QoderSessionSynchronizer } from '@/modules/providers/list/qoder/qoder-session-synchronizer.provider.js'; +import { QoderSessionsProvider } from '@/modules/providers/list/qoder/qoder-sessions.provider.js'; +import { QoderSkillsProvider } from '@/modules/providers/list/qoder/qoder-skills.provider.js'; +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import type { + IProviderAuth, + IProviderModels, + IProviderRuntime, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; + +export class QoderProvider extends AbstractProvider { + readonly runtime: IProviderRuntime = qoderRuntime; + readonly models: IProviderModels = new QoderProviderModels(); + readonly mcp = new QoderMcpProvider(); + readonly auth: IProviderAuth = new QoderProviderAuth(); + readonly skills: IProviderSkills = new QoderSkillsProvider(); + readonly sessions: IProviderSessions = new QoderSessionsProvider(); + readonly sessionSynchronizer: IProviderSessionSynchronizer = new QoderSessionSynchronizer(); + + constructor() { + super('qoder'); + } +} diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts index 8c333fcb72..17608a08f8 100644 --- a/server/modules/providers/provider.registry.ts +++ b/server/modules/providers/provider.registry.ts @@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider. import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js'; +import { QoderProvider } from '@/modules/providers/list/qoder/qoder.provider.js'; import type { IProvider } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; @@ -11,6 +12,7 @@ const providers: Record = { codex: new CodexProvider(), cursor: new CursorProvider(), opencode: new OpenCodeProvider(), + qoder: new QoderProvider(), }; /** diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 810feb0abe..2395083e59 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -286,6 +286,7 @@ const parseProvider = (value: unknown): LLMProvider => { || normalized === 'codex' || normalized === 'cursor' || normalized === 'opencode' + || normalized === 'qoder' ) { return normalized; } diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index b506615571..fa1fb62328 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -81,6 +81,30 @@ const PROVIDER_CAPABILITIES: Record = { supportsTokenUsage: true, supportsEffort: true, }, + qoder: { + provider: 'qoder', + // Mapped by the runtime onto Qoder's `--permission-mode` flag. `qodercli + // --help` accepts default / accept_edits / bypass_permissions / dont_ask / + // auto; accept_edits and bypass_permissions map 1:1 onto the canonical + // acceptEdits / bypassPermissions modes, while dont_ask has no canonical + // equivalent in this repo's cycle and Qoder exposes no plan mode. + permissionModes: ['default', 'acceptEdits', 'bypassPermissions'], + defaultPermissionMode: 'default', + supportsImages: true, + supportsFiles: true, + supportsAbort: true, + supportsPermissionRequests: false, + // qodercli 1.1.13 does not report token counts: every assistant row in its + // transcript carries `input_tokens`/`output_tokens`/`cache_*` as 0 and + // measures spend as `credits` plus a `context_usage_ratio` instead. Reading + // token usage would therefore always report zero, so the capability is + // declared unsupported rather than silently wrong. The readers stay in place + // and will start producing numbers if the CLI ever populates those fields. + supportsTokenUsage: false, + // `qodercli --help` exposes `--reasoning-effort `, so the runtime + // can accept model-level reasoning effort. + supportsEffort: true, + }, }; /** diff --git a/server/modules/providers/services/provider-token-usage.service.ts b/server/modules/providers/services/provider-token-usage.service.ts index f7d1c23ad1..39f8505a55 100644 --- a/server/modules/providers/services/provider-token-usage.service.ts +++ b/server/modules/providers/services/provider-token-usage.service.ts @@ -7,7 +7,7 @@ import Database from 'better-sqlite3'; import { sessionsDb } from '@/modules/database/index.js'; import type { AnyRecord } from '@/shared/types.js'; -import { AppError, getOpenCodeDatabasePath } from '@/shared/utils.js'; +import { AppError, getOpenCodeDatabasePath, resolveQoderTranscriptPath } from '@/shared/utils.js'; type SessionRow = NonNullable>; @@ -290,6 +290,34 @@ export function createProviderTokenUsageService( return readOpenCodeTokenUsage(databasePath, providerSessionId); } + if (session.provider === 'qoder') { + // The shared resolver owns Qoder's cwd encoding (only `/` is folded, + // unlike Claude's broader replacement) and rejects session ids that + // could traverse out of the projects directory. The home root comes from + // the injected dependency so tests can point it at a temp folder. + const sessionFilePath = session.jsonl_path ?? resolveQoderTranscriptPath({ + cwd: session.project_path, + sessionId: providerSessionId, + homeDirectory: dependencies.getHomeDirectory(), + }); + + if (!sessionFilePath || !dependencies.fileExists(sessionFilePath)) { + throw new AppError(`Session file for "${sessionId}" was not found.`, { + code: 'SESSION_FILE_NOT_FOUND', + statusCode: 404, + }); + } + + const fileContent = await dependencies.readTextFile(sessionFilePath); + // Qoder transcripts carry Claude's usage field names, so the Claude + // calculator applies unchanged. Note qodercli 1.1.13 writes zeros into + // those fields (it measures `credits` and `context_usage_ratio` + // instead), which is why `supportsTokenUsage` is false for this + // provider; this branch starts reporting real numbers only once the CLI + // populates them. + return readClaudeTokenUsage(fileContent, dependencies.getClaudeContextWindow()); + } + if (session.provider === 'codex') { const indexedFilePath = session.jsonl_path && dependencies.fileExists(session.jsonl_path) ? session.jsonl_path diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 5bb2b8645f..b2bd12b388 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -22,6 +22,7 @@ export const sessionSynchronizerService = { codex: 0, cursor: 0, opencode: 0, + qoder: 0, }; const failures: string[] = []; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 052afaeb91..c1e94f4d1d 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -8,6 +8,7 @@ import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { LLMProvider } from '@/shared/types.js'; +import { getQoderProjectsDir } from '@/shared/utils.js'; import { generateDisplayName } from '@/modules/projects/index.js'; type WatcherEventType = 'add' | 'change'; @@ -29,6 +30,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'opencode', rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'), }, + { + provider: 'qoder', + rootPath: getQoderProjectsDir(), + }, ]; const WATCHER_IGNORED_PATTERNS = [ diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts index 439966acd4..91c8716faa 100644 --- a/server/modules/providers/tests/mcp.test.ts +++ b/server/modules/providers/tests/mcp.test.ts @@ -313,7 +313,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp workspacePath, }); - assert.equal(globalResult.length, 4); + assert.equal(globalResult.length, 5); assert.ok(globalResult.every((entry) => entry.created === true)); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); diff --git a/server/modules/providers/tests/provider-runtime.service.test.ts b/server/modules/providers/tests/provider-runtime.service.test.ts index 16322a4e9e..43ff055490 100644 --- a/server/modules/providers/tests/provider-runtime.service.test.ts +++ b/server/modules/providers/tests/provider-runtime.service.test.ts @@ -80,6 +80,7 @@ test('providerRegistry owns one runtime for every registered provider', () => { 'codex', 'cursor', 'opencode', + 'qoder', ]); assert.equal(providers.every((provider) => typeof provider.runtime.run === 'function'), true); assert.equal(providers.every((provider) => typeof provider.runtime.abort === 'function'), true); diff --git a/server/modules/providers/tests/provider-token-usage.service.test.ts b/server/modules/providers/tests/provider-token-usage.service.test.ts index 636da1ef85..4985dc912d 100644 --- a/server/modules/providers/tests/provider-token-usage.service.test.ts +++ b/server/modules/providers/tests/provider-token-usage.service.test.ts @@ -171,6 +171,69 @@ test('Cursor returns an explicit unsupported token usage result', async () => { assert.equal(result.total, 0); }); +test('Qoder token usage reads Claude-compatible usage fields from the transcript', async () => { + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'provider-token-usage-qoder-')); + const sessionFilePath = path.join(tempDirectory, 'provider-session.jsonl'); + + try { + await writeFile(sessionFilePath, [ + JSON.stringify({ + type: 'assistant', + message: { + usage: { + input_tokens: 200, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 10, + output_tokens: 80, + }, + }, + }), + '{incomplete', + ].join('\n')); + + const service = createProviderTokenUsageService({ + getSessionById: () => createSessionRow({ + provider: 'qoder', + jsonl_path: sessionFilePath, + }), + }); + + assert.deepEqual(await service.getSessionTokenUsage('app-session'), { + used: 340, + total: 160_000, + inputTokens: 260, + outputTokens: 80, + cacheReadTokens: 50, + cacheCreationTokens: 10, + cacheTokens: 60, + breakdown: { input: 260, output: 80 }, + }); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } +}); + +test('Qoder token usage throws SESSION_FILE_NOT_FOUND when the transcript is missing', async () => { + const service = createProviderTokenUsageService({ + getSessionById: () => createSessionRow({ + provider: 'qoder', + jsonl_path: null, + project_path: '/nonexistent', + provider_session_id: 'missing-session', + }), + getHomeDirectory: () => '/nonexistent-home', + }); + + await assert.rejects( + () => service.getSessionTokenUsage('app-session'), + (error: unknown) => ( + error instanceof AppError + && error.code === 'SESSION_FILE_NOT_FOUND' + && error.statusCode === 404 + ), + ); +}); + test('token usage reports SESSION_NOT_FOUND for an unknown app session id', async () => { const service = createProviderTokenUsageService({ getSessionById: () => null }); diff --git a/server/modules/providers/tests/qoder-args.test.ts b/server/modules/providers/tests/qoder-args.test.ts new file mode 100644 index 0000000000..f3293e54c6 --- /dev/null +++ b/server/modules/providers/tests/qoder-args.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildQoderArgs } from '@/modules/providers/list/qoder/qoder-runtime.provider.js'; + +const baseOptions = { + resolvedModel: undefined, + resolvedEffort: undefined, + workingDir: '/home/user/project', + providerSessionId: undefined, + images: undefined, + files: undefined, + command: 'hello world', + permissionMode: 'default', + toolsSettings: undefined, +}; + +test('buildQoderArgs: basic prompt with default mode has no -- separator', () => { + const args = buildQoderArgs(baseOptions); + assert.deepEqual(args, [ + '-p', '--output-format', 'stream-json', + '-w', '/home/user/project', + 'hello world', + ]); +}); + +test('buildQoderArgs: resolved model is inserted before the prompt', () => { + const args = buildQoderArgs({ ...baseOptions, resolvedModel: 'Ultimate' }); + assert.deepEqual(args, [ + '-p', '--output-format', 'stream-json', + '-w', '/home/user/project', + '-m', 'Ultimate', + 'hello world', + ]); +}); + +test('buildQoderArgs: literal "default" model is dropped, not passed to the CLI', () => { + const args = buildQoderArgs({ ...baseOptions, resolvedModel: 'default' }); + assert.equal(args.includes('-m'), false); +}); + +test('buildQoderArgs: effort follows model flag', () => { + const args = buildQoderArgs({ + ...baseOptions, + resolvedModel: 'Ultimate', + resolvedEffort: 'high', + }); + assert.deepEqual(args, [ + '-p', '--output-format', 'stream-json', + '-w', '/home/user/project', + '-m', 'Ultimate', + '--reasoning-effort', 'high', + 'hello world', + ]); +}); + +test('buildQoderArgs: --resume is added when providerSessionId is set', () => { + const args = buildQoderArgs({ ...baseOptions, providerSessionId: 'abc-123' }); + assert.deepEqual(args, [ + '-p', '--output-format', 'stream-json', + '-w', '/home/user/project', + '--resume', 'abc-123', + 'hello world', + ]); +}); + +test('buildQoderArgs: bypassPermissions adds --permission-mode bypass_permissions', () => { + const args = buildQoderArgs({ ...baseOptions, permissionMode: 'bypassPermissions' }); + assert.deepEqual(args, [ + '-p', '--output-format', 'stream-json', + '-w', '/home/user/project', + '--permission-mode', 'bypass_permissions', + 'hello world', + ]); +}); + +test('buildQoderArgs: restrictedTools produce --tools then -- separator then prompt', () => { + const args = buildQoderArgs({ + ...baseOptions, + toolsSettings: { restrictedTools: ['Read', 'Grep'] }, + }); + assert.deepEqual(args, [ + '-p', '--output-format', 'stream-json', + '-w', '/home/user/project', + '--tools', 'Read', 'Grep', + '--', + 'hello world', + ]); +}); + +test('buildQoderArgs: --attachment comes before --tools so the variadic list stays intact', () => { + const args = buildQoderArgs({ + ...baseOptions, + files: ['/path/to/file.ts'], + toolsSettings: { restrictedTools: ['Read'] }, + }); + + const attachmentIndex = args.indexOf('--attachment'); + const toolsIndex = args.indexOf('--tools'); + const separatorIndex = args.indexOf('--'); + + assert.ok(attachmentIndex > -1, 'expected --attachment'); + assert.ok(toolsIndex > attachmentIndex, '--attachment must come before --tools'); + assert.ok(separatorIndex > toolsIndex, '--tools must come before --'); + assert.ok(args[separatorIndex + 1].includes('hello world'), 'prompt must contain the original text'); +}); + +test('buildQoderArgs: files without command still produce a prompt with files_input tag', () => { + const args = buildQoderArgs({ + ...baseOptions, + command: undefined, + files: ['/path/to/file.ts'], + }); + + const lastArg = args.at(-1); + assert.ok(lastArg, 'expected a prompt argument'); + assert.ok(lastArg.includes(''), 'prompt must contain files_input tag'); + assert.ok(lastArg.includes('/path/to/file.ts'), 'prompt must contain the file path'); +}); + +test('buildQoderArgs: images produce --attachment and an images_input tag', () => { + const args = buildQoderArgs({ + ...baseOptions, + command: undefined, + images: ['/path/to/image.png'], + }); + + const attachmentIndex = args.indexOf('--attachment'); + assert.ok(attachmentIndex > -1, 'expected --attachment'); + assert.equal(args[attachmentIndex + 1], '/path/to/image.png'); + + const lastArg = args.at(-1); + assert.ok(lastArg, 'expected a prompt argument'); + assert.ok(lastArg.includes(''), 'prompt must contain images_input tag'); + assert.ok(lastArg.includes('/path/to/image.png'), 'prompt must contain the image path'); +}); + +test('buildQoderArgs: no prompt and no files means no -- separator and no prompt arg', () => { + const args = buildQoderArgs({ + ...baseOptions, + command: undefined, + files: undefined, + }); + + assert.equal(args.includes('--'), false); + assert.equal(args.at(-1), '/home/user/project'); +}); diff --git a/server/modules/providers/tests/qoder-models.test.ts b/server/modules/providers/tests/qoder-models.test.ts new file mode 100644 index 0000000000..bca03ffc5f --- /dev/null +++ b/server/modules/providers/tests/qoder-models.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseQoderModelsStdout } from '@/modules/providers/list/qoder/qoder-models.provider.js'; + +/** + * Captured verbatim from `qodercli --list-models` (v1.1.13). The first row is + * a literal `MODEL` header, not a model id. + */ +const REAL_LIST_MODELS_OUTPUT = `MODEL +Auto +Ultimate +Performance +Efficient +Lite +Cantus +Qwen3.8-Max +Qwen3.7-Max +Qwen3.7-Plus +Kimi-K3 +Kimi-K2.7-Code +GLM-5.2 +DeepSeek-V4-Pro +DeepSeek-V4-Flash +MiniMax-M3 +Peach-07-17-DogFooding (qwen3.8-max-preview) +`; + +test('Qoder models parser skips the MODEL header row from real CLI output', () => { + const ids = parseQoderModelsStdout(REAL_LIST_MODELS_OUTPUT); + + assert.equal(ids.includes('MODEL'), false); + assert.deepEqual(ids[0], 'Auto'); + assert.deepEqual(ids[1], 'Ultimate'); + assert.equal(ids.length, 16); +}); + +test('Qoder models parser strips the alias suffix printed for preview models', () => { + const ids = parseQoderModelsStdout(REAL_LIST_MODELS_OUTPUT); + + // `qodercli -m "Peach-07-17-DogFooding (qwen3.8-max-preview)"` fails with + // `Invalid model`; only the leading token is a usable id. + assert.equal(ids.includes('Peach-07-17-DogFooding (qwen3.8-max-preview)'), false); + assert.equal(ids.at(-1), 'Peach-07-17-DogFooding'); +}); + +test('Qoder models parser drops free-form banner and warning rows', () => { + const ids = parseQoderModelsStdout(`MODEL +Auto +Warning: your CLI is out of date, run npm i -g @qoder-ai/qodercli +Available models: +Ultimate +`); + + assert.deepEqual(ids, ['Auto', 'Ultimate']); +}); + +test('Qoder models parser removes duplicates and ignores JSON fragments', () => { + const ids = parseQoderModelsStdout(`MODEL +Auto +Auto +{"warning": "beta"} +[debug] +Ultimate +`); + + assert.deepEqual(ids, ['Auto', 'Ultimate']); +}); + +test('Qoder models parser handles CRLF line endings and blank lines', () => { + const ids = parseQoderModelsStdout('MODEL\r\nAuto\r\n\r\n Ultimate \r\n'); + + assert.deepEqual(ids, ['Auto', 'Ultimate']); +}); + +test('Qoder models parser returns empty list for header-only output', () => { + assert.deepEqual(parseQoderModelsStdout('MODEL\n'), []); +}); diff --git a/server/modules/providers/tests/qoder-permissions.test.ts b/server/modules/providers/tests/qoder-permissions.test.ts new file mode 100644 index 0000000000..26cb9a5ab3 --- /dev/null +++ b/server/modules/providers/tests/qoder-permissions.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveQoderPermissionOptions } from '@/modules/providers/list/qoder/qoder-permissions.provider.js'; + +test('default mode without tools settings produces no permission flags', () => { + const noFlags = { args: [], requiresPromptSeparator: false }; + assert.deepEqual(resolveQoderPermissionOptions('default'), noFlags); + assert.deepEqual(resolveQoderPermissionOptions(null), noFlags); + assert.deepEqual(resolveQoderPermissionOptions(undefined, undefined), noFlags); +}); + +test('bypassPermissions maps onto qodercli bypass_permissions mode', () => { + assert.deepEqual( + resolveQoderPermissionOptions('bypassPermissions').args, + ['--permission-mode', 'bypass_permissions'], + ); +}); + +test('acceptEdits maps onto qodercli accept_edits mode', () => { + assert.deepEqual( + resolveQoderPermissionOptions('acceptEdits').args, + ['--permission-mode', 'accept_edits'], + ); +}); + +test('skipPermissions from tools settings forces bypass_permissions', () => { + assert.deepEqual( + resolveQoderPermissionOptions('default', { skipPermissions: true }).args, + ['--permission-mode', 'bypass_permissions'], + ); +}); + +test('allowed and disallowed tools map onto repeated CLI flags', () => { + const { args } = resolveQoderPermissionOptions('default', { + allowedTools: ['Bash(git log:*)', 'Read'], + disallowedTools: ['Bash(rm:*)'], + }); + + assert.deepEqual(args, [ + '--allowed-tools', 'Bash(git log:*)', + '--allowed-tools', 'Read', + '--disallowed-tools', 'Bash(rm:*)', + ]); +}); + +test('blank tool entries are skipped', () => { + const { args } = resolveQoderPermissionOptions('default', { + allowedTools: [' '], + disallowedTools: [''], + }); + + assert.deepEqual(args, []); +}); + +test('restrictedTools collapse into a single variadic --tools flag', () => { + const { args, requiresPromptSeparator } = resolveQoderPermissionOptions('default', { + restrictedTools: ['Read', 'Grep', 'Glob'], + }); + + assert.deepEqual(args, ['--tools', 'Read', 'Grep', 'Glob']); + assert.equal(requiresPromptSeparator, true); +}); + +test('--tools trails the other permission flags so the variadic list stays intact', () => { + const { args } = resolveQoderPermissionOptions('acceptEdits', { + allowedTools: ['Bash'], + disallowedTools: ['Write'], + restrictedTools: ['Read'], + }); + + assert.deepEqual(args, [ + '--permission-mode', 'accept_edits', + '--allowed-tools', 'Bash', + '--disallowed-tools', 'Write', + '--tools', 'Read', + ]); +}); + +test('blank or empty restrictedTools produce no --tools flag and no prompt separator', () => { + for (const restrictedTools of [[], ['', ' ']]) { + const { args, requiresPromptSeparator } = resolveQoderPermissionOptions('default', { + restrictedTools, + }); + + assert.deepEqual(args, []); + assert.equal(requiresPromptSeparator, false); + } +}); diff --git a/server/modules/providers/tests/qoder-session-synchronizer.test.ts b/server/modules/providers/tests/qoder-session-synchronizer.test.ts new file mode 100644 index 0000000000..24102f9cdc --- /dev/null +++ b/server/modules/providers/tests/qoder-session-synchronizer.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { QoderSessionSynchronizer } from '@/modules/providers/list/qoder/qoder-session-synchronizer.provider.js'; + +const synchronizer = new QoderSessionSynchronizer(); + +async function withIsolatedDatabase( + runTest: (tempDir: string) => void | Promise, +): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'qoder-sync-test-')); + closeConnection(); + process.env.DATABASE_PATH = path.join(tempDirectory, 'auth.db'); + await initializeDatabase(); + try { + await runTest(tempDirectory); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +async function writeTranscript(filePath: string, lines: unknown[]): Promise { + const content = lines.map((line) => (line === null ? '' : JSON.stringify(line))).join('\n'); + await writeFile(filePath, content); +} + +test('synchronizeFile: title priority is ai-title > first user > last assistant', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'priority.jsonl'); + await writeTranscript(jsonlPath, [ + { type: 'workspace-directories', sessionId: 's1', directories: ['/proj'] }, + { type: 'user', sessionId: 's1', message: { role: 'user', content: 'first question' } }, + { + type: 'assistant', + sessionId: 's1', + message: { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] }, + }, + { + type: 'assistant', + sessionId: 's1', + message: { role: 'assistant', content: [{ type: 'text', text: 'second answer' }] }, + }, + { type: 'ai-title', sessionId: 's1', aiTitle: 'AI Generated Title' }, + ]); + + const result = await synchronizer.synchronizeFile(jsonlPath); + assert.equal(result, 's1'); + const row = sessionsDb.getSessionByProviderSessionId('s1'); + assert.equal(row?.custom_name, 'AI Generated Title'); + }); +}); + +test('synchronizeFile: falls back to first user prompt when no ai-title', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'no-aititle.jsonl'); + await writeTranscript(jsonlPath, [ + { type: 'workspace-directories', sessionId: 's2', directories: ['/proj'] }, + { type: 'user', sessionId: 's2', message: { role: 'user', content: 'my question' } }, + { + type: 'assistant', + sessionId: 's2', + message: { role: 'assistant', content: [{ type: 'text', text: 'my answer' }] }, + }, + ]); + + await synchronizer.synchronizeFile(jsonlPath); + const row = sessionsDb.getSessionByProviderSessionId('s2'); + assert.equal(row?.custom_name, 'my question'); + }); +}); + +test('synchronizeFile: falls back to last assistant text when no ai-title or user text', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'fallback.jsonl'); + await writeTranscript(jsonlPath, [ + { type: 'workspace-directories', sessionId: 's3', directories: ['/proj'] }, + { + type: 'assistant', + sessionId: 's3', + message: { role: 'assistant', content: [{ type: 'text', text: 'final answer' }] }, + }, + ]); + + await synchronizer.synchronizeFile(jsonlPath); + const row = sessionsDb.getSessionByProviderSessionId('s3'); + assert.equal(row?.custom_name, 'final answer'); + }); +}); + +test('synchronizeFile: tolerates blank lines in JSONL', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'blanks.jsonl'); + await writeTranscript(jsonlPath, [ + null, + { type: 'workspace-directories', sessionId: 's4', directories: ['/proj'] }, + null, + { type: 'user', sessionId: 's4', message: { role: 'user', content: 'survives blanks' } }, + null, + ]); + + await synchronizer.synchronizeFile(jsonlPath); + const row = sessionsDb.getSessionByProviderSessionId('s4'); + assert.equal(row?.custom_name, 'survives blanks'); + }); +}); + +test('synchronizeFile: skips sidechain sessions tagged with isSidechain', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'sidechain.jsonl'); + await writeTranscript(jsonlPath, [ + { + type: 'workspace-directories', + sessionId: 's5', + isSidechain: true, + directories: ['/proj'], + }, + ]); + + const result = await synchronizer.synchronizeFile(jsonlPath); + assert.equal(result, null); + assert.equal(sessionsDb.getSessionByProviderSessionId('s5'), null); + }); +}); + +test('synchronizeFile: skips sessions tagged with parentUuid', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'parent.jsonl'); + await writeTranscript(jsonlPath, [ + { + type: 'workspace-directories', + sessionId: 's6', + parentUuid: 'abc-123', + directories: ['/proj'], + }, + ]); + + const result = await synchronizer.synchronizeFile(jsonlPath); + assert.equal(result, null); + assert.equal(sessionsDb.getSessionByProviderSessionId('s6'), null); + }); +}); + +test('synchronizeFile: last ai-title wins over earlier ones', async () => { + await withIsolatedDatabase(async (tempDir) => { + const jsonlPath = path.join(tempDir, 'multi-aititle.jsonl'); + await writeTranscript(jsonlPath, [ + { type: 'workspace-directories', sessionId: 's7', directories: ['/proj'] }, + { type: 'ai-title', sessionId: 's7', aiTitle: 'First Title' }, + { type: 'ai-title', sessionId: 's7', aiTitle: 'Second Title' }, + ]); + + await synchronizer.synchronizeFile(jsonlPath); + const row = sessionsDb.getSessionByProviderSessionId('s7'); + assert.equal(row?.custom_name, 'Second Title'); + }); +}); diff --git a/server/modules/providers/tests/qoder-sessions.test.ts b/server/modules/providers/tests/qoder-sessions.test.ts new file mode 100644 index 0000000000..0f5dac5892 --- /dev/null +++ b/server/modules/providers/tests/qoder-sessions.test.ts @@ -0,0 +1,315 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { QoderSessionsProvider } from '@/modules/providers/list/qoder/qoder-sessions.provider.js'; + +const provider = new QoderSessionsProvider(); + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'qoder-sessions-test-')); + closeConnection(); + process.env.DATABASE_PATH = path.join(tempDirectory, 'auth.db'); + await initializeDatabase(); + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// normalizeMessage +// --------------------------------------------------------------------------- + +test('normalizeMessage: system/init event is dropped', () => { + const messages = provider.normalizeMessage( + { type: 'system', subtype: 'init', sessionID: 'sess-1' }, + 'sess-1', + ); + assert.deepEqual(messages, []); +}); + +test('normalizeMessage: content_block_delta produces a stream_delta', () => { + const messages = provider.normalizeMessage( + { type: 'content_block_delta', delta: { text: 'hello' } }, + 'sess-1', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'stream_delta'); + assert.equal(messages[0].content, 'hello'); + assert.equal(messages[0].provider, 'qoder'); +}); + +test('normalizeMessage: content_block_stop produces a stream_end', () => { + const messages = provider.normalizeMessage( + { type: 'content_block_stop' }, + 'sess-1', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'stream_end'); +}); + +test('normalizeMessage: result with string result produces an assistant text message', () => { + const messages = provider.normalizeMessage( + { type: 'result', subtype: 'success', result: 'Done' }, + 'sess-1', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'text'); + assert.equal(messages[0].role, 'assistant'); + assert.equal(messages[0].content, 'Done'); +}); + +test('normalizeMessage: result with content array produces multiple text messages', () => { + const messages = provider.normalizeMessage( + { + type: 'result', + result: { + content: [ + { type: 'text', text: 'part 1' }, + { type: 'text', text: 'part 2' }, + ], + }, + }, + 'sess-1', + ); + assert.equal(messages.length, 2); + assert.equal(messages[0].content, 'part 1'); + assert.equal(messages[1].content, 'part 2'); +}); + +test('normalizeMessage: user text message with string content', () => { + const messages = provider.normalizeMessage( + { type: 'user', message: { role: 'user', content: 'hello world' } }, + 'sess-1', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'text'); + assert.equal(messages[0].role, 'user'); + assert.equal(messages[0].content, 'hello world'); +}); + +test('normalizeMessage: assistant text/thinking/tool_use parts', () => { + const messages = provider.normalizeMessage( + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'hmm' }, + { type: 'text', text: 'answer' }, + { type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/a' } }, + ], + }, + }, + 'sess-1', + ); + assert.equal(messages.length, 3); + assert.equal(messages[0].kind, 'thinking'); + assert.equal(messages[0].content, 'hmm'); + assert.equal(messages[1].kind, 'text'); + assert.equal(messages[1].content, 'answer'); + assert.equal(messages[2].kind, 'tool_use'); + assert.equal(messages[2].toolName, 'Read'); +}); + +test('normalizeMessage: tool_result in user message content array', () => { + const messages = provider.normalizeMessage( + { + type: 'user', + message: { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tu-1', content: 'result text', is_error: false }, + ], + }, + }, + 'sess-1', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'tool_result'); + assert.equal(messages[0].toolId, 'tu-1'); + assert.equal(messages[0].content, 'result text'); + assert.equal(messages[0].isError, false); +}); + +test('normalizeMessage: error event produces an error message', () => { + const messages = provider.normalizeMessage( + { type: 'error', error: { message: 'something broke' } }, + 'sess-1', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'error'); + assert.equal(messages[0].content, 'something broke'); +}); + +test('normalizeMessage: internal meta rows are dropped', () => { + for (const type of ['workspace-directories', 'runtime-config', 'ai-title', 'last-prompt']) { + const messages = provider.normalizeMessage({ type, data: 'whatever' }, 'sess-1'); + assert.deepEqual(messages, [], `expected no messages for ${type}`); + } +}); + +// --------------------------------------------------------------------------- +// fetchHistory +// --------------------------------------------------------------------------- + +test('fetchHistory: returns empty result when session file does not exist', async () => { + await withIsolatedDatabase(async () => { + const result = await provider.fetchHistory('nonexistent', {}); + assert.deepEqual(result, { + messages: [], + total: 0, + hasMore: false, + offset: 0, + limit: null, + }); + }); +}); + +test('fetchHistory: returns normalized messages from a JSONL transcript', async () => { + await withIsolatedDatabase(async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'qoder-fetch-')); + const jsonlPath = path.join(tempDir, 'session.jsonl'); + + await writeFile(jsonlPath, [ + JSON.stringify({ + type: 'user', + sessionId: 'sess-fetch', + timestamp: '2026-01-01T00:00:00.000Z', + uuid: 'u1', + message: { role: 'user', content: 'hello' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'sess-fetch', + timestamp: '2026-01-01T00:01:00.000Z', + uuid: 'a1', + message: { role: 'assistant', content: [{ type: 'text', text: 'hi there' }] }, + }), + ].join('\n')); + + sessionsDb.createSession( + 'sess-fetch', + 'qoder', + '/test', + 'Test Session', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:01:00.000Z', + jsonlPath, + ); + + try { + const result = await provider.fetchHistory('sess-fetch', {}); + assert.equal(result.total, 2); + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].kind, 'text'); + assert.equal(result.messages[0].role, 'user'); + assert.equal(result.messages[0].content, 'hello'); + assert.equal(result.messages[1].kind, 'text'); + assert.equal(result.messages[1].role, 'assistant'); + assert.equal(result.messages[1].content, 'hi there'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +test('fetchHistory: limit=0 returns empty page with correct total', async () => { + await withIsolatedDatabase(async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'qoder-limit0-')); + const jsonlPath = path.join(tempDir, 'session.jsonl'); + + await writeFile(jsonlPath, [ + JSON.stringify({ + type: 'user', + sessionId: 'sess-l0', + timestamp: '2026-01-01T00:00:00.000Z', + uuid: 'u1', + message: { role: 'user', content: 'hello' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'sess-l0', + timestamp: '2026-01-01T00:01:00.000Z', + uuid: 'a1', + message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + }), + ].join('\n')); + + sessionsDb.createSession( + 'sess-l0', + 'qoder', + '/test', + 'Test', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:01:00.000Z', + jsonlPath, + ); + + try { + const result = await provider.fetchHistory('sess-l0', { limit: 0 }); + assert.equal(result.total, 2); + assert.equal(result.messages.length, 0); + assert.equal(result.hasMore, true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +test('fetchHistory: result string form is normalized as assistant text', async () => { + await withIsolatedDatabase(async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'qoder-result-')); + const jsonlPath = path.join(tempDir, 'session.jsonl'); + + await writeFile(jsonlPath, [ + JSON.stringify({ + type: 'user', + sessionId: 'sess-r', + timestamp: '2026-01-01T00:00:00.000Z', + uuid: 'u1', + message: { role: 'user', content: 'what is 2+2?' }, + }), + JSON.stringify({ + type: 'result', + sessionId: 'sess-r', + timestamp: '2026-01-01T00:01:00.000Z', + uuid: 'r1', + subtype: 'success', + result: '4', + }), + ].join('\n')); + + sessionsDb.createSession( + 'sess-r', + 'qoder', + '/test', + 'Result Test', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:01:00.000Z', + jsonlPath, + ); + + try { + const result = await provider.fetchHistory('sess-r', {}); + assert.equal(result.total, 2); + assert.equal(result.messages[1].kind, 'text'); + assert.equal(result.messages[1].role, 'assistant'); + assert.equal(result.messages[1].content, '4'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 7d0013608c..50eb6a507e 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -216,6 +216,16 @@ function buildShellCommand( return initialCommand || 'opencode'; } + if (provider === 'qoder') { + if (resumeSessionId) { + if (os.platform() === 'win32') { + return `qodercli --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { qodercli }`; + } + return `qodercli --resume "${resumeSessionId}" || qodercli`; + } + return initialCommand || 'qodercli'; + } + const command = initialCommand || 'claude'; if (resumeSessionId) { if (os.platform() === 'win32') { @@ -536,7 +546,9 @@ export function handleShellConnection( ? 'Codex' : provider === 'opencode' ? 'OpenCode' - : 'Claude'; + : provider === 'qoder' + ? 'Qoder' + : 'Claude'; welcomeMsg = hasSession && resumeSessionId ? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; diff --git a/server/shared/types.ts b/server/shared/types.ts index f8db6b400b..cfa4a78c6e 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -66,7 +66,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & { * Use this as the source of truth whenever a function or payload needs to identify * a specific LLM integration. */ -export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode'; +export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode' | 'qoder'; /** * One selectable model row in a provider model catalog. diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 927118b5bc..7d97f52af1 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -604,6 +604,26 @@ export const writeJsonConfig = async (filePath: string, data: Record => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + // --------------------------- //----------------- PROVIDER SKILL FILE UTILITIES ------------ async function hasGitMarker(dirPath: string): Promise { @@ -881,6 +901,164 @@ export function unwrapJsonStringLiteral(value: string): string { } } +// --------------------------- +//----------------- QODER SESSION STORAGE UTILITIES ------------ +/** + * Resolves the Qoder user configuration home directory. + * + * Qoder keeps user-level state (credentials, transcripts, and config) under + * `~/.qoder`. Provider readers and synchronizers should resolve it through + * this helper instead of hardcoding the home-relative path. + */ +export function getQoderHome(): string { + return path.join(os.homedir(), '.qoder'); +} + +/** + * Resolves the Qoder session transcript root directory. + * + * Qoder stores one JSONL transcript per session under + * `~/.qoder/projects/ '-'>/.jsonl`. Provider + * readers, synchronizers, and watchers should use this path for read-only + * access to live session transcripts. + */ +export function getQoderProjectsDir(): string { + return path.join(getQoderHome(), 'projects'); +} + +/** + * Encodes a working directory the way qodercli does when naming its + * transcript directory: every `/` is replaced by `-` and nothing else + * changes (`/home/admin/my.app` → `-home-admin-my.app`). + * + * All code that derives a Qoder transcript path from a cwd — the runtime's + * token usage reader, the models provider's fallback, and the token usage + * service — must go through this single encoder so the derivation stays + * consistent with what the CLI actually writes on disk. Do NOT reuse the + * Claude-style `replace(/[^a-zA-Z0-9-]/g, '-')` here: Qoder only folds + * slashes, so a stricter replacement breaks paths containing dots or other + * non-alphanumerics. + */ +export function encodeQoderCwd(cwd: string): string { + return cwd.replace(/\//g, '-'); +} + +/** + * Validates a provider-native session id before it is used to build a + * filesystem path. + * + * Session ids arrive from WebSocket and HTTP payloads, so an unvalidated id such + * as `../../../etc/passwd` would escape the provider's transcript directory. + * Every path builder that interpolates a session id must reject ids that fail + * this whitelist. The character set matches the ids qodercli and Claude write on + * disk (UUIDs plus the `.`/`:` separators some providers use). + */ +export function isSafeSessionId(value: string | null | undefined): boolean { + return typeof value === 'string' && /^[a-zA-Z0-9_.\-:]+$/.test(value); +} + +/** + * Resolves the Qoder transcript path for one session, or `null` when the inputs + * cannot produce a safe path. + * + * This is the single derivation used by the qoder runtime, models provider, + * sessions provider, and the provider token usage service. It guarantees three + * things those callers previously each had to remember: + * - the cwd is absolute before it is encoded (a relative cwd would otherwise + * encode to a different directory than the one qodercli wrote), + * - the encoding goes through `encodeQoderCwd` rather than a hand-rolled + * `replace`, + * - the session id is whitelisted, so it cannot traverse out of the projects + * directory. + * + * `homeDirectory` exists for the token usage service, which resolves the home + * root through an injected dependency so tests can point it at a temp folder. + */ +export function resolveQoderTranscriptPath(options: { + cwd: string | null | undefined; + sessionId: string | null | undefined; + homeDirectory?: string; +}): string | null { + const { cwd, sessionId, homeDirectory } = options; + if (!cwd || !isSafeSessionId(sessionId)) { + return null; + } + + const projectsDir = homeDirectory + ? path.join(homeDirectory, '.qoder', 'projects') + : getQoderProjectsDir(); + const encodedCwd = encodeQoderCwd(path.resolve(cwd).replace(/\\/g, '/')); + return path.join(projectsDir, encodedCwd, `${sessionId}.jsonl`); +} + +/** + * Cumulative token usage read from a Qoder transcript. + * + * `inputTokens` folds cache reads into the input figure because that is what the + * chat UI displays as "input"; `used` is the true total of every counted field. + */ +export type QoderTranscriptTokenUsage = { + used: number; + inputTokens: number; + outputTokens: number; + breakdown: { input: number; output: number }; +}; + +/** + * Sums the `usage` objects on every assistant row of a Qoder transcript. + * + * Consumed by the qoder runtime (post-run `token_budget` status event) and by + * the qoder sessions provider (`fetchHistory`), which previously carried two + * copies of this arithmetic. Returns `null` when the transcript contains no + * usage data so callers can skip emitting an empty budget. + * + * Note the deliberate difference from the provider token usage service, which + * reports the *latest* assistant row plus a context window instead of a + * cumulative sum: this helper answers "how many tokens did this session spend", + * not "how full is the context window". + * + * Accepts sync or async iterables so callers can stream a transcript instead of + * buffering it. + */ +export async function aggregateQoderTranscriptTokenUsage( + entries: Iterable | AsyncIterable, +): Promise { + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + + for await (const entry of entries) { + if (entry?.type !== 'assistant') { + continue; + } + + const usage = readJsonRecord(readJsonRecord(entry.message)?.usage); + if (!usage) { + continue; + } + + const safeCount = (v: unknown): number => { const n = Number(v ?? 0); return Number.isFinite(n) && n >= 0 ? n : 0; }; + inputTokens += safeCount(usage.input_tokens); + outputTokens += safeCount(usage.output_tokens); + cacheReadTokens += safeCount(usage.cache_read_input_tokens); + cacheCreationTokens += safeCount(usage.cache_creation_input_tokens); + } + + const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; + if (used <= 0) { + return null; + } + + const displayInput = inputTokens + cacheReadTokens; + return { + used, + inputTokens: displayInput, + outputTokens, + breakdown: { input: displayInput, output: outputTokens }, + }; +} + // --------------------------- //----------------- SAFE DIRECTORY NAME UTILITIES ------------ /** @@ -1018,6 +1196,47 @@ export async function buildLookupMap( return lookup; } +/** + * Streams the parsed JSON objects of a JSONL artifact, one row at a time. + * + * Consumed by the qoder runtime, models provider, and session synchronizer. + * Transcripts grow without bound, so those readers must never buffer a whole + * file: this helper keeps memory flat and yields control to the event loop + * between chunks. Blank lines, malformed rows, and non-object rows are skipped + * (a provider may be appending while this read happens); a missing or unreadable + * file yields nothing instead of throwing. + */ +export async function* readJsonlEntries(filePath: string): AsyncGenerator { + let lineReader: readline.Interface | null = null; + try { + const fileStream = fs.createReadStream(filePath); + lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of lineReader) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + + const record = readJsonRecord(parsed); + if (record) { + yield record; + } + } + } catch { + // Ignore missing or unreadable artifacts so scans keep progressing. + } finally { + lineReader?.close(); + } +} + /** * Reads a JSONL file and returns the first extracted payload that matches caller criteria. * diff --git a/src/assets/qoder.png b/src/assets/qoder.png new file mode 100644 index 0000000000..8724e0e76c Binary files /dev/null and b/src/assets/qoder.png differ diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 8c82f32f50..a68131efa5 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -635,7 +635,9 @@ export function useChatComposerState({ ? 'codex-settings' : provider === 'opencode' ? 'opencode-settings' - : 'claude-settings'; + : provider === 'qoder' + ? 'qoder-settings' + : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); if (savedSettings) { return JSON.parse(savedSettings); diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index cfe83f9c21..6b4ab19aed 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -21,9 +21,10 @@ const FALLBACK_DEFAULT_MODEL: Record = { cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', opencode: 'anthropic/claude-sonnet-4-5', + qoder: 'Auto', }; -const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode']; +const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'qoder']; const readStoredProvider = (): LLMProvider => { const storedProvider = localStorage.getItem('selected-provider'); @@ -43,6 +44,9 @@ const FALLBACK_PERMISSION_MODES: Record = { cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], codex: ['default', 'acceptEdits', 'bypassPermissions'], opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + // Qoder exposes no plan mode; see the qoder entry in + // provider-capabilities.service.ts. + qoder: ['default', 'acceptEdits', 'bypassPermissions'], }; type ProviderCapabilities = { @@ -114,6 +118,9 @@ export function useChatProviderState({ selectedSession, selectedProject: _select const [opencodeModel, setOpenCodeModel] = useState(() => { return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); + const [qoderModel, setQoderModel] = useState(() => { + return localStorage.getItem('qoder-model') || FALLBACK_DEFAULT_MODEL.qoder; + }); /** * Backend-owned capability matrix keyed by provider. Drives the permission @@ -156,6 +163,12 @@ export function useChatProviderState({ selectedSession, selectedProject: _select return; } + if (targetProvider === 'qoder') { + setQoderModel(model); + localStorage.setItem('qoder-model', model); + return; + } + setOpenCodeModel(model); localStorage.setItem('opencode-model', model); }, []); @@ -360,7 +373,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select cursor: cursorModel, codex: codexModel, opencode: opencodeModel, - }), [claudeModel, cursorModel, codexModel, opencodeModel]); + qoder: qoderModel, + }), [claudeModel, cursorModel, codexModel, opencodeModel, qoderModel]); useEffect(() => { const claude = providerModelCatalog.claude; @@ -414,6 +428,19 @@ export function useChatProviderState({ selectedSession, selectedProject: _select } }, [providerModelCatalog.opencode, opencodeModel]); + useEffect(() => { + const qoder = providerModelCatalog.qoder; + if (qoder) { + const next = pickStoredOrCurrent('qoder-model', qoderModel, qoder); + if (next !== qoderModel) { + setQoderModel(next); + } + if (localStorage.getItem('qoder-model') !== next) { + localStorage.setItem('qoder-model', next); + } + } + }, [providerModelCatalog.qoder, qoderModel]); + useEffect(() => { const nextEfforts: Partial> = {}; let hasUpdates = false; @@ -618,6 +645,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select currentProviderModelOptions, opencodeModel, setOpenCodeModel, + qoderModel, + setQoderModel, permissionMode, setPermissionMode, pendingPermissionRequests, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 50b8e9621f..5bffb3cc20 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -74,6 +74,8 @@ function ChatInterface({ currentProviderModelOptions, opencodeModel, setOpenCodeModel, + qoderModel, + setQoderModel, permissionMode, pendingPermissionRequests, setPendingPermissionRequests, @@ -309,7 +311,9 @@ function ChatInterface({ ? t('messageTypes.codex') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) - : t('messageTypes.claude'); + : provider === 'qoder' + ? t('messageTypes.qoder', { defaultValue: 'Qoder' }) + : t('messageTypes.claude'); if (!selectedProject) { return ( @@ -350,6 +354,8 @@ function ChatInterface({ setCodexModel={setCodexModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} + qoderModel={qoderModel} + setQoderModel={setQoderModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} tasksEnabled={tasksEnabled} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index b9f09a0d9c..7f5751e628 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -41,6 +41,8 @@ interface ChatMessagesPaneProps { setCodexModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; + qoderModel: string; + setQoderModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; tasksEnabled: boolean; @@ -89,6 +91,8 @@ function ChatMessagesPane({ setCodexModel, opencodeModel, setOpenCodeModel, + qoderModel, + setQoderModel, providerModelCatalog, providerModelsLoading, tasksEnabled, @@ -195,6 +199,8 @@ function ChatMessagesPane({ setCodexModel={setCodexModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} + qoderModel={qoderModel} + setQoderModel={setQoderModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} tasksEnabled={tasksEnabled} diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index 9f97b49368..5df02ab820 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -61,6 +61,7 @@ const PROVIDER_LABELS: Record = { cursor: 'Cursor', codex: 'Codex', opencode: 'OpenCode', + qoder: 'Qoder', }; const FALLBACK_COMMANDS: CommandEntry[] = [ diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index f54963be16..2509cbdc3d 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -165,7 +165,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ? t('messageTypes.codex') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) - : t('messageTypes.claude'))} + : provider === 'qoder' + ? t('messageTypes.qoder', { defaultValue: 'Qoder' }) + : t('messageTypes.claude'))} )} diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 6772ae09b9..2deb22b2d5 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -28,6 +28,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [ { id: "codex", name: "OpenAI" }, { id: "cursor", name: "Cursor" }, { id: "opencode", name: "OpenCode" }, + { id: "qoder", name: "Qoder" }, ]; const MOD_KEY = @@ -58,6 +59,8 @@ type ProviderSelectionEmptyStateProps = { setCodexModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; + qoderModel: string; + setQoderModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; tasksEnabled: boolean; @@ -86,10 +89,12 @@ function getCurrentModel( cu: string, co: string, o: string, + q: string, ) { if (p === "claude") return c; if (p === "codex") return co; if (p === "opencode") return o; + if (p === "qoder") return q; return cu; } @@ -98,6 +103,7 @@ function getProviderDisplayName(p: LLMProvider) { if (p === "cursor") return "Cursor"; if (p === "codex") return "Codex"; if (p === "opencode") return "OpenCode"; + if (p === "qoder") return "Qoder"; return "Claude"; } @@ -115,6 +121,8 @@ export default function ProviderSelectionEmptyState({ setCodexModel, opencodeModel, setOpenCodeModel, + qoderModel, + setQoderModel, providerModelCatalog, providerModelsLoading, tasksEnabled, @@ -143,6 +151,7 @@ export default function ProviderSelectionEmptyState({ cursorModel, codexModel, opencodeModel, + qoderModel, ); const currentModelLabel = useMemo(() => { @@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({ } else if (providerId === "opencode") { setOpenCodeModel(modelValue); localStorage.setItem("opencode-model", modelValue); + } else if (providerId === "qoder") { + setQoderModel(modelValue); + localStorage.setItem("qoder-model", modelValue); } else { setCursorModel(modelValue); localStorage.setItem("cursor-model", modelValue); } }, - [setClaudeModel, setCursorModel, setCodexModel, setOpenCodeModel], + [setClaudeModel, setCursorModel, setCodexModel, setOpenCodeModel, setQoderModel], ); const handleModelSelect = useCallback( @@ -316,6 +328,10 @@ export default function ProviderSelectionEmptyState({ model: opencodeModel, defaultValue: "Ready with OpenCode {{model}}", }), + qoder: t("providerSelection.readyPrompt.qoder", { + model: qoderModel, + defaultValue: "Ready with Qoder {{model}}", + }), }[provider] }

diff --git a/src/components/llm-logo-provider/QoderLogo.tsx b/src/components/llm-logo-provider/QoderLogo.tsx new file mode 100644 index 0000000000..3a61209a62 --- /dev/null +++ b/src/components/llm-logo-provider/QoderLogo.tsx @@ -0,0 +1,11 @@ +import qoderLogo from '../../assets/qoder.png'; + +type QoderLogoProps = { + className?: string; +}; + +const QoderLogo = ({ className = 'w-5 h-5' }: QoderLogoProps) => ( + Qoder +); + +export default QoderLogo; diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index b93d9eb56e..1831c9a08f 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo'; import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; import OpenCodeLogo from './OpenCodeLogo'; +import QoderLogo from './QoderLogo'; type SessionProviderLogoProps = { provider?: LLMProvider | string | null; @@ -25,5 +26,9 @@ export default function SessionProviderLogo({ return ; } + if (provider === 'qoder') { + return ; + } + return ; } diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts index 5a1848d6f2..18af5717c4 100644 --- a/src/components/mcp/constants.ts +++ b/src/components/mcp/constants.ts @@ -5,6 +5,7 @@ export const MCP_PROVIDER_NAMES: Record = { cursor: 'Cursor', codex: 'Codex', opencode: 'OpenCode', + qoder: 'Qoder', }; export const MCP_SUPPORTED_SCOPES: Record = { @@ -12,6 +13,7 @@ export const MCP_SUPPORTED_SCOPES: Record = { cursor: ['user', 'project'], codex: ['user', 'project'], opencode: ['user', 'project'], + qoder: ['user', 'project'], }; export const MCP_SUPPORTED_TRANSPORTS: Record = { @@ -19,6 +21,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = { cursor: ['stdio', 'http'], codex: ['stdio', 'http'], opencode: ['stdio', 'http'], + qoder: ['stdio'], }; export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project']; @@ -30,6 +33,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record = { cursor: 'bg-primary text-primary-foreground hover:bg-primary/90', codex: 'bg-primary text-primary-foreground hover:bg-primary/90', opencode: 'bg-primary text-primary-foreground hover:bg-primary/90', + qoder: 'bg-primary text-primary-foreground hover:bg-primary/90', }; export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = { @@ -37,6 +41,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = { cursor: false, codex: true, opencode: false, + qoder: false, }; export const DEFAULT_MCP_FORM: McpFormState = { diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx index d9148a9559..4b20d2d77b 100644 --- a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx +++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx @@ -37,6 +37,13 @@ const providerCards = [ iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800', loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600', }, + { + provider: 'qoder' as const, + title: 'Qoder', + connectedClassName: 'bg-zinc-100 dark:bg-zinc-800/50 border-zinc-300 dark:border-zinc-600', + iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800', + loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600', + }, ]; export default function AgentConnectionsStep({ diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts index 5511c1848f..3a27bfe666 100644 --- a/src/components/provider-auth/types.ts +++ b/src/components/provider-auth/types.ts @@ -10,13 +10,14 @@ export type ProviderAuthStatus = { export type ProviderAuthStatusMap = Record; -export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode']; +export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'qoder']; export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { claude: '/api/providers/claude/auth/status', cursor: '/api/providers/cursor/auth/status', codex: '/api/providers/codex/auth/status', opencode: '/api/providers/opencode/auth/status', + qoder: '/api/providers/qoder/auth/status', }; export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({ @@ -24,4 +25,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth cursor: { authenticated: false, email: null, method: null, error: null, loading }, codex: { authenticated: false, email: null, method: null, error: null, loading }, opencode: { authenticated: false, email: null, method: null, error: null, loading }, + qoder: { authenticated: false, email: null, method: null, error: null, loading }, }); diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx index 2a0cfc2ae0..7a017dded6 100644 --- a/src/components/provider-auth/view/ProviderLoginModal.tsx +++ b/src/components/provider-auth/view/ProviderLoginModal.tsx @@ -49,6 +49,7 @@ const getProviderTitle = (provider: LLMProvider) => { if (provider === 'cursor') return 'Cursor CLI Login'; if (provider === 'codex') return 'Codex CLI Login'; if (provider === 'opencode') return 'OpenCode CLI Login'; + if (provider === 'qoder') return 'Qoder'; return 'Claude CLI Login'; }; @@ -64,6 +65,53 @@ export default function ProviderLoginModal({ return null; } + // Qoder has no interactive login flow: authentication is a local `qodercli` + // binary with `~/.qoder/.auth/user` present. Render status guidance instead + // of a terminal shell. + if (provider === 'qoder') { + return ( +
+
+
+

{getProviderTitle(provider)}

+ +
+ +
+
+ + + + + +
+

Qoder CLI Authentication

+

+ Qoder authenticates via the locally installed qodercli binary. + {isAuthenticated + ? ' Your qodercli installation is authenticated.' + : ' Run `qodercli login` in a terminal to authenticate.'} +

+ {!isAuthenticated && ( + + )} +
+
+
+ ); + } + const command = getProviderCommand({ provider, customCommand, isAuthenticated }); const title = getProviderTitle(provider); diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 8c429083df..57e194793a 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -39,7 +39,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [ { id: 'about', label: 'About', keywords: 'about version info', icon: Info }, ]; -export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'opencode']; +export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'qoder']; export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp']; export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name'; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index cc57d37732..62ae8c417a 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -16,6 +16,7 @@ import type { CursorPermissionsState, NotificationPreferencesState, ProjectSortOrder, + QoderPermissionsState, SettingsMainTab, } from '../types/types'; @@ -42,6 +43,13 @@ type CursorSettingsStorage = { skipPermissions?: boolean; }; +type QoderSettingsStorage = { + allowedTools?: string[]; + disallowedTools?: string[]; + skipPermissions?: boolean; + restrictedTools?: string[]; +}; + type CodexSettingsStorage = { permissionMode?: CodexPermissionMode; }; @@ -103,6 +111,13 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({ ...DEFAULT_CURSOR_PERMISSIONS, }); +const createEmptyQoderPermissions = (): QoderPermissionsState => ({ + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + restrictedTools: [], +}); + const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({ channels: { inApp: true, @@ -154,6 +169,9 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl const [cursorPermissions, setCursorPermissions] = useState(() => ( createEmptyCursorPermissions() )); + const [qoderPermissions, setQoderPermissions] = useState(() => ( + createEmptyQoderPermissions() + )); const [notificationPreferences, setNotificationPreferences] = useState(() => ( createDefaultNotificationPreferences() )); @@ -190,6 +208,17 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl skipPermissions: Boolean(savedCursorSettings.skipPermissions), }); + const savedQoderSettings = parseJson( + localStorage.getItem('qoder-settings'), + {}, + ); + setQoderPermissions({ + allowedTools: savedQoderSettings.allowedTools || [], + disallowedTools: savedQoderSettings.disallowedTools || [], + skipPermissions: Boolean(savedQoderSettings.skipPermissions), + restrictedTools: savedQoderSettings.restrictedTools || [], + }); + const savedCodexSettings = parseJson( localStorage.getItem('codex-settings'), {}, @@ -216,6 +245,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl console.error('Error loading settings:', error); setClaudePermissions(createEmptyClaudePermissions()); setCursorPermissions(createEmptyCursorPermissions()); + setQoderPermissions(createEmptyQoderPermissions()); setNotificationPreferences(createDefaultNotificationPreferences()); setCodexPermissionMode('default'); setProjectSortOrder('name'); @@ -263,6 +293,14 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl lastUpdated: now, })); + localStorage.setItem('qoder-settings', JSON.stringify({ + allowedTools: qoderPermissions.allowedTools, + disallowedTools: qoderPermissions.disallowedTools, + skipPermissions: qoderPermissions.skipPermissions, + restrictedTools: qoderPermissions.restrictedTools, + lastUpdated: now, + })); + localStorage.setItem('codex-settings', JSON.stringify({ permissionMode: codexPermissionMode, lastUpdated: now, @@ -289,6 +327,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl cursorPermissions.allowedCommands, cursorPermissions.disallowedCommands, cursorPermissions.skipPermissions, + qoderPermissions.allowedTools, + qoderPermissions.disallowedTools, + qoderPermissions.skipPermissions, + qoderPermissions.restrictedTools, notificationPreferences, projectSortOrder, ]); @@ -390,6 +432,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl setClaudePermissions, cursorPermissions, setCursorPermissions, + qoderPermissions, + setQoderPermissions, notificationPreferences, setNotificationPreferences, codexPermissionMode, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 514de2f3c7..c3c2bf3071 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -45,6 +45,14 @@ export type CursorPermissionsState = { skipPermissions: boolean; }; +export type QoderPermissionsState = { + allowedTools: string[]; + disallowedTools: string[]; + skipPermissions: boolean; + /** Empty means no `--tools` flag, so every built-in tool stays available. */ + restrictedTools: string[]; +}; + export type CodeEditorSettingsState = { wordWrap: boolean; showMinimap: boolean; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 37f40bc96d..917297242b 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -49,6 +49,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set setNotificationPreferences, cursorPermissions, setCursorPermissions, + qoderPermissions, + setQoderPermissions, codexPermissionMode, setCodexPermissionMode, providerAuthStatus, @@ -183,6 +185,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set onClaudePermissionsChange={setClaudePermissions} cursorPermissions={cursorPermissions} onCursorPermissionsChange={setCursorPermissions} + qoderPermissions={qoderPermissions} + onQoderPermissionsChange={setQoderPermissions} codexPermissionMode={codexPermissionMode} onCodexPermissionModeChange={setCodexPermissionMode} projects={projects} diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index 52e87c3272..f6d956615d 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -32,6 +32,10 @@ const agentConfig: Record = { name: 'OpenCode', color: 'zinc', }, + qoder: { + name: 'Qoder', + color: 'zinc', + }, }; const colorClasses = { diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index 31216a103b..10c1dba192 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -14,6 +14,8 @@ export default function AgentsSettingsTab({ onClaudePermissionsChange, cursorPermissions, onCursorPermissionsChange, + qoderPermissions, + onQoderPermissionsChange, codexPermissionMode, onCodexPermissionModeChange, projects, @@ -27,7 +29,7 @@ export default function AgentsSettingsTab({ ), [selectedAgent]); const visibleAgents = useMemo(() => { - return ['claude', 'cursor', 'codex', 'opencode']; + return ['claude', 'cursor', 'codex', 'opencode', 'qoder']; }, []); const agentContextById = useMemo>(() => ({ @@ -47,12 +49,17 @@ export default function AgentsSettingsTab({ authStatus: providerAuthStatus.opencode, onLogin: () => onProviderLogin('opencode'), }, + qoder: { + authStatus: providerAuthStatus.qoder, + onLogin: () => onProviderLogin('qoder'), + }, }), [ onProviderLogin, providerAuthStatus.claude, providerAuthStatus.codex, providerAuthStatus.cursor, providerAuthStatus.opencode, + providerAuthStatus.qoder, ]); useEffect(() => { @@ -86,6 +93,8 @@ export default function AgentsSettingsTab({ onClaudePermissionsChange={onClaudePermissionsChange} cursorPermissions={cursorPermissions} onCursorPermissionsChange={onCursorPermissionsChange} + qoderPermissions={qoderPermissions} + onQoderPermissionsChange={onQoderPermissionsChange} codexPermissionMode={codexPermissionMode} onCodexPermissionModeChange={onCodexPermissionModeChange} projects={projects} diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx index 5bf0de2a0b..5d1ca5410d 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx @@ -15,6 +15,8 @@ export default function AgentCategoryContentSection({ onClaudePermissionsChange, cursorPermissions, onCursorPermissionsChange, + qoderPermissions, + onQoderPermissionsChange, codexPermissionMode, onCodexPermissionModeChange, projects, @@ -73,6 +75,28 @@ export default function AgentCategoryContentSection({ /> )} + {selectedCategory === 'permissions' && selectedAgent === 'qoder' && ( + { + onQoderPermissionsChange({ ...qoderPermissions, skipPermissions: value }); + }} + allowedTools={qoderPermissions.allowedTools} + onAllowedToolsChange={(value) => { + onQoderPermissionsChange({ ...qoderPermissions, allowedTools: value }); + }} + disallowedTools={qoderPermissions.disallowedTools} + onDisallowedToolsChange={(value) => { + onQoderPermissionsChange({ ...qoderPermissions, disallowedTools: value }); + }} + restrictedTools={qoderPermissions.restrictedTools} + onRestrictedToolsChange={(value) => { + onQoderPermissionsChange({ ...qoderPermissions, restrictedTools: value }); + }} + /> + )} + {selectedCategory === 'mcp' && ( // SettingsProject.name is populated from the DB projectId by // normalizeProjectForSettings, so we can map it straight through. diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx index d1f670061b..c56ef18328 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx @@ -30,9 +30,11 @@ export default function AgentCategoryTabsSection({ {category === 'account' && t('tabs.account')} {category === 'permissions' && t('tabs.permissions')} {category === 'mcp' && t('tabs.mcpServers')} - {category === 'skills' && t('tabs.skills', { - defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills', - })} + {category === 'skills' && t( + selectedAgent === 'opencode' || selectedAgent === 'qoder' + ? 'tabs.sharedSkills' + : 'tabs.skills', + )} ))} diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx index e25733b4be..abc0a72d72 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx @@ -8,6 +8,7 @@ const AGENT_NAMES: Record = { cursor: 'Cursor', codex: 'Codex', opencode: 'OpenCode', + qoder: 'Qoder', }; export default function AgentSelectorSection({ @@ -23,7 +24,7 @@ export default function AgentSelectorSection({ const dotColor = agent === 'claude' ? 'bg-blue-500' : agent === 'cursor' ? 'bg-purple-500' : - agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60'; + agent === 'opencode' || agent === 'qoder' ? 'bg-zinc-500' : 'bg-foreground/60'; return ( = { subtextClass: 'text-zinc-700 dark:text-zinc-300', buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600', }, + qoder: { + name: 'Qoder', + description: 'Qoder CLI agent', + bgClass: 'bg-zinc-50 dark:bg-zinc-900/20', + borderClass: 'border-zinc-200 dark:border-zinc-700', + textClass: 'text-zinc-900 dark:text-zinc-100', + subtextClass: 'text-zinc-700 dark:text-zinc-300', + buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600', + }, }; export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) { diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx index 6c41cfa655..3b6a9820c2 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx @@ -36,6 +36,51 @@ const COMMON_CURSOR_COMMANDS = [ 'Shell(node)', ]; +/** + * Quick-add shortcuts for Qoder, grouped by purpose. Bare tool names only: + * qodercli 1.1.13 ignores `Bash(cmd:*)` style sub-command scopes. + */ +const QODER_TOOL_GROUPS = [ + { labelKey: 'read', tools: ['Read', 'Glob', 'Grep'] }, + { labelKey: 'write', tools: ['Write', 'Edit', 'NotebookEdit'] }, + { labelKey: 'exec', tools: ['Bash', 'Monitor'] }, + { labelKey: 'network', tools: ['WebFetch', 'WebSearch'] }, + { labelKey: 'orchestration', tools: ['Agent', 'Skill', 'Workflow', 'TaskCreate'] }, +] as const; + +/** Every built-in tool qodercli 1.1.13 exposes, for the `--tools` allowlist. */ +const QODER_ALL_TOOLS = [ + 'Agent', + 'Bash', + 'CreateGoal', + 'CronCreate', + 'CronDelete', + 'CronList', + 'Edit', + 'EnterWorktree', + 'ExitWorktree', + 'GetGoal', + 'Glob', + 'Grep', + 'ImageGen', + 'ImageSearch', + 'Monitor', + 'NotebookEdit', + 'Read', + 'ScheduleWakeup', + 'Skill', + 'TaskCreate', + 'TaskGet', + 'TaskList', + 'TaskStop', + 'TaskUpdate', + 'UpdateGoal', + 'WebFetch', + 'WebSearch', + 'Workflow', + 'Write', +]; + const addUnique = (items: string[], value: string): string[] => { const normalizedValue = value.trim(); if (!normalizedValue || items.includes(normalizedValue)) { @@ -476,6 +521,18 @@ type CodexPermissionsProps = { onPermissionModeChange: (value: CodexPermissionMode) => void; }; +type QoderPermissionsProps = { + agent: 'qoder'; + skipPermissions: boolean; + onSkipPermissionsChange: (value: boolean) => void; + allowedTools: string[]; + onAllowedToolsChange: (value: string[]) => void; + disallowedTools: string[]; + onDisallowedToolsChange: (value: string[]) => void; + restrictedTools: string[]; + onRestrictedToolsChange: (value: string[]) => void; +}; + function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit) { const { t } = useTranslation('settings'); @@ -579,7 +636,278 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit) { + const { t } = useTranslation('settings'); + const [newAllowedTool, setNewAllowedTool] = useState(''); + const [newDisallowedTool, setNewDisallowedTool] = useState(''); + + const handleAddAllowedTool = (tool: string) => { + const updated = addUnique(allowedTools, tool); + if (updated.length === allowedTools.length) { + return; + } + + onAllowedToolsChange(updated); + setNewAllowedTool(''); + }; + + const handleAddDisallowedTool = (tool: string) => { + const updated = addUnique(disallowedTools, tool); + if (updated.length === disallowedTools.length) { + return; + } + + onDisallowedToolsChange(updated); + setNewDisallowedTool(''); + }; + + const toggleRestrictedTool = (tool: string) => { + onRestrictedToolsChange( + restrictedTools.includes(tool) + ? removeValue(restrictedTools, tool) + : addUnique(restrictedTools, tool), + ); + }; + + return ( +
+
+
+ +

{t('permissions.title')}

+
+
+ +
+
+ +
+
+ +

{t('permissions.allowedTools.title')}

+
+

{t('permissions.allowedTools.description')}

+ +
+ setNewAllowedTool(event.target.value)} + placeholder={t('permissions.qoder.allowedPlaceholder')} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAddAllowedTool(newAllowedTool); + } + }} + className="h-10 flex-1" + /> + +
+ +
+

+ {t('permissions.allowedTools.quickAdd')} +

+ {QODER_TOOL_GROUPS.map((group) => ( +
+

+ {t(`permissions.qoder.groups.${group.labelKey}`)} +

+
+ {group.tools.map((tool) => ( + + ))} +
+
+ ))} +
+ +
+ {allowedTools.map((tool) => ( +
+ {tool} + +
+ ))} + {allowedTools.length === 0 && ( +
+ {t('permissions.allowedTools.empty')} +
+ )} +
+
+ +
+
+ +

{t('permissions.blockedTools.title')}

+
+

{t('permissions.blockedTools.description')}

+ + {skipPermissions && disallowedTools.length > 0 && ( +
+ + {t('permissions.qoder.bypassWarning')} +
+ )} + +
+ setNewDisallowedTool(event.target.value)} + placeholder={t('permissions.qoder.blockedPlaceholder')} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAddDisallowedTool(newDisallowedTool); + } + }} + className="h-10 flex-1" + /> + +
+ +
+ {disallowedTools.map((tool) => ( +
+ {tool} + +
+ ))} + {disallowedTools.length === 0 && ( +
+ {t('permissions.blockedTools.empty')} +
+ )} +
+
+ +
+
+ +

+ {t('permissions.qoder.restrictedTools.title')} +

+
+

+ {t('permissions.qoder.restrictedTools.description')} +

+ +
+ + {t('permissions.qoder.restrictedTools.summary', { count: restrictedTools.length })} + +
+ {QODER_ALL_TOOLS.map((tool) => { + const selected = restrictedTools.includes(tool); + return ( + + ); + })} +
+ {restrictedTools.length === 0 ? ( +

+ {t('permissions.qoder.restrictedTools.empty')} +

+ ) : ( + + )} +
+
+ +
+

+ {t('permissions.qoder.notes.title')} +

+
    +
  • {t('permissions.qoder.notes.allowlistOnly')}
  • +
  • {t('permissions.qoder.notes.denyWins')}
  • +
  • {t('permissions.qoder.notes.scopeUnsupported')}
  • +
+
+
+ ); +} + +type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps | QoderPermissionsProps; export default function PermissionsContent(props: PermissionsContentProps) { if (props.agent === 'claude') { @@ -590,5 +918,9 @@ export default function PermissionsContent(props: PermissionsContentProps) { return ; } + if (props.agent === 'qoder') { + return ; + } + return ; } diff --git a/src/components/settings/view/tabs/agents-settings/types.ts b/src/components/settings/view/tabs/agents-settings/types.ts index a5ed788e0a..63c89a74fa 100644 --- a/src/components/settings/view/tabs/agents-settings/types.ts +++ b/src/components/settings/view/tabs/agents-settings/types.ts @@ -4,6 +4,7 @@ import type { AgentCategory, ClaudePermissionsState, CursorPermissionsState, + QoderPermissionsState, CodexPermissionMode, SettingsProject, } from '../../../types/types'; @@ -23,6 +24,8 @@ export type AgentsSettingsTabProps = { onClaudePermissionsChange: (value: ClaudePermissionsState) => void; cursorPermissions: CursorPermissionsState; onCursorPermissionsChange: (value: CursorPermissionsState) => void; + qoderPermissions: QoderPermissionsState; + onQoderPermissionsChange: (value: QoderPermissionsState) => void; codexPermissionMode: CodexPermissionMode; onCodexPermissionModeChange: (value: CodexPermissionMode) => void; projects: SettingsProject[]; @@ -50,6 +53,8 @@ export type AgentCategoryContentSectionProps = { onClaudePermissionsChange: (value: ClaudePermissionsState) => void; cursorPermissions: CursorPermissionsState; onCursorPermissionsChange: (value: CursorPermissionsState) => void; + qoderPermissions: QoderPermissionsState; + onQoderPermissionsChange: (value: QoderPermissionsState) => void; codexPermissionMode: CodexPermissionMode; onCodexPermissionModeChange: (value: CodexPermissionMode) => void; projects: SettingsProject[]; diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 018c16689c..96fd824927 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -68,6 +68,7 @@ const PROVIDER_LABELS: Record = { codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', + qoder: 'Qoder', }; type CopyState = 'loading' | 'idle' | 'copying' | 'copied' | 'error'; diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx index 908ed07625..40ed1d346b 100644 --- a/src/components/skills/view/ProviderSkills.tsx +++ b/src/components/skills/view/ProviderSkills.tsx @@ -60,12 +60,14 @@ const PROVIDER_NAMES: Record = { codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', + qoder: 'Qoder', }; const PROVIDER_SKILL_PATHS: Record, string> = { claude: '~/.claude/skills//SKILL.md', codex: '~/.agents/skills//SKILL.md', cursor: '~/.cursor/skills//SKILL.md', + qoder: '~/.qoder/skills//SKILL.md', }; const SCOPE_LABELS: Record = { diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index eaecb479aa..f4735c60c8 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "opencode": "OpenCode" + "opencode": "OpenCode", + "qoder": "Qoder" }, "tools": { "settings": "Tool Settings", @@ -161,6 +162,7 @@ "cursor": "Ready to use Cursor with {{model}}. Start typing your message below.", "codex": "Ready to use Codex with {{model}}. Start typing your message below.", "opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.", + "qoder": "Ready to use Qoder with {{model}}. Start typing your message below.", "default": "Select a provider above to begin" }, "pressToSearch": "Press {{shortcut}} to search sessions, files, and commits" diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 305ce574b2..c421073715 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -5,6 +5,7 @@ "permissions": "Permissions", "mcpServers": "MCP Servers", "skills": "Skills", + "sharedSkills": "Shared Skills", "appearance": "Appearance" }, "account": { @@ -351,6 +352,9 @@ }, "opencode": { "description": "OpenCode CLI assistant" + }, + "qoder": { + "description": "Qoder CLI assistant" } }, "connectionStatus": "Connection Status", @@ -369,6 +373,7 @@ "skipPermissions": { "label": "Skip permission prompts (use with caution)", "claudeDescription": "Equivalent to --dangerously-skip-permissions flag", + "qoderDescription": "Equivalent to --permission-mode bypass_permissions flag", "cursorDescription": "Equivalent to -f flag in Cursor CLI" }, "allowedTools": { @@ -411,6 +416,31 @@ "npmInstall": "- Allow npm install", "rmRf": "- Block recursive delete" }, + "qoder": { + "allowedPlaceholder": "e.g., \"Read\" or \"Bash\"", + "blockedPlaceholder": "e.g., \"Bash\" or \"Write\"", + "groups": { + "read": "Read", + "write": "Write", + "exec": "Execute", + "network": "Network", + "orchestration": "Orchestration" + }, + "bypassWarning": "Skip permission prompts is on, so bypass_permissions can override this list and run blocked tools anyway. Use Restrict tool set below for a hard limit.", + "restrictedTools": { + "title": "Restrict Tool Set", + "description": "Removes every unselected tool from the session. This is the only hard limit — allow and block lists above only affect confirmation prompts.", + "summary": "Choose the tools Qoder may use ({{count}} selected)", + "empty": "Nothing selected: all built-in tools stay available.", + "clear": "Clear selection" + }, + "notes": { + "title": "How Qoder applies these settings:", + "allowlistOnly": "Allowed tools only skip the confirmation prompt. They do not restrict what Qoder can reach.", + "denyWins": "When a tool appears in both lists, the block list wins.", + "scopeUnsupported": "Sub-command scopes such as \"Bash(git log:*)\" are not enforced by qodercli — use bare tool names like \"Bash\"." + } + }, "codex": { "permissionMode": "Permission Mode", "description": "Controls how Codex handles file modifications and command execution", @@ -446,14 +476,16 @@ "claude": "Model Context Protocol servers provide additional tools and data sources to Claude", "cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor", "codex": "Model Context Protocol servers provide additional tools and data sources to Codex", - "opencode": "Model Context Protocol servers provide additional tools and data sources to OpenCode" + "opencode": "Model Context Protocol servers provide additional tools and data sources to OpenCode", + "qoder": "Model Context Protocol servers provide additional tools and data sources to Qoder" }, "addButton": "Add MCP Server", "empty": "No MCP servers configured", "serverType": "Type", "scope": { "local": "local", - "user": "user" + "user": "user", + "project": "project" }, "config": { "command": "Command", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 4582dba31b..ace879ba12 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -5,6 +5,7 @@ "permissions": "Permisos", "mcpServers": "Servidores MCP", "skills": "Skills", + "sharedSkills": "Shared Skills", "appearance": "Apariencia" }, "account": { diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 735b95508b..859b57c83d 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -5,6 +5,7 @@ "permissions": "권한", "mcpServers": "MCP 서버", "skills": "스킬", + "sharedSkills": "공유 스킬", "appearance": "외관" }, "account": { diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 6f0c8d05c0..703b2c81b1 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "opencode": "OpenCode" + "opencode": "OpenCode", + "qoder": "Qoder" }, "tools": { "settings": "工具设置", @@ -153,6 +154,7 @@ "cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。", "codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。", "opencode": "准备好使用带有 {{model}} 的 OpenCode。请在下方开始输入您的消息。", + "qoder": "准备好使用带有 {{model}} 的 Qoder。请在下方开始输入您的消息。", "default": "请在上方选择一个提供者以开始" }, "pressToSearch": "按 {{shortcut}} 搜索会话、文件和提交" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 7a32c15c32..4c501d2556 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -5,6 +5,7 @@ "permissions": "权限", "mcpServers": "MCP 服务器", "skills": "技能", + "sharedSkills": "共享技能", "appearance": "外观" }, "account": { @@ -351,6 +352,9 @@ }, "opencode": { "description": "OpenCode CLI 助手" + }, + "qoder": { + "description": "Qoder CLI 助手" } }, "connectionStatus": "连接状态", @@ -369,6 +373,7 @@ "skipPermissions": { "label": "跳过权限提示(请谨慎使用)", "claudeDescription": "等同于 --dangerously-skip-permissions 标志", + "qoderDescription": "等同于 --permission-mode bypass_permissions 标志", "cursorDescription": "等同于 Cursor CLI 中的 -f 标志" }, "allowedTools": { @@ -411,6 +416,31 @@ "npmInstall": "- 允许 npm install", "rmRf": "- 阻止递归删除" }, + "qoder": { + "allowedPlaceholder": "例如 \"Read\" 或 \"Bash\"", + "blockedPlaceholder": "例如 \"Bash\" 或 \"Write\"", + "groups": { + "read": "读取", + "write": "写入", + "exec": "执行", + "network": "网络", + "orchestration": "编排" + }, + "bypassWarning": "已开启跳过权限提示,bypass_permissions 会覆盖此列表,被阻止的工具仍可能执行。需要硬限制请使用下方的“限制工具集”。", + "restrictedTools": { + "title": "限制工具集", + "description": "未勾选的工具会从会话中移除。这是唯一的硬限制——上方的允许和阻止列表只影响确认提示。", + "summary": "选择 Qoder 可以使用的工具(已选 {{count}} 个)", + "empty": "未勾选任何工具:全部内置工具保持可用。", + "clear": "清空选择" + }, + "notes": { + "title": "Qoder 对这些设置的实际处理方式:", + "allowlistOnly": "允许列表只是免确认,并不会限制 Qoder 能访问的范围。", + "denyWins": "同一个工具同时出现在两个列表时,阻止列表优先。", + "scopeUnsupported": "qodercli 不支持 \"Bash(git log:*)\" 这类子命令作用域,请使用 \"Bash\" 这样的裸工具名。" + } + }, "codex": { "permissionMode": "权限模式", "description": "控制 Codex 如何处理文件修改和命令执行", @@ -446,14 +476,16 @@ "claude": "Model Context Protocol 服务器为 Claude 提供额外的工具和数据源", "cursor": "Model Context Protocol 服务器为 Cursor 提供额外的工具和数据源", "codex": "Model Context Protocol 服务器为 Codex 提供额外的工具和数据源", - "opencode": "Model Context Protocol 服务器为 OpenCode 提供额外的工具和数据源" + "opencode": "Model Context Protocol 服务器为 OpenCode 提供额外的工具和数据源", + "qoder": "Model Context Protocol 服务器为 Qoder 提供额外的工具和数据源" }, "addButton": "添加 MCP 服务器", "empty": "未配置 MCP 服务器", "serverType": "类型", "scope": { "local": "本地", - "user": "用户" + "user": "用户", + "project": "项目" }, "config": { "command": "命令", diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index 4474d7ee15..0b464cccd5 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -17,7 +17,8 @@ "tool": "工具", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "qoder": "Qoder" }, "tools": { "settings": "工具設定", @@ -169,6 +170,7 @@ "claude": "準備好使用 {{model}} 的 Claude。請在下方開始輸入您的訊息。", "cursor": "準備好使用 {{model}} 的 Cursor。請在下方開始輸入您的訊息。", "codex": "準備好使用 {{model}} 的 Codex。請在下方開始輸入您的訊息。", + "qoder": "準備好使用 {{model}} 的 Qoder。請在下方開始輸入您的訊息。", "default": "請在上方選擇一個提供者以開始" }, "pressToSearch": "按 {{shortcut}} 搜尋工作階段、檔案和提交" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 5fffcd68ca..776d2d3af4 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -323,6 +323,9 @@ }, "codex": { "description": "OpenAI Codex AI 助手" + }, + "qoder": { + "description": "Qoder CLI 助手" } }, "connectionStatus": "連線狀態", @@ -341,6 +344,7 @@ "skipPermissions": { "label": "略過權限提示(請謹慎使用)", "claudeDescription": "等同於 --dangerously-skip-permissions 旗標", + "qoderDescription": "等同於 --permission-mode bypass_permissions 旗標", "cursorDescription": "等同於 Cursor CLI 中的 -f 旗標" }, "allowedTools": { @@ -383,6 +387,31 @@ "npmInstall": "- 允許 npm install", "rmRf": "- 封鎖遞迴刪除" }, + "qoder": { + "allowedPlaceholder": "例如 \"Read\" 或 \"Bash\"", + "blockedPlaceholder": "例如 \"Bash\" 或 \"Write\"", + "groups": { + "read": "讀取", + "write": "寫入", + "exec": "執行", + "network": "網路", + "orchestration": "編排" + }, + "bypassWarning": "已開啟略過權限提示,bypass_permissions 會覆蓋此清單,被封鎖的工具仍可能執行。需要硬限制請使用下方的「限制工具集」。", + "restrictedTools": { + "title": "限制工具集", + "description": "未勾選的工具會從工作階段中移除。這是唯一的硬限制——上方的允許與封鎖清單只影響確認提示。", + "summary": "選擇 Qoder 可以使用的工具(已選 {{count}} 個)", + "empty": "未勾選任何工具:全部內建工具保持可用。", + "clear": "清除選擇" + }, + "notes": { + "title": "Qoder 對這些設定的實際處理方式:", + "allowlistOnly": "允許清單只是免確認,並不會限制 Qoder 能存取的範圍。", + "denyWins": "同一個工具同時出現在兩個清單時,封鎖清單優先。", + "scopeUnsupported": "qodercli 不支援 \"Bash(git log:*)\" 這類子指令作用域,請使用 \"Bash\" 這樣的裸工具名稱。" + } + }, "codex": { "permissionMode": "權限模式", "description": "控制 Codex 如何處理檔案修改和指令執行", @@ -417,7 +446,8 @@ "description": { "claude": "Model Context Protocol 伺服器為 Claude 提供額外的工具和資料來源", "cursor": "Model Context Protocol 伺服器為 Cursor 提供額外的工具和資料來源", - "codex": "Model Context Protocol 伺服器為 Codex 提供額外的工具和資料來源" + "codex": "Model Context Protocol 伺服器為 Codex 提供額外的工具和資料來源", + "qoder": "Model Context Protocol 伺服器為 Qoder 提供額外的工具和資料來源" }, "addButton": "新增 MCP 伺服器", "empty": "未設定 MCP 伺服器", diff --git a/src/types/app.ts b/src/types/app.ts index 8fa4a68430..01cd32ebfc 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,4 +1,4 @@ -export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; +export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'qoder'; export type ProviderModelOption = { value: string;