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) => (
+
+);
+
+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.'}
+