mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
1fcd963019c7d343122493f6ee2bf96ceb58d088
37 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1fcd963019 |
feat(chat): numbered shortcuts on permission sheet (Phase 2.3)
Hermes v2026.4.23's TUI rewrite added 1-9 numbered shortcuts on approval prompts so power users approve/deny without reaching for the mouse. Mirror the pattern in Scarf: Mac PermissionApprovalView: - Each option button gets a "1. ", "2. ", … prefix on its label. - New private View extension `applyingNumberShortcut(index:)` binds the digit `idx + 1` (no modifiers) via .keyboardShortcut. Capped at 9; extra options stay tappable but unbound. iOS PermissionSheet: - Each row gets a monospaced "1." / "2." prefix as a hierarchy hint. - No keyboard binding (phones don't have hardware keyboards), but the numbering matches the Mac pattern so users transitioning between platforms see the same visual structure. Verified: Mac + iOS builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
70d4c97a6c |
feat(chat): per-turn stopwatch on assistant bubbles (Phase 2.2)
Wall-clock duration of each agent turn renders as a compact pill in the message metadata footer (Mac) / below the bubble (iOS). Mirrors the per-turn stopwatch Hermes v2026.4.23's TUI rewrite ships. ScarfCore RichChatViewModel: - currentTurnStart: Date? captured in addUserMessage when entering a fresh turn (skipped for /steer-style mid-run sends so the duration reflects the FULL turn). - turnDurations: [Int: TimeInterval] keyed by finalised assistant message id; populated in finalizeStreamingMessage and cleared on reset(). - formatTurnDuration(_:) static — "0.8s" / "4.2s" / "1m 12s". Mac: - RichMessageBubble gains turnDuration: TimeInterval?; renders via formatTurnDuration in the existing metadata footer. - RichChatMessageList + MessageGroupView thread the durations dict through; RichChatView wires richChat.turnDurations. iOS: - MessageBubble gains turnDuration parameter; renders below the bubble for assistant messages only. - ChatView's ForEach passes controller.vm.turnDuration(forMessageId:). Verified: Mac + iOS builds clean. Resumed sessions (loaded from state.db) show no pill — turnDurations only populates for live ACP turns, which is the correct behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a9bd51bf05 |
feat(chat): /steer non-interruptive support (Phase 2.1)
Hermes v2026.4.23 introduces /steer — mid-run guidance the agent applies after the next tool call without interrupting the current turn. Surface it as a first-class slash command in both Mac and iOS chat menus with non-interruptive send semantics. ScarfCore RichChatViewModel: - nonInterruptiveCommands static (currently just /steer) merged into availableCommands at the end of the menu. - HermesSlashCommand.Source.acpNonInterruptive case carries the flag through to the menu UI. - transientHint: String? property for short-lived composer toasts. - isNonInterruptiveSlash(_ text: String) -> Bool helper for the send paths to detect /steer-shaped invocations. Mac ChatViewModel.sendViaACP: - /steer-shaped sends skip the "Agent working..." status update (the agent is already on its current turn) and set a 4-second transientHint "Guidance queued — applies after the next tool call." Mac RichChatView: - New steeringToast() above the input bar renders the hint when set; tinted pill with arrow icon, opacity transition. iOS ChatController.send + ChatView: - Same isNonInterruptiveSlash check surfaces the toast above the composer; auto-clears via the same 4s Task pattern. - steeringToast() helper view in ChatView. Verified: ScarfCore + Mac + iOS builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7f5ff1946e |
feat(slash-commands): ScarfGo read-only browser sheet (Phase 1.7)
Read-only surface in iOS for browsing project-scoped slash commands. Editing on phones is its own UX problem (multi-line markdown + keyboard ergonomics) — Mac stays the canonical authoring surface in v2.5; iOS browses + invokes. When a project chat has at least one slash command loaded, projectContextBar grows a tinted "<N> slash" chip on the right side. Tapping opens ProjectSlashCommandsBrowser: - List of every command with /<name>, description, argument hint, optional model-override badge. - Tap a row → CommandDetailSheet with the full prompt-template body rendered in a monospaced block (text-selection enabled), plus metadata rows for argumentHint / model / tags. - Footer points authors back to Mac for editing. Verified: iOS build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8a87ff1922 |
feat(slash-commands): list project commands in AGENTS.md block (Phase 1.5)
The chat layer client-side-expands /<name> args, but the agent still needs to know what commands exist so it can answer "what slash commands does this project have?" and recognise the <!-- scarf-slash:<name> --> marker prepended to expanded prompts. ProjectContextBlock.renderMinimalBlock(...) gains an optional slashCommandNames parameter; when non-empty, a new "Project slash commands" bullet lists the names as backticked /<name> entries. Mac's ProjectAgentContextService.renderBlock(for:) reads the names via ProjectSlashCommandService.loadCommands(at:).map(\.name) and emits the same bullet, keeping Mac and iOS block output aligned where the content overlaps. iOS chat resetAndStartInProject splits the slash-command load into a synchronous read on a detached task BEFORE writing the block — needed because the block has to land on disk before `hermes acp` boots, and the async load that populates the chat menu would lose the race. Verified: ScarfCore, Mac, iOS all build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6808adfa98 |
feat(slash-commands): portable project-scoped slash commands (Phase 1.1-1.4)
Net-new Scarf primitive — Hermes has no project-scoped slash command
concept. Commands live at <project>/.scarf/slash-commands/<name>.md as
Markdown files with YAML frontmatter; Scarf intercepts the chat slash
menu, expands {{argument}} substitution client-side, and sends the
expanded prompt as a normal user message. Works uniformly on Mac + iOS,
local + remote SSH, against any Hermes version (no upstream dep).
Lands the model + service + chat wiring; editor UI (Mac), read-only
browser (iOS), AGENTS.md block extension, .scarftemplate format
extension, and tests follow in subsequent commits.
What this commit ships:
- ScarfCore Models/ProjectSlashCommand.swift — Sendable struct
carrying name + description + argumentHint? + model? + tags? + body
+ sourcePath. Validates name shape (lowercase, hyphens, starts with
letter, ≤64 chars).
- ScarfCore Services/ProjectSlashCommandService.swift — transport-
based loadCommands(at:), loadCommand(at:), save(_:at:),
delete(named:at:), expand(_:withArgument:). Markdown-with-
frontmatter parser reuses HermesYAML so no new dep. Substitution
supports `{{argument}}` and `{{argument | default: "..."}}`.
- HermesSlashCommand.Source gains .projectScoped (full payload looked
up in RichChatViewModel by name) and .acpNonInterruptive (reserved
for /steer in Phase 2.1).
- RichChatViewModel.projectScopedCommands + projectScopedCommand(named:)
+ loadProjectScopedCommands(at:); availableCommands precedence is
ACP > project-scoped > quick_commands, all de-duped by name.
- Mac ChatViewModel: expandIfProjectScoped(_:) helper called in
sendViaACP; loads commands when currentProjectPath is set in
startACPSession's resolution branch.
- iOS ChatController: same pattern in send(); loads commands in both
resetAndStartInProject and startResuming(sessionID:); resume now
resolves both path AND name so we can read the slash-commands dir.
Verified: ScarfCore + Mac + iOS all build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4fc12ca790 |
fix(ios-notifications): feature-gate Approve/Deny stub actions
Push Notifications capability is disabled in the iOS target, so the APNS code path can't fire today — but the `SCARF_PENDING_PERMISSION` category was registered unconditionally, exposing the stub-only `APPROVE_PERMISSION` / `DENY_PERMISSION` action handlers as a route iOS could surface action buttons on if a notification ever slipped through. Add `NotificationRouter.apnsEnabled` (=`false`) and gate `registerCategories()` behind it. While `false`, the category is explicitly cleared so iOS has no path to route a tap to the stubs. The gate is the single switch — flipping it requires the capability + sender + real handler implementations to all land together. Verified: iOS build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3da3d3ce5e |
fix(ios-rootmodel): surface store failures (A.3 + A.4 bundled)
Bundled because the fixes are coherent — they all add the same mechanism (`lastError` + `os.Logger`) to the same model. A.3 — Distinguish "no servers" from "Keychain unreachable": - `RootModel.connect(to:)` previously used `try?` on `keyStore.load(for:)`. A biometric cancel or device-locked Keychain read returned nil → the app dropped the user into fresh onboarding, destroying the existing server's host/user/port. Now we catch the throw, log via os.Logger, set `lastError`, and stay on `.serverList`. The user sees a banner + Dismiss button instead of being kicked back to onboarding. - `RootModel.load()` now logs the corrupted-blob path via os.Logger and sets `lastError` before falling through to onboarding (recovery is necessary, but the user gets context now). A.4 — Surface delete failures in `forget()` and `disconnect()`: - Both used `try?` on every store delete. On partial failure the in-memory dict was wiped while orphan Keychain entries lingered. Now each delete is `do/catch` with logging, failures collected into `lastError`. The in-memory state is reloaded from disk so it tracks what's actually persisted (covers the partial-failure case). ServerListView gains an inline error banner above the list that reads `model.lastError`, with a Dismiss button calling `clearLastError()`. Verified: iOS build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
48e99f2c43 |
fix(ios-chat): surface project context block write failures
ChatController.resetAndStartInProject swallowed the SFTP write of the Scarf-managed AGENTS.md block via `try?` inside `Task.detached`. On failure (permission denied, SFTP error, malformed path) the user saw no feedback while the UI continued claiming the session was project-scoped — but the agent never received the project context, leading to silently degraded chat quality. Replace the `try? + fire-and-forget` with a `Result`-returning detached task. On `.failure`, log the underlying error via `os.Logger` and route it to the existing ACP error banner (`acpError` / `acpErrorHint` / `acpErrorDetails`) with a friendly "Project context not written — agent will proceed without it" payload. Session still starts; only the context-augmentation step is reported as missing. The session-attribution write at the same flow stays fire-and-forget by design — `SessionAttributionService.persist` already logs failures internally, and a missed attribution is purely cosmetic (Dashboard project-badge cosmetics, not chat function). Replaced the comment to make that intent explicit so future readers don't accidentally "fix" it by promoting attribution failures to the chat banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
54a0797334 |
M9 #4.6 (pass-2): Dashboard Overview/Sessions split + chat project bar
Pass-2 feedback bundled into one architectural commit:
1. **Project indicator moved out of the nav-bar principal slot.** The
iPhone nav bar's .principal area gets squeezed to icon-only when
adjacent toolbar buttons exist — the result was a folder icon with
no project-name text, which is worse than no indicator at all. New
`projectContextBar` renders a full-width tinted strip BELOW the
nav bar when a session is project-attributed: "Project chat"
caption + folder icon + full project name. Scrolls away with the
message list. Pattern cribbed from Slack's channel-topic header
and Apple Mail's sender strip.
2. **Dashboard split into Overview + Sessions sub-tabs.** Segmented
picker at the top. Overview = stats + 5 most-recent sessions for
at-a-glance; Sessions = the deeper 25-session list with a project
filter. `See all` button on Overview's Recent Sessions header
switches tabs. Addresses pass-2 complaint: "The dashboard might
need tabs to break it down better."
3. **Project filter on the Sessions sub-tab.** Menu picker (scales
to N projects; segmented doesn't). "All projects" clears; each
project entry filters to sessions attributed there. Uses the same
attribution map loaded once in `IOSDashboardViewModel.load()`, so
filtering is an O(n) in-memory pass over 25 sessions — no extra
SFTP traffic. Addresses pass-2 complaint: "we should add a filter
to the sessions selector in the dash to see by project."
4. **`IOSDashboardViewModel` exposes the wider surface:**
- `allSessions` (25-session window, feeds the Sessions tab)
- `allProjects` (project registry, drives the filter menu)
- `sessions(filteredBy: String?)` helper — accepts a project name
(nil = all), returns filtered subset.
Mac parity note from the earlier commit message still stands — Mac's
global Sessions list doesn't currently filter by project either.
That's a parallel post-TestFlight followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9a4473333b |
M7 #17 (pass-2): empty-transcript UX + defensive project chip
Pass-2 observations:
1. Resumed sessions from Dashboard loaded into chat but showed no
message history.
2. On sessions WITH a project badge, the chat nav-bar chip rendered
the folder icon but no project name.
**Root cause for (1)** — not actually an iOS bug. ACP-native sessions
(the kind ScarfGo starts) don't persist their transcript to the
client-visible `state.db` — only CLI/terminal sessions leave
history there. Confirmed by direct SQLite inspection: the session
IDs in Dashboard's Recent Sessions show `message_count = 0`; the
sessions with lots of messages are all older CLI sessions. The Mac
has this same limitation — just less visible because Mac's Sessions
list surfaces CLI sessions preferentially.
What we fix on the UX side: a friendlier empty state when a resumed
session has no persisted transcript. Replaces the blank canvas with
an icon + "Session resumed" + explanatory caption ("Hermes has the
context for this session, but the transcript isn't cached locally.
Send a message to continue.") Nudges the user toward the right
mental model instead of leaving them wondering why their history
vanished. Gated on `sessionId != nil` so fresh-chat empty state
stays the same.
**Root cause for (2)** — `ProjectEntry.name` shouldn't be empty, but
a defensive treatment avoids ever surfacing a folder-only chip on
edge cases (registry race, partial JSON decode). startResuming now:
- Clears `currentProjectName` eagerly at the start of the resume
flow so a lingering name from a prior session doesn't flash onto
the new header.
- Treats empty strings as nil when the lookup returns one.
And the toolbar renderer adds a `!projectName.isEmpty` guard so an
unexpected empty string never produces an icon-only chip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3b3c037fce |
M9 #4.5 (pass-2): project context surfaced in Chat nav + Dashboard rows
Pass-2 UX feedback: "When selecting a per-project chat, we should update the chat interface to show that we are 'in a project' — and label them in the sessions list so the user can see the session and understand what project it belongs to." Two related changes: **In-chat indicator** — ChatController gains `currentProjectName`, set by `resetAndStartInProject` (direct: we have the ProjectEntry) and by `startResuming` (resolved via SessionAttributionService + project registry lookup). ChatView's toolbar uses a `.principal` ToolbarItem with a VStack: "Chat" title on top, `Label(name, systemImage: "folder.fill")` subtitle underneath when attributed. Mirrors Mac's SessionInfoBar project-chip pattern but fits the iOS nav-bar real estate instead of eating a full-width horizontal row. **Dashboard row labels** — `IOSDashboardViewModel.load()` now does one additional SFTP read per refresh: pulls the session→project sidecar + project registry, maps session id → project display name into `sessionProjectNames`. Row renders a small tinted folder capsule when attributed. Batched so row renders are O(1) dict lookups — no extra SFTP traffic per cell. Silent on failure (attribution is cosmetic). Not in scope for this commit: Mac's global Sessions list doesn't currently show project attribution either — that gap exists on both platforms, but wiring Mac's ProjectsSidebar + SessionsView for per-row labels is a bigger surgery. Scoped as a post-TestFlight followup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1c2939dbbe |
M7 #15 (pass-2): load transcript from state.db on session resume
Pass-2 observation: "When selecting a previous session from the dashboard, the chat opens, loads, but starts fresh — we should load the session with previous work like we do on the mac..." The Mac's resume path does two things: (a) call session/resume on ACPClient to re-bind Hermes to the session id, and (b) call `richChatViewModel.loadSessionHistory(sessionId:acpSessionId:)` to pull the persisted transcript out of state.db and populate the message list. ScarfGo only did (a) — the ACP channel was wired up correctly, but there was no SQLite read, so the UI showed an empty bubble list until the user sent their first new prompt. Added the loadSessionHistory call right after setSessionId in ChatController.startResuming. It internally calls `dataService.refresh()` first so the snapshot reflects whatever Hermes wrote between the Dashboard's last SQLite pull and the resume tap. The acpSessionId param is nil when resume preserved the id (no origin-vs-ACP split needed) and set to the resolved id otherwise so the CLI + ACP message streams can be merged chronologically — same behaviour the Mac gets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f3c4bc56e9 |
M7 #14 (pass-2): keep ACP session alive across tab switches
Pass-2 observation: "when a user switches away from chat and comes
back, there is a loading time — should we keep it open so there
isn't a reload needed?"
Removed the .onDisappear { controller.stop() } hook. TabView unmounts
tab content on switch (disappear fires), but @State keeps the
ChatController alive — so dropping the SSH exec channel + re-
opening on next appear was costing a ~1-2s reconnect every time
the user bounced Dashboard → Chat → Memory → Chat.
Cleanup still happens correctly because ChatController's lifetime
is tied to ChatView's parent (ScarfGoTabRoot). When the user
Disconnects/Forgets from the More tab, RootModel flips out of
.connected, the whole tab root unmounts, and the controller + its
ACPClient tear down via .deinit. Background termination is handled
by iOS naturally.
A comment in the file documents why we no longer tear down on
.onDisappear — easy to re-add if a future iPad / multi-window
variant wants explicit idle-pause behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
723ef6743d |
M7 #13 (pass-2): suppress empty assistant bubble during reasoning-only frames
Pass-2 turned up a ghost-message UX bug we missed in pass-1: every "Thinking…" reasoning disclosure had an empty gray bubble next to it. Happens because assistant messages exist momentarily in a reasoning-only state (chunks of thinking text arrive before any primary content), and the bubble path always rendered its padded background regardless of content. Gate the bubble render on non-empty content for assistant messages. User bubbles still always render (the user explicitly submitted content and saw it land — suppressing it on trim-empty would be surprising). `trimmingCharacters(in: .whitespacesAndNewlines)` so purely-whitespace assistant frames also don't render a bubble. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
444d43dea8 |
M9 #4.4: APNs client skeleton (capability disabled, plumbing ready)
Ships the iOS-side scaffolding so a future Hermes push sender can
light ScarfGo up with no client-side surgery. Keeps the Push
Notifications capability in the Xcode target OFF until:
1. Apple Developer Program enrollment + APNs auth key are set up
(out of scope until TestFlight).
2. Hermes gains a `hermes register-device` endpoint + per-event
sender (new cron job result, new pending permission). Upstream
work, hasn't been specced.
What's now in the tree, ready to flip on:
- `Notifications/APNSTokenStore.swift` — actor-backed singleton that
captures the device-token hex string from a successful remote
registration. Logs for now (no server to POST to yet); has a TODO
marker at the spot where the real HTTPS POST will land.
- `Notifications/NotificationRouter.swift` — UNUserNotificationCenter
delegate that handles:
- foreground presentation (always show banner + sound);
- default tap → route to Chat tab with resume sessionID if
included in the payload (via the existing ScarfGoCoordinator);
- `APPROVE_PERMISSION` / `DENY_PERMISSION` action buttons on
notifications in the `SCARF_PENDING_PERMISSION` category, with
Face ID / passcode required (`.authenticationRequired`). Action
handlers log today; the real one-shot ACPClient respond-and-die
flow is scoped out until the sender pipe exists.
- Local-notification plumbing: `registerCategories()` +
`setUpOnLaunch()` (requests .alert/.sound/.badge permission).
- `registerForRemoteNotifications` deliberately commented out.
Turning it on without the capability surfaces as runtime
"no valid aps-environment entitlement string found" — waiting
keeps logs clean.
Wired at ScarfIOSApp launch via a `.task` on RootView — harmless on
denial, authorization dialog only shows once. ScarfGoTabRoot sets
the router's `coordinator` weak ref on appear so notification-taps
can cross-tab route. When the capability ships, the remaining work
is one call (`UIApplication.shared.registerForRemoteNotifications()`)
inside `setUpOnLaunch`'s `granted` branch + the AppDelegate hooks for
token delivery + a sign-in style payload build in APNSTokenStore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9bfaaf20f0 |
M9 #4.3: scoped Settings editor via hermes config set
Pass-1 feedback: "Settings loads, but no fields are editable." By- design read-only in M6, but the on-the-go story is weaker without at least the core model / approval-mode / display toggles editable. Not a generic YAML round-trip editor — that was ruled out in the original iOS plan because comment/order preservation requires Hermes-side changes or a significant YAML library. Instead: - Curated v1 list of 7 editable keys: model.default, model.provider, approvals.mode, agent.max_turns, display.show_cost / show_reasoning / streaming. Covers ~80% of actual "I want to change this right now while I'm away from my Mac" scenarios. - IOSSettingsViewModel.saveValue(key:value:) shells out to `hermes config set <key> <value>` over the SSH transport's runProcess, reusing the same PATH-prefix trick we added in pass-1 for hermes acp so the remote shell finds hermes even in non- interactive mode. Hermes owns the YAML round-trip; Scarf just picks the value. - SettingEditorSheet renders the right control per key: Toggle (booleans), segmented Picker (approval mode), Stepper (max_turns), TextField (model / provider / timezone). One sheet, four kinds of input, driven by a `SettingSpec.Kind` enum. - SettingsView gets a "Quick edits" section at the top that lists the 7 keys with their current parsed values + an edit affordance. The existing 10+ read-only sections stay unchanged — editing stays scoped to the keys we curated. - On save, the VM calls `load()` again so the parsed config (and therefore the Quick-edits labels + the read-only sections below) reflects the new value immediately. - Errors from `hermes config set` (non-zero exit) surface inline on the sheet via SettingsSaveError.commandFailed.errorDescription, carrying stderr/stdout combined so the user sees what the remote complained about. Sheet stays open on error for retry. ScarfGo builds green. Mac Settings is unaffected — this feature is iOS-only (Mac has its own richer editors via HermesFileService). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
226b6e26be |
M9 #4.2: project-scoped chat + shared SFTP parity services
ScarfGo now supports the Mac app's project-chat flow end-to-end.
Tapping + in Chat opens a sheet with two options:
1. Quick chat — cwd = $HOME (previous default).
2. In project… — pick from the remote Hermes's project registry,
spawn hermes acp with cwd = project.path, record the attribution.
Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the
exact same record types + persistence logic):
- SessionProjectMap — moved from scarf/scarf/Core/Models/ to
ScarfCore. Public struct. Mac consumer unchanged (imports it via
ScarfCore now).
- SessionAttributionService — moved from Mac target to ScarfCore.
Was already transport-backed, so the port is straight lift-and-
shift: made public, added #if canImport(os) guards around the
Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions
VM still call it the same way.
- ProjectContextBlock — new ScarfCore-level primitive that owns the
marker-splice logic for the Scarf-managed region of AGENTS.md:
- applyBlock(_:to:) — pure text splice with 3-case handling.
- writeBlock(_:forProjectAt:context:) — transport-backed write.
- renderMinimalBlock(projectName:projectPath:) — iOS-side block
composer (no template-manifest or cron-attribution fields — iOS
doesn't yet surface those concepts; markers + identity headers
match Mac output byte-for-byte so a project scaffolded on iOS
round-trips cleanly through the Mac).
Mac's ProjectAgentContextService stays in place — still the richer
block renderer (template manifest + cron jobs) — but it now forwards
beginMarker/endMarker/applyBlock to ProjectContextBlock so both
platforms share invariant strings and splice logic. Duplicate
implementations were a recipe for drift.
ScarfGo side:
- Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat /
In project…). Loads the project list over SFTP via
ProjectDashboardService (already transport-backed, works on iOS).
Archived projects hidden (matching Mac sidebar behaviour).
- ChatController.resetAndStartInProject(_:) — stops the current
session, writes the minimal context block to <project>/AGENTS.md
over SFTP, spawns hermes acp with cwd = project.path, records the
attribution via SessionAttributionService. Non-fatal on block-
write failure (chat still starts).
- ChatController.startInternal(...) — refactored to take an optional
projectPath + projectName, so the regular start() and the new
project path share one ACP setup path. Attribution write happens
after newSession returns and the sessionId is known.
Project chip in the chat nav bar is deferred — on-the-go users know
they just picked a project in the sheet, the chip is polish we can
add post-TestFlight. Both schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ff6ea4f6dc |
M9 #4.1: session resume — Dashboard row tap opens Chat in resume mode
Pass-1 showed Dashboard's Recent Sessions list as a read-only marquee — tapping a row did nothing. The natural user expectation is "take me back to that conversation." Users were opening a new chat every time, defeating the point of having a phone client for an already-running agent. Added a tiny cross-tab coordinator (ScarfGoCoordinator) modeled on the Mac app's AppCoordinator pattern: - `@Observable` carrier, injected via `.environment` at ScarfGoTabRoot. - `selectedTab` drives TabView selection (bound with `.tag` on each tab). - `pendingResumeSessionID` is set by Dashboard row taps; consumed by ChatView in `.task` / `.onChange` and cleared immediately so later neutral tab switches don't accidentally re-resume. ChatController gets a new `startResuming(sessionID:)` entry point that mirrors `start()` but calls `session/resume` (falling back to `session/load` if the remote Hermes is < 0.9.x). The rest of the session lifecycle is identical so the event stream + error banner + PATH wrap all stay in force. Dashboard Recent Sessions rows now wrap in Button with `.buttonStyle(.plain)` and fire `coordinator?.resumeSession(session.id)` on tap. First usable on-the-go workflow: tap app icon → pick server → tap Dashboard → see recent sessions → tap one → land directly back in that conversation, full transcript loaded. No new-chat ceremony. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9c2e9279cc |
M9 #3: flush UserHomeCache on soft disconnect
Full ConnectedServerRegistry was scoped out of this phase — SwiftUI view lifecycle already tears down transports via .onDisappear when ScarfGoTabRoot unmounts on state transition to .serverList. Adding a formal registry that tracks every active transport per ServerID is complexity without proven UX payoff right now (can revisit post pass-2 if users hit stale-connection bugs). One real cleanup we should always do on soft disconnect: invalidate the shared UserHomeCache entry for the server we're leaving. The cache lives forever otherwise, and a hypothetical scenario where the remote user's home directory changes between sessions would surface as SFTP paths resolving to the wrong directory. Rare, but free to fix. `RootModel.softDisconnect()` now calls the new static `ServerContext.invalidateCachedHome(forServerID:)` before flipping state to `.serverList`. Static form is a convenience for callers that have the ServerID in hand but not a full ServerContext (avoids forcing a round-trip through config store just to rebuild the context we're already discarding). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bb399e6d35 |
M9 #2+#4: ServerListView root + ServerID-aware onboarding
ScarfGo now boots into a list of configured servers instead of the single-server Dashboard. Each row renders nickname + user@host:port, taps to connect, swipes to forget. A "+" toolbar button re-enters onboarding for a new server. Fresh install → straight to onboarding. RootModel state machine redesigned around the multi-server world: - `.loading` → `.serverList` when listAll() returns 1+ servers. - `.loading` → `.onboarding(forNewServer:)` on fresh install. - `.serverList` → `.onboarding(newID)` via "+" button. - `.serverList` → `.connected(id, config, key)` via row tap. - `.connected(id)` → `.serverList` via soft Disconnect (keeps creds). - `.connected(id)` → `.serverList|.onboarding` via Forget (wipes id). - `.onboarding` → `.connected(newID, …)` on completion. Published `servers: [ServerID: IOSServerConfig]` on the RootModel so ServerListView renders reactively without re-querying stores on every re-render. `refreshServers()` is the `.task` hook; `forget()` wipes a single id + refreshes. OnboardingViewModel gains an optional `targetServerID` so its final save lands in `keyStore.save(_:for:)` / `configStore.save(_🆔)` instead of the singleton shims. Nil falls back to the old singleton path for any remaining callers (tests, previews). OnboardingRootView accepts `targetServerID` + a new `onCancel` closure. The toolbar now shows Cancel so users can back out without leaving half-written credentials; Cancel hides on the final .connected step so you can't race-cancel a just-saved server. ScarfGoTabRoot takes the server's ServerID as the context id so the CitadelServerTransport pool caches per-server (two active servers → two connection holders, no SSH channel contention). Splits the v1 onDisconnect into two callbacks: - onSoftDisconnect: close transport, return to server list, keep creds. - onForget: wipe this server's creds + return to server list (or onboarding if empty). MoreTab renders both Disconnect and Forget rows in distinct sections with explicit footers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
92fba712f8 |
M8: custom detents for ScarfGo sheets (permission, cron editor)
.medium is neither/nor — too tall to peek, too short to commit to. Research recommends custom detents calibrated per sheet. - Permission sheet: `[.height(220), .large]`. 220pt shows the prompt + first ~3 options without forcing the user to drag; `large` is there for edge-case prompts with many options. - Cron editor: `[.large]` only. Cron editing is a focused task with a ~6-field form; peek detent is a distraction. `.presentationDragIndicator(.visible)` on both so users know they can drag the sheet without having to try + fail first. No other sheets in the app today. The Forget-server confirmation uses confirmationDialog (system-owned — no detents needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8282b1d604 |
M8: chat content density (code blocks, scroll anchor, context menus)
Bundles M8 items 2.4, 2.5, 2.6, 2.7 because they all touch ChatView
and together make the conversation readable on a phone:
2.4 — fenced code blocks (```…```) now render in a horizontally-
scrollable monospaced block inside the bubble. Collapsed to 240pt
max height with Expand/Collapse + a copy button; long shell
one-liners / JSON / stack traces stay one line each instead of
soft-wrapping into unreadable 4-line columns. New
`ChatContentFormatter.segments(for:)` splits the message body into
alternating `.text` (routed through AttributedString markdown) and
`.code` (routed to the new CodeBlockView). Deliberately simple
parser — handles the common fence shape, leaves inline backticks
to AttributedString, and falls back to plain text on unterminated
fences so nothing is ever silently swallowed.
2.5 — tool-call cards were already collapsed-by-default via a chevron
toggle. No structural change needed for M8; leaving the existing
ToolCallCard in place.
2.6 — replace the manual `onChange → proxy.scrollTo("bottom")`
pattern with iOS 17+ `.defaultScrollAnchor(.bottom)` plus iOS 18's
`.defaultScrollAnchor(.bottom, for: .sizeChanges)`. Native scroll-
pin fights the user's own scroll-up gesture less (the manual pattern
yanked you back to the bottom if a chunk arrived mid-read).
"New messages" pill for upward scroll-break deferred — needs a bit
of ScrollPosition state we don't plumb yet.
2.7 — `.contextMenu` on every message bubble with Copy + Share
(via ShareLink). User + assistant bubbles both. Code blocks get
their own copy button in the header. Regenerate intentionally
omitted — ACP has no native re-prompt primitive and implementing
one would be non-trivial session-state surgery.
Both schemes build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5f9343be5d |
M8: list density tokens (scarfGoCompactListRow + scarfGoListDensity)
Apple's default List styling targets Reading/Notes-style apps: ~60pt rows, 10pt inter-row spacing, big vertical padding on grouped cells. ScarfGo's lists (Memory, Cron, Skills, More, Dashboard recent sessions) lean information-dense — devs want to see 4-6 items per screen, not 2. Two tokens in Scarf iOS/App/Theme/ListDensity.swift: - `.scarfGoCompactListRow()` — 6pt vertical listRowInsets (down from default ~12pt), explicit `.frame(minHeight: 44)` to preserve the Apple HIG tap target, and `.contentShape(Rectangle())` so rows can shrink below 44pt visually while keeping the full-row hit area. ~48pt rows end up net, vs. ~60pt default. - `.scarfGoListDensity()` — `.listRowSpacing(0)` kills inter-row gaps on the whole List, `.defaultMinListRowHeight(36)` sets the floor for rows that want to go smaller (e.g. `LabeledContent`). Applied to Memory, Cron, Skills, Dashboard, MoreTab. No visual change to Chat (it's not a List — different density patterns for M8 items 2.4–2.7). Research-backed: Fantastical / GitHub Mobile / Mona for Mastodon use similar spacing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5cac3836cf |
M8: TabView root navigation (Chat / Dashboard / Memory / More)
Pass-1 loudest UX complaint — "I don't see any navigation" — was rooted in the Dashboard-as-hub pattern. Chat/Memory/Cron/Skills/ Settings lived as a NavigationLink section halfway down a scrolling List, below the stats + recent sessions. Users had to scroll to find any feature. That was the right shape for a very-early MVP but the wrong shape for a companion app whose primary tab should be Chat. New `ScarfGoTabRoot` renders a 4-tab TabView at the scene root: - **Chat** — primary tab. Tapping the app opens straight into it. - **Dashboard** — stats + recent sessions (stripped of Surfaces / Connected-to / Disconnect, which now live in More). - **Memory** — MEMORY.md + USER.md + SOUL.md, unchanged. - **More** — bucket for Cron / Skills / Settings plus the destructive Forget-this-server action. Also shows the host / user / port info as a read-only section. Uses iOS 18's `.tabViewStyle(.sidebarAdaptable)` so the same tree degrades to a bottom tab bar on iPhone and renders as a native sidebar on iPadOS / macCatalyst if we add those targets later — no UI code change required. Matches the M8 density research's sidebar recommendation. Each tab owns its own NavigationStack so push navigation (Cron editor, Memory detail, chat session list) stays scoped to that tab and doesn't bleed across. DashboardView is now simpler: just stats + recent sessions. The Forget confirmation + Disconnect button moved wholesale to MoreTab inside ScarfGoTabRoot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cecc1060c6 |
M8: clamp Dynamic Type at ScarfGo scene root
ScarfGo is a developer tool that benefits from tighter defaults than Apple's spacious baseline, but shouldn't lock out users who need accessibility sizes. `.dynamicTypeSize(.xSmall ... .accessibility2)` at the WindowGroup gives both: compact-first layout, still scalable to ~XL accessibility for low-vision users. Going past .accessibility2 collapses multi-column rows and forces text truncation in ScarfGo's dense list layouts — not a win for anyone. Matches Use-Your-Loaf's "Restricting Dynamic Type Sizes" guidance from the M8 density research. One-line change ahead of the TabView migration (2.2) so every subsequent UX-density decision factors in the clamped range. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
42c0f683bd |
M7 #11: human-readable cron schedules across Mac + ScarfGo
Pass-1 rightly called out that rendering "0 */6 * * *" and ISO 8601 timestamps directly to users is user-hostile — cron syntax is a devops lingua franca, not a user-facing idiom, and the iOS list is where the problem is most visible. New `CronScheduleFormatter` in ScarfCore pattern-matches common cron shapes into English phrases: - Named macros (@hourly, @daily, @weekly, @monthly, @yearly). - Every N minutes (`*/5 * * * *` → "Every 5 minutes"). - Every hour on minute M (`30 * * * *` → "Every hour at :30"). - Every N hours at M (`0 */6 * * *` → "Every 6 hours"). - Daily at H:MM (`0 9 * * *` → "Daily at 9 AM"). - Weekdays / weekends / single-weekday at H:MM. - Monthly on day D at H:MM. - User-set `display` label (non-cron string) wins — preserves any descriptive name the user typed via `hermes cron set-display`. - Anything unrecognised falls back to the raw expression so no info is ever hidden. 17-test pattern table covers every branch. Sibling `formatNextRun(iso:)` parses Hermes's ISO-8601 `next_run_at` field (handling both with-fractional-seconds and without) and renders `"in 4 hours"` / `"tomorrow at 9 AM"` via Foundation's `.relative(presentation: .numeric)`. Falls back to the raw string if parsing fails so we never blank out useful info. Applied to: - ScarfGo `CronListView.CronRow` — human schedule + relative next-run. - Mac `CronView` — row subtitle + detail-panel "Schedule" label + "Next run" / "Last run" Labels. Both schemes build green. 17/17 new formatter tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f2f6c4e50b |
M7 #9+#10: Memory editor keyboard + Saved pill above keyboard
Pass-1 complaints: - Typing near the bottom of MEMORY.md → keyboard covered the cursor, user lost track of where they were editing (M7 #9). - Tapping Save → "Saved" pill was never visible because it sat at .bottom with a fixed 16pt padding, behind the still-raised keyboard (M7 #10). Fixes: - `.scrollDismissesKeyboard(.interactively)` on the TextEditor so scrolling the editor drags the keyboard down smoothly. - Move the error banner + Saved pill into `.safeAreaInset(edge: .bottom)` so SwiftUI draws them above whatever is presenting the keyboard. The pill is now a full-width material strip (easier to hit/notice) instead of a floating capsule. - Saved pill holds for 2.5s (up from 1.5s — the old timer was too tight to read mid-thought). - Any in-flight hide task is cancelled when a new save lands, so rapid-fire saves don't produce stacked fade timers. No Mac equivalent needed — Mac memory editor is a separate MemoryView with different layout and a non-mobile keyboard concern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
96f60a176d |
M7 #2: non-retryable ACP errors surface as chat error banner
Pass-1 hit HTTP 404 from Nous Portal (misconfigured model), the
agent reported it via ACP stderr + stopReason="error", and ScarfGo
showed nothing — users saw only the perpetual working spinner. Mac
had an errorBanner for this pattern; ScarfGo didn't.
Promotes the error-banner state and helpers from Mac's ChatViewModel
(Mac target) into RichChatViewModel (ScarfCore) so both apps share:
- `acpError`, `acpErrorHint`, `acpErrorDetails` — the banner triplet.
- `clearACPErrorState()` — called on reset() and addUserMessage()
so stale errors don't linger across prompts.
- `recordACPFailure(_:client:)` — populate triplet from a thrown
error + stderr tail, using the existing `ACPErrorHint.classify`.
- `recordPromptStopFailure(stopReason:client:)` — populate triplet
from a non-retryable ACP `promptComplete` stopReason. Provides a
fallback hint per stopReason when classify doesn't match.
- `acpStderrProvider: () async -> String` — closure the controller
sets once so `handlePromptComplete` (called from the event stream)
can pull recent stderr without the VM holding a direct ACPClient
reference.
Mac ChatViewModel's local triplet becomes forwarding properties to
richChatViewModel.* — call sites (~15 in ChatViewModel) stay
unchanged. `recordACPFailure` + `clearACPErrorState` become one-line
forwarders.
ScarfGo ChatView gains an `errorBanner` modeled on the Mac one:
- Orange triangle + hint + raw error
- Expand/collapse "Details" button showing stderr tail (monospaced,
scrollable, max ~140pt tall)
- Copy-all button via `UIPasteboard.general.string` (Mac uses
NSPasteboard; same structure otherwise)
- Rendered above the message list so it's always visible
ChatController wires `acpStderrProvider` to
`{ await client?.recentStderr ?? "" }` before the handshake and
calls `recordACPFailure` on ACP client start / newSession /
sendPrompt failure paths. `handlePromptComplete` already handles
the common provider-404 case via `recordPromptStopFailureUsingProvider`.
Both schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8e14e0e776 |
M7 #4: split isAgentWorking into isGenerating + isPostProcessing
Pass-1 showed the "Agent is working…" spinner persisting long after the reply had landed in the message list — Hermes delays the ACP `promptComplete` event while it does auxiliary post-work (title generation, usage accounting). Spinner stuck ~minute+ on a 2-second response. Fix without touching the ACP state machine: derive two computed properties from existing signals in RichChatViewModel: - `isGenerating`: agent is working AND we don't yet have a finalized assistant reply on the message list. Drives the prominent spinner. - `isPostProcessing`: agent is working AND the user CAN see the reply. Drives a subtle "Finishing up…" pill instead of the big spinner. When `promptComplete` finally arrives, `isAgentWorking` flips false and both derived props go quiet. `isAgentWorking` remains the canonical ACP-level flag (kept public for any consumer that really wants the raw value), just no longer the signal for visible "spinner now" UX. Applied to: - ScarfGo ChatView.swift — primary spinner + post-processing pill. - Mac RichChatView.swift — SessionInfoBar + RichChatMessageList now take `isGenerating` instead of `isAgentWorking`. Same UX win for the macOS app (pass-1 finding was cross-platform, just surfaced first on iOS). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
742605d359 |
M7 #3: Chat 'Connecting…' overlay during SSH exec handshake
ChatController already transitioned through a `.connecting` state between tap-Chat and first-message-ready (ACP initialize + session/new take ~0.5–1.5 s on a warm network), but there was no visible UI — the screen stayed on the idle layout with a disabled composer. Users interpreted the silence as a frozen app (pass-1 M7 #3). Adds a `.regularMaterial` overlay with a large ProgressView + "Connecting to <nickname>…" text, rendered whenever `controller.state == .connecting`. Disappears automatically when state flips to `.ready` (normal path) or `.failed` (handoff to the existing errorOverlay). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fee5e72d30 |
M7 #12: rename Disconnect -> Forget this server + confirmation
Pass-1 found that "Disconnect" was actually a factory reset — wiping both Keychain SSH key and UserDefaults config, forcing full re-onboarding (including re-generating a key and appending it to authorized_keys on the remote). Interim fix ahead of M9 multi-server work: - Relabel button "Forget this server". - Keep destructive role. - Gate tap on a confirmationDialog so users see exactly what gets wiped and can back out. - Add a footer explaining the authorized_keys consequence so the user isn't surprised by a failed reconnect later. Behaviour is unchanged (still wipes both stores). M9 introduces the proper split: soft Disconnect (closes live transport, keeps credentials) vs. hard Forget (this behaviour). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f41ac1c84e |
M7: pass-1 quickfixes (PATH, SFTP tilde, SOUL.md, ScarfGo bundle id)
Four fixes surfaced during the 2026-04-24 pass-1 smoke test of the iOS companion against a local Hermes host. All discovered while collaboratively driving the Simulator + tailing os.Logger. 1. ACPClient+iOS.swift — ACP exec command prepends common install paths to PATH. SSH RFC 4254 exec uses a non-interactive shell whose PATH is sshd's default (`/usr/bin:/bin:/usr/sbin:/sbin`); `.zshrc` doesn't source, so `~/.local/bin/hermes` (pipx default) was invisible and the agent died with "command not found: hermes". Mirrors HermesPathSet.hermesBinaryCandidates (the Mac-side local probe list) inline in the exec command. 2. CitadelServerTransport.swift — SFTP tilde expansion. Every Memory/Cron/Skills/Settings read used paths like `~/.hermes/memories/MEMORY.md`. SFTP treats `~` as a literal character, not a home-dir alias — so every read silently returned nil and the UIs showed "empty file" instead of the real content. Added a per-connection cached `resolveHome()` + a `resolveSFTPPath` helper applied to every SFTP entry point (readFile / writeFile / fileExists / stat / listDirectory / createDirectory / removeFile). This was the single biggest blocker on pass-1. 3. IOSMemoryViewModel.swift + MemoryListView.swift — SOUL.md added as a third Memory row. SOUL.md lives in the Personalities feature on Mac; folding it into Memory on iOS matches the on-the-go scope (all agent prompt inputs in one place). Uses the existing `HermesPathSet.soulMD` path; no new plumbing. 4. project.pbxproj — bundle id rename for ScarfGo branding: - CFBundleDisplayName: "Scarf Mobile" -> "ScarfGo" - PRODUCT_BUNDLE_IDENTIFIER: com.scarf-mobile.app -> com.scarfgo.app Xcode target name stays "scarf mobile" internally (rename surgery isn't worth the PBX churn). Home-screen label + bundle id now match the product name. Both schemes build green. Phase 1 starter commit — per-item M7 fixes follow in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
44d2d6d6c6 |
iOS port M6: YAML parser port, Settings view, Cron editing
Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.
## ScarfCore — YAML infrastructure
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
- ParsedYAML struct (values / lists / maps)
- HermesYAML.parseNestedYAML(_:) — indent-based block parser
- HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping
Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
- HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
one-for-one. Every default, every key, every legacy fallback
(platforms.slack.* vs slack.*, command_allowlist vs permanent_
allowlist, etc.) matches the Mac implementation.
- Forgiving: malformed YAML produces partial state + defaults
rather than throwing. Callers surface the raw text so users can
diagnose parse failures on their own.
## ScarfCore — Cron editing (write paths)
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
- toggleEnabled(id:)
- delete(id:)
- upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.
Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.
## ScarfCore — iOS Settings VM
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
- Reads ~/.hermes/config.yaml via ServerContext.readText
- Parses with HermesConfig(yaml:)
- Surfaces both parsed config and rawYAML
- M6 read-only by design — config.yaml needs round-trip-preserving
YAML serialization (comments, key order, whitespace) for safe
edits; option (a) hand-write one, (b) YAML library dep, (c)
delegate to `hermes config set` via ACP. Defer.
## iOS app
Scarf iOS/Settings/SettingsView.swift:
- Read-only browser grouped into 10 sections matching the Mac
app's tabs. DisclosureGroup at the bottom reveals raw YAML
source for diagnostics.
Scarf iOS/Cron/CronListView.swift rewritten:
- Toggle-enabled circle (tap to flip, saves atomically)
- Swipe-to-delete
- "+" toolbar for new job → editor sheet
- Row-tap opens editor with existing fields populated
New CronEditorView form:
- Name, Prompt, Enabled toggle
- Schedule: kind picker (cron/interval/once), display, expression
(for cron), run_at (for once)
- Optional model + comma-separated skills + delivery route
- Preserves runtime fields (nextRunAt, lastRunAt,
deliveryFailures, etc.) when editing existing jobs — no reset
Dashboard's Surfaces section gains a 5th row: Settings.
## Test-suite reorganization (real bug caught)
swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
- M5's `.serialized` suite sets factory, runs, restores.
- M6's `.serialized` suite did the same in parallel — clobbered.
- M0b's non-serialized `serverContextMakeTransportDispatches`
asserted the DEFAULT factory (nil) returned SSHTransport —
saw whichever factory was temporarily installed.
Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.
## Test results: 108 → 134 passing on Linux
19 new in M6ConfigCronTests:
- YAML parser: scalars, bullets, nested maps, comments, quotes,
inline {} / []
- HermesConfig.init(yaml:): empty → defaults, model + agent,
display, security + blocklist domains, slack legacy fallback,
auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
command_allowlist, quoted strings
- Memberwise inits for HermesCronJob, withEnabled(_:),
CronJobsFile, CronSchedule
7 new in M5FeatureVMTests (.serialized):
- defaultFactoryProducesSSHTransportForRemoteContext (moved +
hardened with explicit factory reset)
- cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
cronPreservesRuntimeFieldsAcrossReloads
- settingsLoadsFromConfigYAML, settingsSurfacesMissingFile
## Manual validation needed on Mac
1. Xcode compile clean.
2. Settings: confirm every section populates from your real
~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
works. "+" creates jobs; round-trip name/prompt/schedule/skills.
Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).
Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
|
||
|
|
6b731ddfb8 |
iOS port M5: Chat polish + Memory + Cron + Skills features
Fleshes out the iOS app from "Chat + placeholder Dashboard" into a
real on-the-go Hermes companion: Chat now renders tool calls + tool
results + permission sheets + markdown + chain-of-thought, and the
Dashboard gains three new feature surfaces.
## Chat polish
scarf/Scarf iOS/Chat/ChatView.swift — several new small SwiftUI
view types:
- ToolCallCard: expandable card for each HermesToolCall on an
assistant message. Tool-kind icon in the header (from
HermesToolCall.toolKind.icon), arguments summary collapsed,
full JSON on tap.
- ToolResultRow: compact "Tool output" disclosure for messages
with role == "tool", shown indented beneath the preceding
assistant bubble.
- PermissionSheet: SwiftUI .sheet(item:) presentation of
RichChatViewModel.pendingPermission. Tapping an option
dispatches ChatController.respondToPermission → ACPClient.
- ReasoningDisclosure: DisclosureGroup for HermesMessage.reasoning,
collapsed by default so chatty thinkers don't dominate scroll.
MessageBubble now renders assistant content through
AttributedString(markdown: options: .inlineOnlyPreservingWhitespace).
User messages stay plain Text (no reason to parse what the user
just typed). Unknown markdown falls through as literal text — worst
case, no formatting.
ChatController gains respondToPermission(requestId:optionId:) that
forwards to ACPClient and clears vm.pendingPermission on the
MainActor.
## New feature surfaces
### Memory (read + edit)
ScarfCore/ViewModels/IOSMemoryViewModel.swift:
- Kind enum (.memory / .user) → maps to paths.memoryMD / .userMD
- text (mutable) + originalText (pristine) + hasUnsavedChanges
- load() / save() / revert()
- async file I/O via ServerContext.readText / writeText — run on
a detached task so the MainActor doesn't hang on remote SFTP
scarf/Scarf iOS/Memory/:
- MemoryListView: two-row NavigationLink (MEMORY.md, USER.md)
- MemoryEditorView: TextEditor bound to vm.text, toolbar Save +
Revert, "Saved" bottom toast on success.
### Cron (read-only)
ScarfCore/ViewModels/IOSCronViewModel.swift:
- Loads ~/.hermes/cron/jobs.json via transport.readFile + decodes
into CronJobsFile (Codable, shipped in M0a)
- Missing file = empty list (no error — common on fresh installs)
- Sort: enabled-first, then nextRunAt ascending, disabled last
- Surfaces decode errors via lastError
scarf/Scarf iOS/Cron/CronListView.swift:
- Row: state-icon + name + schedule.display + next-run-at.
- Detail: prompt, schedule, state, delivery route (via
job.deliveryDisplay), skills, model.
Editing is deferred — needs atomic jobs.json rewrites. Shipped the
read path so users can at least audit their cron config on the go.
### Skills (read-only)
ScarfCore/ViewModels/IOSSkillsViewModel.swift:
- Scans ~/.hermes/skills/<category>/<name>/ via transport.listDirectory
+ transport.stat for directory-ness
- Filters dotfiles. Skips empty categories. Swallows per-category
listing errors (permissions etc.) rather than failing the whole
load.
- requiredConfig stays empty — YAML frontmatter parsing deferred
(would need a parser in ScarfCore; see M5 plan note).
scarf/Scarf iOS/Skills/SkillsListView.swift:
- Grouped by category, tap → SkillDetailView (path + file list).
## Supporting tweaks
- RichChatViewModel.PendingPermission: fields + public init promoted
from `let`/internal to `public let` / `public init(...)` so
PermissionSheet can read title/kind/options and tests can construct
one directly.
- LocalTransport.writeFile refactored to use Data.write(options: .atomic)
instead of FileManager.replaceItemAt. replaceItemAt is Apple-only;
Linux swift-corelibs doesn't fully implement it, which was breaking
the M5 save-path tests on Linux CI. Data.write(atomic) is cross-
platform and has identical semantics (temp-file + rename). Also
auto-creates the parent directory if missing, folding in the one
bit of the old logic that wasn't atomicity-related.
- DashboardView: single Chat Section → "Surfaces" Section with four
NavigationLinks (Chat / Memory / Cron / Skills).
## Tests (ScarfCoreTests/M5FeatureVMTests, 10 new)
.serialized suite — tests install a `withLocalTransportFactory`
helper that swaps ServerContext.sshTransportFactory to produce a
LocalTransport against real tmp files (so .ssh contexts in the
test resolve to local FS paths). Restored in defer. Serialized
because the factory is a static.
- memoryLoadsEmptyWhenFileMissing
- memoryRoundTripsFileContent — seed file → load → edit → save
→ reload via fresh VM → confirm persistence
- memoryRevertRestoresOriginal
- memoryKindPathRouting — pin .memory → memoryMD etc.
- cronEmptyWhenJobsFileMissing — missing file is not an error
- cronLoadsAndSortsJobs — 3-job fixture, verify sort:
enabled-before-disabled and
nextRunAt-ascending within
- cronSurfacesDecodeErrors — garbage jobs.json
- skillsEmptyWhenDirMissing
- skillsScansCategoryAndSkillStructure — 2 categories, dotfile
filter check
- skillsSkipsEmptyCategories
- pendingPermissionMemberwise — SQLite3-gated (RichChatViewModel
is gated)
**108 / 108 passing on Linux** (98 → 108).
## Manual validation needed on Mac
1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call + a permission request. Verify cards
render, options dispatch, markdown looks right.
3. Memory: edit MEMORY.md on phone → save → confirm via `cat` on
the remote.
4. Cron: existing jobs show sorted + detail view useful.
5. Skills: browse matches `ls ~/.hermes/skills/<cat>/<name>/`.
Updated scarf/docs/IOS_PORT_PLAN.md with M5's scope, rationale
for the LocalTransport.writeFile refactor (Linux CI), and the M6
Settings-blocker (needs YAML parser port).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
|
||
|
|
bd6e722029 |
iOS port M4: Chat via SSHExecACPChannel (Citadel exec bidirectional)
First real interactive iOS feature. Streams JSON-RPC over a
Citadel 8-bit-safe exec channel to a remote `hermes acp` process.
Reuses ScarfCore's `RichChatViewModel` state machine (from M0d)
+ `ACPClient` (from M1) unchanged — the only new code is the iOS-
specific channel + factory + SwiftUI view.
## SSHExecACPChannel
Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift
(iOS counterpart to Mac's ProcessACPChannel)
Uses Citadel's `SSHClient.withExec(_:perform:)`:
- RFC 4254 exec channel, no PTY, binary-clean stdin/stdout for
JSON-RPC bytes.
- Bidirectional: `TTYStdinWriter` for our `send(_:)` writes,
`TTYOutput` stream for stdout/stderr.
- withExec's closure-scoped lifecycle handled by running it in
a detached Task. A per-actor pending-waiters queue lets the
first `send(_:)` block until the writer is handed over (one-
time RTT); subsequent sends are instant.
- `close()` cancels the Task, which drops the `withExec`
closure, which triggers Citadel to close the SSH channel.
Clean teardown.
- Line framing via `Data` accumulators for stdout + stderr
separately — Citadel yields bytes in arbitrary chunk sizes,
we only push complete (newline-terminated) lines into the
ACPChannel streams.
## ACPClient+iOS
Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift
(Sibling to Mac's ACPClient+Mac.swift)
Exposes `ACPClient.forIOSApp(context:keyProvider:)`. Opens a
dedicated `SSHClient` per ACP session — NOT reusing the
`CitadelServerTransport` client. Rationale: ACP sessions can
run for minutes/hours of streaming chat, and OpenSSH caps
concurrent channels per connection at ~10. Two separate
connections (transport + ACP) stay well under.
SSH auth: ed25519 via the Keychain-stored bundle, same
`SSHAuthenticationMethod.ed25519(...)` path as
CitadelServerTransport.
## iOS Chat view
scarf/Scarf iOS/Chat/ChatView.swift + embedded ChatController
(@Observable @MainActor). Minimal v1 UX:
- Three-state lifecycle: .connecting / .ready / .failed(reason)
- Auto-scrolling message list
- SwiftUI composer (multi-line TextField + Send button)
- Toolbar "+" for a fresh session (stop → reset → start)
- Message bubble (user: accent; agent: secondary background)
Deferred to M5: tool-call cards, permission request sheets,
markdown rendering, voice.
scarf/Scarf iOS/Dashboard/DashboardView.swift gains a
NavigationLink into Chat.
## Small public-API tweak
`RichChatViewModel.sessionId` promoted from `private(set)` to
`public private(set)` — ChatController reads it to route
`sendPrompt`. Same pattern as earlier M3 public-nits patches.
## Tests: 2 new in M4ACPIOSTests (now 98/98 on Linux)
Deliberately focused — M1's 10-test MockACPChannel suite already
covers the full ACPClient state machine. These two pin the
patterns iOS's new SSHExecACPChannel exercises:
- streamingPromptDeliversChunksAndCompletes: full handshake +
session/new + streamed agent_message_chunk notifications +
session/prompt response. Verifies chunks arrive as
.messageChunk events and prompt resolves with correct usage
tokens.
- permissionRequestYieldsEventAndRespondSends: remote
session/request_permission request → .permissionRequest
event → respondToPermission writes correct JSON back on the
channel with matching id + outcome.
Running `docker run --rm -v $PWD/Packages/ScarfCore:/work
-w /work swift:6.0 swift test` now reports 98 / 98.
## Manual validation needed on Mac
1. Xcode compile of scarf mobile target against the merged
pbxproj (target reconciliation shipped in the previous commit
on this branch).
2. Chat end-to-end against a real Hermes host. From Dashboard,
tap Chat → type "hello" → streaming response. Test "+" for
new session. Verify no leaked SSH connections across
Disconnect + re-onboard.
3. If your Hermes enables tools: verify tool_call_update
notifications come through (won't render with fancy cards
yet — that's M5 polish).
Updated scarf/docs/IOS_PORT_PLAN.md with M4's shipped state, the
"two separate SSH clients" rule, and the M5 polish backlog
(tool cards, permissions, markdown, voice).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
|
||
|
|
110611549e |
iOS target reconciliation: integrate Xcode-created scarf mobile target
Three-way reconciliation of:
- my M2/M3 source tree at scarf/scarf-ios/
- Alan's Xcode-created target with folder scarf/Scarf iOS/ and
target name `scarf mobile` (bundle com.scarf-mobile.app)
- the Mac `scarf` target that already had ScarfCore wired in
Alan created the iOS target on the unrelated `template-configuration`
branch (commit
|