Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// DevServerReadinessProbe.swift
// AgentHub
//
// Polls loopback candidates until a dev server becomes reachable.
//

import Foundation

enum DevServerReadinessProbeResult: Equatable, Sendable {
case ready(URL)
case timedOut
case stale
}

actor DevServerReadinessProbe {
private var candidateURLs: [URL]

init(expectedURL: URL) {
self.candidateURLs = [expectedURL]
}

init(expectedURLs: [URL]) {
self.candidateURLs = Array(expectedURLs)
}

func registerCandidate(_ url: URL) {
guard WebPreviewNavigationPolicy.isAllowedLoopbackURL(url),
!candidateURLs.contains(url) else {
return
}

candidateURLs.append(url)
}

func waitUntilReady(
timeout: Duration = .seconds(30),
pollInterval: Duration = .milliseconds(250),
probe: @escaping @Sendable (URL) async -> Bool,
isCurrent: @escaping @Sendable () async -> Bool
) async -> DevServerReadinessProbeResult {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)

while clock.now < deadline {
if await !isCurrent() {
return .stale
}

let urls = candidateURLs
let readyURL: URL? = await withTaskGroup(of: URL?.self, returning: URL?.self) { group in
for url in urls {
group.addTask { await probe(url) ? url : nil }
}
for await result in group {
if let url = result {
group.cancelAll()
return url
}
}
return nil
}

if let url = readyURL {
return await isCurrent() ? .ready(url) : .stale
}

do {
try await Task.sleep(for: pollInterval)
} catch {
return .stale
}
}

return await isCurrent() ? .timedOut : .stale
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ public actor SessionFileWatcher {
public struct StateUpdate: Sendable {
public let sessionId: String
public let state: SessionMonitorState
/// When non-nil, indicates this session has been continued by a new session (e.g., "clear context and accept plan")
public let continuationSessionId: String?

public init(sessionId: String, state: SessionMonitorState, continuationSessionId: String? = nil) {
self.sessionId = sessionId
self.state = state
self.continuationSessionId = continuationSessionId
}
}

// MARK: - Properties
Expand All @@ -34,6 +42,9 @@ public actor SessionFileWatcher {
/// Seconds to wait before considering a tool as awaiting approval
private var approvalTimeoutSeconds: Int = 0

/// Tracks continuation session IDs already claimed to prevent double-claiming
private var claimedContinuationIds: Set<String> = []

/// Publisher for state updates
public nonisolated var statePublisher: AnyPublisher<StateUpdate, Never> {
stateSubject.eraseToAnyPublisher()
Expand Down Expand Up @@ -155,7 +166,7 @@ public actor SessionFileWatcher {
guard let self = self else { return }

self.processingQueue.async {
AppLogger.watcher.debug("[Polling] TICK (1.5s) for session: \(sessionId.prefix(8), privacy: .public)")
//AppLogger.watcher.debug("[Polling] TICK (1.5s) for session: \(sessionId.prefix(8), privacy: .public)")

// Health check: detect stale file watcher
let timeSinceLastEvent = Date().timeIntervalSince(lastFileEventTime)
Expand Down Expand Up @@ -215,19 +226,82 @@ public actor SessionFileWatcher {
self.stateSubject.send(StateUpdate(sessionId: sessionId, state: updatedState))
}
}

}
}

statusTimer.resume()

// Set up directory watch for continuation detection (e.g., "clear context and accept plan").
// Runs alongside the file watcher and shares lastFileEventTime to detect when
// the old session goes quiet and a new session file appears.
let encodedPath = projectPath.claudeProjectPathEncoded
let projectDir = "\(claudePath)/projects/\(encodedPath)"
let existingJsonlFiles = Set(
(try? FileManager.default.contentsOfDirectory(atPath: projectDir)) ?? []
).filter { $0.hasSuffix(".jsonl") }

var directorySource: DispatchSourceFileSystemObject?
var directoryFd: Int32?
var continuationFired = false

let dirFd = open(projectDir, O_EVTONLY)
if dirFd >= 0 {
let dirSource = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: dirFd,
eventMask: [.write, .link],
queue: DispatchQueue.global(qos: .utility)
)

dirSource.setEventHandler { [weak self] in
guard let self else { return }
self.processingQueue.async {
// Only fire once per monitored session
guard !continuationFired else { return }
// Only trigger if old session has been quiet for 3+ seconds and had activity
let timeSinceLastEvent = Date().timeIntervalSince(lastFileEventTime)
guard timeSinceLastEvent >= 3, parseResult.messageCount > 0 else { return }

guard let currentFiles = try? FileManager.default.contentsOfDirectory(atPath: projectDir) else { return }
let newJsonlFiles = Set(currentFiles)
.subtracting(existingJsonlFiles)
.filter { $0.hasSuffix(".jsonl") }

guard let newFile = newJsonlFiles.first else { return }
let newSessionId = String(newFile.dropLast(6)) // Remove ".jsonl"
continuationFired = true

Task {
await self.emitContinuation(oldSessionId: sessionId, newSessionId: newSessionId)
}
}
}

dirSource.setCancelHandler {
close(dirFd)
}

dirSource.resume()
directorySource = dirSource
directoryFd = dirFd

AppLogger.watcher.info(
"[Continuation] Directory watch started for session: \(sessionId.prefix(8), privacy: .public)"
)
}

// Store watcher info
watchedSessions[sessionId] = FileWatcherInfo(
filePath: filePath,
source: source,
statusTimer: statusTimer,
parseResult: parseResult,
projectPath: projectPath,
lastFileEventTime: lastFileEventTime,
lastKnownFileSize: lastKnownFileSize
lastKnownFileSize: lastKnownFileSize,
directorySource: directorySource,
directoryFd: directoryFd,
existingJsonlFiles: existingJsonlFiles
)
}

Expand All @@ -242,6 +316,10 @@ public actor SessionFileWatcher {

info.source.cancel()
info.statusTimer.cancel()
// Clean up directory watcher for continuation detection
if let dirSource = info.directorySource {
dirSource.cancel()
}
AppLogger.watcher.info("[Polling] Cancelled file watcher and timer for: \(sessionId.prefix(8), privacy: .public)")
}

Expand Down Expand Up @@ -385,6 +463,33 @@ public actor SessionFileWatcher {
if case .awaitingApproval = status { return true }
return false
}

// MARK: - Continuation Detection

/// Emit a continuation event and clean up the old session's watchers
private func emitContinuation(oldSessionId: String, newSessionId: String) {
// Prevent double-claiming the same continuation
guard !claimedContinuationIds.contains(newSessionId) else { return }
claimedContinuationIds.insert(newSessionId)

guard let info = watchedSessions[oldSessionId] else { return }
let state = buildMonitorState(from: info.parseResult)

AppLogger.watcher.info(
"[Continuation] Detected: \(oldSessionId.prefix(8), privacy: .public) -> \(newSessionId.prefix(8), privacy: .public)"
)

stateSubject.send(StateUpdate(
sessionId: oldSessionId,
state: state,
continuationSessionId: newSessionId
))

// Stop monitoring the old session (cleans up file + directory watchers)
Task {
await self.stopMonitoring(sessionId: oldSessionId)
}
}
}

// MARK: - FileWatcherInfo
Expand All @@ -394,10 +499,16 @@ private struct FileWatcherInfo {
let source: DispatchSourceFileSystemObject
let statusTimer: DispatchSourceTimer
var parseResult: SessionJSONLParser.ParseResult
let projectPath: String

// Health check tracking
var lastFileEventTime: Date
var lastKnownFileSize: UInt64

// Continuation detection (directory watching for new session files)
var directorySource: DispatchSourceFileSystemObject?
var directoryFd: Int32?
var existingJsonlFiles: Set<String>
}

// MARK: - Protocol Conformance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ public struct MultiProviderSessionsListView: View {
.onChange(of: codexViewModel.resolvedPendingSessions) { _, newResolutions in
handleResolvedSessions(newResolutions, provider: .codex, viewModel: codexViewModel)
}
.onChange(of: claudeViewModel.resolvedContinuations) { _, newContinuations in
handleContinuationResolutions(newContinuations, provider: .claude, viewModel: claudeViewModel)
}
.onChange(of: codexViewModel.resolvedContinuations) { _, newContinuations in
handleContinuationResolutions(newContinuations, provider: .codex, viewModel: codexViewModel)
}
.onChange(of: claudeViewModel.lastCreatedPendingId) { _, newId in
guard let newId else { return }
primarySessionId = "pending-claude-\(newId.uuidString)"
Expand Down Expand Up @@ -956,6 +962,25 @@ public struct MultiProviderSessionsListView: View {
viewModel.resolvedPendingSessions.removeValue(forKey: pendingUUID)
}

/// Handles session continuation resolutions (e.g., "clear context and accept plan").
/// Updates primarySessionId when the currently selected session has been continued by a new one.
private func handleContinuationResolutions(
_ continuations: [String: String],
provider: SessionProviderKind,
viewModel: CLISessionsViewModel
) {
let providerPrefix = "\(provider.rawValue.lowercased())-"
for (oldSessionId, newSessionId) in continuations {
let oldPrimaryId = "\(providerPrefix)\(oldSessionId)"
if primarySessionId == oldPrimaryId {
let newPrimaryId = "\(providerPrefix)\(newSessionId)"
AppLogger.session.info("[PrimarySelection] Continuation: \(oldPrimaryId.prefix(20), privacy: .public) -> \(newPrimaryId.prefix(20), privacy: .public)")
primarySessionId = newPrimaryId
}
viewModel.resolvedContinuations.removeValue(forKey: oldSessionId)
}
}

private func ensurePrimarySelection() {
let items = filteredSelectedSessionItems
guard !items.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// WebPreviewNavigationPolicy.swift
// AgentHub
//
// Centralized allowlist for embedded web preview navigation.
//

import Foundation

enum WebPreviewNavigationDecision: Equatable {
case allow
case openExternally(URL)
case deny(String)
}

enum WebPreviewNavigationPolicy {

static func decision(
for navigationURL: URL?,
allowedProjectRoot: URL?,
isMainFrameNavigation: Bool,
opensInNewWindow: Bool
) -> WebPreviewNavigationDecision {
guard let navigationURL else { return .allow }

guard isMainFrameNavigation || opensInNewWindow else {
return .allow
}

if navigationURL.isFileURL {
guard let allowedProjectRoot else {
return .deny("Blocked file navigation because the preview has no allowed project root.")
}

return isURL(navigationURL, withinAllowedRoot: allowedProjectRoot)
? .allow
: .deny("Blocked navigation outside the allowed project directory.")
}

if isAllowedLoopbackURL(navigationURL) {
return .allow
}

return .openExternally(navigationURL)
}

static func isAllowedLoopbackURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
let host = url.host?.lowercased() else {
return false
}

return host == "localhost" || host == "127.0.0.1" || host == "::1"
}

static func isURL(_ url: URL, withinAllowedRoot rootURL: URL) -> Bool {
let normalizedURL = url.standardizedFileURL.resolvingSymlinksInPath()
let normalizedRoot = rootURL.standardizedFileURL.resolvingSymlinksInPath()

if normalizedURL.path == normalizedRoot.path {
return true
}

let rootPath = normalizedRoot.path.hasSuffix("/") ? normalizedRoot.path : normalizedRoot.path + "/"
return normalizedURL.path.hasPrefix(rootPath)
}
}
Loading