Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
63595d1
Add TerminalBackend discriminator to SessionTerminal
Apr 29, 2026
8312622
Bundle crow-shell-wrapper.sh and crow-tmux.conf as CrowTerminal resou…
Apr 29, 2026
18253f8
Add SentinelWaiter and TmuxController to CrowTerminal
Apr 29, 2026
71e4806
Add TmuxBackend orchestrator in CrowTerminal
Apr 29, 2026
5dae7a0
Wire CROW_TMUX_BACKEND feature flag: new-terminal / send / close-term…
Apr 29, 2026
16f6aaa
Fix scripts/build-ghostty.sh: drop silent failures in dep-library ext…
Apr 29, 2026
7f04986
Add 2s timeout watchdog to TmuxController.run
Apr 29, 2026
42274c8
Add operator-greppable telemetry hooks for tmux backend
Apr 29, 2026
e35a984
Add first-run onboarding alert for missing tmux
Apr 29, 2026
4a6f9c8
Route ClaudeLauncher's auto-launch send through TerminalRouter
Apr 29, 2026
b974b32
Route SessionService terminal lifecycle through TerminalRouter
Apr 29, 2026
6c135c7
Surface tmux watchdog timeouts via NSAlert with restart action
Apr 29, 2026
04fdd69
Re-bind .tmux terminals on hydrate; silently fall back on failure
Apr 29, 2026
93ca9fc
Address PR #229 review: tmux shutdown, deleteSession, watchdog, UUIDs
dhilgaertner May 1, 2026
fe8bdd2
Merge branch 'main' into feature/crow-tmux-backend
dhilgaertner May 1, 2026
c4a8646
Pass terminal.backend to TerminalSurfaceView so .tmux rows render the…
dhilgaertner May 1, 2026
9df3152
Stop cockpitSurface from spawning duplicate tmux clients
dhilgaertner May 1, 2026
50cd257
Unblock readiness UI on slow shell startup during multi-terminal hydrate
dhilgaertner May 1, 2026
1933b41
Pass cwd to tmux new-window so claude --continue resumes the right pr…
dhilgaertner May 1, 2026
f7fca7b
Add Settings → Experimental tab with tmux backend toggle
dhilgaertner May 1, 2026
44a6d97
Show "T" badge on tmux-backed sessions in the sidebar
dhilgaertner May 1, 2026
8827cf8
Reap orphan tmux servers at launch + Settings UX polish
dhilgaertner May 1, 2026
17a5904
Don't permanently downgrade .tmux rows when backend is unconfigured t…
dhilgaertner May 1, 2026
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
4 changes: 4 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ public final class AppState {
/// Called to promote selected patterns to the global settings.
public var onPromoteToGlobal: ((Set<String>) -> Void)?

/// Called when the user clicks the gear icon in the sidebar toolbar.
/// AppDelegate wires this to its `showSettings()` method.
public var onShowSettings: (() -> Void)?

// MARK: - PR & Tool Status

/// PR status per session (pipeline, review, merge readiness).
Expand Down
10 changes: 8 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public struct AppConfig: Codable, Sendable, Equatable {
public var managerAutoPermissionMode: Bool
public var telemetry: TelemetryConfig
public var autoRespond: AutoRespondSettings
/// Opt into the tmux backend (#198). Surfaced in Settings → Experimental.
/// Read once at app launch; takes effect on next relaunch.
public var experimentalTmuxBackend: Bool

public init(
workspaces: [WorkspaceInfo] = [],
Expand All @@ -23,7 +26,8 @@ public struct AppConfig: Codable, Sendable, Equatable {
remoteControlEnabled: Bool = false,
managerAutoPermissionMode: Bool = true,
telemetry: TelemetryConfig = TelemetryConfig(),
autoRespond: AutoRespondSettings = AutoRespondSettings()
autoRespond: AutoRespondSettings = AutoRespondSettings(),
experimentalTmuxBackend: Bool = false
) {
self.workspaces = workspaces
self.defaults = defaults
Expand All @@ -33,6 +37,7 @@ public struct AppConfig: Codable, Sendable, Equatable {
self.managerAutoPermissionMode = managerAutoPermissionMode
self.telemetry = telemetry
self.autoRespond = autoRespond
self.experimentalTmuxBackend = experimentalTmuxBackend
}

public init(from decoder: Decoder) throws {
Expand All @@ -45,10 +50,11 @@ public struct AppConfig: Codable, Sendable, Equatable {
managerAutoPermissionMode = try container.decodeIfPresent(Bool.self, forKey: .managerAutoPermissionMode) ?? true
telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig()
autoRespond = try container.decodeIfPresent(AutoRespondSettings.self, forKey: .autoRespond) ?? AutoRespondSettings()
experimentalTmuxBackend = try container.decodeIfPresent(Bool.self, forKey: .experimentalTmuxBackend) ?? false
}

private enum CodingKeys: String, CodingKey {
case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond
case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond, experimentalTmuxBackend
}
}

Expand Down
48 changes: 46 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/Terminal.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import Foundation

/// Which terminal backend hosts a `SessionTerminal`'s shell.
///
/// The `.ghostty` path is the historical one — each terminal owns its own
/// libghostty surface and PTY. The `.tmux` path is rolling out behind a
/// feature flag (see #198): all terminals share a single embedded Ghostty
/// surface that's attached to a tmux session, and each terminal is one
/// tmux window inside that session.
///
/// `.ghostty` is the default for back-compat with persisted store rows
/// written before this discriminator existed.
public enum TerminalBackend: String, Codable, Sendable {
case ghostty
case tmux
}

/// Identifies the tmux window that backs a `.tmux` terminal.
///
/// Persisted alongside the terminal so the app can rebind to the same
/// window across restart (when the user opts in to keeping the tmux
/// server alive between Crow launches).
public struct TmuxBinding: Codable, Sendable, Equatable {
public let socketPath: String
public let sessionName: String
public var windowIndex: Int

public init(socketPath: String, sessionName: String, windowIndex: Int) {
self.socketPath = socketPath
self.sessionName = sessionName
self.windowIndex = windowIndex
}
}

/// A terminal instance within a session.
public struct SessionTerminal: Identifiable, Codable, Sendable {
public let id: UUID
Expand All @@ -9,6 +41,11 @@ public struct SessionTerminal: Identifiable, Codable, Sendable {
public var command: String?
public var isManaged: Bool
public var createdAt: Date
/// Which backend hosts this terminal. Defaults to `.ghostty` so rows
/// written before this field existed continue to load unchanged.
public var backend: TerminalBackend
/// Populated when `backend == .tmux`. Nil for `.ghostty`.
public var tmuxBinding: TmuxBinding?

public init(
id: UUID = UUID(),
Expand All @@ -17,7 +54,9 @@ public struct SessionTerminal: Identifiable, Codable, Sendable {
cwd: String,
command: String? = nil,
isManaged: Bool = false,
createdAt: Date = Date()
createdAt: Date = Date(),
backend: TerminalBackend = .ghostty,
tmuxBinding: TmuxBinding? = nil
) {
self.id = id
self.sessionID = sessionID
Expand All @@ -26,9 +65,12 @@ public struct SessionTerminal: Identifiable, Codable, Sendable {
self.command = command
self.isManaged = isManaged
self.createdAt = createdAt
self.backend = backend
self.tmuxBinding = tmuxBinding
}

// Custom decoder for backward compatibility — existing data lacks isManaged.
// Custom decoder for backward compatibility — existing data lacks
// isManaged, backend, and tmuxBinding.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
Expand All @@ -38,5 +80,7 @@ public struct SessionTerminal: Identifiable, Codable, Sendable {
command = try container.decodeIfPresent(String.self, forKey: .command)
isManaged = try container.decodeIfPresent(Bool.self, forKey: .isManaged) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
backend = try container.decodeIfPresent(TerminalBackend.self, forKey: .backend) ?? .ghostty
tmuxBinding = try container.decodeIfPresent(TmuxBinding.self, forKey: .tmuxBinding)
}
}
15 changes: 15 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import Testing
#expect(config.sidebar.hideSessionDetails == false)
#expect(config.remoteControlEnabled == false)
#expect(config.managerAutoPermissionMode == true)
#expect(config.experimentalTmuxBackend == false)
}

@Test func appConfigRemoteControlRoundTrip() throws {
Expand Down Expand Up @@ -230,3 +231,17 @@ import Testing
@Test func repoExcludeEmptyPatterns() {
#expect(repoMatchesExcludePatterns("org/repo", patterns: []) == false)
}

@Test func appConfigExperimentalTmuxBackendRoundTrip() throws {
var config = AppConfig()
config.experimentalTmuxBackend = true

let data = try JSONEncoder().encode(config)
let decoded = try JSONDecoder().decode(AppConfig.self, from: data)
#expect(decoded.experimentalTmuxBackend == true)

config.experimentalTmuxBackend = false
let data2 = try JSONEncoder().encode(config)
let decoded2 = try JSONDecoder().decode(AppConfig.self, from: data2)
#expect(decoded2.experimentalTmuxBackend == false)
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,102 @@ struct SessionTerminalTests {

#expect(terminal.isManaged == true)
}

// MARK: - Backend discriminator (#198 follow-up)

/// Existing rows on disk lack `backend` and `tmuxBinding`. They must
/// keep loading and default to the historical Ghostty path.
@Test func missingBackendDefaultsToGhostty() throws {
let json = """
{
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"sessionID": "11111111-2222-3333-4444-555555555555",
"name": "Shell",
"cwd": "/Users/test",
"createdAt": 1700000000
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let terminal = try decoder.decode(SessionTerminal.self, from: json)

#expect(terminal.backend == .ghostty)
#expect(terminal.tmuxBinding == nil)
}

@Test func tmuxBackendRoundTrip() throws {
let binding = TmuxBinding(
socketPath: "/Users/test/Library/Application Support/Crow/tmux.sock",
sessionName: "crow-cockpit",
windowIndex: 3
)
let terminal = SessionTerminal(
id: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!,
sessionID: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
name: "Build",
cwd: "/Users/test/project",
command: nil,
isManaged: false,
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
backend: .tmux,
tmuxBinding: binding
)

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
let data = try encoder.encode(terminal)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let decoded = try decoder.decode(SessionTerminal.self, from: data)

#expect(decoded.backend == .tmux)
#expect(decoded.tmuxBinding == binding)
}

@Test func explicitGhosttyBackendDecodes() throws {
let json = """
{
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"sessionID": "11111111-2222-3333-4444-555555555555",
"name": "Shell",
"cwd": "/Users/test",
"isManaged": false,
"createdAt": 1700000000,
"backend": "ghostty"
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let terminal = try decoder.decode(SessionTerminal.self, from: json)

#expect(terminal.backend == .ghostty)
#expect(terminal.tmuxBinding == nil)
}

@Test func tmuxBackendWithoutBindingDecodes() throws {
// Permissive: a `.tmux` row without a binding is invalid runtime
// state, but the model doesn't reject it — production construction
// sites enforce the invariant.
let json = """
{
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"sessionID": "11111111-2222-3333-4444-555555555555",
"name": "Shell",
"cwd": "/Users/test",
"isManaged": false,
"createdAt": 1700000000,
"backend": "tmux"
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let terminal = try decoder.decode(SessionTerminal.self, from: json)

#expect(terminal.backend == .tmux)
#expect(terminal.tmuxBinding == nil)
}
}
16 changes: 15 additions & 1 deletion Packages/CrowTerminal/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ let package = Package(
products: [
.library(name: "CrowTerminal", targets: ["CrowTerminal"]),
],
dependencies: [
.package(path: "../CrowCore"),
],
targets: [
.target(
name: "CrowTerminal",
dependencies: ["GhosttyKit"],
dependencies: [
"GhosttyKit",
.product(name: "CrowCore", package: "CrowCore"),
],
resources: [
.copy("Resources/crow-shell-wrapper.sh"),
.copy("Resources/crow-tmux.conf"),
],
swiftSettings: [
.unsafeFlags(["-I../../Frameworks/GhosttyKit.xcframework/macos-arm64/Headers"]),
],
Expand All @@ -27,5 +37,9 @@ let package = Package(
name: "GhosttyKit",
path: "../../Frameworks/GhosttyKit.xcframework"
),
.testTarget(
name: "CrowTerminalTests",
dependencies: ["CrowTerminal"]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Looks up paths to resources bundled with `CrowTerminal`.
///
/// Used by the tmux backend to locate `crow-shell-wrapper.sh` and
/// `crow-tmux.conf` at runtime. SwiftPM's `Bundle.module` is the canonical
/// way to reach package resources from Swift code in the same target.
public enum BundledResources {

/// Path to the bundled shell wrapper script. Returns `nil` only if the
/// resource was excluded at build time, which would be a build-config
/// bug, not a runtime condition. Callers may treat `nil` as fatal.
public static var shellWrapperScriptURL: URL? {
Bundle.module.url(forResource: "crow-shell-wrapper", withExtension: "sh")
}

/// Path to the bundled tmux configuration file. Same nil-vs-fatal rule.
public static var tmuxConfURL: URL? {
Bundle.module.url(forResource: "crow-tmux", withExtension: "conf")
}
}
Loading
Loading