mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-14 02:42:04 +08:00
feat: 添加对 OpenCode 的支持,更新 CLI 工具配置和会话发现逻辑
This commit is contained in:
@@ -1,88 +0,0 @@
|
|||||||
# TypeScript Naming Conventions
|
|
||||||
|
|
||||||
This rule enforces consistent naming conventions for TypeScript code to improve readability and maintain codebase consistency.
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
|
|
||||||
1. **Variables and Functions** - Use camelCase for all variable names, function names, and function parameters
|
|
||||||
2. **Classes and Interfaces** - Use PascalCase for class names, interface names, type aliases, and enum names
|
|
||||||
3. **Constants** - Use UPPER_SNAKE_CASE for module-level constants and readonly static class members
|
|
||||||
4. **Private Members** - Use camelCase with no special prefix for private class members (rely on TypeScript's `private` keyword)
|
|
||||||
5. **File Names** - Use kebab-case for file names (e.g., `user-service.ts`, `auth-controller.ts`)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### ✅ Correct
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Variables and functions
|
|
||||||
const userName = 'John';
|
|
||||||
let itemCount = 0;
|
|
||||||
function calculateTotal(orderItems: Item[]): number {
|
|
||||||
return orderItems.reduce((sum, item) => sum + item.price, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classes and interfaces
|
|
||||||
class UserService {
|
|
||||||
private userRepository: UserRepository;
|
|
||||||
|
|
||||||
constructor(userRepository: UserRepository) {
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiResponse {
|
|
||||||
statusCode: number;
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserId = string;
|
|
||||||
|
|
||||||
enum OrderStatus {
|
|
||||||
Pending,
|
|
||||||
Confirmed,
|
|
||||||
Shipped
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const MAX_RETRY_ATTEMPTS = 3;
|
|
||||||
const API_BASE_URL = 'https://api.example.com';
|
|
||||||
|
|
||||||
class Configuration {
|
|
||||||
static readonly DEFAULT_TIMEOUT = 5000;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ Incorrect
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Wrong: PascalCase for variables/functions
|
|
||||||
const UserName = 'John';
|
|
||||||
function CalculateTotal(order_items: Item[]): number { }
|
|
||||||
|
|
||||||
// Wrong: camelCase for classes/interfaces
|
|
||||||
class userService { }
|
|
||||||
interface apiResponse { }
|
|
||||||
type userId = string;
|
|
||||||
|
|
||||||
// Wrong: camelCase for constants
|
|
||||||
const maxRetryAttempts = 3;
|
|
||||||
const apiBaseUrl = 'https://api.example.com';
|
|
||||||
|
|
||||||
// Wrong: snake_case usage
|
|
||||||
const user_name = 'John';
|
|
||||||
function calculate_total(items: Item[]): number { }
|
|
||||||
class user_service { }
|
|
||||||
|
|
||||||
// Wrong: Hungarian notation or underscore prefix for private
|
|
||||||
class Service {
|
|
||||||
private _userData: User; // Don't use underscore prefix
|
|
||||||
private m_count: number; // Don't use Hungarian notation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Exceptions
|
|
||||||
|
|
||||||
- Third-party library types and names should maintain their original casing for compatibility
|
|
||||||
- Database column names or API field names may use different conventions when mapped from external systems (use transformation layers to convert)
|
|
||||||
- Test files may use descriptive names like `UserService.test.ts` to match the tested class
|
|
||||||
@@ -21,7 +21,7 @@ const HOOK_TEMPLATES = {
|
|||||||
event: 'PostToolUse',
|
event: 'PostToolUse',
|
||||||
matcher: '',
|
matcher: '',
|
||||||
command: 'bash',
|
command: 'bash',
|
||||||
args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); echo "[$(date)] Tool: $TOOL, File: $FILE" >> ~/.claude/tool-usage.log'],
|
args: ['-c', 'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"'],
|
||||||
description: 'Log all tool executions to a file',
|
description: 'Log all tool executions to a file',
|
||||||
category: 'logging'
|
category: 'logging'
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ const HOOK_TEMPLATES = {
|
|||||||
event: 'PostToolUse',
|
event: 'PostToolUse',
|
||||||
matcher: 'Write|Edit',
|
matcher: 'Write|Edit',
|
||||||
command: 'bash',
|
command: 'bash',
|
||||||
args: ['-c', 'INTERVAL=300; LAST_FILE=~/.claude/.last_memory_update; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE"); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
|
args: ['-c', 'INTERVAL=300; LAST_FILE="$HOME/.claude/.last_memory_update"; mkdir -p "$HOME/.claude"; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
|
||||||
description: 'Periodically update CLAUDE.md (default: 5 min interval)',
|
description: 'Periodically update CLAUDE.md (default: 5 min interval)',
|
||||||
category: 'memory',
|
category: 'memory',
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -79,7 +79,7 @@ const HOOK_TEMPLATES = {
|
|||||||
event: 'PostToolUse',
|
event: 'PostToolUse',
|
||||||
matcher: 'Write|Edit',
|
matcher: 'Write|Edit',
|
||||||
command: 'bash',
|
command: 'bash',
|
||||||
args: ['-c', 'THRESHOLD=10; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
|
args: ['-c', 'THRESHOLD=10; COUNT_FILE="$HOME/.claude/.memory_update_count"; mkdir -p "$HOME/.claude"; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
|
||||||
description: 'Update CLAUDE.md when file changes reach threshold (default: 10 files)',
|
description: 'Update CLAUDE.md when file changes reach threshold (default: 10 files)',
|
||||||
category: 'memory',
|
category: 'memory',
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -1163,9 +1163,9 @@ function generateWizardCommand() {
|
|||||||
const params = JSON.stringify({ strategy, tool });
|
const params = JSON.stringify({ strategy, tool });
|
||||||
|
|
||||||
if (triggerType === 'periodic') {
|
if (triggerType === 'periodic') {
|
||||||
return `INTERVAL=${interval}; LAST_FILE=~/.claude/.last_memory_update; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE"); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
|
return `INTERVAL=${interval}; LAST_FILE="$HOME/.claude/.last_memory_update"; mkdir -p "$HOME/.claude"; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
|
||||||
} else if (triggerType === 'count-based') {
|
} else if (triggerType === 'count-based') {
|
||||||
return `THRESHOLD=${threshold}; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
|
return `THRESHOLD=${threshold}; COUNT_FILE="$HOME/.claude/.memory_update_count"; mkdir -p "$HOME/.claude"; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
|
||||||
} else {
|
} else {
|
||||||
return `ccw tool exec update_module_claude '${params}'`;
|
return `ccw tool exec update_module_claude '${params}'`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -643,6 +643,24 @@ function updateProviderSpecificFields() {
|
|||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update API base URL placeholder based on provider type
|
||||||
|
var apiBaseInput = document.getElementById('provider-apibase');
|
||||||
|
if (apiBaseInput) {
|
||||||
|
var defaultBase = getDefaultApiBase(providerType);
|
||||||
|
apiBaseInput.placeholder = defaultBase;
|
||||||
|
|
||||||
|
// If the current value is empty or matches a known default, update to show the new default
|
||||||
|
var currentValue = apiBaseInput.value.trim();
|
||||||
|
var knownDefaults = [
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
'https://api.anthropic.com/v1',
|
||||||
|
'https://api.example.com/v1'
|
||||||
|
];
|
||||||
|
if (!currentValue || knownDefaults.includes(currentValue)) {
|
||||||
|
apiBaseInput.value = ''; // Clear to use placeholder, user can override if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Endpoint Management ==========
|
// ========== Endpoint Management ==========
|
||||||
@@ -1449,8 +1467,14 @@ async function toggleProviderEnabled(providerId, enabled) {
|
|||||||
if (!response.ok) throw new Error('Failed to update provider');
|
if (!response.ok) throw new Error('Failed to update provider');
|
||||||
|
|
||||||
// Update local data (for instant UI feedback)
|
// Update local data (for instant UI feedback)
|
||||||
|
// Ensure apiSettingsData is loaded before accessing
|
||||||
|
if (!apiSettingsData || !apiSettingsData.providers) {
|
||||||
|
await loadApiSettings(true);
|
||||||
|
}
|
||||||
|
if (apiSettingsData && apiSettingsData.providers) {
|
||||||
var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; });
|
var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; });
|
||||||
if (provider) provider.enabled = enabled;
|
if (provider) provider.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
renderProviderList();
|
renderProviderList();
|
||||||
showRefreshToast(t('apiSettings.providerUpdated'), 'success');
|
showRefreshToast(t('apiSettings.providerUpdated'), 'success');
|
||||||
@@ -2129,7 +2153,8 @@ function saveModelSettings(event, providerId, modelId, modelType) {
|
|||||||
})
|
})
|
||||||
.then(function() {
|
.then(function() {
|
||||||
closeModelSettingsModal();
|
closeModelSettingsModal();
|
||||||
return loadApiSettings();
|
// Force refresh to get latest data after saving
|
||||||
|
return loadApiSettings(true);
|
||||||
})
|
})
|
||||||
.then(function() {
|
.then(function() {
|
||||||
if (selectedProviderId === providerId) {
|
if (selectedProviderId === providerId) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface CliConfig {
|
|||||||
tools: Record<string, CliToolConfig>;
|
tools: Record<string, CliToolConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude';
|
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
@@ -28,7 +28,16 @@ export const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
|
|||||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
|
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
|
||||||
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
|
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
|
||||||
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'],
|
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'],
|
||||||
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101']
|
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'],
|
||||||
|
opencode: [
|
||||||
|
'anthropic/claude-sonnet-4-20250514',
|
||||||
|
'anthropic/claude-opus-4-20250514',
|
||||||
|
'anthropic/claude-haiku',
|
||||||
|
'openai/gpt-4.1',
|
||||||
|
'openai/o3',
|
||||||
|
'google/gemini-2.5-pro',
|
||||||
|
'google/gemini-2.5-flash'
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: CliConfig = {
|
export const DEFAULT_CONFIG: CliConfig = {
|
||||||
@@ -53,6 +62,11 @@ export const DEFAULT_CONFIG: CliConfig = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
primaryModel: 'sonnet',
|
primaryModel: 'sonnet',
|
||||||
secondaryModel: 'haiku'
|
secondaryModel: 'haiku'
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
enabled: true,
|
||||||
|
primaryModel: 'anthropic/claude-sonnet-4-20250514',
|
||||||
|
secondaryModel: 'anthropic/claude-haiku'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -69,7 +83,7 @@ function ensureConfigDirForProject(baseDir: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isValidToolName(tool: string): tool is CliToolName {
|
function isValidToolName(tool: string): tool is CliToolName {
|
||||||
return ['gemini', 'qwen', 'codex', 'claude'].includes(tool);
|
return ['gemini', 'qwen', 'codex', 'claude', 'opencode'].includes(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateConfig(config: unknown): config is CliConfig {
|
function validateConfig(config: unknown): config is CliConfig {
|
||||||
@@ -80,7 +94,7 @@ function validateConfig(config: unknown): config is CliConfig {
|
|||||||
if (!c.tools || typeof c.tools !== 'object') return false;
|
if (!c.tools || typeof c.tools !== 'object') return false;
|
||||||
|
|
||||||
const tools = c.tools as Record<string, unknown>;
|
const tools = c.tools as Record<string, unknown>;
|
||||||
for (const toolName of ['gemini', 'qwen', 'codex', 'claude']) {
|
for (const toolName of ['gemini', 'qwen', 'codex', 'claude', 'opencode']) {
|
||||||
const tool = tools[toolName];
|
const tool = tools[toolName];
|
||||||
if (!tool || typeof tool !== 'object') return false;
|
if (!tool || typeof tool !== 'object') return false;
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ import {
|
|||||||
|
|
||||||
// Define Zod schema for validation
|
// Define Zod schema for validation
|
||||||
const ParamsSchema = z.object({
|
const ParamsSchema = z.object({
|
||||||
tool: z.enum(['gemini', 'qwen', 'codex']),
|
tool: z.enum(['gemini', 'qwen', 'codex', 'opencode']),
|
||||||
prompt: z.string().min(1, 'Prompt is required'),
|
prompt: z.string().min(1, 'Prompt is required'),
|
||||||
mode: z.enum(['analysis', 'write', 'auto']).default('analysis'),
|
mode: z.enum(['analysis', 'write', 'auto']).default('analysis'),
|
||||||
format: z.enum(['plain', 'yaml', 'json']).default('plain'), // Multi-turn prompt concatenation format
|
format: z.enum(['plain', 'yaml', 'json']).default('plain'), // Multi-turn prompt concatenation format
|
||||||
@@ -808,7 +808,7 @@ export {
|
|||||||
* Get status of all CLI tools
|
* Get status of all CLI tools
|
||||||
*/
|
*/
|
||||||
export async function getCliToolsStatus(): Promise<Record<string, ToolAvailability>> {
|
export async function getCliToolsStatus(): Promise<Record<string, ToolAvailability>> {
|
||||||
const tools = ['gemini', 'qwen', 'codex', 'claude'];
|
const tools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'];
|
||||||
const results: Record<string, ToolAvailability> = {};
|
const results: Record<string, ToolAvailability> = {};
|
||||||
|
|
||||||
await Promise.all(tools.map(async (tool) => {
|
await Promise.all(tools.map(async (tool) => {
|
||||||
@@ -823,7 +823,8 @@ const CLI_TOOL_PACKAGES: Record<string, string> = {
|
|||||||
gemini: '@google/gemini-cli',
|
gemini: '@google/gemini-cli',
|
||||||
qwen: '@qwen-code/qwen-code',
|
qwen: '@qwen-code/qwen-code',
|
||||||
codex: '@openai/codex',
|
codex: '@openai/codex',
|
||||||
claude: '@anthropic-ai/claude-code'
|
claude: '@anthropic-ai/claude-code',
|
||||||
|
opencode: 'opencode' // https://opencode.ai - installed via npm/pnpm/bun/brew
|
||||||
};
|
};
|
||||||
|
|
||||||
// Disabled tools storage (in-memory fallback, main storage is in cli-config.json)
|
// Disabled tools storage (in-memory fallback, main storage is in cli-config.json)
|
||||||
|
|||||||
@@ -290,6 +290,32 @@ export function buildCommand(params: {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'opencode':
|
||||||
|
// OpenCode: opencode run "prompt" for non-interactive mode
|
||||||
|
// https://opencode.ai/docs/cli/
|
||||||
|
args.push('run');
|
||||||
|
// Native resume: opencode run --continue or --session <id>
|
||||||
|
if (nativeResume?.enabled) {
|
||||||
|
if (nativeResume.isLatest) {
|
||||||
|
args.push('--continue');
|
||||||
|
} else if (nativeResume.sessionId) {
|
||||||
|
args.push('--session', nativeResume.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Model: --model <provider/model> (e.g., anthropic/claude-sonnet-4-20250514)
|
||||||
|
if (model) {
|
||||||
|
args.push('--model', model);
|
||||||
|
}
|
||||||
|
// Write mode: Use full-auto permission via environment or default permissive mode
|
||||||
|
// OpenCode uses OPENCODE_PERMISSION env var for permission control
|
||||||
|
// For now, we rely on project-level configuration
|
||||||
|
// Output format for parsing
|
||||||
|
args.push('--format', 'default');
|
||||||
|
// Prompt is passed as positional argument after 'run'
|
||||||
|
// Use stdin for prompt to avoid shell escaping issues
|
||||||
|
useStdin = true;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
errorLog('BUILD_CMD', `Unknown CLI tool: ${tool}`);
|
errorLog('BUILD_CMD', `Unknown CLI tool: ${tool}`);
|
||||||
throw new Error(`Unknown CLI tool: ${tool}`);
|
throw new Error(`Unknown CLI tool: ${tool}`);
|
||||||
|
|||||||
@@ -679,12 +679,146 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenCode Session Discoverer
|
||||||
|
* Path: ~/.config/opencode/sessions/ or ~/.opencode/sessions/ (fallback)
|
||||||
|
* OpenCode stores sessions with UUID-based session IDs
|
||||||
|
* https://opencode.ai/docs/cli/
|
||||||
|
*/
|
||||||
|
class OpenCodeSessionDiscoverer extends SessionDiscoverer {
|
||||||
|
tool = 'opencode';
|
||||||
|
// Primary: XDG config path, fallback to .opencode in home
|
||||||
|
basePath = join(
|
||||||
|
process.env.OPENCODE_CONFIG_DIR ||
|
||||||
|
process.env.XDG_CONFIG_HOME ||
|
||||||
|
join(getHomePath(), '.config'),
|
||||||
|
'opencode'
|
||||||
|
);
|
||||||
|
fallbackBasePath = join(getHomePath(), '.opencode');
|
||||||
|
|
||||||
|
private getSessionsDir(): string | null {
|
||||||
|
// Check primary path first
|
||||||
|
const primarySessionsDir = join(this.basePath, 'sessions');
|
||||||
|
if (existsSync(primarySessionsDir)) {
|
||||||
|
return primarySessionsDir;
|
||||||
|
}
|
||||||
|
// Fallback to ~/.opencode/sessions
|
||||||
|
const fallbackSessionsDir = join(this.fallbackBasePath, 'sessions');
|
||||||
|
if (existsSync(fallbackSessionsDir)) {
|
||||||
|
return fallbackSessionsDir;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||||
|
const { limit, afterTimestamp } = options;
|
||||||
|
const sessions: NativeSession[] = [];
|
||||||
|
|
||||||
|
const sessionsDir = this.getSessionsDir();
|
||||||
|
if (!sessionsDir) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// OpenCode stores sessions as JSON/JSONL files with UUID names
|
||||||
|
const sessionFiles = readdirSync(sessionsDir)
|
||||||
|
.filter(f => f.endsWith('.json') || f.endsWith('.jsonl'))
|
||||||
|
.map(f => ({
|
||||||
|
name: f,
|
||||||
|
path: join(sessionsDir, f),
|
||||||
|
stat: statSync(join(sessionsDir, f))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||||
|
|
||||||
|
for (const file of sessionFiles) {
|
||||||
|
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to extract session ID from filename (UUID pattern)
|
||||||
|
const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
|
if (uuidMatch) {
|
||||||
|
sessionId = uuidMatch[1];
|
||||||
|
} else {
|
||||||
|
// Try reading first line for session metadata
|
||||||
|
const firstLine = readFileSync(file.path, 'utf8').split('\n')[0];
|
||||||
|
const meta = JSON.parse(firstLine);
|
||||||
|
sessionId = meta.id || meta.session_id || basename(file.name, '.json').replace('.jsonl', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
sessionId,
|
||||||
|
tool: this.tool,
|
||||||
|
filePath: file.path,
|
||||||
|
createdAt: file.stat.birthtime,
|
||||||
|
updatedAt: file.stat.mtime
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip invalid files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||||
|
return limit ? sessions.slice(0, limit) : sessions;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findSessionById(sessionId: string): NativeSession | null {
|
||||||
|
const sessions = this.getSessions();
|
||||||
|
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract first user message from OpenCode session file
|
||||||
|
* Format may vary - try common patterns
|
||||||
|
*/
|
||||||
|
extractFirstUserMessage(filePath: string): string | null {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// Check if JSON or JSONL
|
||||||
|
if (filePath.endsWith('.jsonl')) {
|
||||||
|
const lines = content.split('\n').filter(l => l.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
// Try common patterns for user message
|
||||||
|
if (entry.role === 'user' && entry.content) {
|
||||||
|
return entry.content;
|
||||||
|
}
|
||||||
|
if (entry.type === 'user' && entry.message) {
|
||||||
|
return typeof entry.message === 'string' ? entry.message : entry.message.content;
|
||||||
|
}
|
||||||
|
if (entry.type === 'user_message' && entry.content) {
|
||||||
|
return entry.content;
|
||||||
|
}
|
||||||
|
} catch { /* skip invalid lines */ }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// JSON format - look for messages array
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
if (data.messages && Array.isArray(data.messages)) {
|
||||||
|
const userMsg = data.messages.find((m: { role?: string; type?: string }) =>
|
||||||
|
m.role === 'user' || m.type === 'user'
|
||||||
|
);
|
||||||
|
return userMsg?.content || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Singleton discoverers
|
// Singleton discoverers
|
||||||
const discoverers: Record<string, SessionDiscoverer> = {
|
const discoverers: Record<string, SessionDiscoverer> = {
|
||||||
gemini: new GeminiSessionDiscoverer(),
|
gemini: new GeminiSessionDiscoverer(),
|
||||||
qwen: new QwenSessionDiscoverer(),
|
qwen: new QwenSessionDiscoverer(),
|
||||||
codex: new CodexSessionDiscoverer(),
|
codex: new CodexSessionDiscoverer(),
|
||||||
claude: new ClaudeSessionDiscoverer()
|
claude: new ClaudeSessionDiscoverer(),
|
||||||
|
opencode: new OpenCodeSessionDiscoverer()
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -781,6 +915,13 @@ export function getNativeResumeArgs(
|
|||||||
}
|
}
|
||||||
return ['resume', sessionId];
|
return ['resume', sessionId];
|
||||||
|
|
||||||
|
case 'opencode':
|
||||||
|
// opencode run --continue (latest) or --session <uuid>
|
||||||
|
if (sessionId === 'latest') {
|
||||||
|
return ['--continue'];
|
||||||
|
}
|
||||||
|
return ['--session', sessionId];
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user