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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []

/// Worktrees keyed by session ID.
public var worktrees: [UUID: [SessionWorktree]] = [:]

Expand Down
26 changes: 26 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 6 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
10 changes: 10 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand Down
27 changes: 27 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/RemoteControlBadge.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
11 changes: 8 additions & 3 deletions Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion Packages/CrowUI/Sources/CrowUI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
75 changes: 58 additions & 17 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ 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()
Expand Down Expand Up @@ -400,6 +406,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
notificationManager?.updateSettings(config.notifications)
appState.hideSessionDetails = config.sidebar.hideSessionDetails
appState.remoteControlEnabled = config.remoteControlEnabled
}

// MARK: - Socket Server
Expand Down Expand Up @@ -588,23 +595,41 @@ 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
if isManaged && sessionID != AppState.managerSessionID {
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)]
Expand Down Expand Up @@ -938,18 +963,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 '<sessionName>'` 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
Expand Down
Loading
Loading