diff --git a/apps/purepoint-macos/purepoint-macos/Models/AgentVariant.swift b/apps/purepoint-macos/purepoint-macos/Models/AgentVariant.swift index 5b906d89..eb7b0051 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/AgentVariant.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/AgentVariant.swift @@ -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] } } diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index c36b3a33..b5d77b5e 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -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) @@ -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): @@ -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]) @@ -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": @@ -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" } } @@ -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 } } diff --git a/apps/purepoint-macos/purepoint-macos/State/AgentConfigState.swift b/apps/purepoint-macos/purepoint-macos/State/AgentConfigState.swift index 9748a317..85fd2547 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AgentConfigState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AgentConfigState.swift @@ -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? @@ -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 } @@ -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: @@ -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 + } + + /// 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) @@ -123,10 +186,23 @@ 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 { @@ -134,6 +210,34 @@ final class AgentConfigState { } 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 = [ + "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 { + 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)" } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Settings/SettingsAgentsView.swift b/apps/purepoint-macos/purepoint-macos/Views/Settings/SettingsAgentsView.swift index 98d103c4..3eed4e13 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Settings/SettingsAgentsView.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Settings/SettingsAgentsView.swift @@ -18,28 +18,9 @@ struct SettingsAgentsView: View { )) .toggleStyle(.switch) .disabled(!state.globalSettingsLoaded || state.isSavingGlobalSettings) - Text("Show whether Claude Code, Codex, OpenCode and Pi agents are working, idle or waiting on you. Works with any version of those tools. Applies to new and resumed sessions.") + Text("Show whether Claude Code, Codex, OpenCode and Point Guard agents are working, idle or waiting on you. Works with any version of those tools. Applies to new and resumed sessions.") .font(.system(size: 11)) .foregroundStyle(.secondary) - Divider() - Text("Launch args for all projects") - .font(.system(size: 12, weight: .medium)) - Text("Appended to the command in every project on this Mac that doesn't set its own. Agents launch with the bare command when this is empty.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - ForEach(launchArgsAgents, id: \.self) { agent in - LaunchArgsRow( - command: state.agentConfig(named: agent)?.command ?? agent, - stored: state.machineLaunchArgs[agent] ?? "", - placeholder: launchArgsPlaceholder(for: agent) - ) { text in - Task { - await state.updateMachineLaunchArgs( - agentName: agent, launchArgs: text, projectRoot: appState.activeLocalProjectRoot) - } - } - .disabled(!state.globalSettingsLoaded || state.isSavingGlobalSettings) - } if let error = state.globalError { Text(error).font(.system(size: 12)).foregroundStyle(.red) Button("Retry") { Task { await state.loadGlobalSettings() } } @@ -54,13 +35,46 @@ struct SettingsAgentsView: View { #endif } .groupBoxStyle(SettingsGroupBoxStyle()) + + GroupBox { + AgentLaunchTable( + caption: "What runs in the terminal: launch command, then arguments.", + rows: state.machineAgentNames.map { name in + AgentLaunchRowModel( + agentType: name, + command: state.machineCommands[name] ?? "", + launchArgs: state.machineLaunchArgs[name] ?? "", + commandHint: name, + launchArgsHint: "", + removable: !AgentLaunchDefaults.builtIns.contains(name)) + }, + onSave: { row, command, args in + await state.updateMachineAgent( + agentName: row.agentType, command: command, launchArgs: args, + projectRoot: appState.activeLocalProjectRoot) + }, + onAdd: { command, args in + await state.addMachineAgent( + command: command, launchArgs: args, projectRoot: appState.activeLocalProjectRoot) + }, + onRemove: { row in + await state.removeMachineAgent( + agentName: row.agentType, projectRoot: appState.activeLocalProjectRoot) + } + ) + .disabled(!state.globalSettingsLoaded || state.isSavingGlobalSettings) + } label: { + Label("Agents — All Projects", systemImage: "desktopcomputer") + } + .groupBoxStyle(SettingsGroupBoxStyle()) + if let projectRoot = appState.activeLocalProjectRoot { SettingsAgentsContentView(projectRoot: projectRoot) } else { ContentUnavailableView( "No Project Open", systemImage: "folder.badge.questionmark", - description: Text("Open a project to configure agent launch settings.") + description: Text("Open a project to set its agents.") ) } } @@ -95,38 +109,23 @@ struct SettingsAgentsContentView: View { .frame(maxWidth: .infinity, alignment: .center) } else { GroupBox { - VStack(alignment: .leading, spacing: 10) { - Text("Appended to the command for this project. Leave empty to use the launch args for all projects. To launch the bare command here regardless, set launchArgs: \"\" for the agent in .pu/config.yaml.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - ForEach(launchArgsAgents, id: \.self) { agent in - if let payload = state.agentConfig(named: agent) { - VStack(alignment: .leading, spacing: 2) { - LaunchArgsRow( - command: payload.command, - stored: payload.projectLaunchArgs - ?? payload.launchArgs?.joined(separator: " ") ?? "", - placeholder: payload.projectLaunchArgs == "" - ? "" : payload.machineLaunchArgs ?? launchArgsPlaceholder(for: agent) - ) { text in - Task { - await state.updateLaunchArgs( - projectRoot: projectRoot, agentName: agent, - launchArgs: text.trimmingCharacters(in: .whitespaces).isEmpty ? nil : text) - } - } - if payload.projectLaunchArgs == "" { - Text("Set to empty in .pu/config.yaml: launches the bare command, ignoring the launch args for all projects.") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - } - resolvedCommandView(payload) - } - } + AgentLaunchTable( + caption: "Empty fields use the All Projects value, shown in grey.", + rows: state.agents.map(projectRow), + onSave: { row, command, args in + await state.updateProjectAgent( + projectRoot: projectRoot, agentName: row.agentType, command: command, + launchArgs: args) + }, + onAdd: { command, args in + await state.addProjectAgent(projectRoot: projectRoot, command: command, launchArgs: args) + }, + onRemove: { row in + await state.removeProjectAgent(projectRoot: projectRoot, agentName: row.agentType) } - } + ) } label: { - Label("Launch Args for This Project", systemImage: "terminal") + Label("Agents — This Project", systemImage: "folder") } .groupBoxStyle(SettingsGroupBoxStyle()) } @@ -135,68 +134,163 @@ struct SettingsAgentsContentView: View { await state.load(projectRoot: projectRoot) } } + + /// A project row's grey hints are exactly what runs when its field is left empty. + private func projectRow(_ payload: AgentConfigPayload) -> AgentLaunchRowModel { + // An explicit empty `launchArgs` in .pu/config.yaml launches the bare command. + let launchArgsHint = payload.projectLaunchArgs == "" ? "" : payload.machineLaunchArgs ?? "" + return AgentLaunchRowModel( + agentType: payload.agentType, + command: payload.projectCommand ?? "", + launchArgs: payload.projectLaunchArgs ?? "", + commandHint: payload.inheritedCommand ?? payload.command, + launchArgsHint: launchArgsHint, + removable: payload.origin == "project") + } } -// MARK: - Launch Args +// MARK: - Launch table -/// Agent types whose launch args can be configured. PurePoint knows only their -/// base commands; extra args are a free-form string the user owns. -private let launchArgsAgents = ["claude", "codex", "opencode"] +/// One agent in a launch table: what is stored at this level, and what runs when it is empty. +struct AgentLaunchRowModel: Identifiable { + let agentType: String + let command: String + let launchArgs: String + let commandHint: String + let launchArgsHint: String + let removable: Bool -/// An example flag each CLI accepts, shown when the field is empty. -private func launchArgsPlaceholder(for agent: String) -> String { - switch agent { - case "claude": "--model opus" - case "codex": "--search" - case "opencode": "--auto" - default: "" - } + var id: String { agentType } } -/// One agent's launch args: the read-only base command and a free-form text field. -struct LaunchArgsRow: View { - let command: String - let stored: String - let placeholder: String - let onSave: (String) -> Void +/// Launch command and arguments, one row per agent, plus rows for new agents. +struct AgentLaunchTable: View { + let caption: String + let rows: [AgentLaunchRowModel] + let onSave: @MainActor (AgentLaunchRowModel, String, String) async -> Void + let onAdd: @MainActor (String, String) async -> Void + let onRemove: @MainActor (AgentLaunchRowModel) async -> Void - @State private var text = "" + /// New rows not saved yet: an agent exists once its command is entered. + @State private var drafts: [UUID] = [] var body: some View { - LabeledContent { - HStack(spacing: 6) { - TextField(placeholder, text: $text) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) - .onSubmit { save() } - Button("Save") { save() } - .controlSize(.small) - .disabled(text == stored) - } - } label: { - Text(command) - .font(.system(size: 12, design: .monospaced)) + VStack(alignment: .leading, spacing: 8) { + Text(caption) + .font(.system(size: 11)) .foregroundStyle(.secondary) - .frame(minWidth: 70, alignment: .leading) + VStack(alignment: .leading, spacing: 8) { + header + ForEach(rows) { row in savedRow(row) } + ForEach(drafts, id: \.self) { draft in draftRow(draft) } + } + Button { + drafts.append(UUID()) + } label: { + Label("Add agent", systemImage: "plus") + } + .controlSize(.small) } - .onAppear { text = stored } - .onChange(of: stored) { text = stored } } - private func save() { - guard text != stored else { return } - onSave(text) + private var header: some View { + HStack(spacing: 8) { + Text("Launch command").frame(width: AgentLaunchRowView.commandWidth, alignment: .leading) + Text("Arguments").frame(maxWidth: .infinity, alignment: .leading) + Color.clear.frame(width: AgentLaunchRowView.removeWidth, height: 1) + } + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + + private func savedRow(_ row: AgentLaunchRowModel) -> AgentLaunchRowView { + var remove: (@MainActor () async -> Void)? + if row.removable { + remove = { await onRemove(row) } + } + return AgentLaunchRowView( + command: row.command, launchArgs: row.launchArgs, + commandHint: row.commandHint, launchArgsHint: row.launchArgsHint, + onSave: { command, args in await onSave(row, command, args) }, + onRemove: remove) + } + + private func draftRow(_ draft: UUID) -> AgentLaunchRowView { + AgentLaunchRowView( + command: "", launchArgs: "", commandHint: "command", launchArgsHint: "", + onSave: { command, args in + guard !command.trimmingCharacters(in: .whitespaces).isEmpty else { return } + drafts.removeAll { $0 == draft } + await onAdd(command, args) + }, + onRemove: { drafts.removeAll { $0 == draft } }) } } -// MARK: - Shared +/// Two text fields, saved together when either is submitted or loses focus. +private struct AgentLaunchRowView: View { + let command: String + let launchArgs: String + let commandHint: String + let launchArgsHint: String + let onSave: @MainActor (String, String) async -> Void + /// Nil for agents that can't be removed at this level. + let onRemove: (@MainActor () async -> Void)? + + static let commandWidth: CGFloat = 200 + static let removeWidth: CGFloat = 20 + + private enum Field { case command, launchArgs } -private func resolvedCommandView(_ payload: AgentConfigPayload) -> some View { - Text(payload.resolvedCommand ?? ([payload.command] + payload.resolvedLaunchArgs).joined(separator: " ")) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) + @State private var commandText = "" + @State private var argsText = "" + @FocusState private var focus: Field? + + var body: some View { + HStack(spacing: 8) { + field(.command, hint: commandHint, text: $commandText, label: "Launch command") + .frame(width: Self.commandWidth) + field(.launchArgs, hint: launchArgsHint, text: $argsText, label: "Arguments") + .frame(maxWidth: .infinity) + if let onRemove { + Button { + Task { await onRemove() } + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .accessibilityLabel("Remove agent") + .frame(width: Self.removeWidth) + } else { + Color.clear.frame(width: Self.removeWidth, height: 1) + } + } + .onAppear { + commandText = command + argsText = launchArgs + } + .onChange(of: command) { commandText = command } + .onChange(of: launchArgs) { argsText = launchArgs } + // Moving between the row's two fields keeps focus in the row; leaving it saves. + .onChange(of: focus) { if focus == nil { save() } } + } + + private func field(_ which: Field, hint: String, text: Binding, label: String) -> some View { + TextField(label, text: text, prompt: Text(hint)) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .focused($focus, equals: which) + .onSubmit { save() } + } + + private func save() { + guard commandText != command || argsText != launchArgs else { return } + let (command, args) = (commandText, argsText) + Task { await onSave(command, args) } + } } diff --git a/apps/purepoint-macos/purepoint-macosTests/purepoint_macosTests.swift b/apps/purepoint-macos/purepoint-macosTests/purepoint_macosTests.swift index 44fea025..ee1b4b90 100644 --- a/apps/purepoint-macos/purepoint-macosTests/purepoint_macosTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/purepoint_macosTests.swift @@ -20,7 +20,7 @@ struct GlobalAgentSettingsTests { case .getGlobalAgentSettings: calls.append("load") return .globalAgentSettingsReport(agentStatusTracking: true) - case .updateGlobalAgentSettings(let tracking, _): + case .updateGlobalAgentSettings(let tracking, _, _, _): calls.append("save") return .globalAgentSettingsReport(agentStatusTracking: tracking ?? true) default: @@ -77,14 +77,16 @@ struct GlobalAgentSettingsTests { let state = AgentConfigState( ensureDaemon: {}, sendRequest: { request in - guard case .updateGlobalAgentSettings(_, let launchArgs) = request else { + guard case .updateGlobalAgentSettings(_, let launchArgs, let commands, _) = request else { return .error(code: "TEST", message: "Unexpected request") } - return .globalAgentSettingsReport(agentStatusTracking: true, launchArgs: launchArgs ?? [:]) + return .globalAgentSettingsReport( + agentStatusTracking: true, launchArgs: launchArgs ?? [:], commands: commands ?? [:]) } ) - await state.updateMachineLaunchArgs(agentName: "claude", launchArgs: "--model opus", projectRoot: nil) + await state.updateMachineAgent(agentName: "claude", command: "", launchArgs: "--model opus", projectRoot: nil) #expect(state.machineLaunchArgs == ["claude": "--model opus"]) + #expect(state.globalError == nil) } @Test func oldDaemonMachineLaunchArgsSaveReportsUpdateNeeded() async { @@ -101,7 +103,7 @@ struct GlobalAgentSettingsTests { } ) await state.loadGlobalSettings() - await state.updateMachineLaunchArgs(agentName: "claude", launchArgs: "--model sonnet", projectRoot: nil) + await state.updateMachineAgent(agentName: "claude", command: "", launchArgs: "--model sonnet", projectRoot: nil) #expect(state.globalError?.contains("Update the PurePoint daemon") == true) #expect(state.machineLaunchArgs == ["claude": "--model opus"], "An old daemon's report must not wipe the field") } @@ -114,7 +116,7 @@ struct GlobalAgentSettingsTests { ) await state.load(projectRoot: "/p") #expect(state.error?.contains("Update the PurePoint daemon") == true) - await state.updateLaunchArgs(projectRoot: "/p", agentName: "claude", launchArgs: "--model opus") + await state.updateProjectAgent(projectRoot: "/p", agentName: "claude", command: "", launchArgs: "--model opus") #expect(state.error?.contains("Update the PurePoint daemon") == true) } @@ -139,12 +141,92 @@ struct GlobalAgentSettingsTests { #expect(json["launch_args"] == nil) // Daemons from before Codex YOLO was removed still send `codex_yolo`; it is ignored. let response = try JSONDecoder().decode(DaemonResponse.self, from: Data(#"{"type":"global_agent_settings_report","codex_yolo":true}"#.utf8)) - guard case .globalAgentSettingsReport(let tracking, let launchArgs) = response else { + guard case .globalAgentSettingsReport(let tracking, let launchArgs, let commands) = response else { Issue.record("Expected global settings response") return } #expect(tracking, "Older daemons always track status") #expect(launchArgs == nil, "Older daemons have no machine launch args") + #expect(commands == nil, "Older daemons have no machine commands") + } + + @Test func projectAgentSaveSendsCommandAndArgsTogether() throws { + let data = try JSONEncoder().encode(DaemonRequest.updateAgentConfig( + projectRoot: "/p", agentName: "codex", launchArgs: "--search", command: "scripts/agent-launch.sh codex")) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["command"] as? String == "scripts/agent-launch.sh codex") + #expect(json["launch_args"] as? String == "--search") + #expect(json["remove"] == nil) + let removed = try JSONEncoder().encode(DaemonRequest.updateAgentConfig( + projectRoot: "/p", agentName: "aider", launchArgs: nil, remove: true)) + let removedJson = try #require(JSONSerialization.jsonObject(with: removed) as? [String: Any]) + #expect(removedJson["remove"] as? Bool == true) + #expect(removedJson["command"] == nil, "Left out, the project command is kept") + } + + @Test func blankProjectArgsKeepAnExplicitBareLaunch() async { + let bare = AgentConfigPayload( + name: "claude", command: "claude", launchArgs: [], resolvedLaunchArgs: [], interactive: true, + projectLaunchArgs: "", machineLaunchArgs: "--dangerously-skip-permissions", resolvedCommand: "claude", + inheritedCommand: "claude", origin: "builtin", id: "claude") + let inherits = AgentConfigPayload( + name: "codex", command: "codex", launchArgs: nil, resolvedLaunchArgs: [], interactive: true, + resolvedCommand: "codex", inheritedCommand: "codex", origin: "builtin", id: "codex") + let state = AgentConfigState( + ensureDaemon: {}, + sendRequest: { _ in .configReport(defaultAgent: "claude", agents: [bare, inherits]) } + ) + await state.load(projectRoot: "/p") + #expect(state.projectLaunchArgs(agentName: "claude", text: " ") == "", "Editing only the command keeps the bare launch") + #expect(state.projectLaunchArgs(agentName: "codex", text: "") == nil, "A blank field inherits") + #expect(state.projectLaunchArgs(agentName: "codex", text: " --search ") == "--search") + } + + @Test func addedMachineAgentIsNamedAfterItsCommandAndOffered() async { + var sent: DaemonRequest? + let state = AgentConfigState( + ensureDaemon: {}, + sendRequest: { request in + sent = request + guard case .updateGlobalAgentSettings(_, let launchArgs, let commands, _) = request else { + return .error(code: "TEST", message: "Unexpected request") + } + return .globalAgentSettingsReport( + agentStatusTracking: true, launchArgs: launchArgs ?? [:], commands: commands ?? [:]) + } + ) + await state.addMachineAgent(command: "npx aider", launchArgs: "--yes", projectRoot: nil) + guard case .updateGlobalAgentSettings(_, let launchArgs, let commands, _) = sent else { + Issue.record("Expected a machine settings save") + return + } + #expect(commands == ["aider": "npx aider"]) + #expect(launchArgs == ["aider": "--yes"]) + #expect(state.machineAgentNames == ["claude", "codex", "opencode", "aider"]) + #expect(AgentVariant.allVariants.contains { $0.id == "aider" && $0.subtitle == "npx aider" }) + AgentVariant.addedAgents = [] + } + + @Test func newAgentNamesComeFromTheProgramTheyRun() { + #expect(AgentLaunchDefaults.agentName(for: "aider", taken: []) == "aider") + #expect(AgentLaunchDefaults.agentName(for: "scripts/agent-launch.sh goose", taken: []) == "goose") + #expect(AgentLaunchDefaults.agentName(for: "~/bin/My-Tool --fast", taken: []) == "my-tool") + #expect(AgentLaunchDefaults.agentName(for: "run-agent.sh", taken: []) == "run-agent") + #expect(AgentLaunchDefaults.agentName(for: "claude --model opus", taken: ["claude"]) == "claude-2") + #expect(AgentLaunchDefaults.agentName(for: "--", taken: []) == "agent") + } + + @Test func configReportDecodesCommandsAndOrigin() throws { + let json = #"{"type":"config_report","default_agent":"claude","agents":[{"id":"aider","name":"aider","command":"aider","launch_args":null,"resolved_launch_args":[],"interactive":true,"resolved_command":"aider","project_command":"aider","inherited_command":"aider","origin":"project"}]}"# + let response = try JSONDecoder().decode(DaemonResponse.self, from: Data(json.utf8)) + guard case .configReport(_, let agents) = response, let aider = agents.first else { + Issue.record("Expected config report") + return + } + #expect(aider.agentType == "aider") + #expect(aider.projectCommand == "aider") + #expect(aider.inheritedCommand == "aider") + #expect(aider.origin == "project") } @Test func statusTrackingUpdateSendsOnlyItsOwnField() async throws { diff --git a/crates/pu-cli/src/output/mod.rs b/crates/pu-cli/src/output/mod.rs index 58b8c6a3..e200c05f 100644 --- a/crates/pu-cli/src/output/mod.rs +++ b/crates/pu-cli/src/output/mod.rs @@ -345,9 +345,13 @@ pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliErr Response::GlobalAgentSettingsReport { agent_status_tracking, launch_args, + commands, .. } => { println!("Agent status tracking: {agent_status_tracking}"); + for (agent, command) in commands.iter().flatten() { + println!("Command for {agent} (all projects): {command}"); + } for (agent, args) in launch_args { println!("Launch args for {agent} (all projects): {args}"); } diff --git a/crates/pu-cli/src/output/system.rs b/crates/pu-cli/src/output/system.rs index 2c4d1279..e8751b46 100644 --- a/crates/pu-cli/src/output/system.rs +++ b/crates/pu-cli/src/output/system.rs @@ -97,6 +97,9 @@ mod tests { project_launch_args: project.map(str::to_owned), machine_launch_args: machine.map(str::to_owned), resolved_command: None, + project_command: None, + inherited_command: None, + origin: None, } } diff --git a/crates/pu-core/src/config.rs b/crates/pu-core/src/config.rs index 8de701d0..0fe7c0c7 100644 --- a/crates/pu-core/src/config.rs +++ b/crates/pu-core/src/config.rs @@ -10,6 +10,11 @@ pub struct GlobalAgentSettings { /// Observe agent status (working/idle/question) through the native adapters. #[serde(default = "crate::serde_defaults::default_true")] pub agent_status_tracking: bool, + /// Per-agent command for every project on this machine, used when a + /// project's `.pu/config.yaml` leaves `command` unset. An agent type that + /// is not built in is an agent the user added. + #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")] + pub commands: indexmap::IndexMap, /// Per-agent-type launch args for every project on this machine, used when a /// project's `.pu/config.yaml` leaves `launchArgs` unset. #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")] @@ -20,6 +25,7 @@ impl Default for GlobalAgentSettings { fn default() -> Self { Self { agent_status_tracking: true, + commands: indexmap::IndexMap::new(), launch_args: indexmap::IndexMap::new(), } } @@ -29,8 +35,28 @@ impl Default for GlobalAgentSettings { #[derive(Debug, Default)] pub struct GlobalAgentSettingsUpdate { pub agent_status_tracking: Option, + /// Machine-level command per agent type; a blank string removes it. + pub commands: indexmap::IndexMap, /// Machine-level launch args string per agent type; a blank string removes it. pub launch_args: indexmap::IndexMap, + /// Agent types whose machine-level command and launch args are removed. + pub remove_agents: Vec, +} + +fn check_agent_name(name: &str) -> Result<(), PuError> { + if crate::types::is_valid_agent_name(name) { + Ok(()) + } else { + Err(PuError::InvalidArgument(format!( + "invalid agent name {name:?}: use lowercase letters, digits, - and _" + ))) + } +} + +/// A blank string means "unset"; anything else is trimmed. +fn non_blank(text: &str) -> Option { + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) } pub fn load_global_agent_settings() -> Result { @@ -48,14 +74,16 @@ pub fn save_global_agent_settings( ) -> Result { use fs4::FileExt; // Validate before touching the file so a bad value never replaces a good one. - let defaults = crate::types::default_agents(); + for agent in update + .commands + .keys() + .chain(update.launch_args.keys()) + .chain(&update.remove_agents) + { + check_agent_name(agent)?; + } let mut launch_args = Vec::with_capacity(update.launch_args.len()); for (agent, text) in update.launch_args { - if !defaults.contains_key(&agent) { - return Err(PuError::InvalidArgument(format!( - "unknown agent type: {agent}" - ))); - } let args = if text.trim().is_empty() { None } else { @@ -73,6 +101,16 @@ pub fn save_global_agent_settings( if let Some(enabled) = update.agent_status_tracking { settings.agent_status_tracking = enabled; } + for (agent, command) in update.commands { + match non_blank(&command) { + Some(command) => { + settings.commands.insert(agent, command); + } + None => { + settings.commands.shift_remove(&agent); + } + } + } for (agent, args) in launch_args { match args { Some(args) => { @@ -83,6 +121,10 @@ pub fn save_global_agent_settings( } } } + for agent in update.remove_agents { + settings.commands.shift_remove(&agent); + settings.launch_args.shift_remove(&agent); + } let yaml = serde_yaml_ng::to_string(&settings)?; let tmp_path = path.with_extension("yaml.tmp"); let mut tmp = std::fs::File::create(&tmp_path)?; @@ -92,12 +134,22 @@ pub fn save_global_agent_settings( Ok(settings) } +/// Add the machine's agents and settings to a project config, then resolve each +/// agent's command: project, else machine, else the built-in default. fn apply_global_agent_settings(config: &mut Config, settings: &GlobalAgentSettings) { - for (name, args) in &settings.launch_args { - if let Some(agent) = config.agents.get_mut(name) { - agent.machine_launch_args = Some(args.0.clone()); + for name in settings.commands.keys().chain(settings.launch_args.keys()) { + if !config.agents.contains_key(name) { + config.agents.insert(name.clone(), AgentConfig::named(name)); } } + for (name, agent) in config.agents.iter_mut() { + agent.machine_command = settings.commands.get(name).cloned(); + agent.machine_launch_args = settings.launch_args.get(name).map(|args| args.0.clone()); + agent.command = agent + .project_command + .clone() + .unwrap_or_else(|| agent.inherited_command(name)); + } } pub fn load_config(project_root: &Path) -> Config { @@ -113,6 +165,9 @@ fn load_config_result(project_root: &Path) -> Result { let mut config = match std::fs::read_to_string(&path) { Ok(content) => { let mut config: Config = serde_yaml_ng::from_str(&content)?; + for agent in config.agents.values_mut() { + agent.project_command = non_blank(&std::mem::take(&mut agent.command)); + } // Fill in any agents missing from file with code defaults for (name, agent) in crate::types::default_agents() { config.agents.entry(name).or_insert(agent); @@ -130,23 +185,26 @@ pub fn resolve_agent<'a>(config: &'a Config, name: &str) -> Option<&'a AgentConf config.agents.get(name) } -/// Update a specific agent's launch_args in the project config. +/// Changes to one agent in a project config. +#[derive(Debug, Default)] +pub struct AgentConfigUpdate { + /// `None` keeps the stored command; a blank string clears it. + pub command: Option, + /// The launch args to store; `None` clears them. + pub launch_args: Option>, + /// Drop the agent's entry from the project config. + pub remove: bool, +} + +/// Update one agent's command and launch args in the project config. /// Loads existing config, modifies the named agent, writes back to YAML. /// Returns the updated (merged) config. -/// -/// Only known agent types (from `default_agents()`) are accepted. -/// Unknown agent names return an error to prevent config pollution. pub fn update_agent_config( project_root: &Path, agent_name: &str, - launch_args: Option>, + update: AgentConfigUpdate, ) -> Result { - let defaults = crate::types::default_agents(); - if !defaults.contains_key(agent_name) { - return Err(PuError::InvalidArgument(format!( - "unknown agent type: {agent_name}" - ))); - } + check_agent_name(agent_name)?; let path = paths::config_path(project_root); // Hold an exclusive lock across the read-modify-write so overlapping updates @@ -168,23 +226,19 @@ pub fn update_agent_config( Err(e) => return Err(PuError::Io(e)), }; - // Ensure the agent entry exists - let agent = raw_config - .agents - .entry(agent_name.to_string()) - .or_insert_with(|| { - let defaults = crate::types::default_agents(); - defaults.get(agent_name).cloned().unwrap_or(AgentConfig { - name: agent_name.to_string(), - command: agent_name.to_string(), - prompt_flag: None, - interactive: true, - launch_args: None, - machine_launch_args: None, - }) - }); - - agent.launch_args = launch_args; + if update.remove { + raw_config.agents.shift_remove(agent_name); + } else { + // Only what the user sets is written: an unset command stays inherited. + let agent = raw_config + .agents + .entry(agent_name.to_string()) + .or_insert_with(|| AgentConfig::named(agent_name)); + if let Some(command) = update.command { + agent.command = non_blank(&command).unwrap_or_default(); + } + agent.launch_args = update.launch_args; + } // Write back to YAML atomically: temp file, fsync, rename. A plain truncating // write can leave a torn file (e.g. a duplicated `envFiles:` tail) that fails @@ -217,9 +271,11 @@ envFiles: # # To set your own default for every project on this machine instead, put it in # ~/.pu/agent-settings.yaml: +# commands: +# claude: "claude" # launchArgs: # claude: "--dangerously-skip-permissions" -# A project launchArgs (even "") takes precedence over the machine default. +# A project command or launchArgs (even "") takes precedence over the machine default. # agents: # claude: # name: claude @@ -605,6 +661,113 @@ agents: assert_eq!(resolved(&config, "opencode"), ["--model", "x/y"]); } + #[test] + fn given_machine_command_should_apply_where_the_project_leaves_it_unset() { + // given + let home = TestHome::new(); + write_machine_settings( + &home, + "commands:\n claude: scripts/agent-launch.sh claude\n codex: scripts/agent-launch.sh codex\n", + ); + let project = TempDir::new().unwrap(); + std::fs::create_dir_all(paths::pu_dir(project.path())).unwrap(); + std::fs::write( + paths::config_path(project.path()), + "agents:\n claude:\n name: claude\n launchArgs: --model opus\n codex:\n name: codex\n command: codex\n", + ) + .unwrap(); + // when + let config = load_config_strict(project.path()).unwrap(); + // then — the project's command wins, a missing one inherits + let claude = &config.agents["claude"]; + assert_eq!(claude.command, "scripts/agent-launch.sh claude"); + assert_eq!(claude.project_command, None); + let codex = &config.agents["codex"]; + assert_eq!(codex.command, "codex"); + assert_eq!(codex.project_command.as_deref(), Some("codex")); + assert_eq!( + codex.inherited_command("codex"), + "scripts/agent-launch.sh codex" + ); + assert_eq!(config.agents["opencode"].command, "opencode"); + } + + #[test] + fn given_added_machine_agent_should_load_in_every_project() { + // given + let _home = TestHome::new(); + save_global_agent_settings(GlobalAgentSettingsUpdate { + commands: [("aider".to_string(), "aider".to_string())] + .into_iter() + .collect(), + launch_args: [("aider".to_string(), "--yes".to_string())] + .into_iter() + .collect(), + ..Default::default() + }) + .unwrap(); + let project = TempDir::new().unwrap(); + // when + let config = load_config_strict(project.path()).unwrap(); + // then + assert_eq!(config.agents["aider"].command, "aider"); + assert_eq!(resolved(&config, "aider"), ["--yes"]); + // when — the agent is removed + save_global_agent_settings(GlobalAgentSettingsUpdate { + remove_agents: vec!["aider".into()], + ..Default::default() + }) + .unwrap(); + // then + let config = load_config_strict(project.path()).unwrap(); + assert!(!config.agents.contains_key("aider")); + } + + #[test] + fn given_invalid_agent_name_should_refuse_machine_save() { + let _home = TestHome::new(); + let result = save_global_agent_settings(GlobalAgentSettingsUpdate { + commands: [("My Agent".to_string(), "x".to_string())] + .into_iter() + .collect(), + ..Default::default() + }); + assert_eq!(result.unwrap_err().code(), "INVALID_ARGUMENT"); + } + + #[test] + fn given_update_agent_config_command_should_set_clear_and_remove() { + // given + let _home = TestHome::new(); + let project = TempDir::new().unwrap(); + let root = project.path(); + std::fs::create_dir_all(paths::pu_dir(root)).unwrap(); + let update = |command: Option<&str>, remove: bool| AgentConfigUpdate { + command: command.map(str::to_string), + launch_args: Some(vec!["--yes".into()]), + remove, + }; + // when — only args are set, the command stays inherited + update_agent_config(root, "claude", update(None, false)).unwrap(); + let written = std::fs::read_to_string(paths::config_path(root)).unwrap(); + assert!(!written.contains("command:"), "{written}"); + // when — a command is set + let config = update_agent_config(root, "aider", update(Some(" aider "), false)).unwrap(); + assert_eq!(config.agents["aider"].command, "aider"); + assert_eq!( + config.agents["aider"].project_command.as_deref(), + Some("aider") + ); + // when — the command is cleared + let config = update_agent_config(root, "aider", update(Some(""), false)).unwrap(); + assert_eq!(config.agents["aider"].project_command, None); + assert_eq!(config.agents["aider"].command, "aider"); + // when — the agent is removed + let config = update_agent_config(root, "aider", update(None, true)).unwrap(); + assert!(!config.agents.contains_key("aider")); + assert_eq!(resolved(&config, "claude"), ["--yes"]); + } + #[test] fn given_malformed_machine_launch_args_should_fail_config_resolution() { let home = TestHome::new(); @@ -653,15 +816,22 @@ agents: }; save_global_agent_settings(update("claude", "--verbose")).unwrap(); let bad_quote = save_global_agent_settings(update("claude", "--x 'oops")).unwrap_err(); - let unknown = save_global_agent_settings(update("nope", "--verbose")).unwrap_err(); + let bad_name = save_global_agent_settings(update("No Pe", "--verbose")).unwrap_err(); assert_eq!(bad_quote.code(), "INVALID_ARGUMENT"); - assert_eq!(unknown.code(), "INVALID_ARGUMENT"); + assert_eq!(bad_name.code(), "INVALID_ARGUMENT"); assert_eq!( load_global_agent_settings().unwrap().launch_args["claude"].0, ["--verbose"] ); } + fn args(launch_args: Option>) -> AgentConfigUpdate { + AgentConfigUpdate { + launch_args, + ..Default::default() + } + } + #[test] fn given_update_agent_config_should_write_launch_args() { let tmp = TempDir::new().unwrap(); @@ -674,11 +844,11 @@ agents: let config = update_agent_config( root, "claude", - Some(vec![ + args(Some(vec![ "--verbose".to_string(), "--model".to_string(), "opus".to_string(), - ]), + ])), ) .unwrap(); @@ -714,7 +884,10 @@ agents: update_agent_config( root, "claude", - Some(vec!["--append-system-prompt".into(), "be terse".into()]), + args(Some(vec![ + "--append-system-prompt".into(), + "be terse".into(), + ])), ) .unwrap(); @@ -733,7 +906,7 @@ agents: let yaml = "defaultAgent: claude\nagents:\n claude:\n name: claude\n command: claude\n launchArgs:\n - '--verbose'\n"; std::fs::write(crate::paths::config_path(root), yaml).unwrap(); - let config = update_agent_config(root, "claude", None).unwrap(); + let config = update_agent_config(root, "claude", args(None)).unwrap(); let claude = &config.agents["claude"]; assert!(claude.launch_args.is_none()); } @@ -748,7 +921,7 @@ agents: std::fs::write(crate::paths::config_path(root), yaml).unwrap(); let config = - update_agent_config(root, "claude", Some(vec!["--verbose".to_string()])).unwrap(); + update_agent_config(root, "claude", args(Some(vec!["--verbose".to_string()]))).unwrap(); // Claude should have new args assert_eq!( @@ -763,12 +936,13 @@ agents: } #[test] - fn given_update_agent_config_with_unknown_agent_should_error() { + fn given_update_agent_config_with_invalid_agent_name_should_error() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); std::fs::create_dir_all(crate::paths::pu_dir(root)).unwrap(); - let result = update_agent_config(root, "unknown-agent", Some(vec!["--verbose".into()])); + let result = + update_agent_config(root, "Not An Agent", args(Some(vec!["--verbose".into()]))); assert!(result.is_err()); let err = result.unwrap_err(); assert_eq!(err.code(), "INVALID_ARGUMENT"); @@ -783,7 +957,10 @@ agents: let config = update_agent_config( root, "claude", - Some(vec!["--permission-mode".to_string(), "plan".to_string()]), + args(Some(vec![ + "--permission-mode".to_string(), + "plan".to_string(), + ])), ) .unwrap(); @@ -808,10 +985,10 @@ agents: let root = root.clone(); std::thread::spawn(move || { let agent = ["claude", "codex", "opencode", "terminal"][i % 4]; - let args: Vec = (0..=i).map(|n| format!("--flag-{n}")).collect(); + let flags: Vec = (0..=i).map(|n| format!("--flag-{n}")).collect(); for _ in 0..20 { - update_agent_config(&root, agent, Some(args.clone())).unwrap(); - update_agent_config(&root, agent, None).unwrap(); + update_agent_config(&root, agent, args(Some(flags.clone()))).unwrap(); + update_agent_config(&root, agent, args(None)).unwrap(); } }) }) diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 89815ea1..095ed562 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -362,6 +362,13 @@ pub enum Request { /// removes that agent's entry. Agents left out keep their stored value. #[serde(default, skip_serializing_if = "Option::is_none")] launch_args: Option>, + /// Machine-level command per agent type; a blank string removes it. + /// An agent type that is not built in adds that agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + commands: Option>, + /// Agent types whose machine-level command and launch args are removed. + #[serde(default, skip_serializing_if = "Option::is_none")] + remove_agents: Option>, }, GetConfig { project_root: String, @@ -375,6 +382,12 @@ pub enum Request { deserialize_with = "crate::types::launch_args_serde::deserialize" )] launch_args: Option>, + /// The project's command; left out keeps it, a blank string clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + /// Drop the agent from the project config. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + remove: bool, }, // Trigger CRUD ListTriggers { @@ -698,6 +711,10 @@ pub enum Response { /// Machine-level launch args string per agent type. #[serde(default)] launch_args: IndexMap, + /// Machine-level command per agent type. `None` from daemons that + /// predate machine-level commands. + #[serde(default, skip_serializing_if = "Option::is_none")] + commands: Option>, }, ConfigReport { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/pu-core/src/protocol/payloads.rs b/crates/pu-core/src/protocol/payloads.rs index 99ba24f5..08ff3d11 100644 --- a/crates/pu-core/src/protocol/payloads.rs +++ b/crates/pu-core/src/protocol/payloads.rs @@ -235,6 +235,17 @@ pub struct AgentConfigInfo { /// Preview of `command` plus the resolved launch args. #[serde(default, skip_serializing_if = "Option::is_none")] pub resolved_command: Option, + /// Project `command`; `None` when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_command: Option, + /// The command used when the project leaves it unset: machine-level, else + /// the built-in default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited_command: Option, + /// Where the agent is defined: `builtin`, `machine` (added for all + /// projects) or `project` (added in this project's config). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/pu-core/src/protocol/tests.rs b/crates/pu-core/src/protocol/tests.rs index 75594676..cc89bb37 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -2145,6 +2145,8 @@ fn given_update_agent_config_request_with_args_should_round_trip() { project_root: "/test".into(), agent_name: "claude".into(), launch_args: Some(vec!["--verbose".into(), "--model".into(), "opus".into()]), + command: Some("scripts/wrap.sh claude".into()), + remove: false, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -2153,10 +2155,14 @@ fn given_update_agent_config_request_with_args_should_round_trip() { project_root, agent_name, launch_args, + command, + remove, } => { assert_eq!(project_root, "/test"); assert_eq!(agent_name, "claude"); assert_eq!(launch_args.unwrap(), vec!["--verbose", "--model", "opus"]); + assert_eq!(command.as_deref(), Some("scripts/wrap.sh claude")); + assert!(!remove); } _ => panic!("expected UpdateAgentConfig"), } @@ -2196,13 +2202,28 @@ fn given_update_global_agent_settings_with_launch_args_should_round_trip() { .into_iter() .collect(), ), + commands: Some( + [("aider".to_string(), "aider".to_string())] + .into_iter() + .collect(), + ), + remove_agents: Some(vec!["goose".into()]), }; let json = serde_json::to_string(&req).unwrap(); match serde_json::from_str::(&json).unwrap() { - Request::UpdateGlobalAgentSettings { launch_args, .. } => assert_eq!( - launch_args.unwrap()["claude"], - "--dangerously-skip-permissions" - ), + Request::UpdateGlobalAgentSettings { + launch_args, + commands, + remove_agents, + .. + } => { + assert_eq!( + launch_args.unwrap()["claude"], + "--dangerously-skip-permissions" + ); + assert_eq!(commands.unwrap()["aider"], "aider"); + assert_eq!(remove_agents.unwrap(), ["goose"]); + } _ => panic!("expected UpdateGlobalAgentSettings"), } } @@ -2215,9 +2236,13 @@ fn given_legacy_update_global_agent_settings_with_codex_yolo_should_ignore_it() Request::UpdateGlobalAgentSettings { agent_status_tracking, launch_args, + commands, + remove_agents, } => { assert_eq!(agent_status_tracking, Some(false)); assert!(launch_args.is_none()); + assert!(commands.is_none()); + assert!(remove_agents.is_none()); } _ => panic!("expected UpdateGlobalAgentSettings"), } @@ -2229,6 +2254,7 @@ fn given_global_agent_settings_report_should_still_send_codex_yolo_false_for_pro codex_yolo: false, agent_status_tracking: true, launch_args: IndexMap::new(), + commands: Some(IndexMap::new()), }; let json = serde_json::to_value(&report).unwrap(); assert_eq!(json["codex_yolo"], serde_json::Value::Bool(false)); @@ -2238,7 +2264,14 @@ fn given_global_agent_settings_report_should_still_send_codex_yolo_false_for_pro fn given_legacy_global_agent_settings_report_should_default_launch_args() { let json = r#"{"type":"global_agent_settings_report","codex_yolo":false}"#; match serde_json::from_str::(json).unwrap() { - Response::GlobalAgentSettingsReport { launch_args, .. } => assert!(launch_args.is_empty()), + Response::GlobalAgentSettingsReport { + launch_args, + commands, + .. + } => { + assert!(launch_args.is_empty()); + assert!(commands.is_none()); + } _ => panic!("expected GlobalAgentSettingsReport"), } } @@ -2276,6 +2309,8 @@ fn given_update_agent_config_request_with_none_should_round_trip() { project_root: "/test".into(), agent_name: "codex".into(), launch_args: None, + command: None, + remove: false, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -2304,6 +2339,9 @@ fn given_config_report_response_should_round_trip() { project_launch_args: Some("--verbose".into()), machine_launch_args: None, resolved_command: Some("claude --verbose".into()), + project_command: None, + inherited_command: Some("claude".into()), + origin: Some("builtin".into()), }, AgentConfigInfo { id: None, @@ -2316,6 +2354,9 @@ fn given_config_report_response_should_round_trip() { project_launch_args: None, machine_launch_args: Some("--sandbox workspace-write".into()), resolved_command: None, + project_command: None, + inherited_command: None, + origin: None, }, ], }; diff --git a/crates/pu-core/src/types/config.rs b/crates/pu-core/src/types/config.rs index be3e1374..2f412e71 100644 --- a/crates/pu-core/src/types/config.rs +++ b/crates/pu-core/src/types/config.rs @@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub struct AgentConfig { pub name: String, + /// The executable plus any words before the launch args. After config + /// resolution this is the effective command: project, else machine, else the + /// built-in default. Empty in a config file means "not set here". + #[serde(default, skip_serializing_if = "String::is_empty")] pub command: String, #[serde(default)] pub prompt_flag: Option, @@ -18,9 +22,39 @@ pub struct AgentConfig { /// the project leaves `launch_args` unset. Filled in at config resolution. #[serde(skip)] pub machine_launch_args: Option>, + /// The command `.pu/config.yaml` sets for this agent, if any. Filled in at + /// config resolution. + #[serde(skip)] + pub project_command: Option, + /// Machine-level command from `~/.pu/agent-settings.yaml`. Filled in at + /// config resolution. + #[serde(skip)] + pub machine_command: Option, } impl AgentConfig { + /// An agent with nothing set: it launches `name` with no args. + pub fn named(name: &str) -> Self { + Self { + name: name.to_string(), + command: String::new(), + prompt_flag: None, + interactive: true, + launch_args: None, + machine_launch_args: None, + project_command: None, + machine_command: None, + } + } + + /// The command used when the project leaves it unset: machine, else the + /// built-in default, else the agent's name. + pub fn inherited_command(&self, agent_type: &str) -> String { + self.machine_command + .clone() + .unwrap_or_else(|| default_command(agent_type)) + } + /// The launch args in effect before resolution: project, else machine, /// else none. pub fn configured_launch_args(&self) -> Option<&[String]> { @@ -130,18 +164,35 @@ pub fn default_agents() -> IndexMap { ( name.to_string(), AgentConfig { - name: name.to_string(), command: cmd.to_string(), - prompt_flag: None, - interactive: true, - launch_args: None, - machine_launch_args: None, + ..AgentConfig::named(name) }, ) }) .collect() } +/// The built-in command for an agent type, or the type's own name for agents +/// the user added. +pub fn default_command(agent_type: &str) -> String { + default_agents() + .get(agent_type) + .map_or_else(|| agent_type.to_string(), |a| a.command.clone()) +} + +/// Whether `name` can key an agent in a config: lowercase letters, digits, `-` +/// and `_`, starting with a letter or digit. +pub fn is_valid_agent_name(name: &str) -> bool { + name.len() <= 64 + && name + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + /// Codex's auto-mode flags. Codex removed the `--full-auto` shortcut; these are the /// long-form equivalents (workspace-writable sandbox, model decides when to ask). pub const CODEX_AUTO_ARGS: [&str; 4] = [ @@ -282,7 +333,7 @@ launchArgs: prompt_flag: None, interactive: true, launch_args: Some(vec!["--dangerously-skip-permissions".into()]), - machine_launch_args: None, + ..AgentConfig::named("claude") }; let yaml = serde_yaml_ng::to_string(&config).unwrap(); let parsed: AgentConfig = serde_yaml_ng::from_str(&yaml).unwrap(); diff --git a/crates/pu-engine/src/engine/agent_lifecycle.rs b/crates/pu-engine/src/engine/agent_lifecycle.rs index 1e9cf82a..476578e9 100644 --- a/crates/pu-engine/src/engine/agent_lifecycle.rs +++ b/crates/pu-engine/src/engine/agent_lifecycle.rs @@ -691,6 +691,8 @@ mod resume_command_tests { interactive: true, launch_args: Some(vec![]), machine_launch_args: None, + project_command: None, + machine_command: None, }; // when / then assert!(engine.build_resume_command(kind, &config, None).is_err()); @@ -740,6 +742,8 @@ mod resume_command_tests { interactive: true, launch_args: Some(vec!["--first".into(), "shared".into()]), machine_launch_args: None, + project_command: None, + machine_command: None, }; // when diff --git a/crates/pu-engine/src/engine/mod.rs b/crates/pu-engine/src/engine/mod.rs index 9aeae86b..f5828a4e 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -142,6 +142,9 @@ fn config_to_report( project_launch_args: ac.launch_args.as_deref().map(format_launch_args), machine_launch_args: ac.machine_launch_args.as_deref().map(format_launch_args), resolved_command: Some(resolved_command), + project_command: ac.project_command.clone(), + inherited_command: Some(ac.inherited_command(name)), + origin: Some(agent_origin(name, ac).into()), } }) .collect(); @@ -155,6 +158,16 @@ fn config_to_report( } } +fn agent_origin(name: &str, ac: &pu_core::types::AgentConfig) -> &'static str { + if pu_core::types::default_agents().contains_key(name) { + "builtin" + } else if ac.machine_command.is_some() || ac.machine_launch_args.is_some() { + "machine" + } else { + "project" + } +} + /// Root-cwd executable hint using Spawn parsing and login PATH, without running it. /// A worktree-relative command or its arguments may still fail at launch. fn catalog_executable_available( @@ -472,10 +485,14 @@ impl Engine { Request::UpdateGlobalAgentSettings { launch_args, agent_status_tracking, + commands, + remove_agents, } => { self.handle_global_agent_settings(Some(config::GlobalAgentSettingsUpdate { agent_status_tracking, + commands: commands.unwrap_or_default(), launch_args: launch_args.unwrap_or_default(), + remove_agents: remove_agents.unwrap_or_default(), })) .await } @@ -483,8 +500,15 @@ impl Engine { project_root, agent_name, launch_args, + command, + remove, } => { - self.handle_update_agent_config(&project_root, &agent_name, launch_args) + let update = config::AgentConfigUpdate { + command, + launch_args, + remove, + }; + self.handle_update_agent_config(&project_root, &agent_name, update) .await } Request::Shutdown => Response::ShuttingDown, @@ -938,6 +962,7 @@ impl Engine { .iter() .map(|(agent, args)| (agent.clone(), format_launch_args(&args.0))) .collect(), + commands: Some(settings.commands), }, Err(e) => Response::Error { code: e.code().into(), @@ -976,14 +1001,14 @@ impl Engine { &self, project_root: &str, agent_name: &str, - launch_args: Option>, + update: config::AgentConfigUpdate, ) -> Response { let pr = project_root.to_string(); let name = agent_name.to_string(); let env = self.agent_env().await; tokio::task::spawn_blocking(move || { let root = Path::new(&pr); - match config::update_agent_config(root, &name, launch_args) { + match config::update_agent_config(root, &name, update) { Ok(cfg) => config_to_report(&cfg, root, &env), Err(e) => Response::Error { code: e.code().into(), @@ -1240,6 +1265,8 @@ mod tests { interactive: true, launch_args, machine_launch_args: None, + project_command: None, + machine_command: None, } } @@ -1267,6 +1294,47 @@ mod tests { assert_eq!(codex.resolved_launch_args, vec!["--search"]); } + #[test] + fn given_machine_command_and_added_agents_should_report_hints_and_origin() { + // given + let mut cfg = pu_core::types::Config::default(); + let claude = cfg.agents.get_mut("claude").unwrap(); + claude.machine_command = Some("scripts/agent-launch.sh claude".into()); + claude.command = "scripts/agent-launch.sh claude".into(); + let mut aider = pu_core::types::AgentConfig::named("aider"); + aider.machine_command = Some("npx aider".into()); + aider.command = "npx aider".into(); + cfg.agents.insert("aider".into(), aider); + let mut goose = pu_core::types::AgentConfig::named("goose"); + goose.project_command = Some("goose session".into()); + goose.command = "goose session".into(); + cfg.agents.insert("goose".into(), goose); + + // when + let Response::ConfigReport { agents, .. } = config_to_report(&cfg, Path::new("/"), &[]) + else { + panic!("expected ConfigReport"); + }; + + // then — each grey hint is what runs when the project field is empty + let get = |id: &str| agents.iter().find(|a| a.id.as_deref() == Some(id)).unwrap(); + let claude = get("claude"); + assert_eq!(claude.project_command, None); + assert_eq!( + claude.inherited_command.as_deref(), + Some("scripts/agent-launch.sh claude") + ); + assert_eq!(claude.origin.as_deref(), Some("builtin")); + assert_eq!(get("codex").inherited_command.as_deref(), Some("codex")); + let aider = get("aider"); + assert_eq!(aider.origin.as_deref(), Some("machine")); + assert_eq!(aider.command, "npx aider"); + let goose = get("goose"); + assert_eq!(goose.origin.as_deref(), Some("project")); + assert_eq!(goose.project_command.as_deref(), Some("goose session")); + assert_eq!(goose.inherited_command.as_deref(), Some("goose")); + } + #[test] fn given_claude_build_resume_without_launch_args_should_launch_bare() { // given @@ -1516,6 +1584,8 @@ mod tests { interactive: true, launch_args: None, machine_launch_args: None, + project_command: None, + machine_command: None, } } @@ -1570,6 +1640,8 @@ mod workspace_catalog_tests { interactive: true, launch_args: None, machine_launch_args: None, + project_command: None, + machine_command: None, } } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2dcdc5af..692a9865 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -65,16 +65,20 @@ form. To opt into auto mode yourself without changing any project, set machine-level launch args in `~/.pu/agent-settings.yaml`, or in the macOS app under -**Settings → Agents → Machine Settings → Launch args for all projects**: +**Settings → Agents → Agents — All Projects**: ```yaml +commands: + claude: "claude" # optional; defaults to the agent's name launchArgs: claude: "--dangerously-skip-permissions" codex: "--sandbox workspace-write --ask-for-approval on-request" ``` These apply to every project on this machine that doesn't set its own -`launchArgs`, including projects created later. Changes apply to new and resumed +`command` or `launchArgs`, including projects created later. An agent type under +`commands` that isn't built in (for example `aider: "npx aider"`) adds that agent +to every project. Changes apply to new and resumed agents without restarting the daemon; restart running agents to apply them. A malformed file (bad YAML or an unbalanced quote) makes config resolution fail with an error. PurePoint never falls back to permission-skipping flags. @@ -105,15 +109,14 @@ launchArgs: | Field | Type | Default | Description | |---|---|---|---| | `name` | string | (required) | Agent type identifier | -| `command` | string | (required) | CLI binary to run (`claude`, `codex`, `opencode`, `shell`) | +| `command` | string | machine command, else built-in, else agent name | CLI to run plus any words before the launch args (`claude`, `scripts/agent-launch.sh codex`); optional | | `launchArgs` | string (or legacy string[]) | unset | Extra args appended to the command | | `promptFlag` | string | `"-p"` | Flag used to pass the prompt text to the agent CLI | | `interactive` | bool | `true` | Whether the agent is interactive (affects PTY allocation) | ### Launching bare in one project -In Settings, an empty project field means "use the launch args for all -projects". To launch the bare command in one project even though you set +In Settings, an empty project field means "use the value for all projects". To launch the bare command in one project even though you set machine-level args, set `launchArgs` to an empty string in its config: ```yaml diff --git a/docs/guide/workflows/macos-app.md b/docs/guide/workflows/macos-app.md index 29046f91..d892ea98 100644 --- a/docs/guide/workflows/macos-app.md +++ b/docs/guide/workflows/macos-app.md @@ -95,9 +95,10 @@ Access via the settings panel: - **General**: Restore projects on launch, launch at login, command palette order. Use the up/down arrows to reorder built-in agents and custom commands from the Agents Hub in `Cmd+N`. The first entry is selected by default. Changes persist across launches; Reset restores the default order. - **Point Guard**: Connect Pi chat to the bridge; configure launch command and skip-permissions for Shell mode -- **Agents**: Launch args for Claude, Codex and OpenCode. Each agent has one row: its command and a free-form launch args field. Agents launch with the bare command (no permission flags) unless you add args. - - **Machine Settings → Launch args for all projects** applies to every project on this Mac that doesn't set its own, e.g. `--dangerously-skip-permissions` for Claude if you want auto mode everywhere. Stored in `~/.pu/agent-settings.yaml`. - - **Launch Args for This Project** overrides the all-projects row for the open project. Leaving a field empty falls back to the all-projects args. To launch the bare command in one project regardless, set `launchArgs: ""` for that agent in `.pu/config.yaml`; the row then notes that it launches bare. +- **Agents**: Every agent is a command line. Each row has two text fields: **Launch command** (`claude`, `codex`, `opencode`, or anything else, such as `scripts/agent-launch.sh codex`) and **Arguments** (everything after it). The terminal runs the command followed by the arguments. Agents launch with the bare command (no permission flags) unless you add arguments. Fields save when you press Return or leave the row. + - **Agents — All Projects** applies to every project on this Mac that doesn't set its own, e.g. `--dangerously-skip-permissions` for Claude if you want auto mode everywhere. Stored in `~/.pu/agent-settings.yaml`. + - **Agents — This Project** overrides the All Projects values for the open project. An empty field uses the All Projects value, which it shows in grey: the grey text is exactly what runs. + - **Add agent** adds a row for any other CLI, at either level. The agent is named after the program it runs (`npx aider` → `aider`) and appears wherever you start a new agent. Agents you added can be removed with ✕. - For Codex full access with no command approvals, put `--dangerously-bypass-approvals-and-sandbox` in its launch args. - See [Configuration](../configuration.md#customizing-launch-args) for the format and precedence. - **Hotkeys**: Rebind all keyboard shortcuts with live key recording and conflict detection diff --git a/docs/reference/config-schema.md b/docs/reference/config-schema.md index 4d710e0b..ff86654e 100644 --- a/docs/reference/config-schema.md +++ b/docs/reference/config-schema.md @@ -20,6 +20,8 @@ agents: ```yaml agentStatusTracking: true +commands: + aider: "npx aider" launchArgs: claude: "--dangerously-skip-permissions" ``` @@ -27,13 +29,17 @@ launchArgs: | Field | Type | Default | Description | |---|---|---|---| | `agentStatusTracking` | bool | `true` | Report working/idle/question status through native adapters | +| `commands` | map of agent type → string | `{}` | Command for every project that leaves `command` unset; a type that isn't built in adds that agent | | `launchArgs` | map of agent type → string (or string[]) | `{}` | Launch args for every project that leaves `launchArgs` unset | The file is read on every config resolution, so spawn and resume pick up changes without a daemon restart. It is written under a lock with atomic replacement. Malformed content, including an unbalanced quote in a `launchArgs` string, is a config error; it never falls back to permission flags. The keys under -`launchArgs` are agent types (`claude`, `codex`, `opencode`, `terminal`). +`commands` and `launchArgs` are agent types: lowercase letters, digits, `-` and +`_`. A project agent's `command` is optional; when unset it is the machine +command, else the built-in one (`claude`, `codex`, `opencode`), else the agent's +name. ## Machine-wide Codex permissions @@ -63,7 +69,7 @@ Each entry in `agents` configures a specific agent type. | Field | Type | Default | Description | |---|---|---|---| | `name` | string | (required) | Agent type identifier | -| `command` | string | (required) | Binary to execute | +| `command` | string | machine command, else built-in, else agent name | Program to run plus any words before the launch args; optional | | `promptFlag` | string | `null` | CLI flag for prompt injection (e.g., `"--prompt"`) | | `interactive` | bool | `true` | Whether the agent runs in an interactive PTY | | `launchArgs` | string, string[] or null | `null` | Extra CLI arguments; see resolution logic below | diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 41cb451c..10f44cc3 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -155,7 +155,9 @@ Read agent configuration for a project. "interactive": true, "project_launch_args": null, "machine_launch_args": "--model opus", - "resolved_command": "claude --model opus" + "resolved_command": "claude --model opus", + "inherited_command": "claude", + "origin": "builtin" } ] } @@ -168,16 +170,24 @@ machine-level values in their string form, omitted when unset. append (project, else machine, else none), and `resolved_command` previews the command line. The three string fields were added additively; older clients ignore them. +`project_command` is the project's `command` (omitted when unset), +`inherited_command` is the command used when the project leaves it unset +(machine-level, else built-in), and `origin` is where the agent is defined: +`builtin`, `machine` or `project`. These are additive too. ### UpdateAgentConfig -Set or clear the project launch args for an agent type. +Set or clear the project command and launch args for an agent type, or remove +the agent from the project. **Request**: `{"type": "update_agent_config", "project_root": "/path", "agent_name": "claude", "launch_args": "--model opus --append-system-prompt 'be terse'"}` `launch_args` is a shell-style string or a list of strings; `null` clears the project value so the machine-level args (or none) apply. An unbalanced quote is -rejected. The project config is written with the string form. +rejected. The project config is written with the string form. Optional +`command` sets the project command (a blank string clears it; left out keeps +it) and `"remove": true` drops the agent's entry. `agent_name` may be any agent +type of lowercase letters, digits, `-` and `_`; a new one adds that agent. **Response**: `config_report` for the updated config. @@ -188,9 +198,12 @@ Read or update the machine settings in `~/.pu/agent-settings.yaml`. **Request**: `{"type": "update_global_agent_settings", "launch_args": {"claude": "--dangerously-skip-permissions"}}` All fields are optional and omitted ones keep their stored value: -`agent_status_tracking` (bool) and `launch_args` (agent type → -launch args string; a blank string removes that agent's entry, unknown agent -types and unbalanced quotes are rejected without saving). +`agent_status_tracking` (bool), `launch_args` (agent type → +launch args string; a blank string removes that agent's entry), `commands` +(agent type → command; a blank string removes it, and a type that isn't built +in adds that agent) and `remove_agents` (agent types whose command and launch +args are removed). Invalid agent type names and unbalanced quotes are rejected +without saving. A `codex_yolo` field sent by older clients is ignored; Codex full access is now a launch arg (`--dangerously-bypass-approvals-and-sandbox`). @@ -200,7 +213,9 @@ a launch arg (`--dangerously-bypass-approvals-and-sandbox`). `codex_yolo` is deprecated and always `false`. It is still sent so protocol-12 apps that require it can decode the report; clients should ignore it. `launch_args` defaults to `{}`. A daemon that omits it predates protocol v11 -and does not save machine-level launch args. +and does not save machine-level launch args. The report also carries +`commands` (agent type → command); a daemon that omits it predates +machine-level commands. **Protocol v11:** `update_agent_config.launch_args` takes a shell-style string (v10 daemons accept only a list), `update_global_agent_settings` and