Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
691bb63
Add NotificationService for loop iteration notifications
zmanian Mar 7, 2026
42840c4
Add SwiftData models for loop feature (SpriteLoop, LoopIteration)
zmanian Mar 7, 2026
8487611
Add CreateLoopSheet UI and long-press gesture on send button
zmanian Mar 7, 2026
c93eadc
Add LoopManager for timer-based foreground loop execution
zmanian Mar 7, 2026
e3de8db
Wire LoopManager.executeLoopPrompt to SpritesAPIClient
zmanian Mar 7, 2026
1818540
Add dashboard loops section and loop detail view
zmanian Mar 7, 2026
38ecc5a
Inject LoopManager into environment and request notification permission
zmanian Mar 7, 2026
4df8938
Add BGAppRefreshTask support for background loop execution
zmanian Mar 7, 2026
abdf67d
Enable background fetch for loop BGAppRefreshTask
zmanian Mar 7, 2026
10a3d0f
Switch to custom Info.plist for proper BGTask configuration
zmanian Mar 7, 2026
40c8d64
Wire CreateLoopSheet to ChatView via long-press send
zmanian Mar 7, 2026
7750ae2
Add loop feature design doc and implementation plan
zmanian Mar 7, 2026
36ad709
Fix loop background scheduling lifecycle
zmanian Mar 7, 2026
dd414ee
Persist loop next run schedule
zmanian Mar 7, 2026
9b45809
Fix long-press send gesture by replacing Button with tap/long-press I…
zmanian Mar 7, 2026
e582873
Fix BGTaskScheduler crash: run handler on main queue
zmanian Mar 8, 2026
59e09da
Add test for handleBackgroundRefresh MainActor requirement
zmanian Mar 8, 2026
10f1a98
Add main queue precondition to handleBackgroundRefresh
zmanian Mar 8, 2026
277f6cf
Fix pbxproj and add missing entitlements file
zmanian Mar 8, 2026
7575f95
Fix keychain accessibility for background loop tasks
zmanian Mar 8, 2026
e694992
Fix loop wake: don't block on exec for cold sprites
zmanian Mar 8, 2026
12d5fac
Add retry logic for loop service stream on network errors
zmanian Mar 8, 2026
e8b53e2
Increase loop retry backoff and re-verify sprite before retry
zmanian Mar 8, 2026
13cbf95
Add 1-minute loop interval option
zmanian Mar 8, 2026
8ebc1a5
Fix sprite wake: let service PUT trigger wake, retry tool install
zmanian Mar 8, 2026
1200a96
Make question tool install non-blocking for chat
zmanian Mar 8, 2026
b9fc61d
Fix loop "(No response)": capture result events in flush path
zmanian Mar 8, 2026
703c4f7
Fix loop response: service stdout is raw text, not base64
zmanian Mar 8, 2026
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
341 changes: 310 additions & 31 deletions Wisp.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

41 changes: 40 additions & 1 deletion Wisp/App/WispApp.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
import BackgroundTasks
import SwiftData
import SwiftUI

@main
struct WispApp: App {
private let sharedModelContainer: ModelContainer
@State private var apiClient = SpritesAPIClient()
@State private var browserCoordinator = InAppBrowserCoordinator()
@State private var chatSessionManager = ChatSessionManager()
@State private var loopManager = LoopManager()
@AppStorage("theme") private var theme: String = "system"

init() {
do {
sharedModelContainer = try ModelContainer(for: SpriteChat.self, SpriteSession.self, SpriteLoop.self)
} catch {
fatalError("Failed to initialize model container: \(error)")
}

UserDefaults.standard.register(defaults: [
"claudeQuestionTool": true,
"worktreePerChat": true,
])

KeychainService.shared.migrateAccessibility()

let modelContainer = sharedModelContainer
BGTaskScheduler.shared.register(
forTaskWithIdentifier: LoopManager.bgTaskIdentifier,
using: .main
) { task in
guard let refreshTask = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}

let workTask = Task { @MainActor in
let bgLoopManager = LoopManager()
bgLoopManager.apiClient = SpritesAPIClient()
let modelContext = ModelContext(modelContainer)
let success = await bgLoopManager.handleBackgroundRefresh(modelContext: modelContext)
refreshTask.setTaskCompleted(success: success)
}

refreshTask.expirationHandler = {
workTask.cancel()
}
}
}

private var preferredColorScheme: ColorScheme? {
Expand All @@ -29,11 +63,16 @@ struct WispApp: App {
.environment(apiClient)
.environment(browserCoordinator)
.environment(chatSessionManager)
.environment(loopManager)
.preferredColorScheme(preferredColorScheme)
.onChange(of: apiClient.isAuthenticated, initial: true) {
browserCoordinator.authToken = apiClient.spritesToken
}
.task {
loopManager.apiClient = apiClient
await NotificationService.requestPermission()
}
}
.modelContainer(for: [SpriteChat.self, SpriteSession.self])
.modelContainer(sharedModelContainer)
}
}
57 changes: 57 additions & 0 deletions Wisp/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.wisp.app.loop-refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Wisp</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
166 changes: 166 additions & 0 deletions Wisp/Models/Local/LoopModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Foundation
import SwiftData

// MARK: - Enums

enum LoopState: String, Codable, Sendable {
case active
case paused
case stopped
}

enum LoopInterval: Double, Codable, Sendable, CaseIterable {
case oneMinute = 60
case fiveMinutes = 300
case tenMinutes = 600
case fifteenMinutes = 900
case thirtyMinutes = 1800
case oneHour = 3600

var seconds: TimeInterval {
rawValue
}

var displayName: String {
switch self {
case .oneMinute: "1m"
case .fiveMinutes: "5m"
case .tenMinutes: "10m"
case .fifteenMinutes: "15m"
case .thirtyMinutes: "30m"
case .oneHour: "1h"
}
}
}

enum LoopDuration: Double, Codable, Sendable, CaseIterable {
case oneDay = 86400
case threeDays = 259200
case oneWeek = 604800
case oneMonth = 2592000

var timeInterval: TimeInterval {
rawValue
}

var displayName: String {
switch self {
case .oneDay: "1 Day"
case .threeDays: "3 Days"
case .oneWeek: "1 Week"
case .oneMonth: "1 Month"
}
}
}

enum IterationStatus: Codable, Sendable, Equatable {
case running
case completed
case failed(String)
case skipped
}

// MARK: - SpriteLoop

@Model
final class SpriteLoop {
var id: UUID
var spriteName: String
var workingDirectory: String
var prompt: String
var intervalRaw: Double
var stateRaw: String
var createdAt: Date
var expiresAt: Date
var lastRunAt: Date?
var nextRunAt: Date
var iterationsData: Data?

var interval: LoopInterval {
get { LoopInterval(rawValue: intervalRaw) ?? .tenMinutes }
set { intervalRaw = newValue.rawValue }
}

var state: LoopState {
get { LoopState(rawValue: stateRaw) ?? .active }
set { stateRaw = newValue.rawValue }
}

var isExpired: Bool {
Date() >= expiresAt
}

var timeRemainingDisplay: String {
let remaining = expiresAt.timeIntervalSince(Date())
if remaining <= 0 { return "Expired" }

let hours = Int(remaining) / 3600
let days = hours / 24

if days > 0 {
return "\(days)d \(hours % 24)h remaining"
} else if hours > 0 {
let minutes = (Int(remaining) % 3600) / 60
return "\(hours)h \(minutes)m remaining"
} else {
let minutes = Int(remaining) / 60
return "\(minutes)m remaining"
}
}

var iterations: [LoopIteration] {
get {
guard let data = iterationsData else { return [] }
return (try? JSONDecoder().decode([LoopIteration].self, from: data)) ?? []
}
set {
iterationsData = try? JSONEncoder().encode(newValue)
}
}

init(
spriteName: String,
workingDirectory: String,
prompt: String,
interval: LoopInterval,
duration: LoopDuration = .oneWeek
) {
let createdAt = Date()
self.id = UUID()
self.spriteName = spriteName
self.workingDirectory = workingDirectory
self.prompt = prompt
self.intervalRaw = interval.rawValue
self.stateRaw = LoopState.active.rawValue
self.createdAt = createdAt
self.expiresAt = createdAt.addingTimeInterval(duration.timeInterval)
self.nextRunAt = createdAt
}

func markDueNow(at date: Date = Date()) {
nextRunAt = date
}

func scheduleNextRun(after referenceDate: Date) {
nextRunAt = referenceDate.addingTimeInterval(interval.seconds)
}
}

// MARK: - LoopIteration

struct LoopIteration: Identifiable, Codable, Sendable {
var id: UUID
var startedAt: Date
var completedAt: Date?
var prompt: String
var responseText: String?
var status: IterationStatus
var notificationSummary: String?

init(prompt: String) {
self.id = UUID()
self.startedAt = Date()
self.prompt = prompt
self.status = .running
}
}
20 changes: 20 additions & 0 deletions Wisp/Models/WispActivityAttributes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import ActivityKit
import Foundation

struct WispActivityAttributes: ActivityAttributes {
var spriteName: String
var userTask: String

struct ContentState: Codable, Hashable {
var subject: String?
var currentIntent: String
var currentIntentIcon: String?
var previousIntent: String?
var secondPreviousIntent: String?
var intentStartDate: Date
var intentEndDate: Date?
var stepNumber: Int

var isFinished: Bool { intentEndDate != nil }
}
}
13 changes: 11 additions & 2 deletions Wisp/Services/KeychainService.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import Security

enum KeychainKey: String {
enum KeychainKey: String, CaseIterable {
case spritesToken = "com.wisp.sprites-token"
case claudeToken = "com.wisp.claude-token"
case githubToken = "com.wisp.github-token"
Expand All @@ -24,7 +24,7 @@ struct KeychainService: Sendable {
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key.rawValue,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]

let status = SecItemAdd(addQuery as CFDictionary, nil)
Expand All @@ -50,6 +50,15 @@ struct KeychainService: Sendable {
return String(data: data, encoding: .utf8)
}

/// Re-save existing keychain items with `AfterFirstUnlock` accessibility so
/// background tasks can read tokens while the device is locked.
func migrateAccessibility() {
for key in KeychainKey.allCases {
guard let value = load(key: key) else { continue }
try? save(value, for: key)
}
}

func delete(key: KeychainKey) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
Expand Down
Loading
Loading