feat: add opencode support

This commit is contained in:
Haileyesus
2026-05-13 17:43:10 +03:00
parent 10f721cf14
commit 421bdd2f0f
53 changed files with 2691 additions and 130 deletions

View File

@@ -17,7 +17,7 @@ import crypto from 'crypto';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js'; import { CLAUDE_MODELS } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
import { import {
createNotificationEvent, createNotificationEvent,

View File

@@ -45,6 +45,12 @@ import {
isGeminiSessionActive, isGeminiSessionActive,
getActiveGeminiSessions, getActiveGeminiSessions,
} from './gemini-cli.js'; } from './gemini-cli.js';
import {
spawnOpenCode,
abortOpenCodeSession,
isOpenCodeSessionActive,
getActiveOpenCodeSessions,
} from './opencode-cli.js';
import sessionManager from './sessionManager.js'; import sessionManager from './sessionManager.js';
import { import {
stripAnsiSequences, stripAnsiSequences,
@@ -94,21 +100,25 @@ const wss = createWebSocketServer(server, {
spawnCursor, spawnCursor,
queryCodex, queryCodex,
spawnGemini, spawnGemini,
spawnOpenCode,
abortClaudeSDKSession, abortClaudeSDKSession,
abortCursorSession, abortCursorSession,
abortCodexSession, abortCodexSession,
abortGeminiSession, abortGeminiSession,
abortOpenCodeSession,
resolveToolApproval, resolveToolApproval,
isClaudeSDKSessionActive, isClaudeSDKSessionActive,
isCursorSessionActive, isCursorSessionActive,
isCodexSessionActive, isCodexSessionActive,
isGeminiSessionActive, isGeminiSessionActive,
isOpenCodeSessionActive,
reconnectSessionWriter, reconnectSessionWriter,
getPendingApprovalsForSession, getPendingApprovalsForSession,
getActiveClaudeSDKSessions, getActiveClaudeSDKSessions,
getActiveCursorSessions, getActiveCursorSessions,
getActiveCodexSessions, getActiveCodexSessions,
getActiveGeminiSessions, getActiveGeminiSessions,
getActiveOpenCodeSessions,
}, },
shell: { shell: {
getSessionById: (sessionId) => sessionManager.getSession(sessionId), getSessionById: (sessionId) => sessionManager.getSession(sessionId),
@@ -1148,6 +1158,18 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
}); });
} }
// OpenCode token totals are surfaced through provider history reads.
// This legacy endpoint only knows file-backed session formats.
if (provider === 'opencode') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
});
}
// Handle Codex sessions // Handle Codex sessions
if (provider === 'codex') { if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');

View File

@@ -33,6 +33,7 @@ type ProjectApiView = {
cursorSessions: []; cursorSessions: [];
codexSessions: []; codexSessions: [];
geminiSessions: []; geminiSessions: [];
opencodeSessions: [];
sessionMeta: { sessionMeta: {
hasMore: false; hasMore: false;
total: 0; total: 0;
@@ -84,6 +85,7 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
cursorSessions: [], cursorSessions: [],
codexSessions: [], codexSessions: [],
geminiSessions: [], geminiSessions: [],
opencodeSessions: [],
sessionMeta: { sessionMeta: {
hasMore: false, hasMore: false,
total: 0, total: 0,

View File

@@ -14,7 +14,7 @@ type SessionSummary = {
lastActivity: string; lastActivity: string;
}; };
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>; type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
type SessionRepositoryRow = { type SessionRepositoryRow = {
provider: string; provider: string;
@@ -34,6 +34,7 @@ export type ProjectListItem = {
cursorSessions: SessionSummary[]; cursorSessions: SessionSummary[];
codexSessions: SessionSummary[]; codexSessions: SessionSummary[];
geminiSessions: SessionSummary[]; geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -74,6 +75,7 @@ export type ProjectSessionsPageApiView = {
cursorSessions: SessionSummary[]; cursorSessions: SessionSummary[];
codexSessions: SessionSummary[]; codexSessions: SessionSummary[];
geminiSessions: SessionSummary[]; geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -139,6 +141,7 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
cursor: [], cursor: [],
codex: [], codex: [],
gemini: [], gemini: [],
opencode: [],
}; };
for (const row of rows) { for (const row of rows) {
@@ -253,6 +256,7 @@ export async function getProjectsWithSessions(
cursorSessions: sessionsPage.sessionsByProvider.cursor, cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex, codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini, geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -309,6 +313,7 @@ export async function getArchivedProjectsWithSessions(
cursorSessions: sessionsPage.sessionsByProvider.cursor, cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex, codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini, geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -341,6 +346,7 @@ export async function getProjectSessionsPage(
cursorSessions: sessionsPage.sessionsByProvider.cursor, cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex, codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini, geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,

View File

@@ -37,6 +37,7 @@ Current provider ids in this repo are:
- `codex` - `codex`
- `cursor` - `cursor`
- `gemini` - `gemini`
- `opencode`
Those ids are mirrored in backend unions and frontend provider constants. If Those ids are mirrored in backend unions and frontend provider constants. If
adding a new provider, update every place that hardcodes this list. adding a new provider, update every place that hardcodes this list.
@@ -55,7 +56,8 @@ server/modules/providers/list/<provider>/
<provider>-session-synchronizer.provider.ts <provider>-session-synchronizer.provider.ts
``` ```
The existing provider folders are `claude`, `codex`, `cursor`, and `gemini`. The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
`opencode`.
## What Each Facet Does ## What Each Facet Does
@@ -122,6 +124,7 @@ Current MCP formats in this repo are:
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` | | Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` | | Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` | | Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
5. Implement skills. 5. Implement skills.
@@ -142,6 +145,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. | | 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. | | Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. | | Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/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. |
Command forms currently used by the providers are: Command forms currently used by the providers are:
@@ -150,6 +154,7 @@ Command forms currently used by the providers are:
- Codex skills: `$skill-name` - Codex skills: `$skill-name`
- Cursor skills: `/skill-name` - Cursor skills: `/skill-name`
- Gemini skills: `/skill-name` - Gemini skills: `/skill-name`
- OpenCode skills: `/skill-name`
6. Implement sessions. 6. Implement sessions.
@@ -187,6 +192,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. | | 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. | | Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. | | Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
| 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. |
8. Register the provider. 8. Register the provider.
@@ -207,6 +213,7 @@ If the provider is visible in the UI, update:
- `src/components/chat/hooks/useChatProviderState.ts` - `src/components/chat/hooks/useChatProviderState.ts`
- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` - `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx`
- `src/components/provider-auth/view/ProviderLoginModal.tsx` - `src/components/provider-auth/view/ProviderLoginModal.tsx`
- `src/components/mcp/constants.ts`
## Minimal Wrapper Template ## Minimal Wrapper Template
@@ -324,6 +331,7 @@ Useful tests in this repo:
- `server/modules/providers/tests/mcp.test.ts` - `server/modules/providers/tests/mcp.test.ts`
- `server/modules/providers/tests/skills.test.ts` - `server/modules/providers/tests/skills.test.ts`
- `server/modules/providers/tests/opencode-sessions.test.ts`
If you touch sessions or session synchronization, add or update focused tests If you touch sessions or session synchronization, add or update focused tests
alongside the implementation. alongside the implementation.

View File

@@ -1,52 +1,12 @@
import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type { ProviderSkillSource } from '@/shared/types.js'; import type { ProviderSkillSource } from '@/shared/types.js';
import {
const hasGitMarker = async (dirPath: string): Promise<boolean> => { addUniqueProviderSkillSource,
try { findTopmostGitRoot,
const gitMarkerStats = await fs.stat(path.join(dirPath, '.git')); } from '@/shared/utils.js';
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
} catch {
return false;
}
};
const findTopmostGitRoot = async (startPath: string): Promise<string | null> => {
let currentPath = path.resolve(startPath);
let topmostGitRoot: string | null = null;
while (true) {
if (await hasGitMarker(currentPath)) {
topmostGitRoot = currentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
return topmostGitRoot;
};
const addUniqueSource = (
sources: ProviderSkillSource[],
seenRootDirs: Set<string>,
source: ProviderSkillSource,
): void => {
const normalizedRootDir = path.resolve(source.rootDir);
if (seenRootDirs.has(normalizedRootDir)) {
return;
}
seenRootDirs.add(normalizedRootDir);
sources.push({ ...source, rootDir: normalizedRootDir });
};
export class CodexSkillsProvider extends SkillsProvider { export class CodexSkillsProvider extends SkillsProvider {
constructor() { constructor() {
@@ -58,7 +18,7 @@ export class CodexSkillsProvider extends SkillsProvider {
const seenRootDirs = new Set<string>(); const seenRootDirs = new Set<string>();
const repoRoot = await findTopmostGitRoot(workspacePath); const repoRoot = await findTopmostGitRoot(workspacePath);
addUniqueSource(sources, seenRootDirs, { addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'repo', scope: 'repo',
rootDir: path.join(workspacePath, '.agents', 'skills'), rootDir: path.join(workspacePath, '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
@@ -67,29 +27,29 @@ export class CodexSkillsProvider extends SkillsProvider {
if (repoRoot) { if (repoRoot) {
// Codex checks repository skills at the launch folder, one folder above it, // Codex checks repository skills at the launch folder, one folder above it,
// and the topmost git root; these can collapse to the same directory. // and the topmost git root; these can collapse to the same directory.
addUniqueSource(sources, seenRootDirs, { addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'repo', scope: 'repo',
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'), rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
addUniqueSource(sources, seenRootDirs, { addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'repo', scope: 'repo',
rootDir: path.join(repoRoot, '.agents', 'skills'), rootDir: path.join(repoRoot, '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
} }
addUniqueSource(sources, seenRootDirs, { addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'user', scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'), rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
addUniqueSource(sources, seenRootDirs, { addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'admin', scope: 'admin',
rootDir: path.join('/etc', 'codex', 'skills'), rootDir: path.join('/etc', 'codex', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
addUniqueSource(sources, seenRootDirs, { addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'system', scope: 'system',
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'), rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
commandPrefix: '$', commandPrefix: '$',

View File

@@ -0,0 +1,111 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type OpenCodeCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
const OPENCODE_ENV_CREDENTIAL_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY',
'OPENROUTER_API_KEY',
];
export class OpenCodeProviderAuth implements IProviderAuth {
/**
* Checks whether the OpenCode CLI is available to the server process.
*/
private checkInstalled(): boolean {
try {
const result = spawn.sync('opencode', ['--version'], { stdio: 'ignore', timeout: 5000 });
return !result.error && result.status === 0;
} catch {
return false;
}
}
/**
* Returns OpenCode CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
const credentials = await this.checkCredentials();
return {
installed,
provider: 'opencode',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads OpenCode's auth store or falls back to provider API key environment variables.
*/
private async checkCredentials(): Promise<OpenCodeCredentialsStatus> {
try {
const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = readObjectRecord(JSON.parse(content)) ?? {};
for (const [providerId, providerAuth] of Object.entries(auth)) {
const providerRecord = readObjectRecord(providerAuth);
if (!providerRecord) {
continue;
}
const hasCredential = Object.values(providerRecord).some(
(value) => readOptionalString(value) !== undefined || Boolean(readObjectRecord(value)),
);
if (hasCredential) {
return {
authenticated: true,
email: `${providerId} credentials`,
method: 'credentials_file',
};
}
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
return {
authenticated: false,
email: null,
method: null,
error: error instanceof Error ? error.message : 'Failed to read OpenCode auth',
};
}
}
const envCredential = OPENCODE_ENV_CREDENTIAL_KEYS.find((key) => process.env[key]?.trim());
if (envCredential) {
return {
authenticated: true,
email: envCredential,
method: 'environment',
};
}
return {
authenticated: false,
email: null,
method: null,
error: 'OpenCode not configured',
};
}
}

View File

@@ -0,0 +1,228 @@
import { access, 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,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
type OpenCodeConfigPath = {
filePath: string;
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.
*/
const stripJsonComments = (content: string): string => {
let output = '';
let inString = false;
let quote = '';
let escaped = false;
for (let index = 0; index < content.length; index += 1) {
const char = content[index];
const next = content[index + 1];
if (inString) {
output += char;
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === quote) {
inString = false;
quote = '';
}
continue;
}
if (char === '"' || char === '\'') {
inString = true;
quote = char;
output += char;
continue;
}
if (char === '/' && next === '/') {
while (index < content.length && content[index] !== '\n') {
index += 1;
}
output += '\n';
continue;
}
if (char === '/' && next === '*') {
index += 2;
while (index < content.length && !(content[index] === '*' && content[index + 1] === '/')) {
index += 1;
}
index += 1;
continue;
}
output += char;
}
return output;
};
const stripTrailingCommas = (content: string): string =>
content.replace(/,\s*([}\]])/g, '$1');
const readOpenCodeConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(content))) as unknown;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
const writeOpenCodeConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
};
const resolveOpenCodeConfigPath = async (scope: McpScope, workspacePath: string): Promise<OpenCodeConfigPath> => {
const root = scope === 'user'
? path.join(os.homedir(), '.config', 'opencode')
: workspacePath;
const jsonPath = path.join(root, 'opencode.json');
const jsoncPath = path.join(root, 'opencode.jsonc');
if (await fileExists(jsonPath)) {
return { filePath: jsonPath, exists: true };
}
if (await fileExists(jsoncPath)) {
return { filePath: jsoncPath, exists: true };
}
return { filePath: jsonPath, exists: false };
};
export class OpenCodeMcpProvider extends McpProvider {
constructor() {
super('opencode', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
const config = await readOpenCodeConfig(filePath);
return readObjectRecord(config.mcp) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
const config = await readOpenCodeConfig(filePath);
config.mcp = servers;
await writeOpenCodeConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
type: 'local',
command: [input.command, ...(input.args ?? [])],
enabled: true,
environment: input.env ?? {},
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: 'remote',
url: input.url,
enabled: true,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
const config = readObjectRecord(rawConfig);
if (!config) {
return null;
}
if (config.type === 'local' || config.command !== undefined) {
const commandParts = typeof config.command === 'string'
? [config.command, ...(readStringArray(config.args) ?? [])]
: readStringArray(config.command);
const command = commandParts?.[0];
if (!command) {
return null;
}
return {
provider: 'opencode',
name,
scope,
transport: 'stdio',
command,
args: commandParts.slice(1),
env: readStringRecord(config.environment) ?? readStringRecord(config.env),
};
}
if (config.type === 'remote' || typeof config.url === 'string') {
const url = readOptionalString(config.url);
if (!url) {
return null;
}
return {
provider: 'opencode',
name,
scope,
transport: 'http',
url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,157 @@
import fsSync from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import {
getOpenCodeDatabasePath,
normalizeProviderTimestamp,
normalizeSessionName,
readJsonRecord,
readOptionalString,
} from '@/shared/utils.js';
type OpenCodeSessionRow = {
id: string;
directory: string | null;
title: string | null;
time_created: number | null;
time_updated: number | null;
worktree: string | null;
};
type SynchronizeRowsResult = {
processed: number;
firstSessionId: string | null;
};
/**
* Session indexer for OpenCode's SQLite-backed session store.
*/
export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'opencode' as const;
/**
* Scans OpenCode's shared opencode.db and upserts active sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const result = this.synchronizeRows(since);
return result.processed;
}
/**
* Handles watcher changes for opencode.db.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (path.basename(filePath) !== 'opencode.db') {
return null;
}
const result = this.synchronizeRows(undefined, 1);
return result.firstSessionId;
}
private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult {
const dbPath = getOpenCodeDatabasePath();
if (!fsSync.existsSync(dbPath)) {
return { processed: 0, firstSessionId: null };
}
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const sinceMillis = since?.getTime() ?? null;
const limitClause = limit ? 'LIMIT ?' : '';
const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis];
const rows = db.prepare(`
SELECT
s.id AS id,
s.directory AS directory,
s.title AS title,
s.time_created AS time_created,
s.time_updated AS time_updated,
p.worktree AS worktree
FROM session s
LEFT JOIN project p ON p.id = s.project_id
WHERE s.time_archived IS NULL
AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?)
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC
${limitClause}
`).all(...params) as OpenCodeSessionRow[];
let processed = 0;
let firstSessionId: string | null = null;
for (const row of rows) {
const indexedSessionId = this.upsertSession(db, row);
if (!indexedSessionId) {
continue;
}
if (!firstSessionId) {
firstSessionId = indexedSessionId;
}
processed += 1;
}
return { processed, firstSessionId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message);
return { processed: 0, firstSessionId: null };
} finally {
db.close();
}
}
private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null {
const sessionId = readOptionalString(row.id);
const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree);
if (!sessionId || !projectPath) {
return null;
}
const fallbackTitle = 'Untitled OpenCode Session';
const existingSession = sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
// OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed.
sessionsDb.createSession(
sessionId,
this.provider,
projectPath,
normalizeSessionName(nextName, fallbackTitle),
normalizeProviderTimestamp(row.time_created),
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
null,
);
return sessionId;
}
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
try {
const row = db.prepare(`
SELECT p.data AS data
FROM message m
INNER JOIN part p
ON p.session_id = m.session_id
AND p.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') = 'user'
AND json_extract(p.data, '$.type') = 'text'
ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0)
LIMIT 1
`).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data);
return readOptionalString(data?.text);
} catch {
return undefined;
}
}
}

View File

@@ -0,0 +1,426 @@
import fsSync from 'node:fs';
import Database from 'better-sqlite3';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
createNormalizedMessage,
generateMessageId,
getOpenCodeDatabasePath,
normalizeProviderTimestamp,
readObjectRecord,
readJsonRecord,
readOptionalString,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
type OpenCodeHistoryRow = {
message_id: string;
message_time_created: number | null;
message_data: string | null;
part_id: string | null;
part_time_created: number | null;
part_data: string | null;
};
type OpenCodeTokenTotals = {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
reasoningTokens: number;
};
const openOpenCodeDatabase = (): Database.Database | null => {
const dbPath = getOpenCodeDatabasePath();
if (!fsSync.existsSync(dbPath)) {
return null;
}
return new Database(dbPath, { readonly: true, fileMustExist: true });
};
const 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);
}
};
const extractText = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
const record = readObjectRecord(value);
return readOptionalString(record?.text)
?? readOptionalString(record?.content)
?? '';
};
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
if (!totals) {
return undefined;
}
const inputTokens = totals.inputTokens;
const outputTokens = totals.outputTokens;
const cacheReadTokens = totals.cacheReadTokens;
const cacheCreationTokens = totals.cacheCreationTokens;
const reasoningTokens = totals.reasoningTokens;
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
if (used <= 0) {
return undefined;
}
return {
used,
total: used,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
};
};
/**
* OpenCode stores per-message token counts on assistant `message.data` objects
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
* matches current `opencode.db` layouts that only persist message JSON.
*/
const aggregateOpenCodeSessionTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let reasoningTokens = 0;
for (const row of rows) {
const info = readJsonRecord(row.data);
if (readOptionalString(info?.role) !== 'assistant') {
continue;
}
const tokens = readObjectRecord(info?.tokens);
if (!tokens) {
continue;
}
inputTokens += Number(tokens.input ?? 0);
outputTokens += Number(tokens.output ?? 0);
reasoningTokens += Number(tokens.reasoning ?? 0);
const cache = readObjectRecord(tokens.cache);
cacheReadTokens += Number(cache?.read ?? 0);
cacheCreationTokens += Number(cache?.write ?? 0);
}
return buildTokenUsage({
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
});
};
export class OpenCodeSessionsProvider implements IProviderSessions {
/**
* Normalizes live `opencode run --format json` events into frontend messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
const baseId = readOptionalString(raw.id)
?? readOptionalString(raw.messageID)
?? generateMessageId('opencode');
if (type === 'text') {
const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_delta',
content,
})];
}
if (type === 'reasoning') {
const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
})];
}
if (type === 'tool_use') {
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
const toolMessage = createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: raw.input ?? raw.arguments ?? {},
toolId,
});
if (raw.output !== undefined || raw.error !== undefined) {
toolMessage.toolResult = {
content: formatToolContent(raw.output ?? raw.error),
isError: raw.error !== undefined,
};
}
return [toolMessage];
}
if (type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
})];
}
if (type === 'step_finish') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_end',
})];
}
return [];
}
/**
* Loads OpenCode history from the shared SQLite session database.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
const db = openOpenCodeDatabase();
if (!db) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
try {
const rows = db.prepare(`
SELECT
m.id AS message_id,
m.time_created AS message_time_created,
m.data AS message_data,
p.id AS part_id,
p.time_created AS part_time_created,
p.data AS part_data
FROM message m
LEFT JOIN part p
ON p.session_id = m.session_id
AND p.message_id = m.id
WHERE m.session_id = ?
ORDER BY
COALESCE(m.time_created, 0),
m.id,
COALESCE(p.time_created, 0),
p.id
`).all(sessionId) as OpenCodeHistoryRow[];
const normalized = this.normalizeHistoryRows(rows, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const total = normalized.length;
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, total - normalizedOffset - normalizedLimit),
Math.max(0, total - normalizedOffset),
);
return {
messages,
total,
hasMore: normalizedLimit === null
? false
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
offset: normalizedOffset,
limit: normalizedLimit,
tokenUsage,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
} finally {
db.close();
}
}
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
const normalized: NormalizedMessage[] = [];
const emittedMessageErrors = new Set<string>();
for (const row of rows) {
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
const messageInfo = readJsonRecord(row.message_data);
const messageRole = readOptionalString(messageInfo?.role);
if (
messageInfo
&& messageRole === 'assistant'
&& messageInfo.error != null
&& !emittedMessageErrors.has(row.message_id)
) {
emittedMessageErrors.add(row.message_id);
normalized.push(createNormalizedMessage({
id: `${baseId}_error`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: formatToolContent(messageInfo.error),
}));
}
if (!row.part_id) {
continue;
}
const partData = readJsonRecord(row.part_data) ?? {};
const partType = readOptionalString(partData.type);
if (!partType) {
continue;
}
if (partType === 'text') {
const content = extractText(partData);
if (content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'text',
role: messageRole === 'user' ? 'user' : 'assistant',
content,
}));
}
continue;
}
if (partType === 'reasoning') {
const content = extractText(partData);
if (content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
}));
}
continue;
}
if (partType === 'tool') {
const state = readObjectRecord(partData.state) ?? {};
const status = readOptionalString(state.status);
const toolMessage = createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(partData.tool) ?? 'Tool',
toolInput: state.input ?? partData.input ?? {},
toolId: readOptionalString(partData.callID) ?? row.part_id,
});
if (status === 'completed' || status === 'error') {
toolMessage.toolResult = {
content: formatToolContent(state.output ?? state.error),
isError: status === 'error',
};
}
normalized.push(toolMessage);
continue;
}
if (partType === 'step-finish') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_end',
}));
continue;
}
if (partType === 'patch' || partType === 'agent') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: partType === 'patch' ? 'Patch' : 'Agent',
toolInput: partData,
toolId: row.part_id,
}));
}
}
return normalized;
}
}

View File

@@ -0,0 +1,78 @@
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 OPENCODE_PROJECT_SKILL_DIRS = [
['.opencode', 'skills'],
['.claude', 'skills'],
['.agents', 'skills'],
];
const OPENCODE_USER_SKILL_DIRS = [
['.config', 'opencode', 'skills'],
['.claude', 'skills'],
['.agents', 'skills'],
];
export class OpenCodeSkillsProvider extends SkillsProvider {
constructor() {
super('opencode');
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
const sources: ProviderSkillSource[] = [];
const seenRootDirs = new Set<string>();
const repoRoot = await findTopmostGitRoot(workspacePath);
for (const projectRoot of this.getProjectSearchRoots(workspacePath, repoRoot)) {
for (const skillDir of OPENCODE_PROJECT_SKILL_DIRS) {
// OpenCode 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 OPENCODE_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;
}
}

View File

@@ -0,0 +1,24 @@
import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js';
import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/opencode-skills.provider.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import type {
IProviderAuth,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';
export class OpenCodeProvider extends AbstractProvider {
readonly mcp = new OpenCodeMcpProvider();
readonly auth: IProviderAuth = new OpenCodeProviderAuth();
readonly skills: IProviderSkills = new OpenCodeSkillsProvider();
readonly sessions: IProviderSessions = new OpenCodeSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new OpenCodeSessionSynchronizer();
constructor() {
super('opencode');
}
}

View File

@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js'; import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js'; import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js'; import type { LLMProvider } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js'; import { AppError } from '@/shared/utils.js';
@@ -11,6 +12,7 @@ const providers: Record<LLMProvider, IProvider> = {
codex: new CodexProvider(), codex: new CodexProvider(),
cursor: new CursorProvider(), cursor: new CursorProvider(),
gemini: new GeminiProvider(), gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(),
}; };
/** /**

View File

@@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js'; import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
import { sessionsService } from '@/modules/providers/services/sessions.service.js'; import { sessionsService } from '@/modules/providers/services/sessions.service.js';
@@ -173,7 +174,13 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
const parseProvider = (value: unknown): LLMProvider => { const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value); const normalized = normalizeProviderParam(value);
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') { if (
normalized === 'claude'
|| normalized === 'codex'
|| normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode'
) {
return normalized; return normalized;
} }
@@ -248,6 +255,17 @@ router.get(
}), }),
); );
router.get(
'/:provider/models',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const cwd = workspacePath;
const models = await providerModelsService.getProviderModels(provider, { cwd });
res.json(createApiSuccessResponse({ provider, models }));
}),
);
// ----------------- Skills routes ----------------- // ----------------- Skills routes -----------------
router.get( router.get(
'/:provider/skills', '/:provider/skills',

View File

@@ -0,0 +1,228 @@
import { spawn } from 'node:child_process';
import fsSync from 'node:fs';
import crossSpawn from 'cross-spawn';
import type { LLMProvider, ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
/**
* Claude (Anthropic) — SDK-style ids used by the UI and claude-sdk.js.
*/
export const CLAUDE_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'opus', label: 'Opus' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'haiku', label: 'Haiku' },
{ value: 'claude-opus-4-6', label: 'Opus 4.6' },
{ value: 'opusplan', label: 'Opus Plan' },
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' },
{ value: 'opus[1m]', label: 'Opus [1M]' },
],
DEFAULT: 'opus',
};
export const CURSOR_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'opus-4.6-thinking', label: 'Claude 4.6 Opus (Thinking)' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3' },
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
{ value: 'composer-1', label: 'Composer 1' },
{ value: 'auto', label: 'Auto' },
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
{ value: 'grok', label: 'Grok' },
],
DEFAULT: 'gpt-5.3-codex',
};
export const CODEX_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'o3', label: 'O3' },
{ value: 'o4-mini', label: 'O4-mini' },
],
DEFAULT: 'gpt-5.4',
};
export const GEMINI_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
],
DEFAULT: 'gemini-3.1-pro-preview',
};
/** Static OpenCode defaults when `opencode models` is unavailable or returns nothing. */
export const OPENCODE_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
{ value: 'anthropic/claude-opus-4-1', label: 'Claude Opus 4.1' },
{ value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5' },
{ value: 'openai/gpt-5.1', label: 'GPT-5.1' },
{ value: 'openai/gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'openai/gpt-5.4-mini', label: 'GPT-5.4 Mini' },
{ value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
],
DEFAULT: 'anthropic/claude-sonnet-4-5',
};
const BUILTIN_BY_PROVIDER: Record<Exclude<LLMProvider, 'opencode'>, ProviderModelsDefinition> = {
claude: CLAUDE_MODELS,
cursor: CURSOR_MODELS,
codex: CODEX_MODELS,
gemini: GEMINI_MODELS,
};
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
const parseOpenCodeModelsStdout = (stdout: string): string[] => {
const ids: string[] = [];
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('{') || line.startsWith('[')) {
continue;
}
if (MODEL_ID_LINE.test(line)) {
ids.push(line);
}
}
return [...new Set(ids)];
};
const labelForOpenCodeModelId = (id: string): string => {
const fromStatic = OPENCODE_MODELS.OPTIONS.find((o) => o.value === id)?.label;
if (fromStatic) {
return fromStatic;
}
const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
return tail.replace(/-/g, ' ');
};
const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
}));
const defaultValue = options.some((o) => o.value === OPENCODE_MODELS.DEFAULT)
? OPENCODE_MODELS.DEFAULT
: (options[0]?.value ?? OPENCODE_MODELS.DEFAULT);
return { OPTIONS: options, DEFAULT: defaultValue };
};
const resolveOpenCodeCwd = (cwd?: string): string => {
if (cwd && fsSync.existsSync(cwd)) {
return cwd;
}
return process.cwd();
};
const runOpenCodeModelsCommand = (cwd?: string): Promise<string> =>
new Promise((resolve, reject) => {
const spawnFn = process.platform === 'win32' ? crossSpawn : spawn;
const child = spawnFn('opencode', ['models'], {
cwd: resolveOpenCodeCwd(cwd),
env: { ...process.env },
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
child.kill('SIGTERM');
if (!settled) {
settled = true;
reject(new Error('opencode models timed out'));
}
}, OPEN_CODE_MODELS_TIMEOUT_MS);
const finish = (err: Error | null, out: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (err) {
reject(err);
} else {
resolve(out);
}
};
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
finish(error instanceof Error ? error : new Error(String(error)), '');
});
child.on('close', (code) => {
if (code !== 0) {
finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), '');
return;
}
finish(null, stdout);
});
});
const getBuiltinProviderDefinition = (provider: LLMProvider): ProviderModelsDefinition => {
if (provider === 'opencode') {
return OPENCODE_MODELS;
}
return BUILTIN_BY_PROVIDER[provider];
};
async function getProviderModelsInternal(
provider: LLMProvider,
options?: { cwd?: string },
): Promise<ProviderModelsDefinition> {
if (provider !== 'opencode') {
return getBuiltinProviderDefinition(provider);
}
try {
const stdout = await runOpenCodeModelsCommand(options?.cwd);
const ids = parseOpenCodeModelsStdout(stdout);
if (ids.length === 0) {
return OPENCODE_MODELS;
}
return buildOpenCodeDefinitionFromIds(ids);
} catch {
return OPENCODE_MODELS;
}
}
export const providerModelsService = {
getProviderModels: (provider: LLMProvider, options?: { cwd?: string }): Promise<ProviderModelsDefinition> =>
getProviderModelsInternal(provider, options),
};

View File

@@ -22,6 +22,7 @@ export const sessionSynchronizerService = {
codex: 0, codex: 0,
cursor: 0, cursor: 0,
gemini: 0, gemini: 0,
opencode: 0,
}; };
const failures: string[] = []; const failures: string[] = [];

View File

@@ -34,6 +34,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'gemini', provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'tmp'), rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
}, },
{
provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
},
]; ];
const WATCHER_IGNORED_PATTERNS = [ const WATCHER_IGNORED_PATTERNS = [
@@ -67,6 +71,10 @@ let watcherRescheduleAfterRefresh = false;
* Filters watcher events to provider-specific session artifact file types. * Filters watcher events to provider-specific session artifact file types.
*/ */
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean { function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
if (provider === 'opencode') {
return path.basename(filePath) === 'opencode.db';
}
if (provider === 'gemini') { if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl'); return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
} }

View File

@@ -169,6 +169,93 @@ test('providerMcpService handles codex MCP TOML config and capability validation
} }
}); });
/**
* This test covers OpenCode MCP support for user/project config files, JSONC-compatible
* reads, and validation for unsupported scope/transport combinations.
*/
test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
await fs.writeFile(
path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
`{
// Existing comments should not block OpenCode MCP reads.
"mcp": {}
}\n`,
'utf8',
);
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: { API_KEY: 'x' },
});
await providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-project-http',
scope: 'project',
transport: 'http',
url: 'https://opencode.example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
const userServers = userConfig.mcp as Record<string, unknown>;
const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
assert.equal(userStdio.type, 'local');
assert.deepEqual(userStdio.command, ['node', 'server.js']);
assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
const projectServers = projectConfig.mcp as Record<string, unknown>;
const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
assert.equal(projectHttp.type, 'remote');
assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
await assert.rejects(
providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-local',
scope: 'local',
transport: 'stdio',
command: 'node',
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
error.statusCode === 400,
);
await assert.rejects(
providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/** /**
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence. * This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
*/ */
@@ -255,7 +342,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
}); });
const expectCursorGlobal = process.platform !== 'win32'; const expectCursorGlobal = process.platform !== 'win32';
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3); assert.equal(globalResult.length, expectCursorGlobal ? 5 : 4);
assert.ok(globalResult.every((entry) => entry.created === true)); assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -267,6 +354,9 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']); assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
if (expectCursorGlobal) { if (expectCursorGlobal) {
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json')); const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']); assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);

View File

@@ -0,0 +1,299 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import Database from 'better-sqlite3';
import { closeConnection } from '@/modules/database/connection.js';
import { initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'opencode-provider-db-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
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 });
}
}
const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): Promise<void> => {
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
await mkdir(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'opencode.db'));
try {
db.exec(`
CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL,
vcs TEXT,
name TEXT,
icon_url TEXT,
icon_color TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_initialized INTEGER,
sandboxes TEXT NOT NULL,
commands TEXT,
icon_url_override TEXT
);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER,
workspace_id TEXT,
path TEXT,
agent TEXT,
model TEXT,
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE
);
CREATE INDEX part_session_idx ON part (session_id);
CREATE INDEX session_project_idx ON session (project_id);
CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
CREATE INDEX part_message_id_id_idx ON part (message_id, id);
`);
db.prepare(
'INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)',
).run(
'project-1',
workspacePath,
1_700_000_000_000,
1_700_000_001_000,
'[]',
);
db.prepare(`
INSERT INTO session (
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'open-session-1',
'project-1',
'open-session-1',
workspacePath,
'OpenCode indexed title',
'0.0.0',
1_700_000_000_000,
1_700_000_004_000,
null,
);
const userMessageData = JSON.stringify({
role: 'user',
time: { created: 1_700_000_001_000 },
agent: 'test',
model: { providerID: 'anthropic', modelID: 'claude' },
});
const assistantMessageData = JSON.stringify({
role: 'assistant',
time: { created: 1_700_000_002_000, completed: 1_700_000_003_000 },
parentID: 'message-user',
modelID: 'anthropic/claude-sonnet-4-5',
providerID: 'anthropic',
mode: 'default',
agent: 'test',
path: { cwd: '.', root: '.' },
cost: 0.01,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 3, write: 2 },
},
});
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
).run('message-user', 'open-session-1', 1_700_000_001_000, 1_700_000_001_500, userMessageData);
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
).run('message-assistant', 'open-session-1', 1_700_000_002_000, 1_700_000_003_000, assistantMessageData);
const insertPart = db.prepare(`
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertPart.run(
'part-user-text',
'message-user',
'open-session-1',
1_700_000_001_000,
1_700_000_001_000,
JSON.stringify({
type: 'text',
text: 'Build the OpenCode integration.',
}),
);
insertPart.run(
'part-reasoning',
'message-assistant',
'open-session-1',
1_700_000_002_000,
1_700_000_002_000,
JSON.stringify({
type: 'reasoning',
text: 'I will inspect the provider shape first.',
time: { start: 0, end: 1 },
}),
);
insertPart.run(
'part-assistant-text',
'message-assistant',
'open-session-1',
1_700_000_002_500,
1_700_000_002_500,
JSON.stringify({
type: 'text',
text: 'The provider is wired.',
}),
);
insertPart.run(
'part-tool',
'message-assistant',
'open-session-1',
1_700_000_003_000,
1_700_000_003_000,
JSON.stringify({
type: 'tool',
tool: 'bash',
callID: 'tool-call-1',
state: {
status: 'completed',
input: { command: 'npm test' },
output: 'ok',
title: 'bash',
metadata: {},
time: { start: 0, end: 1 },
},
}),
);
} finally {
db.close();
}
};
test('OpenCode session synchronizer indexes sqlite sessions without deletable transcript paths', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
const synchronizer = new OpenCodeSessionSynchronizer();
const processed = synchronizer.synchronize();
return Promise.resolve(processed).then((count) => {
assert.equal(count, 1);
const indexed = sessionsDb.getSessionById('open-session-1');
assert.equal(indexed?.provider, 'opencode');
assert.equal(indexed?.project_path, workspacePath);
assert.equal(indexed?.custom_name, 'OpenCode indexed title');
assert.equal(indexed?.jsonl_path, null);
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
const provider = new OpenCodeSessionsProvider();
const history = await provider.fetchHistory('open-session-1');
assert.equal(history.total, 4);
assert.equal(history.messages[0]?.kind, 'text');
assert.equal(history.messages[0]?.role, 'user');
assert.equal(history.messages[1]?.kind, 'thinking');
assert.equal(history.messages[2]?.content, 'The provider is wired.');
assert.equal(history.messages[3]?.kind, 'tool_use');
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
assert.deepEqual(history.tokenUsage, {
used: 35,
total: 35,
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 3,
cacheCreationTokens: 2,
});
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
assert.equal(paged.messages.length, 2);
assert.equal(paged.hasMore, true);
assert.equal(paged.messages[0]?.content, 'The provider is wired.');
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -377,6 +377,72 @@ test('providerSkillsService lists codex repository, user, and system skills', {
} }
}); });
/**
* This test covers OpenCode skill lookup across cwd-to-git-root project folders
* plus the global OpenCode/Claude/Agents compatibility locations.
*/
test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
const repoRoot = path.join(tempRoot, 'repo');
const workspacePath = path.join(repoRoot, 'packages', 'app');
await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await writeSkill(
path.join(workspacePath, '.opencode', 'skills'),
'opencode-cwd-dir',
'opencode-cwd',
'OpenCode cwd skill',
);
await writeSkill(
path.join(repoRoot, 'packages', '.claude', 'skills'),
'opencode-claude-parent-dir',
'opencode-claude-parent',
'OpenCode Claude parent skill',
);
await writeSkill(
path.join(repoRoot, '.agents', 'skills'),
'opencode-agents-root-dir',
'opencode-agents-root',
'OpenCode Agents root skill',
);
await writeSkill(
path.join(tempRoot, '.config', 'opencode', 'skills'),
'opencode-user-dir',
'opencode-user',
'OpenCode user skill',
);
await writeSkill(
path.join(tempRoot, '.claude', 'skills'),
'opencode-claude-user-dir',
'opencode-claude-user',
'OpenCode Claude user skill',
);
await writeSkill(
path.join(tempRoot, '.agents', 'skills'),
'opencode-agents-user-dir',
'opencode-agents-user',
'OpenCode Agents user skill',
);
const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
const byName = new Map(skills.map((skill) => [skill.name, skill]));
assert.equal(byName.get('opencode-cwd')?.scope, 'project');
assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
assert.equal(byName.get('opencode-user')?.scope, 'user');
assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/** /**
* This test covers Gemini and Cursor skill directory rules, including shared * This test covers Gemini and Cursor skill directory rules, including shared
* `.agents/skills` project support. * `.agents/skills` project support.

View File

@@ -29,10 +29,12 @@ type ChatWebSocketDependencies = {
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>; spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>; queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>; spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>; abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
abortCursorSession: (sessionId: string) => boolean; abortCursorSession: (sessionId: string) => boolean;
abortCodexSession: (sessionId: string) => boolean; abortCodexSession: (sessionId: string) => boolean;
abortGeminiSession: (sessionId: string) => boolean; abortGeminiSession: (sessionId: string) => boolean;
abortOpenCodeSession: (sessionId: string) => boolean;
resolveToolApproval: ( resolveToolApproval: (
requestId: string, requestId: string,
payload: { payload: {
@@ -46,19 +48,21 @@ type ChatWebSocketDependencies = {
isCursorSessionActive: (sessionId: string) => boolean; isCursorSessionActive: (sessionId: string) => boolean;
isCodexSessionActive: (sessionId: string) => boolean; isCodexSessionActive: (sessionId: string) => boolean;
isGeminiSessionActive: (sessionId: string) => boolean; isGeminiSessionActive: (sessionId: string) => boolean;
isOpenCodeSessionActive: (sessionId: string) => boolean;
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean; reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
getPendingApprovalsForSession: (sessionId: string) => unknown[]; getPendingApprovalsForSession: (sessionId: string) => unknown[];
getActiveClaudeSDKSessions: () => unknown; getActiveClaudeSDKSessions: () => unknown;
getActiveCursorSessions: () => unknown; getActiveCursorSessions: () => unknown;
getActiveCodexSessions: () => unknown; getActiveCodexSessions: () => unknown;
getActiveGeminiSessions: () => unknown; getActiveGeminiSessions: () => unknown;
getActiveOpenCodeSessions: () => unknown;
}; };
/** /**
* Normalizes potentially invalid provider names coming from websocket payloads. * Normalizes potentially invalid provider names coming from websocket payloads.
*/ */
function readProvider(value: unknown): LLMProvider { function readProvider(value: unknown): LLMProvider {
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') { if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
return value; return value;
} }
@@ -134,6 +138,11 @@ export function handleChatConnection(
return; return;
} }
if (messageType === 'opencode-command') {
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'cursor-resume') { if (messageType === 'cursor-resume') {
await dependencies.spawnCursor( await dependencies.spawnCursor(
'', '',
@@ -158,6 +167,8 @@ export function handleChatConnection(
success = dependencies.abortCodexSession(sessionId); success = dependencies.abortCodexSession(sessionId);
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
success = dependencies.abortGeminiSession(sessionId); success = dependencies.abortGeminiSession(sessionId);
} else if (provider === 'opencode') {
success = dependencies.abortOpenCodeSession(sessionId);
} else { } else {
success = await dependencies.abortClaudeSDKSession(sessionId); success = await dependencies.abortClaudeSDKSession(sessionId);
} }
@@ -214,6 +225,8 @@ export function handleChatConnection(
isActive = dependencies.isCodexSessionActive(sessionId); isActive = dependencies.isCodexSessionActive(sessionId);
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
isActive = dependencies.isGeminiSessionActive(sessionId); isActive = dependencies.isGeminiSessionActive(sessionId);
} else if (provider === 'opencode') {
isActive = dependencies.isOpenCodeSessionActive(sessionId);
} else { } else {
isActive = dependencies.isClaudeSDKSessionActive(sessionId); isActive = dependencies.isClaudeSDKSessionActive(sessionId);
if (isActive) { if (isActive) {
@@ -251,6 +264,7 @@ export function handleChatConnection(
cursor: dependencies.getActiveCursorSessions(), cursor: dependencies.getActiveCursorSessions(),
codex: dependencies.getActiveCodexSessions(), codex: dependencies.getActiveCodexSessions(),
gemini: dependencies.getActiveGeminiSessions(), gemini: dependencies.getActiveGeminiSessions(),
opencode: dependencies.getActiveOpenCodeSessions(),
}, },
}); });
} }

View File

@@ -136,6 +136,13 @@ function buildShellCommand(
return command; return command;
} }
if (provider === 'opencode') {
if (hasSession && sessionId) {
return `opencode --session "${sessionId}"`;
}
return initialCommand || 'opencode';
}
const command = initialCommand || 'claude'; const command = initialCommand || 'claude';
if (hasSession && sessionId) { if (hasSession && sessionId) {
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
@@ -389,6 +396,8 @@ export function handleShellConnection(
? 'Codex' ? 'Codex'
: provider === 'gemini' : provider === 'gemini'
? 'Gemini' ? 'Gemini'
: provider === 'opencode'
? 'OpenCode'
: 'Claude'; : 'Claude';
welcomeMsg = hasSession welcomeMsg = hasSession
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`

243
server/opencode-cli.js Normal file
View File

@@ -0,0 +1,243 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const activeOpenCodeProcesses = new Map();
function readOpenCodeSessionId(event) {
if (!event || typeof event !== 'object') {
return null;
}
return event.sessionID || event.sessionId || null;
}
async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null;
let sessionCreatedSent = false;
let stdoutLineBuffer = '';
let terminalNotificationSent = false;
const args = ['run', '--format', 'json'];
if (sessionId) {
args.push('--session', sessionId);
}
if (model) {
args.push('--model', model);
}
if (command && command.trim()) {
args.push(command.trim());
}
const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) {
return;
}
terminalNotificationSent = true;
const finalSessionId = capturedSessionId || sessionId || processKey;
if (code === 0 && !error) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'opencode',
sessionId: finalSessionId,
sessionName: sessionSummary,
stopReason: 'completed',
});
return;
}
notifyRunFailed({
userId: ws?.userId || null,
provider: 'opencode',
sessionId: finalSessionId,
sessionName: sessionSummary,
error: error || `OpenCode CLI exited with code ${code}`,
});
};
const opencodeProcess = spawnFunction('opencode', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
activeOpenCodeProcesses.set(processKey, opencodeProcess);
opencodeProcess.sessionId = processKey;
opencodeProcess.stdin.end();
const registerSession = (nextSessionId) => {
if (!nextSessionId || capturedSessionId === nextSessionId) {
return;
}
capturedSessionId = nextSessionId;
if (processKey !== capturedSessionId) {
activeOpenCodeProcesses.delete(processKey);
activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess);
}
opencodeProcess.sessionId = capturedSessionId;
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: 'opencode',
}));
}
};
const processOpenCodeOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
try {
const response = JSON.parse(line);
registerSession(readOpenCodeSessionId(response));
const normalized = sessionsService.normalizeMessage(
'opencode',
response,
capturedSessionId || sessionId || null,
);
for (const msg of normalized) {
ws.send(msg);
}
} catch {
ws.send(createNormalizedMessage({
kind: 'stream_delta',
content: line,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
}
};
opencodeProcess.stdout.on('data', (data) => {
stdoutLineBuffer += data.toString();
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
completeLines.forEach((line) => {
processOpenCodeOutputLine(line.trim());
});
});
opencodeProcess.stderr.on('data', (data) => {
const stderrText = data.toString();
if (!stderrText.trim()) {
return;
}
ws.send(createNormalizedMessage({
kind: 'error',
content: stderrText,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
});
opencodeProcess.on('close', async (code) => {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeOpenCodeProcesses.delete(finalSessionId);
activeOpenCodeProcesses.delete(processKey);
if (stdoutLineBuffer.trim()) {
processOpenCodeOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: code,
isNewSession: !sessionId && !!command,
sessionId: finalSessionId,
provider: 'opencode',
}));
if (code === 0) {
notifyTerminalState({ code });
resolve();
return;
}
if (code === 127 || code === null) {
const installed = await providerAuthService.isProviderInstalled('opencode');
if (!installed) {
ws.send(createNormalizedMessage({
kind: 'error',
content: 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/',
sessionId: finalSessionId,
provider: 'opencode',
}));
}
}
notifyTerminalState({ code });
reject(new Error(code === null ? 'OpenCode CLI process was terminated' : `OpenCode CLI exited with code ${code}`));
});
opencodeProcess.on('error', async (error) => {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeOpenCodeProcesses.delete(finalSessionId);
activeOpenCodeProcesses.delete(processKey);
const installed = await providerAuthService.isProviderInstalled('opencode');
const errorContent = !installed
? 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/'
: error.message;
ws.send(createNormalizedMessage({
kind: 'error',
content: errorContent,
sessionId: finalSessionId,
provider: 'opencode',
}));
notifyTerminalState({ error });
reject(error);
});
});
}
function abortOpenCodeSession(sessionId) {
const process = activeOpenCodeProcesses.get(sessionId);
if (!process) {
return false;
}
process.kill('SIGTERM');
activeOpenCodeProcesses.delete(sessionId);
return true;
}
function isOpenCodeSessionActive(sessionId) {
return activeOpenCodeProcesses.has(sessionId);
}
function getActiveOpenCodeSessions() {
return Array.from(activeOpenCodeProcesses.keys());
}
export {
spawnOpenCode,
abortOpenCodeSession,
isOpenCodeSessionActive,
getActiveOpenCodeSessions,
};

View File

@@ -9,8 +9,9 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js'; import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js'; import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
import { IS_PLATFORM } from '../constants/config.js'; import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js'; import { normalizeProjectPath } from '../shared/utils.js';
@@ -608,7 +609,7 @@ class ResponseCollector {
/** /**
* POST /api/agent * POST /api/agent
* *
* Trigger an AI agent (Claude or Cursor) to work on a project. * Trigger an AI agent to work on a project.
* Supports automatic GitHub branch and pull request creation after successful completion. * Supports automatic GitHub branch and pull request creation after successful completion.
* *
* ================================================================================================ * ================================================================================================
@@ -633,7 +634,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName) * - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made * - Fallback for PR title if no commits are made
* *
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
* Default: 'claude' * Default: 'claude'
* *
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -751,7 +752,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request): * Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither) * - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string * - message must be non-empty string
* - provider must be 'claude', 'cursor', 'codex', or 'gemini' * - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
* - createBranch/createPR requires githubUrl OR projectPath (not neither) * - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided) * - branchName must pass Git naming rules (if provided)
* *
@@ -859,8 +860,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' }); return res.status(400).json({ error: 'message is required' });
} }
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) { if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' }); return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
} }
// Validate GitHub branch/PR creation requirements // Validate GitHub branch/PR creation requirements
@@ -938,6 +939,10 @@ router.post('/', validateExternalApiKey, async (req, res) => {
}); });
} }
const codexModels = await providerModelsService.getProviderModels('codex');
const geminiModels = await providerModelsService.getProviderModels('gemini');
const opencodeModels = await providerModelsService.getProviderModels('opencode', { cwd: finalProjectPath });
// Start the appropriate session // Start the appropriate session
if (provider === 'claude') { if (provider === 'claude') {
console.log('🤖 Starting Claude SDK session'); console.log('🤖 Starting Claude SDK session');
@@ -967,7 +972,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model || CODEX_MODELS.DEFAULT, model: model || codexModels.DEFAULT,
permissionMode: 'bypassPermissions' permissionMode: 'bypassPermissions'
}, writer); }, writer);
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
@@ -977,9 +982,18 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model, model: model || geminiModels.DEFAULT,
skipPermissions: true // CLI mode bypasses permissions skipPermissions: true // CLI mode bypasses permissions
}, writer); }, writer);
} else if (provider === 'opencode') {
console.log('Starting OpenCode CLI session');
await spawnOpenCode(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
}, writer);
} }
// Handle GitHub branch and PR creation after successful agent completion // Handle GitHub branch and PR creation after successful agent completion

View File

@@ -4,7 +4,7 @@ import path from 'path';
import express from 'express'; import express from 'express';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
import { parseFrontMatter } from '../shared/frontmatter.js'; import { parseFrontMatter } from '../shared/frontmatter.js';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js'; import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
@@ -187,15 +187,31 @@ Custom commands can be created in:
}, },
'/model': async (args, context) => { '/model': async (args, context) => {
// Read available models from centralized constants const [claude, cursor, codex, gemini, opencode] = await Promise.all([
providerModelsService.getProviderModels('claude'),
providerModelsService.getProviderModels('cursor'),
providerModelsService.getProviderModels('codex'),
providerModelsService.getProviderModels('gemini'),
providerModelsService.getProviderModels('opencode'),
]);
const availableModels = { const availableModels = {
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value), claude: claude.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value), cursor: cursor.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value) codex: codex.OPTIONS.map(o => o.value),
gemini: gemini.OPTIONS.map(o => o.value),
opencode: opencode.OPTIONS.map(o => o.value),
}; };
const currentProvider = context?.provider || 'claude'; const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT; const defaults = {
claude: claude.DEFAULT,
cursor: cursor.DEFAULT,
codex: codex.DEFAULT,
gemini: gemini.DEFAULT,
opencode: opencode.DEFAULT,
};
const currentModel = context?.model || defaults[currentProvider] || claude.DEFAULT;
return { return {
type: 'builtin', type: 'builtin',
@@ -216,13 +232,10 @@ Custom commands can be created in:
'/cost': async (args, context) => { '/cost': async (args, context) => {
const tokenUsage = context?.tokenUsage || {}; const tokenUsage = context?.tokenUsage || {};
const provider = context?.provider || 'claude'; const provider = context?.provider || 'claude';
const catalog = await providerModelsService.getProviderModels(provider);
const model = const model =
context?.model || context?.model ||
(provider === 'cursor' catalog.DEFAULT;
? CURSOR_MODELS.DEFAULT
: provider === 'codex'
? CODEX_MODELS.DEFAULT
: CLAUDE_MODELS.DEFAULT);
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0; const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
const total = const total =
@@ -314,6 +327,9 @@ Custom commands can be created in:
? `${uptimeHours}h ${uptimeMinutes % 60}m` ? `${uptimeHours}h ${uptimeMinutes % 60}m`
: `${uptimeMinutes}m`; : `${uptimeMinutes}m`;
const statusProvider = context?.provider || 'claude';
const statusCatalog = await providerModelsService.getProviderModels(statusProvider);
return { return {
type: 'builtin', type: 'builtin',
action: 'status', action: 'status',
@@ -322,8 +338,8 @@ Custom commands can be created in:
packageName, packageName,
uptime: uptimeFormatted, uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime), uptimeSeconds: Math.floor(uptime),
model: context?.model || CLAUDE_MODELS.DEFAULT, model: context?.model || statusCatalog.DEFAULT,
provider: context?.provider || 'claude', provider: statusProvider,
nodeVersion: process.version, nodeVersion: process.version,
platform: process.platform platform: process.platform
} }

View File

@@ -2,7 +2,7 @@ import express from 'express';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CURSOR_MODELS } from '../../shared/modelConstants.js'; import { CURSOR_MODELS } from '../modules/providers/services/provider-models.service.js';
const router = express.Router(); const router = express.Router();

View File

@@ -65,7 +65,23 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
* Use this as the source of truth whenever a function or payload needs to identify * Use this as the source of truth whenever a function or payload needs to identify
* a specific LLM integration. * a specific LLM integration.
*/ */
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor'; export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
/**
* One selectable model row (matches legacy `shared/modelConstants.js` option shape).
*/
export type ProviderModelOption = {
value: string;
label: string;
};
/**
* Provider model catalog returned by `GET /api/providers/:provider/models`.
*/
export type ProviderModelsDefinition = {
OPTIONS: ProviderModelOption[];
DEFAULT: string;
};
/** /**
* Message/event variants emitted by provider adapters and normalized transports. * Message/event variants emitted by provider adapters and normalized transports.

View File

@@ -23,6 +23,7 @@ import type {
ApiSuccessShape, ApiSuccessShape,
AppErrorOptions, AppErrorOptions,
NormalizedMessage, NormalizedMessage,
ProviderSkillSource,
WorkspacePathValidationResult, WorkspacePathValidationResult,
} from '@/shared/types.js'; } from '@/shared/types.js';
@@ -506,6 +507,67 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
// --------------------------- // ---------------------------
//----------------- PROVIDER SKILL FILE UTILITIES ------------ //----------------- PROVIDER SKILL FILE UTILITIES ------------
async function hasGitMarker(dirPath: string): Promise<boolean> {
try {
const gitMarkerStats = await stat(path.join(dirPath, '.git'));
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
} catch {
return false;
}
}
/**
* Finds the highest git worktree root visible from a starting directory.
*
* Provider skill systems such as Codex and OpenCode walk upward through parent
* folders when resolving repository/project skills. Use this helper when a
* provider needs the topmost `.git` marker instead of only the nearest one, so
* monorepos and nested package folders discover shared root-level skills once.
*/
export async function findTopmostGitRoot(startPath: string): Promise<string | null> {
let currentPath = path.resolve(startPath);
let topmostGitRoot: string | null = null;
while (true) {
if (await hasGitMarker(currentPath)) {
topmostGitRoot = currentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
return topmostGitRoot;
}
/**
* Adds one provider skill source after normalizing and de-duplicating its root.
*
* Provider skill lookup rules often point at overlapping folders (for example a
* workspace folder can also be the git root). Use this helper while building a
* provider's `ProviderSkillSource[]` so the shared skills scanner reads each
* physical root once and still preserves provider-specific scope/command data.
*/
export function addUniqueProviderSkillSource(
sources: ProviderSkillSource[],
seenRootDirs: Set<string>,
source: ProviderSkillSource,
): void {
const normalizedRootDir = path.resolve(source.rootDir);
if (seenRootDirs.has(normalizedRootDir)) {
return;
}
seenRootDirs.add(normalizedRootDir);
sources.push({ ...source, rootDir: normalizedRootDir });
}
// ---------------------------
//----------------- PROVIDER SKILL MARKDOWN UTILITIES ------------
/** /**
* Finds direct child skill markdown files under a provider skill root. * Finds direct child skill markdown files under a provider skill root.
* *
@@ -616,6 +678,70 @@ export function normalizeSessionName(rawValue: string | undefined, fallback: str
return normalized.slice(0, 120); return normalized.slice(0, 120);
} }
// ---------------------------
//----------------- PROVIDER SESSION VALUE NORMALIZATION UTILITIES ------------
/**
* Converts provider-native timestamps into ISO strings.
*
* Provider CLIs commonly persist epoch timestamps as milliseconds, seconds, or
* already-formatted date strings. Use this helper when normalizing session
* metadata or transcript events so every provider writes the same ISO timestamp
* shape to API responses and database rows.
*/
export function normalizeProviderTimestamp(value: unknown): string {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
const millis = value < 1_000_000_000_000 ? value * 1000 : value;
return new Date(millis).toISOString();
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return normalizeProviderTimestamp(parsed);
}
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
return date.toISOString();
}
}
return new Date().toISOString();
}
/**
* Parses a JSON string or narrows an existing object into a plain record.
*
* Use this when provider databases store structured JSON inside text columns.
* Invalid JSON, arrays, and primitive values return `null` so callers can skip
* malformed optional metadata without hiding the rest of a session transcript.
*/
export function readJsonRecord(value: unknown): AnyRecord | null {
if (typeof value !== 'string') {
return readObjectRecord(value);
}
try {
return readObjectRecord(JSON.parse(value));
} catch {
return null;
}
}
// ---------------------------
//----------------- OPENCODE SESSION STORAGE UTILITIES ------------
/**
* Resolves the OpenCode SQLite session database path.
*
* OpenCode stores session, message, part, and project metadata in one shared
* `opencode.db` file under its XDG data directory. Provider readers and
* synchronizers should use this path for read-only access and should never store
* it as a deletable transcript path for an individual app session row.
*/
export function getOpenCodeDatabasePath(): string {
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
}
// --------------------------- // ---------------------------
//----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------ //----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------
/** /**

View File

@@ -96,6 +96,27 @@ export const GEMINI_MODELS = {
DEFAULT: "gemini-3.1-pro-preview", DEFAULT: "gemini-3.1-pro-preview",
}; };
/**
* OpenCode Models
*
* OpenCode model ids include the upstream provider prefix. Users can still type
* any OpenCode-supported model in the selector when their config enables it.
*/
export const OPENCODE_MODELS = {
OPTIONS: [
{ value: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ value: "anthropic/claude-opus-4-1", label: "Claude Opus 4.1" },
{ value: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5"},
{ value: "openai/gpt-5.1", label: "GPT-5.1" },
{ value: "openai/gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ value: "openai/gpt-5.4-mini", label: "GPT-5.4 Mini" },
{ value: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
],
DEFAULT: "anthropic/claude-sonnet-4-5",
};
/** /**
* Ordered provider registry. Display order in selection UIs. * Ordered provider registry. Display order in selection UIs.
*/ */
@@ -104,4 +125,5 @@ export const PROVIDERS = [
{ id: "codex", name: "OpenAI", models: CODEX_MODELS }, { id: "codex", name: "OpenAI", models: CODEX_MODELS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS }, { id: "gemini", name: "Google", models: GEMINI_MODELS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS }, { id: "cursor", name: "Cursor", models: CURSOR_MODELS },
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
]; ];

View File

@@ -42,6 +42,7 @@ interface UseChatComposerStateArgs {
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
geminiModel: string; geminiModel: string;
opencodeModel: string;
isLoading: boolean; isLoading: boolean;
canAbortSession: boolean; canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
@@ -111,6 +112,7 @@ export function useChatComposerState({
claudeModel, claudeModel,
codexModel, codexModel,
geminiModel, geminiModel,
opencodeModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget, tokenBudget,
@@ -285,7 +287,15 @@ export function useChatComposerState({
projectId: selectedProject.projectId, projectId: selectedProject.projectId,
sessionId: currentSessionId, sessionId: currentSessionId,
provider, provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel, model: provider === 'cursor'
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel,
tokenUsage: tokenBudget, tokenUsage: tokenBudget,
}; };
@@ -337,6 +347,7 @@ export function useChatComposerState({
currentSessionId, currentSessionId,
cursorModel, cursorModel,
geminiModel, geminiModel,
opencodeModel,
handleBuiltInCommand, handleBuiltInCommand,
handleCustomCommand, handleCustomCommand,
input, input,
@@ -577,6 +588,8 @@ export function useChatComposerState({
? 'codex-settings' ? 'codex-settings'
: provider === 'gemini' : provider === 'gemini'
? 'gemini-settings' ? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: 'claude-settings'; : 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey); const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) { if (savedSettings) {
@@ -644,6 +657,20 @@ export function useChatComposerState({
toolsSettings, toolsSettings,
}, },
}); });
} else if (provider === 'opencode') {
sendMessage({
type: 'opencode-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: opencodeModel,
sessionSummary,
},
});
} else { } else {
sendMessage({ sendMessage({
type: 'claude-command', type: 'claude-command',
@@ -686,6 +713,7 @@ export function useChatComposerState({
cursorModel, cursorModel,
executeCommand, executeCommand,
geminiModel, geminiModel,
opencodeModel,
isLoading, isLoading,
onSessionActive, onSessionActive,
onSessionProcessing, onSessionProcessing,

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { ProjectSession, LLMProvider, Project, ProviderModelsDefinition } from '../../../types/app';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'opus',
cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
};
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => { const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
if (provider === 'codex') { if (provider === 'codex') {
@@ -11,34 +18,180 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[]
if (provider === 'claude') { if (provider === 'claude') {
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan']; return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
} }
if (provider === 'opencode') {
return ['default'];
}
return ['default', 'acceptEdits', 'bypassPermissions', 'plan']; return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
}; };
interface UseChatProviderStateArgs { interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
selectedProject: Project | null;
} }
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) { export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default'); const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]); const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => { const [provider, setProvider] = useState<LLMProvider>(() => {
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
}); });
const [cursorModel, setCursorModel] = useState<string>(() => { const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
}); });
const [claudeModel, setClaudeModel] = useState<string>(() => { const [claudeModel, setClaudeModel] = useState<string>(() => {
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT; return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
}); });
const [codexModel, setCodexModel] = useState<string>(() => { const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
}); });
const [geminiModel, setGeminiModel] = useState<string>(() => { const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT; return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
}); });
const [providerModelCatalog, setProviderModelCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsDefinition>>
>({});
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const lastProviderRef = useRef(provider); const lastProviderRef = useRef(provider);
const workspacePath = selectedProject?.fullPath || selectedProject?.path || '';
useEffect(() => {
let cancelled = false;
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const load = async () => {
setProviderModelsLoading(true);
try {
const results = await Promise.all(
providers.map(async (p) => {
const qs =
p === 'opencode' && workspacePath
? `?workspacePath=${encodeURIComponent(workspacePath)}`
: '';
const response = await authenticatedFetch(`/api/providers/${p}/models${qs}`);
const body = (await response.json()) as {
success?: boolean;
data?: { models?: ProviderModelsDefinition };
};
if (!body.success || !body.data?.models) {
return null;
}
return body.data.models;
}),
);
if (cancelled) {
return;
}
const next: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
providers.forEach((p, i) => {
const entry = results[i];
if (entry) {
next[p] = entry;
}
});
setProviderModelCatalog(next);
} catch (error) {
console.error('Error loading provider models:', error);
} finally {
if (!cancelled) {
setProviderModelsLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [workspacePath]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
def: ProviderModelsDefinition,
): string => {
const stored = localStorage.getItem(storageKey);
if (stored && def.OPTIONS.some((o) => o.value === stored)) {
return stored;
}
if (current && def.OPTIONS.some((o) => o.value === current)) {
return current;
}
return def.DEFAULT;
};
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
const next = pickStoredOrCurrent('claude-model', claudeModel, claude);
if (next !== claudeModel) {
setClaudeModel(next);
}
if (localStorage.getItem('claude-model') !== next) {
localStorage.setItem('claude-model', next);
}
}
}, [providerModelCatalog.claude, claudeModel]);
useEffect(() => {
const cursor = providerModelCatalog.cursor;
if (cursor) {
const next = pickStoredOrCurrent('cursor-model', cursorModel, cursor);
if (next !== cursorModel) {
setCursorModel(next);
}
if (localStorage.getItem('cursor-model') !== next) {
localStorage.setItem('cursor-model', next);
}
}
}, [providerModelCatalog.cursor, cursorModel]);
useEffect(() => {
const codex = providerModelCatalog.codex;
if (codex) {
const next = pickStoredOrCurrent('codex-model', codexModel, codex);
if (next !== codexModel) {
setCodexModel(next);
}
if (localStorage.getItem('codex-model') !== next) {
localStorage.setItem('codex-model', next);
}
}
}, [providerModelCatalog.codex, codexModel]);
useEffect(() => {
const gemini = providerModelCatalog.gemini;
if (gemini) {
const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini);
if (next !== geminiModel) {
setGeminiModel(next);
}
if (localStorage.getItem('gemini-model') !== next) {
localStorage.setItem('gemini-model', next);
}
}
}, [providerModelCatalog.gemini, geminiModel]);
useEffect(() => {
const opencode = providerModelCatalog.opencode;
if (opencode) {
const next = pickStoredOrCurrent('opencode-model', opencodeModel, opencode);
if (next !== opencodeModel) {
setOpenCodeModel(next);
}
if (localStorage.getItem('opencode-model') !== next) {
localStorage.setItem('opencode-model', next);
}
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => { useEffect(() => {
if (!selectedSession?.id) { if (!selectedSession?.id) {
return; return;
@@ -118,10 +271,14 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
setCodexModel, setCodexModel,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel,
setOpenCodeModel,
permissionMode, permissionMode,
setPermissionMode, setPermissionMode,
pendingPermissionRequests, pendingPermissionRequests,
setPendingPermissionRequests, setPendingPermissionRequests,
cyclePermissionMode, cyclePermissionMode,
providerModelCatalog,
providerModelsLoading,
}; };
} }

View File

@@ -72,12 +72,17 @@ function ChatInterface({
setCodexModel, setCodexModel,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel,
setOpenCodeModel,
permissionMode, permissionMode,
pendingPermissionRequests, pendingPermissionRequests,
setPendingPermissionRequests, setPendingPermissionRequests,
cyclePermissionMode, cyclePermissionMode,
providerModelCatalog,
providerModelsLoading,
} = useChatProviderState({ } = useChatProviderState({
selectedSession, selectedSession,
selectedProject,
}); });
const { const {
@@ -182,6 +187,7 @@ function ChatInterface({
claudeModel, claudeModel,
codexModel, codexModel,
geminiModel, geminiModel,
opencodeModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget, tokenBudget,
@@ -280,6 +286,8 @@ function ChatInterface({
? t('messageTypes.codex') ? t('messageTypes.codex')
: provider === 'gemini' : provider === 'gemini'
? t('messageTypes.gemini') ? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'); : t('messageTypes.claude');
return ( return (
@@ -318,6 +326,10 @@ function ChatInterface({
setCodexModel={setCodexModel} setCodexModel={setCodexModel}
geminiModel={geminiModel} geminiModel={geminiModel}
setGeminiModel={setGeminiModel} setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled} tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled} isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks} onShowAllTasks={onShowAllTasks}
@@ -406,6 +418,8 @@ function ChatInterface({
? t('messageTypes.codex') ? t('messageTypes.codex')
: provider === 'gemini' : provider === 'gemini'
? t('messageTypes.gemini') ? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'), : t('messageTypes.claude'),
})} })}
isTextareaExpanded={isTextareaExpanded} isTextareaExpanded={isTextareaExpanded}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types'; import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app'; import type { Project, ProjectSession, LLMProvider, ProviderModelsDefinition } from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent'; import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
@@ -26,6 +26,10 @@ interface ChatMessagesPaneProps {
setCodexModel: (model: string) => void; setCodexModel: (model: string) => void;
geminiModel: string; geminiModel: string;
setGeminiModel: (model: string) => void; setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean; tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null; isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null; onShowAllTasks?: (() => void) | null;
@@ -71,6 +75,10 @@ export default function ChatMessagesPane({
setCodexModel, setCodexModel,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel,
setOpenCodeModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled, tasksEnabled,
isTaskMasterInstalled, isTaskMasterInstalled,
onShowAllTasks, onShowAllTasks,
@@ -154,6 +162,10 @@ export default function ChatMessagesPane({
setCodexModel={setCodexModel} setCodexModel={setCodexModel}
geminiModel={geminiModel} geminiModel={geminiModel}
setGeminiModel={setGeminiModel} setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled} tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled} isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks} onShowAllTasks={onShowAllTasks}

View File

@@ -29,6 +29,7 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
codex: 'messageTypes.codex', codex: 'messageTypes.codex',
cursor: 'messageTypes.cursor', cursor: 'messageTypes.cursor',
gemini: 'messageTypes.gemini', gemini: 'messageTypes.gemini',
opencode: 'messageTypes.opencode',
}; };
function formatElapsedTime(totalSeconds: number) { function formatElapsedTime(totalSeconds: number) {

View File

@@ -176,7 +176,19 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div> </div>
)} )}
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))} {message.type === 'error'
? t('messageTypes.error')
: message.type === 'tool'
? t('messageTypes.tool')
: (provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'))}
</div> </div>
</div> </div>
)} )}

View File

@@ -3,15 +3,8 @@ import { Check, ChevronDown } from "lucide-react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform"; import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import type { ProjectSession, LLMProvider, ProviderModelsDefinition } from "../../../../types/app";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo"; import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
GEMINI_MODELS,
PROVIDERS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master"; import { NextTaskBanner } from "../../../task-master";
import { import {
Dialog, Dialog,
@@ -27,6 +20,14 @@ import {
Card, Card,
} from "../../../../shared/view/ui"; } from "../../../../shared/view/ui";
const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "claude", name: "Anthropic" },
{ id: "codex", name: "OpenAI" },
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
];
const MOD_KEY = const MOD_KEY =
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl"; typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
@@ -44,6 +45,10 @@ type ProviderSelectionEmptyStateProps = {
setCodexModel: (model: string) => void; setCodexModel: (model: string) => void;
geminiModel: string; geminiModel: string;
setGeminiModel: (model: string) => void; setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean; tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null; isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null; onShowAllTasks?: (() => void) | null;
@@ -56,17 +61,12 @@ type ProviderGroup = {
models: { value: string; label: string }[]; models: { value: string; label: string }[];
}; };
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({ function getModelConfig(
id: p.id as LLMProvider, p: LLMProvider,
name: p.name, catalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>,
models: p.models.OPTIONS, ): ProviderModelsDefinition {
})); const entry = catalog[p];
return entry ?? { OPTIONS: [], DEFAULT: "" };
function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS;
} }
function getCurrentModel( function getCurrentModel(
@@ -75,10 +75,12 @@ function getCurrentModel(
cu: string, cu: string,
co: string, co: string,
g: string, g: string,
o: string,
) { ) {
if (p === "claude") return c; if (p === "claude") return c;
if (p === "codex") return co; if (p === "codex") return co;
if (p === "gemini") return g; if (p === "gemini") return g;
if (p === "opencode") return o;
return cu; return cu;
} }
@@ -86,6 +88,7 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "claude") return "Claude"; if (p === "claude") return "Claude";
if (p === "cursor") return "Cursor"; if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex"; if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
return "Gemini"; return "Gemini";
} }
@@ -103,6 +106,10 @@ export default function ProviderSelectionEmptyState({
setCodexModel, setCodexModel,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel,
setOpenCodeModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled, tasksEnabled,
isTaskMasterInstalled, isTaskMasterInstalled,
onShowAllTasks, onShowAllTasks,
@@ -112,10 +119,14 @@ export default function ProviderSelectionEmptyState({
const { isWindowsServer } = useServerPlatform(); const { isWindowsServer } = useServerPlatform();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const visibleProviderGroups = useMemo( const visibleProviderGroups = useMemo(() => {
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS), const groups: ProviderGroup[] = PROVIDER_META.map((p) => ({
[isWindowsServer], id: p.id,
); name: p.name,
models: providerModelCatalog[p.id]?.OPTIONS ?? [],
}));
return isWindowsServer ? groups.filter((p) => p.id !== "cursor") : groups;
}, [isWindowsServer, providerModelCatalog]);
useEffect(() => { useEffect(() => {
if (isWindowsServer && provider === "cursor") { if (isWindowsServer && provider === "cursor") {
@@ -134,15 +145,16 @@ export default function ProviderSelectionEmptyState({
cursorModel, cursorModel,
codexModel, codexModel,
geminiModel, geminiModel,
opencodeModel,
); );
const currentModelLabel = useMemo(() => { const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider); const config = getModelConfig(provider, providerModelCatalog);
const found = config.OPTIONS.find( const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel, (o: { value: string; label: string }) => o.value === currentModel,
); );
return found?.label || currentModel; return found?.label || currentModel;
}, [provider, currentModel]); }, [provider, currentModel, providerModelCatalog]);
const setModelForProvider = useCallback( const setModelForProvider = useCallback(
(providerId: LLMProvider, modelValue: string) => { (providerId: LLMProvider, modelValue: string) => {
@@ -155,12 +167,15 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "gemini") { } else if (providerId === "gemini") {
setGeminiModel(modelValue); setGeminiModel(modelValue);
localStorage.setItem("gemini-model", modelValue); localStorage.setItem("gemini-model", modelValue);
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
} else { } else {
setCursorModel(modelValue); setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue); localStorage.setItem("cursor-model", modelValue);
} }
}, },
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel], [setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
); );
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
@@ -249,6 +264,11 @@ export default function ProviderSelectionEmptyState({
</span> </span>
} }
> >
{group.models.length === 0 && providerModelsLoading ? (
<CommandItem disabled className="ml-4 border-l border-border/40 pl-4 text-muted-foreground">
{t("providerSelection.loadingModels", { defaultValue: "Loading models…" })}
</CommandItem>
) : null}
{group.models.map((model) => { {group.models.map((model) => {
const isSelected = provider === group.id && currentModel === model.value; const isSelected = provider === group.id && currentModel === model.value;
return ( return (
@@ -287,6 +307,10 @@ export default function ProviderSelectionEmptyState({
gemini: t("providerSelection.readyPrompt.gemini", { gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel, model: geminiModel,
}), }),
opencode: t("providerSelection.readyPrompt.opencode", {
model: opencodeModel,
defaultValue: "Ready with OpenCode {{model}}",
}),
}[provider] }[provider]
} }
</p> </p>

View File

@@ -14,6 +14,7 @@ interface SessionsResponse {
cursorSessions?: ProjectSession[]; cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[]; codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[]; geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
} }
export function useSessionsSource(projectId: string | undefined, enabled: boolean) { export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
@@ -33,6 +34,7 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
...(data.cursorSessions ?? []), ...(data.cursorSessions ?? []),
...(data.codexSessions ?? []), ...(data.codexSessions ?? []),
...(data.geminiSessions ?? []), ...(data.geminiSessions ?? []),
...(data.opencodeSessions ?? []),
]; ];
return all.map<SessionResult>((s) => ({ return all.map<SessionResult>((s) => ({
id: s.id, id: s.id,

View File

@@ -0,0 +1,25 @@
type OpenCodeLogoProps = {
className?: string;
};
const OpenCodeLogo = ({ className = 'w-5 h-5' }: OpenCodeLogoProps) => (
<svg
viewBox="0 0 24 24"
role="img"
aria-label="OpenCode"
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="2.5" y="2.5" width="19" height="19" rx="4" className="fill-foreground" />
<path
d="M8.1 8.1 4.9 12l3.2 3.9M15.9 8.1l3.2 3.9-3.2 3.9M13.2 6.9l-2.4 10.2"
className="stroke-background"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default OpenCodeLogo;

View File

@@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo'; import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo'; import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo'; import GeminiLogo from './GeminiLogo';
import OpenCodeLogo from './OpenCodeLogo';
type SessionProviderLogoProps = { type SessionProviderLogoProps = {
provider?: LLMProvider | string | null; provider?: LLMProvider | string | null;
@@ -25,5 +26,9 @@ export default function SessionProviderLogo({
return <GeminiLogo className={className} />; return <GeminiLogo className={className} />;
} }
if (provider === 'opencode') {
return <OpenCodeLogo className={className} />;
}
return <ClaudeLogo className={className} />; return <ClaudeLogo className={className} />;
} }

View File

@@ -5,6 +5,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
cursor: 'Cursor', cursor: 'Cursor',
codex: 'Codex', codex: 'Codex',
gemini: 'Gemini', gemini: 'Gemini',
opencode: 'OpenCode',
}; };
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = { export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
@@ -12,6 +13,7 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
cursor: ['user', 'project'], cursor: ['user', 'project'],
codex: ['user', 'project'], codex: ['user', 'project'],
gemini: ['user', 'project'], gemini: ['user', 'project'],
opencode: ['user', 'project'],
}; };
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = { export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
@@ -19,6 +21,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
cursor: ['stdio', 'http'], cursor: ['stdio', 'http'],
codex: ['stdio', 'http'], codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'], gemini: ['stdio', 'http', 'sse'],
opencode: ['stdio', 'http'],
}; };
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project']; export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
@@ -30,6 +33,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
cursor: 'bg-purple-600 text-white hover:bg-purple-700', cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600', codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700', gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
}; };
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = { export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
@@ -37,6 +41,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
cursor: false, cursor: false,
codex: true, codex: true,
gemini: true, gemini: true,
opencode: false,
}; };
export const DEFAULT_MCP_FORM: McpFormState = { export const DEFAULT_MCP_FORM: McpFormState = {

View File

@@ -10,13 +10,14 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>; export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini']; export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = { export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status', claude: '/api/providers/claude/auth/status',
cursor: '/api/providers/cursor/auth/status', cursor: '/api/providers/cursor/auth/status',
codex: '/api/providers/codex/auth/status', codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status', gemini: '/api/providers/gemini/auth/status',
opencode: '/api/providers/opencode/auth/status',
}; };
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({ 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 }, cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading }, codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading }, gemini: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
}); });

View File

@@ -37,6 +37,10 @@ const getProviderCommand = ({
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login'; return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
} }
if (provider === 'opencode') {
return 'opencode auth login';
}
return 'gemini status'; return 'gemini status';
}; };
@@ -44,6 +48,7 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'claude') return 'Claude CLI Login'; if (provider === 'claude') return 'Claude CLI Login';
if (provider === 'cursor') return 'Cursor CLI Login'; if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login'; if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
return 'Gemini CLI Configuration'; return 'Gemini CLI Configuration';
}; };

View File

@@ -37,7 +37,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info }, { id: 'about', label: 'About', keywords: 'about version info', icon: Info },
]; ];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini']; export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp']; export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name'; export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';

View File

@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = { type AgentConfig = {
name: string; name: string;
color: 'blue' | 'purple' | 'gray' | 'indigo'; color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
}; };
const agentConfig: Record<AgentProvider, AgentConfig> = { const agentConfig: Record<AgentProvider, AgentConfig> = {
@@ -31,7 +31,11 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
gemini: { gemini: {
name: 'Gemini', name: 'Gemini',
color: 'indigo', color: 'indigo',
} },
opencode: {
name: 'OpenCode',
color: 'zinc',
},
}; };
const colorClasses = { const colorClasses = {
@@ -47,6 +51,9 @@ const colorClasses = {
indigo: { indigo: {
dot: 'bg-indigo-500', dot: 'bg-indigo-500',
}, },
zinc: {
dot: 'bg-zinc-500',
},
} as const; } as const;
export default function AgentListItem({ export default function AgentListItem({

View File

@@ -26,7 +26,7 @@ export default function AgentsSettingsTab({
const { isWindowsServer } = useServerPlatform(); const { isWindowsServer } = useServerPlatform();
const visibleAgents = useMemo<AgentProvider[]>(() => { const visibleAgents = useMemo<AgentProvider[]>(() => {
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini']; const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
if (isWindowsServer) { if (isWindowsServer) {
return all.filter((id) => id !== 'cursor'); return all.filter((id) => id !== 'cursor');
} }
@@ -57,12 +57,17 @@ export default function AgentsSettingsTab({
authStatus: providerAuthStatus.gemini, authStatus: providerAuthStatus.gemini,
onLogin: () => onProviderLogin('gemini'), onLogin: () => onProviderLogin('gemini'),
}, },
opencode: {
authStatus: providerAuthStatus.opencode,
onLogin: () => onProviderLogin('opencode'),
},
}), [ }), [
onProviderLogin, onProviderLogin,
providerAuthStatus.claude, providerAuthStatus.claude,
providerAuthStatus.codex, providerAuthStatus.codex,
providerAuthStatus.cursor, providerAuthStatus.cursor,
providerAuthStatus.gemini, providerAuthStatus.gemini,
providerAuthStatus.opencode,
]); ]);
return ( return (

View File

@@ -8,6 +8,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
cursor: 'Cursor', cursor: 'Cursor',
codex: 'Codex', codex: 'Codex',
gemini: 'Gemini', gemini: 'Gemini',
opencode: 'OpenCode',
}; };
export default function AgentSelectorSection({ export default function AgentSelectorSection({
@@ -23,7 +24,8 @@ export default function AgentSelectorSection({
const dotColor = const dotColor =
agent === 'claude' ? 'bg-blue-500' : agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' : agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60'; agent === 'gemini' ? 'bg-indigo-500' :
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
return ( return (
<Pill <Pill

View File

@@ -54,6 +54,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
subtextClass: 'text-indigo-700 dark:text-indigo-300', subtextClass: 'text-indigo-700 dark:text-indigo-300',
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800', buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',
}, },
opencode: {
name: 'OpenCode',
description: 'OpenCode CLI assistant',
bgClass: 'bg-zinc-50 dark:bg-zinc-900/20',
borderClass: 'border-zinc-200 dark:border-zinc-700',
textClass: 'text-zinc-900 dark:text-zinc-100',
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
}; };
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) { export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
@@ -66,7 +75,11 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
<SessionProviderLogo provider={agent} className="h-6 w-6" /> <SessionProviderLogo provider={agent} className="h-6 w-6" />
<div> <div>
<h3 className="text-lg font-medium text-foreground">{config.name}</h3> <h3 className="text-lg font-medium text-foreground">{config.name}</h3>
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p> <p className="text-sm text-muted-foreground">
{t(`agents.account.${agent}.description`, {
defaultValue: config.description || `${config.name} CLI assistant`,
})}
</p>
</div> </div>
</div> </div>

View File

@@ -62,6 +62,7 @@ export type SessionViewModel = {
isCursorSession: boolean; isCursorSession: boolean;
isCodexSession: boolean; isCodexSession: boolean;
isGeminiSession: boolean; isGeminiSession: boolean;
isOpenCodeSession: boolean;
isActive: boolean; isActive: boolean;
sessionName: string; sessionName: string;
sessionTime: string; sessionTime: string;

View File

@@ -85,6 +85,7 @@ export const createSessionViewModel = (
isCursorSession: session.__provider === 'cursor', isCursorSession: session.__provider === 'cursor',
isCodexSession: session.__provider === 'codex', isCodexSession: session.__provider === 'codex',
isGeminiSession: session.__provider === 'gemini', isGeminiSession: session.__provider === 'gemini',
isOpenCodeSession: session.__provider === 'opencode',
isActive: diffInMinutes < 10, isActive: diffInMinutes < 10,
sessionName: getSessionName(session, t), sessionName: getSessionName(session, t),
sessionTime: getSessionTime(session), sessionTime: getSessionTime(session),
@@ -113,7 +114,12 @@ export const getAllSessions = (project: Project): SessionWithProvider[] => {
__provider: 'gemini' as const, __provider: 'gemini' as const,
})); }));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort( const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
...session,
__provider: 'opencode' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
); );
}; };

View File

@@ -61,7 +61,8 @@ const projectsHaveChanges = (
return ( return (
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) || serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) || serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
); );
}); });
}; };
@@ -98,6 +99,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
...(project.codexSessions ?? []), ...(project.codexSessions ?? []),
...(project.cursorSessions ?? []), ...(project.cursorSessions ?? []),
...(project.geminiSessions ?? []), ...(project.geminiSessions ?? []),
...(project.opencodeSessions ?? []),
]; ];
}; };
@@ -145,6 +147,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []), cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []), codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []), geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
}; };
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount); const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
@@ -160,7 +163,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
const mergeProjectSessionPage = ( const mergeProjectSessionPage = (
existingProject: Project, existingProject: Project,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>, sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
): Project => { ): Project => {
const mergedProject: Project = { const mergedProject: Project = {
...existingProject, ...existingProject,
@@ -168,6 +171,7 @@ const mergeProjectSessionPage = (
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []), cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []), codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []), geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
}; };
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0); const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
@@ -555,6 +559,21 @@ export function useProjectsState({
} }
return; return;
} }
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
if (opencodeSession) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
}
return;
}
} }
// Session id is in the URL but not yet present on any project payload (common // Session id is in the URL but not yet present on any project payload (common
@@ -583,6 +602,8 @@ export function useProjectsState({
? 'codex' ? 'codex'
: providerFromStorage === 'gemini' : providerFromStorage === 'gemini'
? 'gemini' ? 'gemini'
: providerFromStorage === 'opencode'
? 'opencode'
: 'claude'; : 'claude';
setSelectedSession({ setSelectedSession({
@@ -665,12 +686,14 @@ export function useProjectsState({
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const removedFromProject = ( const removedFromProject = (
sessions.length !== (project.sessions?.length ?? 0) sessions.length !== (project.sessions?.length ?? 0)
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0) || cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|| codexSessions.length !== (project.codexSessions?.length ?? 0) || codexSessions.length !== (project.codexSessions?.length ?? 0)
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0) || geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
); );
if (!removedFromProject) { if (!removedFromProject) {
@@ -683,6 +706,7 @@ export function useProjectsState({
cursorSessions, cursorSessions,
codexSessions, codexSessions,
geminiSessions, geminiSessions,
opencodeSessions,
}; };
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1); const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
@@ -776,7 +800,7 @@ export function useProjectsState({
throw new Error(message); throw new Error(message);
} }
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>; const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
let mergedProjectForSelection: Project | null = null; let mergedProjectForSelection: Project | null = null;
setProjects((previousProjects) => setProjects((previousProjects) =>

View File

@@ -18,7 +18,8 @@
"claude": "Claude", "claude": "Claude",
"cursor": "Cursor", "cursor": "Cursor",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini" "gemini": "Gemini",
"opencode": "OpenCode"
}, },
"tools": { "tools": {
"settings": "Tool Settings", "settings": "Tool Settings",
@@ -189,6 +190,7 @@
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.", "cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
"codex": "Ready to use Codex with {{model}}. Start typing your message below.", "codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.", "gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
"opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin" "default": "Select a provider above to begin"
}, },
"pressToSearch": "Press <kbd>{{shortcut}}</kbd> to search sessions, files, and commits" "pressToSearch": "Press <kbd>{{shortcut}}</kbd> to search sessions, files, and commits"

View File

@@ -322,6 +322,9 @@
}, },
"gemini": { "gemini": {
"description": "Google Gemini AI assistant" "description": "Google Gemini AI assistant"
},
"opencode": {
"description": "OpenCode CLI assistant"
} }
}, },
"connectionStatus": "Connection Status", "connectionStatus": "Connection Status",
@@ -416,7 +419,8 @@
"description": { "description": {
"claude": "Model Context Protocol servers provide additional tools and data sources to Claude", "claude": "Model Context Protocol servers provide additional tools and data sources to Claude",
"cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor", "cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor",
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex" "codex": "Model Context Protocol servers provide additional tools and data sources to Codex",
"opencode": "Model Context Protocol servers provide additional tools and data sources to OpenCode"
}, },
"addButton": "Add MCP Server", "addButton": "Add MCP Server",
"empty": "No MCP servers configured", "empty": "No MCP servers configured",

View File

@@ -1,4 +1,14 @@
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
export type ProviderModelOption = {
value: string;
label: string;
};
export type ProviderModelsDefinition = {
OPTIONS: ProviderModelOption[];
DEFAULT: string;
};
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`; export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
@@ -46,6 +56,7 @@ export interface Project {
cursorSessions?: ProjectSession[]; cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[]; codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[]; geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
sessionMeta?: ProjectSessionMeta; sessionMeta?: ProjectSessionMeta;
taskmaster?: ProjectTaskmasterInfo; taskmaster?: ProjectTaskmasterInfo;
[key: string]: unknown; [key: string]: unknown;