From bc81475afae848a940290a798c824b0f8a69e763 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 00:09:27 -0700 Subject: [PATCH 01/22] Add Doctor debugging command to debug section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launches an autonomous debugging session in Terminal that: - Analyzes the full debug report - Investigates the user's environment - Proposes prioritized fixes in plan mode - Executes fixes one-by-one with user approval - Iterates until issue is resolved Features: - Uses same command/config as the app (respects preferences) - Runs in plan permission mode for safety - Provides systematic troubleshooting workflow - Helps debug environment/PATH issues Users can now click "Run Doctor" in Settings > Debug to get automated help troubleshooting command execution failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 108 +++++++++++++++++- .../UI/GlobalSettingsView.swift | 40 ++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 010a3f5..bf57946 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -90,7 +90,113 @@ public struct TerminalLauncher { ) } } - + + /// Launches Terminal with a Doctor debugging session + /// - Parameters: + /// - command: The command name to use (from preferences) + /// - additionalPaths: Additional paths from configuration + /// - debugReport: The full debug report to analyze + /// - Returns: An error if launching fails, nil on success + public static func launchDoctorSession( + command: String, + additionalPaths: [String], + debugReport: String + ) -> Error? { + // Find the full path to the executable + guard let claudeExecutablePath = findClaudeExecutable( + command: command, + additionalPaths: additionalPaths + ) else { + return NSError( + domain: "TerminalLauncher", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Could not find '\(command)' command. Please ensure it is installed."] + ) + } + + // Escape paths for shell + let escapedClaudePath = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + // Create doctor system prompt + let doctorPrompt = """ + You are a ClaudeCodeUI Debug Doctor. A user is experiencing issues with their macOS app that uses Claude Code. + + CONTEXT - DEBUG REPORT: + \(debugReport) + + YOUR TASK: + 1. Analyze the debug report and investigate the user's environment + 2. Run diagnostic commands to understand the issue: + - Compare PATH with 'echo $PATH' + - Check shell config: 'cat ~/.zshrc | head -50' + - Test executable: 'which \(command)' and '\(command) --version' + - Check permissions, environment variables, etc. + 3. Identify the root cause of the issue + 4. Propose fixes in priority order (most likely to work first) + + IMPORTANT WORKFLOW: + - First: Investigate thoroughly (read configs, check paths, test commands) + - Then: Create a PLAN with 3-5 concrete, numbered steps + - Wait: Get user approval before executing (you're in plan mode) + - Execute: One step at a time, explain what each does + - Test: After each fix, ask user to restart the app and test + - Iterate: If still broken, ask for new debug report and continue + + Be systematic, clear, and explain your reasoning at each step. + Remember: You're debugging why commands work in Terminal but fail in the macOS app. + """ + + // Escape the prompt for shell + let escapedPrompt = doctorPrompt + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "$", with: "\\$") + .replacingOccurrences(of: "`", with: "\\`") + + // Construct the doctor command with plan permission mode + let homeDir = NSHomeDirectory() + let doctorCommand = """ + cd "\(homeDir)" && echo "\(escapedPrompt)" | "\(escapedClaudePath)" -p --permission-mode plan + """ + + // Create a temporary script file + let tempDir = NSTemporaryDirectory() + let scriptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_\(UUID().uuidString).command") + + // Create the script content + let scriptContent = """ + #!/bin/bash + \(doctorCommand) + """ + + do { + // Write the script to file + try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) + + // Make it executable + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) + + // Open the script with Terminal + let url = URL(fileURLWithPath: scriptPath) + NSWorkspace.shared.open(url) + + // Clean up the script file after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + try? FileManager.default.removeItem(atPath: scriptPath) + } + + return nil + } catch { + return NSError( + domain: "TerminalLauncher", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to launch Doctor session: \(error.localizedDescription)"] + ) + } + } + /// Finds the full path to the Claude executable /// - Parameters: /// - command: The command name to search for (e.g., "claude") diff --git a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift index 7bcbbda..6155296 100644 --- a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift +++ b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift @@ -392,8 +392,22 @@ struct GlobalSettingsView: View { .stroke(hasCommandInfo ? Color.secondary.opacity(0.2) : Color.secondary.opacity(0.15), lineWidth: 1) ) - HStack { + HStack(spacing: 8) { Spacer() + + Button(action: { + runDoctor() + }) { + HStack(spacing: 4) { + Image(systemName: "stethoscope") + Text("Run Doctor") + } + } + .buttonStyle(.bordered) + .disabled(!hasCommandInfo) + .help(hasCommandInfo ? "Launch debugging session in Terminal" : "Send a message first to generate debug info") + .padding(.top, 8) + Button(action: { copyFullReportToClipboard() }) { @@ -762,6 +776,30 @@ struct GlobalSettingsView: View { reportCopied = false } } + + private func runDoctor() { + guard let viewModel = chatViewModel, + let report = viewModel.fullDebugReport else { return } + + // Use the same command and configuration as the app + let command = globalPreferences.claudeCommand + let additionalPaths = viewModel.claudeClient.configuration.additionalPaths + + // Launch the doctor session + if let error = TerminalLauncher.launchDoctorSession( + command: command, + additionalPaths: additionalPaths, + debugReport: report + ) { + // Show error alert + let alert = NSAlert() + alert.messageText = "Failed to Launch Doctor" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } + } } From 5f64dd961d9f5fe4a0a2d9669cade1e3a1090cd3 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 00:30:59 -0700 Subject: [PATCH 02/22] Fix Doctor command execution by using file redirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt was being broken by shell escaping. Now we: - Write prompt to temp file - Use input redirection (< file) instead of echo | pipe - Clean up prompt file after execution This avoids escaping issues with quotes, newlines, and special characters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index bf57946..5d5adc8 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -147,27 +147,34 @@ public struct TerminalLauncher { Remember: You're debugging why commands work in Terminal but fail in the macOS app. """ - // Escape the prompt for shell - let escapedPrompt = doctorPrompt - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "$", with: "\\$") - .replacingOccurrences(of: "`", with: "\\`") + // Write prompt to a temp file for safe piping + let tempDir = NSTemporaryDirectory() + let promptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_prompt_\(UUID().uuidString).txt") + let scriptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_\(UUID().uuidString).command") + + // Write the prompt to the temp file + do { + try doctorPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + } catch { + return NSError( + domain: "TerminalLauncher", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"] + ) + } // Construct the doctor command with plan permission mode let homeDir = NSHomeDirectory() let doctorCommand = """ - cd "\(homeDir)" && echo "\(escapedPrompt)" | "\(escapedClaudePath)" -p --permission-mode plan + cd "\(homeDir)" && "\(escapedClaudePath)" -p --permission-mode plan < "\(promptPath)" """ - // Create a temporary script file - let tempDir = NSTemporaryDirectory() - let scriptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_\(UUID().uuidString).command") - // Create the script content let scriptContent = """ #!/bin/bash \(doctorCommand) + # Clean up prompt file when done + rm -f "\(promptPath)" """ do { From 164fda84d21d179432517ab6334338e8a6dd3031 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 00:42:39 -0700 Subject: [PATCH 03/22] Fix Doctor to use interactive session instead of one-shot prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue was using '-p' flag which exits after processing. Now using: - Interactive session (no -p flag) - --append-system-prompt with command substitution from file - Session stays open for debugging workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ClaudeCodeCore/Services/TerminalLauncher.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 5d5adc8..8fc908f 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -147,7 +147,7 @@ public struct TerminalLauncher { Remember: You're debugging why commands work in Terminal but fail in the macOS app. """ - // Write prompt to a temp file for safe piping + // Write prompt to a temp file let tempDir = NSTemporaryDirectory() let promptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_prompt_\(UUID().uuidString).txt") let scriptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_\(UUID().uuidString).command") @@ -163,17 +163,12 @@ public struct TerminalLauncher { ) } - // Construct the doctor command with plan permission mode + // Construct the doctor command - start interactive session with system prompt from file let homeDir = NSHomeDirectory() - let doctorCommand = """ - cd "\(homeDir)" && "\(escapedClaudePath)" -p --permission-mode plan < "\(promptPath)" - """ - - // Create the script content let scriptContent = """ #!/bin/bash - \(doctorCommand) - # Clean up prompt file when done + cd "\(homeDir)" + "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan rm -f "\(promptPath)" """ From 0ab71beced27ccc650fd8796f732933ebe768591 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 00:51:28 -0700 Subject: [PATCH 04/22] Add initial message to Doctor session to auto-start conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session now automatically sends: "Analyze the debug report and help me troubleshoot this issue. Start by investigating the environment." This kicks off the debugging workflow immediately instead of waiting for user input. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/ClaudeCodeCore/Services/TerminalLauncher.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 8fc908f..3e01310 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -168,7 +168,7 @@ public struct TerminalLauncher { let scriptContent = """ #!/bin/bash cd "\(homeDir)" - "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan + echo "Analyze the debug report and help me troubleshoot this issue. Start by investigating the environment." | "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan rm -f "\(promptPath)" """ From f6ec67590ab387c240af96c73c3c088455c740f1 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 00:55:01 -0700 Subject: [PATCH 05/22] Fix raw mode error by removing stdin pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue: Piping stdin breaks Claude's interactive TUI. Solution: - Removed echo pipe - Added instruction in system prompt to start immediately - Claude now auto-starts investigation when session opens - Keeps interactive mode working (no raw mode error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/ClaudeCodeCore/Services/TerminalLauncher.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 3e01310..35c5c45 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -143,6 +143,9 @@ public struct TerminalLauncher { - Test: After each fix, ask user to restart the app and test - Iterate: If still broken, ask for new debug report and continue + CRITICAL: When this session starts, immediately greet the user and begin your investigation. + Don't wait for user input - start by analyzing the debug report and running diagnostic commands. + Be systematic, clear, and explain your reasoning at each step. Remember: You're debugging why commands work in Terminal but fail in the macOS app. """ @@ -168,7 +171,7 @@ public struct TerminalLauncher { let scriptContent = """ #!/bin/bash cd "\(homeDir)" - echo "Analyze the debug report and help me troubleshoot this issue. Start by investigating the environment." | "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan + "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan rm -f "\(promptPath)" """ From 1fb702ecd77925a6ef02ec0281f7feaa604e056c Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 00:58:24 -0700 Subject: [PATCH 06/22] Doctor: Run diagnostics first, then create plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated system prompt to: - Start by running diagnostic commands immediately (no plan needed) - Only create a plan AFTER understanding the problem - This avoids the initial plan approval blocking auto-start Workflow: investigate → analyze → create plan → get approval → execute fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 35c5c45..86bb494 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -136,15 +136,20 @@ public struct TerminalLauncher { 4. Propose fixes in priority order (most likely to work first) IMPORTANT WORKFLOW: - - First: Investigate thoroughly (read configs, check paths, test commands) - - Then: Create a PLAN with 3-5 concrete, numbered steps - - Wait: Get user approval before executing (you're in plan mode) - - Execute: One step at a time, explain what each does + - First: Investigate thoroughly WITHOUT creating a plan - just run diagnostic commands directly: + * echo $PATH + * which \(command) + * cat ~/.zshrc | head -50 + * \(command) --version + * Compare findings with the debug report + - Then: After investigation is complete, CREATE A PLAN with 3-5 concrete, numbered steps to fix the issue + - Wait: Get user approval before executing fixes (you're in plan mode) + - Execute: One fix at a time, explain what each does - Test: After each fix, ask user to restart the app and test - Iterate: If still broken, ask for new debug report and continue - CRITICAL: When this session starts, immediately greet the user and begin your investigation. - Don't wait for user input - start by analyzing the debug report and running diagnostic commands. + CRITICAL: When this session starts, immediately greet the user and START RUNNING DIAGNOSTIC COMMANDS. + Do NOT create a plan yet - just investigate first. Only create a plan after you understand the problem. Be systematic, clear, and explain your reasoning at each step. Remember: You're debugging why commands work in Terminal but fail in the macOS app. From c17aa974d3d4c031d7529c7cf455841820af7ed5 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 01:11:00 -0700 Subject: [PATCH 07/22] Redesign Doctor with test-first approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New workflow: 1. Run test command with -p to start session and get initial response 2. Extract session ID from output 3. Resume that session interactively in Terminal 4. Session already has context, so it auto-continues Benefits: - Tests command first before investigation - Session auto-starts with initial message - Reuses proven session resume approach - Only investigates if there's actual output to analyze Fallback: If session ID not captured, starts fresh interactive session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 96 +++++++++++++++++++ .../UI/GlobalSettingsView.swift | 48 ++++++---- 2 files changed, 128 insertions(+), 16 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 86bb494..48fcf4e 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -91,6 +91,102 @@ public struct TerminalLauncher { } } + /// Launches Terminal with a Doctor debugging session (test-first approach) + /// - Parameters: + /// - claudeClient: The Claude client for running test command + /// - command: The command name to use + /// - workingDirectory: Working directory for the session + /// - systemPrompt: The doctor system prompt + /// - Returns: An error if launching fails, nil on success + public static func launchDoctorSessionWithTest( + claudeClient: ClaudeCode, + command: String, + workingDirectory: String, + systemPrompt: String + ) async -> Error? { + // Find the full path to the executable + guard let claudeExecutablePath = findClaudeExecutable( + command: command, + additionalPaths: claudeClient.configuration.additionalPaths + ) else { + return NSError( + domain: "TerminalLauncher", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Could not find '\(command)' command. Please ensure it is installed."] + ) + } + + // Write system prompt to temp file + let tempDir = NSTemporaryDirectory() + let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + + do { + try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + } catch { + return NSError( + domain: "TerminalLauncher", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"] + ) + } + + // Escape paths + let escapedClaudePath = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let escapedWorkDir = workingDirectory.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + // Create script that: + // 1. Runs a test command to start session + // 2. Extracts session ID + // 3. Launches Terminal to resume that session + let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launcher_\(UUID().uuidString).command") + let scriptContent = """ + #!/bin/bash + cd "\(escapedWorkDir)" + + # Start a test session and capture output + echo "Starting diagnostic session..." + SESSION_OUTPUT=$("\(escapedClaudePath)" -p "Hello! I need help debugging my environment. Please start by running diagnostic commands." --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan 2>&1) + + # Extract session ID (Claude outputs session ID in specific format) + SESSION_ID=$(echo "$SESSION_OUTPUT" | grep -o "session-[a-zA-Z0-9-]*" | head -1) + + # If we got a session ID, resume it interactively + if [ -n "$SESSION_ID" ]; then + echo "Resuming session: $SESSION_ID" + "\(escapedClaudePath)" -r "$SESSION_ID" + else + echo "Could not capture session ID. Starting fresh interactive session..." + "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan + fi + + # Clean up + rm -f "\(promptPath)" + """ + + do { + try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) + + let url = URL(fileURLWithPath: scriptPath) + NSWorkspace.shared.open(url) + + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + try? FileManager.default.removeItem(atPath: scriptPath) + } + + return nil + } catch { + return NSError( + domain: "TerminalLauncher", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to launch Doctor session: \(error.localizedDescription)"] + ) + } + } + /// Launches Terminal with a Doctor debugging session /// - Parameters: /// - command: The command name to use (from preferences) diff --git a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift index 6155296..b59381f 100644 --- a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift +++ b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift @@ -781,23 +781,39 @@ struct GlobalSettingsView: View { guard let viewModel = chatViewModel, let report = viewModel.fullDebugReport else { return } - // Use the same command and configuration as the app + // Create doctor system prompt let command = globalPreferences.claudeCommand - let additionalPaths = viewModel.claudeClient.configuration.additionalPaths - - // Launch the doctor session - if let error = TerminalLauncher.launchDoctorSession( - command: command, - additionalPaths: additionalPaths, - debugReport: report - ) { - // Show error alert - let alert = NSAlert() - alert.messageText = "Failed to Launch Doctor" - alert.informativeText = error.localizedDescription - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() + let doctorPrompt = """ + You are a ClaudeCodeUI Debug Doctor. Analyze this debug report and help troubleshoot: + + \(report) + + Your task: + 1. First, test if the command actually works by running diagnostic commands + 2. Compare with the debug report to find discrepancies + 3. If you find issues, create a plan to fix them + 4. If everything works, explain that no fixes are needed + + Start by running: echo $PATH, which \(command), and \(command) --version + """ + + // Launch Terminal with doctor session + Task { + if let error = await TerminalLauncher.launchDoctorSessionWithTest( + claudeClient: viewModel.claudeClient, + command: command, + workingDirectory: viewModel.projectPath, + systemPrompt: doctorPrompt + ) { + await MainActor.run { + let alert = NSAlert() + alert.messageText = "Failed to Launch Doctor" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } + } } } } From 39eac8a19b0bd0e58368ce04f236d2ed1b455486 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 11:54:25 -0700 Subject: [PATCH 08/22] Implement Doctor with command execution and session resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow: 1. Get terminalReproductionCommand from debug report 2. Execute that exact command to start a session 3. Parse session_id from JSON output 4. Resume session in Terminal with doctor prompt 5. Claude has full context (command + output + debug report) Benefits: - Tests actual command execution - Reuses session context - Doctor sees real results, not assumptions - Auto-starts with session resume 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 92 +++++++++++++++++++ .../UI/GlobalSettingsView.swift | 24 +++-- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 48fcf4e..819e966 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -91,6 +91,98 @@ public struct TerminalLauncher { } } + /// Launches Terminal with a Doctor session by executing command and resuming + /// - Parameters: + /// - command: The terminal reproduction command to execute + /// - workingDirectory: Working directory for the command + /// - systemPrompt: The doctor system prompt + /// - Returns: An error if launching fails, nil on success + public static func launchDoctorWithCommand( + command: String, + workingDirectory: String, + systemPrompt: String + ) async -> Error? { + // Write system prompt to temp file + let tempDir = NSTemporaryDirectory() + let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + + do { + try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + } catch { + return NSError( + domain: "TerminalLauncher", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"] + ) + } + + // Escape the command for shell script + let escapedCommand = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "$", with: "\\$") + + // Create launcher script + let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launcher_\(UUID().uuidString).command") + let scriptContent = """ + #!/bin/bash + + echo "Executing command to capture session..." + + # Execute the command and capture output + OUTPUT=$(\(escapedCommand)) + + # Extract session ID from first line (JSON format) + SESSION_ID=$(echo "$OUTPUT" | head -1 | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) + + if [ -n "$SESSION_ID" ]; then + echo "Session captured: $SESSION_ID" + echo "Launching Doctor session..." + + # Extract the claude executable path from the command + CLAUDE_PATH=$(echo "\(escapedCommand)" | grep -o '/[^ ]*claude' | head -1) + + if [ -z "$CLAUDE_PATH" ]; then + CLAUDE_PATH="claude" + fi + + # Resume the session with doctor prompt + "$CLAUDE_PATH" -r "$SESSION_ID" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan + else + echo "ERROR: Could not extract session ID from command output" + echo "Output was:" + echo "$OUTPUT" + echo "" + echo "Press Enter to close..." + read + fi + + # Clean up + rm -f "\(promptPath)" + """ + + do { + try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) + + let url = URL(fileURLWithPath: scriptPath) + NSWorkspace.shared.open(url) + + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + try? FileManager.default.removeItem(atPath: scriptPath) + } + + return nil + } catch { + return NSError( + domain: "TerminalLauncher", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to launch Doctor session: \(error.localizedDescription)"] + ) + } + } + /// Launches Terminal with a Doctor debugging session (test-first approach) /// - Parameters: /// - claudeClient: The Claude client for running test command diff --git a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift index b59381f..4f5b2d4 100644 --- a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift +++ b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift @@ -779,29 +779,33 @@ struct GlobalSettingsView: View { private func runDoctor() { guard let viewModel = chatViewModel, - let report = viewModel.fullDebugReport else { return } + let report = viewModel.fullDebugReport, + let reproCommand = viewModel.terminalReproductionCommand else { return } // Create doctor system prompt let command = globalPreferences.claudeCommand let doctorPrompt = """ - You are a ClaudeCodeUI Debug Doctor. Analyze this debug report and help troubleshoot: + You are a ClaudeCodeUI Debug Doctor. + I just executed a command from the app and you have the results in this session's context. + + DEBUG REPORT: \(report) Your task: - 1. First, test if the command actually works by running diagnostic commands - 2. Compare with the debug report to find discrepancies - 3. If you find issues, create a plan to fix them - 4. If everything works, explain that no fixes are needed + 1. Review the command execution that just happened in this session + 2. Compare the output with what's in the debug report + 3. Look for discrepancies (PATH differences, executable location, errors, etc.) + 4. If you find issues, create a plan to fix them + 5. If everything works fine, explain that the app is working correctly - Start by running: echo $PATH, which \(command), and \(command) --version + Start by reviewing the session context to see what the command returned. """ // Launch Terminal with doctor session Task { - if let error = await TerminalLauncher.launchDoctorSessionWithTest( - claudeClient: viewModel.claudeClient, - command: command, + if let error = await TerminalLauncher.launchDoctorWithCommand( + command: reproCommand, workingDirectory: viewModel.projectPath, systemPrompt: doctorPrompt ) { From 3c12600f21bac00a29efaba75efb64f1a36e879b Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 12:01:03 -0700 Subject: [PATCH 09/22] Add separate Doctor function to avoid breaking existing code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created launchDoctorByExecutingCommand(): - Writes reproduction command to temp file (avoids escaping issues) - Executes command and captures JSON output - Extracts session_id from first line - Resumes session in Terminal with doctor prompt This is a separate function so we don't touch the working launchTerminalWithSession() logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 111 ++++++++++++++++-- .../UI/GlobalSettingsView.swift | 5 +- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 819e966..150d86a 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -116,11 +116,18 @@ public struct TerminalLauncher { ) } - // Escape the command for shell script - let escapedCommand = command - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "$", with: "\\$") + // Write the command to a temp file to avoid escaping issues + let commandPath = (tempDir as NSString).appendingPathComponent("doctor_command_\(UUID().uuidString).sh") + + do { + try command.write(toFile: commandPath, atomically: true, encoding: .utf8) + } catch { + return NSError( + domain: "TerminalLauncher", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Failed to write command file: \(error.localizedDescription)"] + ) + } // Create launcher script let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launcher_\(UUID().uuidString).command") @@ -129,8 +136,8 @@ public struct TerminalLauncher { echo "Executing command to capture session..." - # Execute the command and capture output - OUTPUT=$(\(escapedCommand)) + # Execute the command from file and capture output + OUTPUT=$(bash '\(commandPath)' 2>&1) # Extract session ID from first line (JSON format) SESSION_ID=$(echo "$OUTPUT" | head -1 | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) @@ -456,4 +463,94 @@ public struct TerminalLauncher { return nil } + + /// NEW: Launches Doctor by executing reproduction command and resuming session + /// - Parameters: + /// - reproductionCommand: The full command from terminalReproductionCommand + /// - systemPrompt: The doctor system prompt + /// - Returns: An error if launching fails, nil on success + public static func launchDoctorByExecutingCommand( + reproductionCommand: String, + systemPrompt: String + ) async -> Error? { + let tempDir = NSTemporaryDirectory() + + // Write the command to a file (avoids all escaping issues) + let commandPath = (tempDir as NSString).appendingPathComponent("doctor_cmd_\(UUID().uuidString).sh") + let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launch_\(UUID().uuidString).command") + + do { + try reproductionCommand.write(toFile: commandPath, atomically: true, encoding: .utf8) + try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + } catch { + return NSError( + domain: "TerminalLauncher", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Failed to write files: \(error.localizedDescription)"] + ) + } + + // Create the launcher script + let scriptContent = """ + #!/bin/bash + + echo "Executing command to capture session..." + echo "" + + # Execute the command from file and capture output + OUTPUT=$(bash '\(commandPath)' 2>&1) + + # Extract session ID from first line (JSON format) + SESSION_ID=$(echo "$OUTPUT" | head -1 | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) + + if [ -n "$SESSION_ID" ]; then + echo "Session captured: $SESSION_ID" + echo "Launching Doctor session..." + echo "" + + # Extract claude path from the command file + CLAUDE_PATH=$(grep -o '[^ ]*claude' '\(commandPath)' | grep '^/' | head -1) + + if [ -z "$CLAUDE_PATH" ]; then + CLAUDE_PATH="claude" + fi + + # Resume the session with doctor prompt + "$CLAUDE_PATH" -r "$SESSION_ID" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan + else + echo "ERROR: Could not extract session ID" + echo "" + echo "Command output:" + echo "$OUTPUT" + echo "" + echo "Press Enter to close..." + read + fi + + # Clean up + rm -f '\(commandPath)' '\(promptPath)' + """ + + do { + try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) + + let url = URL(fileURLWithPath: scriptPath) + NSWorkspace.shared.open(url) + + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + try? FileManager.default.removeItem(atPath: scriptPath) + } + + return nil + } catch { + return NSError( + domain: "TerminalLauncher", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to launch: \(error.localizedDescription)"] + ) + } + } } diff --git a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift index 4f5b2d4..7f63e5e 100644 --- a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift +++ b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift @@ -804,9 +804,8 @@ struct GlobalSettingsView: View { // Launch Terminal with doctor session Task { - if let error = await TerminalLauncher.launchDoctorWithCommand( - command: reproCommand, - workingDirectory: viewModel.projectPath, + if let error = await TerminalLauncher.launchDoctorByExecutingCommand( + reproductionCommand: reproCommand, systemPrompt: doctorPrompt ) { await MainActor.run { From 52d3d65a7e433d35f0fbce3a8fa7833960de88e4 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 12:11:07 -0700 Subject: [PATCH 10/22] Remove unused doctor launch functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete launchDoctorWithCommand (had escapedCommand error) - Delete launchDoctorSessionWithTest (not used) - Delete launchDoctorSession (not used) - Keep only launchDoctorByExecutingCommand (the working implementation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 310 ------------------ 1 file changed, 310 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 150d86a..c93de8d 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -91,316 +91,6 @@ public struct TerminalLauncher { } } - /// Launches Terminal with a Doctor session by executing command and resuming - /// - Parameters: - /// - command: The terminal reproduction command to execute - /// - workingDirectory: Working directory for the command - /// - systemPrompt: The doctor system prompt - /// - Returns: An error if launching fails, nil on success - public static func launchDoctorWithCommand( - command: String, - workingDirectory: String, - systemPrompt: String - ) async -> Error? { - // Write system prompt to temp file - let tempDir = NSTemporaryDirectory() - let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") - - do { - try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) - } catch { - return NSError( - domain: "TerminalLauncher", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"] - ) - } - - // Write the command to a temp file to avoid escaping issues - let commandPath = (tempDir as NSString).appendingPathComponent("doctor_command_\(UUID().uuidString).sh") - - do { - try command.write(toFile: commandPath, atomically: true, encoding: .utf8) - } catch { - return NSError( - domain: "TerminalLauncher", - code: 4, - userInfo: [NSLocalizedDescriptionKey: "Failed to write command file: \(error.localizedDescription)"] - ) - } - - // Create launcher script - let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launcher_\(UUID().uuidString).command") - let scriptContent = """ - #!/bin/bash - - echo "Executing command to capture session..." - - # Execute the command from file and capture output - OUTPUT=$(bash '\(commandPath)' 2>&1) - - # Extract session ID from first line (JSON format) - SESSION_ID=$(echo "$OUTPUT" | head -1 | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) - - if [ -n "$SESSION_ID" ]; then - echo "Session captured: $SESSION_ID" - echo "Launching Doctor session..." - - # Extract the claude executable path from the command - CLAUDE_PATH=$(echo "\(escapedCommand)" | grep -o '/[^ ]*claude' | head -1) - - if [ -z "$CLAUDE_PATH" ]; then - CLAUDE_PATH="claude" - fi - - # Resume the session with doctor prompt - "$CLAUDE_PATH" -r "$SESSION_ID" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan - else - echo "ERROR: Could not extract session ID from command output" - echo "Output was:" - echo "$OUTPUT" - echo "" - echo "Press Enter to close..." - read - fi - - # Clean up - rm -f "\(promptPath)" - """ - - do { - try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) - let attributes = [FileAttributeKey.posixPermissions: 0o755] - try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) - - let url = URL(fileURLWithPath: scriptPath) - NSWorkspace.shared.open(url) - - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - try? FileManager.default.removeItem(atPath: scriptPath) - } - - return nil - } catch { - return NSError( - domain: "TerminalLauncher", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to launch Doctor session: \(error.localizedDescription)"] - ) - } - } - - /// Launches Terminal with a Doctor debugging session (test-first approach) - /// - Parameters: - /// - claudeClient: The Claude client for running test command - /// - command: The command name to use - /// - workingDirectory: Working directory for the session - /// - systemPrompt: The doctor system prompt - /// - Returns: An error if launching fails, nil on success - public static func launchDoctorSessionWithTest( - claudeClient: ClaudeCode, - command: String, - workingDirectory: String, - systemPrompt: String - ) async -> Error? { - // Find the full path to the executable - guard let claudeExecutablePath = findClaudeExecutable( - command: command, - additionalPaths: claudeClient.configuration.additionalPaths - ) else { - return NSError( - domain: "TerminalLauncher", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Could not find '\(command)' command. Please ensure it is installed."] - ) - } - - // Write system prompt to temp file - let tempDir = NSTemporaryDirectory() - let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") - - do { - try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) - } catch { - return NSError( - domain: "TerminalLauncher", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"] - ) - } - - // Escape paths - let escapedClaudePath = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let escapedWorkDir = workingDirectory.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - - // Create script that: - // 1. Runs a test command to start session - // 2. Extracts session ID - // 3. Launches Terminal to resume that session - let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launcher_\(UUID().uuidString).command") - let scriptContent = """ - #!/bin/bash - cd "\(escapedWorkDir)" - - # Start a test session and capture output - echo "Starting diagnostic session..." - SESSION_OUTPUT=$("\(escapedClaudePath)" -p "Hello! I need help debugging my environment. Please start by running diagnostic commands." --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan 2>&1) - - # Extract session ID (Claude outputs session ID in specific format) - SESSION_ID=$(echo "$SESSION_OUTPUT" | grep -o "session-[a-zA-Z0-9-]*" | head -1) - - # If we got a session ID, resume it interactively - if [ -n "$SESSION_ID" ]; then - echo "Resuming session: $SESSION_ID" - "\(escapedClaudePath)" -r "$SESSION_ID" - else - echo "Could not capture session ID. Starting fresh interactive session..." - "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan - fi - - # Clean up - rm -f "\(promptPath)" - """ - - do { - try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) - let attributes = [FileAttributeKey.posixPermissions: 0o755] - try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) - - let url = URL(fileURLWithPath: scriptPath) - NSWorkspace.shared.open(url) - - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - try? FileManager.default.removeItem(atPath: scriptPath) - } - - return nil - } catch { - return NSError( - domain: "TerminalLauncher", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to launch Doctor session: \(error.localizedDescription)"] - ) - } - } - - /// Launches Terminal with a Doctor debugging session - /// - Parameters: - /// - command: The command name to use (from preferences) - /// - additionalPaths: Additional paths from configuration - /// - debugReport: The full debug report to analyze - /// - Returns: An error if launching fails, nil on success - public static func launchDoctorSession( - command: String, - additionalPaths: [String], - debugReport: String - ) -> Error? { - // Find the full path to the executable - guard let claudeExecutablePath = findClaudeExecutable( - command: command, - additionalPaths: additionalPaths - ) else { - return NSError( - domain: "TerminalLauncher", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Could not find '\(command)' command. Please ensure it is installed."] - ) - } - - // Escape paths for shell - let escapedClaudePath = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - - // Create doctor system prompt - let doctorPrompt = """ - You are a ClaudeCodeUI Debug Doctor. A user is experiencing issues with their macOS app that uses Claude Code. - - CONTEXT - DEBUG REPORT: - \(debugReport) - - YOUR TASK: - 1. Analyze the debug report and investigate the user's environment - 2. Run diagnostic commands to understand the issue: - - Compare PATH with 'echo $PATH' - - Check shell config: 'cat ~/.zshrc | head -50' - - Test executable: 'which \(command)' and '\(command) --version' - - Check permissions, environment variables, etc. - 3. Identify the root cause of the issue - 4. Propose fixes in priority order (most likely to work first) - - IMPORTANT WORKFLOW: - - First: Investigate thoroughly WITHOUT creating a plan - just run diagnostic commands directly: - * echo $PATH - * which \(command) - * cat ~/.zshrc | head -50 - * \(command) --version - * Compare findings with the debug report - - Then: After investigation is complete, CREATE A PLAN with 3-5 concrete, numbered steps to fix the issue - - Wait: Get user approval before executing fixes (you're in plan mode) - - Execute: One fix at a time, explain what each does - - Test: After each fix, ask user to restart the app and test - - Iterate: If still broken, ask for new debug report and continue - - CRITICAL: When this session starts, immediately greet the user and START RUNNING DIAGNOSTIC COMMANDS. - Do NOT create a plan yet - just investigate first. Only create a plan after you understand the problem. - - Be systematic, clear, and explain your reasoning at each step. - Remember: You're debugging why commands work in Terminal but fail in the macOS app. - """ - - // Write prompt to a temp file - let tempDir = NSTemporaryDirectory() - let promptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_prompt_\(UUID().uuidString).txt") - let scriptPath = (tempDir as NSString).appendingPathComponent("claude_doctor_\(UUID().uuidString).command") - - // Write the prompt to the temp file - do { - try doctorPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) - } catch { - return NSError( - domain: "TerminalLauncher", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"] - ) - } - - // Construct the doctor command - start interactive session with system prompt from file - let homeDir = NSHomeDirectory() - let scriptContent = """ - #!/bin/bash - cd "\(homeDir)" - "\(escapedClaudePath)" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan - rm -f "\(promptPath)" - """ - - do { - // Write the script to file - try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) - - // Make it executable - let attributes = [FileAttributeKey.posixPermissions: 0o755] - try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) - - // Open the script with Terminal - let url = URL(fileURLWithPath: scriptPath) - NSWorkspace.shared.open(url) - - // Clean up the script file after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - try? FileManager.default.removeItem(atPath: scriptPath) - } - - return nil - } catch { - return NSError( - domain: "TerminalLauncher", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to launch Doctor session: \(error.localizedDescription)"] - ) - } - } /// Finds the full path to the Claude executable /// - Parameters: From 748386e03c3da38df3df7cead35f6a2a2a03635a Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 12:23:32 -0700 Subject: [PATCH 11/22] Fix: Source shell profile before executing doctor command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load ~/.zshrc, ~/.bash_profile, or ~/.bashrc to get proper PATH - Use 'source' instead of 'bash' to execute command file in same shell context - This ensures claude command is found when executing reproduction command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ClaudeCodeCore/Services/TerminalLauncher.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index c93de8d..1a82f62 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -188,8 +188,17 @@ public struct TerminalLauncher { echo "Executing command to capture session..." echo "" - # Execute the command from file and capture output - OUTPUT=$(bash '\(commandPath)' 2>&1) + # Source shell profile to get proper PATH + if [ -f ~/.zshrc ]; then + source ~/.zshrc + elif [ -f ~/.bash_profile ]; then + source ~/.bash_profile + elif [ -f ~/.bashrc ]; then + source ~/.bashrc + fi + + # Execute the command from file and capture output (source it to keep environment) + OUTPUT=$(source '\(commandPath)' 2>&1) # Extract session ID from first line (JSON format) SESSION_ID=$(echo "$OUTPUT" | head -1 | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) From 7fcd35604d02ac6ebb4f6faa1ef504e12d790f96 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 12:44:14 -0700 Subject: [PATCH 12/22] Doctor: run reproduction via Process, capture session_id, resume in Terminal (plan mode). Fixes PATH/alias isolation and session parsing. (#84) --- .../Services/TerminalLauncher.swift | 254 +++++++++++++----- 1 file changed, 191 insertions(+), 63 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 1a82f62..7a79887 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -154,7 +154,7 @@ public struct TerminalLauncher { return nil } - /// NEW: Launches Doctor by executing reproduction command and resuming session + /// NEW: Launches Doctor by executing reproduction command (headless), capturing session, then resuming in Terminal /// - Parameters: /// - reproductionCommand: The full command from terminalReproductionCommand /// - systemPrompt: The doctor system prompt @@ -163,72 +163,203 @@ public struct TerminalLauncher { reproductionCommand: String, systemPrompt: String ) async -> Error? { - let tempDir = NSTemporaryDirectory() + // Step 1: Prepare execution by resolving the actual claude executable and working directory + let (preparedCommand, workingDir, resolveError) = prepareCommandWithResolvedClaudePath(reproductionCommand) + if let err = resolveError { + return err + } - // Write the command to a file (avoids all escaping issues) - let commandPath = (tempDir as NSString).appendingPathComponent("doctor_cmd_\(UUID().uuidString).sh") - let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") - let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launch_\(UUID().uuidString).command") + // Step 2: Execute the command headlessly and capture combined output + let (output, executionError) = executeHeadless(command: preparedCommand) + if let err = executionError { + return err + } + + // Step 3: Extract session_id from the first non-empty line of output + guard let sessionId = extractSessionId(from: output) else { + // Include a preview of output for debugging + let preview = output.split(separator: "\n").prefix(20).joined(separator: "\n") + return NSError( + domain: "TerminalLauncher", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Could not extract session_id from output. First lines:\n\(preview)"] + ) + } + + // Step 4: Launch Terminal to resume the session with doctor prompt in plan mode + if let launchError = resumeSessionInTerminal(sessionId: sessionId, workingDir: workingDir, systemPrompt: systemPrompt, usePlanMode: true) { + return launchError + } + + return nil + } + + // MARK: - Private helpers for Doctor flow + + /// Returns a tuple of (preparedCommand, workingDir, error) + /// Replaces the first occurrence of the claude command with its resolved absolute path, + /// preserving all quoting and piping. Also extracts the working directory from an initial `cd` if present. + private static func prepareCommandWithResolvedClaudePath(_ reproductionCommand: String) -> (String, String?, Error?) { + // Extract working directory if the command starts with: cd "..." && ... + var workingDir: String? + var remaining = reproductionCommand + + if reproductionCommand.hasPrefix("cd ") { + // Expect format: cd "" && + // Find the first '&&' separator safely + if let rangeOfAnd = reproductionCommand.range(of: " && ") { + let cdPart = String(reproductionCommand[.." + if let firstQuote = cdPart.firstIndex(of: "\"") { + let afterFirst = cdPart.index(after: firstQuote) + if let secondQuote = cdPart[afterFirst...].firstIndex(of: "\"") { + workingDir = String(cdPart[afterFirst.. ..., move to the part after the pipe + var commandPortion = remaining + if commandPortion.hasPrefix("echo ") { + if let pipeRange = commandPortion.range(of: " | ") { + commandPortion = String(commandPortion[pipeRange.upperBound...]) + } + } + + // Identify the command token (first whitespace-delimited token) + let trimmed = commandPortion.trimmingCharacters(in: .whitespacesAndNewlines) + guard let endOfCmd = trimmed.firstIndex(where: { $0.isWhitespace }) ?? trimmed.endIndex as String.Index?, !trimmed.isEmpty else { + return (reproductionCommand, workingDir, NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Unable to parse command token from reproduction command"])) + } + let cmdToken = String(trimmed[.. (String, Error?) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/zsh") + // -l for login shell, -c to run the command; set -o pipefail to better surface errors + task.arguments = ["-lc", "set -o pipefail; \(command)"] + task.environment = ProcessInfo.processInfo.environment + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe do { - try reproductionCommand.write(toFile: commandPath, atomically: true, encoding: .utf8) - try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + try task.run() } catch { + return ("", NSError(domain: "TerminalLauncher", code: 5, userInfo: [NSLocalizedDescriptionKey: "Failed to start process: \(error.localizedDescription)"])) + } + + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + // If process failed, still return output but include error + if task.terminationStatus != 0 { + return (output, NSError(domain: "TerminalLauncher", code: Int(task.terminationStatus), userInfo: [NSLocalizedDescriptionKey: "Command exited with status \(task.terminationStatus). Output:\n\(output)"])) + } + + return (output, nil) + } + + /// Parses the first non-empty line as JSON and extracts session_id; falls back to regex + private static func extractSessionId(from output: String) -> String? { + // First non-empty line + guard let firstLine = output.split(separator: "\n", omittingEmptySubsequences: true).first else { return nil } + + // Try JSON decode + if let data = String(firstLine).data(using: .utf8) { + if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let sessionId = json["session_id"] as? String, !sessionId.isEmpty { + return sessionId + } + } + + // Fallback regex search in the entire output + let pattern = #"\"session_id\"\s*:\s*\"([^\"]+)\""# + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(location: 0, length: (output as NSString).length) + if let match = regex.firstMatch(in: output, options: [], range: range), match.numberOfRanges >= 2 { + let r = match.range(at: 1) + let sessionId = (output as NSString).substring(with: r) + if !sessionId.isEmpty { return sessionId } + } + } + return nil + } + + /// Launches Terminal.app with a small script that resumes the captured session with the doctor prompt + private static func resumeSessionInTerminal(sessionId: String, workingDir: String?, systemPrompt: String, usePlanMode: Bool) -> Error? { + // Resolve the claude executable for the resume step + let claudeCommand = "claude" + guard let claudeExecutablePath = findClaudeExecutable(command: claudeCommand, additionalPaths: nil) else { return NSError( domain: "TerminalLauncher", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Failed to write files: \(error.localizedDescription)"] + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Could not find 'claude' command for resume. Please ensure CLI is installed."] ) } - // Create the launcher script - let scriptContent = """ - #!/bin/bash + // Escape + let escapedClaude = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + let escapedSession = sessionId.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + + // Write prompt to a temp file to avoid huge quoting issues + let tempDir = NSTemporaryDirectory() + let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + do { + try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + } catch { + return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"]) + } - echo "Executing command to capture session..." - echo "" - - # Source shell profile to get proper PATH - if [ -f ~/.zshrc ]; then - source ~/.zshrc - elif [ -f ~/.bash_profile ]; then - source ~/.bash_profile - elif [ -f ~/.bashrc ]; then - source ~/.bashrc - fi - - # Execute the command from file and capture output (source it to keep environment) - OUTPUT=$(source '\(commandPath)' 2>&1) - - # Extract session ID from first line (JSON format) - SESSION_ID=$(echo "$OUTPUT" | head -1 | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) - - if [ -n "$SESSION_ID" ]; then - echo "Session captured: $SESSION_ID" - echo "Launching Doctor session..." - echo "" - - # Extract claude path from the command file - CLAUDE_PATH=$(grep -o '[^ ]*claude' '\(commandPath)' | grep '^/' | head -1) - - if [ -z "$CLAUDE_PATH" ]; then - CLAUDE_PATH="claude" - fi - - # Resume the session with doctor prompt - "$CLAUDE_PATH" -r "$SESSION_ID" --append-system-prompt "$(cat '\(promptPath)')" --permission-mode plan - else - echo "ERROR: Could not extract session ID" - echo "" - echo "Command output:" - echo "$OUTPUT" - echo "" - echo "Press Enter to close..." - read - fi - - # Clean up - rm -f '\(commandPath)' '\(promptPath)' + let escapedPromptPath = promptPath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + let cdPrefix: String = { + if let dir = workingDir, !dir.isEmpty { + let escapedDir = dir.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + return "cd \"\(escapedDir)\" && " + } + return "" + }() + + let permissionArg = usePlanMode ? " --permission-mode plan" : "" + let resumeCommand = "\(cdPrefix)\"\(escapedClaude)\" -r \"\(escapedSession)\" --append-system-prompt \"$(cat \"\(escapedPromptPath)\")\"\(permissionArg)" + + // Write a small .command script and open it + let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launch_\(UUID().uuidString).command") + let scriptContent = """ + #!/bin/bash -l + \(resumeCommand) """ do { @@ -239,17 +370,14 @@ public struct TerminalLauncher { let url = URL(fileURLWithPath: scriptPath) NSWorkspace.shared.open(url) + // Cleanup temp files later DispatchQueue.main.asyncAfter(deadline: .now() + 10) { try? FileManager.default.removeItem(atPath: scriptPath) + try? FileManager.default.removeItem(atPath: promptPath) } - return nil } catch { - return NSError( - domain: "TerminalLauncher", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to launch: \(error.localizedDescription)"] - ) + return NSError(domain: "TerminalLauncher", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to launch Terminal: \(error.localizedDescription)"]) } } } From 240bfed6c086a10b5e58a2cdb8965ab75f7b093a Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 13:48:07 -0700 Subject: [PATCH 13/22] Add captured output context to doctor session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add buildDoctorContextMessage() helper to format execution details - Update resumeSessionInTerminal() to accept capturedOutput and originalCommand - Pipe context message as first user input: cat context.txt | claude -r ... - Claude now receives full execution context (command, output, working dir) - Allows doctor to compare headless execution vs debug report 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 7a79887..6d1fb50 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -186,8 +186,15 @@ public struct TerminalLauncher { ) } - // Step 4: Launch Terminal to resume the session with doctor prompt in plan mode - if let launchError = resumeSessionInTerminal(sessionId: sessionId, workingDir: workingDir, systemPrompt: systemPrompt, usePlanMode: true) { + // Step 4: Launch Terminal to resume the session with doctor prompt in plan mode, passing captured output as context + if let launchError = resumeSessionInTerminal( + sessionId: sessionId, + workingDir: workingDir, + systemPrompt: systemPrompt, + usePlanMode: true, + capturedOutput: output, + originalCommand: reproductionCommand + ) { return launchError } @@ -318,8 +325,39 @@ public struct TerminalLauncher { return nil } - /// Launches Terminal.app with a small script that resumes the captured session with the doctor prompt - private static func resumeSessionInTerminal(sessionId: String, workingDir: String?, systemPrompt: String, usePlanMode: Bool) -> Error? { + /// Builds a context message containing the command execution details for the doctor to analyze + private static func buildDoctorContextMessage( + originalCommand: String, + workingDir: String?, + capturedOutput: String + ) -> String { + var lines: [String] = [] + lines.append("DOCTOR CONTEXT: Previous command execution output for debugging.") + lines.append("") + if let wd = workingDir, !wd.isEmpty { + lines.append("Working Directory:") + lines.append(wd) + lines.append("") + } + lines.append("Reproduction Command:") + lines.append(originalCommand) + lines.append("") + lines.append("Captured Output (stdout + stderr):") + lines.append(capturedOutput) + lines.append("") + lines.append("Please analyze this output versus the debug report in your system prompt and propose a plan to fix any issues.") + return lines.joined(separator: "\n") + } + + /// Launches Terminal.app with a small script that resumes the captured session with the doctor prompt and initial context + private static func resumeSessionInTerminal( + sessionId: String, + workingDir: String?, + systemPrompt: String, + usePlanMode: Bool, + capturedOutput: String, + originalCommand: String + ) -> Error? { // Resolve the claude executable for the resume step let claudeCommand = "claude" guard let claudeExecutablePath = findClaudeExecutable(command: claudeCommand, additionalPaths: nil) else { @@ -334,16 +372,26 @@ public struct TerminalLauncher { let escapedClaude = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") let escapedSession = sessionId.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - // Write prompt to a temp file to avoid huge quoting issues + // Build context message with captured output + let contextMessage = buildDoctorContextMessage( + originalCommand: originalCommand, + workingDir: workingDir, + capturedOutput: capturedOutput + ) + + // Write prompt and context to temp files to avoid huge quoting issues let tempDir = NSTemporaryDirectory() let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + let contextPath = (tempDir as NSString).appendingPathComponent("doctor_context_\(UUID().uuidString).txt") do { try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + try contextMessage.write(toFile: contextPath, atomically: true, encoding: .utf8) } catch { - return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"]) + return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt/context files: \(error.localizedDescription)"]) } let escapedPromptPath = promptPath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + let escapedContextPath = contextPath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") let cdPrefix: String = { if let dir = workingDir, !dir.isEmpty { let escapedDir = dir.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") @@ -353,7 +401,8 @@ public struct TerminalLauncher { }() let permissionArg = usePlanMode ? " --permission-mode plan" : "" - let resumeCommand = "\(cdPrefix)\"\(escapedClaude)\" -r \"\(escapedSession)\" --append-system-prompt \"$(cat \"\(escapedPromptPath)\")\"\(permissionArg)" + // Pipe the context message as first user input to provide execution output to Claude + let resumeCommand = "\(cdPrefix)cat \"\(escapedContextPath)\" | \"\(escapedClaude)\" -r \"\(escapedSession)\" --append-system-prompt \"$(cat \"\(escapedPromptPath)\")\"\(permissionArg)" // Write a small .command script and open it let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launch_\(UUID().uuidString).command") @@ -374,6 +423,7 @@ public struct TerminalLauncher { DispatchQueue.main.asyncAfter(deadline: .now() + 10) { try? FileManager.default.removeItem(atPath: scriptPath) try? FileManager.default.removeItem(atPath: promptPath) + try? FileManager.default.removeItem(atPath: contextPath) } return nil } catch { From 2c4e27a1f335be1c3a1a3e0020d16775a4b8561a Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 14:00:34 -0700 Subject: [PATCH 14/22] Fix: Preserve echo stdin prefix when resolving claude path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save leadingPrefix when parsing 'echo "..." | claude' commands - Restore prefix when rebuilding command: leadingPrefix + replacedPortion - Fixes --print flag error (requires stdin input) Example: Before: echo "test" | claude --print -> "/path/claude" --print (missing stdin) After: echo "test" | claude --print -> echo "test" | "/path/claude" --print 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/ClaudeCodeCore/Services/TerminalLauncher.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 6d1fb50..1e9eb6c 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -229,10 +229,13 @@ public struct TerminalLauncher { } // At this point, `remaining` is either the original command or the part after the leading cd && - // If the command is of the form: echo "..." | ..., move to the part after the pipe + // If the command is of the form: echo "..." | ..., we must preserve the echo prefix for execution, + // but identify the CLI token from the part after the pipe. + var leadingPrefix = "" var commandPortion = remaining if commandPortion.hasPrefix("echo ") { if let pipeRange = commandPortion.range(of: " | ") { + leadingPrefix = String(commandPortion[.. Date: Fri, 3 Oct 2025 14:18:42 -0700 Subject: [PATCH 15/22] Fix: Add PTY retry for Ink raw mode error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add runProcess() helper that combines stdout+stderr - Check for "Raw mode is not supported" BEFORE checking exit status - Auto-retry with PTY via script command when Ink error detected - Fixes headless execution of Claude Code CLI which requires TTY Flow: 1. Try normal execution via Process 2. Check output for Ink error (may occur even with status 0) 3. If detected, retry: script -q /dev/stdout /bin/zsh -lc "command" 4. Return PTY result 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 1e9eb6c..e7a4036 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -273,11 +273,37 @@ public struct TerminalLauncher { /// Executes the given command via login shell, capturing stdout and stderr together private static func executeHeadless(command: String) -> (String, Error?) { + // First attempt: run normally (non-PTY) + let (output1, status1) = runProcess(executable: "/bin/zsh", args: ["-lc", "set -o pipefail; \(command)"], env: ProcessInfo.processInfo.environment) + + // Check for Ink raw mode error BEFORE checking exit status (error may occur even with status 0) + if output1.contains("Raw mode is not supported") || output1.contains("israwmodesupported") { + // Retry with a pseudo-terminal via `script` to satisfy Ink's TTY requirement + let (output2, status2) = runProcess( + executable: "/usr/bin/script", + args: ["-q", "/dev/stdout", "/bin/zsh", "-lc", "set -o pipefail; \(command)"], + env: ProcessInfo.processInfo.environment + ) + + if status2 == 0 { + return (output2, nil) + } + return (output2, NSError(domain: "TerminalLauncher", code: Int(status2), userInfo: [NSLocalizedDescriptionKey: "Command (PTY) exited with status \(status2). Output:\n\(output2)"])) + } + + // No Ink error, return normally + if status1 == 0 { + return (output1, nil) + } + return (output1, NSError(domain: "TerminalLauncher", code: Int(status1), userInfo: [NSLocalizedDescriptionKey: "Command exited with status \(status1). Output:\n\(output1)"])) + } + + /// Runs a process and returns (combined stdout+stderr, exitStatus) + private static func runProcess(executable: String, args: [String], env: [String: String]) -> (String, Int32) { let task = Process() - task.executableURL = URL(fileURLWithPath: "/bin/zsh") - // -l for login shell, -c to run the command; set -o pipefail to better surface errors - task.arguments = ["-lc", "set -o pipefail; \(command)"] - task.environment = ProcessInfo.processInfo.environment + task.executableURL = URL(fileURLWithPath: executable) + task.arguments = args + task.environment = env let pipe = Pipe() task.standardOutput = pipe @@ -286,20 +312,14 @@ public struct TerminalLauncher { do { try task.run() } catch { - return ("", NSError(domain: "TerminalLauncher", code: 5, userInfo: [NSLocalizedDescriptionKey: "Failed to start process: \(error.localizedDescription)"])) + return ("Failed to start process: \(error.localizedDescription)", 127) } task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" - - // If process failed, still return output but include error - if task.terminationStatus != 0 { - return (output, NSError(domain: "TerminalLauncher", code: Int(task.terminationStatus), userInfo: [NSLocalizedDescriptionKey: "Command exited with status \(task.terminationStatus). Output:\n\(output)"])) - } - - return (output, nil) + return (output, task.terminationStatus) } /// Parses the first non-empty line as JSON and extracts session_id; falls back to regex From f85135a47b26de1f78842be6e4ba71de997425a0 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 14:47:23 -0700 Subject: [PATCH 16/22] Fix: Use CI env vars and unbuffer for TTY-free execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set CI=true, TERM=dumb, NO_COLOR=1 to disable TUI - Fallback to unbuffer (from expect) if Ink error persists - Replace script approach with unbuffer (designed for this use case) - Better error message with brew install suggestion This properly handles Claude CLI's Ink TUI requirements in headless mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index e7a4036..1ab286e 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -273,22 +273,40 @@ public struct TerminalLauncher { /// Executes the given command via login shell, capturing stdout and stderr together private static func executeHeadless(command: String) -> (String, Error?) { - // First attempt: run normally (non-PTY) - let (output1, status1) = runProcess(executable: "/bin/zsh", args: ["-lc", "set -o pipefail; \(command)"], env: ProcessInfo.processInfo.environment) + // First attempt: run with CI environment variables to disable TUI + var env = ProcessInfo.processInfo.environment + env["CI"] = "true" // Many CLIs detect CI and disable interactive features + env["TERM"] = "dumb" // Signal no terminal capabilities + env["NO_COLOR"] = "1" // Disable color output + + let (output1, status1) = runProcess( + executable: "/bin/zsh", + args: ["-lc", "set -o pipefail; \(command)"], + env: env + ) // Check for Ink raw mode error BEFORE checking exit status (error may occur even with status 0) if output1.contains("Raw mode is not supported") || output1.contains("israwmodesupported") { - // Retry with a pseudo-terminal via `script` to satisfy Ink's TTY requirement - let (output2, status2) = runProcess( - executable: "/usr/bin/script", - args: ["-q", "/dev/stdout", "/bin/zsh", "-lc", "set -o pipefail; \(command)"], - env: ProcessInfo.processInfo.environment - ) - - if status2 == 0 { - return (output2, nil) + // Try with unbuffer if available (from expect package) + let unbufferPath = "/usr/bin/unbuffer" + if FileManager.default.fileExists(atPath: unbufferPath) { + let (output2, status2) = runProcess( + executable: unbufferPath, + args: ["/bin/zsh", "-lc", "set -o pipefail; \(command)"], + env: env + ) + if status2 == 0 { + return (output2, nil) + } + return (output2, NSError(domain: "TerminalLauncher", code: Int(status2), userInfo: [NSLocalizedDescriptionKey: "Command (unbuffer) exited with status \(status2). Output:\n\(output2)"])) } - return (output2, NSError(domain: "TerminalLauncher", code: Int(status2), userInfo: [NSLocalizedDescriptionKey: "Command (PTY) exited with status \(status2). Output:\n\(output2)"])) + + // If unbuffer not available, return error with suggestion + return (output1, NSError( + domain: "TerminalLauncher", + code: 126, + userInfo: [NSLocalizedDescriptionKey: "Claude Code CLI requires a terminal (TTY) but none is available in headless mode.\n\nOutput:\n\(output1)\n\nTo fix: Install expect package (brew install expect) for unbuffer support."] + )) } // No Ink error, return normally From ae6ac1ac9aaaadcf1c8a51bbbcc178d43b1f6705 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 14:58:14 -0700 Subject: [PATCH 17/22] Refactor: Run doctor command in Terminal, not headless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEM: Claude Code CLI uses Ink TUI which requires real TTY. Cannot run headlessly via Swift Process() - Ink errors every time. SOLUTION: Run everything in Terminal (has TTY), no headless execution. How it works: 1. Launch Terminal with single script that: - Executes reproduction command (OUTPUT=$(command 2>&1)) - Extracts session_id from output - Pipes captured output as context into resumed session 2. All in one Terminal window - simple and works with TTY constraints Removed: - executeHeadless() - no longer needed - runProcess() - no longer needed - extractSessionId() - done in bash now - buildDoctorContextMessage() - done in bash now - resumeSessionInTerminal() - done in bash now Benefits: - Much simpler approach (1 Terminal script vs complex headless retry logic) - Actually works (Terminal provides TTY that Ink requires) - Easier to debug (user can see execution happen in Terminal) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 345 ++++++------------ 1 file changed, 116 insertions(+), 229 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 1ab286e..9ff8229 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -163,45 +163,133 @@ public struct TerminalLauncher { reproductionCommand: String, systemPrompt: String ) async -> Error? { - // Step 1: Prepare execution by resolving the actual claude executable and working directory + // Resolve the claude executable path and extract working directory let (preparedCommand, workingDir, resolveError) = prepareCommandWithResolvedClaudePath(reproductionCommand) if let err = resolveError { return err } - // Step 2: Execute the command headlessly and capture combined output - let (output, executionError) = executeHeadless(command: preparedCommand) - if let err = executionError { - return err - } + // Launch Terminal with a script that executes command, captures output, and auto-resumes with context + // This runs everything in Terminal (has TTY) - no headless execution needed + return launchTerminalWithCaptureAndResume( + command: preparedCommand, + workingDir: workingDir, + originalCommand: reproductionCommand, + systemPrompt: systemPrompt + ) + } - // Step 3: Extract session_id from the first non-empty line of output - guard let sessionId = extractSessionId(from: output) else { - // Include a preview of output for debugging - let preview = output.split(separator: "\n").prefix(20).joined(separator: "\n") - return NSError( - domain: "TerminalLauncher", - code: 4, - userInfo: [NSLocalizedDescriptionKey: "Could not extract session_id from output. First lines:\n\(preview)"] - ) + // MARK: - Private helpers for Doctor flow + + /// Launches Terminal with a script that runs command, captures output, and auto-resumes with context + private static func launchTerminalWithCaptureAndResume( + command: String, + workingDir: String?, + originalCommand: String, + systemPrompt: String + ) -> Error? { + // Write system prompt to temp file + let tempDir = NSTemporaryDirectory() + let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + + do { + try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + } catch { + return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"]) } - // Step 4: Launch Terminal to resume the session with doctor prompt in plan mode, passing captured output as context - if let launchError = resumeSessionInTerminal( - sessionId: sessionId, - workingDir: workingDir, - systemPrompt: systemPrompt, - usePlanMode: true, - capturedOutput: output, - originalCommand: reproductionCommand - ) { - return launchError + // Escape paths for shell + let escapedPromptPath = promptPath.replacingOccurrences(of: "'", with: "'\\''") + + // Build cd prefix if needed + let cdPrefix: String + if let dir = workingDir, !dir.isEmpty { + let escapedDir = dir.replacingOccurrences(of: "'", with: "'\\''") + cdPrefix = "cd '\(escapedDir)'\n" + } else { + cdPrefix = "" } - return nil - } + // Create Terminal script that captures command output and auto-resumes + let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_\(UUID().uuidString).command") + let scriptContent = """ + #!/bin/bash -l - // MARK: - Private helpers for Doctor flow + echo "═══════════════════════════════════════" + echo "ClaudeCodeUI Doctor - Executing Command" + echo "═══════════════════════════════════════" + echo "" + + \(cdPrefix) + # Execute reproduction command and capture all output + echo "Running: \(originalCommand)" + echo "" + OUTPUT=$(\(command) 2>&1) + EXIT_CODE=$? + + echo "" + echo "═══════════════════════════════════════" + echo "Command Completed (exit code: $EXIT_CODE)" + echo "═══════════════════════════════════════" + echo "" + + # Extract session ID from output (first line should be JSON with session_id) + SESSION_ID=$(echo "$OUTPUT" | head -20 | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4) + + if [ -z "$SESSION_ID" ]; then + echo "❌ ERROR: Could not extract session_id from command output" + echo "" + echo "Output preview:" + echo "$OUTPUT" | head -20 + echo "" + echo "Press Enter to close..." + read + exit 1 + fi + + echo "✅ Captured session: $SESSION_ID" + echo "" + echo "═══════════════════════════════════════" + echo "Launching Doctor Session..." + echo "═══════════════════════════════════════" + echo "" + + # Build context message with captured output + CONTEXT="DOCTOR CONTEXT: Previous command execution output for debugging. + + Reproduction Command: + \(originalCommand) + + Captured Output (stdout + stderr): + $OUTPUT + + Please analyze this output versus the debug report in your system prompt and propose a plan to fix any issues." + + # Pipe context into resumed session with doctor prompt + echo "$CONTEXT" | claude -r "$SESSION_ID" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan + + # Cleanup + rm -f '\(escapedPromptPath)' + """ + + do { + try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) + + let url = URL(fileURLWithPath: scriptPath) + NSWorkspace.shared.open(url) + + // Cleanup script after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + try? FileManager.default.removeItem(atPath: scriptPath) + } + + return nil + } catch { + return NSError(domain: "TerminalLauncher", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to launch Terminal: \(error.localizedDescription)"]) + } + } /// Returns a tuple of (preparedCommand, workingDir, error) /// Replaces the first occurrence of the claude command with its resolved absolute path, @@ -270,205 +358,4 @@ public struct TerminalLauncher { return (prepared, workingDir, nil) } - - /// Executes the given command via login shell, capturing stdout and stderr together - private static func executeHeadless(command: String) -> (String, Error?) { - // First attempt: run with CI environment variables to disable TUI - var env = ProcessInfo.processInfo.environment - env["CI"] = "true" // Many CLIs detect CI and disable interactive features - env["TERM"] = "dumb" // Signal no terminal capabilities - env["NO_COLOR"] = "1" // Disable color output - - let (output1, status1) = runProcess( - executable: "/bin/zsh", - args: ["-lc", "set -o pipefail; \(command)"], - env: env - ) - - // Check for Ink raw mode error BEFORE checking exit status (error may occur even with status 0) - if output1.contains("Raw mode is not supported") || output1.contains("israwmodesupported") { - // Try with unbuffer if available (from expect package) - let unbufferPath = "/usr/bin/unbuffer" - if FileManager.default.fileExists(atPath: unbufferPath) { - let (output2, status2) = runProcess( - executable: unbufferPath, - args: ["/bin/zsh", "-lc", "set -o pipefail; \(command)"], - env: env - ) - if status2 == 0 { - return (output2, nil) - } - return (output2, NSError(domain: "TerminalLauncher", code: Int(status2), userInfo: [NSLocalizedDescriptionKey: "Command (unbuffer) exited with status \(status2). Output:\n\(output2)"])) - } - - // If unbuffer not available, return error with suggestion - return (output1, NSError( - domain: "TerminalLauncher", - code: 126, - userInfo: [NSLocalizedDescriptionKey: "Claude Code CLI requires a terminal (TTY) but none is available in headless mode.\n\nOutput:\n\(output1)\n\nTo fix: Install expect package (brew install expect) for unbuffer support."] - )) - } - - // No Ink error, return normally - if status1 == 0 { - return (output1, nil) - } - return (output1, NSError(domain: "TerminalLauncher", code: Int(status1), userInfo: [NSLocalizedDescriptionKey: "Command exited with status \(status1). Output:\n\(output1)"])) - } - - /// Runs a process and returns (combined stdout+stderr, exitStatus) - private static func runProcess(executable: String, args: [String], env: [String: String]) -> (String, Int32) { - let task = Process() - task.executableURL = URL(fileURLWithPath: executable) - task.arguments = args - task.environment = env - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - - do { - try task.run() - } catch { - return ("Failed to start process: \(error.localizedDescription)", 127) - } - - task.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - return (output, task.terminationStatus) - } - - /// Parses the first non-empty line as JSON and extracts session_id; falls back to regex - private static func extractSessionId(from output: String) -> String? { - // First non-empty line - guard let firstLine = output.split(separator: "\n", omittingEmptySubsequences: true).first else { return nil } - - // Try JSON decode - if let data = String(firstLine).data(using: .utf8) { - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let sessionId = json["session_id"] as? String, !sessionId.isEmpty { - return sessionId - } - } - - // Fallback regex search in the entire output - let pattern = #"\"session_id\"\s*:\s*\"([^\"]+)\""# - if let regex = try? NSRegularExpression(pattern: pattern, options: []) { - let range = NSRange(location: 0, length: (output as NSString).length) - if let match = regex.firstMatch(in: output, options: [], range: range), match.numberOfRanges >= 2 { - let r = match.range(at: 1) - let sessionId = (output as NSString).substring(with: r) - if !sessionId.isEmpty { return sessionId } - } - } - return nil - } - - /// Builds a context message containing the command execution details for the doctor to analyze - private static func buildDoctorContextMessage( - originalCommand: String, - workingDir: String?, - capturedOutput: String - ) -> String { - var lines: [String] = [] - lines.append("DOCTOR CONTEXT: Previous command execution output for debugging.") - lines.append("") - if let wd = workingDir, !wd.isEmpty { - lines.append("Working Directory:") - lines.append(wd) - lines.append("") - } - lines.append("Reproduction Command:") - lines.append(originalCommand) - lines.append("") - lines.append("Captured Output (stdout + stderr):") - lines.append(capturedOutput) - lines.append("") - lines.append("Please analyze this output versus the debug report in your system prompt and propose a plan to fix any issues.") - return lines.joined(separator: "\n") - } - - /// Launches Terminal.app with a small script that resumes the captured session with the doctor prompt and initial context - private static func resumeSessionInTerminal( - sessionId: String, - workingDir: String?, - systemPrompt: String, - usePlanMode: Bool, - capturedOutput: String, - originalCommand: String - ) -> Error? { - // Resolve the claude executable for the resume step - let claudeCommand = "claude" - guard let claudeExecutablePath = findClaudeExecutable(command: claudeCommand, additionalPaths: nil) else { - return NSError( - domain: "TerminalLauncher", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Could not find 'claude' command for resume. Please ensure CLI is installed."] - ) - } - - // Escape - let escapedClaude = claudeExecutablePath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - let escapedSession = sessionId.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - - // Build context message with captured output - let contextMessage = buildDoctorContextMessage( - originalCommand: originalCommand, - workingDir: workingDir, - capturedOutput: capturedOutput - ) - - // Write prompt and context to temp files to avoid huge quoting issues - let tempDir = NSTemporaryDirectory() - let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") - let contextPath = (tempDir as NSString).appendingPathComponent("doctor_context_\(UUID().uuidString).txt") - do { - try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) - try contextMessage.write(toFile: contextPath, atomically: true, encoding: .utf8) - } catch { - return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt/context files: \(error.localizedDescription)"]) - } - - let escapedPromptPath = promptPath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - let escapedContextPath = contextPath.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - let cdPrefix: String = { - if let dir = workingDir, !dir.isEmpty { - let escapedDir = dir.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - return "cd \"\(escapedDir)\" && " - } - return "" - }() - - let permissionArg = usePlanMode ? " --permission-mode plan" : "" - // Pipe the context message as first user input to provide execution output to Claude - let resumeCommand = "\(cdPrefix)cat \"\(escapedContextPath)\" | \"\(escapedClaude)\" -r \"\(escapedSession)\" --append-system-prompt \"$(cat \"\(escapedPromptPath)\")\"\(permissionArg)" - - // Write a small .command script and open it - let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_launch_\(UUID().uuidString).command") - let scriptContent = """ - #!/bin/bash -l - \(resumeCommand) - """ - - do { - try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) - let attributes = [FileAttributeKey.posixPermissions: 0o755] - try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) - - let url = URL(fileURLWithPath: scriptPath) - NSWorkspace.shared.open(url) - - // Cleanup temp files later - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - try? FileManager.default.removeItem(atPath: scriptPath) - try? FileManager.default.removeItem(atPath: promptPath) - try? FileManager.default.removeItem(atPath: contextPath) - } - return nil - } catch { - return NSError(domain: "TerminalLauncher", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to launch Terminal: \(error.localizedDescription)"]) - } - } } From 46c4c5ade731cfe8890bf867c813e2eee386cc0b Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 15:07:19 -0700 Subject: [PATCH 18/22] Fix: Avoid bash interpretation errors and resolve claude path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues fixed: 1. originalCommand was embedded in bash causing "command not found" errors 2. Hardcoded 'claude' in resume - not in PATH Solutions: 1. Write originalCommand to temp file, read with cat 2. Extract claude executable path from prepared command via regex 3. Use heredoc for context message to handle multiline properly 4. Use resolved claude path in resume command Now bash won't try to execute lines from the command string. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 9ff8229..0d04d61 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -188,18 +188,33 @@ public struct TerminalLauncher { originalCommand: String, systemPrompt: String ) -> Error? { - // Write system prompt to temp file + // Extract claude executable path from prepared command for use in resume + // The command may be like: echo "..." | "/path/to/claude" args... + let claudePath: String + if let match = command.range(of: #"\"([^\"]+/claude[^\"]*)\"|\s(/[^\s]+/claude)"#, options: .regularExpression) { + let matched = String(command[match]) + claudePath = matched.replacingOccurrences(of: "\"", with: "").trimmingCharacters(in: .whitespaces) + } else { + // Fallback to finding claude executable + claudePath = findClaudeExecutable(command: "claude", additionalPaths: nil) ?? "claude" + } + + // Write files to avoid quoting hell let tempDir = NSTemporaryDirectory() let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + let originalCmdPath = (tempDir as NSString).appendingPathComponent("doctor_original_cmd_\(UUID().uuidString).txt") do { try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + try originalCommand.write(toFile: originalCmdPath, atomically: true, encoding: .utf8) } catch { - return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write prompt file: \(error.localizedDescription)"]) + return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write temp files: \(error.localizedDescription)"]) } // Escape paths for shell let escapedPromptPath = promptPath.replacingOccurrences(of: "'", with: "'\\''") + let escapedOriginalCmdPath = originalCmdPath.replacingOccurrences(of: "'", with: "'\\''") + let escapedClaudePath = claudePath.replacingOccurrences(of: "'", with: "'\\''") // Build cd prefix if needed let cdPrefix: String @@ -222,7 +237,7 @@ public struct TerminalLauncher { \(cdPrefix) # Execute reproduction command and capture all output - echo "Running: \(originalCommand)" + echo "Running: $(cat '\(escapedOriginalCmdPath)')" echo "" OUTPUT=$(\(command) 2>&1) EXIT_CODE=$? @@ -255,21 +270,25 @@ public struct TerminalLauncher { echo "" # Build context message with captured output - CONTEXT="DOCTOR CONTEXT: Previous command execution output for debugging. + cat > /tmp/doctor_context_$$.txt <<'CONTEXT_EOF' +DOCTOR CONTEXT: Previous command execution output for debugging. - Reproduction Command: - \(originalCommand) +Reproduction Command: +CONTEXT_EOF + cat '\(escapedOriginalCmdPath)' >> /tmp/doctor_context_$$.txt + cat >> /tmp/doctor_context_$$.txt < Date: Fri, 3 Oct 2025 15:22:01 -0700 Subject: [PATCH 19/22] Fix: Swift multiline string indentation for heredoc content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: Heredoc content lines caused Swift compiler error due to insufficient indentation in multiline string literal. Solution: Avoid heredoc entirely - use echo with string interpolation and bash brace grouping to build context file. Before (broke): cat > file <<'EOF' [content] EOF After (works): { echo "header"; cat file; echo "footer"; } > file Cleaner and no indentation issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 0d04d61..4a1278c 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -225,6 +225,21 @@ public struct TerminalLauncher { cdPrefix = "" } + // Build context message content + let contextHeader = """ + DOCTOR CONTEXT: Previous command execution output for debugging. + + Reproduction Command: + """ + + let contextFooter = """ + + Captured Output (stdout + stderr): + $OUTPUT + + Please analyze this output versus the debug report in your system prompt and propose a plan to fix any issues. + """ + // Create Terminal script that captures command output and auto-resumes let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_\(UUID().uuidString).command") let scriptContent = """ @@ -269,20 +284,12 @@ public struct TerminalLauncher { echo "═══════════════════════════════════════" echo "" - # Build context message with captured output - cat > /tmp/doctor_context_$$.txt <<'CONTEXT_EOF' -DOCTOR CONTEXT: Previous command execution output for debugging. - -Reproduction Command: -CONTEXT_EOF - cat '\(escapedOriginalCmdPath)' >> /tmp/doctor_context_$$.txt - cat >> /tmp/doctor_context_$$.txt < /tmp/doctor_context_$$.txt # Pipe context into resumed session with doctor prompt cat /tmp/doctor_context_$$.txt | '\(escapedClaudePath)' -r "$SESSION_ID" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan From 5e3a2951073b69c535cf850156bb3ace1b876aaf Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 15:26:06 -0700 Subject: [PATCH 20/22] Fix: Remove stdin piping to avoid Ink raw mode error on resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Piping context into resumed session caused Ink TTY error: cat context.txt | claude -r session --append-system-prompt ... Solution: Resume without piping - session already has execution context: claude -r session --append-system-prompt ... --permission-mode plan Why this works: - Resumed session contains original execution history - Debug report is in system prompt (--append-system-prompt) - No stdin piping = no Ink "raw mode not supported" error - Claude can analyze the session with doctor prompt in plan mode Removed: - Context file building (not needed) - Stdin piping (caused error) - contextHeader/contextFooter variables (unused) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 4a1278c..28e7382 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -225,21 +225,6 @@ public struct TerminalLauncher { cdPrefix = "" } - // Build context message content - let contextHeader = """ - DOCTOR CONTEXT: Previous command execution output for debugging. - - Reproduction Command: - """ - - let contextFooter = """ - - Captured Output (stdout + stderr): - $OUTPUT - - Please analyze this output versus the debug report in your system prompt and propose a plan to fix any issues. - """ - // Create Terminal script that captures command output and auto-resumes let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_\(UUID().uuidString).command") let scriptContent = """ @@ -284,18 +269,11 @@ public struct TerminalLauncher { echo "═══════════════════════════════════════" echo "" - # Build context message - { - echo "\(contextHeader)" - cat '\(escapedOriginalCmdPath)' - echo "\(contextFooter)" - } > /tmp/doctor_context_$$.txt - - # Pipe context into resumed session with doctor prompt - cat /tmp/doctor_context_$$.txt | '\(escapedClaudePath)' -r "$SESSION_ID" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan + # Resume the session with doctor prompt - no piping needed, session already has context + '\(escapedClaudePath)' -r "$SESSION_ID" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan # Cleanup - rm -f '\(escapedPromptPath)' '\(escapedOriginalCmdPath)' /tmp/doctor_context_$$.txt + rm -f '\(escapedPromptPath)' '\(escapedOriginalCmdPath)' """ do { From 7d58aa4248fa7ffb2c393c29afd0a789ead05663 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 15:34:38 -0700 Subject: [PATCH 21/22] Fix: Pass debug report + output as first message when resuming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Resumed session started fresh with "hello" - no context. Solution: Use -p flag to send context message when resuming: - Build CONTEXT_MSG with debug report + command output - Resume with: claude -r session -p "$CONTEXT_MSG" --append-system-prompt ... - Claude now sees both debug report AND execution output immediately Changes: 1. Add debugReport parameter to launchDoctorByExecutingCommand 2. Write debug report to temp file 3. Build context message in bash with debug report + OUTPUT 4. Use -p flag when resuming instead of relying on session history 5. Update system prompt to be instructions-only (no embedded data) Now Claude receives the full context as the first message in the resumed session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/TerminalLauncher.swift | 23 +++++++++++++++---- .../UI/GlobalSettingsView.swift | 17 +++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 28e7382..a406fe2 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -157,10 +157,12 @@ public struct TerminalLauncher { /// NEW: Launches Doctor by executing reproduction command (headless), capturing session, then resuming in Terminal /// - Parameters: /// - reproductionCommand: The full command from terminalReproductionCommand - /// - systemPrompt: The doctor system prompt + /// - debugReport: The full debug report to send as context + /// - systemPrompt: The doctor system prompt (instructions only) /// - Returns: An error if launching fails, nil on success public static func launchDoctorByExecutingCommand( reproductionCommand: String, + debugReport: String, systemPrompt: String ) async -> Error? { // Resolve the claude executable path and extract working directory @@ -175,6 +177,7 @@ public struct TerminalLauncher { command: preparedCommand, workingDir: workingDir, originalCommand: reproductionCommand, + debugReport: debugReport, systemPrompt: systemPrompt ) } @@ -186,6 +189,7 @@ public struct TerminalLauncher { command: String, workingDir: String?, originalCommand: String, + debugReport: String, systemPrompt: String ) -> Error? { // Extract claude executable path from prepared command for use in resume @@ -203,10 +207,12 @@ public struct TerminalLauncher { let tempDir = NSTemporaryDirectory() let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") let originalCmdPath = (tempDir as NSString).appendingPathComponent("doctor_original_cmd_\(UUID().uuidString).txt") + let debugReportPath = (tempDir as NSString).appendingPathComponent("doctor_debug_report_\(UUID().uuidString).txt") do { try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) try originalCommand.write(toFile: originalCmdPath, atomically: true, encoding: .utf8) + try debugReport.write(toFile: debugReportPath, atomically: true, encoding: .utf8) } catch { return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write temp files: \(error.localizedDescription)"]) } @@ -214,6 +220,7 @@ public struct TerminalLauncher { // Escape paths for shell let escapedPromptPath = promptPath.replacingOccurrences(of: "'", with: "'\\''") let escapedOriginalCmdPath = originalCmdPath.replacingOccurrences(of: "'", with: "'\\''") + let escapedDebugReportPath = debugReportPath.replacingOccurrences(of: "'", with: "'\\''") let escapedClaudePath = claudePath.replacingOccurrences(of: "'", with: "'\\''") // Build cd prefix if needed @@ -269,11 +276,19 @@ public struct TerminalLauncher { echo "═══════════════════════════════════════" echo "" - # Resume the session with doctor prompt - no piping needed, session already has context - '\(escapedClaudePath)' -r "$SESSION_ID" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan + # Build context message with debug report + command output + CONTEXT_MSG="$(cat <<'CONTEXT_START' +Debug Report: +CONTEXT_START +)" + CONTEXT_MSG="$CONTEXT_MSG"$'\n'"$(cat '\(escapedDebugReportPath)')" + CONTEXT_MSG="$CONTEXT_MSG"$'\n\nCommand Output:\n'"$OUTPUT" + + # Resume session with context as first message + '\(escapedClaudePath)' -r "$SESSION_ID" -p "$CONTEXT_MSG" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan # Cleanup - rm -f '\(escapedPromptPath)' '\(escapedOriginalCmdPath)' + rm -f '\(escapedPromptPath)' '\(escapedOriginalCmdPath)' '\(escapedDebugReportPath)' """ do { diff --git a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift index 7f63e5e..1451bf7 100644 --- a/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift +++ b/Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift @@ -782,30 +782,25 @@ struct GlobalSettingsView: View { let report = viewModel.fullDebugReport, let reproCommand = viewModel.terminalReproductionCommand else { return } - // Create doctor system prompt - let command = globalPreferences.claudeCommand + // Create doctor system prompt (instructions only, no data) let doctorPrompt = """ You are a ClaudeCodeUI Debug Doctor. - I just executed a command from the app and you have the results in this session's context. - - DEBUG REPORT: - \(report) - Your task: - 1. Review the command execution that just happened in this session - 2. Compare the output with what's in the debug report + 1. Review the debug report and command execution output in the first message + 2. Compare the command output with what's expected in the debug report 3. Look for discrepancies (PATH differences, executable location, errors, etc.) - 4. If you find issues, create a plan to fix them + 4. If you find issues, create a plan to fix them in priority order 5. If everything works fine, explain that the app is working correctly - Start by reviewing the session context to see what the command returned. + Work in plan mode - propose fixes before executing them. """ // Launch Terminal with doctor session Task { if let error = await TerminalLauncher.launchDoctorByExecutingCommand( reproductionCommand: reproCommand, + debugReport: report, systemPrompt: doctorPrompt ) { await MainActor.run { From bc4a1558b9a480fdf60e31c6534f005c93dcaa89 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Fri, 3 Oct 2025 15:37:29 -0700 Subject: [PATCH 22/22] Fix: Remove heredoc to avoid Swift indentation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Heredoc content caused "Insufficient indentation" compiler error because lines inside heredoc don't align with Swift multiline string rules. Solution: Use bash string concatenation instead of heredoc: CONTEXT_MSG="Debug Report:"$'\n'"$(cat file)"$'\n\nCommand Output:\n'"$OUTPUT" This is simpler and avoids all Swift indentation requirements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/ClaudeCodeCore/Services/TerminalLauncher.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index a406fe2..cb7aec5 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -277,12 +277,7 @@ public struct TerminalLauncher { echo "" # Build context message with debug report + command output - CONTEXT_MSG="$(cat <<'CONTEXT_START' -Debug Report: -CONTEXT_START -)" - CONTEXT_MSG="$CONTEXT_MSG"$'\n'"$(cat '\(escapedDebugReportPath)')" - CONTEXT_MSG="$CONTEXT_MSG"$'\n\nCommand Output:\n'"$OUTPUT" + CONTEXT_MSG="Debug Report:"$'\n'"$(cat '\(escapedDebugReportPath)')"$'\n\nCommand Output:\n'"$OUTPUT" # Resume session with context as first message '\(escapedClaudePath)' -r "$SESSION_ID" -p "$CONTEXT_MSG" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan