Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions public/api-docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ <h4>Request Body Parameters</h4>
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, or <code>opencode</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>opencode</code>, or <code>qoder</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
Expand All @@ -544,7 +544,7 @@ <h4>Request Body Parameters</h4>
<td><code>effort</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Reasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use <code>default</code> or omit it to let the provider decide.</td>
<td>Reasoning effort for Claude, Codex, OpenCode, and Qoder models that expose effort metadata. Use <code>default</code> or omit it to let the provider decide.</td>
</tr>
<tr>
<td><code>cleanup</code></td>
Expand Down Expand Up @@ -839,6 +839,7 @@ <h4>Branch & PR Response</h4>
{ id: 'codex', name: 'OpenAI' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
{ id: 'qoder', name: 'Qoder' },
];

async function populateModels() {
Expand Down
2 changes: 2 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -92,6 +93,7 @@ const agentRoutes = createAgentModule({
queryCursor,
queryCodex,
queryOpenCode,
queryQoder,
});

// Single WebSocket server that handles chat, shell, and plugin proxy paths.
Expand Down
2 changes: 1 addition & 1 deletion server/modules/agent/agent.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { createAgentRouter } from './agent.routes.js';

type AgentExternalDependencies = Pick<
Parameters<typeof createAgentRouter>[0],
'queryClaude' | 'queryCursor' | 'queryCodex' | 'queryOpenCode'
'queryClaude' | 'queryCursor' | 'queryCodex' | 'queryOpenCode' | 'queryQoder'
>;

/**
Expand Down
22 changes: 18 additions & 4 deletions server/modules/agent/agent.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type AgentRouterDependencies = {
queryCursor: ProviderRunFunction;
queryCodex: ProviderRunFunction;
queryOpenCode: ProviderRunFunction;
queryQoder: ProviderRunFunction;
GithubClient: typeof import('@octokit/rest').Octokit;
};

Expand All @@ -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();

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/modules/agent/tests/agent.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
3 changes: 2 additions & 1 deletion server/modules/commands/commands.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
12 changes: 11 additions & 1 deletion server/modules/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -65,7 +66,7 @@ server/modules/providers/list/<provider>/
<provider>-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
Expand Down Expand Up @@ -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 `<workspace>/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.

Expand All @@ -163,6 +165,7 @@ Current skill discovery roots are:
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.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:

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 2 additions & 10 deletions server/modules/providers/list/opencode/opencode-mcp.provider.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';

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,
Expand All @@ -17,15 +18,6 @@ type OpenCodeConfigPath = {
exists: boolean;
};

const fileExists = async (filePath: string): Promise<boolean> => {
try {
await access(filePath);
return true;
} catch {
return false;
}
};

/**
* Removes JSONC comments without touching comment-like text inside strings.
*/
Expand Down
65 changes: 65 additions & 0 deletions server/modules/providers/list/qoder/qoder-auth.provider.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<ProviderAuthStatus> {
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<boolean> {
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;
}
}
}
Loading