diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 64f5b6f..eb513c5 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -17,6 +17,12 @@ public final class AppState { /// controlled from claude.ai / the Claude mobile app. Mirrors `AppConfig.remoteControlEnabled`. public var remoteControlEnabled: Bool = false + /// Whether the Manager terminal launches with `--permission-mode auto` so it + /// can run `crow`, `gh`, and `git` commands without per-call approval. + /// Mirrors `AppConfig.managerAutoPermissionMode`. Applies only to the Manager + /// launch; worker sessions and CLI-spawned terminals are unaffected. + public var managerAutoPermissionMode: Bool = true + /// Terminal IDs whose Claude Code was launched with `--rc` — drives the /// per-session indicator badge. Survives toggle changes so existing sessions /// keep showing the badge until they're restarted. diff --git a/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift b/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift index 3df69e6..8c9ec85 100644 --- a/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift +++ b/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift @@ -10,16 +10,33 @@ public enum ClaudeLaunchArgs { "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" } - /// Flags to append after the `claude` binary path when remote control is enabled. + /// Flags to append after the `claude` binary path. /// - /// Returns a string beginning with a leading space — e.g. `" --rc --name 'Manager'"` - /// — or an empty string when `remoteControl` is `false`. The `--name` flag makes the - /// session show up with a recognizable label in claude.ai's Remote Control panel. - public static func argsSuffix(remoteControl: Bool, sessionName: String?) -> String { - guard remoteControl else { return "" } - var s = " --rc" - if let name = sessionName, !name.isEmpty { - s += " --name \(shellQuote(name))" + /// Returns a string beginning with a leading space — e.g. + /// `" --permission-mode auto --rc --name 'Manager'"` — or an empty string + /// when neither option is enabled. The `--name` flag makes the session show + /// up with a recognizable label in claude.ai's Remote Control panel. + /// + /// - Parameters: + /// - remoteControl: Append `--rc` (and `--name …` if `sessionName` is provided). + /// - sessionName: Optional label for the remote-control panel. + /// - autoPermissionMode: Append `--permission-mode auto`. Used for the Manager + /// terminal so orchestration commands (`crow`, `gh`, `git`) can run without + /// per-call approval. Requires a supported Claude Code plan and provider. + public static func argsSuffix( + remoteControl: Bool, + sessionName: String?, + autoPermissionMode: Bool = false + ) -> String { + var s = "" + if autoPermissionMode { + s += " --permission-mode auto" + } + if remoteControl { + s += " --rc" + if let name = sessionName, !name.isEmpty { + s += " --name \(shellQuote(name))" + } } return s } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 1c84d7a..bc85d66 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -11,6 +11,7 @@ public struct AppConfig: Codable, Sendable, Equatable { public var notifications: NotificationSettings public var sidebar: SidebarSettings public var remoteControlEnabled: Bool + public var managerAutoPermissionMode: Bool public var telemetry: TelemetryConfig public init( @@ -19,6 +20,7 @@ public struct AppConfig: Codable, Sendable, Equatable { notifications: NotificationSettings = NotificationSettings(), sidebar: SidebarSettings = SidebarSettings(), remoteControlEnabled: Bool = false, + managerAutoPermissionMode: Bool = true, telemetry: TelemetryConfig = TelemetryConfig() ) { self.workspaces = workspaces @@ -26,6 +28,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.notifications = notifications self.sidebar = sidebar self.remoteControlEnabled = remoteControlEnabled + self.managerAutoPermissionMode = managerAutoPermissionMode self.telemetry = telemetry } @@ -36,11 +39,12 @@ public struct AppConfig: Codable, Sendable, Equatable { notifications = try container.decodeIfPresent(NotificationSettings.self, forKey: .notifications) ?? NotificationSettings() sidebar = try container.decodeIfPresent(SidebarSettings.self, forKey: .sidebar) ?? SidebarSettings() remoteControlEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteControlEnabled) ?? false + managerAutoPermissionMode = try container.decodeIfPresent(Bool.self, forKey: .managerAutoPermissionMode) ?? true telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig() } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, telemetry + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry } } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index c961d69..815cb57 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -39,6 +39,7 @@ import Testing #expect(config.notifications.globalMute == false) #expect(config.sidebar.hideSessionDetails == false) #expect(config.remoteControlEnabled == false) + #expect(config.managerAutoPermissionMode == true) } @Test func appConfigRemoteControlRoundTrip() throws { @@ -50,6 +51,28 @@ import Testing #expect(decoded.remoteControlEnabled == true) } +@Test func appConfigManagerAutoPermissionModeRoundTrip() throws { + var config = AppConfig() + config.managerAutoPermissionMode = false + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.managerAutoPermissionMode == false) + + config.managerAutoPermissionMode = true + let data2 = try JSONEncoder().encode(config) + let decoded2 = try JSONDecoder().decode(AppConfig.self, from: data2) + #expect(decoded2.managerAutoPermissionMode == true) +} + +@Test func appConfigManagerAutoPermissionModeDefaultsTrueWhenKeyMissing() throws { + // Legacy configs without the key should opt in by default so the Manager + // benefits from auto mode without requiring users to re-save settings. + let json = #"{"workspaces": [], "remoteControlEnabled": false}"#.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.managerAutoPermissionMode == true) +} + @Test func appConfigDecodeWithPartialKeys() throws { let json = """ {"workspaces": [{"id": "00000000-0000-0000-0000-000000000001", "name": "Org", "provider": "github", "cli": "gh", "alwaysInclude": []}]} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift index 6215bb0..f4172f5 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift @@ -30,3 +30,24 @@ import Testing #expect(ClaudeLaunchArgs.shellQuote("with space") == "'with space'") #expect(ClaudeLaunchArgs.shellQuote("has'quote") == "'has'\\''quote'") } + +@Test func claudeLaunchArgsAutoPermissionModeOnly() { + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: nil, autoPermissionMode: true) + == " --permission-mode auto") + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: "Manager", autoPermissionMode: true) + == " --permission-mode auto") +} + +@Test func claudeLaunchArgsAutoPermissionModeWithRemoteControl() { + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: "Manager", autoPermissionMode: true) + == " --permission-mode auto --rc --name 'Manager'") + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: nil, autoPermissionMode: true) + == " --permission-mode auto --rc") +} + +@Test func claudeLaunchArgsAutoPermissionModeDefaultsOff() { + // Existing callers that don't pass autoPermissionMode should be unaffected. + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: "Manager") + == " --rc --name 'Manager'") + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: nil) == "") +} diff --git a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift index b7f3c4d..6611711 100644 --- a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift +++ b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift @@ -45,6 +45,23 @@ import Testing let loaded = ConfigStore.loadConfig(from: configURL) #expect(loaded != nil) #expect(loaded?.remoteControlEnabled == false) + // Legacy configs should opt in to auto permission mode by default so the + // Manager benefits without requiring users to re-save settings. + #expect(loaded?.managerAutoPermissionMode == true) +} + +@Test func configStoreManagerAutoPermissionModeRoundTrip() throws { + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let claudeDir = tmpDir.appendingPathComponent(".claude", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + var config = AppConfig() + config.managerAutoPermissionMode = false + try ConfigStore.saveConfig(config, to: claudeDir) + + let configURL = claudeDir.appendingPathComponent("config.json") + let loaded = ConfigStore.loadConfig(from: configURL) + #expect(loaded?.managerAutoPermissionMode == false) } @Test func configStoreLoadMissingFileReturnsNil() { diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index a08cf59..111f931 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -164,6 +164,14 @@ public struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Manager Terminal") { + Toggle("Launch in auto permission mode", isOn: $config.managerAutoPermissionMode) + .onChange(of: config.managerAutoPermissionMode) { _, _ in save() } + Text("Passes --permission-mode auto so the Manager can run crow, gh, and git commands without per-call approval. Requires Claude Code 2.1.83+ on a Max, Team, Enterprise, or API plan with the Anthropic provider. Turn off if your account reports auto mode as unavailable. Takes effect on next app launch.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Telemetry") { Toggle("Enable session analytics", isOn: $config.telemetry.enabled) .onChange(of: config.telemetry.enabled) { _, _ in save() } diff --git a/README.md b/README.md index c21345a..10aa16b 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ On first launch, a setup wizard guides you through choosing your development roo ### The Sidebar - **Tickets** — Assigned issues grouped by project board status (Backlog, Ready, In Progress, In Review, Done in last 24h). Click a status to filter. -- **Manager** — A persistent Claude Code terminal for orchestrating work. Use `/crow-workspace` here to create new sessions. +- **Manager** — A persistent Claude Code terminal for orchestrating work. Use `/crow-workspace` here to create new sessions. Launches in `--permission-mode auto` by default so orchestration commands (`crow`, `gh`, `git`) run without per-call approval; opt out via Settings → General → Manager Terminal. - **Active Sessions** — One per work context. Shows repo, branch, issue/PR badges with pipeline and review status. - **Completed Sessions** — Sessions whose PRs have been merged or issues closed. diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index fa1a5cc..60b52fd 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -113,6 +113,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // set before hydrateState so the Manager terminal's stored command can // be rebuilt to include (or drop) `--rc` before its surface is pre-initialized. appState.remoteControlEnabled = config.remoteControlEnabled + appState.managerAutoPermissionMode = config.managerAutoPermissionMode // Create session service and hydrate state let service = SessionService(store: store, appState: appState, telemetryPort: config.telemetry.enabled ? config.telemetry.port : nil) @@ -443,6 +444,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { notificationManager?.updateSettings(config.notifications) appState.hideSessionDetails = config.sidebar.hideSessionDetails appState.remoteControlEnabled = config.remoteControlEnabled + appState.managerAutoPermissionMode = config.managerAutoPermissionMode } // MARK: - Socket Server diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 09636b9..52869d6 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -65,13 +65,17 @@ final class SessionService { } } else { // Manager terminal: rebuild its claude command to match the - // current remoteControlEnabled preference. Unlike worker sessions - // the Manager launches claude directly as the shell command, so - // the stored string needs to be correct before preInitialize runs. + // current remoteControlEnabled and managerAutoPermissionMode + // preferences. Unlike worker sessions the Manager launches + // claude directly as the shell command, so the stored string + // needs to be correct before preInitialize runs. let claudePath = Self.findClaudeBinary() ?? "claude" let rcEnabled = appState.remoteControlEnabled + let autoMode = appState.managerAutoPermissionMode let managerCommand = claudePath + ClaudeLaunchArgs.argsSuffix( - remoteControl: rcEnabled, sessionName: "Manager" + remoteControl: rcEnabled, + sessionName: "Manager", + autoPermissionMode: autoMode ) for i in terminals.indices { if let cmd = terminals[i].command, cmd.contains("claude") { @@ -261,7 +265,12 @@ final class SessionService { // Find the real claude binary (skip CMUX wrapper) let claudePath = Self.findClaudeBinary() ?? "claude" let rcEnabled = appState.remoteControlEnabled - let managerCommand = claudePath + ClaudeLaunchArgs.argsSuffix(remoteControl: rcEnabled, sessionName: "Manager") + let autoMode = appState.managerAutoPermissionMode + let managerCommand = claudePath + ClaudeLaunchArgs.argsSuffix( + remoteControl: rcEnabled, + sessionName: "Manager", + autoPermissionMode: autoMode + ) let terminal = SessionTerminal( sessionID: managerID, name: "Manager", diff --git a/docs/configuration.md b/docs/configuration.md index 3d4ea8f..cfa8be8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,15 @@ All persistent state lives under `~/Library/Application Support/crow/` (see `Pac - **`branchPrefix`** — used by the `/crow-workspace` skill when creating new branches. - **`excludeDirs`** — ignored when scanning repos for git worktrees. +## Manager Terminal + +The Manager tab runs Claude Code at the dev root and drives workspace orchestration. Its behavior is controlled by these top-level keys in `{devRoot}/.claude/config.json`: + +- **`managerAutoPermissionMode`** (default: `true`) — passes `--permission-mode auto` to the Manager's `claude` launch so it can run `crow`, `gh`, and `git` commands without per-call approval. Requires Claude Code **v2.1.83+**, a **Max / Team / Enterprise / API** plan, the **Anthropic** API provider (not Bedrock / Vertex / Foundry), and a supported model (**Sonnet 4.6**, **Opus 4.6**, or **Opus 4.7**). On Team/Enterprise plans an admin must enable auto mode in Claude Code admin settings. Turn this off via **Settings → General → Manager Terminal** if your account reports auto mode as unavailable. Worker sessions and CLI-spawned terminals are unaffected by this setting. +- **`remoteControlEnabled`** (default: `false`) — launches new Claude Code sessions with `--rc` so you can control them from claude.ai or the Claude mobile app. + +Changes take effect on next app launch — the Manager's stored command is rebuilt on hydration. + ## Directory Structure Crow expects repositories organized under workspace folders: