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
54 changes: 54 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/Hook.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// A unit of automation that reacts to lifecycle events. Many hooks can be
/// registered at once; each implements only the events it cares about (every
/// method has a no-op default). Each method receives a typed payload and the
/// `HookController` it can use to act on the thread / IDE.
///
/// `@MainActor` because hooks drive main-actor host state through the
/// controller. Out-of-process plugins are fronted by a single adapter hook that
/// serializes the (Codable) payloads — they do not conform to this protocol
/// directly.
@MainActor
public protocol Hook: AnyObject {
/// Stable identifier for logging and de-duplication.
var hookID: String { get }
/// Whether this hook should receive events. Defaults to true.
var isEnabled: Bool { get }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome
func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome
func onRepositoryAdded(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome
func onRepositoryCloned(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome
func onQuestionAsk(_ payload: QuestionAskPayload, controller: any HookController) async -> HookOutcome
func onPermissionAsk(_ payload: PermissionAskPayload, controller: any HookController) async -> HookOutcome
func onPermissionApprove(_ payload: PermissionDecisionPayload, controller: any HookController) async -> HookOutcome
func onPermissionDenied(_ payload: PermissionDecisionPayload, controller: any HookController) async -> HookOutcome
func onPlanAccept(_ payload: PlanAcceptPayload, controller: any HookController) async -> HookOutcome
func onPlanReject(_ payload: PlanRejectPayload, controller: any HookController) async -> HookOutcome
func onMCPDisconnected(_ payload: MCPDisconnectedPayload, controller: any HookController) async -> HookOutcome
func onCIFailed(_ payload: CIFailedPayload, controller: any HookController) async -> HookOutcome
func onRemoteConfigChanged(_ payload: RemoteConfigChangedPayload, controller: any HookController) async -> HookOutcome
}

public extension Hook {
var isEnabled: Bool { true }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored }
func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onRepositoryAdded(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onRepositoryCloned(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onQuestionAsk(_ payload: QuestionAskPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onPermissionAsk(_ payload: PermissionAskPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onPermissionApprove(_ payload: PermissionDecisionPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onPermissionDenied(_ payload: PermissionDecisionPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onPlanAccept(_ payload: PlanAcceptPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onPlanReject(_ payload: PlanRejectPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onMCPDisconnected(_ payload: MCPDisconnectedPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onCIFailed(_ payload: CIFailedPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onRemoteConfigChanged(_ payload: RemoteConfigChangedPayload, controller: any HookController) async -> HookOutcome { .ignored }
}
26 changes: 26 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookChoice.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

/// A single selectable option presented to the user by a hook (e.g. one secret
/// environment). Decouples the generic `HookController.requestChoice` picker
/// from any domain model, so the same primitive serves future hooks.
public struct HookChoice: Identifiable, Sendable, Hashable {
public let id: String
public let label: String

public init(id: String, label: String) {
self.id = id
self.label = label
}
}

/// A decrypted file a hook intends to write to disk. Carries plaintext, so it
/// never leaves the in-process app boundary.
public struct HookSecretFile: Sendable, Hashable {
public let filename: String
public let content: String

public init(filename: String, content: String) {
self.filename = filename
self.content = content
}
}
84 changes: 84 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation
import SwiftUI

/// The capabilities a hook may use to act on the thread / IDE. This is the
/// single seam between hooks and the host app (`AppState`): hooks never touch
/// app internals directly, only this surface. It is also the vocabulary a
/// future out-of-process plugin host would expose over RPC.
///
/// `@MainActor` because everything it drives — the message store, the
/// notification center, the thread database — already runs on the main actor.
@MainActor
public protocol HookController: AnyObject {
// MARK: Chat cards

/// Insert a synthetic "running" tool-call card (spinner) into a session's
/// message list and return a handle to complete it later. Streams to paired
/// mobile devices automatically.
func insertCard(sessionKey: String, toolName: String, input: [String: JSONValue]) -> HookCardHandle

/// Fill in a previously-inserted card's result, flipping the spinner to a
/// success/error badge.
func completeCard(_ handle: HookCardHandle, sessionKey: String, result: String, isError: Bool)

/// Persist a session's "last hook" so the synthetic card can be rebuilt on
/// reload (hook cards never reach the CLI transcript).
func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool)

/// Enabled user hook profiles for a project + trigger, loading from disk on
/// first access.
func enabledHookProfiles(projectId: UUID, trigger: HookTrigger) async -> [HookProfile]

// MARK: Queries

var notificationsEnabled: Bool { get }
var isAppActive: Bool { get }

func project(for id: UUID) -> Project?
/// The display title of a session (from its summary), if known.
func sessionTitle(sessionId: String) -> String?
/// First-sentence fallback body for a response-complete notification.
func responseNotificationFallback(from responseText: String) -> String
/// Optionally generate an AI summary of the response for a notification body.
/// Returns nil when summaries are disabled or the session summary is unknown.
func responseNotificationSummary(responseText: String, sessionId: String) async -> String?

// MARK: Notifications

func postResponseComplete(title: String, body: String, projectId: UUID, sessionId: String, postLocalBanner: Bool) async
func postQuestionNeeded(projectName: String?, projectId: UUID?, sessionId: String?) async
func postPermissionNeeded(toolName: String, projectName: String?, projectId: UUID?, sessionId: String?) async
func postMCPDisconnected(name: String, error: String?) async
func postCIFailed(projectName: String?, projectId: UUID?, failingWorkflowNames: [String]) async
func postRemoteConfigChanged(title: String, body: String) async

// MARK: Interactive UI

/// Show the hook loading dialog with an initial status line (e.g. while an
/// async hook does its work). Idempotent — calling again just updates text.
func beginProgress(_ status: LocalizedStringKey)
/// Update the status line of the visible loading dialog.
func updateProgress(_ status: LocalizedStringKey)
/// Dismiss the loading dialog.
func endProgress()

/// Present a single-choice picker and suspend until the user picks (returns
/// the chosen `HookChoice.id`) or cancels (returns `nil`).
func requestChoice(title: LocalizedStringKey, choices: [HookChoice]) async -> String?

/// Present a confirm/cancel dialog; returns `true` if confirmed. `detail`
/// carries dynamic, non-localizable text (e.g. a list of filenames).
func requestConfirmation(title: LocalizedStringKey, detail: String?) async -> Bool

// MARK: Secrets

/// Environments configured for a repo in autopilot. Returns `[]` when there
/// are none, the user is signed out, or the request fails.
func secretEnvironments(repoFullName: String) async -> [HookChoice]
/// Download + decrypt an environment's files (may prompt for the passkey).
/// Does not write anything to disk.
func fetchSecrets(repoFullName: String, env: String) async throws -> [HookSecretFile]
/// Write decrypted files into a project folder, skipping existing files
/// unless `overwrite`. Returns the filenames actually written.
func writeSecrets(_ files: [HookSecretFile], toPath path: String, overwrite: Bool) throws -> [String]
}
88 changes: 88 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookOutcome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation

/// The result of a single hook handling one event.
///
/// `contextOutput` is text the hook contributes back to the agent's context
/// (a start hook's stdout); `isError` mirrors a non-zero bash exit so a failing
/// stop hook can auto-continue the agent; `block` lets a hook veto the action
/// (reserved for future use — e.g. denying a permission or plan from a plugin).
public struct HookOutcome: Sendable {
public enum Control: Sendable {
/// The hook did not handle this event (the default no-op).
case ignored
/// The hook handled the event; proceed normally.
case proceed
/// The hook wants to veto/stop the action. `blockReason` explains why.
case block
}

public let control: Control
public let contextOutput: String?
public let isError: Bool
public let blockReason: String?

public init(control: Control, contextOutput: String? = nil, isError: Bool = false, blockReason: String? = nil) {
self.control = control
self.contextOutput = contextOutput
self.isError = isError
self.blockReason = blockReason
}

public static let ignored = HookOutcome(control: .ignored)
public static let proceed = HookOutcome(control: .proceed)

/// A handled outcome that contributes `text` to the agent context. Pass
/// `isError: true` to signal a non-zero exit (used by stop-hook auto-continue).
public static func output(_ text: String, isError: Bool = false) -> HookOutcome {
HookOutcome(control: .proceed, contextOutput: text.isEmpty ? nil : text, isError: isError)
}

public static func block(_ reason: String) -> HookOutcome {
HookOutcome(control: .block, blockReason: reason)
}
}

/// The folded result of dispatching one event to every registered hook. A
/// drop-in replacement for the old `HookBatchResult`: `combinedOutput` feeds
/// start-hook context injection or a failing stop hook's reprompt; `hasError`
/// is true if any hook signaled a non-zero exit.
public struct HookAggregateResult: Sendable {
public let combinedOutput: String
public let hasError: Bool
public let blocked: Bool
public let blockReason: String?

public init(combinedOutput: String, hasError: Bool, blocked: Bool = false, blockReason: String? = nil) {
self.combinedOutput = combinedOutput
self.hasError = hasError
self.blocked = blocked
self.blockReason = blockReason
}

public static let empty = HookAggregateResult(combinedOutput: "", hasError: false)

/// Fold a sequence of outcomes (run in registration order) into one result.
public static func fold(_ outcomes: [HookOutcome]) -> HookAggregateResult {
let outputs = outcomes.compactMap(\.contextOutput).filter { !$0.isEmpty }
let hasError = outcomes.contains(where: \.isError)
let firstBlock = outcomes.first { $0.control == .block }
return HookAggregateResult(
combinedOutput: outputs.joined(separator: "\n\n"),
hasError: hasError,
blocked: firstBlock != nil,
blockReason: firstBlock?.blockReason
)
}
}

/// Handle to a synthetic chat "card" inserted by a hook, so the hook can fill in
/// the card's result once its work finishes.
public struct HookCardHandle: Sendable {
public let toolId: String
public let messageId: UUID

public init(toolId: String, messageId: UUID) {
self.toolId = toolId
self.messageId = messageId
}
}
Loading
Loading