From 096dbe085e3e509e6b393a6a1c9ac1e76064a286 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 20:19:50 +0200 Subject: [PATCH 1/2] feat(chat): richer slash menu in resumed sessions; preserve agent commands across reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes uncovered by v2.8.0 dogfooding when clicking a previous chat in the sidebar (vs. starting a new one): 1. **Preserve `acpCommands` across `RichChatViewModel.reset()`**. Hermes ACP only emits `available_commands_update` after `session/new`, not after `session/load`. Wiping the cached set on every session switch meant resumed sessions landed at a 4-command fallback even though the agent identity (and therefore the command list) hadn't changed. The new comment in `reset()` documents the rationale; the host-switch case still tears down the whole `ContextBoundRoot`, so stale carry-over isn't reachable when the agent identity does change. 2. **Expand the static fallback when a session is active**. Adds the agent-level command set (`/clear`, `/compact`, `/cost`, `/model`, `/tools`, `/reload-skills`, `/help`) to `alwaysAvailableCommands` when `sessionId != nil`. `/new` continues to show in both states. Pre-session, only `/new` surfaces — the others all require a live session, and surfacing them would mislead. Deduped by name against the ACP-advertised set so the richer (server-authoritative) description / argument hint wins once Hermes does emit them. The two fixes together cover all paths to the slash menu: - Cold start, click resume → fix #2 paints the active-session set - Hot path, switch sessions after a `session/new` → fix #1 keeps the ACP-advertised set in `acpCommands` - Cold start, click "+ New" → ACP populates as before; unchanged Discovered during v2.8.0 dogfooding against a live Hermes v0.13.0 host. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesSlashCommand.swift | 8 ++ .../ViewModels/RichChatViewModel.swift | 108 +++++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift index 2f2bd74..99a99cb 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift @@ -24,6 +24,14 @@ public struct HermesSlashCommand: Identifiable, Sendable, Equatable { /// "agent working" indicator on; the guidance applies after the /// next tool call. Added in v2.5 alongside Hermes v2026.4.23. case acpNonInterruptive + /// ACP-native commands Hermes always supports but only advertises + /// inside an active session via `available_commands_update`. + /// Surfacing a small static fallback pre-session lets the slash + /// menu offer discoverable affordances like `/new` even before + /// the user has opened a session. Once a session starts, the + /// ACP-advertised version takes over (deduped by name in + /// `availableCommands`). Added v2.8 alongside Hermes v0.13. + case alwaysAvailable } public var id: String { name } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index c80d3b9..8895db0 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -290,6 +290,82 @@ public final class RichChatViewModel { ) ] + /// Static fallback commands Hermes ACP always supports but only + /// advertises via `available_commands_update` after `session/new` — + /// not after `session/load`. Without this fallback, resumed sessions + /// (and "no active session" cold starts) showed an artificially + /// sparse menu. With this list, the menu is discoverable everywhere; + /// when the ACP-advertised version arrives, dedupe-by-name in + /// `availableCommands` ensures the canonical (richer description, + /// authoritative argument hint) entry wins. + /// + /// The set splits on whether a session is active: + /// - **Always** (no session AND active session): `/new`. It's the + /// "open a session" affordance and arms the v0.13+ `[]` + /// argument hint via `hasNewWithSessionName`. + /// - **Active-session-only**: `/clear`, `/compact`, `/cost`, `/model`, + /// `/tools`, `/reload-skills`, `/help`, `/exit`. Each requires a + /// live session; surfacing them pre-session would mislead. + public static func alwaysAvailableCommands( + capabilities: HermesCapabilities, + hasActiveSession: Bool + ) -> [HermesSlashCommand] { + var result: [HermesSlashCommand] = [ + HermesSlashCommand( + name: "new", + description: "Start a new chat session", + argumentHint: capabilities.hasNewWithSessionName ? "[]" : nil, + source: .alwaysAvailable + ) + ] + guard hasActiveSession else { return result } + result.append(contentsOf: [ + HermesSlashCommand( + name: "clear", + description: "Clear the current conversation", + argumentHint: nil, + source: .alwaysAvailable + ), + HermesSlashCommand( + name: "compact", + description: "Compress the conversation history", + argumentHint: nil, + source: .alwaysAvailable + ), + HermesSlashCommand( + name: "cost", + description: "Show cost breakdown for this session", + argumentHint: nil, + source: .alwaysAvailable + ), + HermesSlashCommand( + name: "model", + description: "Switch the active model", + argumentHint: "[]", + source: .alwaysAvailable + ), + HermesSlashCommand( + name: "tools", + description: "Manage tool availability", + argumentHint: nil, + source: .alwaysAvailable + ), + HermesSlashCommand( + name: "reload-skills", + description: "Reload the skills index", + argumentHint: nil, + source: .alwaysAvailable + ), + HermesSlashCommand( + name: "help", + description: "Show available commands", + argumentHint: nil, + source: .alwaysAvailable + ) + ]) + return result + } + /// Capability snapshot the chat surface uses to filter /// `availableCommands`. Set by the chat controller (Mac /// `ChatViewModel`, iOS `ChatController`) at session-start time and @@ -396,7 +472,20 @@ public final class RichChatViewModel { } } let nonInterruptive = supported.filter { !occupied.contains($0.name) } - return acpCommands + projectAsHermes + quicks + nonInterruptive + // Static fallbacks. `/new` always shows; the rest of the agent- + // level command set (`/clear`, `/compact`, `/cost`, `/model`, + // `/tools`, `/reload-skills`, `/help`, `/exit`) only when a + // session is active — Hermes ACP doesn't re-emit + // `available_commands_update` after `session/load`, so without + // this fallback resumed sessions showed an artificially sparse + // menu. Deduped against ACP / project / quick names so once a + // session starts and the ACP server advertises its richer + // versions, the ACP-sourced entry wins. + let alwaysAvailable = Self.alwaysAvailableCommands( + capabilities: capabilitiesGate, + hasActiveSession: sessionId != nil + ).filter { !occupied.contains($0.name) } + return acpCommands + projectAsHermes + quicks + nonInterruptive + alwaysAvailable } /// Publish a fresh capabilities snapshot from the controller. @@ -615,7 +704,22 @@ public final class RichChatViewModel { acpErrorDetails = nil acpCachedReadTokens = 0 acpCompressionCount = 0 - acpCommands = [] + // `acpCommands` is intentionally NOT cleared. ACP slash commands + // are agent-level (advertised once per process via + // `available_commands_update` typically piggy-backing on + // `session/new`); they don't change when the user switches + // sessions. Hermes does not re-emit on `session/load`, so if + // we wipe here, resumed sessions land at a 4-command fallback + // until the user starts a fresh session — exactly the dogfood + // bug surfaced during v2.8.0 testing. The caller paths + // (startNewSession, resumeSession, continueLastSession) all + // spawn a fresh ACP subprocess; if that subprocess emits a + // fresh list, our value is replaced; if it doesn't, we keep + // the most recently-known agent-level set, which stays + // accurate as long as the agent identity hasn't changed. The + // host-switch case (Local → SSH) tears down the whole + // ContextBoundRoot so this stale carry-over isn't reachable + // there. See WS-2 / v2.8.0 dogfood report. projectScopedCommands = [] currentTurnStart = nil turnDurations = [:] From e26acaff4e6304c3271e0e1525fc611fed6c36ae Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 20:43:34 +0200 Subject: [PATCH 2/2] fix(chat): drop forward-looking version labels + add /exit to alwaysAvailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from code review on this branch: 1. Drop forward-looking Scarf version labels per the `feedback_no_version_bumps.md` rule (release notes own version labels, not in-code comments). "Added v2.8 alongside Hermes v0.13." becomes "Introduced alongside Hermes v0.13." on `HermesSlashCommand.Source.alwaysAvailable`. The `reset()` explanatory block in `RichChatViewModel` drops the two "v2.8.0" references — the rationale is unchanged, just stops marking the change with a Scarf-side version it might never ship under. 2. Add `/exit` to the active-session-only fallback set so the implementation matches the doc comment. The doc listed eight commands (`/clear`, `/compact`, `/cost`, `/model`, `/tools`, `/reload-skills`, `/help`, `/exit`) but only seven were appended. Adding `/exit` is the right call since it's a real Hermes ACP command; users typing `/exit` on a resumed session will now discover and dispatch it before the ACP-advertised version arrives. Tests: M9SlashCommandTests 30/30 green, Mac scheme builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesSlashCommand.swift | 2 +- .../ScarfCore/ViewModels/RichChatViewModel.swift | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift index 99a99cb..5c94884 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift @@ -30,7 +30,7 @@ public struct HermesSlashCommand: Identifiable, Sendable, Equatable { /// menu offer discoverable affordances like `/new` even before /// the user has opened a session. Once a session starts, the /// ACP-advertised version takes over (deduped by name in - /// `availableCommands`). Added v2.8 alongside Hermes v0.13. + /// `availableCommands`). Introduced alongside Hermes v0.13. case alwaysAvailable } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 8895db0..95150b8 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -361,6 +361,12 @@ public final class RichChatViewModel { description: "Show available commands", argumentHint: nil, source: .alwaysAvailable + ), + HermesSlashCommand( + name: "exit", + description: "End the current session", + argumentHint: nil, + source: .alwaysAvailable ) ]) return result @@ -710,8 +716,8 @@ public final class RichChatViewModel { // `session/new`); they don't change when the user switches // sessions. Hermes does not re-emit on `session/load`, so if // we wipe here, resumed sessions land at a 4-command fallback - // until the user starts a fresh session — exactly the dogfood - // bug surfaced during v2.8.0 testing. The caller paths + // until the user starts a fresh session — observed during + // dogfooding against a Hermes v0.13 host. The caller paths // (startNewSession, resumeSession, continueLastSession) all // spawn a fresh ACP subprocess; if that subprocess emits a // fresh list, our value is replaced; if it doesn't, we keep @@ -719,7 +725,7 @@ public final class RichChatViewModel { // accurate as long as the agent identity hasn't changed. The // host-switch case (Local → SSH) tears down the whole // ContextBoundRoot so this stale carry-over isn't reachable - // there. See WS-2 / v2.8.0 dogfood report. + // there. projectScopedCommands = [] currentTurnStart = nil turnDurations = [:]