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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ nonisolated struct AgentEntry: Codable, Sendable {
let suspendedAt: String?
let command: String?
var signal: AgentSignalSnapshot? = nil
var placement: SpawnPlacement? = nil

// Explicit CodingKeys document the camelCase wire format
// matching Rust's #[serde(rename_all = "camelCase")] on types::AgentEntry.
private enum CodingKeys: String, CodingKey {
case id, name, status, prompt, error, pid, suspended, command, signal
case id, name, status, prompt, error, pid, suspended, command, signal, placement
case agentType, startedAt, completedAt, exitCode, sessionId, suspendedAt
}
}
Expand All @@ -94,5 +95,6 @@ extension AgentEntry {
suspendedAt = try c.decodeIfPresent(String.self, forKey: .suspendedAt)
command = try c.decodeIfPresent(String.self, forKey: .command)
signal = try? c.decodeIfPresent(AgentSignalSnapshot.self, forKey: .signal)
placement = try? c.decodeIfPresent(SpawnPlacement.self, forKey: .placement)
}
}
79 changes: 77 additions & 2 deletions apps/purepoint-macos/purepoint-macos/Models/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -432,10 +432,61 @@ nonisolated enum FocusDirection: Sendable {

// MARK: - Reconciler

/// The tab an agent was spawned for, as recorded on its manifest entry.
nonisolated struct SpawnPlacement: Codable, Hashable, Sendable {
let workspace: String
let surface: Int
}

/// One live agent as the manifest reports it.
nonisolated struct LiveAgent: Equatable, Sendable {
let id: String
let container: WorkspaceContainer
var placement: SpawnPlacement? = nil

/// A Point Guard workspace's ID embeds the project key of the app that made it, and every
/// app re-keys it to its own on load (see `WorkspacePersistence`). A placement another Mac
/// recorded names that Mac's ID, so it is re-keyed the same way.
func placementRekeyed(projectRoot: String) -> LiveAgent {
guard let placement, placement.workspace.hasPrefix("ws-point-guard:") else { return self }
var agent = self
agent.placement = SpawnPlacement(
workspace: Workspace.pointGuardId(projectRoot: projectRoot), surface: placement.surface)
return agent
}
}

/// Folds a layout document another Mac wrote into this app's layout, one workspace at a time.
///
/// Two apps write the same `.pu/workspaces.json` and every save is conditional, so a save that
/// finds the document changed has to take the other Mac's edits without dropping its own.
/// `base` is the document as this app last read or wrote it; their changes are measured against
/// it. Ours are measured against `oursBase` (by default the same), which a caller can set to the
/// base as its own reconciler shows it, so grouping derived from the manifest is not an edit.
/// A workspace this app changed or added keeps this app's version — also when the other Mac
/// changed it too, so a tab just split here for a spawn survives — and everything else takes
/// theirs. The other Mac then adopts the result: its copy of each workspace is unchanged from
/// its base, so it takes ours and no further write follows.
nonisolated enum WorkspaceLayoutMerge {
static func merge(
base: [Workspace], ours: [Workspace], theirs: [Workspace], oursBase: [Workspace]? = nil
) -> [Workspace] {
let baseById = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let oursBaseById = Dictionary((oursBase ?? base).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let oursById = Dictionary(ours.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let theirIds = Set(theirs.map(\.id))
var result: [Workspace] = []
for their in theirs {
if let mine = oursById[their.id] {
result.append(mine != oursBaseById[their.id] ? mine : their)
} else if their != baseById[their.id] {
result.append(their) // removed here, changed there: keep it
}
}
// Added here, or changed here and removed there.
result.append(contentsOf: ours.filter { !theirIds.contains($0.id) && $0 != oursBaseById[$0.id] })
return result
}
}

/// The single function that turns (stored layout, manifest) into the canonical workspace list.
Expand All @@ -451,8 +502,17 @@ nonisolated enum WorkspaceReconciler {
/// 3. Every workspace holds a live agent or a file tab, or is waiting on a spawn
/// (`reserved`) — no ghost rows.
/// 4. Each workspace's container matches where its agents actually live.
/// 5. An agent no tab holds yet goes into the empty tab it was spawned for, when that tab
/// is still there — so every app showing the project places it the same way.
static func reconcile(stored: [Workspace], live: [LiveAgent], reserved: Set<String> = []) -> [Workspace] {
let containerByAgent = Dictionary(live.map { ($0.id, $0.container) }, uniquingKeysWith: { first, _ in first })
let storedAgentIds = Set(stored.flatMap(\.agentIds))
var agentByPlacement: [SpawnPlacement: LiveAgent] = [:]
for agent in live where !storedAgentIds.contains(agent.id) {
guard let placement = agent.placement, agentByPlacement[placement] == nil else { continue }
agentByPlacement[placement] = agent
}
let placementWorkspaceIds = Set(agentByPlacement.keys.map(\.workspace))

var claimed = Set<String>()
var result: [Workspace] = []
Expand Down Expand Up @@ -485,6 +545,18 @@ nonisolated enum WorkspaceReconciler {
}
}

if placementWorkspaceIds.contains(workspace.id) {
for (_, surface) in workspace.surfaces where surface.content == .empty {
let placement = SpawnPlacement(workspace: workspace.id, surface: surface.id)
guard let agent = agentByPlacement[placement], !claimed.contains(agent.id),
workspace.agentIds.isEmpty || workspace.container == agent.container,
!workspace.isPointGuard || agent.container == .projectRoot
else { continue }
workspace.setContent(.agent(agent.id), forSurface: surface.id)
claimed.insert(agent.id)
}
}

// A workspace exists to hold what the user opened. One left with only empty tabs
// is a ghost row — unless one of those tabs is waiting for its agent.
guard !workspace.isGhost || reserved.contains(workspace.id) else { continue }
Expand All @@ -510,9 +582,12 @@ nonisolated enum WorkspaceReconciler {
/// Flatten a project's manifest into the ordered agent list the reconciler expects.
/// Root agents first, then each worktree's agents — manifest order throughout.
static func liveAgents(rootAgents: [AgentModel], worktrees: [WorktreeModel]) -> [LiveAgent] {
var live = rootAgents.map { LiveAgent(id: $0.id, container: .projectRoot) }
var live = rootAgents.map { LiveAgent(id: $0.id, container: .projectRoot, placement: $0.placement) }
for worktree in worktrees {
live.append(contentsOf: worktree.agents.map { LiveAgent(id: $0.id, container: .worktree(worktree.id)) })
live.append(
contentsOf: worktree.agents.map {
LiveAgent(id: $0.id, container: .worktree(worktree.id), placement: $0.placement)
})
}
return live
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {
let suspended: Bool
let command: String?
var signal: AgentSignalSnapshot? = nil
/// The tab this agent was spawned for, if any.
var placement: SpawnPlacement? = nil

var displayName: String {
name.isEmpty ? id : name
Expand All @@ -37,7 +39,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {

init(
id: String, name: String, agentType: String, status: AgentStatus, prompt: String, startedAt: String,
sessionId: String? = nil, suspended: Bool = false, command: String? = nil, signal: AgentSignalSnapshot? = nil
sessionId: String? = nil, suspended: Bool = false, command: String? = nil, signal: AgentSignalSnapshot? = nil,
placement: SpawnPlacement? = nil
) {
self.id = id
self.name = name
Expand All @@ -49,6 +52,7 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {
self.suspended = suspended
self.command = command
self.signal = signal
self.placement = placement
}

init(from entry: AgentEntry) {
Expand All @@ -61,7 +65,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {
startedAt: entry.startedAt,
sessionId: entry.sessionId,
suspended: entry.suspended ?? false,
command: entry.command
command: entry.command,
placement: entry.placement
)
signal = entry.signal
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ nonisolated enum DaemonRequest: Encodable {
case spawn(
projectRoot: String, prompt: String, agent: String = "claude",
name: String? = nil, base: String? = nil, root: Bool = false,
worktree: String? = nil, command: String? = nil)
worktree: String? = nil, command: String? = nil, placement: SpawnPlacement? = nil)
case spawnShell(cwd: String)
case kill(projectRoot: String, target: KillTarget)
case rename(projectRoot: String, agentId: String, name: String)
Expand Down Expand Up @@ -440,7 +440,10 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode(agentId, forKey: .key("agent_id"))
try container.encode(cols, forKey: .key("cols"))
try container.encode(rows, forKey: .key("rows"))
case .spawn(let projectRoot, let prompt, let agent, let name, let base, let root, let worktree, let command):
case .spawn(
let projectRoot, let prompt, let agent, let name, let base, let root, let worktree, let command,
let placement
):
try container.encode("spawn", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
try container.encode(prompt, forKey: .key("prompt"))
Expand All @@ -450,6 +453,7 @@ nonisolated enum DaemonRequest: Encodable {
if root { try container.encode(root, forKey: .key("root")) }
if let worktree { try container.encode(worktree, forKey: .key("worktree")) }
if let command { try container.encode(command, forKey: .key("command")) }
if let placement { try container.encode(placement, forKey: .key("placement")) }
case .spawnShell(let cwd):
try container.encode("spawn_shell", forKey: .key("type"))
try container.encode(cwd, forKey: .key("cwd"))
Expand Down Expand Up @@ -915,9 +919,10 @@ nonisolated struct AgentStatusReport: Decodable {
let prompt: String?
let suspended: Bool
let signal: AgentSignalSnapshot?
let placement: SpawnPlacement?

private enum CodingKeys: String, CodingKey {
case id, name, status, pid, prompt, suspended, signal
case id, name, status, pid, prompt, suspended, signal, placement
case agentType = "agent_type"
case exitCode = "exit_code"
case idleSeconds = "idle_seconds"
Expand All @@ -941,6 +946,7 @@ nonisolated struct AgentStatusReport: Decodable {
prompt = try container.decodeIfPresent(String.self, forKey: .prompt)
signal = try? container.decodeIfPresent(AgentSignalSnapshot.self, forKey: .signal)
suspended = try container.decodeIfPresent(Bool.self, forKey: .suspended) ?? false
placement = try? container.decodeIfPresent(SpawnPlacement.self, forKey: .placement)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ actor DaemonStatusSubscription {
startedAt: report.startedAt ?? "",
sessionId: report.sessionId,
suspended: report.suspended,
signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil }
signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil },
placement: report.placement
)
}
await onEvent(worktreeModels, agentModels)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ nonisolated struct DaemonWorkspaceService: WorkspaceService {
startedAt: report.startedAt ?? "",
sessionId: report.sessionId,
suspended: report.suspended,
signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil }
signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil },
placement: report.placement
)
}
return WorkspaceSnapshot(worktrees: worktreeModels, rootAgents: rootAgents)
Expand Down
2 changes: 1 addition & 1 deletion apps/purepoint-macos/purepoint-macos/State/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ final class AppState {
guard localProjectsSubscription == nil else { return }
let subscription = DaemonProjectsSubscription(client: DaemonEndpoint.local.makeClient())
localProjectsSubscription = subscription
Task {
Task { [weak self] in
// 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) }
Expand Down
63 changes: 56 additions & 7 deletions apps/purepoint-macos/purepoint-macos/State/ProjectState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ final class ProjectState: Identifiable {
@ObservationIgnored private let service: any WorkspaceService
#if os(macOS)
@ObservationIgnored private var manifestWatcher: ManifestWatcher?
/// Watches this project's `.pu/workspaces.json`, which a Mac working on it over SSH writes too.
@ObservationIgnored private var layoutWatcher: ManifestWatcher?
#endif
@ObservationIgnored private var gridSubscription: DaemonGridSubscription?
@ObservationIgnored private var statusSubscription: DaemonStatusSubscription?
Expand Down Expand Up @@ -140,6 +142,8 @@ final class ProjectState: Identifiable {
refreshTask?.cancel()
manifestWatcher?.stop()
manifestWatcher = nil
layoutWatcher?.stop()
layoutWatcher = nil
hasLoadedWorktrees = false

openTask = Task { [weak self] in
Expand Down Expand Up @@ -182,6 +186,12 @@ final class ProjectState: Identifiable {
self.manifestWatcher = ManifestWatcher(path: manifestPath) { [weak self] in
self?.refresh()
}
let projectId = self.projectId
self.layoutWatcher = ManifestWatcher(
path: WorkspacePersistence.filePath(projectRoot: root)
) { [weak self] in
self?.registry?.adoptLocalLayoutIfChanged(projectRoot: projectId)
}

self.channel.startBackground()
self.startGridSubscription()
Expand Down Expand Up @@ -220,6 +230,8 @@ final class ProjectState: Identifiable {
#if os(macOS)
manifestWatcher?.stop()
manifestWatcher = nil
layoutWatcher?.stop()
layoutWatcher = nil
#endif
hasLoadedWorktrees = false

Expand Down Expand Up @@ -285,13 +297,15 @@ final class ProjectState: Identifiable {
let weakSaver = WeakSaverRef(saver)
saver.onConflict { document, modified in
Task { @MainActor in
target.value?.adoptRemoteLayout(
target.value?.adoptExternalLayout(
projectId, workspaces: WorkspacePersistence.workspaces(from: document, projectId: projectId))
weakSaver.value?.loaded(modifiedMs: modified)
}
}
registry.registerRemoteProject(projectId, restored: layout.workspaces) { workspaces, active in
saver.save(workspaces, activeWorkspaceId: active)
saver.save(workspaces, activeWorkspaceId: active) {
Task { @MainActor in target.value?.layoutWritten(projectId, workspaces: workspaces) }
}
}
return
} catch {
Expand Down Expand Up @@ -323,6 +337,8 @@ final class ProjectState: Identifiable {
#if os(macOS)
manifestWatcher?.stop()
manifestWatcher = nil
layoutWatcher?.stop()
layoutWatcher = nil
#endif
}

Expand Down Expand Up @@ -354,6 +370,9 @@ final class ProjectState: Identifiable {
func refresh() {
let root = projectRoot
let svc = service
#if os(macOS)
layoutWatcher?.retry() // the layout file may not have existed when watching began
#endif

refreshTask?.cancel()
refreshTask = Task { [weak self] in
Expand Down Expand Up @@ -424,7 +443,8 @@ final class ProjectState: Identifiable {
///
/// The tab is reserved before the request goes out, so whichever arrives first — the
/// manifest write or the spawn response — the agent lands in this tab and never
/// surfaces as a workspace of its own.
/// surfaces as a workspace of its own. The request also names the tab, which the daemon
/// records on the agent, so any other Mac showing this project places it there too.
func spawnAgentForSurface(
agent: String, prompt: String, workspaceId: String, surfaceId: Int,
name: String? = nil, command: String? = nil
Expand All @@ -434,12 +454,41 @@ final class ProjectState: Identifiable {
let spawnWorktree = workspace.container.worktreeId

registry.reserveSurface(projectRoot: root, workspaceId: workspaceId, surfaceId: surfaceId)
let request = DaemonRequest.spawn(
projectRoot: projectRoot, prompt: prompt, agent: agent,
name: name, root: spawnWorktree == nil, worktree: spawnWorktree, command: command,
placement: SpawnPlacement(workspace: workspaceId, surface: surfaceId)
)

// Another Mac showing this project must see the new tab before the agent appears, or
// it has nowhere to put it: write the layout first.
registry.saveNow(projectRoot: root)
guard let saver = remoteLayoutSaver else {
sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId)
return
}
Task {
// A save that met another Mac's write merges and queues the merge; write that too.
for _ in 0..<2 {
_ = await HostIO.value { saver.flush(until: .now() + 5) }
await Task.yield()
guard registry.hasPendingSave(projectRoot: root) else { break }
registry.saveNow(projectRoot: root)
}
// The tab may have been closed, or the project closed, while the layout was written.
guard registry.workspace(id: workspaceId)?.surface(id: surfaceId)?.content == .empty else {
registry.releaseReservation(projectRoot: root, workspaceId: workspaceId, surfaceId: surfaceId)
return
}
self.sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId)
}
}

private func sendSurfaceSpawn(_ request: DaemonRequest, workspaceId: String, surfaceId: Int) {
let root = projectId
let registry = self.registry
sendDaemonRequest(
.spawn(
projectRoot: projectRoot, prompt: prompt, agent: agent,
name: name, root: spawnWorktree == nil, worktree: spawnWorktree, command: command
),
request,
onFailure: { [weak registry] in
registry?.releaseReservation(projectRoot: root, workspaceId: workspaceId, surfaceId: surfaceId)
}
Expand Down
Loading
Loading