mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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>
This commit is contained in:
+8
-15
@@ -14,21 +14,14 @@ import Foundation
|
|||||||
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
||||||
/// can prefer that and fall back to this file for pre-upgrade
|
/// can prefer that and fall back to this file for pre-upgrade
|
||||||
/// sessions. Missing file → empty map (nothing attributed yet).
|
/// sessions. Missing file → empty map (nothing attributed yet).
|
||||||
struct SessionProjectMap: Codable, Sendable {
|
///
|
||||||
/// session-id → absolute-project-path. Both strings are opaque
|
/// Promoted to ScarfCore in M9 #4.2 so iOS can use the same record
|
||||||
/// from this file's perspective; the service validates project
|
/// type — ScarfGo's project-scoped chat writes here over SFTP.
|
||||||
/// paths against the live registry when building the reverse
|
public struct SessionProjectMap: Codable, Sendable {
|
||||||
/// lookup used by the Sessions tab, so stale entries for
|
public var mappings: [String: String]
|
||||||
/// removed projects are ignored at read time without needing a
|
public var updatedAt: String?
|
||||||
/// write-side cleanup.
|
|
||||||
var mappings: [String: String]
|
|
||||||
|
|
||||||
/// ISO-8601 timestamp of the most recent write. Informational
|
public init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||||
/// 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) {
|
|
||||||
self.mappings = mappings
|
self.mappings = mappings
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -37,7 +30,7 @@ struct SessionProjectMap: Codable, Sendable {
|
|||||||
/// `updatedAt` field. Matches the format used elsewhere in
|
/// `updatedAt` field. Matches the format used elsewhere in
|
||||||
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
||||||
/// greps across .json files sees consistent timestamps.
|
/// greps across .json files sees consistent timestamps.
|
||||||
static func nowISO8601() -> String {
|
public static func nowISO8601() -> String {
|
||||||
ISO8601DateFormatter().string(from: Date())
|
ISO8601DateFormatter().string(from: Date())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
/// `<project>/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 = "<!-- scarf-project:begin -->"
|
||||||
|
public static let endMarker = "<!-- scarf-project:end -->"
|
||||||
|
|
||||||
|
/// 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..<existing.endIndex
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return block + "\n" }
|
||||||
|
return block + "\n\n" + existing
|
||||||
|
}
|
||||||
|
var upperBound = endRange.upperBound
|
||||||
|
while upperBound < existing.endIndex,
|
||||||
|
existing[upperBound].isNewline {
|
||||||
|
upperBound = existing.index(after: upperBound)
|
||||||
|
}
|
||||||
|
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||||
|
let after = String(existing[upperBound..<existing.endIndex])
|
||||||
|
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? ""
|
||||||
|
: trimmingRightNewlines(before) + "\n\n"
|
||||||
|
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? "\n"
|
||||||
|
: "\n\n" + trimmingLeftNewlines(after)
|
||||||
|
return prefix + block + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `<project>/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
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-32
@@ -1,12 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(os)
|
||||||
import os
|
import os
|
||||||
import ScarfCore
|
#endif
|
||||||
|
|
||||||
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
||||||
/// project paths. The `cwd` passed to `hermes acp` at session
|
/// project paths. Promoted to ScarfCore in M9 #4.2 so ScarfGo can
|
||||||
/// creation is ephemeral from Hermes's perspective (not written to
|
/// write project attributions over SFTP — the whole service is
|
||||||
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
|
/// transport-based, so Mac and iOS share the same code path.
|
||||||
/// Hermes's session store.
|
|
||||||
///
|
///
|
||||||
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
||||||
/// `HermesPathSet.sessionProjectMap`).
|
/// `HermesPathSet.sessionProjectMap`).
|
||||||
@@ -16,14 +16,15 @@ import ScarfCore
|
|||||||
/// disk. Concurrent writers (two Scarf windows on the same
|
/// disk. Concurrent writers (two Scarf windows on the same
|
||||||
/// `~/.hermes`) are safe at the file level — last write wins —
|
/// `~/.hermes`) are safe at the file level — last write wins —
|
||||||
/// but the in-memory read in one window may lag until that window
|
/// but the in-memory read in one window may lag until that window
|
||||||
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
|
/// reloads.
|
||||||
/// cross-talk becomes a problem.
|
public struct SessionAttributionService: Sendable {
|
||||||
struct SessionAttributionService: Sendable {
|
#if canImport(os)
|
||||||
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
|
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
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ struct SessionAttributionService: Sendable {
|
|||||||
/// Load the current sidecar contents. Missing file or unparseable
|
/// Load the current sidecar contents. Missing file or unparseable
|
||||||
/// JSON returns an empty map — the sidecar is a convenience
|
/// JSON returns an empty map — the sidecar is a convenience
|
||||||
/// index, not a source of truth for anything load-bearing.
|
/// 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 path = context.paths.sessionProjectMap
|
||||||
let transport = context.makeTransport()
|
let transport = context.makeTransport()
|
||||||
guard transport.fileExists(path) else {
|
guard transport.fileExists(path) else {
|
||||||
@@ -42,26 +43,22 @@ struct SessionAttributionService: Sendable {
|
|||||||
let data = try transport.readFile(path)
|
let data = try transport.readFile(path)
|
||||||
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
|
#if canImport(os)
|
||||||
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
||||||
|
#endif
|
||||||
return SessionProjectMap()
|
return SessionProjectMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up the project path a given session was attributed to.
|
/// Look up the project path a given session was attributed to.
|
||||||
/// Returns nil for unattributed sessions (CLI-started, or
|
/// Returns nil for unattributed sessions.
|
||||||
/// started before v2.3) — those surface in the global Sessions
|
public nonisolated func projectPath(for sessionID: String) -> String? {
|
||||||
/// sidebar unchanged and don't appear in any project's Sessions
|
|
||||||
/// tab.
|
|
||||||
nonisolated func projectPath(for sessionID: String) -> String? {
|
|
||||||
load().mappings[sessionID]
|
load().mappings[sessionID]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reverse lookup: every session ID attributed to the given
|
/// Reverse lookup: every session ID attributed to the given
|
||||||
/// project path. Used by the per-project Sessions tab to filter
|
/// project path.
|
||||||
/// the global session list. Comparison is exact-string; the
|
public nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||||
/// registry stores absolute paths and we write absolute paths,
|
|
||||||
/// so no normalisation is needed in practice.
|
|
||||||
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
|
||||||
let map = load()
|
let map = load()
|
||||||
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
||||||
}
|
}
|
||||||
@@ -69,11 +66,8 @@ struct SessionAttributionService: Sendable {
|
|||||||
// MARK: - Write
|
// MARK: - Write
|
||||||
|
|
||||||
/// Record that `sessionID` was created under the given project
|
/// Record that `sessionID` was created under the given project
|
||||||
/// path. Idempotent — repeated calls for the same pair are no-
|
/// path. Idempotent.
|
||||||
/// ops. Replacing an existing mapping (session moved to a
|
public nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||||
/// different project) is legal but expected to be rare; the
|
|
||||||
/// caller decides when that's correct.
|
|
||||||
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
|
||||||
var map = load()
|
var map = load()
|
||||||
if map.mappings[sessionID] == projectPath {
|
if map.mappings[sessionID] == projectPath {
|
||||||
return
|
return
|
||||||
@@ -83,12 +77,10 @@ struct SessionAttributionService: Sendable {
|
|||||||
persist(map)
|
persist(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
|
/// Remove a mapping. Exposed for future "detach from project"
|
||||||
/// minimal — we don't currently prune on session delete because
|
/// UIs and tests; today's Mac + iOS call sites don't invoke it
|
||||||
/// Hermes owns session lifecycle and we don't observe deletes.
|
/// because Hermes owns session lifecycle.
|
||||||
/// Exposed for future roadmap items (e.g. explicit "detach
|
public nonisolated func forget(sessionID: String) {
|
||||||
/// from project" action) and tests.
|
|
||||||
nonisolated func forget(sessionID: String) {
|
|
||||||
var map = load()
|
var map = load()
|
||||||
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
||||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||||
@@ -110,7 +102,9 @@ struct SessionAttributionService: Sendable {
|
|||||||
let data = try encoder.encode(map)
|
let data = try encoder.encode(map)
|
||||||
try transport.writeFile(path, data: data)
|
try transport.writeFile(path, data: data)
|
||||||
} catch {
|
} catch {
|
||||||
|
#if canImport(os)
|
||||||
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ struct ChatView: View {
|
|||||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||||
@Environment(\.serverContext) private var envContext
|
@Environment(\.serverContext) private var envContext
|
||||||
@State private var controller: ChatController
|
@State private var controller: ChatController
|
||||||
|
@State private var showProjectPicker = false
|
||||||
|
|
||||||
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -50,13 +51,24 @@ struct ChatView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
Task { await controller.resetAndStartNewSession() }
|
showProjectPicker = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.bubble")
|
Image(systemName: "plus.bubble")
|
||||||
}
|
}
|
||||||
.disabled(controller.state == .connecting)
|
.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 {
|
.task {
|
||||||
// Dashboard row taps set `pendingResumeSessionID` on the
|
// Dashboard row taps set `pendingResumeSessionID` on the
|
||||||
// coordinator before switching to the Chat tab. Honor
|
// coordinator before switching to the Chat tab. Honor
|
||||||
@@ -453,6 +465,114 @@ final class ChatController {
|
|||||||
await start()
|
await start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User tapped "In project… <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
|
/// Resume an existing ACP session. Called from ChatView when the
|
||||||
/// coordinator carries a `pendingResumeSessionID` (Dashboard row
|
/// coordinator carries a `pendingResumeSessionID` (Dashboard row
|
||||||
/// tap). If we're currently on a different session, stop first
|
/// tap). If we're currently on a different session, stop first
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,11 +41,10 @@ import ScarfCore
|
|||||||
struct ProjectAgentContextService: Sendable {
|
struct ProjectAgentContextService: Sendable {
|
||||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
|
||||||
|
|
||||||
/// Marker strings. Load-bearing: the format must stay stable
|
/// Marker strings. Delegated to ScarfCore's `ProjectContextBlock`
|
||||||
/// across releases so existing project AGENTS.md files continue
|
/// in M9 #4.2 so both Mac and ScarfGo use identical markers.
|
||||||
/// to be recognized and rewritten cleanly.
|
static let beginMarker = ProjectContextBlock.beginMarker
|
||||||
static let beginMarker = "<!-- scarf-project:begin -->"
|
static let endMarker = ProjectContextBlock.endMarker
|
||||||
static let endMarker = "<!-- scarf-project:end -->"
|
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
|
|
||||||
@@ -115,44 +114,10 @@ struct ProjectAgentContextService: Sendable {
|
|||||||
/// truncate to EOF (as the memory-block installer does)
|
/// truncate to EOF (as the memory-block installer does)
|
||||||
/// because an orphaned begin on this file is more likely
|
/// because an orphaned begin on this file is more likely
|
||||||
/// hand-typed than a corrupt Scarf write.
|
/// 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 {
|
nonisolated static func applyBlock(block: String, to existing: String) -> String {
|
||||||
guard let beginRange = existing.range(of: beginMarker),
|
ProjectContextBlock.applyBlock(block, to: existing)
|
||||||
let endRange = existing.range(
|
|
||||||
of: endMarker,
|
|
||||||
range: beginRange.upperBound..<existing.endIndex
|
|
||||||
)
|
|
||||||
else {
|
|
||||||
// No well-formed Scarf block present — prepend.
|
|
||||||
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmedExisting.isEmpty {
|
|
||||||
return block + "\n"
|
|
||||||
}
|
|
||||||
return block + "\n\n" + existing
|
|
||||||
}
|
|
||||||
// Full span: from the begin marker through the end marker
|
|
||||||
// (inclusive). Consumes any trailing whitespace/newlines
|
|
||||||
// immediately following the end marker so a re-render of a
|
|
||||||
// shorter block doesn't leave a dangling blank line.
|
|
||||||
var upperBound = endRange.upperBound
|
|
||||||
while upperBound < existing.endIndex,
|
|
||||||
existing[upperBound].isNewline {
|
|
||||||
upperBound = existing.index(after: upperBound)
|
|
||||||
}
|
|
||||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
|
||||||
let after = String(existing[upperBound..<existing.endIndex])
|
|
||||||
// Preserve the leading whitespace / content structure of
|
|
||||||
// `before` but ensure exactly one blank line separates it
|
|
||||||
// from the new block when there IS prior content.
|
|
||||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? ""
|
|
||||||
: before.trimmingRightNewlines() + "\n\n"
|
|
||||||
// Suffix: a blank line BEFORE the remaining content, ensuring
|
|
||||||
// the template/user content is visually separated from the
|
|
||||||
// Scarf block.
|
|
||||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? "\n"
|
|
||||||
: "\n\n" + after.trimmingLeftNewlines()
|
|
||||||
return prefix + block + suffix
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Block rendering
|
// MARK: - Block rendering
|
||||||
|
|||||||
Reference in New Issue
Block a user