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
14 changes: 12 additions & 2 deletions apps/purepoint-macos/purepoint-macos/Models/AgentVariant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ struct AgentVariant: Identifiable {
kind: .worktree
)

static let allVariants: [AgentVariant] = [claude, codex, opencode, terminal]
static let variantsWithWorktree: [AgentVariant] = allVariants + [worktree]
/// Agents the user added in Settings › Agents; `AgentConfigState` keeps this current.
static var addedAgents: [AgentVariant] = []

/// An agent the user added: its type is its name, and it runs `command`.
static func added(name: String, command: String) -> AgentVariant {
AgentVariant(
id: name, displayName: name, icon: "chevron.left.forwardslash.chevron.right", subtitle: command,
promptPlaceholder: "Enter prompt...", kind: .agent)
}

static var allVariants: [AgentVariant] { [claude, codex, opencode] + addedAgents + [terminal] }
static var variantsWithWorktree: [AgentVariant] { allVariants + [worktree] }
}
49 changes: 40 additions & 9 deletions apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,17 @@ nonisolated enum DaemonRequest: Encodable {
case deleteTrigger(projectRoot: String, name: String, scope: String)
case getConfig(projectRoot: String)
case getGlobalAgentSettings
/// `launchArgs` maps agent type to its machine-level launch args string; a blank string removes it.
case updateGlobalAgentSettings(agentStatusTracking: Bool? = nil, launchArgs: [String: String]? = nil)
/// `commands` and `launchArgs` map agent type to its machine-level command and launch args
/// string; a blank string removes it. A type that is not built in adds that agent.
/// `removeAgents` drops both values for each type.
case updateGlobalAgentSettings(
agentStatusTracking: Bool? = nil, launchArgs: [String: String]? = nil,
commands: [String: String]? = nil, removeAgents: [String]? = nil)
/// `launchArgs` is the free-form launch args string; `nil` clears the project value.
case updateAgentConfig(projectRoot: String, agentName: String, launchArgs: String?)
/// `command` nil keeps the project command, blank clears it. `remove` drops the agent.
case updateAgentConfig(
projectRoot: String, agentName: String, launchArgs: String?, command: String? = nil,
remove: Bool = false)
case shutdown
/// The daemon's registered projects; no project root means all of them.
case inventory(kind: String, projectRoot: String? = nil)
Expand Down Expand Up @@ -611,16 +618,20 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode(projectRoot, forKey: .key("project_root"))
case .getGlobalAgentSettings:
try container.encode("get_global_agent_settings", forKey: .key("type"))
case .updateGlobalAgentSettings(let agentStatusTracking, let launchArgs):
case .updateGlobalAgentSettings(let agentStatusTracking, let launchArgs, let commands, let removeAgents):
try container.encode("update_global_agent_settings", forKey: .key("type"))
try container.encodeIfPresent(agentStatusTracking, forKey: .key("agent_status_tracking"))
try container.encodeIfPresent(launchArgs, forKey: .key("launch_args"))
case .updateAgentConfig(let projectRoot, let agentName, let launchArgs):
try container.encodeIfPresent(commands, forKey: .key("commands"))
try container.encodeIfPresent(removeAgents, forKey: .key("remove_agents"))
case .updateAgentConfig(let projectRoot, let agentName, let launchArgs, let command, let remove):
try container.encode("update_agent_config", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
try container.encode(agentName, forKey: .key("agent_name"))
// Encode null explicitly to signal "reset to defaults" vs omitting the key
try container.encode(launchArgs, forKey: .key("launch_args"))
try container.encodeIfPresent(command, forKey: .key("command"))
if remove { try container.encode(true, forKey: .key("remove")) }
case .shutdown:
try container.encode("shutdown", forKey: .key("type"))
case .inventory(let kind, let projectRoot):
Expand Down Expand Up @@ -717,8 +728,10 @@ nonisolated enum DaemonResponse: Decodable {
case triggerList(triggers: [TriggerInfoPayload])
case triggerDetail(trigger: TriggerInfoPayload)
case configReport(defaultAgent: String, agents: [AgentConfigPayload])
/// `launchArgs` is nil when the daemon predates machine-level launch args (protocol < 11).
case globalAgentSettingsReport(agentStatusTracking: Bool, launchArgs: [String: String]? = nil)
/// `launchArgs` is nil when the daemon predates machine-level launch args (protocol < 11);
/// `commands` is nil when it predates machine-level commands.
case globalAgentSettingsReport(
agentStatusTracking: Bool, launchArgs: [String: String]? = nil, commands: [String: String]? = nil)
case inventory(InventoryReportPayload)
/// The registered projects in their saved order.
case projectOrder(projectRoots: [String])
Expand Down Expand Up @@ -845,7 +858,8 @@ nonisolated enum DaemonResponse: Decodable {
case "global_agent_settings_report":
let p = try GlobalAgentSettingsPayload(from: decoder)
self = .globalAgentSettingsReport(
agentStatusTracking: p.agentStatusTracking ?? true, launchArgs: p.launchArgs)
agentStatusTracking: p.agentStatusTracking ?? true, launchArgs: p.launchArgs,
commands: p.commands)
case "inventory":
self = .inventory(try InventoryReportPayload(from: decoder))
case "project_order":
Expand Down Expand Up @@ -903,14 +917,28 @@ nonisolated struct AgentConfigPayload: Decodable {
var machineLaunchArgs: String? = nil
/// Command line preview: base command plus resolved launch args.
var resolvedCommand: String? = nil
/// Project command; `nil` when the project leaves it unset.
var projectCommand: String? = nil
/// The command used when the project leaves it unset. `nil` from daemons that predate
/// editable commands.
var inheritedCommand: String? = nil
/// Where the agent is defined: `builtin`, `machine` or `project`.
var origin: String? = nil
/// The agent's key in the config; spawn takes it as the agent type.
var id: String? = nil

/// The agent type to spawn and to save settings under.
var agentType: String { id ?? name }

enum CodingKeys: String, CodingKey {
case name, command, interactive
case id, name, command, interactive, origin
case launchArgs = "launch_args"
case resolvedLaunchArgs = "resolved_launch_args"
case projectLaunchArgs = "project_launch_args"
case machineLaunchArgs = "machine_launch_args"
case resolvedCommand = "resolved_command"
case projectCommand = "project_command"
case inheritedCommand = "inherited_command"
}
}

Expand Down Expand Up @@ -1010,9 +1038,12 @@ private nonisolated struct GlobalAgentSettingsPayload: Decodable {
let agentStatusTracking: Bool?
/// Absent from daemons that predate machine-level launch args.
let launchArgs: [String: String]?
/// Absent from daemons that predate machine-level commands.
let commands: [String: String]?
enum CodingKeys: String, CodingKey {
case agentStatusTracking = "agent_status_tracking"
case launchArgs = "launch_args"
case commands
}
}

Expand Down
130 changes: 117 additions & 13 deletions apps/purepoint-macos/purepoint-macos/State/AgentConfigState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ final class AgentConfigState {
var agentStatusTracking = true
/// Machine-level ("all projects") launch args string per agent type.
var machineLaunchArgs: [String: String] = [:]
/// Machine-level ("all projects") command per agent type.
var machineCommands: [String: String] = [:]
var globalSettingsLoaded = false
var isSavingGlobalSettings = false
var globalError: String?
Expand Down Expand Up @@ -43,11 +45,38 @@ final class AgentConfigState {
await sendGlobalSettings(.getGlobalAgentSettings)
}

/// Save the machine-level launch args for one agent type; a blank string removes them.
func updateMachineLaunchArgs(agentName: String, launchArgs: String, projectRoot: String?) async {
/// The agents in the All Projects list: built-ins, then the ones the user added.
var machineAgentNames: [String] {
let added = Set(machineCommands.keys).union(machineLaunchArgs.keys).subtracting(AgentLaunchDefaults.builtIns)
return AgentLaunchDefaults.builtIns + added.sorted()
}

/// Save one agent's machine-level command and launch args; blank strings remove them.
func updateMachineAgent(agentName: String, command: String, launchArgs: String, projectRoot: String?) async {
await saveMachineSettings(
.updateGlobalAgentSettings(launchArgs: [agentName: launchArgs], commands: [agentName: command]),
projectRoot: projectRoot)
}

/// Add an agent for every project; its type is named after the command.
func addMachineAgent(command: String, launchArgs: String, projectRoot: String?) async {
let name = newAgentName(for: command)
await updateMachineAgent(agentName: name, command: command, launchArgs: launchArgs, projectRoot: projectRoot)
}

/// A type for a new agent that no agent at either level uses yet.
private func newAgentName(for command: String) -> String {
AgentLaunchDefaults.agentName(for: command, taken: Set(machineAgentNames + agents.map(\.agentType)))
}

func removeMachineAgent(agentName: String, projectRoot: String?) async {
await saveMachineSettings(.updateGlobalAgentSettings(removeAgents: [agentName]), projectRoot: projectRoot)
}

private func saveMachineSettings(_ request: DaemonRequest, projectRoot: String?) async {
guard !isSavingGlobalSettings else { return }
isSavingGlobalSettings = true
await sendGlobalSettings(.updateGlobalAgentSettings(launchArgs: [agentName: launchArgs]))
await sendGlobalSettings(request)
if let projectRoot { await load(projectRoot: projectRoot) }
isSavingGlobalSettings = false
}
Expand All @@ -64,14 +93,22 @@ final class AgentConfigState {
do {
try await ensureDaemon()
switch try await sendRequest(request) {
case .globalAgentSettingsReport(let tracking, let launchArgs):
case .globalAgentSettingsReport(let tracking, let launchArgs, let commands):
agentStatusTracking = tracking
globalSettingsLoaded = true
if let launchArgs {
machineLaunchArgs = launchArgs
} else if case .updateGlobalAgentSettings(_, .some) = request {
} else if case .updateGlobalAgentSettings(_, .some, _, _) = request {
globalError = Self.oldDaemonMessage("save launch args for all projects")
}
if let commands {
machineCommands = commands
publishAddedAgents()
} else if case .updateGlobalAgentSettings(_, _, let changed, let removed) = request,
changed != nil || removed != nil
{
globalError = Self.oldDaemonMessage("edit agent commands for all projects")
}
case .error(_, let message):
globalError = message
default:
Expand Down Expand Up @@ -101,12 +138,38 @@ final class AgentConfigState {
isLoading = false
}

/// Save the project's launch args string; `nil` clears it so the machine default applies.
func updateLaunchArgs(projectRoot: String, agentName: String, launchArgs: String?) async {
/// Save one agent's project command and launch args; a blank value clears it so the
/// All Projects value applies.
func updateProjectAgent(projectRoot: String, agentName: String, command: String, launchArgs: String) async {
await sendProjectUpdate(
.updateAgentConfig(
projectRoot: projectRoot, agentName: agentName,
launchArgs: projectLaunchArgs(agentName: agentName, text: launchArgs), command: command))
}

/// The project launch args to store for a blank or typed field. A stored `launchArgs: ""`
/// launches the bare command and looks blank in Settings, so a blank field keeps it.
func projectLaunchArgs(agentName: String, text: String) -> String? {
let args = text.trimmingCharacters(in: .whitespaces)
guard args.isEmpty else { return args }
return agentConfig(named: agentName)?.projectLaunchArgs == "" ? "" : nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// Add an agent to this project only; its type is named after the command.
func addProjectAgent(projectRoot: String, command: String, launchArgs: String) async {
let name = newAgentName(for: command)
await updateProjectAgent(projectRoot: projectRoot, agentName: name, command: command, launchArgs: launchArgs)
}

func removeProjectAgent(projectRoot: String, agentName: String) async {
await sendProjectUpdate(
.updateAgentConfig(projectRoot: projectRoot, agentName: agentName, launchArgs: nil, remove: true))
}

private func sendProjectUpdate(_ request: DaemonRequest) async {
error = nil
do {
let response = try await sendRequest(
.updateAgentConfig(projectRoot: projectRoot, agentName: agentName, launchArgs: launchArgs))
let response = try await sendRequest(request)
switch response {
case .configReport(let defaultAgent, let agents):
applyConfigReport(defaultAgent: defaultAgent, agents: agents)
Expand All @@ -123,17 +186,58 @@ final class AgentConfigState {
private func applyConfigReport(defaultAgent: String, agents: [AgentConfigPayload]) {
self.defaultAgent = defaultAgent
self.agents = agents
// Daemons before protocol 11 omit `resolved_command` and expect list-shaped launch args.
if agents.contains(where: { $0.resolvedCommand == nil }) {
self.error = Self.oldDaemonMessage("edit launch args")
publishAddedAgents()
// Daemons before editable commands omit `inherited_command`.
if agents.contains(where: { $0.inheritedCommand == nil }) {
self.error = Self.oldDaemonMessage("edit agent commands")
}
}

/// Offer the agents the user added wherever a new agent can be started.
private func publishAddedAgents() {
var commands: [String: String] = [:]
for agent in agents where !AgentLaunchDefaults.builtIns.contains(agent.agentType) {
commands[agent.agentType] = agent.command
}
for name in machineAgentNames where !AgentLaunchDefaults.builtIns.contains(name) && commands[name] == nil {
commands[name] = machineCommands[name] ?? name
}
AgentVariant.addedAgents = commands.keys.sorted().map { AgentVariant.added(name: $0, command: commands[$0] ?? $0) }
}

private static func oldDaemonMessage(_ action: String) -> String {
"Update the PurePoint daemon to \(action). Restart it after updating."
}

func agentConfig(named name: String) -> AgentConfigPayload? {
agents.first { $0.name == name }
agents.first { $0.agentType == name }
}
}

/// What PurePoint knows about agent commands before the user changes anything.
enum AgentLaunchDefaults {
/// Agent types PurePoint ships, in the order Settings lists them.
static let builtIns = ["claude", "codex", "opencode"]

/// Words that start another program rather than name the agent.
private static let runners: Set<String> = [
"env", "exec", "npx", "pnpx", "bunx", "uvx", "pipx", "node", "bun", "deno", "python", "python3",
]

/// An agent type for a new agent: the program the command runs (`npx aider` → `aider`,
/// `scripts/agent-launch.sh goose` → `goose`), made unique among `taken`.
static func agentName(for command: String, taken: Set<String>) -> String {
let words = command.split(whereSeparator: \.isWhitespace).map { (String($0) as NSString).lastPathComponent }
let program = words.first { !$0.hasPrefix("-") && !runners.contains($0) && !$0.hasSuffix(".sh") }
?? words.first ?? ""
let stem = ((program as NSString).deletingPathExtension).lowercased()
var slug = String(stem.map { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") ? $0 : "-" })
while let first = slug.first, first == "-" || first == "_" { slug.removeFirst() }
slug = String(slug.prefix(60))
if slug.isEmpty { slug = "agent" }
guard taken.contains(slug) else { return slug }
var n = 2
while taken.contains("\(slug)-\(n)") { n += 1 }
return "\(slug)-\(n)"
}
}
Loading
Loading