diff --git a/scarf/scarf/Core/Models/SessionProjectMap.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/SessionProjectMap.swift similarity index 59% rename from scarf/scarf/Core/Models/SessionProjectMap.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/SessionProjectMap.swift index 55442d7..9ccf33c 100644 --- a/scarf/scarf/Core/Models/SessionProjectMap.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/SessionProjectMap.swift @@ -14,21 +14,14 @@ import Foundation /// compatible: if Hermes ever gains a canonical `cwd` column, Scarf /// can prefer that and fall back to this file for pre-upgrade /// sessions. Missing file → empty map (nothing attributed yet). -struct SessionProjectMap: Codable, Sendable { - /// session-id → absolute-project-path. Both strings are opaque - /// from this file's perspective; the service validates project - /// paths against the live registry when building the reverse - /// lookup used by the Sessions tab, so stale entries for - /// removed projects are ignored at read time without needing a - /// write-side cleanup. - var mappings: [String: String] +/// +/// Promoted to ScarfCore in M9 #4.2 so iOS can use the same record +/// type — ScarfGo's project-scoped chat writes here over SFTP. +public struct SessionProjectMap: Codable, Sendable { + public var mappings: [String: String] + public var updatedAt: String? - /// ISO-8601 timestamp of the most recent write. Informational - /// only — not used for any decision logic. Useful when debugging - /// a stale sidecar ("when was this last updated?"). - var updatedAt: String? - - init(mappings: [String: String] = [:], updatedAt: String? = nil) { + public init(mappings: [String: String] = [:], updatedAt: String? = nil) { self.mappings = mappings self.updatedAt = updatedAt } @@ -37,7 +30,7 @@ struct SessionProjectMap: Codable, Sendable { /// `updatedAt` field. Matches the format used elsewhere in /// Scarf (e.g. `TemplateLock.installedAt`) so tooling that /// greps across .json files sees consistent timestamps. - static func nowISO8601() -> String { + public static func nowISO8601() -> String { ISO8601DateFormatter().string(from: Date()) } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift new file mode 100644 index 0000000..6106916 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift @@ -0,0 +1,144 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Pure block-splice logic for the Scarf-managed region of a project's +/// `/AGENTS.md`. Shared by Mac (which wraps it in +/// `ProjectAgentContextService` with template-manifest + cron-aware +/// block rendering) and ScarfGo (which renders a simpler block and +/// writes it over SFTP). +/// +/// The marker contract is a cross-platform invariant — both apps must +/// produce byte-identical markers so a Mac-scaffolded block round-trips +/// through iOS and vice-versa without either side treating the other's +/// content as "missing markers." +public enum ProjectContextBlock { + + /// Load-bearing across releases. Do not change these strings + /// without a coordinated migration — existing project AGENTS.md + /// files on disk carry them. + public static let beginMarker = "" + public static let endMarker = "" + + /// Errors surfaced by writers. Narrow set — most callers just log + /// and continue; a missing project-context block is a polish + /// degradation, not a chat-start blocker. + public enum WriteError: Error, LocalizedError { + case encodingFailed + public var errorDescription: String? { + switch self { + case .encodingFailed: return "Couldn't encode AGENTS.md block as UTF-8" + } + } + } + + /// Splice `block` into `existing`, preserving everything outside + /// the markers. Three cases: + /// 1. `existing` has both markers → replace inclusive region. + /// 2. `existing` has no markers → prepend block + blank line. + /// 3. `existing` has only a begin marker → prepend (don't guess). + public static func applyBlock(_ block: String, to existing: String) -> String { + guard let beginRange = existing.range(of: beginMarker), + let endRange = existing.range( + of: endMarker, + range: beginRange.upperBound../AGENTS.md`, splice in the given block, write + /// back — all via the provided context's transport. Idempotent on + /// identical inputs. + /// + /// Called by ScarfGo's ChatController.startNewSession when the + /// user picks "In project…". Mac's ProjectAgentContextService is + /// a richer wrapper that constructs the block first, but the + /// persistence step uses the same splice logic under the hood. + public static func writeBlock( + _ block: String, + forProjectAt projectPath: String, + context: ServerContext + ) throws { + let transport = context.makeTransport() + let agentsMdPath = projectPath + "/AGENTS.md" + + if !transport.fileExists(projectPath) { + try transport.createDirectory(projectPath) + } + + if !transport.fileExists(agentsMdPath) { + let data = (block + "\n").data(using: .utf8) ?? Data() + try transport.writeFile(agentsMdPath, data: data) + return + } + + let existingData = try transport.readFile(agentsMdPath) + let existing = String(data: existingData, encoding: .utf8) ?? "" + let rewritten = applyBlock(block, to: existing) + guard let outData = rewritten.data(using: .utf8) else { + throw WriteError.encodingFailed + } + guard outData != existingData else { return } + try transport.writeFile(agentsMdPath, data: outData) + } + + /// Render a minimal Scarf-managed block for iOS ScarfGo usage. + /// Omits the template-manifest + cron-job sections that the Mac + /// service fills in — ScarfGo v1 doesn't surface those concepts + /// yet. The marker + identity headers match the Mac output byte- + /// for-byte where the content overlaps, so a project scaffolded + /// on iOS round-trips cleanly through the Mac. + public static func renderMinimalBlock(projectName: String, projectPath: String) -> String { + var lines: [String] = [] + lines.append(beginMarker) + lines.append("## Scarf project context") + lines.append("") + lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._") + lines.append("") + lines.append("You are operating inside a Scarf project named **\"\(projectName)\"**. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.") + lines.append("") + lines.append("- **Project directory:** `\(projectPath)`") + lines.append("- **Dashboard:** `\(projectPath)/.scarf/dashboard.json`") + lines.append("") + lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.") + lines.append(endMarker) + return lines.joined(separator: "\n") + } + + // MARK: - Private + + private static func trimmingRightNewlines(_ s: String) -> String { + var result = s + while let last = result.last, last.isNewline { + result.removeLast() + } + return result + } + + private static func trimmingLeftNewlines(_ s: String) -> String { + var result = s + while let first = result.first, first.isNewline { + result.removeFirst() + } + return result + } +} diff --git a/scarf/scarf/Core/Services/SessionAttributionService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SessionAttributionService.swift similarity index 62% rename from scarf/scarf/Core/Services/SessionAttributionService.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SessionAttributionService.swift index 83ec8d9..499edf2 100644 --- a/scarf/scarf/Core/Services/SessionAttributionService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SessionAttributionService.swift @@ -1,12 +1,12 @@ import Foundation +#if canImport(os) import os -import ScarfCore +#endif /// Owns the sidecar that attributes Hermes session IDs to Scarf -/// project paths. The `cwd` passed to `hermes acp` at session -/// creation is ephemeral from Hermes's perspective (not written to -/// `state.db`), so Scarf keeps this Scarf-owned record parallel to -/// Hermes's session store. +/// project paths. Promoted to ScarfCore in M9 #4.2 so ScarfGo can +/// write project attributions over SFTP — the whole service is +/// transport-based, so Mac and iOS share the same code path. /// /// File: `~/.hermes/scarf/session_project_map.json` (resolved via /// `HermesPathSet.sessionProjectMap`). @@ -16,14 +16,15 @@ import ScarfCore /// disk. Concurrent writers (two Scarf windows on the same /// `~/.hermes`) are safe at the file level — last write wins — /// but the in-memory read in one window may lag until that window -/// reloads. Acceptable for v2.3's scale; revisit if multi-window -/// cross-talk becomes a problem. -struct SessionAttributionService: Sendable { +/// reloads. +public struct SessionAttributionService: Sendable { + #if canImport(os) private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService") + #endif - let context: ServerContext + public let context: ServerContext - nonisolated init(context: ServerContext = .local) { + public nonisolated init(context: ServerContext = .local) { self.context = context } @@ -32,7 +33,7 @@ struct SessionAttributionService: Sendable { /// Load the current sidecar contents. Missing file or unparseable /// JSON returns an empty map — the sidecar is a convenience /// index, not a source of truth for anything load-bearing. - nonisolated func load() -> SessionProjectMap { + public nonisolated func load() -> SessionProjectMap { let path = context.paths.sessionProjectMap let transport = context.makeTransport() guard transport.fileExists(path) else { @@ -42,26 +43,22 @@ struct SessionAttributionService: Sendable { let data = try transport.readFile(path) return try JSONDecoder().decode(SessionProjectMap.self, from: data) } catch { + #if canImport(os) Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map") + #endif return SessionProjectMap() } } /// Look up the project path a given session was attributed to. - /// Returns nil for unattributed sessions (CLI-started, or - /// started before v2.3) — those surface in the global Sessions - /// sidebar unchanged and don't appear in any project's Sessions - /// tab. - nonisolated func projectPath(for sessionID: String) -> String? { + /// Returns nil for unattributed sessions. + public nonisolated func projectPath(for sessionID: String) -> String? { load().mappings[sessionID] } /// Reverse lookup: every session ID attributed to the given - /// project path. Used by the per-project Sessions tab to filter - /// the global session list. Comparison is exact-string; the - /// registry stores absolute paths and we write absolute paths, - /// so no normalisation is needed in practice. - nonisolated func sessionIDs(forProject projectPath: String) -> Set { + /// project path. + public nonisolated func sessionIDs(forProject projectPath: String) -> Set { let map = load() return Set(map.mappings.filter { $0.value == projectPath }.keys) } @@ -69,11 +66,8 @@ struct SessionAttributionService: Sendable { // MARK: - Write /// Record that `sessionID` was created under the given project - /// path. Idempotent — repeated calls for the same pair are no- - /// ops. Replacing an existing mapping (session moved to a - /// different project) is legal but expected to be rare; the - /// caller decides when that's correct. - nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) { + /// path. Idempotent. + public nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) { var map = load() if map.mappings[sessionID] == projectPath { return @@ -83,12 +77,10 @@ struct SessionAttributionService: Sendable { persist(map) } - /// Remove a mapping. Called in v2.3's Sessions-tab code path is - /// minimal — we don't currently prune on session delete because - /// Hermes owns session lifecycle and we don't observe deletes. - /// Exposed for future roadmap items (e.g. explicit "detach - /// from project" action) and tests. - nonisolated func forget(sessionID: String) { + /// Remove a mapping. Exposed for future "detach from project" + /// UIs and tests; today's Mac + iOS call sites don't invoke it + /// because Hermes owns session lifecycle. + public nonisolated func forget(sessionID: String) { var map = load() guard map.mappings.removeValue(forKey: sessionID) != nil else { return } map.updatedAt = SessionProjectMap.nowISO8601() @@ -110,7 +102,9 @@ struct SessionAttributionService: Sendable { let data = try encoder.encode(map) try transport.writeFile(path, data: data) } catch { + #if canImport(os) Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") + #endif } } } diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index fb6b5f1..47fa4a3 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -23,6 +23,7 @@ struct ChatView: View { @Environment(\.scarfGoCoordinator) private var coordinator @Environment(\.serverContext) private var envContext @State private var controller: ChatController + @State private var showProjectPicker = false init(config: IOSServerConfig, key: SSHKeyBundle) { self.config = config @@ -50,13 +51,24 @@ struct ChatView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { - Task { await controller.resetAndStartNewSession() } + showProjectPicker = true } label: { Image(systemName: "plus.bubble") } .disabled(controller.state == .connecting) } } + .sheet(isPresented: $showProjectPicker) { + ProjectPickerSheet( + context: config.toServerContext(id: Self.sharedContextID), + onQuickChat: { + Task { await controller.resetAndStartNewSession() } + }, + onProject: { project in + Task { await controller.resetAndStartInProject(project) } + } + ) + } .task { // Dashboard row taps set `pendingResumeSessionID` on the // coordinator before switching to the Chat tab. Honor @@ -453,6 +465,114 @@ final class ChatController { await start() } + /// User tapped "In project… ". Stop, reset, and start + /// with the project's path as cwd. Writes the Scarf-managed + /// AGENTS.md block via ProjectContextBlock BEFORE spawning `hermes + /// acp`, so Hermes sees the project context at boot. Records the + /// returned session id in the attribution sidecar. + func resetAndStartInProject(_ project: ProjectEntry) async { + await stop() + vm.reset() + // Write the context block first. Non-fatal on failure — chat + // still starts, just without the managed block; the user sees + // the error via controller.state if it escalates. + let block = ProjectContextBlock.renderMinimalBlock( + projectName: project.name, + projectPath: project.path + ) + let ctx = context + await Task.detached { + try? ProjectContextBlock.writeBlock( + block, + forProjectAt: project.path, + context: ctx + ) + }.value + await start(projectPath: project.path, projectName: project.name) + } + + /// Inline variant of `start()` that accepts a cwd + attribution + /// hooks. The default `start()` delegates to this with nil project + /// fields, so the ACP code path stays single-sourced. + private func startInternal( + projectPath: String?, + projectName: String? + ) async { + if state == .connecting || state == .ready { return } + state = .connecting + let client = ACPClient.forIOSApp( + context: context, + keyProvider: { + let store = KeychainSSHKeyStore() + guard let key = try await store.load() else { + throw SSHKeyStoreError.backendFailure( + message: "No SSH key in Keychain — re-run onboarding.", + osStatus: nil + ) + } + return key + } + ) + self.client = client + vm.acpStderrProvider = { [weak client] in + await client?.recentStderr ?? "" + } + + do { + try await client.start() + } catch { + state = .failed(error.localizedDescription) + await vm.recordACPFailure(error, client: client) + return + } + + let stream = await client.events + eventTask = Task { [weak self] in + for await event in stream { + guard let self else { break } + await MainActor.run { + self.vm.handleACPEvent(event) + } + } + } + + do { + // Use the project's path as cwd when provided; else the + // remote user's home, matching the pre-M9 default. + let cwd: String + if let projectPath { + cwd = projectPath + } else { + cwd = await context.resolvedUserHome() + } + let sessionId = try await client.newSession(cwd: cwd) + vm.setSessionId(sessionId) + state = .ready + + // If this was a project-scoped session, record the + // attribution so the Mac's per-project Sessions tab picks + // it up. Best-effort — ACP session creation already won, + // a failed attribution write is cosmetic. + if let projectPath { + let ctx = context + Task.detached { + SessionAttributionService(context: ctx) + .attribute(sessionID: sessionId, toProjectPath: projectPath) + } + } + _ = projectName // reserved for future chat-header chip + } catch { + state = .failed(error.localizedDescription) + await vm.recordACPFailure(error, client: client) + await stop() + } + } + + /// Public entry used internally by resetAndStartInProject. + func start(projectPath: String, projectName: String) async { + await startInternal(projectPath: projectPath, projectName: projectName) + } + /// Resume an existing ACP session. Called from ChatView when the /// coordinator carries a `pendingResumeSessionID` (Dashboard row /// tap). If we're currently on a different session, stop first diff --git a/scarf/Scarf iOS/Chat/ProjectPickerSheet.swift b/scarf/Scarf iOS/Chat/ProjectPickerSheet.swift new file mode 100644 index 0000000..53b79ed --- /dev/null +++ b/scarf/Scarf iOS/Chat/ProjectPickerSheet.swift @@ -0,0 +1,135 @@ +import SwiftUI +import ScarfCore + +/// Sheet presented from ChatView's "+" toolbar button. Offers two +/// modes: +/// - **Quick chat** — starts with `cwd = $HOME`, no project attribution. +/// The current default behavior. +/// - **In project…** — lets the user pick a registered project. On +/// confirm, the caller is handed back the project path so it can +/// (1) write the Scarf-managed AGENTS.md block via +/// `ProjectContextBlock.writeBlock` and (2) spawn `hermes acp` with +/// `cwd = project.path`, then attribute the resulting session. +/// +/// The project list is loaded from the remote Hermes's +/// `~/.hermes/scarf/projects.json` via the shared +/// `ProjectDashboardService` (transport-backed, so SFTP works). +struct ProjectPickerSheet: View { + let context: ServerContext + let onQuickChat: () -> Void + let onProject: (ProjectEntry) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var projects: [ProjectEntry] = [] + @State private var isLoading: Bool = true + @State private var loadError: String? + + var body: some View { + NavigationStack { + List { + Section { + Button { + onQuickChat() + dismiss() + } label: { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "bolt.horizontal.circle.fill") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text("Quick chat") + .font(.body) + .fontWeight(.medium) + .foregroundStyle(.primary) + Text("No project — agent runs in your home directory.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + .scarfGoCompactListRow() + } + + Section("In project…") { + if isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + .padding(.vertical, 8) + } else if let err = loadError { + Label(err, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + } else if projects.isEmpty { + Text("No Scarf projects registered yet. Create one in the Mac app's Projects sidebar.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(sortedVisibleProjects) { project in + Button { + onProject(project) + dismiss() + } label: { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "folder.fill") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(project.name) + .font(.body) + .foregroundStyle(.primary) + Text(project.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .buttonStyle(.plain) + .scarfGoCompactListRow() + } + } + } + } + .scarfGoListDensity() + .navigationTitle("New chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + } + .task { await loadProjects() } + } + .presentationDetents([.height(320), .large]) + .presentationDragIndicator(.visible) + } + + /// Hide archived projects from the picker (they're deliberately + /// out-of-sight on the Mac sidebar; honor that on iOS too). + /// Sort alphabetically for predictability. + private var sortedVisibleProjects: [ProjectEntry] { + projects + .filter { !$0.archived } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// Load the project registry over SFTP via the shared + /// `ProjectDashboardService`. Transport-backed, so on iOS this + /// reads `~/.hermes/scarf/projects.json` through the open Citadel + /// connection. A missing/empty registry isn't an error — just + /// means no projects are configured yet. + private func loadProjects() async { + isLoading = true + defer { isLoading = false } + let ctx = context + let loaded: [ProjectEntry] = await Task.detached { + let service = ProjectDashboardService(context: ctx) + return service.loadRegistry().projects + }.value + projects = loaded + } +} diff --git a/scarf/scarf/Core/Services/ProjectAgentContextService.swift b/scarf/scarf/Core/Services/ProjectAgentContextService.swift index 1f39b29..72ebe94 100644 --- a/scarf/scarf/Core/Services/ProjectAgentContextService.swift +++ b/scarf/scarf/Core/Services/ProjectAgentContextService.swift @@ -41,11 +41,10 @@ import ScarfCore struct ProjectAgentContextService: Sendable { private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService") - /// Marker strings. Load-bearing: the format must stay stable - /// across releases so existing project AGENTS.md files continue - /// to be recognized and rewritten cleanly. - static let beginMarker = "" - static let endMarker = "" + /// Marker strings. Delegated to ScarfCore's `ProjectContextBlock` + /// in M9 #4.2 so both Mac and ScarfGo use identical markers. + static let beginMarker = ProjectContextBlock.beginMarker + static let endMarker = ProjectContextBlock.endMarker let context: ServerContext @@ -115,44 +114,10 @@ struct ProjectAgentContextService: Sendable { /// truncate to EOF (as the memory-block installer does) /// because an orphaned begin on this file is more likely /// hand-typed than a corrupt Scarf write. + /// Kept as a thin forwarder so pre-existing callers + tests keep + /// working. The logic lives in ScarfCore now (M9 #4.2). nonisolated static func applyBlock(block: String, to existing: String) -> String { - guard let beginRange = existing.range(of: beginMarker), - let endRange = existing.range( - of: endMarker, - range: beginRange.upperBound..