Skip to content
Open
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
40 changes: 34 additions & 6 deletions Sources/OpenIslandApp/HookInstallationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -472,9 +472,17 @@ final class HookInstallationCoordinator {
guard let self else { return }

let binaryURL = self.hooksBinaryURL
let expectsClaude = self.expectsInstalledHooks(.claudeCode)
let expectsCodex = self.expectsInstalledHooks(.codex)
let (claudeReport, codexReport, openCodeReport) = await Task.detached(priority: .utility) {
let claude = HookHealthCheck.checkClaude(hooksBinaryURL: binaryURL)
let codex = HookHealthCheck.checkCodex(hooksBinaryURL: binaryURL)
let claude = HookHealthCheck.checkClaude(
hooksBinaryURL: binaryURL,
expectsInstalledHooks: expectsClaude
)
let codex = HookHealthCheck.checkCodex(
hooksBinaryURL: binaryURL,
expectsInstalledHooks: expectsCodex
)
let openCode = HookHealthCheck.checkOpenCode()
return (claude, codex, openCode)
}.value
Expand All @@ -500,9 +508,17 @@ final class HookInstallationCoordinator {

// Re-run health checks first
let binaryURL = hooksBinaryURL
let expectsClaude = expectsInstalledHooks(.claudeCode)
let expectsCodex = expectsInstalledHooks(.codex)
let (claudeReport, codexReport, openCodeReport) = await Task.detached(priority: .utility) {
let claude = HookHealthCheck.checkClaude(hooksBinaryURL: binaryURL)
let codex = HookHealthCheck.checkCodex(hooksBinaryURL: binaryURL)
let claude = HookHealthCheck.checkClaude(
hooksBinaryURL: binaryURL,
expectsInstalledHooks: expectsClaude
)
let codex = HookHealthCheck.checkCodex(
hooksBinaryURL: binaryURL,
expectsInstalledHooks: expectsCodex
)
let openCode = HookHealthCheck.checkOpenCode()
return (claude, codex, openCode)
}.value
Expand Down Expand Up @@ -536,8 +552,14 @@ final class HookInstallationCoordinator {
if repaired {
try? await Task.sleep(for: .milliseconds(500))
let (updatedClaude, updatedCodex, updatedOpenCode) = await Task.detached(priority: .utility) {
let claude = HookHealthCheck.checkClaude(hooksBinaryURL: binaryURL)
let codex = HookHealthCheck.checkCodex(hooksBinaryURL: binaryURL)
let claude = HookHealthCheck.checkClaude(
hooksBinaryURL: binaryURL,
expectsInstalledHooks: expectsClaude
)
let codex = HookHealthCheck.checkCodex(
hooksBinaryURL: binaryURL,
expectsInstalledHooks: expectsCodex
)
let openCode = HookHealthCheck.checkOpenCode()
return (claude, codex, openCode)
}.value
Expand Down Expand Up @@ -798,6 +820,12 @@ final class HookInstallationCoordinator {
/// install. `.untouched` and `.uninstalled` both return false;
/// untouched agents are surfaced to the user via the first-run
/// onboarding window and the empty-state banner instead.
/// Whether the user has asked for this agent's hooks, so a config with none
/// of ours in it is a fault worth reporting rather than the desired state.
func expectsInstalledHooks(_ agent: AgentIdentifier) -> Bool {
intentStore.intent(for: agent) == .installed
}

func shouldAutoInstall(_ agent: AgentIdentifier) -> Bool {
guard intentStore.intent(for: agent) == .installed else {
return false
Expand Down
54 changes: 53 additions & 1 deletion Sources/OpenIslandCore/ClaudeHookInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ public enum ClaudeHookInstaller {
var rootObject = try loadRootObject(from: existingData)
var hooksObject = rootObject["hooks"] as? [String: Any] ?? [:]
var mutated = false
var ownHooksPresent = false

for spec in eventSpecs {
let existingGroups = hooksObject[spec.name] as? [Any] ?? []
Expand All @@ -126,6 +127,10 @@ public enum ClaudeHookInstaller {
mutated = true
}

if containsOwnManagedHook(in: existingGroups, managedCommand: managedCommand) {
ownHooksPresent = true
}

if cleanedGroups.isEmpty {
hooksObject.removeValue(forKey: spec.name)
} else {
Expand All @@ -143,7 +148,7 @@ public enum ClaudeHookInstaller {
return ClaudeHookFileMutation(
contents: contents,
changed: mutated || contents != existingData,
managedHooksPresent: mutated,
managedHooksPresent: ownHooksPresent,
hasClaudeIslandHooks: containsClaudeIslandHook(in: hooksObject)
)
}
Expand Down Expand Up @@ -230,6 +235,25 @@ public enum ClaudeHookInstaller {
}
}

/// Whether any hook in `groups` is one of ours.
/// The ownership counterpart to ``containsManagedHook(in:managedCommand:)``.
private static func containsOwnManagedHook(in groups: [Any], managedCommand: String?) -> Bool {
groups.contains { item in
guard let group = item as? [String: Any],
let hooks = group["hooks"] as? [Any] else {
return false
}

return hooks.contains { hook in
guard let hook = hook as? [String: Any] else {
return false
}

return isOwnManagedHook(hook, managedCommand: managedCommand)
}
}
}

private static func containsClaudeIslandHook(in hooksObject: [String: Any]) -> Bool {
hooksObject.values.contains { value in
let groups = value as? [Any] ?? []
Expand Down Expand Up @@ -275,6 +299,10 @@ public enum ClaudeHookInstaller {
return group
}

/// Whether a hook entry should be dropped when we (re)write our own hooks.
/// Deliberately broad: it also covers the closed-source Vibe Island bridge
/// so a user migrating from that app doesn't end up with two islands
/// reacting to every event.
private static func isManagedHook(_ hook: [String: Any], managedCommand: String?) -> Bool {
guard let command = hook["command"] as? String else {
return false
Expand All @@ -287,6 +315,30 @@ public enum ClaudeHookInstaller {
return isLegacyOpenIslandHookCommand(command)
}

/// Whether a hook entry is *ours* — the command we installed, or our hook
/// CLI under its current or pre-rename name.
///
/// This is the question "are Open Island's hooks installed?", and it must
/// stay narrower than ``isManagedHook(_:managedCommand:)``. Answering it
/// with the broad predicate made a leftover `vibe-island-bridge` entry from
/// the closed-source app read as a successful install, which reported
/// "hooks installed" in Settings and suppressed the startup repair in
/// `shouldAutoInstall` — so no hook of ours ever ran.
private static func isOwnManagedHook(_ hook: [String: Any], managedCommand: String?) -> Bool {
guard let command = hook["command"] as? String else {
return false
}

if let managedCommand, command == managedCommand {
return true
}

// Path-independent fallback: the app bundle may have moved since
// install, which invalidates the recorded command but not ownership.
let normalized = command.lowercased()
return normalized.contains("openislandhooks") || normalized.contains("vibeislandhooks")
}

private static func isManagedHookForInstall(_ hook: [String: Any], replacingCommand: String) -> Bool {
if isManagedHook(hook, managedCommand: replacingCommand) {
return true
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenIslandCore/CodexHookInstallationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public final class CodexHookInstallationManager: @unchecked Sendable {
let managedHooksPresent = ((try? CodexHookInstaller.uninstallHooksJSON(
existingData: hooksData,
managedCommand: managedCommand
))?.changed) == true
))?.ownHooksPresent) == true

return CodexHookInstallationStatus(
codexDirectory: codexDirectory,
Expand Down
41 changes: 36 additions & 5 deletions Sources/OpenIslandCore/CodexHookInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,26 @@ public struct CodexHookFileMutation: Equatable, Sendable {
public var contents: Data?
public var changed: Bool
public var hasRemainingHooks: Bool

public init(contents: Data?, changed: Bool, hasRemainingHooks: Bool) {
/// Whether hooks of ours were actually found in the file.
///
/// Distinct from ``changed``, which is also true when the file merely
/// re-serializes differently (we write sorted, pretty-printed JSON). Use
/// this — never ``changed`` — to answer "are our hooks installed?".
public var ownHooksPresent: Bool

/// - Parameter ownHooksPresent: Defaults to `false` so existing callers that
/// only describe a write keep compiling; the uninstall path, which is the
/// one asked about installed state, always passes it explicitly.
public init(
contents: Data?,
changed: Bool,
hasRemainingHooks: Bool,
ownHooksPresent: Bool = false
) {
self.contents = contents
self.changed = changed
self.hasRemainingHooks = hasRemainingHooks
self.ownHooksPresent = ownHooksPresent
Comment on lines +38 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Make the modified models Codable.

  • Sources/OpenIslandCore/CodexHookInstaller.swift#L38-L54: Add Codable to CodexHookFileMutation.
  • Sources/OpenIslandCore/HookHealthCheck.swift#L27-L29: Add Codable to HookHealthReport and its nested model types.

As per coding guidelines, “All models must be Sendable and Codable.”

📍 Affects 2 files
  • Sources/OpenIslandCore/CodexHookInstaller.swift#L38-L54 (this comment)
  • Sources/OpenIslandCore/HookHealthCheck.swift#L27-L29
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/OpenIslandCore/CodexHookInstaller.swift` around lines 38 - 54, Make
CodexHookFileMutation in Sources/OpenIslandCore/CodexHookInstaller.swift conform
to Codable. Also update HookHealthReport and every nested model type in
Sources/OpenIslandCore/HookHealthCheck.swift to conform to Codable, preserving
their existing Sendable conformances and behavior.

Source: Coding guidelines

}
}

Expand Down Expand Up @@ -133,12 +148,18 @@ public enum CodexHookInstaller {
var rootObject = try loadRootObject(from: existingData)
var hooksObject = rootObject["hooks"] as? [String: Any] ?? [:]
var mutated = false
var ownHooksPresent = false

for spec in eventSpecs {
let existingGroups = hooksObject[spec.name] as? [Any] ?? []
let cleanedGroups = sanitize(groups: existingGroups, managedCommand: managedCommand)

if cleanedGroups.count != existingGroups.count || containsManagedHook(in: existingGroups, managedCommand: managedCommand) {
let hadOwnHooks = containsManagedHook(in: existingGroups, managedCommand: managedCommand)
if hadOwnHooks {
ownHooksPresent = true
}

if cleanedGroups.count != existingGroups.count || hadOwnHooks {
mutated = true
Comment on lines +157 to 163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use an ownership-specific predicate.

Line 154 calls containsManagedHook. That predicate accepts legacyManagedStatusMessage, so a Vibe Island-only Codex configuration sets ownHooksPresent to true.

CodexHookInstallationManager.status() then reports Open Island hooks as installed. Intent migration can suppress the required repair.

Add containsOwnManagedHook and isOwnManagedHook, as in ClaudeHookInstaller. Do not use legacy status messages to establish ownership. Add a regression test with Managed by Vibe Island entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/OpenIslandCore/CodexHookInstaller.swift` around lines 154 - 160,
Update CodexHookInstaller’s ownership detection by adding containsOwnManagedHook
and isOwnManagedHook, matching the ClaudeHookInstaller pattern, and use the
ownership-specific predicate when setting ownHooksPresent and deciding mutation.
Exclude legacyManagedStatusMessage from ownership checks, while retaining it
only for cleanup if needed. Add a regression test covering entries with “Managed
by Vibe Island” and verify CodexHookInstallationManager.status() does not report
Open Island hooks as installed.

}

Expand All @@ -150,12 +171,22 @@ public enum CodexHookInstaller {
}

if hooksObject.isEmpty {
return CodexHookFileMutation(contents: nil, changed: mutated, hasRemainingHooks: false)
return CodexHookFileMutation(
contents: nil,
changed: mutated,
hasRemainingHooks: false,
ownHooksPresent: ownHooksPresent
)
}

rootObject["hooks"] = hooksObject
let data = try serialize(rootObject)
return CodexHookFileMutation(contents: data, changed: mutated || data != existingData, hasRemainingHooks: true)
return CodexHookFileMutation(
contents: data,
changed: mutated || data != existingData,
hasRemainingHooks: true,
ownHooksPresent: ownHooksPresent
)
}

/// Enables the current Codex hooks feature flag and migrates the legacy flag when present.
Expand Down
37 changes: 30 additions & 7 deletions Sources/OpenIslandCore/HookHealthCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public struct HookHealthReport: Equatable, Sendable {
case manifestMissing(expectedPath: String)
/// The OpenCode plugin file is missing even though it should be installed.
case pluginMissing(expectedPath: String)
/// The user asked for these hooks, but none of ours are in the config.
/// Usually another tool rewrote the file and dropped our entries.
case hooksMissing(configPath: String)

public var description: String {
switch self {
Expand All @@ -41,6 +44,8 @@ public struct HookHealthReport: Equatable, Sendable {
"Installation manifest missing: \(expectedPath)"
case .pluginMissing(let expectedPath):
"OpenCode plugin file is missing: \(expectedPath)"
case .hooksMissing(let configPath):
"Open Island hooks are missing from \(configPath) — another tool may have rewritten it."
}
}

Expand All @@ -55,7 +60,7 @@ public struct HookHealthReport: Equatable, Sendable {

public var isAutoRepairable: Bool {
switch self {
case .staleCommandPath, .binaryNotExecutable, .manifestMissing, .pluginMissing:
case .staleCommandPath, .binaryNotExecutable, .manifestMissing, .pluginMissing, .hooksMissing:
true
default:
false
Expand Down Expand Up @@ -97,10 +102,16 @@ public struct HookHealthReport: Equatable, Sendable {
/// Performs deep health checks on hook installations, beyond the simple "managed hooks present" check.
public enum HookHealthCheck {
/// Check Claude Code hook health.
///
/// - Parameter expectsInstalledHooks: Whether the user has asked for these
/// hooks (persisted intent is `.installed`). Only then is an empty config
/// a problem — otherwise "no hooks of ours" is the desired state, and
/// reporting it would auto-repair hooks the user deliberately removed.
public static func checkClaude(
claudeDirectory: URL = ClaudeConfigDirectory.resolved(),
hooksBinaryURL: URL? = nil,
managedHooksBinaryURL: URL = ManagedHooksBinary.defaultURL(),
expectsInstalledHooks: Bool = false,
fileManager: FileManager = .default
) -> HookHealthReport {
var issues: [HookHealthReport.Issue] = []
Expand Down Expand Up @@ -151,13 +162,19 @@ public enum HookHealthCheck {
}
}

// 3. Check manifest
if fileManager.fileExists(atPath: settingsPath),
hasOpenIslandHooks(in: settingsURL, fileManager: fileManager) {
// 3. Check that our own hooks are actually there, and the manifest.
let hasOwnHooks = hasOpenIslandHooks(in: settingsURL, fileManager: fileManager)

if hasOwnHooks {
let legacyManifestURL = claudeDirectory.appendingPathComponent(ClaudeHookInstallerManifest.legacyFileName)
if !fileManager.fileExists(atPath: manifestURL.path) && !fileManager.fileExists(atPath: legacyManifestURL.path) {
issues.append(.manifestMissing(expectedPath: manifestURL.path))
}
} else if expectsInstalledHooks, !issues.contains(.configMalformedJSON(path: settingsPath)) {
// Don't claim the hooks are gone when we couldn't parse the file —
// the malformed-JSON issue already describes that case, and
// re-installing over unparseable settings would not help.
issues.append(.hooksMissing(configPath: settingsPath))
}

return HookHealthReport(
Expand All @@ -169,10 +186,13 @@ public enum HookHealthCheck {
}

/// Check Codex hook health.
///
/// - Parameter expectsInstalledHooks: See ``checkClaude(claudeDirectory:hooksBinaryURL:managedHooksBinaryURL:expectsInstalledHooks:fileManager:)``.
public static func checkCodex(
codexDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex", isDirectory: true),
hooksBinaryURL: URL? = nil,
managedHooksBinaryURL: URL = ManagedHooksBinary.defaultURL(),
expectsInstalledHooks: Bool = false,
fileManager: FileManager = .default
) -> HookHealthReport {
var issues: [HookHealthReport.Issue] = []
Expand Down Expand Up @@ -214,13 +234,16 @@ public enum HookHealthCheck {
}
}

// 3. Check manifest
if fileManager.fileExists(atPath: hooksPath),
hasOpenIslandHooks(in: hooksURL, fileManager: fileManager) {
// 3. Check that our own hooks are actually there, and the manifest.
let hasOwnHooks = hasOpenIslandHooks(in: hooksURL, fileManager: fileManager)

if hasOwnHooks {
let legacyManifestURL = codexDirectory.appendingPathComponent(CodexHookInstallerManifest.legacyFileName)
if !fileManager.fileExists(atPath: manifestURL.path) && !fileManager.fileExists(atPath: legacyManifestURL.path) {
issues.append(.manifestMissing(expectedPath: manifestURL.path))
}
} else if expectsInstalledHooks, !issues.contains(.configMalformedJSON(path: hooksPath)) {
issues.append(.hooksMissing(configPath: hooksPath))
}

return HookHealthReport(
Expand Down
Loading
Loading