diff --git a/docs-ai/053-agent-profiles/005-v1-audit.md b/docs-ai/053-agent-profiles/005-v1-audit.md index 3b678e33..f98672ba 100644 --- a/docs-ai/053-agent-profiles/005-v1-audit.md +++ b/docs-ai/053-agent-profiles/005-v1-audit.md @@ -93,6 +93,13 @@ UUID 派生 home 的双重包含闸、单一 launch action、三层推荐、root 5. settings 文件权限:加载时迁移 0600;写入改为 0600 temp + rename,消除 默认权限窗口。 6. 测试:上述各项 + provision 失败中止(不建 tab、不记身份)。 +7. (同日追加)**login-shell executable probe**(follow-up 3 的探测部分提前落地): + `AgentRuntimeAvailabilityProbe` 经 `ShellClient.runLogin` 在用户 login shell 内 + `command -v`,与启动共用同一条 PATH 解析,结果按 session 缓存于 + `@Shared(.inMemory)`;阳性终局、阴性/未知在每次打开 Agents popover 时后台重测。 + 判定两级:probe 已应答即 ground truth("not on your shell's PATH"),未应答回退 + home 启发式("may not be installed")。播种保持 home 启发式(信号语义是"用户在用 + 这家",而非"二进制存在")。launcher query 的 resolved-option 统一仍留在 follow-up。 ## 遗留 follow-up(按优先级) diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index dca22da8..09138a03 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -28,10 +28,10 @@ respawn. - **Toolbar Agents capsule** — always opens a popover. With a detected agent it leads with Hand Off; launch rows follow, the current worktree's - **Recommended** profile first. Rows for runtimes that look uninstalled are - dimmed with a warning ("… may not be installed") but stay clickable — the - heuristic has false negatives, so it never blocks a launch. "Manage Agent - Profiles…" opens Settings → Agents. + **Recommended** profile first. Rows for runtimes that look unavailable are + dimmed with a warning but stay clickable — availability signals can be + wrong, so they never block a launch. "Manage Agent Profiles…" opens + Settings → Agents. - **Command Palette** (`⌘P`) — "Launch Agent: " rows dispatch the exact same action, and carry the same availability warning in their subtitle. @@ -138,10 +138,15 @@ effort, execution mode, placement). (resume and handoff artifacts resolve against the profile's config root). An agent you start manually with your own `CLAUDE_CONFIG_DIR`/`CODEX_HOME` is still detected, but without session identity. -- Availability warnings use a heuristic: the runtime's default home - (`~/.claude` / `~/.codex`) exists iff the CLI has ever run. It can be wrong - in both directions (installed but never run; binary removed but home kept), - which is why it dims rows instead of disabling them. +- Availability is judged in two tiers. Ground truth is a background + login-shell probe (`command -v`, the same PATH resolution a launch uses): + once it answers, "not found" warns "… is not on your shell's PATH" and + "found" clears any warning. Until it answers, the fallback heuristic — the + runtime's default home (`~/.claude` / `~/.codex`) exists iff the CLI has + ever run — warns "… may not be installed". A positive probe is cached for + the session; negatives re-probe each time the Agents popover opens, so + installing a CLI mid-session clears its warning without a relaunch. Both + signals only dim rows, never disable them. - Prowl provides no directory sharing between a bound home and the default one. Symlinking read-mostly directories (e.g. `skills/`) yourself works, but never link files the CLI rewrites (`settings.json`, `config.toml`, diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index b038d372..5b47acd0 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -55,6 +55,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { "ApplePressAndHoldEnabled": false ]) AgentProfileSeeder.seedIfNeeded() + Task { await AgentRuntimeAvailabilityProbe.refresh() } appStore?.send(.appLaunched) } diff --git a/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift b/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift new file mode 100644 index 00000000..db382344 --- /dev/null +++ b/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift @@ -0,0 +1,74 @@ +import Dependencies +import Foundation +import Sharing + +extension SharedReaderKey where Self == InMemoryKey<[AgentProfileRuntime: Bool]>.Default { + /// Session cache of the login-shell executable probe. A missing entry means + /// the probe has not answered for that runtime yet this session. + static var agentRuntimeProbedAvailability: Self { + Self[.inMemory("agentRuntimeProbedAvailability"), default: [:]] + } +} + +extension AgentProfileAvailability { + /// The launcher surfaces' judgment: the session probe cache first, the + /// home-directory heuristic while a runtime is unanswered. + @MainActor + static func launchWarning(for profile: AgentProfile) -> String? { + @Shared(.agentRuntimeProbedAvailability) var probed + return launchWarning(for: profile, probedAvailable: probed[profile.runtime]) + } +} + +/// Resolves each runtime's executable through the user's login shell — the +/// same resolution a profile launch uses, so a positive answer means the +/// launch will find the binary (docs-ai 053/005). A GUI app's own PATH is the +/// launchd default and useless for this; the login shell's rc-built PATH is +/// the truth. Results cache for the session: a positive is final, while a +/// negative or unanswered runtime re-probes on the next refresh (opening the +/// Agents popover), so installing a CLI mid-session clears its warning +/// without a relaunch. +@MainActor +enum AgentRuntimeAvailabilityProbe { + static func refresh() async { + @Shared(.agentRuntimeProbedAvailability) var probed + let pending = AgentProfileRuntime.allCases.filter { probed[$0] != true } + guard !pending.isEmpty else { return } + await withTaskGroup(of: (AgentProfileRuntime, Bool?).self) { group in + for runtime in pending { + group.addTask { (runtime, await probeAvailability(of: runtime)) } + } + for await (runtime, availability) in group { + guard let availability else { continue } + $probed.withLock { $0[runtime] = availability } + } + } + } + + /// true/false when the login shell answered; nil when the probe itself + /// could not run (spawn failure) — an unanswered probe must not masquerade + /// as "not installed". + private static func probeAvailability(of runtime: AgentProfileRuntime) async -> Bool? { + guard + let executable = try? AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(agent: runtime.agent, intent: .interactive) + ).executable + else { return nil } + @Dependency(ShellClient.self) var shell + do { + // `command -v` is a shell builtin, so it runs in an inner /bin/sh that + // inherits the PATH the outer login shell built from the user's rc. + _ = try await shell.runLogin( + URL(fileURLWithPath: "/bin/sh"), + ["-c", "command -v -- '\(executable)'"], + nil, + log: false + ) + return true + } catch let error as ShellClientError { + return error.exitCode > 0 ? false : nil + } catch { + return nil + } + } +} diff --git a/supacode/Domain/AgentProfile/AgentProfileAvailability.swift b/supacode/Domain/AgentProfile/AgentProfileAvailability.swift index 0b66ebf7..ef576cd5 100644 --- a/supacode/Domain/AgentProfile/AgentProfileAvailability.swift +++ b/supacode/Domain/AgentProfile/AgentProfileAvailability.swift @@ -8,13 +8,25 @@ import Foundation /// (installed but never run, or a dedicated-home-only login) would otherwise /// lock out a perfectly launchable profile. nonisolated enum AgentProfileAvailability { + /// Two-tier judgment. The login-shell probe is ground truth once it has + /// answered — it resolves the executable exactly the way a launch will, in + /// both directions (installed-but-never-run stops warning; uninstalled with + /// a leftover home starts warning). Until it answers, the home-directory + /// heuristic fills in. static func launchWarning( for profile: AgentProfile, + probedAvailable: Bool?, isRuntimeInstalled: (AgentProfileRuntime) -> Bool = isRuntimeInstalled ) -> String? { - guard !isRuntimeInstalled(profile.runtime) else { return nil } let name = AgentRuntimeAdapterRegistry.displayName(for: profile.runtime.agent) - return "\(name) may not be installed" + switch probedAvailable { + case true?: + return nil + case false?: + return "\(name) is not on your shell's PATH" + case nil: + return isRuntimeInstalled(profile.runtime) ? nil : "\(name) may not be installed" + } } /// The runtime's default home exists iff the CLI has ever run; PATH lookups diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 6d0c2ff8..7d0286f4 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -546,7 +546,7 @@ private func customCommandItems(_ commands: [EffectiveCustomCommand]) -> [Comman func agentProfileLaunchItems( _ repositories: RepositoriesFeature.State, actionTargetWorktreeID: Worktree.ID? = nil, - isRuntimeInstalled: (AgentProfileRuntime) -> Bool = AgentProfileAvailability.isRuntimeInstalled + launchWarning: @MainActor (AgentProfile) -> String? = AgentProfileAvailability.launchWarning(for:) ) -> [CommandPaletteItem] { guard let worktree = repositories.actionTargetTerminalWorktree( @@ -571,10 +571,7 @@ func agentProfileLaunchItems( let placement = profile.id == recommendedID ? "Recommended · " : "" // Same soft availability judgment as the Agents popover — surfaced in the // subtitle, never blocking activation (docs-ai 053/005). - let warning = AgentProfileAvailability.launchWarning( - for: profile, - isRuntimeInstalled: isRuntimeInstalled - ) + let warning = launchWarning(profile) let detail = warning.map { "\($0) · \(worktree.name)" } ?? "New \(runtimeName) in \(worktree.name)" return CommandPaletteItem( id: CommandPaletteItemID.launchAgentProfile(profile.id), diff --git a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift index 2a10590e..a0010b44 100644 --- a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift +++ b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift @@ -182,6 +182,9 @@ private struct AgentsPopoverContent: View { } .padding(6) .frame(width: 280, alignment: .leading) + // Runtimes still warned about (or unanswered) re-probe on every open, so + // a CLI installed mid-session clears its warning without a relaunch. + .task { await AgentRuntimeAvailabilityProbe.refresh() } } } diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index b60a9fa2..0dd9a6c7 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -326,6 +326,34 @@ struct AgentProfileTests { ) } + @Test func launchWarningPrefersTheProbeAnswerOverTheHomeHeuristic() { + let codex = profile(name: "Codex") + + // Probe answered: ground truth in both directions, home is irrelevant. + #expect( + AgentProfileAvailability.launchWarning( + for: codex, probedAvailable: true, isRuntimeInstalled: { _ in false } + ) == nil + ) + #expect( + AgentProfileAvailability.launchWarning( + for: codex, probedAvailable: false, isRuntimeInstalled: { _ in true } + ) == "Codex is not on your shell's PATH" + ) + + // Probe unanswered: the home-directory heuristic fills in. + #expect( + AgentProfileAvailability.launchWarning( + for: codex, probedAvailable: nil, isRuntimeInstalled: { _ in true } + ) == nil + ) + #expect( + AgentProfileAvailability.launchWarning( + for: codex, probedAvailable: nil, isRuntimeInstalled: { _ in false } + ) == "Codex may not be installed" + ) + } + @Test func containmentRejectsBaseItselfAndOutsidePaths() { let base = URL(fileURLWithPath: "/base/agent-profiles", isDirectory: true) #expect(!AgentProfileLaunchPlanner.isContained(base, in: base)) diff --git a/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift b/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift new file mode 100644 index 00000000..be9886ba --- /dev/null +++ b/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift @@ -0,0 +1,58 @@ +import Dependencies +import DependenciesTestSupport +import Foundation +import Sharing +import Testing + +@testable import supacode + +@MainActor +struct AgentRuntimeAvailabilityProbeTests { + @Test(.dependencies) func refreshRecordsShellAnswersPerRuntime() async { + await withDependencies { + $0.shellClient = ShellClient( + run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runLoginImpl: { _, arguments, _, _ in + let command = arguments.joined(separator: " ") + if command.contains("'codex'") { + return ShellOutput(stdout: "/opt/homebrew/bin/codex", stderr: "", exitCode: 0) + } + // `command -v` answers "not found" with a non-zero exit, which the + // shell client surfaces as a thrown error. + throw ShellClientError(command: command, stdout: "", stderr: "", exitCode: 1) + } + ) + } operation: { + await AgentRuntimeAvailabilityProbe.refresh() + + @Shared(.agentRuntimeProbedAvailability) var probed + #expect(probed[.codex] == true) + #expect(probed[.claude] == false) + } + } + + @Test(.dependencies) func refreshLeavesFailedProbesUnansweredAndSkipsPositives() async { + let probeCalls = LockIsolated(0) + await withDependencies { + $0.shellClient = ShellClient( + run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runLoginImpl: { _, _, _, _ in + probeCalls.withValue { $0 += 1 } + // A spawn-level failure (not a shell answer) must not masquerade as + // "not installed". + throw CocoaError(.fileNoSuchFile) + } + ) + } operation: { + @Shared(.agentRuntimeProbedAvailability) var probed + // A positive answer is final for the session: no re-probe. + $probed.withLock { $0[.codex] = true } + + await AgentRuntimeAvailabilityProbe.refresh() + + #expect(probeCalls.value == 1) + #expect(probed[.codex] == true) + #expect(probed[.claude] == nil) + } + } +} diff --git a/supacodeTests/AppFeatureAgentProfileTests.swift b/supacodeTests/AppFeatureAgentProfileTests.swift index b8846b0b..3416dce3 100644 --- a/supacodeTests/AppFeatureAgentProfileTests.swift +++ b/supacodeTests/AppFeatureAgentProfileTests.swift @@ -151,7 +151,7 @@ struct AppFeatureAgentProfileTests { @Shared(.userRepositorySettings(worktree.repositoryRootURL)) var repoSettings $repoSettings.withLock { $0.defaultAgentProfileID = second.id } - let items = agentProfileLaunchItems(repositories, isRuntimeInstalled: { _ in true }) + let items = agentProfileLaunchItems(repositories, launchWarning: { _ in nil }) #expect(items.map(\.title) == ["Launch Agent: Second", "Launch Agent: First"]) #expect(items.first?.kind == .launchAgentProfile(second.id)) @@ -175,7 +175,7 @@ struct AppFeatureAgentProfileTests { @Shared(.userGlobalSettings) var settings $settings.withLock { $0.agentProfiles = [profile] } - let items = agentProfileLaunchItems(repositories, isRuntimeInstalled: { _ in false }) + let items = agentProfileLaunchItems(repositories, launchWarning: { _ in "Codex may not be installed" }) // The soft heuristic surfaces as a subtitle warning; the row stays a // normal, activatable launch item (docs-ai 053/005). @@ -201,11 +201,11 @@ struct AppFeatureAgentProfileTests { @Shared(.userGlobalSettings) var settings $settings.withLock { $0.agentProfiles = [profile] } - #expect(agentProfileLaunchItems(repositories, isRuntimeInstalled: { _ in true }).isEmpty) + #expect(agentProfileLaunchItems(repositories, launchWarning: { _ in nil }).isEmpty) let items = agentProfileLaunchItems( repositories, actionTargetWorktreeID: worktree.id, - isRuntimeInstalled: { _ in true } + launchWarning: { _ in nil } ) #expect(items.map(\.title) == ["Launch Agent: Codex"]) #expect(items.first?.subtitle?.contains(worktree.name) == true) @@ -271,7 +271,7 @@ struct AppFeatureAgentProfileTests { let items = agentProfileLaunchItems( repositories, actionTargetWorktreeID: plain.id, - isRuntimeInstalled: { _ in true } + launchWarning: { _ in nil } ) #expect(items.map(\.title) == ["Launch Agent: Codex"]) #expect(items.first?.subtitle?.contains("plain-folder") == true)