mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user