Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ worktree on disk, which is why so much of what follows is about not destroying o

Longer documents, pointed at rather than repeated here: `README.md` for what the app is,
`RELEASING.md` for signing, notarising and the appcast, `docs/CODEX.md` for the Codex app-server
protocol as measured, `docs/GROK.md` for Grok's ACP over stdio, `docs/PROTOCOL.md` for Claude Code's stream-json,
protocol as measured, `docs/GROK.md` for Grok's ACP over stdio, `docs/GITLAB.md` for GitLab through `glab`, `docs/PROTOCOL.md` for Claude Code's stream-json,
`docs/AGENTS-INTEGRATION.md` for how agent CLIs are detected, `docs/BRIDGE.md` for the MCP
bridge an agent calls back in through and which callers may call what, `docs/PLAN.md` for what was
bridge an agent calls back in through and which callers may call what, `docs/ROUTER.md` for the
automatic router that has a light model pick a new workspace's model and effort, `docs/PLAN.md` for what was
built and in what order, `docs/start-from.html` for the design note the create sheet's source picker
was drawn from, which is a page to open in a browser rather than to read here.

Expand Down
3 changes: 2 additions & 1 deletion Sources/Bloom/Intents/WorkspaceLookup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ enum WorkspaceLookup {
/// workspace falls back to what git alone can say about it.
static func pullRequest(for workspace: Workspace) async -> PullRequest? {
guard FileManager.default.fileExists(atPath: workspace.path) else { return nil }
return try? await GitHub.pullRequest(for: workspace, maxAge: .seconds(60))
let forge = await ForgeResolver.client(for: workspace.path)
return try? await forge.pullRequest(for: workspace, maxAge: .seconds(60))
}
}
58 changes: 58 additions & 0 deletions Sources/Bloom/State/AgentAvailability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import BloomCore
import Foundation
import Observation

/// Which agent CLIs are signed in on this Mac, outliving any one screen, so the router asks only
/// an agent that can answer. The Agents pane hands over what it detects; the create window
/// refreshes when the answer is older than `staleAfter`.
@MainActor
@Observable
final class AgentAvailability {
static let shared = AgentAvailability()

private(set) var connected: Set<AgentKind> = []
/// Tells "nothing is connected" from "nobody has looked yet".
private(set) var hasLoaded = false

@ObservationIgnored private var overrides: [AgentKind: String] = [:]
@ObservationIgnored private var store: Store?
@ObservationIgnored private var loadTask: Task<Void, Never>?
@ObservationIgnored private var loadedAt: Date?

static let staleAfter: TimeInterval = 300

/// Detects at launch only when the router, its only reader, is on: detection runs every agent's
/// `--version`.
func configure(store: Store) {
self.store = store
if ModelRouterPreferences().isEnabled { refresh() }
}

func refresh() {
loadTask?.cancel()
let store = store
loadTask = Task { [weak self] in
let overrides = await AgentCatalog.executablePathOverrides(in: store)
let statuses = await AgentCatalog(overrides: overrides).statuses()
guard !Task.isCancelled else { return }
self?.adopt(statuses: statuses, overrides: overrides)
}
}

func refreshIfStale(now: Date = Date()) {
if let loadedAt, now.timeIntervalSince(loadedAt) < Self.staleAfter { return }
refresh()
}

/// What a screen that has just run its own detection found, so this does not run it twice.
func adopt(statuses: [AgentStatus], overrides: [AgentKind: String]) {
connected = Set(statuses.filter { $0.connection == .connected }.map(\.kind))
self.overrides = overrides
hasLoaded = true
loadedAt = Date()
}

func executable(for kind: AgentKind) -> String {
AgentCatalog.executable(for: kind, override: overrides[kind])
}
}
26 changes: 26 additions & 0 deletions Sources/Bloom/State/AppModel+Projects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ extension AppModel {
case .alreadyRepository(let root):
guard let repo = await addKnownRepository(at: root) else { return nil }
if focusing { await focus(repo) }
// Not awaited: a glab probe to a host behind a VPN that is down takes seconds.
Task { await askForgeIfBoth(repo) }
return repo

case .refuse(let refusal):
Expand Down Expand Up @@ -113,6 +115,30 @@ extension AppModel {
))
}

/// Asks which forge `repo` uses when its remotes are on both, and returns once it is answered.
func askForgeIfBoth(_ repo: Repo) async {
guard forgeQuestion == nil, await ForgeResolver.offersBoth(repo.path), forgeQuestion == nil
else { return }
await withCheckedContinuation { answered in
forgeQuestionAnswered = answered
forgeQuestion = repo
}
}

/// The answer to `forgeQuestion`, kept for every workspace of the project. `nil` is Not now.
func answerForgeQuestion(_ forge: Forge?, for repo: Repo? = nil) {
if let forge, let repo {
do {
try ForgeResolver.remember(forge, for: repo.path)
} catch {
alert = BloomAlert(title: "Could not save the forge", message: error.readableMessage)
}
}
let answered = forgeQuestionAnswered
forgeQuestionAnswered = nil
answered?.resume()
}

/// Called by `ProjectSetupSheet` once the folder really is a repository.
func finishProjectSetup(_ path: String?) async {
ProjectSetup.shared.dismiss()
Expand Down
26 changes: 26 additions & 0 deletions Sources/Bloom/State/AppModel+Router.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import BloomCore

/// The router's two decisions that need live state (who reads the task, which table applies),
/// in one place so the create window offering Auto and `startWorkspace` asking cannot disagree.
extension AppModel {
struct OpeningRouter {
let analyser: RouterAnalyser
let table: ModelRouterTable
}

/// Nil when nothing signed in can read a task, or the chat's agent has no table yet (a Codex or
/// Grok list that has not answered).
func openingRouter(
for backend: AgentKind,
settings: ModelRouterSettings = ModelRouterPreferences().settings
) -> OpeningRouter? {
let models = ComposerModelCatalog.shared.models
guard let analyser = RouterAnalyser.resolve(
chatBackend: backend,
settings: settings,
connected: AgentAvailability.shared.connected,
models: models
), let table = settings.table(for: backend, models: models[backend] ?? []) else { return nil }
return OpeningRouter(analyser: analyser, table: table)
}
}
41 changes: 36 additions & 5 deletions Sources/Bloom/State/AppModel+Workspaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ extension AppModel {
controls: ComposerControls? = nil,
staged: StagedAttachments? = nil,
checkout: WorkspaceCheckout? = nil,
runSetupScript: Bool = true
runSetupScript: Bool = true,
routesModel: Bool = false
) async -> Workspace? {
do {
return try await startWorkspace(
in: repo, prompt: prompt, baseBranch: baseBranch, opensWith: opensWith,
branch: branch, controls: controls, staged: staged, checkout: checkout,
runSetupScript: runSetupScript
runSetupScript: runSetupScript, routesModel: routesModel
)
} catch {
// Diagnosed rather than reported. `error.readableMessage` on a `ShellError` is the git
Expand Down Expand Up @@ -99,7 +100,10 @@ extension AppModel {
/// A thread on the chosen backend for the new chat to pick up. Only `carryOn` passes one.
/// See `WorkspaceStartRequest.resuming`.
resuming: String? = nil,
runSetupScript: Bool = true
runSetupScript: Bool = true,
/// Whether the automatic router chooses the first chat's model. Only the create window passes
/// true, since nothing else has a window to show the router's card in. See `OpeningRoute`.
routesModel: Bool = false
) async throws -> Workspace {
guard let manager else { throw AppNotReady.stillStartingUp }
isCreatingWorkspace = true
Expand Down Expand Up @@ -138,6 +142,31 @@ extension AppModel {
effectiveControls.agentKind = agentKind
}

// Asked before the worktree is cut, so the analyser reads while git and the setup script run.
// Short circuited, so a workspace created with the router off decodes no table.
let router = routesModel ? openingRouter(for: effectiveControls.agentKind) : nil
let route: OpeningRoute?
if let router, ModelRouting.shouldRoute(
isEnabled: routesModel,
hasAnalyser: true,
mode: opensWith,
backend: effectiveControls.agentKind,
prompt: spoken,
isResuming: resuming != nil
) {
route = OpeningRoute(
task: spoken,
project: repo.name,
controls: effectiveControls,
analyser: router.analyser,
executable: AgentAvailability.shared.executable(for: router.analyser.kind),
table: router.table,
models: ComposerModelCatalog.shared.models[effectiveControls.agentKind] ?? []
)
} else {
route = nil
}

// Whether to ask a model for a name at all. Read here rather than inside the closure
// below, because it is two facts about this machine that only the main actor holds: the
// preference, and whether the CLI is installed.
Expand Down Expand Up @@ -267,6 +296,8 @@ extension AppModel {
// The only way out with the row still up. Everything past this line has a stored
// workspace behind it, and `reload` retires the pending row against that.
forgetPending(id)
// And the only way out with the router still asking about a chat that will not exist.
route?.cancel()
throw error
}

Expand Down Expand Up @@ -308,7 +339,7 @@ extension AppModel {

// The persisted setup state carries the creation choice. The opening prompt still goes
// through the same queue when setup is skipped, including in a chat workspace.
await model(for: started.workspace).startSetupThenSend(prompt: opening, repo: repo)
await model(for: started.workspace).startSetupThenSend(prompt: opening, repo: repo, route: route)
return started.workspace
}

Expand Down Expand Up @@ -650,7 +681,7 @@ extension AppModel {
pullRequest: PullRequest,
in model: WorkspaceModel
) async {
let template = PromptOverrides().template(for: .continueAfterMerge)
let template = PromptOverrides().template(for: .continueAfterMerge, forge: pullRequest.forge)
let render = continuation.render(template: template, pullRequest: pullRequest.number)

guard let session = await continuationSession(in: model) else { return }
Expand Down
24 changes: 23 additions & 1 deletion Sources/Bloom/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ final class AppModel {
/// Non-nil after an archive script failed, while the owner decides between keeping the
/// workspace and archiving it without the script. RootView presents it.
var pendingScriptFailure: ScriptFailureRequest?
/// A project just added with remotes on both GitHub and GitLab. RootView asks which one it
/// uses. See `ForgeResolver.offersBoth`.
var forgeQuestion: Repo? {
// A dialog dismissed without its buttons must still let `askForgeIfBoth` return.
didSet { if forgeQuestion == nil { Task { self.answerForgeQuestion(nil) } } }
}
/// Resumed once `forgeQuestion` is answered, so the create window can wait for it.
@ObservationIgnored var forgeQuestionAnswered: CheckedContinuation<Void, Never>?
/// The transcript half of a search, one row per workspace. Held here rather than in `HomeView`
/// for the same reason `homeFilter` is: the pane is destroyed and rebuilt every time the
/// selection leaves Home, and a result list that had to be fetched again on the way back would
Expand Down Expand Up @@ -517,6 +525,15 @@ final class AppModel {
private(set) var lastQuotaAskAt: Date?
private(set) var isAskingForQuotas = false
private var identityTask: Task<Void, Never>?

/// GitLab accounts once the launch's identity lookup has finished, which can be after a
/// settings pane has already opened.
func gitLabAccounts() async -> [String: String] {
await identityTask?.value
// Read again on every visit: a sign in may have happened in a terminal since launch.
if GitLab.isInstalled { await GitLabIdentity.resolve(force: true) }
return GitLabIdentity.usernames
}
/// The launch sweep for project icons. Not private, because the work it does is in
/// `AppModel+ProjectIcons.swift`, and outside observation because nothing draws from it.
@ObservationIgnored var iconSearchTask: Task<Void, Never>?
Expand Down Expand Up @@ -550,6 +567,8 @@ final class AppModel {
Log.launchStep("store open")
self.store = store
ComposerModelCatalog.shared.configure(store: store)
// For the automatic router; in the background, so nothing at launch waits for it.
AgentAvailability.shared.configure(store: store)
self.manager = WorkspaceManager(store: store)
try await store.resetRunningSessions()
try await store.recoverDeliveryClaims()
Expand Down Expand Up @@ -631,7 +650,10 @@ final class AppModel {

// Held so quitting takes it with us: it is a `gh` subprocess with a ten second timeout,
// and `Shell.run` terminates the child when its task is cancelled.
identityTask = Task { await GitHubIdentity.resolve() }
identityTask = Task {
await GitHubIdentity.resolve()
await GitLabIdentity.resolve()
}
startBackgroundRefresh()
startObservingStore()
startObservingSessions()
Expand Down
17 changes: 11 additions & 6 deletions Sources/Bloom/State/Bridges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import BloomCore
/// The one place the app layer touches `GitHub`. Keeping it behind a single adapter means a
/// change to the gh wrapper's signature is a one-file fix rather than a sweep through views.
///
/// Pull requests can be prepared by an agent or directly through git and gh. Direct merges and
/// marking a draft ready also use the GitHub CLI without involving an agent.
/// Pull requests can be prepared by an agent or directly through git and gh, or glab on GitLab.
/// Direct merges and marking a draft ready also use the forge's CLI without involving an agent.
enum GitHubBridge {
static func createPullRequestDirectly(for workspace: Workspace) async throws -> PullRequest {
try await DirectPullRequestCreation.create(for: workspace)
try await DirectPullRequestCreation.create(
for: workspace, forge: await ForgeResolver.forge(for: workspace.path)
)
}
static func readPullRequest(for workspace: Workspace, maxAge: Duration = .zero) async -> PullRequestRead {
guard await ForgeResolver.forge(for: workspace.path) == .gitHub else {
return await GitLab.readPullRequest(for: workspace, maxAge: maxAge)
}
let availability = await GitHubAvailability.shared.check()
if availability == .notInstalled {
return .unavailable(GitHubReadFailure(reason: .unavailable, message: "Install the GitHub CLI to refresh pull requests."))
Expand Down Expand Up @@ -42,14 +47,14 @@ enum GitHubBridge {
}

static func markReadyForReview(_ pullRequest: PullRequest, worktree: String) async throws {
try await GitHub.markReadyForReview(pullRequest, worktree: worktree)
try await ForgeResolver.client(for: worktree).markReadyForReview(pullRequest, worktree: worktree)
}

static func mergePullRequest(
_ pullRequest: PullRequest, base: String, method: GitHub.MergeMethod,
squashMessage: SquashCommitMessage?, worktree: String
) async throws -> DirectMergeOutcome {
try await GitHub.mergePullRequest(
try await ForgeResolver.client(of: pullRequest.forge).mergePullRequest(
pullRequest, into: base, method: method, squashMessage: squashMessage, worktree: worktree
)
}
Expand All @@ -58,7 +63,7 @@ enum GitHubBridge {
/// as it always was, because `try?` would flatten it into that nil.
static func checks(for workspace: Workspace) async -> [CheckRun]? {
do {
return try await GitHub.checks(for: workspace)
return try await ForgeResolver.client(for: workspace.path).checks(for: workspace)
} catch {
return []
}
Expand Down
Loading
Loading