Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions Sources/AgentSyncApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,20 @@ enum Prefs {
}

static var codexTargetModel: String {
UserDefaults.standard.string(forKey: codexTargetModelKey) ?? production.defaultCodexModel
nonBlank(UserDefaults.standard.string(forKey: codexTargetModelKey)) ?? production.defaultCodexModel
}

static var claudeTargetModel: String {
UserDefaults.standard.string(forKey: claudeTargetModelKey) ?? production.defaultClaudeModel
nonBlank(UserDefaults.standard.string(forKey: claudeTargetModelKey)) ?? production.defaultClaudeModel
}

/// A cleared Settings field stores "", which must fall back to the default
/// rather than become the target model.
private static func nonBlank(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}

static var preferredTerminal: TerminalApp {
Expand Down Expand Up @@ -331,6 +340,18 @@ final class AppModel: ObservableObject {
private var activationObserver: NSObjectProtocol?
private var titleCache: [String: String] = [:]
private var titleGenerationInFlight: Set<String> = []
/// Sessions the on-device model declined to title this launch (too short,
/// guardrails, timeout). Skipped so they don't block the ones behind them.
private var titleGenerationFailed: Set<String> = []
/// A refresh requested while a scan or conversion is running (e.g. after a
/// Settings path change) runs once that work lands.
private var refreshQueued = false
/// Bumped whenever the continue flow is abandoned, so a conversion that
/// finishes afterwards doesn't resurrect a stale review page.
private var conversionGeneration = 0
/// The picker whose continue flow started the current conversion. Each
/// hotkey panel is a fresh picker, so only its own close abandons work.
private var conversionOwner: UUID?
private let isDemo: Bool

/// Demo instance for README screenshots: no scanning, timers, or hotkey, so
Expand Down Expand Up @@ -402,9 +423,11 @@ final class AppModel: ObservableObject {
guard !isDemo else {
return
}
guard !isRefreshing, launchingID == nil else {
guard launchingID == nil, !isRefreshing else {
refreshQueued = true
return
}
refreshQueued = false
isRefreshing = true
let configuration = Prefs.configuration()

Expand Down Expand Up @@ -433,9 +456,12 @@ final class AppModel: ObservableObject {
self.pruneTitleCache(keeping: decorated)
self.generateMissingTitles()
case .failure(let error):
self.status = .error(String(describing: error))
if self.launchingID == nil {
self.setStatus(.error(String(describing: error)))
}
}
self.isRefreshing = false
self.runQueuedRefresh()
}
}
}
Expand All @@ -447,7 +473,11 @@ final class AppModel: ObservableObject {
return
}
let candidates = sessions
.filter { $0.wantsRefinedTitle && !titleGenerationInFlight.contains($0.id) }
.filter {
$0.wantsRefinedTitle
&& !titleGenerationInFlight.contains($0.id)
&& !titleGenerationFailed.contains($0.id)
}
.prefix(3)
for item in candidates {
titleGenerationInFlight.insert(item.id)
Expand All @@ -457,6 +487,8 @@ final class AppModel: ObservableObject {
await MainActor.run {
self.titleGenerationInFlight.remove(item.id)
guard let title else {
self.titleGenerationFailed.insert(item.id)
self.generateMissingTitles()
return
}
guard let index = self.sessions.firstIndex(where: { $0.id == item.id }),
Expand Down Expand Up @@ -502,15 +534,18 @@ final class AppModel: ObservableObject {
}
}

func prepareResume(_ item: SessionItem, target: AgentKind, mode: ResumeMode) {
func prepareResume(_ item: SessionItem, target: AgentKind, mode: ResumeMode, owner: UUID) {
guard launchingID == nil, target != item.preview.provider else {
return
}
conversionOwner = owner
let terminal = Prefs.preferredTerminal
let codexDestination = Prefs.codexLaunchDestination
let claudeDestination = Prefs.claudeLaunchDestination
let opencodeDestination = Prefs.opencodeLaunchDestination
launchingID = item.id
conversionGeneration += 1
let generation = conversionGeneration
setStatus(.working("Converting for \(target.displayName)…"))
Prefs.setPrimaryTarget(target, for: item.preview.provider)
let configuration = Prefs.configuration()
Expand Down Expand Up @@ -557,7 +592,9 @@ final class AppModel: ObservableObject {
await MainActor.run {
switch result {
case .success(let prepared):
self.preparedConversion = prepared
if generation == self.conversionGeneration {
self.preparedConversion = prepared
}
self.setStatus(.idle)
case .failure(let error):
self.setStatus(.error(shortErrorText(error)))
Expand All @@ -567,6 +604,7 @@ final class AppModel: ObservableObject {
}
}
self.launchingID = nil
self.runQueuedRefresh()
}
}
}
Expand Down Expand Up @@ -611,9 +649,26 @@ final class AppModel: ObservableObject {
}

func clearPreparedConversion() {
conversionGeneration += 1
preparedConversion = nil
}

/// Drops the prepared (or in-flight) conversion only if `owner`'s picker
/// started it, so closing one picker never discards another's work.
func abandonConversion(ownedBy owner: UUID) {
guard conversionOwner == owner else {
return
}
conversionOwner = nil
clearPreparedConversion()
}

private func runQueuedRefresh() {
if refreshQueued {
refresh()
}
}

func clearStatus() {
setStatus(.idle)
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/AgentSyncApp/ContinueView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import SwiftUI
struct ContinueView: View {
@ObservedObject var model: AppModel
var item: SessionItem
var ownerID: UUID
var onDismiss: () -> Void

@State private var target: AgentKind
@State private var mode: ResumeMode

init(model: AppModel, item: SessionItem, onDismiss: @escaping () -> Void) {
init(model: AppModel, item: SessionItem, ownerID: UUID = UUID(), onDismiss: @escaping () -> Void) {
self.model = model
self.item = item
self.ownerID = ownerID
self.onDismiss = onDismiss
let initialTarget = item.primaryTarget
_target = State(initialValue: initialTarget)
Expand Down Expand Up @@ -104,7 +106,7 @@ struct ContinueView: View {
Spacer()

Button {
model.prepareResume(item, target: target, mode: mode)
model.prepareResume(item, target: target, mode: mode, owner: ownerID)
} label: {
HStack(spacing: 5) {
if model.launchingID == item.id {
Expand Down
6 changes: 5 additions & 1 deletion Sources/AgentSyncApp/QuickPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ enum QuickPicker {
panel = nil
return
}
let hosting = NSHostingController(rootView: SessionPickerView(model: model))
let ownerID = UUID()
let hosting = NSHostingController(rootView: SessionPickerView(model: model, ownerID: ownerID))
let newPanel = KeyablePanel(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 200),
styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView],
Expand All @@ -86,6 +87,9 @@ enum QuickPicker {
newPanel.isReleasedWhenClosed = false
newPanel.onDismiss = {
panel = nil
// This panel's picker is gone for good; its review page must not
// reappear in the next one.
model.abandonConversion(ownedBy: ownerID)
}

// Spotlight placement: horizontally centered, upper third.
Expand Down
6 changes: 4 additions & 2 deletions Sources/AgentSyncApp/SessionPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import SwiftUI

struct SessionPickerView: View {
@ObservedObject var model: AppModel
/// Identifies this picker's continue flow to the shared model.
var ownerID = UUID()
@State private var query = ""
@State private var selectedItem: SessionItem?

Expand Down Expand Up @@ -31,11 +33,11 @@ struct SessionPickerView: View {
var body: some View {
VStack(spacing: 0) {
if let item = selectedItem {
ContinueView(model: model, item: item) {
ContinueView(model: model, item: item, ownerID: ownerID) {
// Leaving the continue flow abandons any prepared
// conversion; otherwise reopening the same session would
// restore (and offer to launch) stale choices.
model.clearPreparedConversion()
model.abandonConversion(ownedBy: ownerID)
selectedItem = nil
}
} else {
Expand Down
27 changes: 21 additions & 6 deletions Sources/AgentSyncCLI/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ struct AgentSyncCLI {
let path = value(after: "--path", in: args) else {
throw AgentSyncError.invalidArguments("prepare-resume requires --provider claude|codex and --path FILE plus the home/state flags")
}
let mode = value(after: "--mode", in: args).flatMap(ResumeMode.init(rawValue:)) ?? .auto
let target = value(after: "--target", in: args).flatMap(AgentKind.init(rawValue:))
let mode = try parsed(after: "--mode", in: args, as: ResumeMode.init(rawValue:)) ?? .auto
let target = try parsed(after: "--target", in: args, as: AgentKind.init(rawValue:))
let ticket = try SyncEngine(configuration: config).prepareResume(
provider: provider,
sourcePath: provider == .opencode ? path : NSString(string: path).expandingTildeInPath,
Expand Down Expand Up @@ -151,8 +151,8 @@ struct AgentSyncCLI {
stateDirectory: URL(fileURLWithPath: NSString(string: stateDir).expandingTildeInPath, isDirectory: true),
defaultCodexModel: value(after: "--codex-model", in: args) ?? "gpt-5.5",
defaultClaudeModel: value(after: "--claude-model", in: args) ?? "claude-sonnet-5",
sessionLookbackDays: intValue(after: "--lookback-days", in: args) ?? 14,
maximumImportedSessions: intValue(after: "--max-sessions", in: args) ?? 3
sessionLookbackDays: try intValue(after: "--lookback-days", in: args) ?? 14,
maximumImportedSessions: try intValue(after: "--max-sessions", in: args) ?? 3
)
}

Expand All @@ -163,8 +163,23 @@ struct AgentSyncCLI {
return args[index + 1]
}

private static func intValue(after flag: String, in args: [String]) -> Int? {
value(after: flag, in: args).flatMap(Int.init)
private static func intValue(after flag: String, in args: [String]) throws -> Int? {
try parsed(after: flag, in: args, as: Int.init)
}

/// Nil when the flag is absent; throws when it is present but unparsable,
/// so a typo like `--mode ful` fails instead of silently using the default.
private static func parsed<T>(after flag: String, in args: [String], as parse: (String) -> T?) throws -> T? {
guard args.contains(flag) else {
return nil
}
guard let raw = value(after: flag, in: args) else {
throw AgentSyncError.invalidArguments("missing value for \(flag)")
}
guard let parsedValue = parse(raw) else {
throw AgentSyncError.invalidArguments("invalid value for \(flag): \(raw)")
}
return parsedValue
}

private static func printUsage() {
Expand Down
12 changes: 8 additions & 4 deletions Sources/AgentSyncCore/CodexThreadIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,23 @@ struct CodexThreadIndex {
private func runSQLiteCapturing(_ sql: String) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
process.arguments = [databaseURL.path, sql]
// Codex keeps this database open; wait out its write locks instead of
// failing immediately with "database is locked".
process.arguments = ["-cmd", ".timeout 3000", databaseURL.path, sql]
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
// Drain stdout before waiting so a large result can't fill the pipe
// and deadlock the child.
let data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
if process.terminationStatus != 0 {
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8) ?? "sqlite3 failed"
let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
let message = String(data: errorData, encoding: .utf8) ?? "sqlite3 failed"
throw AgentSyncError.commandFailed(message)
}
let data = stdout.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
}
Expand Down
35 changes: 29 additions & 6 deletions Sources/AgentSyncCore/ModelCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,19 @@ public enum ModelCatalog {
/// Blocking GET of the models.dev catalog; nil on any non-200 or timeout.
private static func fetchCatalogData(timeout: TimeInterval) -> Data? {
let semaphore = DispatchSemaphore(value: 0)
var fetched: Data?
URLSession.shared.dataTask(with: sourceURL) { data, response, _ in
let fetched = FetchResult()
let task = URLSession.shared.dataTask(with: sourceURL) { data, response, _ in
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
fetched = data
fetched.set(data)
}
semaphore.signal()
}.resume()
_ = semaphore.wait(timeout: .now() + timeout)
return fetched
}
task.resume()
guard semaphore.wait(timeout: .now() + timeout) == .success else {
task.cancel()
return nil
}
return fetched.get()
}

/// Usable input tokens for a model, matched as "provider/model" or bare
Expand Down Expand Up @@ -132,3 +136,22 @@ public enum ModelCatalog {
lock.unlock()
}
}

/// The completion handler can still be running when a timed-out fetch
/// returns, so its result crosses threads behind a lock.
private final class FetchResult: @unchecked Sendable {
private let lock = NSLock()
private var data: Data?

func set(_ data: Data?) {
lock.lock()
defer { lock.unlock() }
self.data = data
}

func get() -> Data? {
lock.lock()
defer { lock.unlock() }
return data
}
}
9 changes: 6 additions & 3 deletions Sources/AgentSyncCore/NativeFileWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ enum NativeFileWriter {
}
try text.write(to: tmp, atomically: true, encoding: .utf8)

if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
// rename(2) swaps the file in one step, so a concurrent reader (a
// resuming CLI) never sees the mirror missing mid-replace.
guard rename(tmp.path, destination.path) == 0 else {
let code = errno
try? fm.removeItem(at: tmp)
throw AgentSyncError.unsafeWrite("Could not move mirror into place at \(destination.path): \(String(cString: strerror(code)))")
}
try fm.moveItem(at: tmp, to: destination)
}

/// Canonicalizes a path whether or not it exists yet, by resolving the
Expand Down
4 changes: 3 additions & 1 deletion Sources/AgentSyncCore/OpenCodeAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ public struct OpenCodeAdapter: Sendable {
text: "OpenCode tool call: \(toolName)\n\(input)",
metadata: metadata
))
// A failed call carries its message in `error`, not `output`.
let rawOutput = state.string("output") ?? state["output"]?.prettyString() ?? ""
let output = boundedTranscriptText(
state.string("output") ?? state["output"]?.prettyString() ?? "",
rawOutput.isEmpty ? (state.string("error") ?? "") : rawOutput,
limit: 20_000
)
if !output.isEmpty {
Expand Down
Loading
Loading