diff --git a/CLAUDE.md b/CLAUDE.md index 675dcd5a..bea3c127 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1548 (must match `package.json` for npm publish) +**Version**: 0.1549 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 78b4a82f..79fd17be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1548", + "version": "0.1549", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/mux-interface.ts b/src/mux-interface.ts index f921e6fa..19f7e7d2 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -70,6 +70,7 @@ export interface TerminalMultiplexer extends EventEmitter { mode: 'claude' | 'shell', name?: string, niceConfig?: NiceConfig, + model?: string, ): Promise; /** diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 59fd81b2..dbbb6faa 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -150,7 +150,7 @@ function tryParseJSON(jsonString: string): { success: boolean; data?: unknown; e // Constants // ============================================================================ -const MODEL = 'opus'; +const DEFAULT_MODEL = 'opus'; // ============================================================================ // Main Orchestrator Class @@ -163,11 +163,20 @@ export class PlanOrchestrator { private runningSessions: Set = new Set(); private cancelled = false; private taskDescription = ''; + private researchModel: string; + private plannerModel: string; - constructor(mux: TerminalMultiplexer, workingDir: string = process.cwd(), outputDir?: string) { + constructor( + mux: TerminalMultiplexer, + workingDir: string = process.cwd(), + outputDir?: string, + modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record }, + ) { this.mux = mux; this.workingDir = workingDir; this.outputDir = outputDir; + this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL; + this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL; } private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void { @@ -386,7 +395,7 @@ export class PlanOrchestrator { return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'Cancelled', durationMs: 0 }; } - onSubagent?.({ type: 'started', agentId, agentType: 'research', model: MODEL, status: 'running', detail: 'Researching...' }); + onSubagent?.({ type: 'started', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: 'Researching...' }); const session = new Session({ workingDir: this.workingDir, @@ -402,11 +411,11 @@ export class PlanOrchestrator { // Start progress interval before try block to ensure cleanup in finally const progressInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); - onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: MODEL, status: 'running', detail: `${elapsed}s elapsed` }); + onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: `${elapsed}s elapsed` }); }, 30000); try { - const { result: response } = await session.runPrompt(prompt, { model: MODEL }); + const { result: response } = await session.runPrompt(prompt, { model: this.researchModel }); this.runningSessions.delete(session); @@ -415,13 +424,13 @@ export class PlanOrchestrator { // Extract JSON from response const jsonMatch = response.match(/\{[\s\S]*\}/); if (!jsonMatch) { - onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: MODEL, status: 'failed', error: 'No JSON found', durationMs }); + onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs }); return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs }; } const parsed = tryParseJSON(jsonMatch[0]); if (!parsed.success) { - onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: MODEL, status: 'failed', error: parsed.error, durationMs }); + onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: parsed.error, durationMs }); return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: parsed.error, durationMs }; } @@ -440,14 +449,14 @@ export class PlanOrchestrator { }; this.saveAgentOutput('research', prompt, result, durationMs); - onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: MODEL, status: 'completed', durationMs }); + onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: this.researchModel, status: 'completed', durationMs }); return result; } catch (err) { this.runningSessions.delete(session); const durationMs = Date.now() - startTime; const error = err instanceof Error ? err.message : String(err); - onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: MODEL, status: 'failed', error, durationMs }); + onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs }); return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs }; } finally { // Always clear the progress interval to prevent memory leaks @@ -468,7 +477,7 @@ export class PlanOrchestrator { return { success: false, error: 'Cancelled' }; } - onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: MODEL, status: 'running', detail: 'Generating plan...' }); + onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: 'Generating plan...' }); const session = new Session({ workingDir: this.workingDir, @@ -486,11 +495,11 @@ export class PlanOrchestrator { // Start progress interval before try block to ensure cleanup in finally const progressInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); - onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: MODEL, status: 'running', detail: `${elapsed}s elapsed` }); + onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: `${elapsed}s elapsed` }); }, 30000); try { - const { result: response } = await session.runPrompt(prompt, { model: MODEL }); + const { result: response } = await session.runPrompt(prompt, { model: this.plannerModel }); this.runningSessions.delete(session); @@ -499,13 +508,13 @@ export class PlanOrchestrator { // Extract JSON from response const jsonMatch = response.match(/\{[\s\S]*\}/); if (!jsonMatch) { - onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: MODEL, status: 'failed', error: 'No JSON found', durationMs }); + onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs }); return { success: false, error: 'No JSON in response' }; } const parsed = tryParseJSON(jsonMatch[0]); if (!parsed.success) { - onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: MODEL, status: 'failed', error: parsed.error, durationMs }); + onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: parsed.error, durationMs }); return { success: false, error: parsed.error }; } @@ -515,7 +524,7 @@ export class PlanOrchestrator { const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : []; this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs); - onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: MODEL, status: 'completed', itemCount: items.length, durationMs }); + onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: this.plannerModel, status: 'completed', itemCount: items.length, durationMs }); onProgress?.('planning', `Generated ${items.length} tasks`); @@ -524,7 +533,7 @@ export class PlanOrchestrator { this.runningSessions.delete(session); const durationMs = Date.now() - startTime; const error = err instanceof Error ? err.message : String(err); - onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: MODEL, status: 'failed', error, durationMs }); + onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs }); return { success: false, error }; } finally { // Always clear the progress interval to prevent memory leaks diff --git a/src/session.ts b/src/session.ts index 32a3b5e9..efc8827c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -317,6 +317,9 @@ export class Session extends EventEmitter { // Nice prioritying configuration private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG }; + // Claude model override (e.g., 'opus', 'sonnet', 'haiku') + private _model: string | undefined; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -361,6 +364,8 @@ export class Session extends EventEmitter { /** Existing mux session for restored sessions */ muxSession?: MuxSession; niceConfig?: NiceConfig; // Nice prioritying configuration + /** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */ + model?: string; }) { super(); this.setMaxListeners(25); @@ -391,6 +396,11 @@ export class Session extends EventEmitter { this._niceConfig = { ...config.niceConfig }; } + // Apply model override if provided + if (config.model) { + this._model = config.model; + } + // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); this._taskTrackerHandlers = { @@ -845,7 +855,7 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { // Create a new mux session - this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig); + this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig, this._model); console.log('[Session] Created mux session:', this._muxSession.muxName); // Wait a moment for mux to fully start @@ -920,10 +930,9 @@ export class Session extends EventEmitter { try { // Pass --session-id to use the SAME ID as the Claudeman session // This ensures subagents can be directly matched to the correct tab - this.ptyProcess = pty.spawn('claude', [ - '--dangerously-skip-permissions', - '--session-id', this.id - ], { + const args = ['--dangerously-skip-permissions', '--session-id', this.id]; + if (this._model) args.push('--model', this._model); + this.ptyProcess = pty.spawn('claude', args, { name: 'xterm-256color', cols: 120, rows: 40, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a4eb6980..f19d8353 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -201,6 +201,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode: 'claude' | 'shell', name?: string, niceConfig?: NiceConfig, + model?: string, ): Promise { const muxName = `claudeman-${sessionId.slice(0, 8)}`; @@ -240,8 +241,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, ].join(' && '); + // Validate model to prevent command injection (only allow alphanumeric, hyphens, dots) + const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined; + const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : ''; const baseCmd = mode === 'claude' - ? `claude --dangerously-skip-permissions --session-id "${sessionId}"` + ? `claude --dangerously-skip-permissions --session-id "${sessionId}"${modelFlag}` : '$SHELL'; const config = niceConfig || DEFAULT_NICE_CONFIG; diff --git a/src/web/server.ts b/src/web/server.ts index b3b828de..10daa0eb 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -785,6 +785,8 @@ export class WebServer extends EventEmitter { } const globalNice = await this.getGlobalNiceConfig(); + const modelConfig = await this.getModelConfig(); + const model = (body.mode !== 'shell') ? modelConfig?.defaultModel : undefined; const session = new Session({ workingDir, mode: body.mode || 'claude', @@ -792,6 +794,7 @@ export class WebServer extends EventEmitter { mux: this.mux, useMux: true, niceConfig: globalNice, + model, }); this.sessions.set(session.id, session); @@ -2569,14 +2572,17 @@ export class WebServer extends EventEmitter { } // Create a new session with the case as working directory - // Apply global Nice priority config if enabled in settings + // Apply global Nice priority config and model config from settings const niceConfig = await this.getGlobalNiceConfig(); + const qsModelConfig = await this.getModelConfig(); + const qsModel = (mode !== 'shell') ? qsModelConfig?.defaultModel : undefined; const session = new Session({ workingDir: casePath, mux: this.mux, useMux: true, mode: mode, niceConfig: niceConfig, + model: qsModel, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting @@ -2750,8 +2756,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; mode: 'claude', }); - // Use Opus 4.5 for plan generation (better reasoning) - const modelToUse = 'opus'; + // Use configured model for plan generation, falling back to opus + const planModelConfig = await this.getModelConfig(); + const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus'; try { const { result, cost } = await session.runPrompt(prompt, { model: modelToUse }); @@ -2866,7 +2873,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } } - const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir); + const detailedModelConfig = await this.getModelConfig(); + const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined); // Store orchestrator for potential cancellation via API (not on disconnect) // Plan generation continues even if browser disconnects - only explicit cancel stops it @@ -4566,6 +4574,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return undefined; } + // Helper to get model configuration from settings + private async getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record } | null> { + const settingsPath = join(homedir(), '.claudeman', 'settings.json'); + + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + return JSON.parse(content).modelConfig || null; + } catch { + return null; + } + } + private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise { const id = uuidv4(); const now = Date.now();