From b66ed7e8d7c25d218cbb5b5df9caa513c9afd5c8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 13:29:16 +0200 Subject: [PATCH] fix(kanban): show stderr-only in error banner, parse stdout-only as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KanbanViewModel.load` previously assigned the combined stdout+stderr output of `runHermesCLI` into both the JSON-parse `data` and the `stderr` slot of its result tuple. Two consequences: - On non-zero exit, the error banner showed combined output (often stdout usage text concatenated with the actual error), reducing the signal-to-noise ratio when troubleshooting. - On non-zero exit with mixed output, JSON decoding could fail because stderr text was prepended to the JSON body. Added `HermesFileService.runHermesCLISplit` — a sibling of `runHermesCLI` that returns `(exitCode, stdout, stderr)` separately, leaning on the already-separated `stdoutString` / `stderrString` from the transport layer. KanbanViewModel now uses it: stdout is the JSON parse target, stderr is the error-banner source. Existing `runHermesCLI` callers are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/HermesFileService.swift | 33 +++++++++++++++++++ .../Kanban/ViewModels/KanbanViewModel.swift | 15 ++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 57e139a..2d21c23 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -1577,6 +1577,39 @@ struct HermesFileService: Sendable { } } + /// Split-stream variant of `runHermesCLI`. Use this when you need to + /// parse stdout (e.g. JSON output) without stderr contamination, and + /// surface stderr separately as a user-facing error message. Transport + /// failures land in `stderr` with an empty `stdout`. + @discardableResult + nonisolated func runHermesCLISplit(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, stdout: String, stderr: String) { + let binary: String + if context.isRemote { + binary = context.paths.hermesBinary + } else { + guard let local = hermesBinaryPath() else { return (-1, "", "hermes binary not found") } + binary = local + } + + let stdinData = stdinInput?.data(using: .utf8) + do { + let result = try transport.runProcess( + executable: binary, + args: args, + stdin: stdinData, + timeout: timeout + ) + return (result.exitCode, result.stdoutString, result.stderrString) + } catch let error as TransportError { + let message = error.diagnosticStderr.isEmpty + ? (error.errorDescription ?? "transport error") + : error.diagnosticStderr + return (-1, "", message) + } catch { + return (-1, "", error.localizedDescription) + } + } + // MARK: - File I/O /// Read a UTF-8 text file through the transport. Missing files and any diff --git a/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift b/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift index f6ae882..52dadfc 100644 --- a/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift +++ b/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift @@ -71,18 +71,17 @@ final class KanbanViewModel { isLoading = true let svc = fileService let filter = statusFilter - let result = await Task.detached { () -> (data: Data?, exitCode: Int32, stderr: String) in + let result = await Task.detached { () -> (exitCode: Int32, stdout: String, stderr: String) in var args = ["kanban", "list", "--json"] if filter != .all { args.append(contentsOf: ["--status", filter.rawValue]) } - let r = svc.runHermesCLI(args: args, timeout: 15) - return (r.output.data(using: .utf8), r.exitCode, r.output) + return svc.runHermesCLISplit(args: args, timeout: 15) }.value defer { isLoading = false } - guard result.exitCode == 0, let data = result.data else { + guard result.exitCode == 0 else { lastError = result.stderr.isEmpty ? "kanban list failed (\(result.exitCode))" : result.stderr @@ -90,6 +89,12 @@ final class KanbanViewModel { return } + guard let data = result.stdout.data(using: .utf8) else { + lastError = "kanban list returned non-UTF8 output" + tasks = [] + return + } + do { let decoded = try JSONDecoder().decode([HermesKanbanTask].self, from: data) tasks = decoded @@ -98,7 +103,7 @@ final class KanbanViewModel { // Hermes may print a "no matching tasks" line as text instead of // empty JSON; handle gracefully so the UI shows an empty list // without raising an error banner. - if String(data: data, encoding: .utf8)?.contains("no matching tasks") == true { + if result.stdout.contains("no matching tasks") { tasks = [] lastError = nil return