From b297c381ef1d28c84888beecaed6fe88d9a5ae36 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 14 Apr 2026 00:20:53 -0500 Subject: [PATCH 1/2] Add auto-enable Claude Code remote control setting Adds a Settings > General toggle that launches new Claude Code sessions with `--rc --name ''` so they appear in claude.ai's Remote Control panel under the matching Crow session name (Manager tab shows as "Manager"). Applies to worker auto-launch, the Manager terminal, and crow-CLI-spawned terminals. A small antenna badge marks sessions whose Claude was launched with RC active. Closes #157 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 9 +++ .../Sources/CrowCore/ClaudeLaunchArgs.swift | 26 +++++++ .../Sources/CrowCore/Models/AppConfig.swift | 8 +- .../Tests/CrowCoreTests/AppConfigTests.swift | 10 +++ .../CrowCoreTests/ClaudeLaunchArgsTests.swift | 32 ++++++++ .../ConfigStoreTests.swift | 21 +++++- .../Sources/CrowUI/RemoteControlBadge.swift | 27 +++++++ .../Sources/CrowUI/SessionDetailView.swift | 11 ++- .../Sources/CrowUI/SessionListView.swift | 11 ++- .../CrowUI/Sources/CrowUI/SettingsView.swift | 8 ++ Sources/Crow/App/AppDelegate.swift | 74 ++++++++++++++----- Sources/Crow/App/SessionService.swift | 20 ++++- 12 files changed, 229 insertions(+), 28 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift create mode 100644 Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift create mode 100644 Packages/CrowUI/Sources/CrowUI/RemoteControlBadge.swift diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index aec9298..92de14c 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -13,6 +13,15 @@ public final class AppState { /// Whether subtitle rows (ticket title, repo/branch) are hidden in sidebar session rows. public var hideSessionDetails: Bool = false + /// Whether new Claude Code sessions are launched with `--rc` so they can be + /// controlled from claude.ai / the Claude mobile app. Mirrors `AppConfig.remoteControlEnabled`. + public var remoteControlEnabled: Bool = false + + /// 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. + public var remoteControlActiveTerminals: Set = [] + /// Worktrees keyed by session ID. public var worktrees: [UUID: [SessionWorktree]] = [:] diff --git a/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift b/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift new file mode 100644 index 0000000..3df69e6 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Helpers for building the argument string appended to a `claude` shell invocation. +/// +/// Centralized so worker-session launches, the Manager tab, and crow-CLI-spawned +/// terminals all produce consistent flags — and so the logic is independently testable. +public enum ClaudeLaunchArgs { + /// POSIX single-quote escape for safe interpolation into a shell command line. + public static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Flags to append after the `claude` binary path when remote control is enabled. + /// + /// 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))" + } + return s + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 8810fbb..c9644a5 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -10,17 +10,20 @@ public struct AppConfig: Codable, Sendable, Equatable { public var defaults: ConfigDefaults public var notifications: NotificationSettings public var sidebar: SidebarSettings + public var remoteControlEnabled: Bool public init( workspaces: [WorkspaceInfo] = [], defaults: ConfigDefaults = ConfigDefaults(), notifications: NotificationSettings = NotificationSettings(), - sidebar: SidebarSettings = SidebarSettings() + sidebar: SidebarSettings = SidebarSettings(), + remoteControlEnabled: Bool = false ) { self.workspaces = workspaces self.defaults = defaults self.notifications = notifications self.sidebar = sidebar + self.remoteControlEnabled = remoteControlEnabled } public init(from decoder: Decoder) throws { @@ -29,10 +32,11 @@ public struct AppConfig: Codable, Sendable, Equatable { defaults = try container.decodeIfPresent(ConfigDefaults.self, forKey: .defaults) ?? ConfigDefaults() 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 } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar + case workspaces, defaults, notifications, sidebar, remoteControlEnabled } } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 7f2478e..c961d69 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -38,6 +38,16 @@ import Testing #expect(config.defaults.branchPrefix == "feature/") #expect(config.notifications.globalMute == false) #expect(config.sidebar.hideSessionDetails == false) + #expect(config.remoteControlEnabled == false) +} + +@Test func appConfigRemoteControlRoundTrip() throws { + var config = AppConfig() + config.remoteControlEnabled = true + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.remoteControlEnabled == true) } @Test func appConfigDecodeWithPartialKeys() throws { diff --git a/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift new file mode 100644 index 0000000..6215bb0 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func claudeLaunchArgsDisabledReturnsEmpty() { + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: nil) == "") + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: "crow-157") == "") +} + +@Test func claudeLaunchArgsEnabledNoName() { + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: nil) == " --rc") + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: "") == " --rc") +} + +@Test func claudeLaunchArgsEnabledWithName() { + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: "Manager") + == " --rc --name 'Manager'") + #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: "crow-157-auto-remote-control") + == " --rc --name 'crow-157-auto-remote-control'") +} + +@Test func claudeLaunchArgsShellQuotesApostrophe() { + // POSIX single-quote escape: ' → '\'' + let result = ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: "Bob's session") + #expect(result == " --rc --name 'Bob'\\''s session'") +} + +@Test func claudeLaunchArgsShellQuoteBasics() { + #expect(ClaudeLaunchArgs.shellQuote("plain") == "'plain'") + #expect(ClaudeLaunchArgs.shellQuote("with space") == "'with space'") + #expect(ClaudeLaunchArgs.shellQuote("has'quote") == "'has'\\''quote'") +} diff --git a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift index 75f2ab0..b7f3c4d 100644 --- a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift +++ b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift @@ -12,7 +12,8 @@ import Testing workspaces: [WorkspaceInfo(name: "TestOrg")], defaults: ConfigDefaults(branchPrefix: "fix/"), notifications: NotificationSettings(globalMute: true), - sidebar: SidebarSettings(hideSessionDetails: true) + sidebar: SidebarSettings(hideSessionDetails: true), + remoteControlEnabled: true ) try ConfigStore.saveConfig(config, to: claudeDir) @@ -26,6 +27,24 @@ import Testing #expect(loaded?.defaults.branchPrefix == "fix/") #expect(loaded?.notifications.globalMute == true) #expect(loaded?.sidebar.hideSessionDetails == true) + #expect(loaded?.remoteControlEnabled == true) +} + +@Test func configStoreForwardCompatDefaultsRemoteControlOff() throws { + // A config.json written by an older Crow build won't include `remoteControlEnabled`. + // Decoding must succeed and default the flag to false. + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmpDir) } + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + let configURL = tmpDir.appendingPathComponent("config.json") + // Minimal pre-existing config with no remoteControlEnabled key. All top-level + // fields on AppConfig use decodeIfPresent, so an empty object is sufficient. + try "{}".write(to: configURL, atomically: true, encoding: .utf8) + + let loaded = ConfigStore.loadConfig(from: configURL) + #expect(loaded != nil) + #expect(loaded?.remoteControlEnabled == false) } @Test func configStoreLoadMissingFileReturnsNil() { diff --git a/Packages/CrowUI/Sources/CrowUI/RemoteControlBadge.swift b/Packages/CrowUI/Sources/CrowUI/RemoteControlBadge.swift new file mode 100644 index 0000000..bc4542c --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/RemoteControlBadge.swift @@ -0,0 +1,27 @@ +import SwiftUI +import CrowCore + +/// Small antenna indicator shown on sessions whose Claude Code was launched with `--rc`. +/// +/// The indicator is driven by `AppState.remoteControlActiveTerminals` so it stays accurate +/// even after the user toggles the global setting mid-session — only sessions that actually +/// started with `--rc` are flagged. +struct RemoteControlBadge: View { + var compact: Bool = false + + var body: some View { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: compact ? 10 : 12, weight: .semibold)) + .foregroundStyle(CorveilTheme.gold) + .help("Remote control is active — this session can be driven from claude.ai") + .accessibilityLabel("Remote control active") + } +} + +extension AppState { + /// Whether any terminal belonging to `sessionID` was launched with remote control. + func isRemoteControlActive(sessionID: UUID) -> Bool { + let terminals = terminals(for: sessionID) + return terminals.contains { remoteControlActiveTerminals.contains($0.id) } + } +} diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index 5f608f5..d53ac2f 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -44,9 +44,14 @@ public struct SessionDetailView: View { // Row 1: Name + Status HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { - Text(session.name) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(CorveilTheme.gold) + HStack(spacing: 6) { + Text(session.name) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(CorveilTheme.gold) + if appState.isRemoteControlActive(sessionID: session.id) { + RemoteControlBadge() + } + } if let title = session.ticketTitle { Text(title) diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index 3f5a92b..3f5fb33 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -197,6 +197,12 @@ struct ManagerAllowListRow: View { ) { appState.selectedSessionID = AppState.managerSessionID } + .overlay(alignment: .topTrailing) { + if appState.isRemoteControlActive(sessionID: AppState.managerSessionID) { + RemoteControlBadge(compact: true) + .padding(4) + } + } sidebarButton( title: "Allow List", @@ -264,11 +270,14 @@ struct SessionRow: View { var body: some View { VStack(alignment: .leading, spacing: 3) { // Row 1: Name + status indicator - HStack { + HStack(spacing: 4) { Text(session.name) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(CorveilTheme.textPrimary) .lineLimit(1) + if appState.isRemoteControlActive(sessionID: session.id) { + RemoteControlBadge(compact: true) + } Spacer() statusIndicator } diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 96c8421..5352a73 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -135,6 +135,14 @@ public struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + Section("Remote Control") { + Toggle("Enable remote control for new sessions", isOn: $config.remoteControlEnabled) + .onChange(of: config.remoteControlEnabled) { _, _ in save() } + Text("New Claude Code sessions start with --rc so you can control them from claude.ai or the Claude mobile app. Each session's name matches its Crow session name.") + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 7d76e85..5688884 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -112,6 +112,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.sessionService = service NSLog("[Crow] Session state hydrated (%d sessions)", appState.sessions.count) + // Mirror the remote-control preference to AppState so launch paths + // can read the current value without a config round-trip. Must happen + // before ensureManagerSession (below) since that path reads the flag. + appState.remoteControlEnabled = config.remoteControlEnabled + // Detect orphaned worktrees (runs async, updates UI when done) Task { await service.detectOrphanedWorktrees() } @@ -400,6 +405,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } notificationManager?.updateSettings(config.notifications) appState.hideSessionDetails = config.sidebar.hideSessionDetails + appState.remoteControlEnabled = config.remoteControlEnabled } // MARK: - Socket Server @@ -588,16 +594,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard AppDelegate.isPathWithinDevRoot(cwd, devRoot: devRoot) else { throw RPCError.invalidParams("Terminal cwd must be within the configured devRoot") } - // Resolve claude binary path if command references claude - var command = params["command"]?.stringValue - if let cmd = command, cmd.contains("claude") { - command = AppDelegate.resolveClaudeInCommand(cmd) - } + let rawCommand = params["command"]?.stringValue let isManaged = params["managed"]?.boolValue ?? false let defaultName = isManaged ? "Claude Code" : "Shell" - let terminal = SessionTerminal(sessionID: sessionID, name: params["name"]?.stringValue ?? defaultName, - cwd: cwd, command: command, isManaged: isManaged) + let terminalName = params["name"]?.stringValue ?? defaultName return await MainActor.run { + // Resolve claude binary path if command references claude; also + // inject --rc --name when remote control is enabled so the session + // appears in claude.ai's Remote Control panel under the Crow + // session name. + var command = rawCommand + var rcInjected = false + if let cmd = rawCommand, cmd.contains("claude") { + let rcEnabled = capturedAppState.remoteControlEnabled + let sessionName = capturedAppState.sessions.first(where: { $0.id == sessionID })?.name + command = AppDelegate.resolveClaudeInCommand( + cmd, + remoteControl: rcEnabled, + sessionName: sessionName + ) + rcInjected = rcEnabled + && !cmd.contains("--rc") + && !cmd.contains("--remote-control") + } + let terminal = SessionTerminal(sessionID: sessionID, name: terminalName, + cwd: cwd, command: command, isManaged: isManaged) capturedAppState.terminals[sessionID, default: []].append(terminal) capturedStore.mutate { $0.terminals.append(terminal) } // Track readiness only for managed work session terminals @@ -605,6 +626,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { capturedAppState.terminalReadiness[terminal.id] = .uninitialized TerminalManager.shared.trackReadiness(for: terminal.id) } + if rcInjected { + capturedAppState.remoteControlActiveTerminals.insert(terminal.id) + } // Pre-initialize in offscreen window so shell starts immediately TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: cwd, command: command) return ["terminal_id": .string(terminal.id.uuidString), "session_id": .string(idStr)] @@ -938,18 +962,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Claude Binary Resolution /// Replace bare `claude` in a command string with the full path to the real binary, - /// skipping the CMUX wrapper. - nonisolated static func resolveClaudeInCommand(_ command: String) -> String { + /// skipping the CMUX wrapper. When `remoteControl` is true and the command does not + /// already request remote control, also inject `--rc --name ''` immediately + /// after the claude path so it sits before any trailing prompt argument. + nonisolated static func resolveClaudeInCommand( + _ command: String, + remoteControl: Bool = false, + sessionName: String? = nil + ) -> String { for path in SessionService.claudeBinaryCandidates { if FileManager.default.isExecutableFile(atPath: path) { - // Replace "claude" at word boundaries with the full path - // Handle: "claude ...", "claude", "/path/to/claude ..." - var result = command - // If command starts with bare "claude" (not already a path) - if result.hasPrefix("claude ") || result == "claude" { - result = path + result.dropFirst(6) - } - return result + // Only touch commands that start with the bare `claude` token. + let rest: String? + if command == "claude" { + rest = "" + } else if command.hasPrefix("claude ") { + rest = String(command.dropFirst("claude".count)) // " ..." + } else { + rest = nil + } + guard let rest else { return command } + + let wantsRC = remoteControl + && !command.contains("--rc") + && !command.contains("--remote-control") + let extra = wantsRC + ? ClaudeLaunchArgs.argsSuffix(remoteControl: true, sessionName: sessionName) + : "" + return path + extra + rest } } return command diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index bbf4457..27d463c 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -175,6 +175,9 @@ final class SessionService { } let claudePath = Self.findClaudeBinary() ?? "claude" + let sessionName = sessionID.flatMap { id in appState.sessions.first(where: { $0.id == id })?.name } + let rcEnabled = appState.remoteControlEnabled + let rcArgs = ClaudeLaunchArgs.argsSuffix(remoteControl: rcEnabled, sessionName: sessionName) // For review sessions, launch claude with the review prompt file if let sessionID, @@ -182,12 +185,15 @@ final class SessionService { session.kind == .review, let worktree = appState.primaryWorktree(for: sessionID) { let promptPath = (worktree.worktreePath as NSString).appendingPathComponent(".crow-review-prompt.md") - TerminalManager.shared.send(id: terminalID, text: "\(claudePath) \"$(cat \(promptPath))\"\n") + TerminalManager.shared.send(id: terminalID, text: "\(claudePath)\(rcArgs) \"$(cat \(promptPath))\"\n") } else { - TerminalManager.shared.send(id: terminalID, text: "\(claudePath) --continue\n") + TerminalManager.shared.send(id: terminalID, text: "\(claudePath)\(rcArgs) --continue\n") } appState.terminalReadiness[terminalID] = .claudeLaunched + if rcEnabled { + appState.remoteControlActiveTerminals.insert(terminalID) + } } // MARK: - Ensure Manager Session @@ -213,11 +219,13 @@ final class SessionService { if appState.terminals(for: managerID).isEmpty { // 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 terminal = SessionTerminal( sessionID: managerID, name: "Manager", cwd: devRoot, - command: claudePath + command: managerCommand ) appState.terminals[managerID] = [terminal] @@ -227,8 +235,12 @@ final class SessionService { } } + if rcEnabled { + appState.remoteControlActiveTerminals.insert(terminal.id) + } + // Pre-initialize in offscreen window so Manager terminal starts immediately - TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: devRoot, command: claudePath) + TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: devRoot, command: managerCommand) } // Select Manager on launch (selectedSessionID isn't persisted) From ad75ea65eca42be8a0adc992366b9792a222ad1a Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 14 Apr 2026 00:27:13 -0500 Subject: [PATCH 2/2] Apply remote-control flag to existing Manager terminal on hydrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Manager terminal is persisted and its `command` is executed directly by the shell, so the stored string must reflect the current RC setting before `preInitialize` runs. Rebuilds the Manager terminal's command in `hydrateState` from `appState.remoteControlEnabled` (moved to be set before hydrate) so toggling the preference — or installing this change on top of a pre-existing Manager — takes effect on next launch and the badge is surfaced. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Crow/App/AppDelegate.swift | 11 ++++++----- Sources/Crow/App/SessionService.swift | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 5688884..1126783 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -106,17 +106,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let store = JSONStore() self.store = store + // Mirror the remote-control preference to AppState so hydrate + launch + // paths can read the current value without a config round-trip. Must be + // 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 + // Create session service and hydrate state let service = SessionService(store: store, appState: appState) service.hydrateState() self.sessionService = service NSLog("[Crow] Session state hydrated (%d sessions)", appState.sessions.count) - // Mirror the remote-control preference to AppState so launch paths - // can read the current value without a config round-trip. Must happen - // before ensureManagerSession (below) since that path reads the flag. - appState.remoteControlEnabled = config.remoteControlEnabled - // Detect orphaned worktrees (runs async, updates UI when done) Task { await service.detectOrphanedWorktrees() } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 27d463c..09252a6 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -61,6 +61,29 @@ 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. + let claudePath = Self.findClaudeBinary() ?? "claude" + let rcEnabled = appState.remoteControlEnabled + let managerCommand = claudePath + ClaudeLaunchArgs.argsSuffix( + remoteControl: rcEnabled, sessionName: "Manager" + ) + for i in terminals.indices { + if let cmd = terminals[i].command, cmd.contains("claude") { + terminals[i] = SessionTerminal( + id: terminals[i].id, sessionID: terminals[i].sessionID, + name: terminals[i].name, cwd: terminals[i].cwd, + command: managerCommand, isManaged: terminals[i].isManaged, + createdAt: terminals[i].createdAt + ) + if rcEnabled { + appState.remoteControlActiveTerminals.insert(terminals[i].id) + } + } + } } appState.terminals[session.id] = terminals