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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ test, and documentation-only changes are omitted.
its idle policy after the last owner releases one.
- Ghosthub keeps its SSH kwt daemon state in a dedicated `~/.ghosthub/ssh/kwt`
directory, separate from the account's ordinary kwt registry state.
- Kwt-managed worktree and directory sessions now use kwt's dedicated tmux
server. Ghosthub follows kwt's explicit direct or protected attachment mode
instead of guessing policy from the socket name.
- Apple Silicon releases now support macOS 15 (Sequoia) and newer, with the
packaged application and libghostty runtime checked on a hosted Sequoia
runner before release changes merge.
Expand Down
2 changes: 1 addition & 1 deletion KWT_REVISION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b5c7c0c6163816117da2e06ef89c19104d619292
d818907f61dd1111415dfa6bbb86a41a513dd71b
60 changes: 15 additions & 45 deletions Sources/App/KwtInventoryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ struct KwtWorktreeRecord: Codable, Equatable, Sendable {
var repository: String
var sessionName: String
var tmuxSocketName: String?
var tmuxAttachMode: TmuxAttachMode = .direct

private enum CodingKeys: String, CodingKey {
case path, branch, repository
Expand All @@ -80,6 +81,7 @@ struct KwtWorktreeRecord: Codable, Equatable, Sendable {
case generation
case sessionName = "session_name"
case tmuxSocketName = "tmux_socket_name"
case tmuxAttachMode = "tmux_attach_mode"
}
}

Expand All @@ -88,11 +90,15 @@ struct KwtDirectoryWorkspaceRecord: Codable, Equatable, Sendable {
var path: String
var sessionName: String
var sessionLive: Bool
var tmuxSocketName: String? = nil
var tmuxAttachMode: TmuxAttachMode = .direct

private enum CodingKeys: String, CodingKey {
case name, path
case sessionName = "session_name"
case sessionLive = "session_live"
case tmuxSocketName = "tmux_socket_name"
case tmuxAttachMode = "tmux_attach_mode"
}
}

Expand Down Expand Up @@ -583,11 +589,6 @@ enum KwtSnapshotMerger {
let path: String
}

private struct WorktreeGenerationKey: Hashable {
let projectID: UUID
let generation: String
}

static func merge(
_ inventory: KwtHostInventory,
hostID: UUID,
Expand Down Expand Up @@ -618,20 +619,6 @@ enum KwtSnapshotMerger {
)
}
)
var existingSocketByProjectAndGeneration:
[WorktreeGenerationKey: String] = [:]
var seenWorktreeGenerations = Set<WorktreeGenerationKey>()
for worktree in existingWorktrees {
guard let generation = worktree.generation else { continue }
let key = WorktreeGenerationKey(
projectID: worktree.projectID,
generation: generation
)
guard seenWorktreeGenerations.insert(key).inserted,
let socketName = worktree.tmuxSocketName
else { continue }
existingSocketByProjectAndGeneration[key] = socketName
}
var projects: [ProjectSummary] = []
var worktrees: [WorktreeSummary] = []
if inventory.projectsWarning != nil,
Expand Down Expand Up @@ -663,7 +650,9 @@ enum KwtSnapshotMerger {
name: $0.name,
path: $0.path,
sessionName: $0.tmuxSessionName,
sessionLive: $0.sessionLive
sessionLive: $0.sessionLive,
tmuxSocketName: $0.tmuxSocketName,
tmuxAttachMode: $0.tmuxAttachMode
)
}
: inventory.directoryWorkspaces
Expand All @@ -679,12 +668,16 @@ enum KwtSnapshotMerger {
name: record.name,
path: record.path,
tmuxSessionName: record.sessionName,
tmuxSocketName: record.tmuxSocketName,
tmuxAttachMode: record.tmuxAttachMode,
sessionLive: record.sessionLive
)
workspace.hostID = hostID
workspace.name = record.name
workspace.path = record.path
workspace.tmuxSessionName = record.sessionName
workspace.tmuxSocketName = record.tmuxSocketName
workspace.tmuxAttachMode = record.tmuxAttachMode
workspace.sessionLive = record.sessionLive
return workspace
}
Expand Down Expand Up @@ -793,31 +786,8 @@ enum KwtSnapshotMerger {
consistentExisting?.generation
)
worktree.tmuxSessionName = record.sessionName
// The protected socket is a fail-closed marker: it keeps
// contributor-authored terminal configuration out of the app
// config and routes attachment through kwt's protected
// command. A refresh can omit it without unprotecting the same
// workspace. Its last canonical generation is retained when
// that same incomplete record also omits identity, but a new
// canonical generation must not inherit the socket from the
// prior owner of a reused path. Canonical generation therefore
// outranks path identity.
// Deleting the workspace drops the record entirely, which is
// how a protected marker is actually retired.
if let generation = WorktreeGeneration.canonical(
record.generation
) {
worktree.tmuxSocketName = record.tmuxSocketName
?? existingSocketByProjectAndGeneration[
WorktreeGenerationKey(
projectID: projectID,
generation: generation
)
]
} else {
worktree.tmuxSocketName = record.tmuxSocketName
?? consistentExisting?.tmuxSocketName
}
worktree.tmuxSocketName = record.tmuxSocketName
worktree.tmuxAttachMode = record.tmuxAttachMode
worktree.sessionBackend = snapshot.host(id: hostID)?.kind == .remote
? .remoteTmux : .localTmux
worktrees.append(worktree)
Expand Down
15 changes: 10 additions & 5 deletions Sources/App/KwtPullRequestClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ struct KwtPullRequestClient: Sendable {
),
on: host
)
guard response.workspace.hasValidSocketName,
response.pullRequest.workspace?.hasValidSocketName != false
guard response.workspace.hasValidProtectedEndpoint,
response.pullRequest.workspace?.hasValidProtectedEndpoint
!= false
else {
throw KwtPullRequestError.malformedOutput(
host: host.displayName
Expand Down Expand Up @@ -358,15 +359,18 @@ private struct PullRequestWorkspaceDTO: Decodable {
var state: String
var sessionName: String
var tmuxSocketName: String
var tmuxAttachMode: TmuxAttachMode

private enum CodingKeys: String, CodingKey {
case id, repository, branch, path, state
case sessionName = "session_name"
case tmuxSocketName = "tmux_socket_name"
case tmuxAttachMode = "tmux_attach_mode"
}

var hasValidSocketName: Bool {
!tmuxSocketName
var hasValidProtectedEndpoint: Bool {
tmuxAttachMode == .protected
&& !tmuxSocketName
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

Expand All @@ -378,7 +382,8 @@ private struct PullRequestWorkspaceDTO: Decodable {
path: path,
state: state,
sessionName: sessionName,
tmuxSocketName: tmuxSocketName
tmuxSocketName: tmuxSocketName,
tmuxAttachMode: tmuxAttachMode
)
}
}
Expand Down
34 changes: 25 additions & 9 deletions Sources/App/NativeTmuxSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@ struct BorrowedTmuxSessionHandle: Equatable, Sendable {
var name: String
var surfaceID: UUID
var socketName: String?
var tmuxAttachMode: TmuxAttachMode?

init(
id: UUID,
hostID: UUID,
name: String,
surfaceID: UUID,
socketName: String? = nil
socketName: String? = nil,
tmuxAttachMode: TmuxAttachMode? = nil
) {
self.id = id
self.hostID = hostID
self.name = name
self.surfaceID = surfaceID
self.socketName = socketName
self.tmuxAttachMode = tmuxAttachMode
}
}

Expand All @@ -40,6 +43,7 @@ private struct NativeTmuxSessionKey: Hashable {
var hostID: UUID
var name: String
var socketName: String?
var tmuxAttachMode: TmuxAttachMode?
}

private struct NativeTmuxPathCacheKey: Hashable {
Expand Down Expand Up @@ -256,6 +260,7 @@ final class NativeTmuxSessionCoordinator {
name: String,
host: CommandHost,
socketName: String? = nil,
tmuxAttachMode: TmuxAttachMode? = nil,
launchMode: TmuxAttachmentLaunchMode = .attach,
initialCommand: String? = nil,
workingDirectory: String? = nil,
Expand All @@ -268,7 +273,8 @@ final class NativeTmuxSessionCoordinator {
let key = NativeTmuxSessionKey(
hostID: hostID,
name: name,
socketName: socketName
socketName: socketName,
tmuxAttachMode: tmuxAttachMode
)
if let existing = handlesByKey[key],
targetHostsByHandle[existing.id] != host {
Expand All @@ -280,7 +286,8 @@ final class NativeTmuxSessionCoordinator {
hostID: hostID,
name: name,
surfaceID: UUID(),
socketName: socketName
socketName: socketName,
tmuxAttachMode: tmuxAttachMode
)
handlesByKey[key] = handle
targetHostsByHandle[handle.id] = host
Expand Down Expand Up @@ -346,6 +353,7 @@ final class NativeTmuxSessionCoordinator {
handle: handle,
host: host,
socketName: socketName,
tmuxAttachMode: tmuxAttachMode,
launchMode: launchMode,
initialCommand: launchMode == .create ? initialCommand : nil,
workingDirectory: workingDirectory,
Expand All @@ -369,6 +377,7 @@ final class NativeTmuxSessionCoordinator {
handle: BorrowedTmuxSessionHandle,
host: CommandHost,
socketName: String?,
tmuxAttachMode: TmuxAttachMode?,
launchMode: TmuxAttachmentLaunchMode,
initialCommand: String?,
workingDirectory: String?,
Expand Down Expand Up @@ -396,9 +405,9 @@ final class NativeTmuxSessionCoordinator {
let enablesInteractiveSizing = interactiveSizingHandles.remove(
handle.id
) != nil
let protectedWorkspacePath = socketName == nil
? nil
: workingDirectory
let protectedWorkspacePath = tmuxAttachMode == .protected
? workingDirectory
: nil
attachments[handle.id] = NativeTmuxAttachment(
id: attachmentID,
host: host,
Expand Down Expand Up @@ -470,11 +479,17 @@ final class NativeTmuxSessionCoordinator {
)
}

func detach(hostID: UUID, name: String, socketName: String? = nil) {
func detach(
hostID: UUID,
name: String,
socketName: String? = nil,
tmuxAttachMode: TmuxAttachMode? = nil
) {
let key = NativeTmuxSessionKey(
hostID: hostID,
name: name,
socketName: socketName
socketName: socketName,
tmuxAttachMode: tmuxAttachMode
)
guard let handle = handlesByKey.removeValue(forKey: key) else {
return
Expand Down Expand Up @@ -1419,7 +1434,8 @@ final class NativeTmuxSessionCoordinator {
NativeTmuxSessionKey(
hostID: handle.hostID,
name: handle.name,
socketName: handle.socketName
socketName: handle.socketName,
tmuxAttachMode: handle.tmuxAttachMode
)
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/App/TailscaleDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ enum TailscaleDiscovery {
) async -> [TailscalePeer] {
guard !peers.isEmpty else { return peers }
let concurrentCount = min(max(1, maximumConcurrent), peers.count)
let startedAt = DispatchTime.now().uptimeNanoseconds
var resolved = peers

let (resolutions, continuation) = AsyncStream<UsernameResolution>
Expand Down Expand Up @@ -187,6 +188,9 @@ enum TailscaleDiscovery {
for await resolution in resolutions {
switch resolution {
case let .peer(index, username):
guard DispatchTime.now().uptimeNanoseconds - startedAt
< timeoutNanoseconds
else { return resolved }
resolved[index] = peers[index]
.resolvingSSHUsername(username)
case .workerFinished:
Expand Down
Loading
Loading