mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -19,10 +19,30 @@ enum HermesPaths: Sendable {
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||
nonisolated static let agentLog: String = home + "/logs/agent.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 projectsRegistry: String = scarfDir + "/projects.json"
|
||||
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
|
||||
|
||||
@@ -24,6 +24,27 @@ actor ACPClient {
|
||||
private(set) var currentSessionId: String?
|
||||
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.
|
||||
var isHealthy: Bool {
|
||||
guard isConnected, let process else { return false }
|
||||
@@ -398,7 +419,8 @@ actor ACPClient {
|
||||
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
|
||||
let handle = stderr.fileHandleForReading
|
||||
while !Task.isCancelled {
|
||||
@@ -407,6 +429,7 @@ actor ACPClient {
|
||||
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,58 +1203,66 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
nonisolated func hermesBinaryPath() -> String? {
|
||||
let candidates = [
|
||||
("\(NSHomeDirectory())/.local/bin/hermes"),
|
||||
"/opt/homebrew/bin/hermes",
|
||||
"/usr/local/bin/hermes"
|
||||
]
|
||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
// Single source of truth for install-location candidates lives in
|
||||
// HermesPaths.hermesBinaryCandidates — keeps pipx/brew/manual lookups
|
||||
// consistent across the app.
|
||||
return HermesPaths.hermesBinaryCandidates
|
||||
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
/// PATH cobbled together from the user's login shell — needed because
|
||||
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew,
|
||||
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`,
|
||||
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such
|
||||
/// file or directory`. Computed once and cached.
|
||||
private static let enrichedPath: String = {
|
||||
let pipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc
|
||||
// chain on macOS) so PATH manipulations made there are picked up.
|
||||
// Skip -i to avoid hangs from interactive prompts.
|
||||
process.arguments = ["-l", "-c", "echo $PATH"]
|
||||
process.standardOutput = pipe
|
||||
process.standardError = errPipe
|
||||
defer {
|
||||
try? pipe.fileHandleForReading.close()
|
||||
try? pipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
/// Keys queried from the user's login shell. PATH is needed because .app
|
||||
/// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no
|
||||
/// nvm, no asdf, no mise). The credential keys are needed because Hermes
|
||||
/// resolves AI provider auth by reading env vars — a GUI-launched Scarf
|
||||
/// subprocess sees none of the `export ANTHROPIC_API_KEY=…` lines from
|
||||
/// the user's shell init files.
|
||||
private static let shellEnvKeys: [String] = [
|
||||
"PATH",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN"
|
||||
]
|
||||
|
||||
/// Env vars harvested from the user's login shell. Computed once and cached.
|
||||
///
|
||||
/// Probing strategy — two attempts, best result wins:
|
||||
/// 1. `zsh -l -i` (login + interactive) — sources BOTH `.zprofile` and
|
||||
/// `.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 {
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while process.isRunning && Date() < deadline {
|
||||
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.
|
||||
// Attempt 2: login only (safe fallback if interactive hangs).
|
||||
if let result = runShellProbe(script: script, interactive: false, timeout: 3.0),
|
||||
result["PATH"] != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback when the login shell can't be queried (zsh missing,
|
||||
// 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()
|
||||
return [
|
||||
let fallbackPath = [
|
||||
"\(home)/.local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
@@ -1263,18 +1271,124 @@ struct HermesFileService: Sendable {
|
||||
"/usr/sbin",
|
||||
"/sbin"
|
||||
].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
|
||||
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical
|
||||
/// to ProcessInfo.processInfo.environment but with PATH replaced by the
|
||||
/// login-shell PATH.
|
||||
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Starts
|
||||
/// from ProcessInfo.environment and overlays PATH + allowlisted credential
|
||||
/// env vars harvested from the user's login shell.
|
||||
nonisolated static func enrichedEnvironment() -> [String: String] {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
||||
|
||||
@@ -30,6 +30,14 @@ final class ChatViewModel {
|
||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||
var acpStatus: 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 reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
@@ -39,6 +47,34 @@ final class ChatViewModel {
|
||||
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
|
||||
|
||||
func startNewSession() {
|
||||
@@ -157,10 +193,8 @@ final class ChatViewModel {
|
||||
// Now send the queued prompt
|
||||
sendViaACP(client: client, text: text)
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Auto-start ACP failed: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
@@ -169,6 +203,7 @@ final class ChatViewModel {
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
acpError = "No session ID — cannot send"
|
||||
return
|
||||
}
|
||||
@@ -192,10 +227,8 @@ final class ChatViewModel {
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("ACP prompt failed: \(msg)")
|
||||
acpStatus = "Error"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "ACP prompt failed")
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||
stopReason: "error",
|
||||
@@ -211,7 +244,7 @@ final class ChatViewModel {
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
stopACP()
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient()
|
||||
@@ -259,10 +292,8 @@ final class ChatViewModel {
|
||||
|
||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Failed to start ACP session: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
@@ -333,7 +364,7 @@ final class ChatViewModel {
|
||||
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -379,7 +410,7 @@ final class ChatViewModel {
|
||||
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
|
||||
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
@@ -404,6 +435,7 @@ final class ChatViewModel {
|
||||
private func showConnectionFailure() {
|
||||
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||
acpStatus = "Connection lost"
|
||||
clearACPErrorState()
|
||||
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,113 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.task { await viewModel.loadRecentSessions() }
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user