fix: chat works without a terminal hermes session; surface the real error when it doesn't

A fresh-install user reported Scarf chat only worked while `hermes chat`
was also running in Terminal. ACP connected successfully but sending a
message errored. `~/.hermes/logs/errors.log` showed the real cause:

  RuntimeError: No Anthropic credentials found. Set ANTHROPIC_TOKEN or
  ANTHROPIC_API_KEY, run 'claude setup-token', or authenticate with
  'claude /login'.

The terminal workaround masked the bug because the terminal-launched
`hermes` inherits the user's shell env (ANTHROPIC_* exports, Keychain
session) while a Finder/Dock-launched Scarf subprocess does not.
Scarf's previous PATH-only enrichment (commit b2a29ab) fixed binary
discovery but not credential propagation.

Five changes:

1. Propagate credential env vars from the login shell.
   HermesFileService.enrichedEnvironment() now harvests a conservative
   allowlist of AI-provider keys (ANTHROPIC_API_KEY/TOKEN/BASE_URL,
   OPENAI_*, OPENROUTER_*, GEMINI/GOOGLE/GROQ/MISTRAL/XAI API keys,
   CLAUDE_CODE_OAUTH_TOKEN) alongside PATH. Uses one `zsh` probe with
   null-delimited `printf` so values with newlines survive, cached for
   the process lifetime.

2. Two-attempt shell probe catches nvm/asdf/mise PATH.
   Previous `zsh -l` missed `.zshrc`-exported PATH (nvm). New probe
   first tries `zsh -l -i` (login + interactive, sources .zshrc) with
   prompt frameworks defanged (TERM=dumb, empty PS1/PROMPT,
   POWERLEVEL9K_INSTANT_PROMPT=off, STARSHIP_DISABLE=1,
   ZSH_DISABLE_COMPFIX=true) and a 5s timeout; falls back to `zsh -l`
   with 3s; finally to hardcoded defaults.

3. Resolve `hermes` binary across install locations.
   HermesPaths.hermesBinary is now computed, walking pipx
   (~/.local/bin), Apple Silicon brew (/opt/homebrew/bin), Intel brew
   / manual (/usr/local/bin), and ~/.hermes/bin. Returns the first
   executable match or the pipx default for "Expected at …"
   diagnostics. All 10+ callsites (ACPClient, scarfApp, Health /
   Gateway / Tools / Sessions / QuickCommands / Personalities /
   Settings / WhatsAppSetup / OAuthFlow / CredentialPools
   ViewModels) auto-migrate with zero edits.
   HermesFileService.hermesBinaryPath() shares the same candidate
   list as the source of truth.

4. Surface the real failure in the chat UI.
   ACPClient keeps a 50-line ring buffer of subprocess stderr
   (previously only sent to os_log). New ACPErrorHint.classify pattern-
   matches the common fresh-install failures — "No credentials found",
   "No such file or directory: 'npx'", rate-limit — and returns a short
   human hint. ChatView gains an errorBanner between toolbar and chat
   area showing the hint + raw message + a "Show details" disclosure
   with the stderr tail in a selectable monospaced view, plus a
   clipboard-copy button.

5. Preflight credential check.
   HermesFileService.hasAnyAICredential() scans the enriched env and
   ~/.hermes/.env for any known provider key. ChatViewModel exposes
   `missingCredentials`; the banner becomes a pre-emptive warning
   ("No AI provider credentials detected — add ANTHROPIC_API_KEY to
   ~/.hermes/.env or your shell profile") before the user even hits
   Send. HermesFileWatcher already watches ~/.hermes/.env, so edits
   re-trigger preflight automatically.

Incidental cleanup: recordACPFailure(_:client:context:) folds the
per-site `logger.error` calls, removing three `_ = msg` suppressions.
Dead `enrichedPath` alias removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-16 18:49:44 -07:00
parent 41ea3aeb83
commit 75e489e39c
5 changed files with 380 additions and 64 deletions
+21 -1
View File
@@ -19,10 +19,30 @@ enum HermesPaths: Sendable {
nonisolated static let errorsLog: String = home + "/logs/errors.log" nonisolated static let errorsLog: String = home + "/logs/errors.log"
nonisolated static let agentLog: String = home + "/logs/agent.log" nonisolated static let agentLog: String = home + "/logs/agent.log"
nonisolated static let gatewayLog: String = home + "/logs/gateway.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf" nonisolated static let scarfDir: String = home + "/scarf"
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json" nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens" nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
/// Install locations we look for the `hermes` binary in, in priority order.
/// Checked every access so a user installing via a different method doesn't
/// need to relaunch Scarf.
nonisolated static let hermesBinaryCandidates: [String] = [
userHome + "/.local/bin/hermes", // pipx / pip --user (default)
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
userHome + "/.hermes/bin/hermes" // Some self-install layouts
]
/// Resolved path to the `hermes` executable. Returns the first candidate
/// that exists and is executable; falls back to the pipx default so error
/// messages ("Expected at ") still make sense on a fresh machine.
nonisolated static var hermesBinary: String {
for path in hermesBinaryCandidates
where FileManager.default.isExecutableFile(atPath: path) {
return path
}
return hermesBinaryCandidates[0]
}
} }
// MARK: - SQLite Constants // MARK: - SQLite Constants
+56 -1
View File
@@ -24,6 +24,27 @@ actor ACPClient {
private(set) var currentSessionId: String? private(set) var currentSessionId: String?
private(set) var statusMessage = "" private(set) var statusMessage = ""
/// Ring buffer of recent stderr lines from `hermes acp` used to attach
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
/// growth when the subprocess logs heavily.
private var stderrBuffer: [String] = []
private static let stderrBufferMaxLines = 50
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
/// `hermes acp` subprocess, joined by newlines.
var recentStderr: String {
stderrBuffer.joined(separator: "\n")
}
fileprivate func appendStderr(_ text: String) {
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
stderrBuffer.append(String(line))
}
if stderrBuffer.count > Self.stderrBufferMaxLines {
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
}
}
/// Check if the underlying process is still alive and connected. /// Check if the underlying process is still alive and connected.
var isHealthy: Bool { var isHealthy: Bool {
guard isConnected, let process else { return false } guard isConnected, let process else { return false }
@@ -398,7 +419,8 @@ actor ACPClient {
await self?.handleReadLoopEnded() await self?.handleReadLoopEnded()
} }
// Read stderr in background for diagnostic logging // Read stderr in background for diagnostic logging AND ring-buffer
// capture so we can attach a tail to user-visible errors.
stderrTask = Task.detached { [weak self] in stderrTask = Task.detached { [weak self] in
let handle = stderr.fileHandleForReading let handle = stderr.fileHandleForReading
while !Task.isCancelled { while !Task.isCancelled {
@@ -407,6 +429,7 @@ actor ACPClient {
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty { !text.isEmpty {
await self?.logger.info("ACP stderr: \(text.prefix(500))") await self?.logger.info("ACP stderr: \(text.prefix(500))")
await self?.appendStderr(text)
} }
} }
} }
@@ -516,3 +539,35 @@ enum ACPClientError: Error, LocalizedError {
} }
} }
} }
/// Maps a raw error message (RPC message or captured stderr) to a short
/// human-readable hint for the chat UI. Pattern-matches the most common
/// fresh-install failure modes. Returns nil when no known pattern matches.
enum ACPErrorHint {
static func classify(errorMessage: String, stderrTail: String) -> String? {
let haystack = errorMessage + "\n" + stderrTail
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
options: .regularExpression) != nil
|| haystack.contains("ANTHROPIC_API_KEY")
|| haystack.contains("ANTHROPIC_TOKEN")
|| haystack.contains("claude setup-token")
|| haystack.contains("claude /login") {
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
}
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
options: .regularExpression) {
let matched = String(haystack[match])
if let nameStart = matched.range(of: "'"),
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
}
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
}
if haystack.localizedCaseInsensitiveContains("rate limit")
|| haystack.localizedCaseInsensitiveContains("429") {
return "Your AI provider returned a rate-limit error. Try again in a moment."
}
return nil
}
}
+163 -49
View File
@@ -1203,58 +1203,66 @@ struct HermesFileService: Sendable {
} }
nonisolated func hermesBinaryPath() -> String? { nonisolated func hermesBinaryPath() -> String? {
let candidates = [ // Single source of truth for install-location candidates lives in
("\(NSHomeDirectory())/.local/bin/hermes"), // HermesPaths.hermesBinaryCandidates keeps pipx/brew/manual lookups
"/opt/homebrew/bin/hermes", // consistent across the app.
"/usr/local/bin/hermes" return HermesPaths.hermesBinaryCandidates
] .first { FileManager.default.isExecutableFile(atPath: $0) }
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
} }
/// PATH cobbled together from the user's login shell needed because /// Keys queried from the user's login shell. PATH is needed because .app
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew, /// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`, /// nvm, no asdf, no mise). The credential keys are needed because Hermes
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such /// resolves AI provider auth by reading env vars a GUI-launched Scarf
/// file or directory`. Computed once and cached. /// subprocess sees none of the `export ANTHROPIC_API_KEY=` lines from
private static let enrichedPath: String = { /// the user's shell init files.
let pipe = Pipe() private static let shellEnvKeys: [String] = [
let errPipe = Pipe() "PATH",
let process = Process() "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL",
process.executableURL = URL(fileURLWithPath: "/bin/zsh") "OPENAI_API_KEY", "OPENAI_BASE_URL",
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc "OPENROUTER_API_KEY",
// chain on macOS) so PATH manipulations made there are picked up. "GEMINI_API_KEY", "GOOGLE_API_KEY",
// Skip -i to avoid hangs from interactive prompts. "GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY",
process.arguments = ["-l", "-c", "echo $PATH"] "CLAUDE_CODE_OAUTH_TOKEN"
process.standardOutput = pipe ]
process.standardError = errPipe
defer { /// Env vars harvested from the user's login shell. Computed once and cached.
try? pipe.fileHandleForReading.close() ///
try? pipe.fileHandleForWriting.close() /// Probing strategy two attempts, best result wins:
try? errPipe.fileHandleForReading.close() /// 1. `zsh -l -i` (login + interactive) sources BOTH `.zprofile` and
try? errPipe.fileHandleForWriting.close() /// `.zshrc`, which is required for nvm/asdf/mise PATH on most setups
/// (those tools inject PATH from `.zshrc`, not `.zprofile`).
/// Interactive mode can hang on prompt frameworks (oh-my-zsh,
/// powerlevel10k, starship) so we suppress prompts via env and bound
/// with a 5-second timeout.
/// 2. If that yields no PATH (timed out / prompt framework broke it),
/// fall back to `zsh -l` (login only) with a 3-second timeout.
/// 3. If that also fails, hardcoded sane-default PATH; no credentials.
private static let enrichedShellEnv: [String: String] = {
// Build a shell script that prints `KEY\0VALUE\0` for each key.
// Using printf with \0 as separator lets us unambiguously split the
// output even if a value contains newlines.
let script = shellEnvKeys.map { key in
#"printf '%s\0%s\0' "\#(key)" "$\#(key)""#
}.joined(separator: "; ")
// Attempt 1: login + interactive (covers nvm/asdf/mise in .zshrc).
if let result = runShellProbe(script: script, interactive: true, timeout: 5.0),
result["PATH"] != nil {
return result
} }
do { // Attempt 2: login only (safe fallback if interactive hangs).
try process.run() if let result = runShellProbe(script: script, interactive: false, timeout: 3.0),
let deadline = Date().addingTimeInterval(3) result["PATH"] != nil {
while process.isRunning && Date() < deadline { return result
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning { process.terminate() }
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let path = (String(data: data, encoding: .utf8) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if process.terminationStatus == 0 && !path.isEmpty {
return path
}
} catch {
// Fall through to default below.
} }
// Fallback when the login shell can't be queried (zsh missing, // Fallback when the login shell can't be queried (zsh missing,
// sandbox restriction, timeout). Covers Apple Silicon + Intel // sandbox restriction, timeout). Covers Apple Silicon + Intel
// Homebrew plus the standard system paths. // Homebrew plus the standard system paths. No credential env is
// inferred the user will see the missing-credentials hint instead.
let home = NSHomeDirectory() let home = NSHomeDirectory()
return [ let fallbackPath = [
"\(home)/.local/bin", "\(home)/.local/bin",
"/opt/homebrew/bin", "/opt/homebrew/bin",
"/usr/local/bin", "/usr/local/bin",
@@ -1263,18 +1271,124 @@ struct HermesFileService: Sendable {
"/usr/sbin", "/usr/sbin",
"/sbin" "/sbin"
].joined(separator: ":") ].joined(separator: ":")
return ["PATH": fallbackPath]
}() }()
/// Runs a zsh probe with the given script and returns the parsed
/// `KEY\0VALUE\0`-delimited output. Returns nil on timeout/failure.
/// When `interactive` is true, injects env vars that suppress common
/// prompt frameworks so the shell doesn't hang waiting for terminal setup.
private static func runShellProbe(script: String, interactive: Bool, timeout: TimeInterval) -> [String: String]? {
let pipe = Pipe()
let errPipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = interactive ? ["-l", "-i", "-c", script] : ["-l", "-c", script]
process.standardOutput = pipe
process.standardError = errPipe
if interactive {
// Defang prompt frameworks so -i doesn't hang on async prompt init.
// We still inherit the parent env (HOME, USER etc.) so rc files resolve.
var env = ProcessInfo.processInfo.environment
env["TERM"] = "dumb" // disables fancy prompt setup
env["PS1"] = ""
env["PROMPT"] = ""
env["RPROMPT"] = ""
env["POWERLEVEL9K_INSTANT_PROMPT"] = "off" // p10k
env["STARSHIP_DISABLE"] = "1" // starship (some versions)
env["ZSH_DISABLE_COMPFIX"] = "true" // oh-my-zsh compaudit hang
process.environment = env
}
defer {
try? pipe.fileHandleForReading.close()
try? pipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
process.terminate()
// Brief grace period for SIGTERM to take; then the defer
// cleanup closes the pipes regardless.
Thread.sleep(forTimeInterval: 0.1)
return nil
}
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0, !data.isEmpty else { return nil }
var result: [String: String] = [:]
let parts = data.split(separator: 0, omittingEmptySubsequences: false)
var i = 0
while i + 1 < parts.count {
if let key = String(data: Data(parts[i]), encoding: .utf8),
let value = String(data: Data(parts[i + 1]), encoding: .utf8),
!key.isEmpty, !value.isEmpty {
result[key] = value
}
i += 2
}
return result.isEmpty ? nil : result
} catch {
return nil
}
}
/// Environment to hand any subprocess that may itself spawn user-installed /// Environment to hand any subprocess that may itself spawn user-installed
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical /// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Starts
/// to ProcessInfo.processInfo.environment but with PATH replaced by the /// from ProcessInfo.environment and overlays PATH + allowlisted credential
/// login-shell PATH. /// env vars harvested from the user's login shell.
nonisolated static func enrichedEnvironment() -> [String: String] { nonisolated static func enrichedEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment var env = ProcessInfo.processInfo.environment
env["PATH"] = enrichedPath for (key, value) in enrichedShellEnv where !value.isEmpty {
// Shell wins for PATH (we explicitly want the enriched one). For
// credential keys, also let the shell win GUI env rarely has
// them, and if it does, the shell-exported value is usually the
// one the user actually maintains.
env[key] = value
}
return env return env
} }
/// True if any known AI-provider credential is reachable either already
/// in the current process env, present in the login-shell env we queried,
/// or present in `~/.hermes/.env`. Used by Chat to warn the user before
/// `hermes acp` fails on send with "No Anthropic credentials found".
nonisolated static func hasAnyAICredential() -> Bool {
let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
let env = enrichedEnvironment()
for key in credentialKeys {
if let value = env[key], !value.isEmpty {
return true
}
}
// Scan ~/.hermes/.env for KEY= lines. Uses a simple substring check
// good enough for a preflight hint; hermes itself does the real parse.
let envPath = HermesPaths.home + "/.env"
if let data = try? String(contentsOfFile: envPath, encoding: .utf8) {
for line in data.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") {
// Must have a non-empty value after `=`
if let eq = trimmed.firstIndex(of: "="),
trimmed.index(after: eq) < trimmed.endIndex {
let value = trimmed[trimmed.index(after: eq)...]
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
if !value.isEmpty { return true }
}
}
}
}
return false
}
@discardableResult @discardableResult
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) { nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
guard let binary = hermesBinaryPath() else { return (-1, "") } guard let binary = hermesBinaryPath() else { return (-1, "") }
@@ -30,6 +30,14 @@ final class ChatViewModel {
var isACPConnected: Bool { acpClient != nil && hasActiveProcess } var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
var acpStatus: String = "" var acpStatus: String = ""
var acpError: String? var acpError: String?
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
/// Shown above the raw error in the UI when present.
var acpErrorHint: String?
/// Tail of stderr captured from `hermes acp` at the time of the last
/// failure shown in a collapsible details section so users can copy/paste.
var acpErrorDetails: String?
/// True when `hasAnyAICredential()` returned false at last preflight.
var missingCredentials: Bool = false
private static let maxReconnectAttempts = 5 private static let maxReconnectAttempts = 5
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
@@ -39,6 +47,34 @@ final class ChatViewModel {
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
} }
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
/// updates `missingCredentials`. Cheap safe to call from view `.task`.
func refreshCredentialPreflight() {
missingCredentials = !HermesFileService.hasAnyAICredential()
}
/// Clears the error/hint/details triplet so future failures overwrite
/// cleanly instead of stacking on top of stale state.
private func clearACPErrorState() {
acpError = nil
acpErrorHint = nil
acpErrorDetails = nil
}
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
/// stderr tail the ACP client captured, and logs the failure with a
/// site-specific context label. Call on any failure path.
@MainActor
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
let msg = error.localizedDescription
logger.error("\(context): \(msg)")
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
}
// MARK: - Session Lifecycle // MARK: - Session Lifecycle
func startNewSession() { func startNewSession() {
@@ -157,10 +193,8 @@ final class ChatViewModel {
// Now send the queued prompt // Now send the queued prompt
sendViaACP(client: client, text: text) sendViaACP(client: client, text: text)
} catch { } catch {
let msg = error.localizedDescription
logger.error("Auto-start ACP failed: \(msg)")
acpStatus = "Failed" acpStatus = "Failed"
acpError = msg await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
hasActiveProcess = false hasActiveProcess = false
acpClient = nil acpClient = nil
} }
@@ -169,6 +203,7 @@ final class ChatViewModel {
private func sendViaACP(client: ACPClient, text: String) { private func sendViaACP(client: ACPClient, text: String) {
guard let sessionId = richChatViewModel.sessionId else { guard let sessionId = richChatViewModel.sessionId else {
clearACPErrorState()
acpError = "No session ID — cannot send" acpError = "No session ID — cannot send"
return return
} }
@@ -192,10 +227,8 @@ final class ChatViewModel {
} catch is CancellationError { } catch is CancellationError {
acpStatus = "Cancelled" acpStatus = "Cancelled"
} catch { } catch {
let msg = error.localizedDescription
logger.error("ACP prompt failed: \(msg)")
acpStatus = "Error" acpStatus = "Error"
acpError = msg await recordACPFailure(error, client: client, context: "ACP prompt failed")
richChatViewModel.handleACPEvent( richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: ACPPromptResult( .promptComplete(sessionId: sessionId, response: ACPPromptResult(
stopReason: "error", stopReason: "error",
@@ -211,7 +244,7 @@ final class ChatViewModel {
private func startACPSession(resume sessionId: String?) { private func startACPSession(resume sessionId: String?) {
stopACP() stopACP()
acpError = nil clearACPErrorState()
acpStatus = "Starting..." acpStatus = "Starting..."
let client = ACPClient() let client = ACPClient()
@@ -259,10 +292,8 @@ final class ChatViewModel {
logger.info("ACP session ready: \(resolvedSessionId)") logger.info("ACP session ready: \(resolvedSessionId)")
} catch { } catch {
let msg = error.localizedDescription
logger.error("Failed to start ACP session: \(msg)")
acpStatus = "Failed" acpStatus = "Failed"
acpError = msg await recordACPFailure(error, client: client, context: "Failed to start ACP session")
hasActiveProcess = false hasActiveProcess = false
acpClient = nil acpClient = nil
} }
@@ -333,7 +364,7 @@ final class ChatViewModel {
private func attemptReconnect(sessionId: String) { private func attemptReconnect(sessionId: String) {
reconnectTask?.cancel() reconnectTask?.cancel()
acpError = nil clearACPErrorState()
reconnectTask = Task { @MainActor [weak self] in reconnectTask = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
@@ -379,7 +410,7 @@ final class ChatViewModel {
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId) await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))" acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
acpError = nil clearACPErrorState()
startACPEventLoop(client: client) startACPEventLoop(client: client)
startHealthMonitor(client: client) startHealthMonitor(client: client)
@@ -404,6 +435,7 @@ final class ChatViewModel {
private func showConnectionFailure() { private func showConnectionFailure() {
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly")) richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
acpStatus = "Connection lost" acpStatus = "Connection lost"
clearACPErrorState()
acpError = "Connection lost. Use the Session menu to reconnect." acpError = "Connection lost. Use the Session menu to reconnect."
} }
+96 -1
View File
@@ -3,18 +3,113 @@ import SwiftUI
struct ChatView: View { struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel @Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
@State private var showErrorDetails = false
var body: some View { var body: some View {
@Bindable var vm = viewModel @Bindable var vm = viewModel
VStack(spacing: 0) { VStack(spacing: 0) {
toolbar toolbar
Divider() Divider()
errorBanner
chatArea chatArea
} }
.navigationTitle("Chat") .navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() } .task {
await viewModel.loadRecentSessions()
viewModel.refreshCredentialPreflight()
}
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() } Task { await viewModel.loadRecentSessions() }
viewModel.refreshCredentialPreflight()
}
}
/// Banner rendered between the toolbar and the chat area when either
/// (a) a preflight credential check failed, or (b) the ACP subprocess
/// returned an error we captured. Shows a short hint + expandable raw
/// details (stderr tail) that the user can copy to the clipboard.
@ViewBuilder
private var errorBanner: some View {
if let err = viewModel.acpError {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
if let hint = viewModel.acpErrorHint {
Text(hint)
.font(.callout)
.textSelection(.enabled)
}
Text(err)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(showErrorDetails ? nil : 2)
}
Spacer()
if viewModel.acpErrorDetails != nil {
Button(showErrorDetails ? "Hide details" : "Show details") {
showErrorDetails.toggle()
}
.buttonStyle(.borderless)
.controlSize(.small)
}
Button {
let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails]
.compactMap { $0 }
.joined(separator: "\n\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(payload, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy error details")
}
if showErrorDetails, let details = viewModel.acpErrorDetails {
ScrollView {
Text(details)
.font(.system(.caption2, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 160)
.padding(8)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(10)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle()
.fill(Color.orange.opacity(0.25))
.frame(height: 1),
alignment: .bottom
)
} else if viewModel.missingCredentials && !viewModel.hasActiveProcess {
HStack(spacing: 8) {
Image(systemName: "key.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("No AI provider credentials detected")
.font(.callout)
Text("Add `ANTHROPIC_API_KEY` (or similar) to `~/.hermes/.env` or your shell profile, then restart Scarf.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(10)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle()
.fill(Color.orange.opacity(0.25))
.frame(height: 1),
alignment: .bottom
)
} }
} }