diff --git a/package.json b/package.json index 87040fab..e30cd63f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "happy-coder", - "version": "0.14.0-0", + "version": "0.15.0", "description": "Mobile and Web client for Claude Code and Codex", "author": "Kirill Dubovitskiy", "license": "MIT", diff --git a/scripts/claude_local_launcher.cjs b/scripts/claude_local_launcher.cjs index 6afab097..cec15407 100644 --- a/scripts/claude_local_launcher.cjs +++ b/scripts/claude_local_launcher.cjs @@ -3,6 +3,9 @@ const fs = require('fs'); // Disable autoupdater (never works really) process.env.DISABLE_AUTOUPDATER = '1'; +// Disable Claude Code's terminal title setting so Happy CLI can control it +process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE = '1'; + // Helper to write JSON messages to fd 3 function writeMessage(message) { try { diff --git a/scripts/claude_version_utils.cjs b/scripts/claude_version_utils.cjs index 2184917a..daef5246 100644 --- a/scripts/claude_version_utils.cjs +++ b/scripts/claude_version_utils.cjs @@ -497,6 +497,19 @@ function runClaudeCli(cliPath) { stdio: 'inherit', env: process.env }); + + // Forward signals to child process so it gets killed when parent is killed + // This prevents orphaned Claude processes when switching between local/remote modes + // Fix for issue #11 / GitHub slopus/happy#430 + const forwardSignal = (signal) => { + if (child.pid && !child.killed) { + child.kill(signal); + } + }; + process.on('SIGTERM', () => forwardSignal('SIGTERM')); + process.on('SIGINT', () => forwardSignal('SIGINT')); + process.on('SIGHUP', () => forwardSignal('SIGHUP')); + child.on('exit', (code) => { process.exit(code || 0); }); diff --git a/src/api/api.ts b/src/api/api.ts index fc381180..d2d5ac43 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -285,6 +285,25 @@ export class ApiClient { return this.pushClient; } + async postFeedItem(body: Record, repeatKey?: string): Promise { + try { + await axios.post( + `${configuration.serverUrl}/v1/feed`, + { body, repeatKey }, + { + headers: { + 'Authorization': `Bearer ${this.credential.token}`, + 'Content-Type': 'application/json' + }, + timeout: 5000 + } + ); + logger.debug('[API] Feed item posted successfully'); + } catch (error) { + logger.debug('[API] Failed to post feed item:', error); + } + } + /** * Register a vendor API token with the server * The token is sent as a JSON string - server handles encryption diff --git a/src/api/apiMachine.ts b/src/api/apiMachine.ts index b8e2b570..3c528122 100644 --- a/src/api/apiMachine.ts +++ b/src/api/apiMachine.ts @@ -92,7 +92,7 @@ export class ApiMachineClient { logger: (msg, data) => logger.debug(msg, data) }); - registerCommonHandlers(this.rpcHandlerManager, process.cwd()); + registerCommonHandlers(this.rpcHandlerManager, process.cwd(), this.machine.id); } setRPCHandlers({ diff --git a/src/api/apiSession.ts b/src/api/apiSession.ts index 187ce5b8..d5cf9796 100644 --- a/src/api/apiSession.ts +++ b/src/api/apiSession.ts @@ -72,7 +72,7 @@ export class ApiSessionClient extends EventEmitter { encryptionVariant: this.encryptionVariant, logger: (msg, data) => logger.debug(msg, data) }); - registerCommonHandlers(this.rpcHandlerManager, this.metadata.path); + registerCommonHandlers(this.rpcHandlerManager, this.metadata.path, this.sessionId); // // Create socket @@ -221,10 +221,9 @@ export class ApiSessionClient extends EventEmitter { logger.debugLargeJson('[SOCKET] Sending message through socket:', content) - // Check if socket is connected before sending + // Socket.io buffers messages when disconnected and sends them on reconnect if (!this.socket.connected) { - logger.debug('[API] Socket not connected, cannot send Claude session message. Message will be lost:', { type: body.type }); - return; + logger.debug('[API] Socket not connected, message will be buffered for reconnect:', { type: body.type }); } const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); @@ -267,12 +266,11 @@ export class ApiSessionClient extends EventEmitter { }; const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); - // Check if socket is connected before sending + // Socket.io buffers messages when disconnected and sends them on reconnect if (!this.socket.connected) { - logger.debug('[API] Socket not connected, cannot send message. Message will be lost:', { type: body.type }); - // TODO: Consider implementing message queue or HTTP fallback for reliability + logger.debug('[API] Socket not connected, message will be buffered for reconnect:', { type: body.type }); } - + this.socket.emit('message', { sid: this.sessionId, message: encrypted @@ -316,6 +314,8 @@ export class ApiSessionClient extends EventEmitter { type: 'permission-mode-changed', mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' } | { type: 'ready' + } | { + type: 'coordinator-state', [key: string]: unknown }, id?: string) { let content = { role: 'agent', @@ -384,6 +384,36 @@ export class ApiSessionClient extends EventEmitter { this.socket.emit('usage-report', usageReport); } + /** + * Send cost data from SDK result to the server + */ + sendCostData(totalCostUsd: number, usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }) { + if (!usage) return; + + const totalTokens = usage.input_tokens + usage.output_tokens + + (usage.cache_read_input_tokens || 0) + + (usage.cache_creation_input_tokens || 0); + + const usageReport = { + key: 'claude-session', + sessionId: this.sessionId, + tokens: { + total: totalTokens, + input: usage.input_tokens, + output: usage.output_tokens, + cache_creation: usage.cache_creation_input_tokens || 0, + cache_read: usage.cache_read_input_tokens || 0 + }, + cost: { + total: totalCostUsd, + input: 0, + output: 0 + } + }; + logger.debugLargeJson('[SOCKET] Sending cost data from result:', usageReport); + this.socket.emit('usage-report', usageReport); + } + /** * Update session metadata * @param handler - Handler function that returns the updated metadata @@ -454,6 +484,16 @@ export class ApiSessionClient extends EventEmitter { }); } + /** + * Signal the end of a Claude session turn. + * In the standalone CLI (socket-based), this is a no-op since we don't use + * the session protocol envelope system. The monorepo version sends turn-end + * envelopes via HTTP outbox. + */ + closeClaudeSessionTurn(status: 'completed' | 'failed' | 'cancelled' = 'completed') { + logger.debug(`[API] closeClaudeSessionTurn: ${status}`); + } + async close() { logger.debug('[API] socket.close() called'); this.socket.close(); diff --git a/src/api/pushNotifications.ts b/src/api/pushNotifications.ts index a194a861..31506e21 100644 --- a/src/api/pushNotifications.ts +++ b/src/api/pushNotifications.ts @@ -157,7 +157,10 @@ export class PushNotificationClient { body, data, sound: 'default', - priority: 'high' + priority: 'high', + ...(data?.categoryIdentifier && { + categoryId: data.categoryIdentifier, + }), } }) diff --git a/src/claude/claudeLocal.ts b/src/claude/claudeLocal.ts index d4f7ac0b..e5a580c1 100644 --- a/src/claude/claudeLocal.ts +++ b/src/claude/claudeLocal.ts @@ -217,14 +217,21 @@ export async function claudeLocal(opts: { // Prepare environment variables // Note: Local mode uses global Claude installation with --session-id flag // Launcher only intercepts fetch for thinking state tracking - const env = { + const env: Record = { ...process.env, ...opts.claudeEnvVars } + // Remove Claude Code nesting detection vars that may linger from SDK remote mode. + // The SDK sets CLAUDE_CODE_ENTRYPOINT in process.env (query.ts:282-283), + // which persists after remote mode ends. If passed to the local Claude CLI, + // it causes Claude to think it's running nested inside another session and exit. + delete env.CLAUDECODE + delete env.CLAUDE_CODE_ENTRYPOINT logger.debug(`[ClaudeLocal] Spawning launcher: ${claudeCliPath}`); logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`); + const spawnTime = Date.now(); const child = spawn('node', [claudeCliPath, ...args], { stdio: ['inherit', 'inherit', 'inherit', 'pipe'], signal: opts.abort, @@ -232,6 +239,29 @@ export async function claudeLocal(opts: { env, }); + // Forward signals to child process to prevent orphaned processes + // Fix for issue #11 / GitHub slopus/happy#430 + // Note: signal: opts.abort handles programmatic abort (mode switching), + // but direct OS signals (e.g., kill, Ctrl+C) need explicit forwarding + const forwardSignal = (signal: NodeJS.Signals) => { + if (child.pid && !child.killed) { + child.kill(signal); + } + }; + const onSigterm = () => forwardSignal('SIGTERM'); + const onSigint = () => forwardSignal('SIGINT'); + const onSighup = () => forwardSignal('SIGHUP'); + process.on('SIGTERM', onSigterm); + process.on('SIGINT', onSigint); + process.on('SIGHUP', onSighup); + + // Cleanup signal handlers when child exits to avoid leaks + child.on('exit', () => { + process.off('SIGTERM', onSigterm); + process.off('SIGINT', onSigint); + process.off('SIGHUP', onSighup); + }); + // Listen to the custom fd (fd 3) for thinking state tracking if (child.stdio[3]) { const rl = createInterface({ @@ -300,14 +330,21 @@ export async function claudeLocal(opts: { }); } child.on('error', (error) => { - // Ignore + logger.debug(`[ClaudeLocal] Process spawn error: ${error.message}`); }); child.on('exit', (code, signal) => { + const runtime = Date.now() - spawnTime; + logger.debug(`[ClaudeLocal] Process exited: code=${code}, signal=${signal}, runtime=${runtime}ms, aborted=${opts.abort.aborted}`); if (signal === 'SIGTERM' && opts.abort.aborted) { // Normal termination due to abort signal r(); } else if (signal) { reject(new Error(`Process terminated with signal: ${signal}`)); + } else if (code !== 0 && code !== null) { + reject(new Error(`Process exited with code ${code} after ${runtime}ms`)); + } else if (runtime < 2000 && !opts.abort.aborted) { + // Process exited too quickly (< 2s) - likely a startup failure + reject(new Error(`Process exited suspiciously fast (${runtime}ms) - possible startup failure`)); } else { r(); } diff --git a/src/claude/claudeLocalLauncher.ts b/src/claude/claudeLocalLauncher.ts index 0a7772ed..0096ef47 100644 --- a/src/claude/claudeLocalLauncher.ts +++ b/src/claude/claudeLocalLauncher.ts @@ -89,6 +89,8 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | } // Run local mode + let launchAttempts = 0; + const MAX_LAUNCH_ATTEMPTS = 3; while (true) { // If we already have an exit reason, return it if (exitReason) { @@ -96,7 +98,8 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | } // Launch - logger.debug('[local]: launch'); + launchAttempts++; + logger.debug(`[local]: launch (attempt ${launchAttempts}/${MAX_LAUNCH_ATTEMPTS})`); try { await claudeLocal({ path: session.path, @@ -115,15 +118,23 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | // For example we don't want to pass --resume flag after first spawn session.consumeOneTimeFlags(); - // Normal exit + // Normal exit - reset attempts on successful run + launchAttempts = 0; if (!exitReason) { exitReason = 'exit'; break; } } catch (e) { - logger.debug('[local]: launch error', e); + const errorMsg = e instanceof Error ? e.message : String(e); + logger.debug(`[local]: launch error (attempt ${launchAttempts}): ${errorMsg}`); if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + if (launchAttempts >= MAX_LAUNCH_ATTEMPTS) { + logger.debug(`[local]: max launch attempts (${MAX_LAUNCH_ATTEMPTS}) reached, giving up`); + session.client.sendSessionEvent({ type: 'message', message: `Local mode failed after ${MAX_LAUNCH_ATTEMPTS} attempts: ${errorMsg}` }); + exitReason = 'exit'; + break; + } + session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${errorMsg}. Retrying...` }); continue; } else { break; diff --git a/src/claude/claudeRemote.ts b/src/claude/claudeRemote.ts index d93215c8..a85a416d 100644 --- a/src/claude/claudeRemote.ts +++ b/src/claude/claudeRemote.ts @@ -10,7 +10,7 @@ import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; import { getProjectPath } from "./utils/path"; import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; import { systemPrompt } from "./utils/systemPrompt"; -import { PermissionResult } from "./sdk/types"; +import { PermissionResult, SDKResultMessage } from "./sdk/types"; import type { JsRuntime } from "./runClaude"; export async function claudeRemote(opts: { @@ -39,7 +39,8 @@ export async function claudeRemote(opts: { onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onCompletionEvent?: (message: string) => void, - onSessionReset?: () => void + onSessionReset?: () => void, + onResult?: (result: SDKResultMessage) => void }) { // Check if session is valid @@ -168,7 +169,6 @@ export async function claudeRemote(opts: { for await (const message of response) { logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message); - // Handle messages opts.onMessage(message); // Handle special system messages @@ -194,6 +194,11 @@ export async function claudeRemote(opts: { updateThinking(false); logger.debug('[claudeRemote] Result received, exiting claudeRemote'); + // Forward result with cost data + if (opts.onResult) { + opts.onResult(message as SDKResultMessage); + } + // Send completion messages if (isCompactCommand) { logger.debug('[claudeRemote] Compaction completed'); diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index 81e6454a..79294262 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -15,6 +15,9 @@ import { EnhancedMode } from "./loop"; import { RawJSONLines } from "@/claude/types"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { getToolName } from "./utils/getToolName"; +import { parseOptions } from '@/utils/parseOptions'; +import { Coordinator, type CoordinatorTask, type CoordinatorState } from '@/claude/coordinator'; + interface PermissionsField { date: number; @@ -99,6 +102,96 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Create permission handler const permissionHandler = new PermissionHandler(session); + // Create coordinator for auto-pilot task management + const coordinator = new Coordinator(); + + // Wire coordinator to inject messages via the same path as user messages + coordinator.onNextTask((prompt) => { + session.queue.push(prompt, { + permissionMode: 'bypassPermissions', + } as EnhancedMode); + }); + + // Coordinator RPC handlers + session.client.rpcHandlerManager.registerHandler< + { tasks: Array<{ prompt: string; label?: string }> }, + { success: boolean; tasks: CoordinatorTask[] } + >('coordinator:set-tasks', async (params) => { + coordinator.clearPending(); + const tasks = coordinator.addTasks(params.tasks); + coordinator.enable(); + return { success: true, tasks }; + }); + + session.client.rpcHandlerManager.registerHandler< + { prompt: string; label?: string }, + { success: boolean; task: CoordinatorTask } + >('coordinator:add-task', async (params) => { + const task = coordinator.addTask(params.prompt, params.label); + return { success: true, task }; + }); + + session.client.rpcHandlerManager.registerHandler< + { id: string }, + { success: boolean } + >('coordinator:remove-task', async (params) => { + const success = coordinator.removeTask(params.id); + return { success }; + }); + + session.client.rpcHandlerManager.registerHandler< + void, + CoordinatorState + >('coordinator:get-state', async () => { + return coordinator.getState(); + }); + + session.client.rpcHandlerManager.registerHandler< + { enabled: boolean }, + { success: boolean } + >('coordinator:toggle', async (params) => { + if (params.enabled) { + coordinator.enable(); + } else { + coordinator.disable(); + } + return { success: true }; + }); + + session.client.rpcHandlerManager.registerHandler< + void, + { success: boolean } + >('coordinator:clear', async () => { + coordinator.clearPending(); + return { success: true }; + }); + + session.client.rpcHandlerManager.registerHandler< + void, + { success: boolean; task?: CoordinatorTask } + >('coordinator:dispatch-next', async () => { + const dispatched = coordinator.dispatchNext(); + if (dispatched) { + const state = coordinator.getState(); + const running = state.tasks.find(t => t.status === 'running'); + session.api.push().sendToAllDevices( + 'Auto-pilot', + `Running task: ${running?.label || running?.prompt.slice(0, 60) || 'next task'} (${coordinator.pendingCount()} remaining)`, + { sessionId: session.client.sessionId } + ); + return { success: true, task: running }; + } + return { success: false }; + }); + + // Emit coordinator state changes to mobile app + coordinator.onStateChanged((state) => { + session.client.sendSessionEvent({ + type: 'coordinator-state', + ...state, + }); + }); + // Create outgoing message queue const messageQueue = new OutgoingMessageQueue( (logMessage) => session.client.sendClaudeSessionMessage(logMessage) @@ -109,6 +202,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | messageQueue.releaseToolCall(toolCallId); }); + // Create SDK to Log converter (pass responses from permissions) const sdkToLogConverter = new SDKToLogConverter({ sessionId: session.sessionId || 'unknown', @@ -120,6 +214,16 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Handle messages let planModeToolCalls = new Set(); let ongoingToolCalls = new Map(); + let lastAssistantText = ''; + let sessionTitle = ''; + let lastResult: any = null; + const sessionStartTime = Date.now(); + + // Track tools/files for session recap summary + const filesRead = new Set(); + const filesModified = new Set(); + const commandsRun: string[] = []; + const toolsUsed = new Set(); function onMessage(message: SDKMessage) { @@ -154,6 +258,54 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } } } + + // Track session title and tool usage for recap summary + if (message.type === 'assistant') { + const umessage = message as SDKAssistantMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (const c of umessage.message.content) { + if (c.type === 'tool_use') { + const input = c.input as Record | undefined; + + // Track session title + if (c.name === 'mcp__happy__change_title' && input) { + sessionTitle = input.title || sessionTitle; + } + + // Track tool usage for recap summary + if (c.name) { + toolsUsed.add(c.name); + } + if (input) { + const stripCwd = (p: string) => p.startsWith(session.path) ? p.slice(session.path.length + 1) : p; + if (c.name === 'Read' && input.file_path) { + filesRead.add(stripCwd(input.file_path)); + } else if ((c.name === 'Edit' || c.name === 'Write') && input.file_path) { + filesModified.add(stripCwd(input.file_path)); + } else if (c.name === 'Bash' && input.command) { + const cmd = String(input.command).length > 120 + ? String(input.command).slice(0, 120) + '...' + : String(input.command); + commandsRun.push(cmd); + } + } + } + } + } + } + + // Track last assistant text for options extraction + if (message.type === 'assistant') { + const umessage = message as SDKAssistantMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (const c of umessage.message.content) { + if (c.type === 'text' && typeof c.text === 'string') { + lastAssistantText = c.text; + } + } + } + } + if (message.type === 'user') { let umessage = message as SDKUserMessage; if (umessage.message.content && Array.isArray(umessage.message.content)) { @@ -381,15 +533,97 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | logger.debug('[remote]: Session reset'); session.clearSessionId(); }, + onResult: (result) => { + if (result.total_cost_usd !== undefined) { + session.client.sendCostData(result.total_cost_usd, result.usage); + } + lastResult = result; + + // Post session recap to feed (fire-and-forget) + if (session.client.sessionId) { + const duration = Date.now() - sessionStartTime; + const model = Object.keys(result.modelUsage || {})[0] || 'claude'; + session.api.postFeedItem({ + kind: 'session_recap', + title: sessionTitle || session.path.split('/').pop() || 'Session', + duration, + cost: result.total_cost_usd || 0, + model, + turns: result.num_turns || 0, + sessionId: session.client.sessionId, + inputTokens: result.usage?.input_tokens, + outputTokens: result.usage?.output_tokens, + cacheReadTokens: result.usage?.cache_read_input_tokens, + cacheCreationTokens: result.usage?.cache_creation_input_tokens, + summary: { + filesRead: [...filesRead].slice(0, 20), + filesModified: [...filesModified].slice(0, 20), + commands: commandsRun.slice(0, 15), + toolsUsed: [...toolsUsed], + }, + }, `session-recap:${session.client.sessionId}`).catch(err => + logger.debug('[remote]: Failed to post session recap:', err) + ); + } + }, onReady: () => { + session.client.closeClaudeSessionTurn('completed'); if (!pending && session.queue.size() === 0) { - session.client.sendSessionEvent({ type: 'ready' }); + // Try coordinator first — if it dispatches a task, skip notification + if (coordinator.onClaudeIdle()) { + // Live Activity updated via coordinator.onStateChanged + const state = coordinator.getState(); + const running = state.tasks.find(t => t.status === 'running'); + session.api.push().sendToAllDevices( + 'Auto-pilot', + `Running task: ${running?.label || running?.prompt.slice(0, 60) || 'next task'} (${coordinator.pendingCount()} remaining)`, + { sessionId: session.client.sessionId } + ); + lastAssistantText = ''; + return; + } + + // Check if coordinator just finished all tasks + const cState = coordinator.getState(); + if (cState.tasks.length > 0 && coordinator.pendingCount() === 0 && !cState.tasks.some(t => t.status === 'running')) { + const completedCount = cState.tasks.filter(t => t.status === 'completed').length; + const failedCount = cState.tasks.filter(t => t.status === 'failed').length; + const summary = failedCount > 0 + ? `${completedCount} completed, ${failedCount} failed.` + : `All ${completedCount} tasks finished.`; + session.api.push().sendToAllDevices( + 'Auto-pilot complete!', + summary, + { sessionId: session.client.sessionId } + ); + } + + const options = parseOptions(lastAssistantText); + const hasOptions = options.length > 0; + const plainText = lastAssistantText + .replace(/[\s\S]*<\/options>/g, '') + .replace(/```[\s\S]*?```/g, '[code]') + .replace(/[*_~`#>\[\]]/g, '') + .replace(/\n+/g, ' ') + .trim(); + const body = hasOptions + ? options.map((opt, i) => `${i + 1}. ${opt}`).join('\n') + : plainText.length > 0 + ? plainText.slice(0, 200) + (plainText.length > 200 ? '...' : '') + : 'Claude is waiting for your command'; session.api.push().sendToAllDevices( 'It\'s ready!', - `Claude is waiting for your command`, - { sessionId: session.client.sessionId } + body, + { + sessionId: session.client.sessionId, + ...(hasOptions && { + options, + categoryIdentifier: 'claude-options', + }), + } ); } + lastAssistantText = ''; }, signal: abortController.signal, }); @@ -398,11 +632,15 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | session.consumeOneTimeFlags(); if (!exitReason && abortController.signal.aborted) { + session.client.closeClaudeSessionTurn('cancelled'); session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } } catch (e) { logger.debug('[remote]: launch error', e); + // Mark any running coordinator task as failed + coordinator.markCurrentFailed(e instanceof Error ? e.message : 'Process exited unexpectedly'); if (!exitReason) { + session.client.closeClaudeSessionTurn('failed'); session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); continue; } @@ -458,4 +696,4 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } return exitReason || 'exit'; -} \ No newline at end of file +} diff --git a/src/claude/coordinator/Coordinator.test.ts b/src/claude/coordinator/Coordinator.test.ts new file mode 100644 index 00000000..75db272c --- /dev/null +++ b/src/claude/coordinator/Coordinator.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Coordinator } from './Coordinator'; + +describe('Coordinator', () => { + it('adds tasks and returns them in getState', () => { + const c = new Coordinator(); + const task = c.addTask('Build the login page', 'Login'); + expect(task.status).toBe('pending'); + expect(c.getState().tasks).toHaveLength(1); + expect(c.getState().tasks[0].label).toBe('Login'); + }); + + it('dispatches next pending task on idle when enabled', () => { + const c = new Coordinator(); + const handler = vi.fn(); + c.onNextTask(handler); + c.enable(); + + c.addTask('Task 1'); + c.addTask('Task 2'); + + const dispatched = c.onClaudeIdle(); + expect(dispatched).toBe(true); + expect(handler).toHaveBeenCalledWith('Task 1'); + expect(c.getState().tasks[0].status).toBe('running'); + }); + + it('does not dispatch when disabled', () => { + const c = new Coordinator(); + const handler = vi.fn(); + c.onNextTask(handler); + + c.addTask('Task 1'); + const dispatched = c.onClaudeIdle(); + expect(dispatched).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it('marks running task as completed on next idle', () => { + const c = new Coordinator(); + const handler = vi.fn(); + c.onNextTask(handler); + c.enable(); + + c.addTask('Task 1'); + c.addTask('Task 2'); + + c.onClaudeIdle(); // Dispatches Task 1 + c.onClaudeIdle(); // Completes Task 1, dispatches Task 2 + + const tasks = c.getState().tasks; + expect(tasks[0].status).toBe('completed'); + expect(tasks[1].status).toBe('running'); + }); + + it('returns false when no pending tasks remain', () => { + const c = new Coordinator(); + c.onNextTask(vi.fn()); + c.enable(); + + c.addTask('Only task'); + c.onClaudeIdle(); // Dispatches + c.onClaudeIdle(); // Completes, nothing next + + expect(c.onClaudeIdle()).toBe(false); + }); + + it('removes pending tasks but not running ones', () => { + const c = new Coordinator(); + c.onNextTask(vi.fn()); + c.enable(); + + const t1 = c.addTask('Task 1'); + const t2 = c.addTask('Task 2'); + + c.onClaudeIdle(); // t1 is running + expect(c.removeTask(t1.id)).toBe(false); + expect(c.removeTask(t2.id)).toBe(true); + }); + + it('prepends taskPrefix to prompts when configured', () => { + const c = new Coordinator({ taskPrefix: 'You are an expert developer.' }); + const handler = vi.fn(); + c.onNextTask(handler); + c.enable(); + + c.addTask('Build X'); + c.onClaudeIdle(); + + expect(handler).toHaveBeenCalledWith('You are an expert developer.\n\nBuild X'); + }); + + it('clearPending removes only pending tasks', () => { + const c = new Coordinator(); + c.onNextTask(vi.fn()); + c.enable(); + + c.addTask('Task 1'); + c.addTask('Task 2'); + c.addTask('Task 3'); + + c.onClaudeIdle(); // Task 1 running + c.clearPending(); // Remove Task 2 and 3 + + expect(c.getState().tasks).toHaveLength(1); + expect(c.pendingCount()).toBe(0); + }); + + it('addTasks adds multiple tasks at once', () => { + const c = new Coordinator(); + const tasks = c.addTasks([ + { prompt: 'Task A', label: 'A' }, + { prompt: 'Task B', label: 'B' }, + ]); + expect(tasks).toHaveLength(2); + expect(c.getState().tasks).toHaveLength(2); + }); + + it('hasPendingTasks returns correct value', () => { + const c = new Coordinator(); + expect(c.hasPendingTasks()).toBe(false); + c.addTask('Something'); + expect(c.hasPendingTasks()).toBe(true); + }); +}); diff --git a/src/claude/coordinator/Coordinator.ts b/src/claude/coordinator/Coordinator.ts new file mode 100644 index 00000000..204404bf --- /dev/null +++ b/src/claude/coordinator/Coordinator.ts @@ -0,0 +1,200 @@ +/** + * Coordinator manages a task queue and auto-feeds tasks to Claude sessions. + * Tasks are added via RPC from the mobile app and executed sequentially. + */ + +import { randomUUID } from 'node:crypto'; +import { logger } from '@/ui/logger'; +import type { CoordinatorTask, CoordinatorState, CoordinatorConfig } from './types'; + +export class Coordinator { + private tasks: CoordinatorTask[] = []; + private enabled = false; + private config: CoordinatorConfig; + private onTaskReady?: (prompt: string) => void; + private onStateChange?: (state: CoordinatorState) => void; + + constructor(config?: Partial) { + this.config = { + autoAdvance: true, + ...config, + }; + } + + /** Register callback for when a task should be sent to Claude */ + onNextTask(handler: (prompt: string) => void) { + this.onTaskReady = handler; + } + + /** Register callback for state changes */ + onStateChanged(handler: (state: CoordinatorState) => void) { + this.onStateChange = handler; + } + + private emitStateChange() { + if (this.onStateChange) { + this.onStateChange(this.getState()); + } + } + + /** Add a task to the queue */ + addTask(prompt: string, label?: string): CoordinatorTask { + const task: CoordinatorTask = { + id: randomUUID(), + prompt, + status: 'pending', + createdAt: Date.now(), + label, + }; + this.tasks.push(task); + logger.debug(`[coordinator] Task added: ${task.id} "${label || prompt.slice(0, 50)}"`); + this.emitStateChange(); + return task; + } + + /** Add multiple tasks at once */ + addTasks(tasks: Array<{ prompt: string; label?: string }>): CoordinatorTask[] { + return tasks.map(t => this.addTask(t.prompt, t.label)); + } + + /** Remove a task by ID */ + removeTask(id: string): boolean { + const idx = this.tasks.findIndex(t => t.id === id); + if (idx === -1) return false; + const task = this.tasks[idx]; + if (task.status === 'running') return false; + this.tasks.splice(idx, 1); + logger.debug(`[coordinator] Task removed: ${id}`); + this.emitStateChange(); + return true; + } + + /** Clear all pending tasks */ + clearPending() { + this.tasks = this.tasks.filter(t => t.status === 'running' || t.status === 'completed'); + logger.debug('[coordinator] Pending tasks cleared'); + this.emitStateChange(); + } + + /** Enable the coordinator */ + enable() { + this.enabled = true; + logger.debug('[coordinator] Enabled'); + this.emitStateChange(); + } + + /** Disable the coordinator */ + disable() { + this.enabled = false; + logger.debug('[coordinator] Disabled'); + this.emitStateChange(); + } + + /** Get current state for the mobile app */ + getState(): CoordinatorState { + return { + enabled: this.enabled, + tasks: [...this.tasks], + }; + } + + /** + * Called when Claude becomes idle (onReady + empty queue). + * If enabled and tasks are pending, fires the next task. + * Returns true if a task was dispatched. + */ + onClaudeIdle(): boolean { + if (!this.enabled || !this.config.autoAdvance) return false; + + // Mark current running task as completed + const running = this.tasks.find(t => t.status === 'running'); + if (running) { + running.status = 'completed'; + running.completedAt = Date.now(); + logger.debug(`[coordinator] Task completed: ${running.id}`); + } + + // Find next pending task + const next = this.tasks.find(t => t.status === 'pending'); + if (!next) { + logger.debug('[coordinator] No more pending tasks'); + this.emitStateChange(); + return false; + } + + // Dispatch it + next.status = 'running'; + next.startedAt = Date.now(); + const prompt = this.config.taskPrefix + ? `${this.config.taskPrefix}\n\n${next.prompt}` + : next.prompt; + + logger.debug(`[coordinator] Dispatching task: ${next.id} "${next.label || next.prompt.slice(0, 50)}"`); + + this.emitStateChange(); + + if (this.onTaskReady) { + this.onTaskReady(prompt); + } + + return true; + } + + /** + * Manually dispatch the next pending task, bypassing enabled/autoAdvance checks. + * Won't dispatch if a task is already running. + * Returns true if a task was dispatched. + */ + dispatchNext(): boolean { + // Don't dispatch if a task is already running + if (this.tasks.some(t => t.status === 'running')) { + logger.debug('[coordinator] Cannot dispatch — a task is already running'); + return false; + } + + const next = this.tasks.find(t => t.status === 'pending'); + if (!next) { + logger.debug('[coordinator] No pending tasks to dispatch'); + return false; + } + + next.status = 'running'; + next.startedAt = Date.now(); + const prompt = this.config.taskPrefix + ? `${this.config.taskPrefix}\n\n${next.prompt}` + : next.prompt; + + logger.debug(`[coordinator] Manual dispatch: ${next.id} "${next.label || next.prompt.slice(0, 50)}"`); + this.emitStateChange(); + + if (this.onTaskReady) { + this.onTaskReady(prompt); + } + + return true; + } + + /** + * Mark the currently running task as failed. + * Called when Claude exits with an error or the session crashes. + */ + markCurrentFailed(error?: string): void { + const running = this.tasks.find(t => t.status === 'running'); + if (!running) return; + + running.status = 'failed'; + running.completedAt = Date.now(); + logger.debug(`[coordinator] Task failed: ${running.id} ${error ? `— ${error}` : ''}`); + this.emitStateChange(); + } + + /** Check if coordinator has pending work */ + hasPendingTasks(): boolean { + return this.tasks.some(t => t.status === 'pending'); + } + + /** Get count of pending tasks */ + pendingCount(): number { + return this.tasks.filter(t => t.status === 'pending').length; + } +} diff --git a/src/claude/coordinator/index.ts b/src/claude/coordinator/index.ts new file mode 100644 index 00000000..4af61b0e --- /dev/null +++ b/src/claude/coordinator/index.ts @@ -0,0 +1,2 @@ +export { Coordinator } from './Coordinator'; +export type { CoordinatorTask, CoordinatorState, CoordinatorConfig } from './types'; diff --git a/src/claude/coordinator/types.ts b/src/claude/coordinator/types.ts new file mode 100644 index 00000000..7a0223f5 --- /dev/null +++ b/src/claude/coordinator/types.ts @@ -0,0 +1,35 @@ +/** + * Coordinator types for auto-pilot task management. + * The Coordinator feeds tasks to Claude when it becomes idle. + */ + +export interface CoordinatorTask { + /** Unique task ID (UUID) */ + id: string; + /** The prompt/instruction to send to Claude */ + prompt: string; + /** Task status */ + status: 'pending' | 'running' | 'completed' | 'failed'; + /** When the task was created */ + createdAt: number; + /** When the task started running */ + startedAt?: number; + /** When the task completed */ + completedAt?: number; + /** Optional label for display in the app */ + label?: string; +} + +export interface CoordinatorState { + /** Whether the coordinator is active */ + enabled: boolean; + /** The task queue */ + tasks: CoordinatorTask[]; +} + +export interface CoordinatorConfig { + /** Auto-send next task when Claude is idle */ + autoAdvance: boolean; + /** Optional system prompt prefix for coordinator tasks */ + taskPrefix?: string; +} diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index bcdd74fd..7fa753b9 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -240,7 +240,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Forward messages to the queue // Permission modes: Use the unified 7-mode type, mapping happens at SDK boundary in claudeRemote.ts - let currentPermissionMode: PermissionMode | undefined = options.permissionMode; + const currentPermissionMode: PermissionMode = 'bypassPermissions'; let currentModel = options.model; // Track current model state let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt @@ -249,14 +249,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools session.onUserMessage((message) => { - // Resolve permission mode from meta - pass through as-is, mapping happens at SDK boundary - let messagePermissionMode: PermissionMode | undefined = currentPermissionMode; - if (message.meta?.permissionMode) { - messagePermissionMode = message.meta.permissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`); - } else { - logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`); + // Permission mode is always bypassPermissions - ignore any overrides from app + const messagePermissionMode: PermissionMode = 'bypassPermissions'; + if (message.meta?.permissionMode && message.meta.permissionMode !== 'bypassPermissions') { + logger.debug(`[loop] Ignoring permission mode override from app: ${message.meta.permissionMode}, forcing bypassPermissions`); } // Resolve model - use message.meta.model if provided, otherwise use current model diff --git a/src/claude/sdk/query.ts b/src/claude/sdk/query.ts index 5ec76736..ed6f4d88 100644 --- a/src/claude/sdk/query.ts +++ b/src/claude/sdk/query.ts @@ -340,7 +340,10 @@ export function query(config: { // Spawn Claude Code process // Use clean env for global claude to avoid local node_modules/.bin taking precedence - const spawnEnv = isCommandOnly ? getCleanEnv() : process.env + const spawnEnv = isCommandOnly ? getCleanEnv() : { ...process.env } + // Remove Claude Code nesting detection vars - Happy CLI legitimately spawns Claude as a subprocess + delete spawnEnv.CLAUDECODE + delete spawnEnv.CLAUDE_CODE_ENTRYPOINT logDebug(`Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(' ')} (using ${isCommandOnly ? 'clean' : 'normal'} env)`) const child = spawn(spawnCommand, spawnArgs, { diff --git a/src/claude/utils/permissionHandler.ts b/src/claude/utils/permissionHandler.ts index 1f8d7b8c..b755c10e 100644 --- a/src/claude/utils/permissionHandler.ts +++ b/src/claude/utils/permissionHandler.ts @@ -342,6 +342,7 @@ export class PermissionHandler { this.allowedTools.clear(); this.allowedBashLiterals.clear(); this.allowedBashPrefixes.clear(); + this.permissionMode = 'default'; // Cancel all pending requests for (const [, pending] of this.pendingRequests.entries()) { diff --git a/src/claude/utils/startHappyServer.ts b/src/claude/utils/startHappyServer.ts index 9a1bb21b..1f8ebe74 100644 --- a/src/claude/utils/startHappyServer.ts +++ b/src/claude/utils/startHappyServer.ts @@ -12,18 +12,33 @@ import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; +/** + * Set the terminal window title using OSC escape sequences. + * Works with iTerm2, Terminal.app, and most modern terminal emulators. + * Uses OSC 0 which sets both window title and icon name. + * Only writes if stdout is a TTY to avoid EPIPE in remote/daemon sessions. + */ +function setTerminalTitle(title: string): void { + if (process.stdout.isTTY) { + process.stdout.write(`\x1b]0;${title}\x07`); + } +} + export async function startHappyServer(client: ApiSessionClient) { // Handler that sends title updates via the client const handler = async (title: string) => { logger.debug('[happyMCP] Changing title to:', title); try { - // Send title as a summary message, similar to title generator + // Set the terminal window title (iTerm2, Terminal.app, etc.) + setTerminalTitle(title); + + // Also send title as a summary message to Happy server (for mobile app) client.sendClaudeSessionMessage({ type: 'summary', summary: title, leafUuid: randomUUID() }); - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 75889d14..0daa250b 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -17,7 +17,7 @@ import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquire import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; -import { readFileSync } from 'fs'; +import { readFileSync, existsSync, writeFileSync } from 'fs'; import { join } from 'path'; import { projectPath } from '@/projectPath'; import { getTmuxUtilities, isTmuxAvailable, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier } from '@/utils/tmux'; @@ -33,6 +33,61 @@ export const initialMachineMetadata: MachineMetadata = { happyLibDir: projectPath() }; +// --- Session tracking persistence --- +// Persists tracked sessions to disk so they survive daemon restarts. +// The file is a simple JSON array of { pid, happySessionId, startedBy }. + +type PersistedSession = { pid: number; happySessionId: string; startedBy: string }; + +const trackedSessionsFile = join(configuration.happyHomeDir, 'tracked-sessions.json'); + +function persistTrackedSessions(pidToTrackedSession: Map): void { + try { + const entries: PersistedSession[] = []; + for (const [pid, session] of pidToTrackedSession) { + if (session.happySessionId) { + entries.push({ pid, happySessionId: session.happySessionId, startedBy: session.startedBy }); + } + } + writeFileSync(trackedSessionsFile, JSON.stringify(entries)); + } catch (error) { + logger.debug('[SESSION PERSIST] Failed to write tracked sessions:', error); + } +} + +function recoverTrackedSessions(pidToTrackedSession: Map): number { + if (!existsSync(trackedSessionsFile)) return 0; + + try { + const data = readFileSync(trackedSessionsFile, 'utf-8'); + const entries: PersistedSession[] = JSON.parse(data); + let recovered = 0; + + for (const entry of entries) { + if (pidToTrackedSession.has(entry.pid)) continue; + + // Check if process is still alive + try { + process.kill(entry.pid, 0); + } catch { + continue; + } + + pidToTrackedSession.set(entry.pid, { + startedBy: entry.startedBy, + happySessionId: entry.happySessionId, + pid: entry.pid + }); + recovered++; + } + + return recovered; + } catch (error) { + logger.debug('[SESSION PERSIST] Failed to read tracked sessions:', error); + return 0; + } +} + // Get environment variables for a profile, filtered for agent compatibility async function getProfileEnvironmentVariablesForAgent( profileId: string, @@ -202,6 +257,7 @@ export async function startDaemon(): Promise { awaiter(existingSession); logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`); } + persistTrackedSessions(pidToTrackedSession); } else if (!existingSession) { // New session started externally const trackedSession: TrackedSession = { @@ -212,6 +268,7 @@ export async function startDaemon(): Promise { }; pidToTrackedSession.set(pid, trackedSession); logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); + persistTrackedSessions(pidToTrackedSession); } }; @@ -621,6 +678,7 @@ export async function startDaemon(): Promise { } pidToTrackedSession.delete(pid); + persistTrackedSessions(pidToTrackedSession); logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`); return true; } @@ -634,6 +692,7 @@ export async function startDaemon(): Promise { const onChildExited = (pid: number) => { logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`); pidToTrackedSession.delete(pid); + persistTrackedSessions(pidToTrackedSession); }; // Start control server @@ -675,6 +734,12 @@ export async function startDaemon(): Promise { }); logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`); + // Recover sessions from previous daemon instance + const recoveredCount = recoverTrackedSessions(pidToTrackedSession); + if (recoveredCount > 0) { + logger.debug(`[DAEMON RUN] Recovered ${recoveredCount} session(s) from previous daemon instance`); + } + // Create realtime machine session const apiMachine = api.machineSyncClient(machine); @@ -716,6 +781,7 @@ export async function startDaemon(): Promise { pidToTrackedSession.delete(pid); } } + persistTrackedSessions(pidToTrackedSession); // Check if daemon needs update // If version on disk is different from the one in package.json - we need to restart diff --git a/src/modules/common/pathSecurity.ts b/src/modules/common/pathSecurity.ts index f8f82720..e91b24d2 100644 --- a/src/modules/common/pathSecurity.ts +++ b/src/modules/common/pathSecurity.ts @@ -1,4 +1,5 @@ import { resolve } from 'path'; +import { realpathSync } from 'fs'; export interface PathValidationResult { valid: boolean; @@ -6,24 +7,52 @@ export interface PathValidationResult { } /** - * Validates that a path is within the allowed working directory + * Validates that a path is within any of the allowed directories. + * Resolves symlinks to prevent traversal via symbolic links. * @param targetPath - The path to validate (can be relative or absolute) * @param workingDirectory - The session's working directory (must be absolute) + * @param additionalAllowedDirs - Extra absolute directories that are also permitted * @returns Validation result */ -export function validatePath(targetPath: string, workingDirectory: string): PathValidationResult { - // Resolve both paths to absolute paths to handle path traversal attempts +export function validatePath(targetPath: string, workingDirectory: string, additionalAllowedDirs?: string[]): PathValidationResult { const resolvedTarget = resolve(workingDirectory, targetPath); - const resolvedWorkingDir = resolve(workingDirectory); - // Check if the resolved target path starts with the working directory - // This prevents access to files outside the working directory - if (!resolvedTarget.startsWith(resolvedWorkingDir + '/') && resolvedTarget !== resolvedWorkingDir) { - return { - valid: false, - error: `Access denied: Path '${targetPath}' is outside the working directory` - }; + // Resolve symlinks to get the true filesystem path. + // This prevents symlink-based traversal (e.g., ln -s /etc/passwd /tmp/happy/uploads/evil.jpg) + let realTarget: string; + try { + realTarget = realpathSync(resolvedTarget); + } catch { + // File doesn't exist yet (e.g., new file being written) — validate the parent dir instead + const parentDir = resolve(resolvedTarget, '..'); + try { + realTarget = realpathSync(parentDir) + '/' + resolvedTarget.split('/').pop(); + } catch { + // Parent doesn't exist either — fall back to the resolved path (mkdir -p will create it) + realTarget = resolvedTarget; + } } - return { valid: true }; + // Collect all directories the path is allowed to live under. + // Resolve symlinks on allowed dirs so comparisons are consistent with the symlink-resolved realTarget. + const allowedDirs = [workingDirectory, ...(additionalAllowedDirs ?? [])].map(d => { + const resolved = resolve(d); + try { + return realpathSync(resolved); + } catch { + // Directory may not exist yet (e.g., upload dir before first upload) — fall back to resolve() + return resolved; + } + }); + + for (const dir of allowedDirs) { + if (realTarget.startsWith(dir + '/') || realTarget === dir) { + return { valid: true }; + } + } + + return { + valid: false, + error: `Access denied: Path '${targetPath}' is outside the allowed directories` + }; } diff --git a/src/modules/common/registerCommonHandlers.ts b/src/modules/common/registerCommonHandlers.ts index bd4e07a5..4ab55847 100644 --- a/src/modules/common/registerCommonHandlers.ts +++ b/src/modules/common/registerCommonHandlers.ts @@ -1,9 +1,10 @@ import { logger } from '@/ui/logger'; import { exec, ExecOptions } from 'child_process'; import { promisify } from 'util'; -import { readFile, writeFile, readdir, stat } from 'fs/promises'; +import { readFile, writeFile, readdir, stat, mkdir } from 'fs/promises'; import { createHash } from 'crypto'; -import { join } from 'path'; +import { dirname, join, resolve } from 'path'; +import { tmpdir } from 'os'; import { run as runRipgrep } from '@/modules/ripgrep/index'; import { run as runDifftastic } from '@/modules/difftastic/index'; import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager'; @@ -11,6 +12,12 @@ import { validatePath } from './pathSecurity'; const execAsync = promisify(exec); +/** Scoped temp directory for uploads from the mobile app. Allowed in addition to workingDirectory. */ +const UPLOAD_TEMP_DIR = join(tmpdir(), 'happy', 'uploads'); + +/** Maximum file size for writeFile RPC (10 MB base64 ≈ 7.5 MB decoded). */ +const MAX_WRITE_SIZE = 10 * 1024 * 1024; + interface BashRequest { command: string; cwd?: string; @@ -64,6 +71,12 @@ interface ListDirectoryResponse { error?: string; } +interface GetUploadDirResponse { + success: boolean; + path?: string; + error?: string; +} + interface GetDirectoryTreeRequest { path: string; maxDepth: number; @@ -145,7 +158,9 @@ export type SpawnSessionResult = /** * Register all RPC handlers with the session */ -export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string) { +export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string, sessionId: string) { + // Sanitize sessionId to prevent path traversal when used in filesystem paths + const safeSessionId = sessionId.replace(/[^a-zA-Z0-9-]/g, ''); // Shell command handler - executes commands in the default shell rpcHandlerManager.registerHandler('bash', async (data) => { @@ -235,14 +250,16 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor rpcHandlerManager.registerHandler('readFile', async (data) => { logger.debug('Read file request:', data.path); - // Validate path is within working directory - const validation = validatePath(data.path, workingDirectory); + // Validate path — scoped to this session's upload subdirectory (not the global upload dir) + const sessionUploadDir = join(UPLOAD_TEMP_DIR, safeSessionId); + const validation = validatePath(data.path, workingDirectory, [sessionUploadDir]); if (!validation.valid) { return { success: false, error: validation.error }; } try { - const buffer = await readFile(data.path); + const resolvedPath = resolve(workingDirectory, data.path); + const buffer = await readFile(resolvedPath); const content = buffer.toString('base64'); return { success: true, content }; } catch (error) { @@ -255,17 +272,26 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor rpcHandlerManager.registerHandler('writeFile', async (data) => { logger.debug('Write file request:', data.path); - // Validate path is within working directory - const validation = validatePath(data.path, workingDirectory); + // Enforce file size limit to prevent abuse + if (data.content && data.content.length > MAX_WRITE_SIZE) { + return { success: false, error: `File content exceeds maximum allowed size (${MAX_WRITE_SIZE} bytes)` }; + } + + // Validate path — scoped to this session's upload subdirectory + const sessionUploadDir = join(UPLOAD_TEMP_DIR, safeSessionId); + const validation = validatePath(data.path, workingDirectory, [sessionUploadDir]); if (!validation.valid) { return { success: false, error: validation.error }; } try { + // Resolve path relative to working directory + const resolvedPath = resolve(workingDirectory, data.path); + // If expectedHash is provided (not null), verify existing file if (data.expectedHash !== null && data.expectedHash !== undefined) { try { - const existingBuffer = await readFile(data.path); + const existingBuffer = await readFile(resolvedPath); const existingHash = createHash('sha256').update(existingBuffer).digest('hex'); if (existingHash !== data.expectedHash) { @@ -288,7 +314,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor } else { // expectedHash is null - expecting new file try { - await stat(data.path); + await stat(resolvedPath); // File exists but we expected it to be new return { success: false, @@ -303,9 +329,12 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor } } + // Create parent directories if needed + await mkdir(dirname(resolvedPath), { recursive: true }); + // Write the file const buffer = Buffer.from(data.content, 'base64'); - await writeFile(data.path, buffer); + await writeFile(resolvedPath, buffer); // Calculate and return hash of written file const hash = createHash('sha256').update(buffer).digest('hex'); @@ -317,6 +346,11 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor } }); + // Returns the OS temp upload directory scoped to this session + rpcHandlerManager.registerHandler, GetUploadDirResponse>('getUploadDir', async () => { + return { success: true, path: join(UPLOAD_TEMP_DIR, safeSessionId) }; + }); + // List directory handler rpcHandlerManager.registerHandler('listDirectory', async (data) => { logger.debug('List directory request:', data.path); diff --git a/src/utils/parseOptions.test.ts b/src/utils/parseOptions.test.ts new file mode 100644 index 00000000..513dd728 --- /dev/null +++ b/src/utils/parseOptions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { parseOptions } from './parseOptions'; + +describe('parseOptions', () => { + it('should extract options from XML tags', () => { + const text = `Here is my answer.\n\n\n\n\n\n`; + expect(parseOptions(text)).toEqual(['Option A', 'Option B', 'Option C']); + }); + + it('should return empty array when no options', () => { + expect(parseOptions('Just a normal message')).toEqual([]); + }); + + it('should limit to 4 options (iOS constraint)', () => { + const text = `\n\n\n\n\n\n`; + expect(parseOptions(text)).toEqual(['A', 'B', 'C', 'D']); + }); + + it('should handle whitespace and newlines in options', () => { + const text = `\n \n`; + expect(parseOptions(text)).toEqual(['Trimmed']); + }); +}); diff --git a/src/utils/parseOptions.ts b/src/utils/parseOptions.ts new file mode 100644 index 00000000..3b996a9a --- /dev/null +++ b/src/utils/parseOptions.ts @@ -0,0 +1,21 @@ +const MAX_OPTIONS = 4; + +/** + * Parse XML from Claude's response text. + * Returns up to 4 option strings (iOS notification action limit). + */ +export function parseOptions(text: string): string[] { + const optionsMatch = text.match(/([\s\S]*?)<\/options>/); + if (!optionsMatch) return []; + + const optionMatches = optionsMatch[1].matchAll(/