From 785e4c23babcac1da505fa3e74023543ee9fae87 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Wed, 21 Oct 2026 23:29:53 -0500 Subject: [PATCH 1/7] feat(projects): make adding and removing projects explicit across devices The daemon's project registry is now each machine's single project list. Only `init` adds a project and the new `remove_project` request removes it. Previously the registry grew whenever any request touched an initialized project, and the Mac removed projects by deleting .pu/manifest.json, which never reached the daemon, so other Macs and the phone kept showing removed projects. - Protocol 13: `remove_project` (keeps files and .pu/; refuses while agents are live unless `suspend_agents`) and `subscribe_projects`, which pushes the full root list on every change. - Using a project no longer registers it, so a project removed on one device stays removed while others still watch it. - Registry entries whose folder exists without a manifest (left behind by older clients) are dropped on load and by the next global inventory. - CLI: `pu projects add [path]` and `pu projects remove [path] [--suspend-agents]`. - macOS: the sidebar follows the local daemon's registry instead of its own UserDefaults list (imported once). Remove Project calls remove_project instead of deleting files. Remote projects offer both "Hide on This Mac/iPad" and "Remove from ", and machines follow pushed changes. - Phone: bridge `workspace.removeProject` and a "Remove from " action. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- .../Services/DaemonProjectsSubscription.swift | 62 +++ .../Services/DaemonProtocol.swift | 39 +- .../purepoint-macos/State/AppState.swift | 132 +++++- .../State/MachineState+OpenProject.swift | 21 + .../purepoint-macos/State/MachineState.swift | 35 +- .../Views/Sidebar/SidebarMenuModel.swift | 64 ++- .../SidebarOutlineView+Callbacks.swift | 48 ++- ...SidebarOutlineViewController+Actions.swift | 4 + .../SidebarOutlineViewController+iOS.swift | 5 +- .../SidebarOutlineViewController.swift | 5 +- .../purepoint-macosTests/MachinesTests.swift | 27 +- .../PointGuardRoutingTests.swift | 59 ++- .../RemoteFolderPickerTests.swift | 31 ++ .../SidebarMenuModelTests.swift | 25 +- .../verification/PointGuardRoutingStubs.swift | 11 + .../PurePoint/AgentsView.swift | 34 +- .../PurePoint/ChatDomain.swift | 1 + apps/purepoint-mobile/bridge/workspace.js | 26 +- .../purepoint-mobile/bridge/workspace.test.js | 40 +- apps/purepoint-mobile/docs/contract.md | 5 +- .../verification/check-rpc-budgets.py | 1 + crates/pu-cli/src/commands/init.rs | 5 +- crates/pu-cli/src/commands/mod.rs | 1 + crates/pu-cli/src/commands/projects.rs | 62 +++ crates/pu-cli/src/main.rs | 51 ++- crates/pu-cli/src/output/mod.rs | 15 + crates/pu-cli/src/output/system.rs | 15 + crates/pu-cli/tests/global_inventory.rs | 43 ++ crates/pu-core/src/protocol/mod.rs | 22 +- crates/pu-core/src/protocol/tests.rs | 32 +- crates/pu-engine/src/engine/inventory.rs | 389 +++++++++++++++++- crates/pu-engine/src/engine/mod.rs | 13 +- crates/pu-engine/src/engine/subscriptions.rs | 11 + crates/pu-engine/src/ipc_server/mod.rs | 7 + crates/pu-engine/src/ipc_server/streams.rs | 50 +++ crates/pu-engine/src/ipc_server/tests.rs | 62 +++ docs/product/cli.md | 4 +- docs/product/daemon.md | 2 + docs/reference/cli-commands-map.csv | 2 + docs/reference/ipc-api.md | 27 +- 40 files changed, 1378 insertions(+), 110 deletions(-) create mode 100644 apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift create mode 100644 crates/pu-cli/src/commands/projects.rs diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift new file mode 100644 index 00000000..a6dddcee --- /dev/null +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Follows a daemon's registered projects (protocol 13). The daemon sends the full list of +/// roots when the stream opens and again after every add or remove, so a project added or +/// removed from any device, or with `pu projects`, shows up here without polling. +/// Modeled after DaemonStatusSubscription; unlike it, it never gives up while running: a +/// restarted daemon is simply subscribed to again. +actor DaemonProjectsSubscription { + private var connection: (any DaemonByteStream)? + private var stopped = false + private let client: DaemonClient + + init(client: DaemonClient) { + self.client = client + } + + /// Delivers each list of roots until `stop()`. Reconnects with backoff after a failure. + func start(onChange: @escaping @MainActor ([String]) -> Void) async { + var backoff: UInt64 = 500_000_000 // 0.5s + let maxBackoff: UInt64 = 10_000_000_000 // 10s + while !stopped { + do { + try await run(onChange: onChange) + backoff = 500_000_000 + } catch is CancellationError { + return + } catch { + #if os(iOS) + if await AppActivity.shared.isInBackground { + await AppActivity.shared.untilForeground() + backoff = 500_000_000 + continue + } + #endif + } + guard !stopped else { return } + do { try await Task.sleep(nanoseconds: backoff) } catch { return } + backoff = min(backoff * 2, maxBackoff) + } + } + + func stop() { + stopped = true + connection?.close() + connection = nil + } + + private func run(onChange: @escaping @MainActor ([String]) -> Void) async throws { + let (conn, reader) = try await client.connect() + connection?.close() + connection = conn + try await DaemonClient.write(.subscribeProjects, to: conn) + guard case .projectsSubscribed = DaemonClient.parse(try await reader.readLine()) else { + throw DaemonStatusError.unexpectedResponse + } + while !stopped { + if case .projectsChanged(let projects) = DaemonClient.parse(try await reader.readLine()) { + await onChange(projects) + } + } + } +} diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index a9c0f453..8950a8c3 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -299,7 +299,12 @@ nonisolated enum DaemonRequest: Encodable { case channelEdit(projectRoot: String, messageId: String, text: String) case channelReact(projectRoot: String, messageId: String, active: Bool) case health + /// Adds the folder to the daemon's projects, initializing it if needed. case initProject(projectRoot: String) + /// Forgets a project on the daemon for every device; its files are untouched (protocol 13). + case removeProject(projectRoot: String, suspendAgents: Bool = false) + /// Streams the daemon's registered project roots, now and on every change (protocol 13). + case subscribeProjects case status(projectRoot: String, agentId: String? = nil) case attach(agentId: String, clientId: String? = nil) case input(agentId: String, data: Data) @@ -405,6 +410,12 @@ nonisolated enum DaemonRequest: Encodable { case .initProject(let projectRoot): try container.encode("init", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) + case .removeProject(let projectRoot, let suspendAgents): + try container.encode("remove_project", forKey: .key("type")) + try container.encode(projectRoot, forKey: .key("project_root")) + try container.encode(suspendAgents, forKey: .key("suspend_agents")) + case .subscribeProjects: + try container.encode("subscribe_projects", forKey: .key("type")) case .status(let projectRoot, let agentId): try container.encode("status", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) @@ -666,6 +677,9 @@ nonisolated enum DaemonResponse: Decodable { case channelMessage(ChannelMutation) case healthReport(pid: Int, uptimeSeconds: Int, protocolVersion: Int, agentCount: Int) case initResult(created: Bool) + case projectRemoved(projectRoot: String, removed: Bool, suspended: [String]) + case projectsSubscribed + case projectsChanged(projects: [String]) case statusReport(worktrees: [WorktreeEntry], agents: [AgentStatusReport]) case resync case ptySize(cols: Int, rows: Int, ownerClientId: String?) @@ -730,6 +744,13 @@ nonisolated enum DaemonResponse: Decodable { case "init_result": let p = try InitResultPayload(from: decoder) self = .initResult(created: p.created) + case "project_removed": + let p = try ProjectRemovedPayload(from: decoder) + self = .projectRemoved(projectRoot: p.projectRoot, removed: p.removed, suspended: p.suspended) + case "projects_subscribed": + self = .projectsSubscribed + case "projects_changed": + self = .projectsChanged(projects: try ProjectsChangedPayload(from: decoder).projects) case "status_report": let p = try StatusReportPayload(from: decoder) self = .statusReport(worktrees: p.worktrees, agents: p.agents) @@ -1033,6 +1054,22 @@ private nonisolated struct InitResultPayload: Decodable { let created: Bool } +private nonisolated struct ProjectRemovedPayload: Decodable { + let projectRoot: String + let removed: Bool + let suspended: [String] + + enum CodingKeys: String, CodingKey { + case projectRoot = "project_root" + case removed + case suspended + } +} + +private nonisolated struct ProjectsChangedPayload: Decodable { + let projects: [String] +} + private nonisolated struct HealthReportPayload: Decodable { let pid: Int let uptimeSeconds: Int @@ -1215,7 +1252,7 @@ private nonisolated struct DynamicCodingKey: CodingKey { nonisolated enum DaemonProtocol { /// The protocol this app speaks. A daemon reporting another version is incompatible. - static let version = 12 + static let version = 13 } nonisolated struct InventoryProjectPayload: Decodable, Sendable, Equatable { diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 65916026..671c46a1 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -108,19 +108,38 @@ final class AppState { @ObservationIgnored private let service: any WorkspaceService @ObservationIgnored private let startProject: @MainActor (ProjectState) -> Void + /// Whether `restoreProjects()` follows the local daemon's project list (off in tests). + @ObservationIgnored private let followsLocalDaemon: Bool #if os(macOS) @ObservationIgnored private var binaryWatcher: ManifestWatcher? #endif @ObservationIgnored private var machineRegistryWatcher: MachineRegistryWatcher? - + #if os(macOS) + /// Follows the local daemon's registered projects, which decide this Mac's sidebar. + @ObservationIgnored private var localProjectsSubscription: DaemonProjectsSubscription? + /// The local daemon's projects as last reported, to tell a removal from a project + /// whose `init` has not reached the daemon yet. + @ObservationIgnored private var lastLocalProjects = Set() + #endif + /// Point Guard folders the user also opened as sidebar projects. Other Point Guard + /// folders stay out of the sidebar even though the daemon has them registered. + @ObservationIgnored private var shownPointGuardFolders = Set() + /// Until the local daemon first reports its projects, a saved workspace selection may + /// belong to one that has not opened yet. + @ObservationIgnored private var awaitingLocalProjects = false + + /// The sidebar list from before the daemon owned it; read once, then deleted. private static let openProjectsKey = "PurePointOpenProjects" + private static let shownPointGuardFoldersKey = "PurePointShownPointGuardFolders" init( service: any WorkspaceService = DaemonWorkspaceService(), - startProject: @escaping @MainActor (ProjectState) -> Void = { $0.startWatching() } + startProject: @escaping @MainActor (ProjectState) -> Void = { $0.startWatching() }, + followsLocalDaemon: Bool = true ) { self.service = service self.startProject = startProject + self.followsLocalDaemon = followsLocalDaemon #if os(iOS) installLifecycleHooks() #endif @@ -236,6 +255,7 @@ final class AppState { ?? ProjectState(projectRoot: root, service: service, registry: registry) project.appState = self projects.append(project) + if pointGuardFolders.contains(root) { shownPointGuardFolders.insert(root) } startProject(project) initializeActiveProjectIfNeeded(root: root) @@ -258,10 +278,9 @@ final class AppState { } guard let index = projects.firstIndex(where: { $0.projectRoot == root }) else { return false } let project = projects.remove(at: index) - if root == pointGuardRoot || pointGuardFolders.contains(root) - || registry?.workspacesByProject[root]?.contains(where: \.isPointGuard) == true - { + if isPointGuardFolder(root) { pointGuardFolders.insert(root) + shownPointGuardFolders.remove(root) pointGuardProjects[root] = project } else { project.stopWatching() @@ -274,6 +293,39 @@ final class AppState { return projectState(forRoot: root) == nil } + /// Whether Point Guard uses this local folder, so it must stay registered and followed + /// even when it is not in the sidebar. + func isPointGuardFolder(_ root: String) -> Bool { + root == pointGuardRoot || pointGuardFolders.contains(root) + || registry?.workspacesByProject[root]?.contains(where: \.isPointGuard) == true + } + + /// Remove a project for every device that shows it: its machine's daemon forgets it + /// (`remove_project`), suspending its running agents and keeping its files, and each + /// device's sidebar follows. A local folder Point Guard still uses only leaves this + /// Mac's sidebar, since Point Guard needs it registered. + func removeProject(_ project: ProjectState) async throws { + if project.isRemote { + guard let machine = machines.first(where: { $0.machine.id == project.key.machineId }) else { return } + try await machine.removeProject(root: project.key.root, suspendAgents: true) + if activeProjectRoot == project.projectId { activeProjectRoot = projects.first?.projectRoot } + return + } + let root = project.projectRoot + if !isPointGuardFolder(root) { + let client = DaemonEndpoint.local.makeClient() + switch try await client.send(.removeProject(projectRoot: root, suspendAgents: true)) { + case .projectRemoved: + break + case .error(_, let message): + throw MachineOpenProjectError(message: message) + default: + throw MachineOpenProjectError(message: "The daemon did not remove \(root).") + } + } + closeProject(root) + } + /// Whether a path is too broad to open as a project: `/`, `$HOME`, or an ancestor /// of `$HOME`. Mirrors the daemon's `is_too_broad_project_root` validation so the /// app doesn't attempt to open roots the daemon will reject. @@ -293,12 +345,20 @@ final class AppState { return false } + /// Open the projects this Mac shows. With a root (`--project-root`) only that one; otherwise + /// the local daemon's registered projects, followed from then on, so a project added or + /// removed on any device, or with `pu projects`, is added or removed here too. func restoreProjects(projectRoot: String? = nil) { + // The list saved before the daemon owned it is opened once, which registers each + // project with the daemon, and then deleted. let paths = projectRoot.map { [$0] } ?? UserDefaults.standard.stringArray(forKey: Self.openProjectsKey) ?? [] - // Opening projects persists current state, so snapshot both saved lists first. + if projectRoot == nil { UserDefaults.standard.removeObject(forKey: Self.openProjectsKey) } + // Opening projects persists current state, so snapshot the saved lists first. let savedPointGuardFolders = UserDefaults.standard.stringArray(forKey: "PurePointPointGuardFolders") ?? [] pointGuardFolders.formUnion(savedPointGuardFolders) + shownPointGuardFolders.formUnion( + UserDefaults.standard.stringArray(forKey: Self.shownPointGuardFoldersKey) ?? []) for path in paths { guard projectRoot != nil || FileManager.default.fileExists(atPath: path) else { continue } guard !isTooBroadProjectRoot(path) else { continue } @@ -309,10 +369,58 @@ final class AppState { guard !isTooBroadProjectRoot(root) else { continue } ensurePointGuardProject(root: root) } + #if os(macOS) + if projectRoot == nil, followsLocalDaemon { followLocalProjects() } + #endif // Workspace layouts load lazily in WorkspaceRegistry.reconcile, on the first // manifest read for each project. } + #if os(macOS) + private func followLocalProjects() { + guard localProjectsSubscription == nil else { return } + let subscription = DaemonProjectsSubscription(client: DaemonEndpoint.local.makeClient()) + localProjectsSubscription = subscription + awaitingLocalProjects = true + Task { + // The daemon holds the project list, so it runs even before any project opens. + try? await DaemonLifecycle.ensureDaemon() + await subscription.start { [weak self] roots in self?.applyLocalProjects(roots) } + } + } + #endif + + /// Match the sidebar to the local daemon's projects. A project that left the list was + /// removed (on another device, or with `pu projects remove`) and closes here; one that + /// joined opens, unless it is a Point Guard folder the user has not opened. + func applyLocalProjects(_ roots: [String]) { + #if os(macOS) + let registered = Set(roots) + // The daemon reports canonical roots; a project may have been opened by another path. + let canonical = Dictionary( + projects.map { (Self.canonicalPath($0.projectRoot), $0.projectRoot) }, uniquingKeysWith: { first, _ in first }) + for root in lastLocalProjects.subtracting(registered) { + if let open = canonical[root] { closeProject(open) } + } + let hidden = Set(pointGuardProjects.keys.filter { !shownPointGuardFolders.contains($0) }.map(Self.canonicalPath)) + for root in roots where canonical[root] == nil { + guard FileManager.default.fileExists(atPath: root), !isTooBroadProjectRoot(root) else { continue } + if hidden.contains(root) { continue } + openProject(root) + } + lastLocalProjects = registered + awaitingLocalProjects = false + #endif + } + + /// The path with symlinks resolved, as the daemon's `canonicalize` reports it (unlike + /// `URL.resolvingSymlinksInPath`, which drops `/private`). + nonisolated static func canonicalPath(_ path: String) -> String { + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) + } + // MARK: - Machines /// Read `~/.pu/machines.json` and connect every machine the user has not disconnected. @@ -451,7 +559,8 @@ final class AppState { private func ensurePointGuardProject(root: String) { pointGuardFolders.insert(root) - UserDefaults.standard.set(Array(pointGuardFolders), forKey: "PurePointPointGuardFolders") + if projects.contains(where: { $0.projectRoot == root }) { shownPointGuardFolders.insert(root) } + persistOpenProjects() #if os(macOS) guard projectState(forRoot: root) == nil else { return } let project = ProjectState(projectRoot: root, service: service, registry: registry) @@ -671,7 +780,9 @@ final class AppState { return pendingId } // An unreachable machine may never load its layouts; it must not hold the selection. - if localManagedProjects.allSatisfy({ registry.workspacesByProject[$0.projectId] != nil }) { + if !awaitingLocalProjects, + localManagedProjects.allSatisfy({ registry.workspacesByProject[$0.projectId] != nil }) + { pendingSelectWorkspaceId = nil } return nil @@ -679,9 +790,10 @@ final class AppState { // MARK: - Private + /// The daemon keeps which projects exist; this Mac only keeps which Point Guard folders + /// it shows. private func persistOpenProjects() { - let paths = projects.map(\.projectRoot) - UserDefaults.standard.set(paths, forKey: Self.openProjectsKey) + UserDefaults.standard.set(Array(shownPointGuardFolders), forKey: Self.shownPointGuardFoldersKey) UserDefaults.standard.set(Array(pointGuardFolders), forKey: "PurePointPointGuardFolders") } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift index cab1a33e..47e56987 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift @@ -33,6 +33,27 @@ extension MachineState { } } +extension MachineState { + /// Remove a project from the machine for every device (`remove_project`). Its folder and + /// `.pu/` are kept, so opening it again brings it back as it was. The daemon refuses while + /// agents run unless `suspendAgents`, which suspends them so they resume on the next open. + func removeProject(root: String, suspendAgents: Bool, client: DaemonClient? = nil) async throws { + let client = client ?? endpoint.makeClient() + switch try await client.send(.removeProject(projectRoot: root, suspendAgents: suspendAgents)) { + case .projectRemoved: + break + case .error(_, let message): + throw MachineOpenProjectError(message: message) + default: + throw MachineOpenProjectError(message: "\(machine.name) did not remove \(root).") + } + // Nothing left to hide: a later open shows it again everywhere. + unhideProject(root: root) + if state.isDaemonRunning { await refreshProjects() } + } +} + +/// A project action the machine's daemon refused, with its message. struct MachineOpenProjectError: LocalizedError, Equatable { let message: String var errorDescription: String? { message } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift index ae985186..9a8da71a 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -41,12 +41,15 @@ final class MachineState: Identifiable { @ObservationIgnored private var connection: MachineConnection! @ObservationIgnored private var connectionGeneration = 0 @ObservationIgnored private var refreshTask: Task? + /// Pushes the daemon's project list on every add or remove; the poll is the fallback. + @ObservationIgnored private var projectsSubscription: DaemonProjectsSubscription? @ObservationIgnored private var hiddenRoots: Set /// Closing the forward after pending layout saves were flushed; a reconnect waits for it. @ObservationIgnored private var disconnectTask: Task? @ObservationIgnored private var isUserDisconnecting = false - /// How often the project list is re-read while the daemon is up. + /// How often the project list is re-read while the daemon is up, in case a pushed + /// change was missed. private static let refreshInterval: Duration = .seconds(30) var endpoint: DaemonEndpoint { .machine(machine.id) } @@ -147,8 +150,7 @@ final class MachineState: Identifiable { func disconnect(remember: Bool = false, keepUnsaved: Bool = false) { if remember { Self.setUserDisconnected(machine.id, true) } isUserDisconnecting = true - refreshTask?.cancel() - refreshTask = nil + stopRefreshing() let savers = removeAllProjects(keepingUnsaved: keepUnsaved) state = .disconnected let connection = self.connection! @@ -166,8 +168,7 @@ final class MachineState: Identifiable { /// Quit: write pending layout saves synchronously (bounded), then close the forward. func disconnectForTermination(deadline: DispatchTime) { isUserDisconnecting = true - refreshTask?.cancel() - refreshTask = nil + stopRefreshing() for saver in removeAllProjects(keepingUnsaved: false) { saver.flush(until: deadline) } state = .disconnected let connection = self.connection! @@ -291,8 +292,7 @@ final class MachineState: Identifiable { if newState.isDaemonRunning { if !wasRunning { startRefreshing() } } else { - refreshTask?.cancel() - refreshTask = nil + stopRefreshing() // An incompatible daemon gets no further requests; a stopped one (after several // failed probes) has nothing to stream. Projects come back with the daemon. While // only the SSH link is down (connecting/offline) they stay, as do projects with @@ -305,7 +305,15 @@ final class MachineState: Identifiable { // MARK: - Projects private func startRefreshing() { - refreshTask?.cancel() + stopRefreshing() + let subscription = DaemonProjectsSubscription(client: endpoint.makeClient()) + projectsSubscription = subscription + Task { + await subscription.start { [weak self] roots in + guard let self, self.projectsSubscription === subscription, self.state.isDaemonRunning else { return } + self.setProjects(roots: roots) + } + } refreshTask = Task { [weak self] in while !Task.isCancelled { #if os(iOS) @@ -320,6 +328,14 @@ final class MachineState: Identifiable { } /// Discover the daemon's registered projects (`inventory`, kind projects, all roots). + private func stopRefreshing() { + refreshTask?.cancel() + refreshTask = nil + let subscription = projectsSubscription + projectsSubscription = nil + Task { await subscription?.stop() } + } + func refreshProjects() async { let client = endpoint.makeClient() let response: DaemonResponse @@ -389,7 +405,8 @@ final class MachineState: Identifiable { return project } - /// Remove a project from this app only. Nothing on the machine is deleted or stopped. + /// Hide a project on this device only. Nothing on the machine is deleted or stopped, and + /// other devices still show it; `removeProject` removes it from the machine instead. /// Pending layout saves are written, not dropped. Callers confirm discarding unsaved file /// tabs first. func hideProject(root: String) { diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift index 021bc323..971920d2 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift @@ -19,6 +19,7 @@ enum SidebarMenuAction: Equatable { case deleteWorktree case killAllProjectAgents case retryLayout + case hideProject case removeProject case newAgentInWorktree case newTerminalInWorktree @@ -125,9 +126,27 @@ enum SidebarMenus { if project.isRemote, project.registry?.layoutErrorsByProject[project.projectId] != nil { entries.append(.action("Retry Layout", .retryLayout)) } - entries.append(.action("Remove Project\u{2026}", .removeProject)) + if project.isRemote { + // Hiding is this device's choice; removing changes the machine for every device. + entries.append(.action("Hide on \(deviceTitle)\u{2026}", .hideProject)) + entries.append(.action("Remove from \(machineName(of: project))\u{2026}", .removeProject)) + } else { + entries.append(.action("Remove Project\u{2026}", .removeProject)) + } return entries } + + private static var deviceTitle: String { + #if os(macOS) + "This Mac" + #else + "This iPad" + #endif + } + + static func machineName(of project: ProjectState) -> String { + project.appState?.machines.first { $0.machine.id == project.key.machineId }?.machine.name ?? "Machine" + } } // MARK: - Confirmations @@ -191,22 +210,43 @@ extension SidebarConfirmation { message: "This will kill \(aliveCount) running agent\(aliveCount == 1 ? "" : "s") in this project.") } + static func hideProject(_ project: ProjectState) -> SidebarConfirmation { + SidebarConfirmation( + title: "Hide \(project.projectName)?", + message: "This hides the project in PurePoint on \(deviceName). Nothing on the remote machine is changed." + + unsavedWarning(unsavedFileNames(in: project)), + confirmTitle: "Hide") + } + static func removeProject(_ project: ProjectState) -> SidebarConfirmation { - let unsaved = - project.isRemote - ? project.appState?.machines.first { $0.machine.id == project.key.machineId }?.unsavedFileNames(in: project) - ?? [] - : [] + if !project.isRemote, project.appState?.isPointGuardFolder(project.projectRoot) == true { + return SidebarConfirmation( + title: "Remove \(project.projectName)?", + message: "Point Guard still uses this folder, so it only leaves the sidebar on this Mac.", + confirmTitle: "Remove") + } + let machine = project.isRemote ? SidebarMenus.machineName(of: project) : nil + let aliveCount = project.allAgents.filter { $0.status.isAlive && !$0.suspended }.count + let agents = + aliveCount == 0 + ? "" + : " Its \(aliveCount) running agent" + + (aliveCount == 1 ? " is suspended and resumes" : "s are suspended and resume") + + " when the project is added again." return SidebarConfirmation( - title: "Remove \(project.projectName)?", - message: project.isRemote - ? "This hides the project in PurePoint on \(deviceName). Nothing on the remote machine is changed." - + unsavedWarning(unsaved) - : "This will close the project in PurePoint and delete .pu/manifest.json and .pu/agents/. " - + "The project folder will not be deleted.", + title: machine.map { "Remove \(project.projectName) from \($0)?" } ?? "Remove \(project.projectName)?", + message: "It disappears from PurePoint on \(machine ?? "this Mac") and every device connected to it." + + agents + " The folder, including .pu/, is not changed." + + unsavedWarning(unsavedFileNames(in: project)), confirmTitle: "Remove") } + private static func unsavedFileNames(in project: ProjectState) -> [String] { + guard project.isRemote else { return [] } + return project.appState?.machines.first { $0.machine.id == project.key.machineId }? + .unsavedFileNames(in: project) ?? [] + } + /// The device a hidden project stays hidden on. private static var deviceName: String { #if os(macOS) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift index c786d31f..e633bd43 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift @@ -80,33 +80,37 @@ extension SidebarOutlineView { project.killAllAgents() } - vc.onRemoveProject = { [viewCache] project in - let projectRoot = project.projectRoot + // Hiding is this device's choice: the project and its agents stay on the machine. + vc.onHideProject = { [viewCache] project in + for agent in project.allAgents { + viewCache.remove(agentId: agent.id) + } + appState.closeProject(project.projectId) + } - // A remote project is only hidden here: its files and agents stay on the machine. - if project.isRemote { + // The machine's daemon forgets the project, so it leaves every device's sidebar. Its + // files, including .pu/, stay: adding the folder again brings the project back. + vc.onRemoveProject = { [viewCache] project in + Task { @MainActor in + do { + try await appState.removeProject(project) + } catch { + let message = "Could not remove \(project.projectName): \(error.localizedDescription)" + if let machine = appState.machines.first(where: { $0.machine.id == project.key.machineId }), + project.isRemote + { + machine.actionError = message + } else { + appState.daemonError = message + } + return + } + // Point Guard may still own a local folder; its terminal views stay attached. + guard appState.projectState(forRoot: project.projectId) == nil else { return } for agent in project.allAgents { viewCache.remove(agentId: agent.id) } - appState.closeProject(project.projectId) - return } - - // Point Guard can still own this folder after it leaves the project sidebar. - // Preserve both daemon files and attached terminal views in that case. - guard appState.closeProject(projectRoot) else { return } - - for agent in project.allAgents { - viewCache.remove(agentId: agent.id) - } - - // Clean up .pu directory: delete manifest.json and agents/ - let puDir = URL(fileURLWithPath: projectRoot).appendingPathComponent(".pu") - let manifestURL = puDir.appendingPathComponent("manifest.json") - let agentsDirURL = puDir.appendingPathComponent("agents") - - try? FileManager.default.removeItem(at: manifestURL) - try? FileManager.default.removeItem(at: agentsDirURL) } vc.onRemoveMachine = { [viewCache] machine in diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift index 54321ba5..eaab01c5 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift @@ -41,6 +41,10 @@ extension SidebarOutlineViewController { } case (.retryLayout, .project(let project)): project.retryRemoteLayout() + case (.hideProject, .project(let project)): + showConfirmation(.hideProject(project)) { + self.onHideProject?(project) + } case (.removeProject, .project(let project)): showConfirmation(.removeProject(project)) { self.onRemoveProject?(project) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift index c5d07e4e..129bf614 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift @@ -39,9 +39,12 @@ class SidebarOutlineViewController: UIViewController { /// Callback for killing all agents in a project. var onKillAllProjectAgents: ((ProjectState) -> Void)? - /// Callback for removing a project from the sidebar. + /// Callback for removing a project from its machine, for every device. var onRemoveProject: ((ProjectState) -> Void)? + /// Callback for hiding a remote project on this device only. + var onHideProject: ((ProjectState) -> Void)? + /// Callback for forgetting a machine (its forward closes; nothing on it changes). var onRemoveMachine: ((MachineState) -> Void)? diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift index 7d12abab..b16c79d2 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift @@ -36,9 +36,12 @@ class SidebarOutlineViewController: NSViewController, NSOutlineViewDataSource, N /// Callback for killing all agents in a project. var onKillAllProjectAgents: ((ProjectState) -> Void)? - /// Callback for removing a project from the sidebar. + /// Callback for removing a project from its machine, for every device. var onRemoveProject: ((ProjectState) -> Void)? + /// Callback for hiding a remote project on this device only. + var onHideProject: ((ProjectState) -> Void)? + /// Callback for forgetting a machine (its forward closes; nothing on it changes). var onRemoveMachine: ((MachineState) -> Void)? diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index 6eb313c4..c441f905 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -315,7 +315,32 @@ struct HealthProbeTests { struct DaemonProjectHostRequestTests { @Test func givenProtocolShouldBeCurrentVersion() { - #expect(DaemonProtocol.version == 12) + #expect(DaemonProtocol.version == 13) + } + + @Test func givenRemoveProjectShouldNameTheRootAndWhetherToSuspendAgents() throws { + let object = try json(.removeProject(projectRoot: "/srv/app", suspendAgents: true)) + #expect(object["type"] as? String == "remove_project") + #expect(object["project_root"] as? String == "/srv/app") + #expect(object["suspend_agents"] as? Bool == true) + #expect(try json(.subscribeProjects)["type"] as? String == "subscribe_projects") + } + + @Test func givenProjectRegistryRepliesShouldDecodeRootsAndRemoval() { + guard case .projectsChanged(let projects) = decode(#"{"type":"projects_changed","projects":["/a","/b"]}"#) + else { + Issue.record("projects_changed did not decode") + return + } + #expect(projects == ["/a", "/b"]) + guard + case .projectRemoved(let root, let removed, let suspended) = decode( + #"{"type":"project_removed","project_root":"/a","removed":true,"suspended":["ag-1"]}"#) + else { + Issue.record("project_removed did not decode") + return + } + #expect(root == "/a" && removed && suspended == ["ag-1"]) } @Test func givenInventoryShouldAskForEveryRegisteredProject() throws { diff --git a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift index 6dc59959..be022215 100644 --- a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift @@ -13,10 +13,10 @@ private struct PointGuardRoutingService: WorkspaceService { private func withState(_ body: (AppState, WorkspaceRegistry, String) throws -> Void) rethrows { let root = NSTemporaryDirectory() + "point-guard-routing-" + UUID().uuidString let defaults = UserDefaults.standard - let keys = ["PurePointPointGuardFolders", "PurePointOpenProjects"] + let keys = ["PurePointPointGuardFolders", "PurePointOpenProjects", "PurePointShownPointGuardFolders"] let saved = keys.map { defaults.object(forKey: $0) } let registry = WorkspaceRegistry() - let state = AppState(service: PointGuardRoutingService(), startProject: { _ in }) + let state = AppState(service: PointGuardRoutingService(), startProject: { _ in }, followsLocalDaemon: false) state.registry = registry state.updatePointGuardRoot(root) defer { @@ -225,4 +225,59 @@ private struct PointGuardRoutingService: WorkspaceService { #expect(state.projectState(forRoot: root) === owner) } } + + // MARK: - The local daemon's projects decide the sidebar + + @Test func registeredProjectsOpenAndOnesRemovedElsewhereClose() throws { + try withState { state, _, root in + for path in [root + "-project", root + "-next"] { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + } + // The daemon reports canonical roots (`/private/var/...` for a temporary folder). + let project = AppState.canonicalPath(root + "-project"), next = AppState.canonicalPath(root + "-next") + state.applyLocalProjects([next, project]) + #expect(Set(state.projects.map(\.projectRoot)) == [project, next]) + // Removed on another device, or with `pu projects remove`. + state.applyLocalProjects([project]) + #expect(state.projects.map(\.projectRoot) == [project]) + } + } + + @Test func projectOpenedBeforeTheDaemonListsItStaysOpen() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root + "-project", withIntermediateDirectories: true) + // Its `init` has not reached the daemon yet: absent from a list it was never in. + state.openProject(root + "-project") + state.applyLocalProjects([]) + #expect(state.projects.map(\.projectRoot) == [root + "-project"]) + // Opened by another path, it is the same project once the daemon lists it. + state.applyLocalProjects([AppState.canonicalPath(root + "-project")]) + #expect(state.projects.map(\.projectRoot) == [root + "-project"]) + } + } + + @Test func pointGuardFolderStaysOutOfTheSidebarUnlessOpened() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + UserDefaults.standard.removeObject(forKey: "PurePointOpenProjects") + UserDefaults.standard.set([root], forKey: "PurePointPointGuardFolders") + UserDefaults.standard.removeObject(forKey: "PurePointShownPointGuardFolders") + state.restoreProjects() + state.applyLocalProjects([AppState.canonicalPath(root)]) + #expect(state.projects.isEmpty) + #expect(state.projectState(forRoot: root) != nil) + state.openProject(root) + #expect(UserDefaults.standard.stringArray(forKey: "PurePointShownPointGuardFolders") == [root]) + } + } + + @Test func legacySidebarListIsImportedOnceThenDeleted() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root + "-project", withIntermediateDirectories: true) + UserDefaults.standard.set([root + "-project"], forKey: "PurePointOpenProjects") + state.restoreProjects() + #expect(state.projects.map(\.projectRoot) == [root + "-project"]) + #expect(UserDefaults.standard.stringArray(forKey: "PurePointOpenProjects") == nil) + } + } } diff --git a/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift b/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift index 034e365a..55825fb8 100644 --- a/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift @@ -302,4 +302,35 @@ struct MachineOpenProjectTests { } } +@MainActor +struct MachineRemoveProjectTests { + private let machine = Machine( + id: UUID(uuidString: "6F1C2B3A-1111-4222-8333-944455556667")!, name: "studio", sshHost: "me@studio") + + @Test func givenAProjectShouldAskTheMachineDaemonToForgetItAndSuspendItsAgents() async throws { + let state = MachineState(machine: machine, appState: nil, registry: nil) + state.hideProject(root: "/home/me/code/app") + let daemon = CannedDaemon(replies: [ + #"{"type":"project_removed","project_root":"/home/me/code/app","removed":true,"suspended":[]}"# + ]) + try await state.removeProject( + root: "/home/me/code/app", suspendAgents: true, client: DaemonClient(transport: daemon)) + #expect(daemon.requests.first?["type"] as? String == "remove_project") + #expect(daemon.requests.first?["project_root"] as? String == "/home/me/code/app") + #expect(daemon.requests.first?["suspend_agents"] as? Bool == true) + // Removed from the machine, it no longer needs hiding on this device. + #expect(state.hiddenProjectCount == 0) + } + + @Test func givenTheDaemonRefusesShouldThrowItsMessage() async { + let state = MachineState(machine: machine, appState: nil, registry: nil) + let daemon = CannedDaemon(replies: [ + #"{"type":"error","code":"PROJECT_HAS_LIVE_AGENTS","message":"/srv/app has 1 live agent"}"# + ]) + await #expect(throws: MachineOpenProjectError(message: "/srv/app has 1 live agent")) { + try await state.removeProject(root: "/srv/app", suspendAgents: false, client: DaemonClient(transport: daemon)) + } + } +} + #endif diff --git a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift index ffb2e6cb..c6da9fa6 100644 --- a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift @@ -35,6 +35,14 @@ private func makeProject(root: String, worktreeCount: Int, status: AgentStatus = return project } +/// A machine's project offers hiding on this device and removing from the machine. +#if os(macOS) +private let hideTitle = "Hide on This Mac\u{2026}" +#else +private let hideTitle = "Hide on This iPad\u{2026}" +#endif +private let remoteProjectActions = [hideTitle, "Remove from Machine\u{2026}"] + @Suite(.serialized) @MainActor struct SidebarMenuModelTests { @@ -57,7 +65,7 @@ struct SidebarMenuModelTests { let nodes = SidebarTree.build( projects: [project], machines: [machine], workspacesByProject: [project.projectId: [single]]) - #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == ["Kill All Agents (1)", "-", "Remove Project\u{2026}"]) + #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == ["Kill All Agents (1)", "-"] + remoteProjectActions) #expect( titles(SidebarMenus.contextMenu(for: nodes[0].children[0])) == ["Kill All Agents (1)", "-", "Delete Worktree\u{2026}"]) @@ -74,7 +82,7 @@ struct SidebarMenuModelTests { @Test func givenDeadAgentsShouldOfferOnlyTheDestructiveRowActions() { let project = makeProject(root: "/tmp/dead", worktreeCount: 1, status: .broken) let nodes = SidebarTree.build(projects: [project], machines: [], workspacesByProject: [:]) - #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == ["Remove Project\u{2026}"]) + #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == remoteProjectActions) #expect(titles(SidebarMenus.contextMenu(for: nodes[0].children[0])) == ["Delete Worktree\u{2026}"]) } @@ -97,9 +105,18 @@ struct SidebarMenuModelTests { confirmTitle: "Delete")) #expect(SidebarConfirmation.killWorktreeAgents(worktree).confirmTitle == "Kill All") #expect(SidebarConfirmation.closeWorkspace(agentCount: 3).message == "This will kill 3 agents across its panes.") - // A machine's project is only hidden; nothing on the machine changes. + // Hiding changes nothing on the machine. #expect( - SidebarConfirmation.removeProject(project).message.hasSuffix( + SidebarConfirmation.hideProject(project).message.hasSuffix( "Nothing on the remote machine is changed.")) + // Removing does, for every device, but keeps the files and suspends running agents. + #expect( + SidebarConfirmation.removeProject(project) + == SidebarConfirmation( + title: "Remove confirm from Machine?", + message: "It disappears from PurePoint on Machine and every device connected to it." + + " Its 1 running agent is suspended and resumes when the project is added again." + + " The folder, including .pu/, is not changed.", + confirmTitle: "Remove")) } } diff --git a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift index f31f2b3d..d9e38d02 100644 --- a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift +++ b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift @@ -62,12 +62,22 @@ enum FileIOService { func disconnectForTermination(deadline: DispatchTime) {} func update(from machine: Machine) {} func hideProject(root: String) {} + func removeProject(root: String, suspendAgents: Bool) async throws {} static func isUserDisconnected(_ id: UUID) -> Bool { true } } enum DaemonLifecycle { static func findBinary() -> String? { nil } static func didLaunchDaemon() async -> Bool { false } static func restartDaemon() async throws {} + static func ensureDaemon() async throws {} +} +struct MachineOpenProjectError: LocalizedError { + let message: String +} +actor DaemonProjectsSubscription { + init(client: DaemonClient) {} + func start(onChange: @escaping @MainActor ([String]) -> Void) async {} + func stop() {} } @MainActor final class ManifestWatcher { init(path: String, onChange: @escaping () -> Void) {} @@ -82,6 +92,7 @@ enum DaemonLifecycle { @MainActor final class ProjectState { let projectRoot: String var projectId: String { projectRoot } + var key: ProjectKey { ProjectKey(id: projectRoot) } var isRemote: Bool { false } let host: any ProjectHost = LocalProjectHost.shared let client = DaemonClient() diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index 3d453ac9..6b727658 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -438,6 +438,7 @@ struct MobileProjectsView: View { @AccessibilityFocusState private var focusedAgent: String? @State private var launch: WorkspaceLaunchTarget? @State private var stop: AgentShortcut? + @State private var confirmRemove = false private var machine: AgentMachine? { model.machines.first { $0.id == machineID } } private var project: WorkspaceProject? { machine?.registeredProjects?.first { $0.id == projectID } } var body: some View { @@ -459,6 +460,17 @@ struct MobileProjectsView: View { self.stop = nil } } message: { Text(stop.map { "Stop \($0.agent.name) on \($0.machine.name), \($0.agent.projectRoot ?? "unknown project")? Other agents keep running." } ?? "") } + .confirmationDialog( + "Remove \(project?.name ?? "project") from \(machine?.name ?? "the machine")?", isPresented: $confirmRemove, + titleVisibility: .visible + ) { + Button("Remove from \(machine?.name ?? "machine")", role: .destructive) { + guard let machine, let project else { return } + launch = WorkspaceLaunchTarget(host: model.bridgeIdentity, machine: machine.id, machineName: machine.name, project: project.id, projectName: project.name, worktree: nil, kind: "removeProject") + } + } message: { + Text("It disappears from PurePoint on every device connected to \(machine?.name ?? "the machine"). Running agents are suspended and resume when the project is added again. Files, including .pu/, are not changed.") + } .task { await model.refresh() } .onChange(of: model.bridgeIdentity) { _, _ in projectID = nil; machineID = nil; highlighted = nil; launch = nil; stop = nil } } @@ -496,6 +508,14 @@ struct MobileProjectsView: View { } .disabled(!model.canManage || !machine.online || !project.available) .accessibilityLabel("New agent, worktree or terminal") + Menu { + Button("Remove from \(machine.name)\u{2026}", systemImage: "trash", role: .destructive) { confirmRemove = true } + // Older daemons cannot forget a project for every device. + .disabled(!model.canManage || !machine.online || (machine.protocolVersion ?? 0) < 13) + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Project actions") } else { Button { Task { await model.refresh() } } label: { if model.loading { ProgressView() } else { Image(systemName: "arrow.clockwise") } @@ -797,7 +817,7 @@ struct WorkspaceLaunchForm: View { // Mirrors the bridge: blank instructions add nothing to the preset. private func expandedPrompt(_ preset: WorkspaceCatalog.Preset) -> String { hasInstructions ? preset.prompt + "\n\n" + draft.prompt : preset.prompt } private var valid: Bool { - guard model.canManage, model.bridgeIdentity == target.host, model.machines.contains(where: { $0.id == target.machine && $0.online && $0.metadataStatus != "error" && $0.registeredProjects?.contains(where: { $0.id == target.project && $0.available }) == true }), draft.operationId == nil, !submitting else { return false } + guard model.canManage, model.bridgeIdentity == target.host, model.machines.contains(where: { $0.id == target.machine && $0.online && $0.metadataStatus != "error" && $0.registeredProjects?.contains(where: { $0.id == target.project && ($0.available || target.kind == "removeProject") }) == true }), draft.operationId == nil, !submitting else { return false } if target.kind == "rename" || target.kind == "createWorktree" || (target.kind == "spawn" && draft.destination == "newWorktree") { guard !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, draft.name.utf8.count <= 80, !draft.name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return false } } @@ -882,15 +902,15 @@ struct WorkspaceLaunchForm: View { Text("No operation is replayed after reconnect. Unknown status is not proof that launch failed.").font(.caption) } else { Text("\(target.machineName) / \(target.projectName) / \(draft.destination == "root" ? "project root" : draft.destination == "newWorktree" ? draft.name : draft.worktree)").font(.caption).foregroundStyle(.secondary) - Button(role: target.kind == "stop" ? .destructive : nil) { Task { await submit() } } label: { + Button(role: destructive ? .destructive : nil) { Task { await submit() } } label: { Text(actionTitle).font(.body.weight(.semibold)).frame(maxWidth: .infinity, minHeight: 36) // The accent is near-white in dark mode, so the default white label would vanish. - .foregroundStyle(target.kind == "stop" ? Color.white : Color(uiColor: .systemBackground)) - }.buttonStyle(.borderedProminent).tint(target.kind == "stop" ? Color.red : Color.accentColor).disabled(!valid) + .foregroundStyle(destructive ? Color.white : Color(uiColor: .systemBackground)) + }.buttonStyle(.borderedProminent).tint(destructive ? Color.red : Color.accentColor).disabled(!valid) } } } - .navigationTitle(kind != nil ? "New in \(target.projectName)" : target.kind == "rename" ? "Rename agent" : target.kind == "stop" ? "Stop agent" : "New \(target.kind == "createWorktree" ? "worktree" : target.kind == "spawn" ? "agent" : "terminal")") + .navigationTitle(kind != nil ? "New in \(target.projectName)" : target.kind == "rename" ? "Rename agent" : target.kind == "stop" ? "Stop agent" : target.kind == "removeProject" ? "Remove project" : "New \(target.kind == "createWorktree" ? "worktree" : target.kind == "spawn" ? "agent" : "terminal")") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Close") { save(); dismiss() }.keyboardShortcut(.cancelAction) } } .task { if let data = UserDefaults.standard.data(forKey: key), let saved = try? JSONDecoder().decode(WorkspaceFormDraft.self, from: data) { draft = saved } @@ -907,6 +927,7 @@ struct WorkspaceLaunchForm: View { .onDisappear { save() } } } + private var destructive: Bool { target.kind == "stop" || target.kind == "removeProject" } private var actionTitle: String { switch target.kind { case "spawn": return "Start agent" @@ -914,6 +935,7 @@ struct WorkspaceLaunchForm: View { case "terminal": return "Open terminal" case "rename": return "Rename agent" case "stop": return "Confirm stop exact agent" + case "removeProject": return "Remove from \(target.machineName)" default: return "Confirm \(target.kind)" } } @@ -941,7 +963,7 @@ struct WorkspaceLaunchForm: View { if target.kind == "rename" || target.kind == "createWorktree" || (target.kind == "spawn" && draft.destination == "newWorktree") { fields["name"] = draft.name } if target.kind == "createWorktree" || (target.kind == "spawn" && draft.destination == "newWorktree"), !draft.base.isEmpty { fields["base"] = draft.base } if let agent = target.agent { fields["agentId"] = agent } - if target.kind == "stop" { fields["confirm"] = true } + if target.kind == "stop" || target.kind == "removeProject" { fields["confirm"] = true } do { let result = try await model.workspaceRequest("workspace." + target.kind, fields: fields) draft.state = result.state; draft.message = result.message diff --git a/apps/purepoint-mobile/PurePoint/ChatDomain.swift b/apps/purepoint-mobile/PurePoint/ChatDomain.swift index 1395b88c..18ebeffc 100644 --- a/apps/purepoint-mobile/PurePoint/ChatDomain.swift +++ b/apps/purepoint-mobile/PurePoint/ChatDomain.swift @@ -427,6 +427,7 @@ enum MobileRPCTiming { case "workspace.createWorktree": return 100_000_000_000 case "workspace.rename": return 100_000_000_000 case "workspace.stop": return 100_000_000_000 + case "workspace.removeProject": return 100_000_000_000 default: return 35_000_000_000 } } diff --git a/apps/purepoint-mobile/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index 4c4eb6b0..b568a52d 100644 --- a/apps/purepoint-mobile/bridge/workspace.js +++ b/apps/purepoint-mobile/bridge/workspace.js @@ -5,6 +5,8 @@ export const workspaceFeatures = Object.freeze({ inventory: true, management: true, operationStatus: true, + // Daemons at protocol 13 or later can forget a project for every device. + projectRemoval: true, }); export const workspaceOperations = [ "workspace.catalog", @@ -14,6 +16,7 @@ export const workspaceOperations = [ "workspace.terminal", "workspace.rename", "workspace.stop", + "workspace.removeProject", ]; const fail = (code, message) => { throw new Error(`workspace_${code}: ${message}`); @@ -114,6 +117,7 @@ const fields = { "workspace.terminal": ["target", "worktreeId"], "workspace.rename": ["agentId", "name"], "workspace.stop": ["agentId", "confirm"], + "workspace.removeProject": ["confirm"], }; const fingerprint = (request) => JSON.stringify( @@ -162,7 +166,11 @@ export function workspaceConnection({ if (metadata.metadataStatus !== "complete") fail("unsupported", "Complete authoritative project metadata required"); const project = metadata.projects.find((p) => p.id === r.projectId); - if (!project || !project.available) + // An unavailable project can still be removed; nothing else runs in one. + if ( + !project || + (!project.available && r.op !== "workspace.removeProject") + ) fail("target", "Registered available project required"); return { machine, metadata, project }; }; @@ -341,6 +349,21 @@ export function workspaceConnection({ exclude: [], }; } + } else if (r.op === "workspace.removeProject") { + if (r.confirm !== true) + fail("invalid", "Explicit removal confirmation required"); + const { version } = await daemons.inspect(machine, { + signal: abort.signal, + }); + check(); + if (version < 13) + fail("unsupported", "Update the machine's daemon to remove projects"); + // Running agents are suspended, not killed: they resume if the project is added again. + ipc = { + type: "remove_project", + project_root: project.id, + suspend_agents: true, + }; } else if (r.op === "workspace.createWorktree") { if (!bounded(r.name, 80)) fail("invalid", "Worktree name required"); ipc = { @@ -442,6 +465,7 @@ export function workspaceConnection({ "workspace.createWorktree": "create_worktree_result", "workspace.rename": "rename_result", "workspace.stop": "kill_result", + "workspace.removeProject": "project_removed", }[r.op]; if (reply.type !== expected) throw new Error("Unexpected daemon receipt"); entry.result = { diff --git a/apps/purepoint-mobile/bridge/workspace.test.js b/apps/purepoint-mobile/bridge/workspace.test.js index 2687d6e3..55dd89d2 100644 --- a/apps/purepoint-mobile/bridge/workspace.test.js +++ b/apps/purepoint-mobile/bridge/workspace.test.js @@ -25,6 +25,7 @@ async function fixture(t) { legacy = false, drop = false, holdMetadata = false, + protocolVersion = 10, catalogKeys = ["custom"], presetType = "custom"; const server = net.createServer((socket) => { @@ -53,7 +54,7 @@ async function fixture(t) { requests.push(r); events.emit(r.type, r); if (r.type === "health") - send({ type: "health_report", protocol_version: 10 }); + send({ type: "health_report", protocol_version: protocolVersion }); else if (r.type === "inventory") { if (holdMetadata) continue; if (partial && r.kind === "worktrees") @@ -141,6 +142,13 @@ async function fixture(t) { send({ type: "kill_result", killed: [r.target.agent] }); else if (r.type === "create_worktree") send({ type: "create_worktree_result", worktree_id: "wt-created" }); + else if (r.type === "remove_project") + send({ + type: "project_removed", + project_root: r.project_root, + removed: true, + suspended: [], + }); } }); }); @@ -179,6 +187,9 @@ async function fixture(t) { holdMetadata(v) { holdMetadata = v; }, + protocolVersion(v) { + protocolVersion = v; + }, partial(v) { partial = v; }, @@ -912,3 +923,30 @@ test("terminal, worktree, rename and stop retain their 65-second bridge caps and 4, ); }); + +test("project removal needs confirmation and a daemon that can forget projects for every device", async (t) => { + const f = await fixture(t), + c = f.connect(); + t.after(c.dispose); + const remove = (operationId, extra = {}) => + c.request({ + op: "workspace.removeProject", + operationId, + machine: "local", + projectId: "/registered", + ...extra, + }); + assert.equal((await remove("remove-0001")).state, "failed"); + const old = await remove("remove-0002", { confirm: true }); + assert.equal(old.state, "failed"); + assert.match(old.message, /Update the machine's daemon/); + assert.equal(f.requests.filter((r) => r.type === "remove_project").length, 0); + + f.protocolVersion(13); + const removed = await remove("remove-0003", { confirm: true }); + assert.equal(removed.state, "succeeded"); + const sent = f.requests.filter((r) => r.type === "remove_project"); + assert.deepEqual(sent, [ + { type: "remove_project", project_root: "/registered", suspend_agents: true }, + ]); +}); diff --git a/apps/purepoint-mobile/docs/contract.md b/apps/purepoint-mobile/docs/contract.md index 9c46e3b0..e36605c5 100644 --- a/apps/purepoint-mobile/docs/contract.md +++ b/apps/purepoint-mobile/docs/contract.md @@ -196,7 +196,7 @@ Events are `{type:terminal.output,streamId,data}` (UTF-8 ANSI), `{type:terminal. Additive to `apps/purepoint-mobile/docs/contract.md`. All requests use existing authenticated WSS envelope `{version:1,id,clientId,op,...}` and receipt `{type:"receipt",id,ok:true,data}` or `{type:"receipt",id,ok:false,error:}`. Request id remains transport correlation; `operationId` below is durable-form identity. No Pi readiness dependency. -Permissions discovery (`permissions` result and `permissions` event): `{terminals:"off"|"view"|"control",workspaceManagement:false,chat:"off"|"read"|"send" (derived),workspace:{version:1,inventory:true,management:true,operationStatus:true}}`. Missing workspace means unsupported; missing grant means false. All mutations require terminals control AND workspaceManagement true; all fleet reads require terminals view/control. Local owner admin `devices.workspaceManagement {deviceId,workspaceManagement:}`. Existing grants default false. Permission loss fences dispatch and late responses; cancellation cannot roll back committed work. +Permissions discovery (`permissions` result and `permissions` event): `{terminals:"off"|"view"|"control",workspaceManagement:false,chat:"off"|"read"|"send" (derived),workspace:{version:1,inventory:true,management:true,operationStatus:true,projectRemoval:true}}`. Missing workspace means unsupported; missing grant means false. All mutations require terminals control AND workspaceManagement true; all fleet reads require terminals view/control. Local owner admin `devices.workspaceManagement {deviceId,workspaceManagement:}`. Existing grants default false. Permission loss fences dispatch and late responses; cancellation cannot roll back committed work. `agents` retains machines and agent fields, adds each machine `metadataStatus:"complete"|"partial"|"offline"|"error"`, `metadataError:string|null`, `projects:[{id:,name,available,error,counts:}]`, `worktrees:[{projectId,id,name,path,branch,status,agentIds:[string]}]`. Agent projectRoot is project ID; worktreeId null means root only when project metadata confirms association. Machine id scopes every project/worktree/agent. Off returns no metadata (request denied). Projects and worktrees queried separately; unsupported metadata produces partial with usable agents. No projects inferred from agents. Machine offline differs from incomplete metadata. Queries bounded to 32 machines, 4096 entries per kind, 5s each, four machines concurrently. @@ -209,10 +209,11 @@ Management fields are strict allowlists. Common `{operationId:<8..128 ASCII alnu - `workspace.terminal`: common + `{target:"root"|"worktree",worktreeId?:string}`. Uses scoped Spawn terminal without command, ensuring manifest association. Result detail `{agentId,worktreeId:string|null}`. - `workspace.rename`: common + `{agentId,name}`. Membership must match exact project. Result detail `{agentId}`. - `workspace.stop`: common + `{agentId,confirm:true}`. Only Kill Agent target. Result detail `{agentId}`. +- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; running agents are suspended and the folder, including `.pu/`, is kept. Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later (`workspace_unsupported` otherwise; the phone disables the action below 13). Result detail `{}`. Mutation result `{operationId,state:"succeeded"|"failed"|"uncertain",detail?:,message?:string}`. `workspace.operation {operationId}` is read-authorized and returns `{operationId,state:"pending"|"succeeded"|"failed"|"uncertain"|"unknown",detail?,message?}`. No mutation events beyond existing permissions; clients explicitly refresh status and agents. Errors before dispatch are failed; errors after dispatch conservatively uncertain (even daemon errors can follow a committed spawn). Duplicate pending ID rejected; identical completed ID returns ledger status without dispatch; different payload same ID rejected. Ledger retains max 2048 operations for bridge process lifetime, does not evict pending; full ledger rejects new mutations. Restart loses ledger: unknown NEVER means safe to retry. Inventory refresh may locate committed work but absent inventory is not proof of non-commit. User must review authoritative inventory before explicitly initiating any new operation. No automatic replay/retry on reconnect or timeout. A committed-but-unacknowledged operation stays uncertain; no rollback claimed. -Errors use existing receipt error strings with stable prefixes `workspace_permission`, `workspace_invalid`, `workspace_target`, `workspace_unsupported`, `workspace_duplicate`, `workspace_capacity`. Status requires current read grant and only reveals caller's operations. Downgrade suppresses detail; revoke closes connection. Legacy clients ignore additive fields. Names reject control characters; machine/agent/worktree IDs max128. No bulk target, removal, arbitrary path, shell command, config or SSH APIs. +Errors use existing receipt error strings with stable prefixes `workspace_permission`, `workspace_invalid`, `workspace_target`, `workspace_unsupported`, `workspace_duplicate`, `workspace_capacity`. Status requires current read grant and only reveals caller's operations. Downgrade suppresses detail; revoke closes connection. Legacy clients ignore additive fields. Names reject control characters; machine/agent/worktree IDs max128. No bulk target, arbitrary path, shell command, config or SSH APIs; the only removal is `workspace.removeProject` of one exact registered project, which deletes no files. Clarifications (reviewed schema): name/base limits are UTF-8 bytes; preset-expanded final prompt must fit 65536 UTF-8 bytes. After management loss, while terminal view/control remains, status returns only operationId/state and a fixed permission-loss message, no detail or original error. Off denies status. Pending duplicate gives workspace_duplicate; query status with same operationId and a fresh transport id. Ledger never evicts entries in-process; restart clears it. Re-pair creates a new device scope, whose old operation IDs are unknown. Unknown after restart/re-pair must NEVER resend even the original mutation ID; inventory review precedes any explicit new launch. Optional daemon ConfigReport agents[].id/available plus terminal_available are capability detection; protocol version alone cannot establish catalog support. Availability checks the daemon launch executable with login PATH and registered project-root cwd without executing it; wrappers/arguments/runtime behavior may still fail at launch. diff --git a/apps/purepoint-mobile/verification/check-rpc-budgets.py b/apps/purepoint-mobile/verification/check-rpc-budgets.py index 5c4a95e3..020ab46c 100644 --- a/apps/purepoint-mobile/verification/check-rpc-budgets.py +++ b/apps/purepoint-mobile/verification/check-rpc-budgets.py @@ -46,6 +46,7 @@ "workspace.createWorktree": (metadata_ms + short_dispatch_ms, 100_000), "workspace.rename": (metadata_ms + inspect_ms + short_dispatch_ms, 100_000), "workspace.stop": (metadata_ms + inspect_ms + short_dispatch_ms, 100_000), + "workspace.removeProject": (metadata_ms + inspect_ms + short_dispatch_ms, 100_000), } for operation, (bridge_ms, expected_phone_ms) in matrix.items(): assert phone_ms.get(operation, 35_000) == expected_phone_ms, (operation, "Exact receipt deadline mismatch") diff --git a/crates/pu-cli/src/commands/init.rs b/crates/pu-cli/src/commands/init.rs index 1340c75d..e2313d6e 100644 --- a/crates/pu-cli/src/commands/init.rs +++ b/crates/pu-cli/src/commands/init.rs @@ -4,8 +4,11 @@ use crate::error::CliError; use crate::skill; pub async fn run(socket: &std::path::Path, json: bool) -> Result<(), CliError> { + run_at(socket, crate::commands::project_root_string()?, json).await +} + +pub async fn run_at(socket: &std::path::Path, root: String, json: bool) -> Result<(), CliError> { crate::daemon_ctrl::ensure_daemon(socket).await?; - let root = crate::commands::project_root_string()?; let response = crate::client::send_request( socket, &pu_core::protocol::Request::Init { diff --git a/crates/pu-cli/src/commands/mod.rs b/crates/pu-cli/src/commands/mod.rs index 44768423..733c3c75 100644 --- a/crates/pu-cli/src/commands/mod.rs +++ b/crates/pu-cli/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod kill; pub mod logs; pub mod machine; pub mod phone; +pub mod projects; pub mod prompt; pub mod pulse; pub mod read; diff --git a/crates/pu-cli/src/commands/projects.rs b/crates/pu-cli/src/commands/projects.rs new file mode 100644 index 00000000..2561ea3e --- /dev/null +++ b/crates/pu-cli/src/commands/projects.rs @@ -0,0 +1,62 @@ +use std::path::Path; + +use pu_core::protocol::{InventoryKind, Request}; + +use crate::error::CliError; +use crate::{ProjectsAction, client, daemon_ctrl, output}; + +pub async fn run(socket: &Path, action: ProjectsAction) -> Result<(), CliError> { + match action { + ProjectsAction::List { json, .. } => { + crate::commands::inventory::run(socket, InventoryKind::Projects, None, json).await + } + ProjectsAction::Add { path, json } => { + let root = resolve(path)?; + if crate::machine_target::remote_machine().is_none() { + // Locally, adding is `pu init`: it also writes the agent context file. + return crate::commands::init::run_at(socket, root, json).await; + } + send(socket, Request::Init { project_root: root }, json).await + } + ProjectsAction::Remove { + path, + suspend_agents, + json, + } => { + let project_root = resolve(path)?; + send( + socket, + Request::RemoveProject { + project_root, + suspend_agents, + }, + json, + ) + .await + } + } +} + +async fn send(socket: &Path, request: Request, json: bool) -> Result<(), CliError> { + daemon_ctrl::ensure_daemon(socket).await?; + let response = client::send_request(socket, &request).await?; + let response = output::check_response(response, json)?; + output::print_response(&response, json) +} + +/// A path argument wins over --project and the current directory. On another +/// machine it must be absolute: a local relative path means nothing there. +fn resolve(path: Option) -> Result { + let Some(path) = path else { + return crate::commands::project_root_string(); + }; + if crate::machine_target::remote_machine().is_some() { + if !Path::new(&path).is_absolute() { + return Err(CliError::Other(format!( + "{path} must be an absolute path on the machine" + ))); + } + return Ok(path); + } + Ok(std::path::absolute(&path)?.to_string_lossy().into_owned()) +} diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index 2e07e9dc..ff816d5e 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -104,10 +104,10 @@ enum Commands { #[arg(long)] json: bool, }, - /// List projects known to the daemon + /// List, add or remove the projects the daemon serves Projects { #[command(subcommand)] - action: InventoryAction, + action: ProjectsAction, }, /// List agent instances (saved definitions use `pu agent`) Agents { @@ -598,6 +598,34 @@ enum InventoryAction { }, } +#[derive(Subcommand)] +enum ProjectsAction { + /// List all registered projects by default; --project narrows the scope + List { + #[arg(long, conflicts_with = "project")] + global: bool, + #[arg(long)] + json: bool, + }, + /// Add a folder as a project, initializing it if needed (same as `pu init`) + Add { + /// Project folder (default: --project, then the current directory) + path: Option, + #[arg(long)] + json: bool, + }, + /// Remove a project from this daemon on every device; its files are kept + Remove { + /// Project folder (default: --project, then the current directory) + path: Option, + /// Suspend the project's running agents instead of refusing + #[arg(long)] + suspend_agents: bool, + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] enum ScheduleAction { /// List schedules @@ -860,14 +888,7 @@ async fn async_main() { commands::status::run(&socket, agent, json).await } } - Commands::Projects { action } => { - commands::inventory::run_list( - &socket, - pu_core::protocol::InventoryKind::Projects, - action, - ) - .await - } + Commands::Projects { action } => commands::projects::run(&socket, action).await, Commands::Agents { action } => { commands::inventory::run_list(&socket, pu_core::protocol::InventoryKind::Agents, action) .await @@ -1177,6 +1198,16 @@ mod tests { for args in [ vec!["pu", "status", "--global", "--json"], vec!["pu", "projects", "list", "--json"], + vec!["pu", "projects", "add", "--json"], + vec!["pu", "projects", "add", "/project"], + vec![ + "pu", + "projects", + "remove", + "/project", + "--suspend-agents", + "--json", + ], vec!["pu", "agents", "list", "--state", "running", "--json"], vec!["pu", "worktrees", "list", "--global", "--json"], ] { diff --git a/crates/pu-cli/src/output/mod.rs b/crates/pu-cli/src/output/mod.rs index a7d53455..241740b8 100644 --- a/crates/pu-cli/src/output/mod.rs +++ b/crates/pu-cli/src/output/mod.rs @@ -113,6 +113,21 @@ pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliErr Response::InitResult { created } => { system::print_init_result(*created); } + Response::ProjectRemoved { + project_root, + removed, + suspended, + } => { + system::print_project_removed(project_root, *removed, suspended); + } + Response::ProjectsSubscribed => { + println!("Projects subscription active"); + } + Response::ProjectsChanged { projects } => { + for root in projects { + println!("{root}"); + } + } Response::SpawnResult { worktree_id, agent_id, diff --git a/crates/pu-cli/src/output/system.rs b/crates/pu-cli/src/output/system.rs index f7aab600..33c9ca75 100644 --- a/crates/pu-cli/src/output/system.rs +++ b/crates/pu-cli/src/output/system.rs @@ -24,6 +24,21 @@ pub(crate) fn print_init_result(created: bool) { } } +pub(crate) fn print_project_removed(project_root: &str, removed: bool, suspended: &[String]) { + if !removed { + println!("{project_root} was not a registered project"); + return; + } + println!("{} {project_root}", "Removed".green()); + if !suspended.is_empty() { + println!( + " Suspended {} agent(s); they resume when the project is added again", + suspended.len() + ); + } + println!(" Files, including .pu/, were not changed"); +} + pub(crate) fn print_config_report(default_agent: &str, agents: &[AgentConfigInfo]) { println!("{}", "Agent Configuration".bold()); println!(" Default agent: {}", default_agent.green()); diff --git a/crates/pu-cli/tests/global_inventory.rs b/crates/pu-cli/tests/global_inventory.rs index 6c4d7247..01e556c0 100644 --- a/crates/pu-cli/tests/global_inventory.rs +++ b/crates/pu-cli/tests/global_inventory.rs @@ -336,3 +336,46 @@ async fn given_cli_outside_projects_should_query_and_control_agents_globally_wit wire(&socket, Request::Shutdown).await; handle.await.unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn given_projects_add_and_remove_should_change_what_every_client_lists() { + let tmp = tempfile::TempDir::new().unwrap(); + let socket = tmp.path().join("test.sock"); + let registry = pu_core::paths::project_registry_path(&socket); + let engine = pu_engine::engine::Engine::with_project_registry(registry).unwrap(); + let server = pu_engine::ipc_server::IpcServer::bind(&socket, engine).unwrap(); + let handle = tokio::spawn(async move { + server.run().await.unwrap(); + }); + let project = tmp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let root = project.canonicalize().unwrap(); + let root_s = root.to_str().unwrap(); + + // A relative path resolves against the current directory. + let added = json( + &socket, + tmp.path(), + &["projects", "add", "project", "--json"], + ) + .await; + assert_eq!(added["created"], true); + let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; + assert_eq!(listed["projects"][0]["project_root"], root_s); + + let removed = json( + &socket, + tmp.path(), + &["projects", "remove", root_s, "--json"], + ) + .await; + assert_eq!(removed["removed"], true); + assert!(root.join(".pu/manifest.json").exists()); + let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; + assert_eq!(listed["projects"], serde_json::json!([])); + match wire(&socket, Request::Health).await { + Response::HealthReport { projects, .. } => assert!(projects.is_empty()), + other => panic!("unexpected {other:?}"), + } + handle.abort(); +} diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 2eb0d35e..fc0b9b8e 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::types::{AgentStatus, WorktreeEntry}; use indexmap::IndexMap; -pub const PROTOCOL_VERSION: u32 = 12; +pub const PROTOCOL_VERSION: u32 = 13; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -93,6 +93,16 @@ pub enum Request { Init { project_root: String, }, + /// Forget a registered project on this daemon. Files, including `.pu/`, are + /// untouched, so a later `Init` brings the project back as it was. + RemoveProject { + project_root: String, + /// Suspend the project's live agents instead of refusing the removal. + #[serde(default)] + suspend_agents: bool, + }, + /// Stream the registered project roots: once now, then on every change. + SubscribeProjects, Spawn { project_root: String, prompt: String, @@ -510,6 +520,16 @@ pub enum Response { InitResult { created: bool, }, + ProjectRemoved { + project_root: String, + /// False when the root was not registered. + removed: bool, + suspended: Vec, + }, + ProjectsSubscribed, + ProjectsChanged { + projects: Vec, + }, SpawnResult { worktree_id: Option, agent_id: String, diff --git a/crates/pu-core/src/protocol/tests.rs b/crates/pu-core/src/protocol/tests.rs index 420529f5..bc6ff1be 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -23,6 +23,36 @@ fn given_init_request_should_round_trip() { } } +#[test] +fn given_remove_project_wire_form_should_default_to_refusing_live_agents() { + let req: Request = + serde_json::from_str(r#"{"type":"remove_project","project_root":"/test"}"#).unwrap(); + match req { + Request::RemoveProject { + project_root, + suspend_agents, + } => { + assert_eq!(project_root, "/test"); + assert!(!suspend_agents); + } + _ => panic!("expected RemoveProject"), + } +} + +#[test] +fn given_project_list_change_should_serialize_with_its_roots() { + let json = serde_json::to_value(Response::ProjectsChanged { + projects: vec!["/a".into()], + }) + .unwrap(); + assert_eq!( + json, + serde_json::json!({"type": "projects_changed", "projects": ["/a"]}) + ); + let req: Request = serde_json::from_str(r#"{"type":"subscribe_projects"}"#).unwrap(); + assert!(matches!(req, Request::SubscribeProjects)); +} + #[test] fn given_spawn_request_should_default_agent_to_claude() { let json = r#"{"type":"spawn","project_root":"/test","prompt":"fix bug"}"#; @@ -491,7 +521,7 @@ fn given_kill_request_without_exclude_should_default_empty() { #[test] fn given_protocol_version_should_be_current() { // Intentional hardcoded check — update when the protocol version is bumped. - assert_eq!(PROTOCOL_VERSION, 12); + assert_eq!(PROTOCOL_VERSION, 13); } // --- GridCommand round-trips --- diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index 3bb2515f..80bd9481 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Duration; +use pu_core::error::PuError; use pu_core::manifest; use pu_core::protocol::*; use pu_core::types::{AgentEntry, AgentStatus}; @@ -26,11 +27,17 @@ impl Engine { } let mut engine = Self::new(); // `/`, `$HOME` and its ancestors are never projects, whatever an older - // daemon recorded; drop them from the registry on disk too. + // daemon recorded; drop them from the registry on disk too. So are + // folders whose manifest older clients deleted to remove the project: + // clients that follow the registry must not open them again. let count = projects.len(); let projects: Vec = projects .into_iter() - .filter(|root| !is_too_broad_project_root(Path::new(root), engine.home.as_deref())) + .filter(|root| { + let path = Path::new(root); + !is_too_broad_project_root(path, engine.home.as_deref()) + && !was_removed(path, &manifest::read_manifest(path)) + }) .collect(); if projects.len() != count && let Err(e) = write_registry(&path, &projects) @@ -44,6 +51,7 @@ impl Engine { /// Registers an initialized project, except one too broad to be a project /// (`/`, `$HOME` or an ancestor of it), which is skipped without error. + /// Only `Init` registers: using a project never adds it back. pub(super) async fn register_project(&self, root: &str) -> Result<(), Response> { let home = self.home.clone(); // Serialize durable updates without holding the inventory mutex over @@ -52,12 +60,12 @@ impl Engine { let root = root.to_string(); let projects = self.registered_projects.clone(); let registry = self.project_registry_path.clone(); - tokio::task::spawn_blocking(move || -> std::io::Result<()> { + let added = tokio::task::spawn_blocking(move || -> std::io::Result { // Keep serialization even if the awaiting request is cancelled. let _update = update; let root = Path::new(&root).canonicalize()?; if is_too_broad_project_root(&root, home.as_deref()) { - return Ok(()); + return Ok(false); } // Registration is only valid for readable, initialized projects. manifest::read_manifest(&root).map_err(std::io::Error::other)?; @@ -67,7 +75,7 @@ impl Engine { .lock() .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; if current.contains(&root) { - return Ok(()); + return Ok(false); } current.iter().cloned().collect() }; @@ -80,11 +88,129 @@ impl Engine { .lock() .map_err(|_| std::io::Error::other("project registry lock poisoned"))? .insert(root); - Ok(()) + Ok(true) }) .await .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))? - .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string())) + .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))?; + if added { + self.notify_projects_changed(); + } + Ok(()) + } + + /// Removes registered roots and returns the ones that were registered. + /// With `only_if_removed`, each root is re-checked under the update lock + /// and kept unless its folder still exists without a manifest, so one + /// reopened since it was seen missing stays. + async fn unregister_projects( + &self, + roots: Vec, + only_if_removed: bool, + ) -> std::io::Result> { + let update = self.project_registry_updates.clone().lock_owned().await; + let projects = self.registered_projects.clone(); + let registry = self.project_registry_path.clone(); + let removed = tokio::task::spawn_blocking(move || -> std::io::Result> { + let _update = update; + let (removed, next): (Vec<_>, Vec<_>) = { + let current = projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; + current.iter().cloned().partition(|root| { + roots.contains(root) + && (!only_if_removed || { + let root = Path::new(root); + was_removed(root, &manifest::read_manifest(root)) + }) + }) + }; + if removed.is_empty() { + return Ok(removed); + } + let mut next = next; + next.sort(); + if let Some(path) = registry { + write_registry(&path, &next)?; + } + let mut current = projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; + for root in &removed { + current.remove(root); + } + Ok(removed) + }) + .await + .map_err(std::io::Error::other)??; + if !removed.is_empty() { + self.notify_projects_changed(); + } + Ok(removed) + } + + /// Forgets a project on this daemon without touching its files. Live + /// agents block the removal unless the caller asks to suspend them, so + /// they can be resumed if the project is added again. + pub(super) async fn handle_remove_project( + &self, + project_root: &str, + suspend_agents: bool, + ) -> Response { + // A deleted folder can no longer be canonicalized; match it as given. + let path = PathBuf::from(project_root); + let root = tokio::task::spawn_blocking(move || path.canonicalize()) + .await + .ok() + .and_then(Result::ok) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| project_root.to_string()); + let live: Vec = self + .sessions + .lock() + .await + .iter() + .filter(|(_, h)| { + h.project_root.as_deref() == Some(root.as_str()) && h.exit_rx.borrow().is_none() + }) + .map(|(id, _)| id.clone()) + .collect(); + let mut suspended = Vec::new(); + if !live.is_empty() { + if !suspend_agents { + return Self::inventory_error( + "PROJECT_HAS_LIVE_AGENTS", + format!( + "{root} has {} live agent{}; suspend them first or remove with suspend_agents", + live.len(), + if live.len() == 1 { "" } else { "s" } + ), + ); + } + if let Response::SuspendResult { suspended: ids } = + self.handle_suspend(&root, SuspendTarget::All).await + { + suspended = ids; + } + // Sessions the manifest does not list (or a missing manifest) still + // belong to the project; nothing may keep running in a removed one. + let rest: Vec = live + .into_iter() + .filter(|id| !suspended.contains(id)) + .collect(); + if !rest.is_empty() { + self.kill_agents(&rest).await; + suspended.extend(rest); + } + } + match self.unregister_projects(vec![root.clone()], false).await { + Ok(removed) => Response::ProjectRemoved { + removed: !removed.is_empty(), + project_root: root, + suspended, + }, + Err(e) => Self::inventory_error("IO_ERROR", e.to_string()), + } } pub(super) fn inventory_error(code: &str, message: String) -> Response { @@ -115,7 +241,6 @@ impl Engine { } => Some(id.clone()), _ => None, }; - let is_init = matches!(request, Request::Init { .. }); let legacy_root_use = allowed_on_too_broad_root(request); if let Some(root) = request.project_root_mut() { if root.is_empty() { @@ -142,11 +267,6 @@ impl Engine { if let Some(id) = targeted.as_deref() { self.validate_live_owner(id, root).await?; } - // Init may create the manifest; every other successful project use - // registers an already-initialized project without inventing one. - if !is_init && self.read_manifest_async(root).await.is_ok() { - self.register_project(root).await?; - } } Ok(()) } @@ -189,11 +309,16 @@ impl Engine { for root in roots { reads.spawn(async move { let path = PathBuf::from(&root); - let read = tokio::task::spawn_blocking(move || manifest::read_manifest(&path)); + let read = tokio::task::spawn_blocking(move || { + let result = manifest::read_manifest(&path); + let removed = was_removed(&path, &result); + (result, removed) + }); + // The flag marks a project removed from this machine. let result = match tokio::time::timeout(Duration::from_secs(2), read).await { - Ok(Ok(result)) => result.map_err(|e| e.to_string()), - Ok(Err(e)) => Err(e.to_string()), - Err(_) => Err("project manifest read timed out".into()), + Ok(Ok((result, removed))) => result.map_err(|e| (removed, e.to_string())), + Ok(Err(e)) => Err((false, e.to_string())), + Err(_) => Err((false, "project manifest read timed out".into())), }; (root, result) }); @@ -208,7 +333,19 @@ impl Engine { manifests.sort_by(|a, b| a.0.cmp(&b.0)); let sessions = self.sessions.lock().await; let mut observed_sessions = HashSet::new(); + let mut removed = Vec::new(); for (root, result) in manifests { + // A registry-wide query forgets a project whose manifest is gone, + // unless an agent this daemon runs still claims it. + if report.project_root.is_none() + && matches!(result, Err((true, _))) + && !sessions + .values() + .any(|h| h.project_root.as_deref() == Some(root.as_str())) + { + removed.push(root); + continue; + } let mut project = ProjectInventoryEntry { name: Path::new(&root) .file_name() @@ -225,7 +362,7 @@ impl Engine { }; let m = match result { Ok(m) => m, - Err(error) => { + Err((_, error)) => { report.complete = false; project.error = Some(error); report.projects.push(project); @@ -374,6 +511,11 @@ impl Engine { } } drop(sessions); + if !removed.is_empty() + && let Err(e) = self.unregister_projects(removed, true).await + { + tracing::warn!("failed to unregister removed projects: {e}"); + } report.summary = standalone; report.summary.projects = report.projects.len(); for p in &report.projects { @@ -567,6 +709,12 @@ pub(super) fn project_root_not_allowed(root: &str) -> Response { } } +/// A project folder whose manifest is gone was removed from PurePoint. A +/// missing folder is only unavailable: it may be on an unmounted volume. +fn was_removed(root: &Path, read: &Result) -> bool { + matches!(read, Err(PuError::NotInitialized)) && root.is_dir() +} + fn write_registry(path: &Path, projects: &[String]) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -1033,4 +1181,209 @@ mod tests { Response::HealthReport { agent_count: 0, .. } )); } + + #[tokio::test] + async fn given_registered_project_whose_manifest_was_deleted_should_drop_it_from_inventory_and_registry() + { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let kept = init(&engine, &tmp.path().join("kept")).await; + let removed = init(&engine, &tmp.path().join("removed")).await; + // What the app does when a project is removed from the sidebar. + std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&removed))).unwrap(); + + let report = inventory(&engine, InventoryKind::Projects).await; + assert!(report.complete); + let roots: Vec<_> = report.projects.iter().map(|p| &p.project_root).collect(); + assert_eq!(roots, vec![&kept]); + assert_eq!(engine.registered_projects(), vec![kept.clone()]); + drop(engine); + assert_eq!( + Engine::with_project_registry(path) + .unwrap() + .registered_projects(), + vec![kept] + ); + } + + #[tokio::test] + async fn given_registry_listing_a_project_removed_by_an_older_client_should_drop_it_on_load() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let kept = init(&engine, &tmp.path().join("kept")).await; + let removed = init(&engine, &tmp.path().join("removed")).await; + // A missing folder may be an unmounted volume, so it stays. + let unmounted = tmp.path().join("unmounted").to_string_lossy().into_owned(); + drop(engine); + std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&removed))).unwrap(); + let mut stored = vec![kept.clone(), removed, unmounted.clone()]; + stored.sort(); + std::fs::write(&path, serde_json::to_vec(&stored).unwrap()).unwrap(); + + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let mut expected = vec![kept, unmounted]; + expected.sort(); + assert_eq!(engine.registered_projects(), expected); + let on_disk: Vec = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(on_disk, expected); + } + + #[tokio::test] + async fn given_corrupt_manifest_should_keep_project_registered() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + std::fs::write(pu_core::paths::manifest_path(Path::new(&root)), b"corrupt").unwrap(); + + let report = inventory(&engine, InventoryKind::Projects).await; + assert!(!report.complete); + assert_eq!(report.projects.len(), 1); + assert!(!report.projects[0].available); + assert_eq!(engine.registered_projects(), vec![root]); + } + + #[tokio::test] + async fn given_live_session_in_project_without_manifest_should_keep_it() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp.path().join("project").canonicalize().unwrap(); + std::fs::remove_file(pu_core::paths::manifest_path(&root)).unwrap(); + + let report = inventory(&engine, InventoryKind::Agents).await; + let root = root.to_string_lossy().into_owned(); + assert_eq!(report.projects.len(), 1); + assert_eq!(report.projects[0].project_root, root); + assert!(report.agents.iter().any(|a| a.id == id)); + assert_eq!(engine.registered_projects(), vec![root]); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_removed_project_reopened_should_register_again() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&root))).unwrap(); + inventory(&engine, InventoryKind::Projects).await; + assert!(engine.registered_projects().is_empty()); + + init(&engine, Path::new(&root)).await; + assert_eq!(engine.registered_projects(), vec![root.clone()]); + let report = inventory(&engine, InventoryKind::Projects).await; + assert_eq!(report.projects[0].project_root, root); + } + + #[tokio::test] + async fn given_removed_project_should_leave_registry_and_keep_its_files() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + + let response = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + assert!( + matches!(&response, Response::ProjectRemoved { removed: true, project_root, .. } if *project_root == root), + "{response:?}" + ); + assert!(pu_core::paths::manifest_path(Path::new(&root)).exists()); + assert!( + inventory(&engine, InventoryKind::Projects) + .await + .projects + .is_empty() + ); + drop(engine); + assert!( + Engine::with_project_registry(path) + .unwrap() + .registered_projects() + .is_empty() + ); + } + + #[tokio::test] + async fn given_unregistered_root_should_report_nothing_removed() { + let tmp = tempfile::TempDir::new().unwrap(); + let response = Engine::new() + .handle_request(Request::RemoveProject { + project_root: tmp.path().join("gone").to_string_lossy().into_owned(), + suspend_agents: false, + }) + .await; + assert!( + matches!(response, Response::ProjectRemoved { removed: false, .. }), + "{response:?}" + ); + } + + #[tokio::test] + async fn given_removed_project_should_not_come_back_when_used() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::new(); + let root = init(&engine, &tmp.path().join("project")).await; + engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + // Another device still watching it must not add it back. + engine + .handle_request(Request::Status { + project_root: root.clone(), + agent_id: None, + }) + .await; + assert!(engine.registered_projects().is_empty()); + + init(&engine, Path::new(&root)).await; + assert_eq!(engine.registered_projects(), vec![root]); + } + + #[tokio::test] + async fn given_live_agents_should_refuse_removal_unless_asked_to_suspend_them() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp + .path() + .join("project") + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + let refused = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + assert!( + matches!(&refused, Response::Error { code, .. } if code == "PROJECT_HAS_LIVE_AGENTS"), + "{refused:?}" + ); + assert_eq!(engine.registered_projects(), vec![root.clone()]); + + let removed = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: true, + }) + .await; + assert!( + matches!(&removed, Response::ProjectRemoved { removed: true, suspended, .. } if *suspended == vec![id.clone()]), + "{removed:?}" + ); + assert!(engine.registered_projects().is_empty()); + let agent = manifest::read_manifest(Path::new(&root)).unwrap().agents[&id].clone(); + assert!( + agent.suspended, + "suspended agents resume when the project is added again" + ); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } } diff --git a/crates/pu-engine/src/engine/mod.rs b/crates/pu-engine/src/engine/mod.rs index 4cc7d800..ef7646e8 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -89,8 +89,10 @@ pub struct Engine { grid_channels: Arc>>>, /// Per-project broadcast channels for status push updates. status_channels: Arc>>>, - /// Projects that have been initialized or used — scheduler scans these. + /// Projects added with `Init` and not removed since — scheduler scans these. registered_projects: Arc>>, + /// Signalled whenever a project is added to or removed from the registry. + projects_changed: tokio::sync::broadcast::Sender<()>, /// Agents with a resume in progress, so two resumes of one agent can never /// both spawn `claude --resume` on the same transcript. resuming: Arc>>, @@ -205,6 +207,7 @@ impl Engine { grid_channels: Arc::new(Mutex::new(HashMap::new())), status_channels: Arc::new(Mutex::new(HashMap::new())), registered_projects: Arc::new(std::sync::Mutex::new(HashSet::new())), + projects_changed: tokio::sync::broadcast::channel(16).0, resuming: Arc::new(std::sync::Mutex::new(HashSet::new())), shutdown_killed: Arc::new(std::sync::Mutex::new(HashSet::new())), } @@ -436,6 +439,14 @@ impl Engine { } response } + Request::RemoveProject { + project_root, + suspend_agents, + } => { + self.handle_remove_project(&project_root, suspend_agents) + .await + } + Request::SubscribeProjects => Response::ProjectsSubscribed, Request::Rename { project_root, agent_id, diff --git a/crates/pu-engine/src/engine/subscriptions.rs b/crates/pu-engine/src/engine/subscriptions.rs index 25f4cad8..8778db45 100644 --- a/crates/pu-engine/src/engine/subscriptions.rs +++ b/crates/pu-engine/src/engine/subscriptions.rs @@ -67,6 +67,17 @@ impl Engine { tx.subscribe() } + // --- Project Registry Push --- + + /// Receiver signalled on every registry change (used by the IPC server). + pub fn subscribe_projects(&self) -> tokio::sync::broadcast::Receiver<()> { + self.projects_changed.subscribe() + } + + pub(super) fn notify_projects_changed(&self) { + let _ = self.projects_changed.send(()); + } + // --- Status Push --- pub(super) async fn handle_subscribe_status(&self, project_root: &str) -> Response { diff --git a/crates/pu-engine/src/ipc_server/mod.rs b/crates/pu-engine/src/ipc_server/mod.rs index b4c1ddfe..c92ad63b 100644 --- a/crates/pu-engine/src/ipc_server/mod.rs +++ b/crates/pu-engine/src/ipc_server/mod.rs @@ -50,6 +50,7 @@ enum StreamMode { Attach(String, Option, Option), Grid(String), Status(String), + Projects, Fs { root: String, recursive: bool }, } @@ -63,6 +64,7 @@ impl StreamMode { } => Self::Attach(agent_id.clone(), *replay, client_id.clone()), Request::SubscribeGrid { project_root } => Self::Grid(project_root.clone()), Request::SubscribeStatus { project_root } => Self::Status(project_root.clone()), + Request::SubscribeProjects => Self::Projects, Request::SubscribeFs { root, recursive } => Self::Fs { root: root.clone(), recursive: *recursive, @@ -371,6 +373,11 @@ impl IpcServer { .await; } } + StreamMode::Projects => { + if matches!(response, Response::ProjectsSubscribed) { + return streams::handle_projects_stream(reader, pending, writer, engine).await; + } + } StreamMode::Fs { root, recursive } => { if matches!(response, Response::FsSubscribed) { // A watch stream never returns to request mode: if the diff --git a/crates/pu-engine/src/ipc_server/streams.rs b/crates/pu-engine/src/ipc_server/streams.rs index 29b53c46..eb46cfa4 100644 --- a/crates/pu-engine/src/ipc_server/streams.rs +++ b/crates/pu-engine/src/ipc_server/streams.rs @@ -306,6 +306,56 @@ pub(super) async fn handle_status_stream( true } +async fn send_projects(writer: &mut IpcWriter, engine: &Engine) -> bool { + let mut projects = engine.registered_projects(); + projects.sort(); + write_response(writer, &Response::ProjectsChanged { projects }) + .await + .is_ok() +} + +/// Streaming project registry: pushes the full list of registered roots now +/// and after every add or remove, so every client shows the same projects. +pub(super) async fn handle_projects_stream( + reader: &mut IpcReader, + partial: &mut Vec, + writer: &mut IpcWriter, + engine: &Engine, +) -> bool { + // Subscribe before the first send so no change between them is missed. + let mut rx = engine.subscribe_projects(); + if !send_projects(writer, engine).await { + return true; + } + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + while rx.try_recv().is_ok() {} + if !send_projects(writer, engine).await { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + frame = read_frame(reader, partial) => { + match frame { + // Any request ends the projects stream. + Ok(Frame::Line(_)) | Ok(Frame::Eof) => break, + Ok(Frame::TooLarge) => { + reject_frame_too_large(writer).await; + return false; + } + Err(_) => return false, + } + } + } + } + true +} + async fn write_output_chunks( writer: &mut IpcWriter, agent_id: &str, diff --git a/crates/pu-engine/src/ipc_server/tests.rs b/crates/pu-engine/src/ipc_server/tests.rs index d4d46c74..d44f5a8a 100644 --- a/crates/pu-engine/src/ipc_server/tests.rs +++ b/crates/pu-engine/src/ipc_server/tests.rs @@ -622,6 +622,68 @@ async fn given_subscribe_fs_should_stream_coalesced_file_events() { handle.abort(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn given_subscribe_projects_should_push_the_list_on_add_and_remove() { + let tmp = TempDir::new().unwrap(); + let sock_path = tmp.path().join("test.sock"); + let project_root = tmp.path().canonicalize().unwrap().join("project"); + std::fs::create_dir_all(&project_root).unwrap(); + let root = project_root.to_string_lossy().into_owned(); + let server = IpcServer::bind(&sock_path, Engine::new()).unwrap(); + let handle = tokio::spawn(async move { server.run().await }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let stream = UnixStream::connect(&sock_path).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let req = serde_json::to_string(&Request::SubscribeProjects).unwrap(); + writer + .write_all(format!("{req}\n").as_bytes()) + .await + .unwrap(); + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsSubscribed + )); + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsChanged { projects } if projects.is_empty() + )); + + let send = |request: Request| { + let sock_path = sock_path.clone(); + async move { + let stream = UnixStream::connect(&sock_path).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let json = serde_json::to_string(&request).unwrap(); + writer + .write_all(format!("{json}\n").as_bytes()) + .await + .unwrap(); + next_response(&mut BufReader::new(reader)).await + } + }; + send(Request::Init { + project_root: root.clone(), + }) + .await; + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsChanged { projects } if projects == vec![root.clone()] + )); + send(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsChanged { projects } if projects.is_empty() + )); + + handle.abort(); +} + async fn attachment_pair( engine: std::sync::Arc, agent_id: String, diff --git a/docs/product/cli.md b/docs/product/cli.md index 2cad706b..50d04864 100644 --- a/docs/product/cli.md +++ b/docs/product/cli.md @@ -33,10 +33,12 @@ Key behaviors: | Command | Args/Flags | Description | |---|---|---| -| `pu init` | `--json` | Register current project with daemon | +| `pu init` | `--json` | Add the current project to the daemon (same as `pu projects add`) | | `pu spawn [prompt]` | `--agent`, `--name`, `--base`, `--root`, `--worktree`, `--template`, `--file`, `--command`, `--var KEY=VALUE`, `--no-auto`, `--agent-args`, `--plan`, `--no-trigger`, `--trigger`, `--json` | Spawn an agent (in worktree or root) | | `pu status` | `--global`, `--agent `, `--json` | Show project, global summary, or agent status | | `pu projects list` | `--global`, `--json` | List daemon-known projects | +| `pu projects add [path]` | `--json` | Add a folder as a project, initializing it if needed; path defaults to `--project`, then cwd. Works with `--machine` given an absolute path | +| `pu projects remove [path]` | `--suspend-agents`, `--json` | Remove a project from the daemon for every device; files, including `.pu/`, are kept. Refuses while agents run unless `--suspend-agents` | | `pu agents list` | `--global`, `--state running\|suspended\|broken\|unknown`, `--json` | List agent instances with ownership | | `pu worktrees list` | `--global`, `--json` | List worktrees with ownership | | `pu bench [agent_id]` | `--all`, `--json` | Suspend (bench) agents | diff --git a/docs/product/daemon.md b/docs/product/daemon.md index 38ea76c6..3c2fe5e9 100644 --- a/docs/product/daemon.md +++ b/docs/product/daemon.md @@ -25,6 +25,8 @@ Daemon (pu-engine binary) **Global inventory (implemented 2026-10-10):** Production startup loads canonical project roots from `.projects.json`; successful Init and validated project use register roots atomically. Alternate sockets have independent registries. Invalid registrations do not enter the set, unavailable registered projects remain visible with errors, and restart does not spawn agents. A derived Inventory query combines manifests with live ownership metadata; no duplicate global agent database is stored. Global summary and flat agent/worktree records share count semantics, and Health now returns known projects and the count of live session handles rather than all retained handles. In-memory Engine::new remains available for isolated embedding/tests. +**Explicit project membership (implemented 2026-10-21, protocol 13):** The registry is each machine's one project list, and only `Init` adds to it and `RemoveProject` removes from it. Using a project (status, spawn, subscriptions) no longer registers it — that made a project removed on one device reappear as soon as another device still watching it sent a request. Removal keeps the project's files and `.pu/` so adding it again restores it; live agents block removal unless the caller asks to suspend them. `SubscribeProjects` pushes the full list on every change, and the macOS app's sidebar, each machine's project list and the phone bridge all follow the registry instead of keeping their own lists. Registered roots whose folder exists without a manifest (removed by clients that deleted `.pu/manifest.json`) are dropped by the next global inventory. + **Daemon binary:** `pu-engine` (separate from `pu-cli`). Both modes take an exclusive lock on `daemon.sock.lock` (one daemon per socket) and write the PID file; managed mode (`--managed`) additionally exits when its parent app dies. Socket path configurable via `--socket `, defaults to `~/.pu/daemon.sock`. ## Open Questions diff --git a/docs/reference/cli-commands-map.csv b/docs/reference/cli-commands-map.csv index 1636efdc..6057ebb5 100644 --- a/docs/reference/cli-commands-map.csv +++ b/docs/reference/cli-commands-map.csv @@ -1,5 +1,7 @@ Command,IPC Request,Tier,Notes pu init,Init,1,Creates .pu/ directory and pu.db +pu projects add,Init,1,Adds a folder as a project (same as pu init) +pu projects remove,RemoveProject,1,Removes a project for every device; files are kept pu spawn,Spawn,1,Most complex command — worktree + agents pu status,Status,1,Returns full workspace state from daemon pu kill,Kill,1,Stop agents; optionally remove worktrees diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 9cc55a5d..dda968f2 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -8,7 +8,7 @@ The PurePoint daemon communicates with clients (CLI, macOS app) via NDJSON over |---|---| | Socket path | `~/.pu/daemon.sock` (mode 0600; the daemon keeps `~/.pu` at 0700) | | Access | Only processes running as the daemon's user: every accepted connection's peer uid must equal the daemon's effective uid, or it is closed | -| Protocol version | 11 | +| Protocol version | 13 | | Framing | Newline-delimited JSON (one JSON object per line) | | Max message size | 1 MB (1,048,576 bytes); a longer line gets one `FRAME_TOO_LARGE` error and the connection is closed | | First request | Must arrive within 30 seconds of connecting, or the connection is closed | @@ -44,6 +44,7 @@ Responses use the same `"type"` field convention. Error responses have `"type": | `NOT_FOUND` | Template, agent def, swarm def, schedule, or trigger not found | | `NOT_INITIALIZED` | Project not initialized (run `pu init`) | | `ALREADY_INITIALIZED` | Project already initialized | +| `PROJECT_HAS_LIVE_AGENTS` | `remove_project` without `suspend_agents` while the project has live agents | | `DAEMON_NOT_RUNNING` | Daemon is not running | | `DAEMON_CONNECTION_FAILED` | Connection to daemon failed | | `PROTOCOL_MISMATCH` | Client/daemon protocol version mismatch | @@ -74,7 +75,7 @@ Inventory returns `type: inventory`, observation time, optional project scope (n ResolveAgent returns `type: agent_resolved`, agent_id, project_root and worktree_id. Legacy Status (with agent_id), Kill (single agent), Suspend (single agent), Resume, Rename and AssignTrigger accept an empty project_root for daemon ownership resolution. Explicit roots constrain ownership. Bulk requests require a root. Duplicate durable IDs return AMBIGUOUS_AGENT; a live session owned by another project returns AGENT_OWNERSHIP_CONFLICT; an incomplete durable inventory returns INVENTORY_INCOMPLETE when a known live owner cannot resolve the ID. Standalone shells support status/logs/input/attach/kill and reject resumable project operations with UNSUPPORTED_AGENT_OPERATION. -Production registry: `.projects.json`, loaded on startup and atomically updated after successful initialization or validated use. `/`, `$HOME` and ancestors of `$HOME` are never registered, even when `~/.pu/manifest.json` makes `$HOME` look initialized; such entries left by older daemons are removed when the registry loads. Requests naming such a root fail with `project_root_not_allowed`, except `status`, `subscribe_status`, `read`, `channel_read` and `kill`, which stay available so agents an older version recorded there can be seen and stopped. Roots are canonical, registration survives restart, and startup does not resume agents. Health returns registered roots and counts only session handles without observed exits. +Production registry: `.projects.json`, loaded on startup and atomically updated by `init` (add) and `remove_project` (remove) — nothing else adds a project, so a removed project stays removed even while other clients still use it. A registered root whose folder still exists but has no `.pu/manifest.json` (removed by an older client) is dropped by the next global inventory; a root whose folder is missing stays, reported unavailable, since it may be on an unmounted volume. `/`, `$HOME` and ancestors of `$HOME` are never registered, even when `~/.pu/manifest.json` makes `$HOME` look initialized; such entries left by older daemons are removed when the registry loads. Requests naming such a root fail with `project_root_not_allowed`, except `status`, `subscribe_status`, `read`, `channel_read` and `kill`, which stay available so agents an older version recorded there can be seen and stopped. Roots are canonical, registration survives restart, and startup does not resume agents. Health returns registered roots and counts only session handles without observed exits. ### Health @@ -88,7 +89,7 @@ Check daemon liveness and protocol version. "type": "health_report", "pid": 12345, "uptime_seconds": 3600, - "protocol_version": 11, + "protocol_version": 13, "projects": ["/path/to/project"], "agent_count": 3 } @@ -96,12 +97,20 @@ Check daemon liveness and protocol version. ### Init -Register a project and create `.pu/manifest.json`. +Add a project: create `.pu/manifest.json` if needed and register the root. This is the only request that registers a project. **Request**: `{"type": "init", "project_root": "/path/to/project"}` **Response**: `{"type": "init_result", "created": true}` +### RemoveProject + +Forget a project on this daemon, for every client. Its files, including `.pu/`, are not touched, so a later `init` brings it back with its worktrees and agents. Live agents refuse the removal with `PROJECT_HAS_LIVE_AGENTS` unless `suspend_agents` is true (default false), which suspends them so they resume when the project is added again. An unregistered root answers `removed: false`. + +**Request**: `{"type": "remove_project", "project_root": "/path/to/project", "suspend_agents": true}` + +**Response**: `{"type": "project_removed", "project_root": "/path/to/project", "removed": true, "suspended": ["ag-abc"]}` + ### Shutdown Gracefully shut down the daemon. @@ -389,6 +398,16 @@ Subscribe to workspace status changes. Then streams: `{"type": "status_event", "worktrees": [...], "agents": [...]}` +### SubscribeProjects + +Follow the registered projects. The full sorted list of roots is sent once on subscribing and again after every `init` that adds a project and every removal, so every client shows the same projects without polling. Any request line ends the stream. + +**Request**: `{"type": "subscribe_projects"}` + +**Response**: `{"type": "projects_subscribed"}` + +Then streams: `{"type": "projects_changed", "projects": ["/path/a", "/path/b"]}` + ## Grid commands **Request**: `{"type": "grid_command", "project_root": "/path", "command": {...}}` From c4aae83d4edb0cf1ebf7edcb283d57759a0bba49 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Wed, 21 Oct 2026 23:53:39 -0500 Subject: [PATCH 2/7] fix(projects): address review of explicit project membership - Prune projects removed by older clients in the background with bounded reads, so a stale network mount can no longer stop the daemon binding its socket. The project stream waits for the prune, so no client opens one of those projects again. - An agent in a folder that is not a registered project counts as standalone and no longer marks the global inventory partial, which had blocked phone management. - remove_project reports sessions it had to kill separately (`killed`) from suspended agents, which resume. - macOS: - Projects that join the sidebar after launch keep their suspended agents paused. - A Point Guard folder removed from another device is registered again. - Legacy sidebar entries on unmounted volumes are kept for the next launch. - Local remove sends the canonical path. - The projects subscription no longer leaks a connection when stopped mid-connect. - docs: drop the stale "validated use registers" wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- .../Services/DaemonProjectsSubscription.swift | 5 + .../purepoint-macos/State/AppState.swift | 38 +++++- .../purepoint-macos/State/ProjectState.swift | 9 +- .../PointGuardRoutingTests.swift | 41 ++++++ .../verification/PointGuardRoutingStubs.swift | 1 + crates/pu-cli/src/output/mod.rs | 3 +- crates/pu-cli/src/output/system.rs | 13 +- crates/pu-core/src/protocol/mod.rs | 4 + crates/pu-engine/src/engine/inventory.rs | 124 ++++++++++++++++-- crates/pu-engine/src/engine/mod.rs | 3 + crates/pu-engine/src/ipc_server/streams.rs | 1 + crates/pu-engine/src/main.rs | 3 + docs/product/daemon.md | 2 +- 13 files changed, 225 insertions(+), 22 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift index a6dddcee..936be8e5 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift @@ -47,6 +47,11 @@ actor DaemonProjectsSubscription { private func run(onChange: @escaping @MainActor ([String]) -> Void) async throws { let (conn, reader) = try await client.connect() + // Stopped while connecting: nothing else would ever close this connection. + guard !stopped else { + conn.close() + return + } connection?.close() connection = conn try await DaemonClient.write(.subscribeProjects, to: conn) diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 671c46a1..ea81752b 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -127,6 +127,9 @@ final class AppState { /// Until the local daemon first reports its projects, a saved workspace selection may /// belong to one that has not opened yet. @ObservationIgnored private var awaitingLocalProjects = false + /// Whether the first local project list reopens last session's sidebar, whose agents + /// this app suspended on quit and resumes. Projects that join later keep theirs paused. + @ObservationIgnored private var resumesOnFirstLocalList = false /// The sidebar list from before the daemon owned it; read once, then deleted. private static let openProjectsKey = "PurePointOpenProjects" @@ -247,13 +250,14 @@ final class AppState { /// Open a project on this Mac. iOS has no local machine: every iPad project is /// machine-backed and arrives through its `MachineState`, so a local root never opens. - func openProject(_ root: String) { + func openProject(_ root: String, resumingSuspendedAgents: Bool = true) { #if os(macOS) guard !projects.contains(where: { $0.projectRoot == root }) else { return } let project = pointGuardProjects.removeValue(forKey: root) ?? ProjectState(projectRoot: root, service: service, registry: registry) project.appState = self + if !resumingSuspendedAgents { project.keepsSuspendedAgentsOnNextOpen = true } projects.append(project) if pointGuardFolders.contains(root) { shownPointGuardFolders.insert(root) } startProject(project) @@ -314,7 +318,8 @@ final class AppState { let root = project.projectRoot if !isPointGuardFolder(root) { let client = DaemonEndpoint.local.makeClient() - switch try await client.send(.removeProject(projectRoot: root, suspendAgents: true)) { + // The daemon registered the canonical path; a symlinked one may name a deleted folder. + switch try await client.send(.removeProject(projectRoot: Self.canonicalPath(root), suspendAgents: true)) { case .projectRemoved: break case .error(_, let message): @@ -353,17 +358,29 @@ final class AppState { // project with the daemon, and then deleted. let paths = projectRoot.map { [$0] } ?? UserDefaults.standard.stringArray(forKey: Self.openProjectsKey) ?? [] - if projectRoot == nil { UserDefaults.standard.removeObject(forKey: Self.openProjectsKey) } + resumesOnFirstLocalList = paths.isEmpty // Opening projects persists current state, so snapshot the saved lists first. let savedPointGuardFolders = UserDefaults.standard.stringArray(forKey: "PurePointPointGuardFolders") ?? [] pointGuardFolders.formUnion(savedPointGuardFolders) shownPointGuardFolders.formUnion( UserDefaults.standard.stringArray(forKey: Self.shownPointGuardFoldersKey) ?? []) + var notImported: [String] = [] for path in paths { - guard projectRoot != nil || FileManager.default.fileExists(atPath: path) else { continue } + guard projectRoot != nil || FileManager.default.fileExists(atPath: path) else { + // On a volume that is not mounted yet: try again next launch. + notImported.append(path) + continue + } guard !isTooBroadProjectRoot(path) else { continue } openProject(path) } + if projectRoot == nil { + if notImported.isEmpty { + UserDefaults.standard.removeObject(forKey: Self.openProjectsKey) + } else { + UserDefaults.standard.set(notImported, forKey: Self.openProjectsKey) + } + } for root in savedPointGuardFolders { guard FileManager.default.fileExists(atPath: root) else { continue } guard !isTooBroadProjectRoot(root) else { continue } @@ -400,13 +417,22 @@ final class AppState { let canonical = Dictionary( projects.map { (Self.canonicalPath($0.projectRoot), $0.projectRoot) }, uniquingKeysWith: { first, _ in first }) for root in lastLocalProjects.subtracting(registered) { - if let open = canonical[root] { closeProject(open) } + if let owner = projectState(forRoot: canonical[root] ?? root) ?? pointGuardProjects.first(where: { + Self.canonicalPath($0.key) == root + })?.value, isPointGuardFolder(owner.projectRoot) { + // Removed elsewhere, but Point Guard works in this folder: register it again. + startProject(owner) + } else if let open = canonical[root] { + closeProject(open) + } } + let resuming = resumesOnFirstLocalList + resumesOnFirstLocalList = false let hidden = Set(pointGuardProjects.keys.filter { !shownPointGuardFolders.contains($0) }.map(Self.canonicalPath)) for root in roots where canonical[root] == nil { guard FileManager.default.fileExists(atPath: root), !isTooBroadProjectRoot(root) else { continue } if hidden.contains(root) { continue } - openProject(root) + openProject(root, resumingSuspendedAgents: resuming) } lastLocalProjects = registered awaitingLocalProjects = false diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index aa3623cf..e930faae 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -27,6 +27,9 @@ final class ProjectState: Identifiable { /// A remote project kept in the sidebar while its machine is unreachable, because it has /// file tabs with unsaved edits. Nothing is watched until the daemon is back. var isUnavailable = false + /// Leaves suspended agents paused on the next open: a project another device or the CLI + /// added was not suspended by this app's quit, so its benched agents stay benched. + @ObservationIgnored var keepsSuspendedAgentsOnNextOpen = false let channel: ChannelState @@ -194,7 +197,11 @@ final class ProjectState: Identifiable { self.appState?.daemonError = error.localizedDescription return } - self.resumeSuspendedAgents() + if self.keepsSuspendedAgentsOnNextOpen { + self.keepsSuspendedAgentsOnNextOpen = false + } else { + self.resumeSuspendedAgents() + } } #else startWatchingRemote() diff --git a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift index be022215..d1f653b5 100644 --- a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift @@ -280,4 +280,45 @@ private struct PointGuardRoutingService: WorkspaceService { #expect(UserDefaults.standard.stringArray(forKey: "PurePointOpenProjects") == nil) } } + + @Test func projectsJoiningAfterLaunchKeepTheirSuspendedAgentsPaused() throws { + try withState { state, _, root in + for path in [root + "-project", root + "-next"] { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + } + UserDefaults.standard.removeObject(forKey: "PurePointOpenProjects") + state.restoreProjects() + // Last session's sidebar: this app suspended its agents on quit and resumes them. + let project = AppState.canonicalPath(root + "-project"), next = AppState.canonicalPath(root + "-next") + state.applyLocalProjects([project]) + #expect(state.projectState(forRoot: project)?.keepsSuspendedAgentsOnNextOpen == false) + // Added later, by another device or the CLI. + state.applyLocalProjects([next, project]) + #expect(state.projectState(forRoot: next)?.keepsSuspendedAgentsOnNextOpen == true) + } + } + + @Test func pointGuardFolderRemovedElsewhereIsRegisteredAgain() throws { + var started: [String] = [] + let root = NSTemporaryDirectory() + "point-guard-reregister-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: root) } + let saved = UserDefaults.standard.object(forKey: "PurePointPointGuardFolders") + let savedShown = UserDefaults.standard.object(forKey: "PurePointShownPointGuardFolders") + defer { + UserDefaults.standard.set(saved, forKey: "PurePointPointGuardFolders") + UserDefaults.standard.set(savedShown, forKey: "PurePointShownPointGuardFolders") + } + let state = AppState( + service: PointGuardRoutingService(), startProject: { started.append($0.projectRoot) }, + followsLocalDaemon: false) + state.updatePointGuardRoot(root) + state.openProject(root) + let canonical = AppState.canonicalPath(root) + state.applyLocalProjects([canonical]) + started.removeAll() + state.applyLocalProjects([]) + #expect(started == [root]) + #expect(state.projects.map(\.projectRoot) == [root]) + } } diff --git a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift index d9e38d02..69d2cce5 100644 --- a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift +++ b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift @@ -105,6 +105,7 @@ actor DaemonProjectsSubscription { self.projectRoot = projectRoot self.registry = registry } + var keepsSuspendedAgentsOnNextOpen = false func startWatching() {} func stopWatching() {} func agent(byId id: String) -> AgentModel? { allAgents.first { $0.id == id } } diff --git a/crates/pu-cli/src/output/mod.rs b/crates/pu-cli/src/output/mod.rs index b002b79f..d3f9a291 100644 --- a/crates/pu-cli/src/output/mod.rs +++ b/crates/pu-cli/src/output/mod.rs @@ -117,8 +117,9 @@ pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliErr project_root, removed, suspended, + killed, } => { - system::print_project_removed(project_root, *removed, suspended); + system::print_project_removed(project_root, *removed, suspended, killed); } Response::ProjectsSubscribed => { println!("Projects subscription active"); diff --git a/crates/pu-cli/src/output/system.rs b/crates/pu-cli/src/output/system.rs index b09faa09..244614d3 100644 --- a/crates/pu-cli/src/output/system.rs +++ b/crates/pu-cli/src/output/system.rs @@ -24,7 +24,12 @@ pub(crate) fn print_init_result(created: bool) { } } -pub(crate) fn print_project_removed(project_root: &str, removed: bool, suspended: &[String]) { +pub(crate) fn print_project_removed( + project_root: &str, + removed: bool, + suspended: &[String], + killed: &[String], +) { if !removed { println!("{project_root} was not a registered project"); return; @@ -36,6 +41,12 @@ pub(crate) fn print_project_removed(project_root: &str, removed: bool, suspended suspended.len() ); } + if !killed.is_empty() { + println!( + " Stopped {} session(s) the project did not list", + killed.len() + ); + } println!(" Files, including .pu/, were not changed"); } diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index e5db6b2e..330d4225 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -522,7 +522,11 @@ pub enum Response { project_root: String, /// False when the root was not registered. removed: bool, + /// Agents suspended so they resume when the project is added again. suspended: Vec, + /// Live sessions the manifest did not list, which cannot be resumed. + #[serde(default)] + killed: Vec, }, ProjectsSubscribed, ProjectsChanged { diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index 80bd9481..dcd0d60e 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -27,17 +27,11 @@ impl Engine { } let mut engine = Self::new(); // `/`, `$HOME` and its ancestors are never projects, whatever an older - // daemon recorded; drop them from the registry on disk too. So are - // folders whose manifest older clients deleted to remove the project: - // clients that follow the registry must not open them again. + // daemon recorded; drop them from the registry on disk too. let count = projects.len(); let projects: Vec = projects .into_iter() - .filter(|root| { - let path = Path::new(root); - !is_too_broad_project_root(path, engine.home.as_deref()) - && !was_removed(path, &manifest::read_manifest(path)) - }) + .filter(|root| !is_too_broad_project_root(Path::new(root), engine.home.as_deref())) .collect(); if projects.len() != count && let Err(e) = write_registry(&path, &projects) @@ -46,9 +40,55 @@ impl Engine { } engine.project_registry_path = Some(path); *engine.registered_projects.lock().unwrap() = projects.into_iter().collect(); + // Until `start_registry_prune` has run, the project stream waits: a client + // following it must not open a project an older client removed. + engine.registry_ready.send_replace(false); Ok(engine) } + /// Drops, in the background, registered folders whose manifest older clients + /// deleted to remove the project. Reads are bounded so a stale network mount + /// delays nothing but itself; the project stream opens once this finishes. + pub fn start_registry_prune(self: &std::sync::Arc) { + let engine = std::sync::Arc::clone(self); + tokio::spawn(async move { engine.prune_removed_projects().await }); + } + + pub(crate) async fn prune_removed_projects(&self) { + let mut reads = tokio::task::JoinSet::new(); + for root in self.registered_projects() { + reads.spawn(async move { + let path = PathBuf::from(&root); + let read = tokio::task::spawn_blocking(move || { + was_removed(&path, &manifest::read_manifest(&path)) + }); + let removed = matches!( + tokio::time::timeout(Duration::from_secs(2), read).await, + Ok(Ok(true)) + ); + removed.then_some(root) + }); + } + let mut removed = Vec::new(); + while let Some(result) = reads.join_next().await { + if let Ok(Some(root)) = result { + removed.push(root); + } + } + if !removed.is_empty() + && let Err(e) = self.unregister_projects(removed, true).await + { + tracing::warn!("failed to drop removed projects from the registry: {e}"); + } + self.registry_ready.send_replace(true); + } + + /// Resolves once registered projects removed by older clients are dropped. + pub(crate) async fn registry_ready(&self) { + let mut ready = self.registry_ready.subscribe(); + let _ = ready.wait_for(|ready| *ready).await; + } + /// Registers an initialized project, except one too broad to be a project /// (`/`, `$HOME` or an ancestor of it), which is skipped without error. /// Only `Init` registers: using a project never adds it back. @@ -175,7 +215,7 @@ impl Engine { }) .map(|(id, _)| id.clone()) .collect(); - let mut suspended = Vec::new(); + let (mut suspended, mut killed) = (Vec::new(), Vec::new()); if !live.is_empty() { if !suspend_agents { return Self::inventory_error( @@ -200,7 +240,7 @@ impl Engine { .collect(); if !rest.is_empty() { self.kill_agents(&rest).await; - suspended.extend(rest); + killed = rest; } } match self.unregister_projects(vec![root.clone()], false).await { @@ -208,6 +248,7 @@ impl Engine { removed: !removed.is_empty(), project_root: root, suspended, + killed, }, Err(e) => Self::inventory_error("IO_ERROR", e.to_string()), } @@ -331,6 +372,7 @@ impl Engine { } } manifests.sort_by(|a, b| a.0.cmp(&b.0)); + let registered: HashSet = self.registered_projects().into_iter().collect(); let sessions = self.sessions.lock().await; let mut observed_sessions = HashSet::new(); let mut removed = Vec::new(); @@ -472,7 +514,12 @@ impl Engine { { &mut project.counts } else { - report.complete = false; + // A registered project whose manifest could not be read is + // missing from the report. An agent in a folder that is not + // a registered project counts as standalone. + if registered.contains(root) { + report.complete = false; + } &mut standalone } } else { @@ -1208,7 +1255,8 @@ mod tests { } #[tokio::test] - async fn given_registry_listing_a_project_removed_by_an_older_client_should_drop_it_on_load() { + async fn given_registry_listing_a_project_removed_by_an_older_client_should_drop_it_after_load() + { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("registry.json"); let engine = Engine::with_project_registry(path.clone()).unwrap(); @@ -1223,6 +1271,7 @@ mod tests { std::fs::write(&path, serde_json::to_vec(&stored).unwrap()).unwrap(); let engine = Engine::with_project_registry(path.clone()).unwrap(); + engine.prune_removed_projects().await; let mut expected = vec![kept, unmounted]; expected.sort(); assert_eq!(engine.registered_projects(), expected); @@ -1386,4 +1435,55 @@ mod tests { ); engine.kill_all_sessions(Duration::from_millis(10)).await; } + + #[tokio::test] + async fn given_agent_in_an_unregistered_folder_should_count_as_standalone_without_partial_inventory() + { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp + .path() + .join("project") + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + engine + .unregister_projects(vec![root.clone()], false) + .await + .unwrap(); + let report = inventory(&engine, InventoryKind::Agents).await; + assert!( + report.complete, + "an unregistered folder is not a missing project" + ); + assert!(report.projects.is_empty()); + assert_eq!(report.summary.running, 1); + assert!(report.agents.iter().any(|a| a.id == id)); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_loaded_registry_should_hold_project_followers_until_removed_projects_are_dropped() + { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let wait = Duration::from_millis(50); + assert!( + tokio::time::timeout(wait, engine.registry_ready()) + .await + .is_err() + ); + engine.prune_removed_projects().await; + assert!( + tokio::time::timeout(wait, engine.registry_ready()) + .await + .is_ok() + ); + // An in-memory engine has nothing to prune. + assert!( + tokio::time::timeout(wait, Engine::new().registry_ready()) + .await + .is_ok() + ); + } } diff --git a/crates/pu-engine/src/engine/mod.rs b/crates/pu-engine/src/engine/mod.rs index 3a47f1a1..884997cb 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -93,6 +93,8 @@ pub struct Engine { registered_projects: Arc>>, /// Signalled whenever a project is added to or removed from the registry. projects_changed: tokio::sync::broadcast::Sender<()>, + /// False from loading a registry until its removed projects are dropped. + registry_ready: tokio::sync::watch::Sender, /// Agents with a resume in progress, so two resumes of one agent can never /// both spawn `claude --resume` on the same transcript. resuming: Arc>>, @@ -207,6 +209,7 @@ impl Engine { status_channels: Arc::new(Mutex::new(HashMap::new())), registered_projects: Arc::new(std::sync::Mutex::new(HashSet::new())), projects_changed: tokio::sync::broadcast::channel(16).0, + registry_ready: tokio::sync::watch::Sender::new(true), resuming: Arc::new(std::sync::Mutex::new(HashSet::new())), shutdown_killed: Arc::new(std::sync::Mutex::new(HashSet::new())), } diff --git a/crates/pu-engine/src/ipc_server/streams.rs b/crates/pu-engine/src/ipc_server/streams.rs index eb46cfa4..c4f3fa09 100644 --- a/crates/pu-engine/src/ipc_server/streams.rs +++ b/crates/pu-engine/src/ipc_server/streams.rs @@ -324,6 +324,7 @@ pub(super) async fn handle_projects_stream( ) -> bool { // Subscribe before the first send so no change between them is missed. let mut rx = engine.subscribe_projects(); + engine.registry_ready().await; if !send_projects(writer, engine).await { return true; } diff --git a/crates/pu-engine/src/main.rs b/crates/pu-engine/src/main.rs index 646f54bd..52ea35c6 100644 --- a/crates/pu-engine/src/main.rs +++ b/crates/pu-engine/src/main.rs @@ -86,6 +86,9 @@ async fn main() { } }; + // Drop projects older clients removed by deleting their manifest + server.engine().start_registry_prune(); + // Start background reaper for dead sessions and orphaned channels server.engine().start_session_reaper(); diff --git a/docs/product/daemon.md b/docs/product/daemon.md index 3c2fe5e9..da705322 100644 --- a/docs/product/daemon.md +++ b/docs/product/daemon.md @@ -23,7 +23,7 @@ Daemon (pu-engine binary) **Single daemon, per-project state keyed by project root.** The daemon is a single process serving all projects. Each request includes a `project_root` parameter to scope operations. `Engine` maintains per-project state internally. `Request::Init { project_root }` registers a project; subsequent `Spawn`/`Status`/`Kill` requests reference it. `Response::HealthReport` includes a `projects: Vec` listing all registered project roots and `agent_count: usize` across all projects. -**Global inventory (implemented 2026-10-10):** Production startup loads canonical project roots from `.projects.json`; successful Init and validated project use register roots atomically. Alternate sockets have independent registries. Invalid registrations do not enter the set, unavailable registered projects remain visible with errors, and restart does not spawn agents. A derived Inventory query combines manifests with live ownership metadata; no duplicate global agent database is stored. Global summary and flat agent/worktree records share count semantics, and Health now returns known projects and the count of live session handles rather than all retained handles. In-memory Engine::new remains available for isolated embedding/tests. +**Global inventory (implemented 2026-10-10):** Production startup loads canonical project roots from `.projects.json`; successful Init registers roots atomically (see explicit project membership below). Alternate sockets have independent registries. Invalid registrations do not enter the set, unavailable registered projects remain visible with errors, and restart does not spawn agents. A derived Inventory query combines manifests with live ownership metadata; no duplicate global agent database is stored. Global summary and flat agent/worktree records share count semantics, and Health now returns known projects and the count of live session handles rather than all retained handles. In-memory Engine::new remains available for isolated embedding/tests. **Explicit project membership (implemented 2026-10-21, protocol 13):** The registry is each machine's one project list, and only `Init` adds to it and `RemoveProject` removes from it. Using a project (status, spawn, subscriptions) no longer registers it — that made a project removed on one device reappear as soon as another device still watching it sent a request. Removal keeps the project's files and `.pu/` so adding it again restores it; live agents block removal unless the caller asks to suspend them. `SubscribeProjects` pushes the full list on every change, and the macOS app's sidebar, each machine's project list and the phone bridge all follow the registry instead of keeping their own lists. Registered roots whose folder exists without a manifest (removed by clients that deleted `.pu/manifest.json`) are dropped by the next global inventory. From c92d33e26b7c096e8d2d53a3e4d5fa875ac2cc2f Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Wed, 21 Oct 2026 23:53:49 -0500 Subject: [PATCH 3/7] docs(ipc): document killed sessions and the deferred registry prune Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- docs/reference/ipc-api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 9fab07a9..9104e284 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -109,7 +109,9 @@ Forget a project on this daemon, for every client. Its files, including `.pu/`, **Request**: `{"type": "remove_project", "project_root": "/path/to/project", "suspend_agents": true}` -**Response**: `{"type": "project_removed", "project_root": "/path/to/project", "removed": true, "suspended": ["ag-abc"]}` +**Response**: `{"type": "project_removed", "project_root": "/path/to/project", "removed": true, "suspended": ["ag-abc"], "killed": []}` + +`killed` lists live sessions in the folder that the manifest did not record; they are stopped and cannot be resumed. A daemon loading its registry drops, in the background, registered folders that exist without a manifest; `subscribe_projects` sends its first list only after that. ### Shutdown From d3ab290623f504403a6a2661610ea13937f28024 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:03:48 -0500 Subject: [PATCH 4/7] fix(projects): address CodeRabbit and second review findings CLI: - `pu projects remove` without a path uses --project, then the current directory. An inherited PU_PROJECT_ROOT no longer chooses a project to remove for every device. - Remote roots must be absolute after the final path is chosen, including one given with --project. Daemon: - remove_project refuses when suspending fails on a readable project, instead of killing agents it would have suspended. Only a missing manifest falls back to stopping sessions, which are reported as killed. - IpcServer::bind starts the registry prune, so a served registry always opens the projects stream; the final step is bounded. macOS: - Re-registering a Point Guard folder removed elsewhere keeps the agents that removal suspended paused. - Resuming last session's agents depends on whether a legacy entry was actually imported, not just present. - The keep-paused flag is consumed when an open starts, so a failed open can't leave it set. Removal warnings on the phone, in the contract and in the bridge now say that a session the project does not record is stopped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- .../purepoint-macos/State/AppState.swift | 7 +- .../purepoint-macos/State/ProjectState.swift | 9 ++- .../PointGuardRoutingTests.swift | 14 ++++ .../PurePoint/AgentsView.swift | 2 +- apps/purepoint-mobile/bridge/workspace.js | 3 +- apps/purepoint-mobile/docs/contract.md | 2 +- crates/pu-cli/src/commands/projects.rs | 29 +++++++-- crates/pu-cli/tests/global_inventory.rs | 21 ++++++ crates/pu-engine/src/engine/inventory.rs | 64 +++++++++++++++++-- crates/pu-engine/src/ipc_server/mod.rs | 5 +- crates/pu-engine/src/ipc_server/tests.rs | 4 +- crates/pu-engine/src/main.rs | 3 - 12 files changed, 135 insertions(+), 28 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index ea81752b..ee98f1ed 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -358,7 +358,6 @@ final class AppState { // project with the daemon, and then deleted. let paths = projectRoot.map { [$0] } ?? UserDefaults.standard.stringArray(forKey: Self.openProjectsKey) ?? [] - resumesOnFirstLocalList = paths.isEmpty // Opening projects persists current state, so snapshot the saved lists first. let savedPointGuardFolders = UserDefaults.standard.stringArray(forKey: "PurePointPointGuardFolders") ?? [] pointGuardFolders.formUnion(savedPointGuardFolders) @@ -374,6 +373,8 @@ final class AppState { guard !isTooBroadProjectRoot(path) else { continue } openProject(path) } + // The first list reopens last session's sidebar, unless that came from the legacy list. + resumesOnFirstLocalList = paths.count == notImported.count if projectRoot == nil { if notImported.isEmpty { UserDefaults.standard.removeObject(forKey: Self.openProjectsKey) @@ -420,7 +421,9 @@ final class AppState { if let owner = projectState(forRoot: canonical[root] ?? root) ?? pointGuardProjects.first(where: { Self.canonicalPath($0.key) == root })?.value, isPointGuardFolder(owner.projectRoot) { - // Removed elsewhere, but Point Guard works in this folder: register it again. + // Removed elsewhere, but Point Guard works in this folder: register it again, + // leaving the agents the removal suspended paused. + owner.keepsSuspendedAgentsOnNextOpen = true startProject(owner) } else if let open = canonical[root] { closeProject(open) diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index e930faae..bd1a0279 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -132,6 +132,9 @@ final class ProjectState: Identifiable { let root = projectRoot let svc = service let client = self.client + // One open only, whether or not it succeeds. + let keepsSuspendedAgents = keepsSuspendedAgentsOnNextOpen + keepsSuspendedAgentsOnNextOpen = false openTask?.cancel() refreshTask?.cancel() @@ -197,11 +200,7 @@ final class ProjectState: Identifiable { self.appState?.daemonError = error.localizedDescription return } - if self.keepsSuspendedAgentsOnNextOpen { - self.keepsSuspendedAgentsOnNextOpen = false - } else { - self.resumeSuspendedAgents() - } + if !keepsSuspendedAgents { self.resumeSuspendedAgents() } } #else startWatchingRemote() diff --git a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift index d1f653b5..e5464162 100644 --- a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift @@ -298,6 +298,18 @@ private struct PointGuardRoutingService: WorkspaceService { } } + @Test func legacyEntryOnAnUnmountedVolumeStillResumesLastSession() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root + "-project", withIntermediateDirectories: true) + UserDefaults.standard.set([root + "-unmounted"], forKey: "PurePointOpenProjects") + state.restoreProjects() + #expect(UserDefaults.standard.stringArray(forKey: "PurePointOpenProjects") == [root + "-unmounted"]) + let project = AppState.canonicalPath(root + "-project") + state.applyLocalProjects([project]) + #expect(state.projectState(forRoot: project)?.keepsSuspendedAgentsOnNextOpen == false) + } + } + @Test func pointGuardFolderRemovedElsewhereIsRegisteredAgain() throws { var started: [String] = [] let root = NSTemporaryDirectory() + "point-guard-reregister-" + UUID().uuidString @@ -320,5 +332,7 @@ private struct PointGuardRoutingService: WorkspaceService { state.applyLocalProjects([]) #expect(started == [root]) #expect(state.projects.map(\.projectRoot) == [root]) + // The agents the removal suspended stay paused. + #expect(state.projectState(forRoot: root)?.keepsSuspendedAgentsOnNextOpen == true) } } diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index 4e025f8c..e7d65030 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -561,7 +561,7 @@ struct HomeView: View { removing = nil } } message: { - Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Running agents are suspended and resume when the project is added again. Files, including .pu/, are not changed." } ?? "") + Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Its agents are suspended and resume when the project is added again; a session the project does not record is stopped. Files, including .pu/, are not changed." } ?? "") } .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil; removing = nil } } diff --git a/apps/purepoint-mobile/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index b568a52d..924c84af 100644 --- a/apps/purepoint-mobile/bridge/workspace.js +++ b/apps/purepoint-mobile/bridge/workspace.js @@ -358,7 +358,8 @@ export function workspaceConnection({ check(); if (version < 13) fail("unsupported", "Update the machine's daemon to remove projects"); - // Running agents are suspended, not killed: they resume if the project is added again. + // The project's agents are suspended so they resume if it is added again; only a + // session its manifest does not record is stopped (reported as `killed`). ipc = { type: "remove_project", project_root: project.id, diff --git a/apps/purepoint-mobile/docs/contract.md b/apps/purepoint-mobile/docs/contract.md index e481958f..d46b692c 100644 --- a/apps/purepoint-mobile/docs/contract.md +++ b/apps/purepoint-mobile/docs/contract.md @@ -209,7 +209,7 @@ Management fields are strict allowlists. Common `{operationId:<8..128 ASCII alnu - `workspace.terminal`: common + `{target:"root"|"worktree",worktreeId?:string}`. Uses scoped Spawn terminal without command, ensuring manifest association. Result detail `{agentId,worktreeId:string|null}`. - `workspace.rename`: common + `{agentId,name}`. Membership must match exact project. Result detail `{agentId}`. - `workspace.stop`: common + `{agentId,confirm:true}`. Only Kill Agent target. Result detail `{agentId}`. -- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; running agents are suspended and the folder, including `.pu/`, is kept. Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later (`workspace_unsupported` otherwise; the phone disables the action below 13). Result detail `{}`. +- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; the project's running agents are suspended (they resume when it is added again), any live session the manifest does not record is stopped and cannot be resumed, and the folder, including `.pu/`, is kept. Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later (`workspace_unsupported` otherwise; the phone disables the action below 13). Result detail `{}`. Mutation result `{operationId,state:"succeeded"|"failed"|"uncertain",detail?:,message?:string}`. `workspace.operation {operationId}` is read-authorized and returns `{operationId,state:"pending"|"succeeded"|"failed"|"uncertain"|"unknown",detail?,message?}`. No mutation events beyond existing permissions; clients explicitly refresh status and agents. Errors before dispatch are failed; errors after dispatch conservatively uncertain (even daemon errors can follow a committed spawn). Duplicate pending ID rejected; identical completed ID returns ledger status without dispatch; different payload same ID rejected. Ledger retains max 2048 operations for bridge process lifetime, does not evict pending; full ledger rejects new mutations. Restart loses ledger: unknown NEVER means safe to retry. Inventory refresh may locate committed work but absent inventory is not proof of non-commit. User must review authoritative inventory before explicitly initiating any new operation. No automatic replay/retry on reconnect or timeout. A committed-but-unacknowledged operation stays uncertain; no rollback claimed. diff --git a/crates/pu-cli/src/commands/projects.rs b/crates/pu-cli/src/commands/projects.rs index 2561ea3e..4d36d1a7 100644 --- a/crates/pu-cli/src/commands/projects.rs +++ b/crates/pu-cli/src/commands/projects.rs @@ -11,7 +11,7 @@ pub async fn run(socket: &Path, action: ProjectsAction) -> Result<(), CliError> crate::commands::inventory::run(socket, InventoryKind::Projects, None, json).await } ProjectsAction::Add { path, json } => { - let root = resolve(path)?; + let root = resolve(path, Default::Init)?; if crate::machine_target::remote_machine().is_none() { // Locally, adding is `pu init`: it also writes the agent context file. return crate::commands::init::run_at(socket, root, json).await; @@ -23,7 +23,7 @@ pub async fn run(socket: &Path, action: ProjectsAction) -> Result<(), CliError> suspend_agents, json, } => { - let project_root = resolve(path)?; + let project_root = resolve(path, Default::Explicit)?; send( socket, Request::RemoveProject { @@ -44,11 +44,28 @@ async fn send(socket: &Path, request: Request, json: bool) -> Result<(), CliErro output::print_response(&response, json) } +/// Where a command without a path argument looks for its project. +enum Default { + /// As `pu init`: --project, then PU_PROJECT_ROOT (set for agents), then cwd. + Init, + /// --project, then cwd. Removal ignores an inherited PU_PROJECT_ROOT, so it + /// never removes a project for every device that the caller did not name. + Explicit, +} + /// A path argument wins over --project and the current directory. On another -/// machine it must be absolute: a local relative path means nothing there. -fn resolve(path: Option) -> Result { - let Some(path) = path else { - return crate::commands::project_root_string(); +/// machine the root must be absolute: a local relative path means nothing there. +fn resolve(path: Option, default: Default) -> Result { + let path = match (path, default) { + (Some(path), _) => path, + (None, Default::Init) => crate::commands::project_root_string()?, + (None, Default::Explicit) => match crate::commands::explicit_project() { + Some(root) => root, + None => match crate::machine_target::remote_machine() { + Some(machine) => return Err(crate::commands::remote_project_required(machine)), + None => crate::commands::cwd_string()?, + }, + }, }; if crate::machine_target::remote_machine().is_some() { if !Path::new(&path).is_absolute() { diff --git a/crates/pu-cli/tests/global_inventory.rs b/crates/pu-cli/tests/global_inventory.rs index 01e556c0..6e38a1d5 100644 --- a/crates/pu-cli/tests/global_inventory.rs +++ b/crates/pu-cli/tests/global_inventory.rs @@ -363,6 +363,27 @@ async fn given_projects_add_and_remove_should_change_what_every_client_lists() { let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; assert_eq!(listed["projects"][0]["project_root"], root_s); + // An agent's inherited PU_PROJECT_ROOT never chooses what to remove. + let inherited = tokio::process::Command::new(env!("CARGO_BIN_EXE_pu")) + .arg("--socket") + .arg(&socket) + .args(["projects", "remove", "--json"]) + .current_dir(tmp.path()) + .env("PU_PROJECT_ROOT", root_s) + .env_remove("PU_AGENT_ID") + .output() + .await + .unwrap(); + assert!( + inherited.status.success(), + "{}", + String::from_utf8_lossy(&inherited.stderr) + ); + let inherited: serde_json::Value = serde_json::from_slice(&inherited.stdout).unwrap(); + assert_eq!(inherited["removed"], false); + let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; + assert_eq!(listed["projects"][0]["project_root"], root_s); + let removed = json( &socket, tmp.path(), diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index dcd0d60e..b0d465a9 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -49,7 +49,11 @@ impl Engine { /// Drops, in the background, registered folders whose manifest older clients /// deleted to remove the project. Reads are bounded so a stale network mount /// delays nothing but itself; the project stream opens once this finishes. + /// Does nothing for an engine without a loaded registry. pub fn start_registry_prune(self: &std::sync::Arc) { + if *self.registry_ready.borrow() { + return; + } let engine = std::sync::Arc::clone(self); tokio::spawn(async move { engine.prune_removed_projects().await }); } @@ -75,10 +79,17 @@ impl Engine { removed.push(root); } } - if !removed.is_empty() - && let Err(e) = self.unregister_projects(removed, true).await + // The removal re-reads each manifest under the update lock; a mount that went + // stale since must not keep the projects stream closed. + match tokio::time::timeout( + Duration::from_secs(5), + self.unregister_projects(removed, true), + ) + .await { - tracing::warn!("failed to drop removed projects from the registry: {e}"); + Ok(Ok(_)) => {} + Ok(Err(e)) => tracing::warn!("failed to drop removed projects from the registry: {e}"), + Err(_) => tracing::warn!("dropping removed projects from the registry timed out"), } self.registry_ready.send_replace(true); } @@ -227,10 +238,12 @@ impl Engine { ), ); } - if let Response::SuspendResult { suspended: ids } = - self.handle_suspend(&root, SuspendTarget::All).await - { - suspended = ids; + match self.handle_suspend(&root, SuspendTarget::All).await { + Response::SuspendResult { suspended: ids } => suspended = ids, + // Without a manifest nothing can be suspended; its sessions are stopped below. + Response::Error { code, .. } if code == "NOT_INITIALIZED" => {} + // Never kill agents a readable project would have suspended. + error => return error, } // Sessions the manifest does not list (or a missing manifest) still // belong to the project; nothing may keep running in a removed one. @@ -1486,4 +1499,41 @@ mod tests { .is_ok() ); } + + #[tokio::test] + async fn given_unreadable_manifest_should_refuse_removal_rather_than_kill_suspendable_agents() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp.path().join("project").canonicalize().unwrap(); + std::fs::write(pu_core::paths::manifest_path(&root), b"corrupt").unwrap(); + let root = root.to_string_lossy().into_owned(); + let response = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: true, + }) + .await; + assert!(matches!(response, Response::Error { .. }), "{response:?}"); + assert_eq!(engine.registered_projects(), vec![root]); + assert!(engine.sessions.lock().await.contains_key(&id)); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_missing_manifest_should_stop_live_sessions_and_report_them_killed() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp.path().join("project").canonicalize().unwrap(); + std::fs::remove_file(pu_core::paths::manifest_path(&root)).unwrap(); + let response = engine + .handle_request(Request::RemoveProject { + project_root: root.to_string_lossy().into_owned(), + suspend_agents: true, + }) + .await; + assert!( + matches!(&response, Response::ProjectRemoved { removed: true, suspended, killed, .. } + if suspended.is_empty() && *killed == vec![id.clone()]), + "{response:?}" + ); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } } diff --git a/crates/pu-engine/src/ipc_server/mod.rs b/crates/pu-engine/src/ipc_server/mod.rs index c92ad63b..a5dc7a11 100644 --- a/crates/pu-engine/src/ipc_server/mod.rs +++ b/crates/pu-engine/src/ipc_server/mod.rs @@ -146,9 +146,12 @@ impl IpcServer { // Owner-only whatever the umask. Connections accepted before this are // still held to the peer check in `run`. std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?; + let engine = Arc::new(engine); + // Every served registry is pruned, so the projects stream always opens. + engine.start_registry_prune(); Ok(Self { listener, - engine: Arc::new(engine), + engine, shutdown: Arc::new(Notify::new()), conn_limit: Arc::new(Semaphore::new(MAX_CONNECTIONS)), first_line_timeout: FIRST_LINE_TIMEOUT, diff --git a/crates/pu-engine/src/ipc_server/tests.rs b/crates/pu-engine/src/ipc_server/tests.rs index d44f5a8a..436a8219 100644 --- a/crates/pu-engine/src/ipc_server/tests.rs +++ b/crates/pu-engine/src/ipc_server/tests.rs @@ -629,7 +629,9 @@ async fn given_subscribe_projects_should_push_the_list_on_add_and_remove() { let project_root = tmp.path().canonicalize().unwrap().join("project"); std::fs::create_dir_all(&project_root).unwrap(); let root = project_root.to_string_lossy().into_owned(); - let server = IpcServer::bind(&sock_path, Engine::new()).unwrap(); + // A loaded registry is pruned once served, which opens the stream. + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let server = IpcServer::bind(&sock_path, engine).unwrap(); let handle = tokio::spawn(async move { server.run().await }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; diff --git a/crates/pu-engine/src/main.rs b/crates/pu-engine/src/main.rs index 52ea35c6..646f54bd 100644 --- a/crates/pu-engine/src/main.rs +++ b/crates/pu-engine/src/main.rs @@ -86,9 +86,6 @@ async fn main() { } }; - // Drop projects older clients removed by deleting their manifest - server.engine().start_registry_prune(); - // Start background reaper for dead sessions and orphaned channels server.engine().start_session_reaper(); From 2f1cc2aae5369a91844dc699efe50017945400d9 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:20:03 -0500 Subject: [PATCH 5/7] refactor(projects): simplify registry membership after cleanup review - Inventory is read-only again. Folders removed by older clients are only dropped by the startup prune, which removes the (bool, String) error plumbing. The prune re-check stats the manifest instead of parsing it, `unregister_projects` takes a named mode, and the projects stream no longer re-sorts an already sorted list. - Suspended agents "can be resumed" once a project is added again (the Mac resumes them only when it reopens the project itself), so every confirmation, CLI line and doc now says that instead of "resume". - Bridge: the protocol probe for removal sends only `health` instead of a full agent inventory, and the unused `projectRemoval` feature flag is gone. - macOS: one `DaemonClient.removeProject` for local and remote removal, one `AppState.machine(for:)` lookup and one device-name value. Canonical roots are resolved once per root. "No list yet" is derived from `lastLocalProjects == nil`. Fixed a misplaced doc comment. - CLI: rename the path-default enum, which shadowed `Default`, and fix stale help text. - Test: `assert_invalid_bytes_close_stream` sends its two lines in one write, so the daemon closing first can no longer fail it with a broken pipe. It flaked on CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- .../Services/DaemonProjectsSubscription.swift | 8 +- .../purepoint-macos/State/AppState.swift | 64 ++++++++----- .../State/MachineState+OpenProject.swift | 23 +++-- .../purepoint-macos/State/MachineState.swift | 2 +- .../Views/Sidebar/SidebarMenuModel.swift | 39 +++----- .../SidebarOutlineView+Callbacks.swift | 18 ++-- .../SidebarMenuModelTests.swift | 2 +- .../verification/PointGuardRoutingStubs.swift | 1 + .../PurePoint/AgentsView.swift | 2 +- apps/purepoint-mobile/bridge/workspace.js | 23 ++--- .../purepoint-mobile/bridge/workspace.test.js | 6 +- apps/purepoint-mobile/docs/contract.md | 4 +- crates/pu-cli/src/commands/projects.rs | 12 +-- crates/pu-cli/src/main.rs | 2 +- crates/pu-cli/src/output/system.rs | 2 +- crates/pu-core/src/protocol/mod.rs | 2 +- crates/pu-engine/src/engine/inventory.rs | 94 +++++++++---------- crates/pu-engine/src/ipc_server/streams.rs | 3 +- crates/pu-engine/src/ipc_server/tests.rs | 7 +- docs/product/daemon.md | 2 +- docs/reference/ipc-api.md | 4 +- 21 files changed, 167 insertions(+), 153 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift index 936be8e5..47fb4a24 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift @@ -10,25 +10,27 @@ actor DaemonProjectsSubscription { private var stopped = false private let client: DaemonClient + private static let initialBackoff: UInt64 = 500_000_000 // 0.5s + init(client: DaemonClient) { self.client = client } /// Delivers each list of roots until `stop()`. Reconnects with backoff after a failure. func start(onChange: @escaping @MainActor ([String]) -> Void) async { - var backoff: UInt64 = 500_000_000 // 0.5s + var backoff = Self.initialBackoff let maxBackoff: UInt64 = 10_000_000_000 // 10s while !stopped { do { try await run(onChange: onChange) - backoff = 500_000_000 + backoff = Self.initialBackoff } catch is CancellationError { return } catch { #if os(iOS) if await AppActivity.shared.isInBackground { await AppActivity.shared.untilForeground() - backoff = 500_000_000 + backoff = Self.initialBackoff continue } #endif diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index ee98f1ed..b3ee74cb 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -118,15 +118,14 @@ final class AppState { /// Follows the local daemon's registered projects, which decide this Mac's sidebar. @ObservationIgnored private var localProjectsSubscription: DaemonProjectsSubscription? /// The local daemon's projects as last reported, to tell a removal from a project - /// whose `init` has not reached the daemon yet. - @ObservationIgnored private var lastLocalProjects = Set() + /// whose `init` has not reached the daemon yet; nil until the first report. + @ObservationIgnored private var lastLocalProjects: Set? + /// Each opened root's canonical path, resolved once rather than on every report. + @ObservationIgnored private var canonicalRoots: [String: String] = [:] #endif /// Point Guard folders the user also opened as sidebar projects. Other Point Guard /// folders stay out of the sidebar even though the daemon has them registered. @ObservationIgnored private var shownPointGuardFolders = Set() - /// Until the local daemon first reports its projects, a saved workspace selection may - /// belong to one that has not opened yet. - @ObservationIgnored private var awaitingLocalProjects = false /// Whether the first local project list reopens last session's sidebar, whose agents /// this app suspended on quit and resumes. Projects that join later keep theirs paused. @ObservationIgnored private var resumesOnFirstLocalList = false @@ -310,23 +309,16 @@ final class AppState { /// Mac's sidebar, since Point Guard needs it registered. func removeProject(_ project: ProjectState) async throws { if project.isRemote { - guard let machine = machines.first(where: { $0.machine.id == project.key.machineId }) else { return } + guard let machine = machine(for: project) else { return } try await machine.removeProject(root: project.key.root, suspendAgents: true) if activeProjectRoot == project.projectId { activeProjectRoot = projects.first?.projectRoot } return } let root = project.projectRoot if !isPointGuardFolder(root) { - let client = DaemonEndpoint.local.makeClient() // The daemon registered the canonical path; a symlinked one may name a deleted folder. - switch try await client.send(.removeProject(projectRoot: Self.canonicalPath(root), suspendAgents: true)) { - case .projectRemoved: - break - case .error(_, let message): - throw MachineOpenProjectError(message: message) - default: - throw MachineOpenProjectError(message: "The daemon did not remove \(root).") - } + try await DaemonEndpoint.local.makeClient().removeProject( + root: Self.canonicalPath(root), suspendAgents: true, daemonName: "The daemon") } closeProject(root) } @@ -399,7 +391,6 @@ final class AppState { guard localProjectsSubscription == nil else { return } let subscription = DaemonProjectsSubscription(client: DaemonEndpoint.local.makeClient()) localProjectsSubscription = subscription - awaitingLocalProjects = true Task { // The daemon holds the project list, so it runs even before any project opens. try? await DaemonLifecycle.ensureDaemon() @@ -416,11 +407,13 @@ final class AppState { let registered = Set(roots) // The daemon reports canonical roots; a project may have been opened by another path. let canonical = Dictionary( - projects.map { (Self.canonicalPath($0.projectRoot), $0.projectRoot) }, uniquingKeysWith: { first, _ in first }) - for root in lastLocalProjects.subtracting(registered) { - if let owner = projectState(forRoot: canonical[root] ?? root) ?? pointGuardProjects.first(where: { - Self.canonicalPath($0.key) == root - })?.value, isPointGuardFolder(owner.projectRoot) { + projects.map { (canonicalRoot(of: $0.projectRoot), $0.projectRoot) }, uniquingKeysWith: { first, _ in first }) + let pointGuard = Dictionary( + pointGuardProjects.map { (canonicalRoot(of: $0.key), $0.value) }, uniquingKeysWith: { first, _ in first }) + for root in (lastLocalProjects ?? []).subtracting(registered) { + if let owner = canonical[root].flatMap({ projectState(forRoot: $0) }) ?? pointGuard[root], + isPointGuardFolder(owner.projectRoot) + { // Removed elsewhere, but Point Guard works in this folder: register it again, // leaving the agents the removal suspended paused. owner.keepsSuspendedAgentsOnNextOpen = true @@ -431,17 +424,40 @@ final class AppState { } let resuming = resumesOnFirstLocalList resumesOnFirstLocalList = false - let hidden = Set(pointGuardProjects.keys.filter { !shownPointGuardFolders.contains($0) }.map(Self.canonicalPath)) for root in roots where canonical[root] == nil { + // A Point Guard folder the user has not opened stays out of the sidebar. + if let owner = pointGuard[root], !shownPointGuardFolders.contains(owner.projectRoot) { continue } guard FileManager.default.fileExists(atPath: root), !isTooBroadProjectRoot(root) else { continue } - if hidden.contains(root) { continue } openProject(root, resumingSuspendedAgents: resuming) } lastLocalProjects = registered - awaitingLocalProjects = false #endif } + #if os(macOS) + private func canonicalRoot(of root: String) -> String { + if let known = canonicalRoots[root] { return known } + let resolved = Self.canonicalPath(root) + canonicalRoots[root] = resolved + return resolved + } + #endif + + /// Until the local daemon first reports its projects, a saved workspace selection may + /// belong to one that has not opened yet. + private var awaitingLocalProjects: Bool { + #if os(macOS) + localProjectsSubscription != nil && lastLocalProjects == nil + #else + false + #endif + } + + /// The machine a remote project lives on; nil for a local one. + func machine(for project: ProjectState) -> MachineState? { + machines.first { $0.machine.id == project.key.machineId } + } + /// The path with symlinks resolved, as the daemon's `canonicalize` reports it (unlike /// `URL.resolvingSymlinksInPath`, which drops `/private`). nonisolated static func canonicalPath(_ path: String) -> String { diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift index 47e56987..40680edc 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift @@ -36,20 +36,27 @@ extension MachineState { extension MachineState { /// Remove a project from the machine for every device (`remove_project`). Its folder and /// `.pu/` are kept, so opening it again brings it back as it was. The daemon refuses while - /// agents run unless `suspendAgents`, which suspends them so they resume on the next open. + /// agents run unless `suspendAgents`, which suspends them so they can be resumed once it is added again. func removeProject(root: String, suspendAgents: Bool, client: DaemonClient? = nil) async throws { - let client = client ?? endpoint.makeClient() - switch try await client.send(.removeProject(projectRoot: root, suspendAgents: suspendAgents)) { + try await (client ?? endpoint.makeClient()).removeProject( + root: root, suspendAgents: suspendAgents, daemonName: machine.name) + // Nothing left to hide: a later open shows it again everywhere. + unhideProject(root: root) + if state.isDaemonRunning { await refreshProjects() } + } +} + +extension DaemonClient { + /// Ask this daemon to forget a project (`remove_project`), throwing its refusal. + func removeProject(root: String, suspendAgents: Bool, daemonName: String) async throws { + switch try await send(.removeProject(projectRoot: root, suspendAgents: suspendAgents)) { case .projectRemoved: - break + return case .error(_, let message): throw MachineOpenProjectError(message: message) default: - throw MachineOpenProjectError(message: "\(machine.name) did not remove \(root).") + throw MachineOpenProjectError(message: "\(daemonName) did not remove \(root).") } - // Nothing left to hide: a later open shows it again everywhere. - unhideProject(root: root) - if state.isDaemonRunning { await refreshProjects() } } } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift index 9a8da71a..40d3b105 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -327,7 +327,6 @@ final class MachineState: Identifiable { } } - /// Discover the daemon's registered projects (`inventory`, kind projects, all roots). private func stopRefreshing() { refreshTask?.cancel() refreshTask = nil @@ -336,6 +335,7 @@ final class MachineState: Identifiable { Task { await subscription?.stop() } } + /// Discover the daemon's registered projects (`inventory`, kind projects, all roots). func refreshProjects() async { let client = endpoint.makeClient() let response: DaemonResponse diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift index 971920d2..f0233f4d 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift @@ -128,7 +128,7 @@ enum SidebarMenus { } if project.isRemote { // Hiding is this device's choice; removing changes the machine for every device. - entries.append(.action("Hide on \(deviceTitle)\u{2026}", .hideProject)) + entries.append(.action("Hide on This \(sidebarDevice)\u{2026}", .hideProject)) entries.append(.action("Remove from \(machineName(of: project))\u{2026}", .removeProject)) } else { entries.append(.action("Remove Project\u{2026}", .removeProject)) @@ -136,19 +136,20 @@ enum SidebarMenus { return entries } - private static var deviceTitle: String { - #if os(macOS) - "This Mac" - #else - "This iPad" - #endif - } - static func machineName(of project: ProjectState) -> String { - project.appState?.machines.first { $0.machine.id == project.key.machineId }?.machine.name ?? "Machine" + project.appState?.machine(for: project)?.machine.name ?? "Machine" } } +/// The device a hidden project stays hidden on. +private var sidebarDevice: String { + #if os(macOS) + "Mac" + #else + "iPad" + #endif +} + // MARK: - Confirmations /// A confirmation a sidebar action asks for before it runs. @@ -213,7 +214,7 @@ extension SidebarConfirmation { static func hideProject(_ project: ProjectState) -> SidebarConfirmation { SidebarConfirmation( title: "Hide \(project.projectName)?", - message: "This hides the project in PurePoint on \(deviceName). Nothing on the remote machine is changed." + message: "This hides the project in PurePoint on this \(sidebarDevice). Nothing on the remote machine is changed." + unsavedWarning(unsavedFileNames(in: project)), confirmTitle: "Hide") } @@ -231,8 +232,8 @@ extension SidebarConfirmation { aliveCount == 0 ? "" : " Its \(aliveCount) running agent" - + (aliveCount == 1 ? " is suspended and resumes" : "s are suspended and resume") - + " when the project is added again." + + (aliveCount == 1 ? " is suspended" : "s are suspended") + + " and can be resumed once the project is added again." return SidebarConfirmation( title: machine.map { "Remove \(project.projectName) from \($0)?" } ?? "Remove \(project.projectName)?", message: "It disappears from PurePoint on \(machine ?? "this Mac") and every device connected to it." @@ -243,17 +244,7 @@ extension SidebarConfirmation { private static func unsavedFileNames(in project: ProjectState) -> [String] { guard project.isRemote else { return [] } - return project.appState?.machines.first { $0.machine.id == project.key.machineId }? - .unsavedFileNames(in: project) ?? [] - } - - /// The device a hidden project stays hidden on. - private static var deviceName: String { - #if os(macOS) - "this Mac" - #else - "this iPad" - #endif + return project.appState?.machine(for: project)?.unsavedFileNames(in: project) ?? [] } /// Closing a remote project drops its file tabs, edits included. diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift index e633bd43..9b0fe1a7 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift @@ -80,25 +80,27 @@ extension SidebarOutlineView { project.killAllAgents() } - // Hiding is this device's choice: the project and its agents stay on the machine. - vc.onHideProject = { [viewCache] project in + let dropTerminalViews = { [viewCache] (project: ProjectState) in for agent in project.allAgents { viewCache.remove(agentId: agent.id) } + } + + // Hiding is this device's choice: the project and its agents stay on the machine. + vc.onHideProject = { project in + dropTerminalViews(project) appState.closeProject(project.projectId) } // The machine's daemon forgets the project, so it leaves every device's sidebar. Its // files, including .pu/, stay: adding the folder again brings the project back. - vc.onRemoveProject = { [viewCache] project in + vc.onRemoveProject = { project in Task { @MainActor in do { try await appState.removeProject(project) } catch { let message = "Could not remove \(project.projectName): \(error.localizedDescription)" - if let machine = appState.machines.first(where: { $0.machine.id == project.key.machineId }), - project.isRemote - { + if project.isRemote, let machine = appState.machine(for: project) { machine.actionError = message } else { appState.daemonError = message @@ -107,9 +109,7 @@ extension SidebarOutlineView { } // Point Guard may still own a local folder; its terminal views stay attached. guard appState.projectState(forRoot: project.projectId) == nil else { return } - for agent in project.allAgents { - viewCache.remove(agentId: agent.id) - } + dropTerminalViews(project) } } diff --git a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift index c6da9fa6..fcc031d4 100644 --- a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift @@ -115,7 +115,7 @@ struct SidebarMenuModelTests { == SidebarConfirmation( title: "Remove confirm from Machine?", message: "It disappears from PurePoint on Machine and every device connected to it." - + " Its 1 running agent is suspended and resumes when the project is added again." + + " Its 1 running agent is suspended and can be resumed once the project is added again." + " The folder, including .pu/, is not changed.", confirmTitle: "Remove")) } diff --git a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift index 69d2cce5..d76f01b9 100644 --- a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift +++ b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift @@ -25,6 +25,7 @@ struct DaemonWorkspaceService: WorkspaceService { struct DaemonClient { init(socketPath: String? = nil) {} func send(_ request: DaemonRequest) async throws -> DaemonResponse { .ok } + func removeProject(root: String, suspendAgents: Bool, daemonName: String) async throws {} } // Project hosts: only the local, file-free surface AppState and persistence name. struct HostFileData { let data: Data; let modifiedMs: Int64; let truncated: Bool } diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index e7d65030..3fe21174 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -561,7 +561,7 @@ struct HomeView: View { removing = nil } } message: { - Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Its agents are suspended and resume when the project is added again; a session the project does not record is stopped. Files, including .pu/, are not changed." } ?? "") + Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Its agents are suspended and can be resumed once the project is added again; a session the project does not record is stopped. Files, including .pu/, are not changed." } ?? "") } .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil; removing = nil } } diff --git a/apps/purepoint-mobile/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index 924c84af..17900834 100644 --- a/apps/purepoint-mobile/bridge/workspace.js +++ b/apps/purepoint-mobile/bridge/workspace.js @@ -5,8 +5,6 @@ export const workspaceFeatures = Object.freeze({ inventory: true, management: true, operationStatus: true, - // Daemons at protocol 13 or later can forget a project for every device. - projectRemoval: true, }); export const workspaceOperations = [ "workspace.catalog", @@ -167,10 +165,7 @@ export function workspaceConnection({ fail("unsupported", "Complete authoritative project metadata required"); const project = metadata.projects.find((p) => p.id === r.projectId); // An unavailable project can still be removed; nothing else runs in one. - if ( - !project || - (!project.available && r.op !== "workspace.removeProject") - ) + if (!project || (!project.available && r.op !== "workspace.removeProject")) fail("target", "Registered available project required"); return { machine, metadata, project }; }; @@ -352,13 +347,19 @@ export function workspaceConnection({ } else if (r.op === "workspace.removeProject") { if (r.confirm !== true) fail("invalid", "Explicit removal confirmation required"); - const { version } = await daemons.inspect(machine, { - signal: abort.signal, - }); + // Health alone carries the version; inspect would also read every manifest. + const health = await daemonRequest( + machine.socket, + { type: "health" }, + { signal: abort.signal }, + ); check(); - if (version < 13) + if ( + !Number.isInteger(health.protocol_version) || + health.protocol_version < 13 + ) fail("unsupported", "Update the machine's daemon to remove projects"); - // The project's agents are suspended so they resume if it is added again; only a + // The project's agents are suspended so they can be resumed once it is added again; only a // session its manifest does not record is stopped (reported as `killed`). ipc = { type: "remove_project", diff --git a/apps/purepoint-mobile/bridge/workspace.test.js b/apps/purepoint-mobile/bridge/workspace.test.js index 31ad5065..adf66bab 100644 --- a/apps/purepoint-mobile/bridge/workspace.test.js +++ b/apps/purepoint-mobile/bridge/workspace.test.js @@ -949,6 +949,10 @@ test("project removal needs confirmation and a daemon that can forget projects f assert.equal(removed.state, "succeeded"); const sent = f.requests.filter((r) => r.type === "remove_project"); assert.deepEqual(sent, [ - { type: "remove_project", project_root: "/registered", suspend_agents: true }, + { + type: "remove_project", + project_root: "/registered", + suspend_agents: true, + }, ]); }); diff --git a/apps/purepoint-mobile/docs/contract.md b/apps/purepoint-mobile/docs/contract.md index d46b692c..637f2054 100644 --- a/apps/purepoint-mobile/docs/contract.md +++ b/apps/purepoint-mobile/docs/contract.md @@ -196,7 +196,7 @@ Events are `{type:terminal.output,streamId,data}` (UTF-8 ANSI), `{type:terminal. Additive to `apps/purepoint-mobile/docs/contract.md`. All requests use existing authenticated WSS envelope `{version:1,id,clientId,op,...}` and receipt `{type:"receipt",id,ok:true,data}` or `{type:"receipt",id,ok:false,error:}`. Request id remains transport correlation; `operationId` below is durable-form identity. No Pi readiness dependency. -Permissions discovery (`permissions` result and `permissions` event): `{terminals:"off"|"view"|"control",workspaceManagement:false,chat:"off"|"read"|"send" (derived),workspace:{version:1,inventory:true,management:true,operationStatus:true,projectRemoval:true}}`. Missing workspace means unsupported; missing grant means false. All mutations require terminals control AND workspaceManagement true; all fleet reads require terminals view/control. Local owner admin `devices.workspaceManagement {deviceId,workspaceManagement:}`. Existing grants default false. Permission loss fences dispatch and late responses; cancellation cannot roll back committed work. +Permissions discovery (`permissions` result and `permissions` event): `{terminals:"off"|"view"|"control",workspaceManagement:false,chat:"off"|"read"|"send" (derived),workspace:{version:1,inventory:true,management:true,operationStatus:true}}`. Missing workspace means unsupported; missing grant means false. All mutations require terminals control AND workspaceManagement true; all fleet reads require terminals view/control. Local owner admin `devices.workspaceManagement {deviceId,workspaceManagement:}`. Existing grants default false. Permission loss fences dispatch and late responses; cancellation cannot roll back committed work. `agents` retains machines and agent fields, adds each machine `metadataStatus:"complete"|"partial"|"offline"|"error"`, `metadataError:string|null`, `projects:[{id:,name,available,error,counts:}]`, `worktrees:[{projectId,id,name,path,branch,status,agentIds:[string]}]`. Agent projectRoot is project ID; worktreeId null means root only when project metadata confirms association. Machine id scopes every project/worktree/agent. Off returns no metadata (request denied). Projects and worktrees queried separately; unsupported metadata produces partial with usable agents. No projects inferred from agents. Machine offline differs from incomplete metadata. Queries bounded to 32 machines, 4096 entries per kind, 5s each, four machines concurrently. @@ -209,7 +209,7 @@ Management fields are strict allowlists. Common `{operationId:<8..128 ASCII alnu - `workspace.terminal`: common + `{target:"root"|"worktree",worktreeId?:string}`. Uses scoped Spawn terminal without command, ensuring manifest association. Result detail `{agentId,worktreeId:string|null}`. - `workspace.rename`: common + `{agentId,name}`. Membership must match exact project. Result detail `{agentId}`. - `workspace.stop`: common + `{agentId,confirm:true}`. Only Kill Agent target. Result detail `{agentId}`. -- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; the project's running agents are suspended (they resume when it is added again), any live session the manifest does not record is stopped and cannot be resumed, and the folder, including `.pu/`, is kept. Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later (`workspace_unsupported` otherwise; the phone disables the action below 13). Result detail `{}`. +- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; the project's running agents are suspended (they can be resumed once it is added again), any live session the manifest does not record is stopped and cannot be resumed, and the folder, including `.pu/`, is kept. Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later (`workspace_unsupported` otherwise; the phone disables the action below 13). Result detail `{}`. Mutation result `{operationId,state:"succeeded"|"failed"|"uncertain",detail?:,message?:string}`. `workspace.operation {operationId}` is read-authorized and returns `{operationId,state:"pending"|"succeeded"|"failed"|"uncertain"|"unknown",detail?,message?}`. No mutation events beyond existing permissions; clients explicitly refresh status and agents. Errors before dispatch are failed; errors after dispatch conservatively uncertain (even daemon errors can follow a committed spawn). Duplicate pending ID rejected; identical completed ID returns ledger status without dispatch; different payload same ID rejected. Ledger retains max 2048 operations for bridge process lifetime, does not evict pending; full ledger rejects new mutations. Restart loses ledger: unknown NEVER means safe to retry. Inventory refresh may locate committed work but absent inventory is not proof of non-commit. User must review authoritative inventory before explicitly initiating any new operation. No automatic replay/retry on reconnect or timeout. A committed-but-unacknowledged operation stays uncertain; no rollback claimed. diff --git a/crates/pu-cli/src/commands/projects.rs b/crates/pu-cli/src/commands/projects.rs index 4d36d1a7..e62492ec 100644 --- a/crates/pu-cli/src/commands/projects.rs +++ b/crates/pu-cli/src/commands/projects.rs @@ -11,7 +11,7 @@ pub async fn run(socket: &Path, action: ProjectsAction) -> Result<(), CliError> crate::commands::inventory::run(socket, InventoryKind::Projects, None, json).await } ProjectsAction::Add { path, json } => { - let root = resolve(path, Default::Init)?; + let root = resolve(path, RootDefault::Init)?; if crate::machine_target::remote_machine().is_none() { // Locally, adding is `pu init`: it also writes the agent context file. return crate::commands::init::run_at(socket, root, json).await; @@ -23,7 +23,7 @@ pub async fn run(socket: &Path, action: ProjectsAction) -> Result<(), CliError> suspend_agents, json, } => { - let project_root = resolve(path, Default::Explicit)?; + let project_root = resolve(path, RootDefault::Explicit)?; send( socket, Request::RemoveProject { @@ -45,7 +45,7 @@ async fn send(socket: &Path, request: Request, json: bool) -> Result<(), CliErro } /// Where a command without a path argument looks for its project. -enum Default { +enum RootDefault { /// As `pu init`: --project, then PU_PROJECT_ROOT (set for agents), then cwd. Init, /// --project, then cwd. Removal ignores an inherited PU_PROJECT_ROOT, so it @@ -55,11 +55,11 @@ enum Default { /// A path argument wins over --project and the current directory. On another /// machine the root must be absolute: a local relative path means nothing there. -fn resolve(path: Option, default: Default) -> Result { +fn resolve(path: Option, default: RootDefault) -> Result { let path = match (path, default) { (Some(path), _) => path, - (None, Default::Init) => crate::commands::project_root_string()?, - (None, Default::Explicit) => match crate::commands::explicit_project() { + (None, RootDefault::Init) => crate::commands::project_root_string()?, + (None, RootDefault::Explicit) => match crate::commands::explicit_project() { Some(root) => root, None => match crate::machine_target::remote_machine() { Some(machine) => return Err(crate::commands::remote_project_required(machine)), diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index ff816d5e..e844d24f 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -586,7 +586,7 @@ enum TabAction { #[derive(Subcommand)] enum InventoryAction { - /// List all registered projects by default; --project narrows the scope + /// List across all registered projects by default; --project narrows the scope List { #[arg(long, conflicts_with = "project")] global: bool, diff --git a/crates/pu-cli/src/output/system.rs b/crates/pu-cli/src/output/system.rs index 244614d3..b752dd41 100644 --- a/crates/pu-cli/src/output/system.rs +++ b/crates/pu-cli/src/output/system.rs @@ -37,7 +37,7 @@ pub(crate) fn print_project_removed( println!("{} {project_root}", "Removed".green()); if !suspended.is_empty() { println!( - " Suspended {} agent(s); they resume when the project is added again", + " Suspended {} agent(s); they can be resumed once the project is added again", suspended.len() ); } diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 330d4225..09a41248 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -522,7 +522,7 @@ pub enum Response { project_root: String, /// False when the root was not registered. removed: bool, - /// Agents suspended so they resume when the project is added again. + /// Agents suspended so they can be resumed once the project is added again. suspended: Vec, /// Live sessions the manifest did not list, which cannot be resumed. #[serde(default)] diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index b0d465a9..d907d124 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Duration; -use pu_core::error::PuError; use pu_core::manifest; use pu_core::protocol::*; use pu_core::types::{AgentEntry, AgentStatus}; @@ -63,9 +62,7 @@ impl Engine { for root in self.registered_projects() { reads.spawn(async move { let path = PathBuf::from(&root); - let read = tokio::task::spawn_blocking(move || { - was_removed(&path, &manifest::read_manifest(&path)) - }); + let read = tokio::task::spawn_blocking(move || was_removed(&path)); let removed = matches!( tokio::time::timeout(Duration::from_secs(2), read).await, Ok(Ok(true)) @@ -83,7 +80,7 @@ impl Engine { // stale since must not keep the projects stream closed. match tokio::time::timeout( Duration::from_secs(5), - self.unregister_projects(removed, true), + self.unregister_projects(removed, Unregister::IfRemoved), ) .await { @@ -151,14 +148,12 @@ impl Engine { } /// Removes registered roots and returns the ones that were registered. - /// With `only_if_removed`, each root is re-checked under the update lock - /// and kept unless its folder still exists without a manifest, so one - /// reopened since it was seen missing stays. async fn unregister_projects( &self, roots: Vec, - only_if_removed: bool, + mode: Unregister, ) -> std::io::Result> { + let roots: HashSet = roots.into_iter().collect(); let update = self.project_registry_updates.clone().lock_owned().await; let projects = self.registered_projects.clone(); let registry = self.project_registry_path.clone(); @@ -170,10 +165,7 @@ impl Engine { .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; current.iter().cloned().partition(|root| { roots.contains(root) - && (!only_if_removed || { - let root = Path::new(root); - was_removed(root, &manifest::read_manifest(root)) - }) + && (mode == Unregister::Always || was_removed(Path::new(root))) }) }; if removed.is_empty() { @@ -256,7 +248,10 @@ impl Engine { killed = rest; } } - match self.unregister_projects(vec![root.clone()], false).await { + match self + .unregister_projects(vec![root.clone()], Unregister::Always) + .await + { Ok(removed) => Response::ProjectRemoved { removed: !removed.is_empty(), project_root: root, @@ -363,16 +358,11 @@ impl Engine { for root in roots { reads.spawn(async move { let path = PathBuf::from(&root); - let read = tokio::task::spawn_blocking(move || { - let result = manifest::read_manifest(&path); - let removed = was_removed(&path, &result); - (result, removed) - }); - // The flag marks a project removed from this machine. + let read = tokio::task::spawn_blocking(move || manifest::read_manifest(&path)); let result = match tokio::time::timeout(Duration::from_secs(2), read).await { - Ok(Ok((result, removed))) => result.map_err(|e| (removed, e.to_string())), - Ok(Err(e)) => Err((false, e.to_string())), - Err(_) => Err((false, "project manifest read timed out".into())), + Ok(Ok(result)) => result.map_err(|e| e.to_string()), + Ok(Err(e)) => Err(e.to_string()), + Err(_) => Err("project manifest read timed out".into()), }; (root, result) }); @@ -385,22 +375,15 @@ impl Engine { } } manifests.sort_by(|a, b| a.0.cmp(&b.0)); - let registered: HashSet = self.registered_projects().into_iter().collect(); + // A global query read every registered root; a scoped one needs the registry. + let registered: HashSet = if report.project_root.is_none() { + manifests.iter().map(|(root, _)| root.clone()).collect() + } else { + self.registered_projects().into_iter().collect() + }; let sessions = self.sessions.lock().await; let mut observed_sessions = HashSet::new(); - let mut removed = Vec::new(); for (root, result) in manifests { - // A registry-wide query forgets a project whose manifest is gone, - // unless an agent this daemon runs still claims it. - if report.project_root.is_none() - && matches!(result, Err((true, _))) - && !sessions - .values() - .any(|h| h.project_root.as_deref() == Some(root.as_str())) - { - removed.push(root); - continue; - } let mut project = ProjectInventoryEntry { name: Path::new(&root) .file_name() @@ -417,7 +400,7 @@ impl Engine { }; let m = match result { Ok(m) => m, - Err((_, error)) => { + Err(error) => { report.complete = false; project.error = Some(error); report.projects.push(project); @@ -571,11 +554,6 @@ impl Engine { } } drop(sessions); - if !removed.is_empty() - && let Err(e) = self.unregister_projects(removed, true).await - { - tracing::warn!("failed to unregister removed projects: {e}"); - } report.summary = standalone; report.summary.projects = report.projects.len(); for p in &report.projects { @@ -769,10 +747,18 @@ pub(super) fn project_root_not_allowed(root: &str) -> Response { } } -/// A project folder whose manifest is gone was removed from PurePoint. A +/// A project folder whose manifest is gone was removed by an older client. A /// missing folder is only unavailable: it may be on an unmounted volume. -fn was_removed(root: &Path, read: &Result) -> bool { - matches!(read, Err(PuError::NotInitialized)) && root.is_dir() +fn was_removed(root: &Path) -> bool { + root.is_dir() && !pu_core::paths::manifest_path(root).exists() +} + +/// Whether `unregister_projects` re-checks, under the update lock, that each +/// root is still a removed folder: one reopened since it was seen stays. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Unregister { + Always, + IfRemoved, } fn write_registry(path: &Path, projects: &[String]) -> std::io::Result<()> { @@ -1243,7 +1229,7 @@ mod tests { } #[tokio::test] - async fn given_registered_project_whose_manifest_was_deleted_should_drop_it_from_inventory_and_registry() + async fn given_manifest_deleted_while_running_should_report_it_unavailable_until_the_next_prune() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("registry.json"); @@ -1253,10 +1239,14 @@ mod tests { // What the app does when a project is removed from the sidebar. std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&removed))).unwrap(); + // Inventory is a read: it never changes the registry. let report = inventory(&engine, InventoryKind::Projects).await; - assert!(report.complete); - let roots: Vec<_> = report.projects.iter().map(|p| &p.project_root).collect(); - assert_eq!(roots, vec![&kept]); + assert!(!report.complete); + let unavailable: Vec<_> = report.projects.iter().filter(|p| !p.available).collect(); + assert_eq!(unavailable.len(), 1); + assert_eq!(unavailable[0].project_root, removed); + assert_eq!(engine.registered_projects().len(), 2); + engine.prune_removed_projects().await; assert_eq!(engine.registered_projects(), vec![kept.clone()]); drop(engine); assert_eq!( @@ -1327,7 +1317,7 @@ mod tests { let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); let root = init(&engine, &tmp.path().join("project")).await; std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&root))).unwrap(); - inventory(&engine, InventoryKind::Projects).await; + engine.prune_removed_projects().await; assert!(engine.registered_projects().is_empty()); init(&engine, Path::new(&root)).await; @@ -1444,7 +1434,7 @@ mod tests { let agent = manifest::read_manifest(Path::new(&root)).unwrap().agents[&id].clone(); assert!( agent.suspended, - "suspended agents resume when the project is added again" + "suspended agents can be resumed once the project is added again" ); engine.kill_all_sessions(Duration::from_millis(10)).await; } @@ -1461,7 +1451,7 @@ mod tests { .to_string_lossy() .into_owned(); engine - .unregister_projects(vec![root.clone()], false) + .unregister_projects(vec![root.clone()], Unregister::Always) .await .unwrap(); let report = inventory(&engine, InventoryKind::Agents).await; diff --git a/crates/pu-engine/src/ipc_server/streams.rs b/crates/pu-engine/src/ipc_server/streams.rs index c4f3fa09..a8f77fa8 100644 --- a/crates/pu-engine/src/ipc_server/streams.rs +++ b/crates/pu-engine/src/ipc_server/streams.rs @@ -307,8 +307,7 @@ pub(super) async fn handle_status_stream( } async fn send_projects(writer: &mut IpcWriter, engine: &Engine) -> bool { - let mut projects = engine.registered_projects(); - projects.sort(); + let projects = engine.registered_projects(); write_response(writer, &Response::ProjectsChanged { projects }) .await .is_ok() diff --git a/crates/pu-engine/src/ipc_server/tests.rs b/crates/pu-engine/src/ipc_server/tests.rs index 436a8219..5bd826d0 100644 --- a/crates/pu-engine/src/ipc_server/tests.rs +++ b/crates/pu-engine/src/ipc_server/tests.rs @@ -1459,8 +1459,11 @@ async fn assert_invalid_bytes_close_stream(sock_path: &std::path::Path, subscrib write_line(&mut writer, &request).await; let first = read_response(&mut reader).await; assert!(subscribed(&first), "{first:?}"); - writer.write_all(b"\xff{}\n").await.unwrap(); - write_line(&mut writer, &Request::Health).await; + // One write: the daemon may close as soon as it reads the bad line, and a + // second write would then race it and fail with a broken pipe. + let mut bytes = b"\xff{}\n".to_vec(); + bytes.extend_from_slice((serde_json::to_string(&Request::Health).unwrap() + "\n").as_bytes()); + writer.write_all(&bytes).await.unwrap(); let responses = responses_until_close(&mut reader).await; assert!( !responses diff --git a/docs/product/daemon.md b/docs/product/daemon.md index da705322..a89e6f7b 100644 --- a/docs/product/daemon.md +++ b/docs/product/daemon.md @@ -25,7 +25,7 @@ Daemon (pu-engine binary) **Global inventory (implemented 2026-10-10):** Production startup loads canonical project roots from `.projects.json`; successful Init registers roots atomically (see explicit project membership below). Alternate sockets have independent registries. Invalid registrations do not enter the set, unavailable registered projects remain visible with errors, and restart does not spawn agents. A derived Inventory query combines manifests with live ownership metadata; no duplicate global agent database is stored. Global summary and flat agent/worktree records share count semantics, and Health now returns known projects and the count of live session handles rather than all retained handles. In-memory Engine::new remains available for isolated embedding/tests. -**Explicit project membership (implemented 2026-10-21, protocol 13):** The registry is each machine's one project list, and only `Init` adds to it and `RemoveProject` removes from it. Using a project (status, spawn, subscriptions) no longer registers it — that made a project removed on one device reappear as soon as another device still watching it sent a request. Removal keeps the project's files and `.pu/` so adding it again restores it; live agents block removal unless the caller asks to suspend them. `SubscribeProjects` pushes the full list on every change, and the macOS app's sidebar, each machine's project list and the phone bridge all follow the registry instead of keeping their own lists. Registered roots whose folder exists without a manifest (removed by clients that deleted `.pu/manifest.json`) are dropped by the next global inventory. +**Explicit project membership (implemented 2026-10-21, protocol 13):** The registry is each machine's one project list, and only `Init` adds to it and `RemoveProject` removes from it. Using a project (status, spawn, subscriptions) no longer registers it — that made a project removed on one device reappear as soon as another device still watching it sent a request. Removal keeps the project's files and `.pu/` so adding it again restores it; live agents block removal unless the caller asks to suspend them. `SubscribeProjects` pushes the full list on every change, and the macOS app's sidebar, each machine's project list and the phone bridge all follow the registry instead of keeping their own lists. Registered roots whose folder exists without a manifest (removed by clients that deleted `.pu/manifest.json`) are dropped in the background when the daemon starts; a manifest deleted while it runs leaves the project listed as unavailable until it is removed or the daemon restarts. Inventory never changes the registry. **Daemon binary:** `pu-engine` (separate from `pu-cli`). Both modes take an exclusive lock on `daemon.sock.lock` (one daemon per socket) and write the PID file; managed mode (`--managed`) additionally exits when its parent app dies. Socket path configurable via `--socket `, defaults to `~/.pu/daemon.sock`. diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 9104e284..c1f16d46 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -75,7 +75,7 @@ Inventory returns `type: inventory`, observation time, optional project scope (n ResolveAgent returns `type: agent_resolved`, agent_id, project_root and worktree_id. Legacy Status (with agent_id), Kill (single agent), Suspend (single agent), Resume, Rename and AssignTrigger accept an empty project_root for daemon ownership resolution. Explicit roots constrain ownership. Bulk requests require a root. Duplicate durable IDs return AMBIGUOUS_AGENT; a live session owned by another project returns AGENT_OWNERSHIP_CONFLICT; an incomplete durable inventory returns INVENTORY_INCOMPLETE when a known live owner cannot resolve the ID. Standalone shells support status/logs/input/attach/kill and reject resumable project operations with UNSUPPORTED_AGENT_OPERATION. -Production registry: `.projects.json`, loaded on startup and atomically updated by `init` (add) and `remove_project` (remove) — nothing else adds a project, so a removed project stays removed even while other clients still use it. A registered root whose folder still exists but has no `.pu/manifest.json` (removed by an older client) is dropped by the next global inventory; a root whose folder is missing stays, reported unavailable, since it may be on an unmounted volume. `/`, `$HOME` and ancestors of `$HOME` are never registered, even when `~/.pu/manifest.json` makes `$HOME` look initialized; such entries left by older daemons are removed when the registry loads. Requests naming such a root fail with `project_root_not_allowed`, except `status`, `subscribe_status`, `read`, `channel_read` and `kill`, which stay available so agents an older version recorded there can be seen and stopped. Roots are canonical, registration survives restart, and startup does not resume agents. Health returns registered roots and counts only session handles without observed exits. +Production registry: `.projects.json`, loaded on startup and atomically updated by `init` (add) and `remove_project` (remove) — nothing else adds a project, so a removed project stays removed even while other clients still use it. A registered root whose folder still exists but has no `.pu/manifest.json` (removed by an older client) is dropped in the background when the daemon starts (inventory itself never changes the registry); a root whose folder is missing stays, reported unavailable, since it may be on an unmounted volume. `/`, `$HOME` and ancestors of `$HOME` are never registered, even when `~/.pu/manifest.json` makes `$HOME` look initialized; such entries left by older daemons are removed when the registry loads. Requests naming such a root fail with `project_root_not_allowed`, except `status`, `subscribe_status`, `read`, `channel_read` and `kill`, which stay available so agents an older version recorded there can be seen and stopped. Roots are canonical, registration survives restart, and startup does not resume agents. Health returns registered roots and counts only session handles without observed exits. ### Health @@ -105,7 +105,7 @@ Add a project: create `.pu/manifest.json` if needed and register the root. This ### RemoveProject -Forget a project on this daemon, for every client. Its files, including `.pu/`, are not touched, so a later `init` brings it back with its worktrees and agents. Live agents refuse the removal with `PROJECT_HAS_LIVE_AGENTS` unless `suspend_agents` is true (default false), which suspends them so they resume when the project is added again. An unregistered root answers `removed: false`. +Forget a project on this daemon, for every client. Its files, including `.pu/`, are not touched, so a later `init` brings it back with its worktrees and agents. Live agents refuse the removal with `PROJECT_HAS_LIVE_AGENTS` unless `suspend_agents` is true (default false), which suspends them so they can be resumed once the project is added again. An unregistered root answers `removed: false`. **Request**: `{"type": "remove_project", "project_root": "/path/to/project", "suspend_agents": true}` From d7c3e5e26f6589c0c9476e78e1680933869c1a05 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:37:43 -0500 Subject: [PATCH 6/7] fix(projects): keep unreadable projects and retry unresolved paths - `was_removed` treats only a manifest that is definitely absent (`try_exists() == Ok(false)`) as removed. A permission error or a stale mount at startup no longer drops a project from every device's list. - macOS caches a root's canonical path only once `realpath` succeeds, so a project restored before its volume mounted is still matched to the daemon's root afterwards, instead of opening twice. - Fix a stale comment about the prune re-check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- .../purepoint-macos/State/AppState.swift | 12 ++++++++-- crates/pu-engine/src/engine/inventory.rs | 24 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index b3ee74cb..1d7a1c11 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -437,7 +437,10 @@ final class AppState { #if os(macOS) private func canonicalRoot(of root: String) -> String { if let known = canonicalRoots[root] { return known } - let resolved = Self.canonicalPath(root) + guard let resolved = Self.resolvedPath(root) else { + // Not resolvable yet (an unmounted volume): try again on the next report. + return root + } canonicalRoots[root] = resolved return resolved } @@ -461,7 +464,12 @@ final class AppState { /// The path with symlinks resolved, as the daemon's `canonicalize` reports it (unlike /// `URL.resolvingSymlinksInPath`, which drops `/private`). nonisolated static func canonicalPath(_ path: String) -> String { - guard let resolved = realpath(path, nil) else { return path } + resolvedPath(path) ?? path + } + + /// The path with symlinks resolved, or nil when it does not resolve (yet). + nonisolated private static func resolvedPath(_ path: String) -> String? { + guard let resolved = realpath(path, nil) else { return nil } defer { free(resolved) } return String(cString: resolved) } diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index d907d124..2ebd73fa 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -76,7 +76,7 @@ impl Engine { removed.push(root); } } - // The removal re-reads each manifest under the update lock; a mount that went + // The removal re-checks each manifest under the update lock; a mount that went // stale since must not keep the projects stream closed. match tokio::time::timeout( Duration::from_secs(5), @@ -749,8 +749,10 @@ pub(super) fn project_root_not_allowed(root: &str) -> Response { /// A project folder whose manifest is gone was removed by an older client. A /// missing folder is only unavailable: it may be on an unmounted volume. +/// Only a manifest known to be absent counts: one that cannot be checked (a +/// permission error, a stale network mount) keeps the project. fn was_removed(root: &Path) -> bool { - root.is_dir() && !pu_core::paths::manifest_path(root).exists() + root.is_dir() && matches!(pu_core::paths::manifest_path(root).try_exists(), Ok(false)) } /// Whether `unregister_projects` re-checks, under the update lock, that each @@ -1526,4 +1528,22 @@ mod tests { ); engine.kill_all_sessions(Duration::from_millis(10)).await; } + + #[tokio::test] + async fn given_manifest_that_cannot_be_checked_should_keep_the_project_on_prune() { + use std::os::unix::fs::PermissionsExt; + // Permissions do not stop root, so the stat would succeed and prove nothing. + if unsafe { libc::geteuid() } == 0 { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + let pu = pu_core::paths::pu_dir(Path::new(&root)); + // An unreadable `.pu/` is not a removed project, whatever the manifest stat says. + std::fs::set_permissions(&pu, std::fs::Permissions::from_mode(0o000)).unwrap(); + engine.prune_removed_projects().await; + std::fs::set_permissions(&pu, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(engine.registered_projects(), vec![root]); + } } From 7692d0486348f794db5b71d63a15186155e72609 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 01:45:52 -0500 Subject: [PATCH 7/7] fix(projects): address CodeRabbit review of the latest commits - Engine: `unregister_projects` snapshots the registry, releases the mutex, and only then stats folders. A stale mount can no longer block health, inventory or the projects stream. - Bridge: removal proceeds when only worktree metadata is partial, so a registered project with an unreadable manifest can still be removed. - macOS decodes `killed` from `project_removed`, and the removal confirmation says any other session in the folder is stopped. - Wording: removal keeps the folder and `.pu/`, but suspending agents updates the manifest. The CLI, the Mac and phone confirmations and the docs no longer claim `.pu/` is untouched. The contract now describes an older daemon's actual result: a failed mutation with an update message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEnaUaTZMG1Rr8DGJpUnCm --- .../Services/DaemonProtocol.swift | 8 +++++-- .../Views/Sidebar/SidebarMenuModel.swift | 2 +- .../purepoint-macosTests/MachinesTests.swift | 6 +++--- .../SidebarMenuModelTests.swift | 2 +- .../PurePoint/AgentsView.swift | 2 +- apps/purepoint-mobile/bridge/workspace.js | 10 ++++++++- .../purepoint-mobile/bridge/workspace.test.js | 20 +++++++++++------- apps/purepoint-mobile/docs/contract.md | 2 +- crates/pu-cli/src/output/system.rs | 2 +- crates/pu-core/src/protocol/mod.rs | 5 +++-- crates/pu-engine/src/engine/inventory.rs | 21 ++++++++++--------- docs/reference/ipc-api.md | 2 +- 12 files changed, 51 insertions(+), 31 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index 15cb42be..0cf8e814 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -676,7 +676,7 @@ nonisolated enum DaemonResponse: Decodable { case channelMessage(ChannelMutation) case healthReport(pid: Int, uptimeSeconds: Int, protocolVersion: Int, agentCount: Int) case initResult(created: Bool) - case projectRemoved(projectRoot: String, removed: Bool, suspended: [String]) + case projectRemoved(projectRoot: String, removed: Bool, suspended: [String], killed: [String]) case projectsSubscribed case projectsChanged(projects: [String]) case statusReport(worktrees: [WorktreeEntry], agents: [AgentStatusReport]) @@ -745,7 +745,8 @@ nonisolated enum DaemonResponse: Decodable { self = .initResult(created: p.created) case "project_removed": let p = try ProjectRemovedPayload(from: decoder) - self = .projectRemoved(projectRoot: p.projectRoot, removed: p.removed, suspended: p.suspended) + self = .projectRemoved( + projectRoot: p.projectRoot, removed: p.removed, suspended: p.suspended, killed: p.killed ?? []) case "projects_subscribed": self = .projectsSubscribed case "projects_changed": @@ -1055,11 +1056,14 @@ private nonisolated struct ProjectRemovedPayload: Decodable { let projectRoot: String let removed: Bool let suspended: [String] + /// Sessions the manifest did not list, stopped rather than suspended. + let killed: [String]? enum CodingKeys: String, CodingKey { case projectRoot = "project_root" case removed case suspended + case killed } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift index f0233f4d..801f865b 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift @@ -237,7 +237,7 @@ extension SidebarConfirmation { return SidebarConfirmation( title: machine.map { "Remove \(project.projectName) from \($0)?" } ?? "Remove \(project.projectName)?", message: "It disappears from PurePoint on \(machine ?? "this Mac") and every device connected to it." - + agents + " The folder, including .pu/, is not changed." + + agents + " Any other session in the folder is stopped. The folder and .pu/ are kept." + unsavedWarning(unsavedFileNames(in: project)), confirmTitle: "Remove") } diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index c441f905..dbbb03f6 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -334,13 +334,13 @@ struct DaemonProjectHostRequestTests { } #expect(projects == ["/a", "/b"]) guard - case .projectRemoved(let root, let removed, let suspended) = decode( - #"{"type":"project_removed","project_root":"/a","removed":true,"suspended":["ag-1"]}"#) + case .projectRemoved(let root, let removed, let suspended, let killed) = decode( + #"{"type":"project_removed","project_root":"/a","removed":true,"suspended":["ag-1"],"killed":["sh-2"]}"#) else { Issue.record("project_removed did not decode") return } - #expect(root == "/a" && removed && suspended == ["ag-1"]) + #expect(root == "/a" && removed && suspended == ["ag-1"] && killed == ["sh-2"]) } @Test func givenInventoryShouldAskForEveryRegisteredProject() throws { diff --git a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift index fcc031d4..ccf987d1 100644 --- a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift @@ -116,7 +116,7 @@ struct SidebarMenuModelTests { title: "Remove confirm from Machine?", message: "It disappears from PurePoint on Machine and every device connected to it." + " Its 1 running agent is suspended and can be resumed once the project is added again." - + " The folder, including .pu/, is not changed.", + + " Any other session in the folder is stopped. The folder and .pu/ are kept.", confirmTitle: "Remove")) } } diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index 3fe21174..5f53213c 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -561,7 +561,7 @@ struct HomeView: View { removing = nil } } message: { - Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Its agents are suspended and can be resumed once the project is added again; a session the project does not record is stopped. Files, including .pu/, are not changed." } ?? "") + Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Its agents are suspended and can be resumed once the project is added again; a session the project does not record is stopped. The folder and .pu/ are kept." } ?? "") } .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil; removing = nil } } diff --git a/apps/purepoint-mobile/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index 17900834..0dbd5d6d 100644 --- a/apps/purepoint-mobile/bridge/workspace.js +++ b/apps/purepoint-mobile/bridge/workspace.js @@ -161,7 +161,15 @@ export function workspaceConnection({ check(); const metadata = await projectMetadata(machine, { signal }); check(); - if (metadata.metadataStatus !== "complete") + // Removal needs only the project list: a registered project whose manifest + // cannot be read leaves worktree metadata partial, and must stay removable. + if ( + metadata.metadataStatus !== "complete" && + !( + r.op === "workspace.removeProject" && + metadata.metadataStatus === "partial" + ) + ) fail("unsupported", "Complete authoritative project metadata required"); const project = metadata.projects.find((p) => p.id === r.projectId); // An unavailable project can still be removed; nothing else runs in one. diff --git a/apps/purepoint-mobile/bridge/workspace.test.js b/apps/purepoint-mobile/bridge/workspace.test.js index adf66bab..13d3adce 100644 --- a/apps/purepoint-mobile/bridge/workspace.test.js +++ b/apps/purepoint-mobile/bridge/workspace.test.js @@ -947,12 +947,18 @@ test("project removal needs confirmation and a daemon that can forget projects f f.protocolVersion(13); const removed = await remove("remove-0003", { confirm: true }); assert.equal(removed.state, "succeeded"); + // Partial worktree metadata (an unreadable manifest) does not block removal. + f.partial(true); + assert.equal( + (await remove("remove-0004", { confirm: true })).state, + "succeeded", + ); + f.partial(false); const sent = f.requests.filter((r) => r.type === "remove_project"); - assert.deepEqual(sent, [ - { - type: "remove_project", - project_root: "/registered", - suspend_agents: true, - }, - ]); + const expected = { + type: "remove_project", + project_root: "/registered", + suspend_agents: true, + }; + assert.deepEqual(sent, [expected, expected]); }); diff --git a/apps/purepoint-mobile/docs/contract.md b/apps/purepoint-mobile/docs/contract.md index 637f2054..b03ea107 100644 --- a/apps/purepoint-mobile/docs/contract.md +++ b/apps/purepoint-mobile/docs/contract.md @@ -209,7 +209,7 @@ Management fields are strict allowlists. Common `{operationId:<8..128 ASCII alnu - `workspace.terminal`: common + `{target:"root"|"worktree",worktreeId?:string}`. Uses scoped Spawn terminal without command, ensuring manifest association. Result detail `{agentId,worktreeId:string|null}`. - `workspace.rename`: common + `{agentId,name}`. Membership must match exact project. Result detail `{agentId}`. - `workspace.stop`: common + `{agentId,confirm:true}`. Only Kill Agent target. Result detail `{agentId}`. -- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; the project's running agents are suspended (they can be resumed once it is added again), any live session the manifest does not record is stopped and cannot be resumed, and the folder, including `.pu/`, is kept. Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later (`workspace_unsupported` otherwise; the phone disables the action below 13). Result detail `{}`. +- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; the project's running agents are suspended (they can be resumed once it is added again), any live session the manifest does not record is stopped and cannot be resumed, and the folder and `.pu/` are kept (suspension only updates the manifest). Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later: an older one gives the mutation result `state:"failed"` with an update-the-daemon message, and the phone disables the action below 13. Result detail `{}`. Mutation result `{operationId,state:"succeeded"|"failed"|"uncertain",detail?:,message?:string}`. `workspace.operation {operationId}` is read-authorized and returns `{operationId,state:"pending"|"succeeded"|"failed"|"uncertain"|"unknown",detail?,message?}`. No mutation events beyond existing permissions; clients explicitly refresh status and agents. Errors before dispatch are failed; errors after dispatch conservatively uncertain (even daemon errors can follow a committed spawn). Duplicate pending ID rejected; identical completed ID returns ledger status without dispatch; different payload same ID rejected. Ledger retains max 2048 operations for bridge process lifetime, does not evict pending; full ledger rejects new mutations. Restart loses ledger: unknown NEVER means safe to retry. Inventory refresh may locate committed work but absent inventory is not proof of non-commit. User must review authoritative inventory before explicitly initiating any new operation. No automatic replay/retry on reconnect or timeout. A committed-but-unacknowledged operation stays uncertain; no rollback claimed. diff --git a/crates/pu-cli/src/output/system.rs b/crates/pu-cli/src/output/system.rs index b752dd41..2c4d1279 100644 --- a/crates/pu-cli/src/output/system.rs +++ b/crates/pu-cli/src/output/system.rs @@ -47,7 +47,7 @@ pub(crate) fn print_project_removed( killed.len() ); } - println!(" Files, including .pu/, were not changed"); + println!(" Project files and .pu/ were kept (suspending agents only updates the manifest)"); } pub(crate) fn print_config_report(default_agent: &str, agents: &[AgentConfigInfo]) { diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 09a41248..48e28f8b 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -93,8 +93,9 @@ pub enum Request { Init { project_root: String, }, - /// Forget a registered project on this daemon. Files, including `.pu/`, are - /// untouched, so a later `Init` brings the project back as it was. + /// Forget a registered project on this daemon. Its files and `.pu/` are kept + /// (suspending agents only updates the manifest), so a later `Init` brings + /// the project back as it was. RemoveProject { project_root: String, /// Suspend the project's live agents instead of refusing the removal. diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index 2ebd73fa..e2c52720 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -159,19 +159,20 @@ impl Engine { let registry = self.project_registry_path.clone(); let removed = tokio::task::spawn_blocking(move || -> std::io::Result> { let _update = update; - let (removed, next): (Vec<_>, Vec<_>) = { - let current = projects - .lock() - .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; - current.iter().cloned().partition(|root| { - roots.contains(root) - && (mode == Unregister::Always || was_removed(Path::new(root))) - }) - }; + // Snapshot under the registry mutex, then stat without it: a stale + // mount must not block readers. The update lock keeps writers out. + let current: Vec = projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))? + .iter() + .cloned() + .collect(); + let (removed, mut next): (Vec<_>, Vec<_>) = current.into_iter().partition(|root| { + roots.contains(root) && (mode == Unregister::Always || was_removed(Path::new(root))) + }); if removed.is_empty() { return Ok(removed); } - let mut next = next; next.sort(); if let Some(path) = registry { write_registry(&path, &next)?; diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index c1f16d46..e5fe51d2 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -105,7 +105,7 @@ Add a project: create `.pu/manifest.json` if needed and register the root. This ### RemoveProject -Forget a project on this daemon, for every client. Its files, including `.pu/`, are not touched, so a later `init` brings it back with its worktrees and agents. Live agents refuse the removal with `PROJECT_HAS_LIVE_AGENTS` unless `suspend_agents` is true (default false), which suspends them so they can be resumed once the project is added again. An unregistered root answers `removed: false`. +Forget a project on this daemon, for every client. Its files and `.pu/` are kept (suspending agents only updates the manifest), so a later `init` brings it back with its worktrees and agents. Live agents refuse the removal with `PROJECT_HAS_LIVE_AGENTS` unless `suspend_agents` is true (default false), which suspends them so they can be resumed once the project is added again. An unregistered root answers `removed: false`. **Request**: `{"type": "remove_project", "project_root": "/path/to/project", "suspend_agents": true}`