Skip to content

feat: add hook system with controller and event hooks#72

Merged
sirily11 merged 2 commits into
mainfrom
hook
May 31, 2026
Merged

feat: add hook system with controller and event hooks#72
sirily11 merged 2 commits into
mainfrom
hook

Conversation

@sirily11
Copy link
Copy Markdown
Contributor

Summary

  • Introduce a new hook architecture in RxCodeCore (Hook, HookController, HookOutcome, HookPayloads)
  • Add an AppState-backed HookManager and AppStateHookController to drive hooks from app state
  • Migrate user hooks, notifications, and response handling into dedicated event hooks: CI, MCP, permission, question, remote config, response, and user-added

Notes

  • 15 new events added across the migrated notification/response paths
  • Removes the legacy logic from AppState+Hooks.swift in favor of the new system

🤖 Generated with Claude Code

Introduce a new hook architecture in RxCodeCore (Hook, HookController,
HookOutcome, HookPayloads) and an AppState-backed HookManager /
AppStateHookController. Migrate user hooks, notifications, and response
handling into dedicated event hooks (CI, MCP, permission, question,
remote config, response, user-added).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 31, 2026 12:12
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rxcode Ready Ready Preview, Comment May 31, 2026 1:20pm

Request Review

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Introduces a new, strongly-typed hook architecture in RxCodeCore and wires it into the macOS app via an AppState-backed controller/manager, migrating legacy hook + notification dispatch sites onto dedicated hook events.

Changes:

  • Adds RxCodeCore hook primitives (Hook, HookController, HookOutcome/HookAggregateResult, HookPayloads) to model lifecycle events with Codable/Sendable payloads.
  • Adds HookManager + AppStateHookController and registers built-in hooks (user-added bash hooks + various notification hooks).
  • Replaces legacy hook/notification dispatch in AppState with typed hook dispatches across session lifecycle, CI/MCP, permissions/plans, remote config, and project add/clone.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
RxCode/Services/Hooks/hooks/UserAddedHook.swift Runs persisted user bash hook profiles and persists synthetic hook cards/output.
RxCode/Services/Hooks/hooks/ResponseNotificationHook.swift Posts response-complete notifications via afterSessionEnd.
RxCode/Services/Hooks/hooks/RemoteConfigNotificationHook.swift Posts banners for remote config changes.
RxCode/Services/Hooks/hooks/QuestionNotificationHook.swift Posts banners when an AskUserQuestion tool call arrives.
RxCode/Services/Hooks/hooks/PermissionNotificationHook.swift Posts banners when a permission approval is needed.
RxCode/Services/Hooks/hooks/MCPNotificationHook.swift Posts banners for MCP disconnect events.
RxCode/Services/Hooks/hooks/CINotificationHook.swift Posts banners when CI fails.
RxCode/Services/Hooks/HookManager.swift Registers and sequentially dispatches typed events to enabled hooks.
RxCode/Services/Hooks/AppStateHookController.swift Implements HookController backed by AppState capabilities.
RxCode/App/AppState+Stream.swift Dispatches stop/permission/plan events through the new hook manager.
RxCode/App/AppState+Project.swift Dispatches repository added/cloned events; adjusts add APIs to return Project?.
RxCode/App/AppState+MobileRemote.swift Routes remote-config banners through the remote-config hook event.
RxCode/App/AppState+Lifecycle.swift Routes permission/question notifications through hook events.
RxCode/App/AppState+Hooks.swift Removes legacy runHooks implementation; keeps hook profile persistence utilities.
RxCode/App/AppState+CrossProject.swift Routes session start/end hooks and response-complete notifications through hook dispatch.
RxCode/App/AppState+CIStatus.swift Routes CI-failure notifications through hook dispatch.
RxCode/App/AppState+Agents.swift Routes MCP disconnect notifications through hook dispatch.
RxCode/App/AppState.swift Adds and initializes hookController/hookManager and registers built-in hooks.
Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift Defines event kinds and typed Codable/Sendable payload structs.
Packages/Sources/RxCodeCore/Hooks/HookOutcome.swift Defines per-hook outcomes and folding into an aggregate result.
Packages/Sources/RxCodeCore/Hooks/HookController.swift Defines the host capability surface exposed to hooks.
Packages/Sources/RxCodeCore/Hooks/Hook.swift Defines the hook protocol and default no-op handlers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

/// Posts the "response complete" banner when a turn finishes on its own. Fires
/// on `afterSessionEnd`, but only for a genuinely-completed, non-errored turn
/// (a cancelled turn or an errored one is suppressed). The AI summary + post are
/// done in a detached `Task` so dispatch isn't blocked on the network.
Comment on lines +1 to +16
import Foundation
import os
import RxCodeCore

/// Registry + dispatcher for `Hook`s. Owned by `AppState`. Dispatch runs each
/// enabled hook **sequentially in registration order** (not concurrently):
/// hook cards are inserted one-by-one and the persisted "last hook" status row
/// is overwritten per hook, so concurrent runs would race the message list and
/// that row. Each event has its own typed `dispatch*` method so payloads stay
/// strongly typed end-to-end.
@MainActor
final class HookManager {
private(set) var hooks: [any Hook] = []
private let controller: any HookController
private let logger = Logger(subsystem: "com.claudework", category: "HookManager")

Comment on lines +1 to +14
import AppKit
import Foundation
import os
import RxCodeCore

/// Concrete `HookController` backed by `AppState`. This is the *only* type
/// allowed to reach into app internals on a hook's behalf; hooks themselves
/// stay decoupled from `AppState`. Holds a `weak` reference so the controller
/// never keeps the app alive (AppState → HookManager → controller → AppState).
@MainActor
final class AppStateHookController: HookController {
private weak var app: AppState?
private let logger = Logger(subsystem: "com.claudework", category: "HookController")

Comment on lines +133 to +149
public struct PermissionDecisionPayload: Codable, Sendable {
public let toolUseId: String
public let toolName: String
public let sessionId: String?
public let projectId: UUID?
/// Stable, serializable label for the decision (e.g. "allow", "deny",
/// "allowSessionTool", "allowAndSetMode", "denyWithReason").
public let decision: String

public init(toolUseId: String, toolName: String, sessionId: String?, projectId: UUID?, decision: String) {
self.toolUseId = toolUseId
self.toolName = toolName
self.sessionId = sessionId
self.projectId = projectId
self.decision = decision
}
}
Comment on lines +526 to +534
// Fan the resolved decision out to hooks. Dispatching here (rather than at
// each UI button) means desktop and mobile responses both fire exactly once.
let payload = PermissionDecisionPayload(
toolUseId: request.id,
toolName: request.toolName,
sessionId: request.sessionId,
projectId: window.selectedProject?.id,
decision: Self.permissionDecisionLabel(decision)
)
/// Posts the "response complete" banner when a turn finishes on its own. Fires
/// on `afterSessionEnd`, but only for a genuinely-completed, non-errored turn
/// (a cancelled turn or an errored one is suppressed). The AI summary + post are
/// done in a detached `Task` so dispatch isn't blocked on the network.
Comment on lines +1 to +16
import Foundation
import os
import RxCodeCore

/// Registry + dispatcher for `Hook`s. Owned by `AppState`. Dispatch runs each
/// enabled hook **sequentially in registration order** (not concurrently):
/// hook cards are inserted one-by-one and the persisted "last hook" status row
/// is overwritten per hook, so concurrent runs would race the message list and
/// that row. Each event has its own typed `dispatch*` method so payloads stay
/// strongly typed end-to-end.
@MainActor
final class HookManager {
private(set) var hooks: [any Hook] = []
private let controller: any HookController
private let logger = Logger(subsystem: "com.claudework", category: "HookManager")

Comment on lines +1 to +14
import AppKit
import Foundation
import os
import RxCodeCore

/// Concrete `HookController` backed by `AppState`. This is the *only* type
/// allowed to reach into app internals on a hook's behalf; hooks themselves
/// stay decoupled from `AppState`. Holds a `weak` reference so the controller
/// never keeps the app alive (AppState → HookManager → controller → AppState).
@MainActor
final class AppStateHookController: HookController {
private weak var app: AppState?
private let logger = Logger(subsystem: "com.claudework", category: "HookController")

Comment on lines +133 to +149
public struct PermissionDecisionPayload: Codable, Sendable {
public let toolUseId: String
public let toolName: String
public let sessionId: String?
public let projectId: UUID?
/// Stable, serializable label for the decision (e.g. "allow", "deny",
/// "allowSessionTool", "allowAndSetMode", "denyWithReason").
public let decision: String

public init(toolUseId: String, toolName: String, sessionId: String?, projectId: UUID?, decision: String) {
self.toolUseId = toolUseId
self.toolName = toolName
self.sessionId = sessionId
self.projectId = projectId
self.decision = decision
}
}
Comment on lines +526 to +534
// Fan the resolved decision out to hooks. Dispatching here (rather than at
// each UI button) means desktop and mobile responses both fire exactly once.
let payload = PermissionDecisionPayload(
toolUseId: request.id,
toolName: request.toolName,
sessionId: request.sessionId,
projectId: window.selectedProject?.id,
decision: Self.permissionDecisionLabel(decision)
)
Add HookChoice/secret-file domain types, continuation-based hook UI
requests (picker + confirmation), SecretsAutoDownloadHook, and a progress
overlay. Wire interactive UI and secrets methods into AppStateHookController
and AppState, and surface hidden .env files with NSOpenPanel in AddSecretSheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sirily11 sirily11 enabled auto-merge (squash) May 31, 2026 13:20
@sirily11 sirily11 merged commit 434ea12 into main May 31, 2026
13 checks passed
@sirily11 sirily11 deleted the hook branch May 31, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants