From 6c7a92cfb68122ccb12711733ee83b4c04fd6228 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:03:21 -0600 Subject: [PATCH 1/3] feat: implement iOS state management (AppState + ManifestStore) Observable state layer for the iOS app: - AppState: manages connections list, active connection, REST/WS lifecycle - ManifestStore: manifest cache with full refresh and incremental WS updates - UserDefaults-backed connection persistence - Auto-connect to last-used server on launch - Connection switching (disconnect current, connect to new) - Error state management with user-facing messages Closes #82 --- ios/PPGMobile/PPGMobile/State/AppState.swift | 231 ++++++++++++++++++ .../PPGMobile/State/ManifestStore.swift | 122 +++++++++ 2 files changed, 353 insertions(+) create mode 100644 ios/PPGMobile/PPGMobile/State/AppState.swift create mode 100644 ios/PPGMobile/PPGMobile/State/ManifestStore.swift diff --git a/ios/PPGMobile/PPGMobile/State/AppState.swift b/ios/PPGMobile/PPGMobile/State/AppState.swift new file mode 100644 index 0000000..7a39ada --- /dev/null +++ b/ios/PPGMobile/PPGMobile/State/AppState.swift @@ -0,0 +1,231 @@ +import Foundation + +// MARK: - UserDefaults Keys + +private enum DefaultsKey { + static let savedConnections = "ppg_saved_connections" + static let lastConnectionId = "ppg_last_connection_id" +} + +// MARK: - AppState + +/// Root application state managing server connections and the REST/WS lifecycle. +/// +/// `AppState` is the single entry point for connection management. It persists +/// connections to `UserDefaults`, auto-connects to the last-used server on +/// launch, and coordinates `PPGClient` (REST) and `WebSocketManager` (WS) +/// through `ManifestStore`. +@Observable +final class AppState { + + // MARK: - Connection State + + /// All saved server connections. + private(set) var connections: [ServerConnection] = [] + + /// The currently active connection, or `nil` if disconnected. + private(set) var activeConnection: ServerConnection? + + /// Whether a connection attempt is in progress. + private(set) var isConnecting = false + + /// User-facing error message, cleared on next successful action. + private(set) var errorMessage: String? + + // MARK: - WebSocket State + + /// Current WebSocket connection state. + private(set) var webSocketState: WebSocketConnectionState = .disconnected + + // MARK: - Dependencies + + let client = PPGClient() + let manifestStore: ManifestStore + private var webSocket: WebSocketManager? + + // MARK: - Init + + init() { + self.manifestStore = ManifestStore(client: client) + loadConnections() + } + + // MARK: - Auto-Connect + + /// Connects to the last-used server if one exists. + /// Call this from the app's `.task` modifier on launch. + @MainActor + func autoConnect() async { + guard let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), + let uuid = UUID(uuidString: lastId), + let connection = connections.first(where: { $0.id == uuid }) else { + return + } + await connect(to: connection) + } + + // MARK: - Connect / Disconnect + + /// Connects to the given server: configures REST client, tests reachability, + /// starts WebSocket, and fetches the initial manifest. + @MainActor + func connect(to connection: ServerConnection) async { + // Disconnect current connection first + if activeConnection != nil { + disconnect() + } + + isConnecting = true + errorMessage = nil + + await client.configure(connection: connection) + + do { + try await client.testConnection() + } catch { + isConnecting = false + errorMessage = "Cannot reach server: \(error.localizedDescription)" + return + } + + activeConnection = connection + UserDefaults.standard.set(connection.id.uuidString, forKey: DefaultsKey.lastConnectionId) + + // Start WebSocket + startWebSocket(for: connection) + + // Fetch initial manifest + await manifestStore.refresh() + + isConnecting = false + } + + /// Disconnects from the current server, tearing down WS and clearing state. + @MainActor + func disconnect() { + stopWebSocket() + activeConnection = nil + manifestStore.clear() + webSocketState = .disconnected + errorMessage = nil + } + + // MARK: - Connection CRUD + + /// Adds a new connection, persists it, and optionally connects to it. + @MainActor + func addConnection(_ connection: ServerConnection, connectImmediately: Bool = true) async { + // Avoid duplicates by host+port + if let existing = connections.firstIndex(where: { $0.host == connection.host && $0.port == connection.port }) { + connections[existing] = connection + } else { + connections.append(connection) + } + saveConnections() + + if connectImmediately { + await connect(to: connection) + } + } + + /// Removes a saved connection. Disconnects first if it's the active one. + @MainActor + func removeConnection(_ connection: ServerConnection) { + if activeConnection?.id == connection.id { + disconnect() + } + connections.removeAll { $0.id == connection.id } + saveConnections() + + // Clear last-used if it was this connection + if let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), + lastId == connection.id.uuidString { + UserDefaults.standard.removeObject(forKey: DefaultsKey.lastConnectionId) + } + } + + /// Updates an existing connection's properties and re-persists. + @MainActor + func updateConnection(_ connection: ServerConnection) { + guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return } + connections[index] = connection + saveConnections() + + // If this is the active connection, reconnect with new settings + if activeConnection?.id == connection.id { + Task { + await connect(to: connection) + } + } + } + + // MARK: - Error Handling + + /// Clears the current error message. + @MainActor + func clearError() { + errorMessage = nil + } + + // MARK: - WebSocket Lifecycle + + private func startWebSocket(for connection: ServerConnection) { + stopWebSocket() + + let ws = WebSocketManager(url: connection.webSocketURL) + ws.onStateChange = { [weak self] state in + Task { @MainActor in + self?.webSocketState = state + } + } + ws.onEvent = { [weak self] event in + Task { @MainActor in + self?.handleWebSocketEvent(event) + } + } + webSocket = ws + ws.connect() + } + + private func stopWebSocket() { + webSocket?.disconnect() + webSocket = nil + } + + @MainActor + private func handleWebSocketEvent(_ event: WebSocketEvent) { + switch event { + case .manifestUpdated(let manifest): + manifestStore.applyManifest(manifest) + + case .agentStatusChanged(let agentId, let status): + manifestStore.updateAgentStatus(agentId: agentId, status: status) + + case .worktreeStatusChanged(let worktreeId, let statusRaw): + if let status = WorktreeStatus(rawValue: statusRaw) { + manifestStore.updateWorktreeStatus(worktreeId: worktreeId, status: status) + } + + case .pong: + break + + case .unknown: + break + } + } + + // MARK: - Persistence (UserDefaults) + + private func loadConnections() { + guard let data = UserDefaults.standard.data(forKey: DefaultsKey.savedConnections), + let decoded = try? JSONDecoder().decode([ServerConnection].self, from: data) else { + return + } + connections = decoded + } + + private func saveConnections() { + guard let data = try? JSONEncoder().encode(connections) else { return } + UserDefaults.standard.set(data, forKey: DefaultsKey.savedConnections) + } +} diff --git a/ios/PPGMobile/PPGMobile/State/ManifestStore.swift b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift new file mode 100644 index 0000000..48df7dc --- /dev/null +++ b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift @@ -0,0 +1,122 @@ +import Foundation + +// MARK: - ManifestStore + +/// Caches the ppg manifest and applies incremental WebSocket updates. +/// +/// `ManifestStore` owns the manifest data and provides read access to views. +/// It is updated either by a full REST fetch or by individual WebSocket events +/// (agent/worktree status changes) to keep the UI responsive without polling. +@Observable +final class ManifestStore { + + // MARK: - Published State + + /// The cached manifest, or `nil` if not yet loaded. + private(set) var manifest: Manifest? + + /// Whether a fetch is currently in progress. + private(set) var isLoading = false + + /// Last error from a fetch or WebSocket update. + private(set) var error: String? + + /// Timestamp of the last successful refresh. + private(set) var lastRefreshed: Date? + + // MARK: - Dependencies + + private let client: PPGClient + + // MARK: - Init + + init(client: PPGClient) { + self.client = client + } + + // MARK: - Full Refresh + + /// Fetches the full manifest from the REST API and replaces the cache. + @MainActor + func refresh() async { + isLoading = true + error = nil + + do { + let fetched = try await client.fetchStatus() + manifest = fetched + lastRefreshed = Date() + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + // MARK: - Incremental Updates + + /// Applies a full manifest snapshot received from WebSocket. + @MainActor + func applyManifest(_ updated: Manifest) { + manifest = updated + lastRefreshed = Date() + error = nil + } + + /// Updates a single agent's status in the cached manifest. + @MainActor + func updateAgentStatus(agentId: String, status: AgentStatus) { + guard var m = manifest else { return } + for (wtId, var worktree) in m.worktrees { + if var agent = worktree.agents[agentId] { + agent.status = status + worktree.agents[agentId] = agent + m.worktrees[wtId] = worktree + manifest = m + return + } + } + } + + /// Updates a single worktree's status in the cached manifest. + @MainActor + func updateWorktreeStatus(worktreeId: String, status: WorktreeStatus) { + guard var m = manifest, + var worktree = m.worktrees[worktreeId] else { return } + worktree.status = status + m.worktrees[worktreeId] = worktree + manifest = m + } + + // MARK: - Clear + + /// Resets the store to its initial empty state. + @MainActor + func clear() { + manifest = nil + isLoading = false + error = nil + lastRefreshed = nil + } + + // MARK: - Convenience + + /// All worktrees sorted by creation date (newest first). + var sortedWorktrees: [WorktreeEntry] { + manifest?.sortedWorktrees ?? [] + } + + /// All agents across all worktrees. + var allAgents: [AgentEntry] { + manifest?.allAgents ?? [] + } + + /// Counts of agents by status. + var agentCounts: [AgentStatus: Int] { + var counts: [AgentStatus: Int] = [:] + for agent in allAgents { + counts[agent.status, default: 0] += 1 + } + return counts + } +} From dd9f3557d572e33cad8eddd1ac309a2d24535352 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:47:45 -0600 Subject: [PATCH 2/3] fix: address code review findings for iOS state management Security: - Strip tokens from UserDefaults, store via TokenStorage (Keychain) - Add PersistedConnection projection for token-free serialization - Clean up orphaned Keychain tokens on duplicate replacement and removal Thread safety: - Mark AppState and ManifestStore @MainActor at class level - Remove redundant per-method @MainActor annotations Concurrency: - Guard against concurrent connect() calls with isConnecting check - Make updateConnection() async to await reconnect instead of fire-and-forget UX: - Stop clearing errorMessage in disconnect() so errors remain visible --- ios/PPGMobile/PPGMobile/State/AppState.swift | 87 ++++++++++++------- .../PPGMobile/State/ManifestStore.swift | 6 +- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/State/AppState.swift b/ios/PPGMobile/PPGMobile/State/AppState.swift index 7a39ada..0cbd7ec 100644 --- a/ios/PPGMobile/PPGMobile/State/AppState.swift +++ b/ios/PPGMobile/PPGMobile/State/AppState.swift @@ -7,14 +7,41 @@ private enum DefaultsKey { static let lastConnectionId = "ppg_last_connection_id" } +/// Codable projection of ServerConnection without the token. +/// Tokens are stored separately in Keychain via TokenStorage. +private struct PersistedConnection: Codable { + let id: UUID + var host: String + var port: Int + var caCertificate: String? + + init(from connection: ServerConnection) { + self.id = connection.id + self.host = connection.host + self.port = connection.port + self.caCertificate = connection.caCertificate + } + + func toServerConnection(token: String) -> ServerConnection { + ServerConnection( + id: id, + host: host, + port: port, + caCertificate: caCertificate, + token: token + ) + } +} + // MARK: - AppState /// Root application state managing server connections and the REST/WS lifecycle. /// /// `AppState` is the single entry point for connection management. It persists -/// connections to `UserDefaults`, auto-connects to the last-used server on -/// launch, and coordinates `PPGClient` (REST) and `WebSocketManager` (WS) -/// through `ManifestStore`. +/// connection metadata to `UserDefaults` and tokens to Keychain via `TokenStorage`. +/// Auto-connects to the last-used server on launch and coordinates `PPGClient` +/// (REST) and `WebSocketManager` (WS) through `ManifestStore`. +@MainActor @Observable final class AppState { @@ -29,7 +56,7 @@ final class AppState { /// Whether a connection attempt is in progress. private(set) var isConnecting = false - /// User-facing error message, cleared on next successful action. + /// User-facing error message, cleared on next connect attempt. private(set) var errorMessage: String? // MARK: - WebSocket State @@ -54,7 +81,6 @@ final class AppState { /// Connects to the last-used server if one exists. /// Call this from the app's `.task` modifier on launch. - @MainActor func autoConnect() async { guard let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), let uuid = UUID(uuidString: lastId), @@ -68,8 +94,9 @@ final class AppState { /// Connects to the given server: configures REST client, tests reachability, /// starts WebSocket, and fetches the initial manifest. - @MainActor func connect(to connection: ServerConnection) async { + guard !isConnecting else { return } + // Disconnect current connection first if activeConnection != nil { disconnect() @@ -91,33 +118,32 @@ final class AppState { activeConnection = connection UserDefaults.standard.set(connection.id.uuidString, forKey: DefaultsKey.lastConnectionId) - // Start WebSocket startWebSocket(for: connection) - - // Fetch initial manifest await manifestStore.refresh() isConnecting = false } /// Disconnects from the current server, tearing down WS and clearing state. - @MainActor func disconnect() { stopWebSocket() activeConnection = nil manifestStore.clear() webSocketState = .disconnected - errorMessage = nil } // MARK: - Connection CRUD /// Adds a new connection, persists it, and optionally connects to it. - @MainActor func addConnection(_ connection: ServerConnection, connectImmediately: Bool = true) async { - // Avoid duplicates by host+port - if let existing = connections.firstIndex(where: { $0.host == connection.host && $0.port == connection.port }) { - connections[existing] = connection + // Clean up orphaned Keychain token if replacing a duplicate + if let existing = connections.first(where: { $0.host == connection.host && $0.port == connection.port }), + existing.id != connection.id { + try? TokenStorage.delete(for: existing.id) + } + + if let index = connections.firstIndex(where: { $0.host == connection.host && $0.port == connection.port }) { + connections[index] = connection } else { connections.append(connection) } @@ -129,15 +155,14 @@ final class AppState { } /// Removes a saved connection. Disconnects first if it's the active one. - @MainActor func removeConnection(_ connection: ServerConnection) { if activeConnection?.id == connection.id { disconnect() } connections.removeAll { $0.id == connection.id } + try? TokenStorage.delete(for: connection.id) saveConnections() - // Clear last-used if it was this connection if let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), lastId == connection.id.uuidString { UserDefaults.standard.removeObject(forKey: DefaultsKey.lastConnectionId) @@ -145,24 +170,19 @@ final class AppState { } /// Updates an existing connection's properties and re-persists. - @MainActor - func updateConnection(_ connection: ServerConnection) { + func updateConnection(_ connection: ServerConnection) async { guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return } connections[index] = connection saveConnections() - // If this is the active connection, reconnect with new settings if activeConnection?.id == connection.id { - Task { - await connect(to: connection) - } + await connect(to: connection) } } // MARK: - Error Handling /// Clears the current error message. - @MainActor func clearError() { errorMessage = nil } @@ -192,7 +212,6 @@ final class AppState { webSocket = nil } - @MainActor private func handleWebSocketEvent(_ event: WebSocketEvent) { switch event { case .manifestUpdated(let manifest): @@ -214,18 +233,28 @@ final class AppState { } } - // MARK: - Persistence (UserDefaults) + // MARK: - Persistence private func loadConnections() { guard let data = UserDefaults.standard.data(forKey: DefaultsKey.savedConnections), - let decoded = try? JSONDecoder().decode([ServerConnection].self, from: data) else { + let persisted = try? JSONDecoder().decode([PersistedConnection].self, from: data) else { return } - connections = decoded + connections = persisted.compactMap { entry in + guard let token = try? TokenStorage.load(for: entry.id) else { return nil } + return entry.toServerConnection(token: token) + } } private func saveConnections() { - guard let data = try? JSONEncoder().encode(connections) else { return } + // Persist metadata to UserDefaults (no tokens) + let persisted = connections.map { PersistedConnection(from: $0) } + guard let data = try? JSONEncoder().encode(persisted) else { return } UserDefaults.standard.set(data, forKey: DefaultsKey.savedConnections) + + // Persist tokens to Keychain + for connection in connections { + try? TokenStorage.save(token: connection.token, for: connection.id) + } } } diff --git a/ios/PPGMobile/PPGMobile/State/ManifestStore.swift b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift index 48df7dc..4e5ed1f 100644 --- a/ios/PPGMobile/PPGMobile/State/ManifestStore.swift +++ b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift @@ -7,6 +7,7 @@ import Foundation /// `ManifestStore` owns the manifest data and provides read access to views. /// It is updated either by a full REST fetch or by individual WebSocket events /// (agent/worktree status changes) to keep the UI responsive without polling. +@MainActor @Observable final class ManifestStore { @@ -37,7 +38,6 @@ final class ManifestStore { // MARK: - Full Refresh /// Fetches the full manifest from the REST API and replaces the cache. - @MainActor func refresh() async { isLoading = true error = nil @@ -56,7 +56,6 @@ final class ManifestStore { // MARK: - Incremental Updates /// Applies a full manifest snapshot received from WebSocket. - @MainActor func applyManifest(_ updated: Manifest) { manifest = updated lastRefreshed = Date() @@ -64,7 +63,6 @@ final class ManifestStore { } /// Updates a single agent's status in the cached manifest. - @MainActor func updateAgentStatus(agentId: String, status: AgentStatus) { guard var m = manifest else { return } for (wtId, var worktree) in m.worktrees { @@ -79,7 +77,6 @@ final class ManifestStore { } /// Updates a single worktree's status in the cached manifest. - @MainActor func updateWorktreeStatus(worktreeId: String, status: WorktreeStatus) { guard var m = manifest, var worktree = m.worktrees[worktreeId] else { return } @@ -91,7 +88,6 @@ final class ManifestStore { // MARK: - Clear /// Resets the store to its initial empty state. - @MainActor func clear() { manifest = nil isLoading = false From 74e8924a57f954f0e30900fadb71d5fdf54de198 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:38:14 -0600 Subject: [PATCH 3/3] Fix state persistence error handling and test manifest typing --- ios/PPGMobile/PPGMobile/State/AppState.swift | 61 ++++++++++++++++--- .../PPGMobile/State/ManifestStore.swift | 7 ++- src/commands/spawn.test.ts | 5 +- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/State/AppState.swift b/ios/PPGMobile/PPGMobile/State/AppState.swift index 0cbd7ec..5c37884 100644 --- a/ios/PPGMobile/PPGMobile/State/AppState.swift +++ b/ios/PPGMobile/PPGMobile/State/AppState.swift @@ -139,7 +139,11 @@ final class AppState { // Clean up orphaned Keychain token if replacing a duplicate if let existing = connections.first(where: { $0.host == connection.host && $0.port == connection.port }), existing.id != connection.id { - try? TokenStorage.delete(for: existing.id) + do { + try TokenStorage.delete(for: existing.id) + } catch { + errorMessage = "Failed to remove stale credentials from Keychain." + } } if let index = connections.firstIndex(where: { $0.host == connection.host && $0.port == connection.port }) { @@ -160,7 +164,11 @@ final class AppState { disconnect() } connections.removeAll { $0.id == connection.id } - try? TokenStorage.delete(for: connection.id) + do { + try TokenStorage.delete(for: connection.id) + } catch { + errorMessage = "Failed to remove connection credentials from Keychain." + } saveConnections() if let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), @@ -236,25 +244,58 @@ final class AppState { // MARK: - Persistence private func loadConnections() { - guard let data = UserDefaults.standard.data(forKey: DefaultsKey.savedConnections), - let persisted = try? JSONDecoder().decode([PersistedConnection].self, from: data) else { + guard let data = UserDefaults.standard.data(forKey: DefaultsKey.savedConnections) else { + return + } + + let persisted: [PersistedConnection] + do { + persisted = try JSONDecoder().decode([PersistedConnection].self, from: data) + } catch { + errorMessage = "Failed to load saved connections." return } - connections = persisted.compactMap { entry in - guard let token = try? TokenStorage.load(for: entry.id) else { return nil } - return entry.toServerConnection(token: token) + + var loaded: [ServerConnection] = [] + var failedTokenLoad = false + for entry in persisted { + do { + let token = try TokenStorage.load(for: entry.id) + loaded.append(entry.toServerConnection(token: token)) + } catch { + failedTokenLoad = true + } + } + connections = loaded + + if failedTokenLoad { + errorMessage = "Some saved connection tokens could not be loaded." } } private func saveConnections() { // Persist metadata to UserDefaults (no tokens) let persisted = connections.map { PersistedConnection(from: $0) } - guard let data = try? JSONEncoder().encode(persisted) else { return } - UserDefaults.standard.set(data, forKey: DefaultsKey.savedConnections) + do { + let data = try JSONEncoder().encode(persisted) + UserDefaults.standard.set(data, forKey: DefaultsKey.savedConnections) + } catch { + errorMessage = "Failed to save connections." + return + } // Persist tokens to Keychain + var failedTokenSave = false for connection in connections { - try? TokenStorage.save(token: connection.token, for: connection.id) + do { + try TokenStorage.save(token: connection.token, for: connection.id) + } catch { + failedTokenSave = true + } + } + + if failedTokenSave { + errorMessage = "Some connection tokens could not be saved." } } } diff --git a/ios/PPGMobile/PPGMobile/State/ManifestStore.swift b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift index 4e5ed1f..1c065a7 100644 --- a/ios/PPGMobile/PPGMobile/State/ManifestStore.swift +++ b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift @@ -41,6 +41,7 @@ final class ManifestStore { func refresh() async { isLoading = true error = nil + defer { isLoading = false } do { let fetched = try await client.fetchStatus() @@ -49,8 +50,6 @@ final class ManifestStore { } catch { self.error = error.localizedDescription } - - isLoading = false } // MARK: - Incremental Updates @@ -71,6 +70,8 @@ final class ManifestStore { worktree.agents[agentId] = agent m.worktrees[wtId] = worktree manifest = m + lastRefreshed = Date() + error = nil return } } @@ -83,6 +84,8 @@ final class ManifestStore { worktree.status = status m.worktrees[worktreeId] = worktree manifest = m + lastRefreshed = Date() + error = nil } // MARK: - Clear diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..8a61882 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -93,7 +94,7 @@ function createManifest(tmuxWindow = '') { baseBranch: 'main', status: 'active' as const, tmuxWindow, - agents: {} as Record, + agents: {}, createdAt: '2026-02-27T00:00:00.000Z', }, },