fix(kanban): show stderr-only in error banner, parse stdout-only as JSON

`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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 13:29:16 +02:00
parent 46cec816ec
commit b66ed7e8d7
2 changed files with 43 additions and 5 deletions
@@ -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
@@ -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