diff --git a/Packages/Sources/RxCodeCore/Hooks/Hook.swift b/Packages/Sources/RxCodeCore/Hooks/Hook.swift new file mode 100644 index 0000000..be64f7f --- /dev/null +++ b/Packages/Sources/RxCodeCore/Hooks/Hook.swift @@ -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 } +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookChoice.swift b/Packages/Sources/RxCodeCore/Hooks/HookChoice.swift new file mode 100644 index 0000000..e8bb120 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Hooks/HookChoice.swift @@ -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 + } +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift new file mode 100644 index 0000000..30cdcae --- /dev/null +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -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] +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookOutcome.swift b/Packages/Sources/RxCodeCore/Hooks/HookOutcome.swift new file mode 100644 index 0000000..694e32a --- /dev/null +++ b/Packages/Sources/RxCodeCore/Hooks/HookOutcome.swift @@ -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 + } +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift new file mode 100644 index 0000000..4b78eff --- /dev/null +++ b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift @@ -0,0 +1,206 @@ +import Foundation + +// MARK: - Hook Event Kind + +/// Identifies a lifecycle event. Used for logging and to route a serialized +/// event to a future out-of-process plugin. +public enum HookEventKind: String, Codable, Sendable, CaseIterable { + case onProjectNewChatStart + case onSessionStart + case beforeSessionEnd + case afterSessionEnd + case onRepositoryAdded + case onRepositoryCloned + case onQuestionAsk + case onPermissionAsk + case onPermissionApprove + case onPermissionDenied + case onPlanAccept + case onPlanReject + case onMCPDisconnected + case onCIFailed + case onRemoteConfigChanged +} + +// MARK: - Why a session ended + +/// Distinguishes a turn that finished on its own from one the user cancelled, +/// so a hook (e.g. the response-complete notification) can fire only on real +/// completion. `beforeSessionEnd` / `afterSessionEnd` carry this. +public enum SessionEndReason: String, Codable, Sendable { + case completed + case cancelled +} + +// MARK: - Payloads +// +// Every payload is `Codable & Sendable` so the same value that flows to an +// in-process Swift hook can be JSON-encoded for an external plugin later. + +public struct NewChatStartPayload: Codable, Sendable { + public let projectId: UUID + public let sessionKey: String + + public init(projectId: UUID, sessionKey: String) { + self.projectId = projectId + self.sessionKey = sessionKey + } +} + +public struct SessionStartPayload: Codable, Sendable { + public let project: Project + public let sessionKey: String + + public init(project: Project, sessionKey: String) { + self.project = project + self.sessionKey = sessionKey + } +} + +public struct SessionEndPayload: Codable, Sendable { + public let project: Project + public let sessionKey: String + /// The resolved CLI session id (used to title the notification / look up the + /// session summary). On the cancel path this is the in-memory session key. + public let sessionId: String + public let reason: SessionEndReason + /// True when the turn itself errored — gates the auto-continue reprompt and + /// the response-complete notification. + public let turnDidError: Bool + /// The most recent assistant text, captured at dispatch time for the + /// response-complete notification body fallback. + public let lastAssistantText: String + + public init( + project: Project, + sessionKey: String, + sessionId: String, + reason: SessionEndReason, + turnDidError: Bool, + lastAssistantText: String + ) { + self.project = project + self.sessionKey = sessionKey + self.sessionId = sessionId + self.reason = reason + self.turnDidError = turnDidError + self.lastAssistantText = lastAssistantText + } +} + +public struct RepositoryPayload: Codable, Sendable { + public let project: Project + /// True when the project was added by cloning a remote repo, false for a + /// locally-added folder. + public let wasCloned: Bool + + public init(project: Project, wasCloned: Bool) { + self.project = project + self.wasCloned = wasCloned + } +} + +public struct QuestionAskPayload: Codable, Sendable { + public let toolUseId: String + public let sessionId: String? + public let projectId: UUID? + public let projectName: String? + + public init(toolUseId: String, sessionId: String?, projectId: UUID?, projectName: String?) { + self.toolUseId = toolUseId + self.sessionId = sessionId + self.projectId = projectId + self.projectName = projectName + } +} + +public struct PermissionAskPayload: Codable, Sendable { + public let toolUseId: String + public let toolName: String + public let sessionId: String? + public let projectId: UUID? + public let projectName: String? + + public init(toolUseId: String, toolName: String, sessionId: String?, projectId: UUID?, projectName: String?) { + self.toolUseId = toolUseId + self.toolName = toolName + self.sessionId = sessionId + self.projectId = projectId + self.projectName = projectName + } +} + +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 + } +} + +public struct PlanAcceptPayload: Codable, Sendable { + public let toolUseId: String + public let sessionKey: String + /// The permission mode the user accepted into: "ask" | "acceptEdits" | "auto". + public let mode: String + + public init(toolUseId: String, sessionKey: String, mode: String) { + self.toolUseId = toolUseId + self.sessionKey = sessionKey + self.mode = mode + } +} + +public struct PlanRejectPayload: Codable, Sendable { + public let toolUseId: String + public let sessionKey: String + public let reason: String? + + public init(toolUseId: String, sessionKey: String, reason: String?) { + self.toolUseId = toolUseId + self.sessionKey = sessionKey + self.reason = reason + } +} + +public struct MCPDisconnectedPayload: Codable, Sendable { + public let name: String + public let error: String? + + public init(name: String, error: String?) { + self.name = name + self.error = error + } +} + +public struct CIFailedPayload: Codable, Sendable { + public let projectId: UUID? + public let projectName: String? + public let failingWorkflowNames: [String] + + public init(projectId: UUID?, projectName: String?, failingWorkflowNames: [String]) { + self.projectId = projectId + self.projectName = projectName + self.failingWorkflowNames = failingWorkflowNames + } +} + +public struct RemoteConfigChangedPayload: Codable, Sendable { + public let title: String + public let body: String + + public init(title: String, body: String) { + self.title = title + self.body = body + } +} diff --git a/RxCode/App/AppState+Agents.swift b/RxCode/App/AppState+Agents.swift index 10be876..205e0ec 100644 --- a/RxCode/App/AppState+Agents.swift +++ b/RxCode/App/AppState+Agents.swift @@ -503,10 +503,9 @@ extension AppState { if case .connected = (previousStatus ?? .unknown), case .failed(let message) = newStatus { - let notifyService = NotificationService.shared let serverName = name - Task { @MainActor in - await notifyService.postMCPDisconnected(name: serverName, error: message) + Task { @MainActor [weak self] in + await self?.hookManager.dispatchMCPDisconnected(MCPDisconnectedPayload(name: serverName, error: message)) } } } diff --git a/RxCode/App/AppState+CIStatus.swift b/RxCode/App/AppState+CIStatus.swift index a06b2fb..f52ae7a 100644 --- a/RxCode/App/AppState+CIStatus.swift +++ b/RxCode/App/AppState+CIStatus.swift @@ -135,11 +135,11 @@ extension AppState { let failingNames = status.failing.compactMap(\.workflowName) if notificationsEnabled { - await NotificationService.shared.postCIFailed( - projectName: project.name, + await hookManager.dispatchCIFailed(CIFailedPayload( projectId: project.id, + projectName: project.name, failingWorkflowNames: failingNames - ) + )) } if enableAutoCIFix { diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 9aabc59..9f82b51 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -188,16 +188,17 @@ extension AppState { let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } logPreflight("ideMCP", detail: "port=\(idePort.map(String.init) ?? "")") - // Before-session-start hooks fire once, only for a brand-new thread (no - // resumed CLI session). Their stdout is injected into this turn's agent - // context the same way the branch briefing / memory context is, and the - // status cards inserted by `runHooks` persist via the `.result` save. + // Session-start hooks fire once, only for a brand-new thread (no resumed + // CLI session). Their stdout is injected into this turn's agent context + // the same way the branch briefing / memory context is, and the status + // cards inserted by UserAddedHook persist via the `.result` save. var hookStartContext = "" if cliSessionId == nil, let project = projects.first(where: { $0.id == projectId }) { - hookStartContext = await runHooks( - trigger: .beforeSessionStart, - project: project, - sessionKey: sessionKey + await hookManager.dispatchProjectNewChatStart( + NewChatStartPayload(projectId: projectId, sessionKey: sessionKey) + ) + hookStartContext = await hookManager.dispatchSessionStart( + SessionStartPayload(project: project, sessionKey: sessionKey) ).combinedOutput logPreflight("hooksStart", detail: "contextChars=\(hookStartContext.count)") } @@ -719,7 +720,14 @@ extension AppState { var stopHookFailureOutput: String? if let stopProject, !stopHooksHandledStreamIds.contains(streamId) { stopHooksHandledStreamIds.insert(streamId) - let stopResult = await runHooks(trigger: .beforeSessionStop, project: stopProject, sessionKey: sessionKey) + let stopResult = await hookManager.dispatchBeforeSessionEnd(SessionEndPayload( + project: stopProject, + sessionKey: sessionKey, + sessionId: resultEvent.sessionId, + reason: .completed, + turnDidError: resultEvent.isError, + lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages) + )) if stopResult.hasError { stopHookFailureOutput = stopResult.combinedOutput } else { @@ -754,9 +762,19 @@ extension AppState { reconcileFromDisk(sessionId: resultEvent.sessionId, projectId: projectId, cwd: cwd) } - // After-session-stop hooks: shown only, not re-saved. + // After-session-stop hooks: shown only, not re-saved. This + // dispatch also drives the response-complete notification + // (ResponseNotificationHook), which self-suppresses unless + // the turn genuinely completed without error. if let stopProject { - await runHooks(trigger: .afterSessionStop, project: stopProject, sessionKey: sessionKey) + await hookManager.dispatchAfterSessionEnd(SessionEndPayload( + project: stopProject, + sessionKey: sessionKey, + sessionId: resultEvent.sessionId, + reason: .completed, + turnDidError: resultEvent.isError, + lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages) + )) } // A failing before-stop hook auto-continues the agent so it @@ -779,29 +797,9 @@ extension AppState { } } - if notificationsEnabled { - let summary = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId }) - let title = summary?.title ?? "New Session" - let responseText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) - let fallbackBody = responseNotificationFallback(from: responseText) - let pid = projectId - let sid = resultEvent.sessionId - let postLocalBanner = !NSApp.isActive - logger.info("[Notification] building response-complete session=\(sid, privacy: .public) hasSummary=\(summary != nil, privacy: .public) responseTextLen=\(responseText.count, privacy: .public) fallbackBodyLen=\(fallbackBody.count, privacy: .public)") - if responseText.isEmpty { - logger.warning("[Notification] last assistant response text is EMPTY — banner will fall back to \"Response complete\" unless the AI summary produces text. messageCount=\(self.stateForSession(sessionKey).messages.count, privacy: .public)") - } - Task { [weak self] in - var body = fallbackBody - if let self, let summary { - let aiSummary = await self.generateResponseNotificationSummary(responseText: responseText, summary: summary) - self.logger.info("[Notification] ai summary session=\(sid, privacy: .public) returnedNonNil=\(aiSummary != nil, privacy: .public) len=\(aiSummary?.count ?? 0, privacy: .public)") - body = aiSummary ?? fallbackBody - } - self?.logger.info("[Notification] posting response-complete session=\(sid, privacy: .public) finalBodyLen=\(body.count, privacy: .public) usedFallback=\(body == fallbackBody, privacy: .public)") - await NotificationService.shared.postResponseComplete(title: title, body: body, projectId: pid, sessionId: sid, postLocalBanner: postLocalBanner) - } - } + // The response-complete notification is posted by + // ResponseNotificationHook via the after-session-end + // dispatch above. scheduleThreadSummaryUpdate( sessionId: resultEvent.sessionId, diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index 7902542..eca133f 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -3,17 +3,6 @@ import os import RxCodeChatKit import RxCodeCore -/// Aggregate outcome of running every hook for one trigger. `combinedOutput` -/// is the concatenated stdout (used to inject start-hook context or to feed a -/// failing stop hook back to the agent); `hasError` is true if any hook exited -/// non-zero. -struct HookBatchResult: Sendable { - let combinedOutput: String - let hasError: Bool - - static let empty = HookBatchResult(combinedOutput: "", hasError: false) -} - extension AppState { // MARK: - Per-project hook cache (mirrors run profiles) @@ -54,90 +43,6 @@ extension AppState { "Hook: \(name.isEmpty ? "Untitled" : name)" } - /// Run every enabled hook for `trigger`, inserting a live status card into - /// the session's message list for each (running spinner → success/error + - /// expandable output). Returns the hooks' combined stdout so a caller can - /// inject it into the agent's context (used by `beforeSessionStart`). - /// - /// Inserting via `updateState` means the cards stream to paired mobile - /// devices automatically. Whether the cards persist is up to the caller: - /// start / before-stop hooks are followed by a `saveSession`; after-stop - /// hooks are not, so their cards are shown but not saved ("nothing passed"). - @discardableResult - func runHooks( - trigger: HookTrigger, - project: Project, - sessionKey: String - ) async -> HookBatchResult { - await ensureHookProfilesLoaded(for: project.id) - let hooks = hookProfiles(for: project.id).filter { $0.enabled && $0.trigger == trigger } - guard !hooks.isEmpty else { return .empty } - - var combined: [String] = [] - var anyError = false - for hook in hooks { - let toolId = UUID().uuidString - let messageId = UUID() - let toolName = Self.hookToolName(for: hook) - - updateState(sessionKey) { state in - let toolCall = ToolCall( - id: toolId, - name: toolName, - input: [ - "name": .string(hook.name), - "trigger": .string(hook.trigger.displayName), - ], - result: nil - ) - // Synthetic hook cards are NOT part of the agent's streaming - // turn, so they must not carry `isStreaming`. A stop hook runs - // after `finalizeStreamSession` has cleared the session-level - // `isStreaming`; a trailing message flagged streaming while the - // session is not makes `settledOnlyMessages` drop the whole last - // assistant run (the real reply + this card) into a streaming - // slice that never renders — both vanish. The "running" spinner - // is driven by `result == nil` instead (see `ToolResultView`). - state.messages.append(ChatMessage( - id: messageId, - role: .assistant, - blocks: [.toolCall(toolCall)], - isStreaming: false - )) - } - - let result = await HookService.run(hook, project: project) - let displayOutput = result.output.isEmpty ? "(no output)" : result.output - - updateState(sessionKey) { state in - guard let idx = state.messages.firstIndex(where: { $0.id == messageId }) else { return } - state.messages[idx].setToolResult(id: toolId, result: displayOutput, isError: result.isError) - state.messages[idx].isStreaming = false - state.messages[idx].isResponseComplete = true - } - - // Persist this hook as the session's "last hook" so its card can be - // rebuilt on reload — hook cards are synthetic and never reach the - // CLI transcript. Each hook overwrites the prior row, so the most - // recent hook (across triggers) is the one that survives. - threadStore.setHookStatus( - sessionId: sessionKey, - toolId: toolId, - name: hook.name, - trigger: hook.trigger.displayName, - output: displayOutput, - isError: result.isError - ) - - if result.isError { anyError = true } - if !result.output.isEmpty { - combined.append("### Hook: \(hook.name)\n\(result.output)") - } - } - - return HookBatchResult(combinedOutput: combined.joined(separator: "\n\n"), hasError: anyError) - } - /// Rebuild the persisted "last hook" card and append it to a freshly-loaded /// message list so the hook stays visible after a reload. No-op when there's /// no stored hook or when the live list already contains that card (matched diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index b08aa99..c2328e1 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -349,20 +349,23 @@ extension AppState { { window.presentedPermissionId = request.id } - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self else { return } if toolName == "AskUserQuestion" { - await NotificationService.shared.postQuestionNeeded( - projectName: projectName, + await self.hookManager.dispatchQuestionAsk(QuestionAskPayload( + toolUseId: request.id, + sessionId: sessionId, projectId: projectId, - sessionId: sessionId - ) + projectName: projectName + )) } else { - await NotificationService.shared.postPermissionNeeded( + await self.hookManager.dispatchPermissionAsk(PermissionAskPayload( + toolUseId: request.id, toolName: toolName, - projectName: projectName, + sessionId: sessionId, projectId: projectId, - sessionId: sessionId - ) + projectName: projectName + )) } } } diff --git a/RxCode/App/AppState+MobileRemote.swift b/RxCode/App/AppState+MobileRemote.swift index a0ce469..200b993 100644 --- a/RxCode/App/AppState+MobileRemote.swift +++ b/RxCode/App/AppState+MobileRemote.swift @@ -97,10 +97,10 @@ extension AppState { if ok { let verb = request.operation == .install ? "installed" : "removed" - await NotificationService.shared.postRemoteConfigChanged( + await hookManager.dispatchRemoteConfigChanged(RemoteConfigChangedPayload( title: "Skill \(verb) remotely", body: plugin.name - ) + )) } let result = SkillMutationResultPayload( @@ -157,7 +157,7 @@ extension AppState { marketplaceInstalledNames = await marketplace.installedPluginNames() if ok, let bannerTitle, let bannerBody { - await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + await hookManager.dispatchRemoteConfigChanged(RemoteConfigChangedPayload(title: bannerTitle, body: bannerBody)) } let result = SkillSourceMutationResultPayload( @@ -266,7 +266,7 @@ extension AppState { } if ok, let bannerTitle, let bannerBody { - await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + await hookManager.dispatchRemoteConfigChanged(RemoteConfigChangedPayload(title: bannerTitle, body: bannerBody)) } let result = ACPMutationResultPayload( @@ -375,7 +375,7 @@ extension AppState { } if ok, let bannerTitle { - await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: request.serverName) + await hookManager.dispatchRemoteConfigChanged(RemoteConfigChangedPayload(title: bannerTitle, body: request.serverName)) } var servers: [MobileMCPServer] = [] diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index d725330..7418ab2 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -9,8 +9,13 @@ import SwiftUI extension AppState { // MARK: - Project Management - func addProject(name: String, path: String, gitHubRepo: String?) async { - guard !projects.contains(where: { $0.path == path }) else { return } + /// Add a local project. Fires `onRepositoryAdded` unless `suppressHookEvent` + /// is set (the clone path fires `onRepositoryCloned` instead, to avoid a + /// double event). Returns the project (existing one if the path was already + /// added). + @discardableResult + func addProject(name: String, path: String, gitHubRepo: String?, suppressHookEvent: Bool = false) async -> Project? { + if let existing = projects.first(where: { $0.path == path }) { return existing } let project = Project(name: name, path: path, gitHubRepo: gitHubRepo) projects.append(project) do { @@ -18,6 +23,10 @@ extension AppState { } catch { logger.error("Failed to save projects: \(error.localizedDescription)") } + if !suppressHookEvent { + await hookManager.dispatchRepositoryAdded(RepositoryPayload(project: project, wasCloned: false)) + } + return project } func selectProject(_ project: Project, in window: WindowState) { @@ -86,15 +95,17 @@ extension AppState { await addAndSelectProject(name: url.lastPathComponent, path: url.path, gitHubRepo: gitHubRepo, in: window) } - func addAndSelectProject(name: String, path: String, gitHubRepo: String? = nil, in window: WindowState) async { + @discardableResult + func addAndSelectProject(name: String, path: String, gitHubRepo: String? = nil, suppressAddedHook: Bool = false, in window: WindowState) async -> Project? { if let existing = projects.first(where: { $0.path == path }) { selectProject(existing, in: window) - return + return existing } - await addProject(name: name, path: path, gitHubRepo: gitHubRepo) - if let project = projects.last { + let project = await addProject(name: name, path: path, gitHubRepo: gitHubRepo, suppressHookEvent: suppressAddedHook) + if let project { selectProject(project, in: window) } + return project } // MARK: - Session Management @@ -384,7 +395,16 @@ extension AppState { } let cloneURL = repo.isPrivate ? repo.sshUrl : repo.cloneUrl try await gitClone(from: cloneURL, to: clonePath) - await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) + // A clone fires BOTH `onRepositoryAdded` (so add-time hooks like secret + // auto-download run for clones too) and the more specific + // `onRepositoryCloned`. No built-in hook handles both, so there is no + // double-processing. + let project = await addAndSelectProject( + name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window + ) + if let project { + await hookManager.dispatchRepositoryCloned(RepositoryPayload(project: project, wasCloned: true)) + } } private func gitClone(from url: String, to path: String) async throws { diff --git a/RxCode/App/AppState+Secrets.swift b/RxCode/App/AppState+Secrets.swift index 8ca2146..fdbbf90 100644 --- a/RxCode/App/AppState+Secrets.swift +++ b/RxCode/App/AppState+Secrets.swift @@ -145,6 +145,17 @@ extension AppState { ) async throws -> [String] { let bundle = try await secrets.bundle(repo: repo, env: env) let files = try await decryptSecretBundle(bundle) + return try writeDecryptedSecrets(files, to: directory, overwrite: overwrite) + } + + /// Writes already-decrypted `(filename, content)` pairs into `directory`, + /// skipping existing files unless `overwrite`. Returns filenames written. + @discardableResult + func writeDecryptedSecrets( + _ files: [(filename: String, content: String)], + to directory: URL, + overwrite: Bool + ) throws -> [String] { var written: [String] = [] for file in files { let dest = directory.appendingPathComponent(file.filename) diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index ec8af02..a91945e 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -476,14 +476,28 @@ extension AppState { let alreadyHandled = streamToCancel.map { stopHooksHandledStreamIds.contains($0) } ?? true if let streamToCancel, !alreadyHandled { stopHooksHandledStreamIds.insert(streamToCancel) - await runHooks(trigger: .beforeSessionStop, project: project, sessionKey: key) + await hookManager.dispatchBeforeSessionEnd(SessionEndPayload( + project: project, + sessionKey: key, + sessionId: key, + reason: .cancelled, + turnDidError: false, + lastAssistantText: lastAssistantResponseText(in: stateForSession(key).messages) + )) } let messages = stateForSession(key).messages if !messages.isEmpty { await saveSession(sessionId: key, projectId: project.id, messages: messages) } if streamToCancel != nil, !alreadyHandled { - await runHooks(trigger: .afterSessionStop, project: project, sessionKey: key) + await hookManager.dispatchAfterSessionEnd(SessionEndPayload( + project: project, + sessionKey: key, + sessionId: key, + reason: .cancelled, + turnDidError: false, + lastAssistantText: lastAssistantResponseText(in: stateForSession(key).messages) + )) } } } @@ -508,6 +522,43 @@ extension AppState { if let sessionId = request.sessionId { broadcastMobileSessionStatus(sessionID: sessionId) } + + // 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) + ) + if Self.permissionDecisionIsApproval(decision) { + await hookManager.dispatchPermissionApprove(payload) + } else { + await hookManager.dispatchPermissionDenied(payload) + } + } + + /// Stable, serializable label for a permission decision (for hook payloads). + static func permissionDecisionLabel(_ decision: PermissionDecision) -> String { + switch decision { + case .allow: return "allow" + case .deny: return "deny" + case .allowSessionTool: return "allowSessionTool" + case .allowAlwaysCommand: return "allowAlwaysCommand" + case .allowAndSetMode: return "allowAndSetMode" + case .denyWithReason: return "denyWithReason" + } + } + + /// Whether a decision grants the tool call (vs. denies it). + static func permissionDecisionIsApproval(_ decision: PermissionDecision) -> Bool { + switch decision { + case .allow, .allowSessionTool, .allowAlwaysCommand, .allowAndSetMode: + return true + case .deny, .denyWithReason: + return false + } } // MARK: - AskUserQuestion Response @@ -737,6 +788,21 @@ extension AppState { await permission.respond(toolUseId: toolUseId, decision: decision) + // Fan the plan decision out to hooks (one site → fires once for desktop + mobile). + switch action { + case .acceptAsk: + await hookManager.dispatchPlanAccept(PlanAcceptPayload(toolUseId: toolUseId, sessionKey: key, mode: "ask")) + case .acceptWithEdits: + await hookManager.dispatchPlanAccept(PlanAcceptPayload(toolUseId: toolUseId, sessionKey: key, mode: "acceptEdits")) + case .acceptAutoApprove: + await hookManager.dispatchPlanAccept(PlanAcceptPayload(toolUseId: toolUseId, sessionKey: key, mode: "auto")) + case .reject: + await hookManager.dispatchPlanReject(PlanRejectPayload(toolUseId: toolUseId, sessionKey: key, reason: nil)) + case .rejectWithFeedback(let reason): + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + await hookManager.dispatchPlanReject(PlanRejectPayload(toolUseId: toolUseId, sessionKey: key, reason: trimmed.isEmpty ? nil : trimmed)) + } + // When the CLI honors `allowAndSetMode` it continues the same turn — the model // executes (or revises) the plan inline and the turn ends naturally. In that // case sending a follow-up prompt would spawn a redundant second turn that @@ -776,15 +842,16 @@ extension AppState { guard let planBlockIdx = messages[messageIdx].toolCallIndex(id: toolUseId) else { continue } - // Same message: any tool-call block after the plan block counts as work. + // Same message: any *implementation* tool-call block after the plan block + // counts as work. let trailingBlocks = messages[messageIdx].blocks.dropFirst(planBlockIdx + 1) - if trailingBlocks.contains(where: { $0.toolCall != nil }) { + if trailingBlocks.contains(where: Self.isImplementationToolCall) { return true } - // Subsequent assistant messages: any tool-call block at all. + // Subsequent assistant messages: any implementation tool-call block at all. if messageIdx + 1 < messages.count { for later in messages[(messageIdx + 1)...] where later.role == .assistant { - if later.blocks.contains(where: { $0.toolCall != nil }) { + if later.blocks.contains(where: Self.isImplementationToolCall) { return true } } @@ -794,6 +861,19 @@ extension AppState { return false } + /// True when a block is a tool call that represents actual plan *execution* + /// rather than plan *scaffolding*. Re-writing the plan into a + /// `~/.claude/plans/*.md` file, or re-emitting `ExitPlanMode`, is the model + /// restating the plan — not implementing it — and must not be mistaken for + /// "the turn continued", otherwise the "Proceed with the plan." nudge is + /// suppressed and the user is left having to re-prompt manually. + private static func isImplementationToolCall(_ block: MessageBlock) -> Bool { + guard let call = block.toolCall else { return false } + if isExitPlanModeCall(call) { return false } + if PlanLogic.isPlanFileWrite(call) { return false } + return true + } + /// Prefixes of result strings written by `respondToPlanDecision`. Sourced from /// `PlanDecisionAction.userDecisionResultPrefixes` so the chip in chat, the /// CLI-session reload guard, and the live-stream guard all share one source diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 993cd55..d276cdd 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -903,6 +903,16 @@ final class AppState { /// half-loaded projects/sessions. var isInitialized = false + // MARK: - Hook-driven UI + + /// Non-nil while a hook is showing the loading dialog; the value is the + /// status line the hook is currently displaying. + var hookProgressStatus: LocalizedStringKey? + /// Non-nil while a hook is awaiting the user's pick from a choice sheet. + var hookChoiceRequest: HookChoiceRequest? + /// Non-nil while a hook is awaiting a confirm/cancel decision. + var hookConfirmRequest: HookConfirmRequest? + // MARK: - Services let rxAuth = RxAuthService.shared @@ -940,6 +950,12 @@ final class AppState { let ideMCPServer = IDEMCPServer() var mobileSyncObservers: [NSObjectProtocol] = [] + /// The seam hooks act through (thread/IDE capabilities). Implicitly-unwrapped + /// because it captures `self`; assigned at the end of `init`. + var hookController: AppStateHookController! + /// Registry + dispatcher for lifecycle hooks. See `registerBuiltInHooks()`. + var hookManager: HookManager! + /// Weak refs to every `WindowState` that's been wired up via `setupChatBridge`. /// Used by AppState-driven queue maintenance (e.g. `flushNextQueuedMessageIfNeeded`) /// to scrub stale entries out of each window's in-memory queue mirror. @@ -1090,6 +1106,29 @@ final class AppState { setupMobileSyncBridge() } + + // Build the hook controller/manager last — the controller captures + // `self` (weakly) and every other service it forwards to is now ready. + let hookController = AppStateHookController(app: self) + self.hookController = hookController + self.hookManager = HookManager(controller: hookController) + registerBuiltInHooks() + } + + /// Register the built-in hooks in dispatch order. User bash hooks run first + /// (their start-hook stdout is injected into agent context), then the + /// notification hooks. New hooks (or future plugins) append here. + private func registerBuiltInHooks() { + hookManager.register(UserAddedHook()) + hookManager.register(ResponseNotificationHook()) + hookManager.register(QuestionNotificationHook()) + hookManager.register(PermissionNotificationHook()) + hookManager.register(MCPNotificationHook()) + hookManager.register(CINotificationHook()) + hookManager.register(RemoteConfigNotificationHook()) + #if os(macOS) + hookManager.register(SecretsAutoDownloadHook()) + #endif } diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 6536ff8..7aa6a10 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -23,7 +23,20 @@ } }, "-- --port 3000" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "-- --port 3000" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "-- --port 3000" + } + } + } }, "—" : { "localizations" : { @@ -66,7 +79,20 @@ } }, "\"%@\" will be archived. Archived chats are hidden from the active list and can be restored from Archived." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”이(가) 보관됩니다. 보관된 채팅은 활성 목록에서 숨겨지며 보관함에서 복원할 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”将被归档。已归档的对话会从活动列表中隐藏,可从“已归档”中恢复。" + } + } + } }, "\"%@\" will be deleted. This action cannot be undone." : { "localizations" : { @@ -218,6 +244,18 @@ "state" : "new", "value" : "%1$d workflows failed: %2$@" } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 %1$d개 실패: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d 个工作流失败:%2$@" + } } } }, @@ -242,10 +280,36 @@ } }, "%lld B" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld B" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld B" + } + } + } }, "%lld bytes" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld바이트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 字节" + } + } + } }, "%lld changed" : { "localizations" : { @@ -312,6 +376,18 @@ "state" : "new", "value" : "%1$lld env · %2$lld secrets" } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경 %1$lld개 · 시크릿 %2$lld개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 个环境 · %2$lld 个密钥" + } } } }, @@ -480,7 +556,21 @@ } }, "A workflow failed" : { - "comment" : "CI failure notification body when the workflow name is unknown." + "comment" : "CI failure notification body when the workflow name is unknown.", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 실패" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流失败" + } + } + } }, "Absolute or project-relative path. Leave empty to use the project root." : { "localizations" : { @@ -802,7 +892,20 @@ } }, "Add Secret" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加密钥" + } + } + } }, "Add server" : { "localizations" : { @@ -990,7 +1093,20 @@ } }, "All secrets in this environment will be permanently deleted." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 환경의 모든 시크릿이 영구적으로 삭제됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此环境中的所有密钥都将被永久删除。" + } + } + } }, "All sessions in the current project will be deleted. This action cannot be undone." : { "localizations" : { @@ -1194,7 +1310,20 @@ } }, "Archive Chat" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅 보관" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "归档对话" + } + } + } }, "archived" : { "localizations" : { @@ -1419,7 +1548,20 @@ } }, "Auto-fix CI failures" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 실패 자동 수정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动修复 CI 失败" + } + } + } }, "Auto-preview Attachments" : { "localizations" : { @@ -1466,7 +1608,20 @@ } }, "Autopilot" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot" + } + } + } }, "Awaiting check" : { "localizations" : { @@ -1699,7 +1854,20 @@ } }, "Checking enrollment…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "등록 확인 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查注册状态…" + } + } + } }, "Checking..." : { "localizations" : { @@ -1723,6 +1891,22 @@ } } }, + "Choose" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择" + } + } + } + }, "Choose one" : { "localizations" : { "zh-Hans" : { @@ -1764,13 +1948,53 @@ } }, "CI failed%@" : { - "comment" : "Notification title when GitHub Actions CI fails on a project's current branch. %@ is replaced with \" — \" or empty string." + "comment" : "Notification title when GitHub Actions CI fails on a project's current branch. %@ is replaced with \" — \" or empty string.", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 실패%@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 失败%@" + } + } + } }, "CI failing — open the failing run on GitHub" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 실패 — GitHub에서 실패한 실행 열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 失败 — 在 GitHub 上打开失败的运行" + } + } + } }, "CI Status" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 상태" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 状态" + } + } + } }, "Claude CLI Installation Check" : { "extractionState" : "stale", @@ -2154,7 +2378,20 @@ } }, "Connect your rxlab account to enable autopilot." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot을 사용하려면 rxlab 계정을 연결하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接你的 rxlab 账户以启用 Autopilot。" + } + } + } }, "Connected" : { "localizations" : { @@ -2327,7 +2564,20 @@ } }, "Create" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建" + } + } + } }, "Create and checkout" : { "localizations" : { @@ -2360,7 +2610,20 @@ } }, "Create your encryption key. You'll authenticate with your passkey; the key is derived from it and used to protect your secrets." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호화 키를 생성하세요. 패스키로 인증하며, 키는 패스키에서 파생되어 시크릿을 보호하는 데 사용됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建你的加密密钥。你将使用通行密钥进行身份验证;密钥由其派生,并用于保护你的密钥数据。" + } + } + } }, "Creating…" : { "localizations" : { @@ -2373,7 +2636,20 @@ } }, "Current" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前" + } + } + } }, "Current branch" : { "localizations" : { @@ -2397,17 +2673,30 @@ } }, "Custom script" : { - - }, - "Custom Sources" : { "localizations" : { - "zh-Hans" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "自定义来源" + "value" : "사용자 지정 스크립트" } - } - } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义脚本" + } + } + } + }, + "Custom Sources" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义来源" + } + } + } }, "Custom target" : { "localizations" : { @@ -2450,10 +2739,36 @@ } }, "Decrypting…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복호화 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在解密…" + } + } + } }, "Decrypts with your passkey and writes the .env file(s) into the project folder." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패스키로 복호화하여 .env 파일을 프로젝트 폴더에 기록합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用你的通行密钥解密,并将 .env 文件写入项目文件夹。" + } + } + } }, "Default" : { "localizations" : { @@ -2638,7 +2953,20 @@ } }, "Delete Environment" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경 삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除环境" + } + } + } }, "Delete Hook" : { "localizations" : { @@ -2713,7 +3041,20 @@ } }, "Delete Secret" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除密钥" + } + } + } }, "Delete Session" : { "localizations" : { @@ -2726,7 +3067,20 @@ } }, "Deleting \"%@\"…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@” 삭제 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在删除“%@”…" + } + } + } }, "Deny" : { "localizations" : { @@ -2761,7 +3115,20 @@ } }, "dev" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "dev" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "dev" + } + } + } }, "dev / prod / beta" : { "localizations" : { @@ -2892,7 +3259,20 @@ } }, "Download" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" + } + } + } }, "Download APK" : { "localizations" : { @@ -2925,7 +3305,20 @@ } }, "Download Secret" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 다운로드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载密钥" + } + } + } }, "Duplicate Hook" : { "localizations" : { @@ -3072,7 +3465,20 @@ } }, "Empty Package Configuration" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈 패키지 구성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空的软件包配置" + } + } + } }, "Empty Xcode Configuration" : { "localizations" : { @@ -3137,10 +3543,36 @@ } }, "Encrypt & Upload" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호화 및 업로드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加密并上传" + } + } + } }, "Encryption enabled" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호화 사용 설정됨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已启用加密" + } + } + } }, "Endpoint" : { "localizations" : { @@ -3153,7 +3585,20 @@ } }, "Enroll with Passkey" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패스키로 등록" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用通行密钥注册" + } + } + } }, "Enter a new name for this device." : { "localizations" : { @@ -3305,6 +3750,18 @@ "state" : "new", "value" : "Expires in %1$d:%2$02d" } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d:%2$02d 후 만료" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d:%2$02d 后过期" + } } } }, @@ -3425,7 +3882,20 @@ } }, "Filename" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 이름" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件名" + } + } + } }, "Files" : { "localizations" : { @@ -3450,7 +3920,20 @@ } }, "Finished installing? Tap Refresh." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치를 완료했나요? 새로 고침을 누르세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装完成了吗?点按刷新。" + } + } + } }, "First MCP Server" : { "localizations" : { @@ -3709,7 +4192,20 @@ } }, "GitHub Actions" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub Actions" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub Actions" + } + } + } }, "GitHub Repository" : { "localizations" : { @@ -3824,6 +4320,87 @@ } } }, + "hook.loading" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加载中…" + } + } + } + }, + "hook.secrets.checking" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking for secrets…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查密钥…" + } + } + } + }, + "hook.secrets.downloading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading secrets…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在下载密钥…" + } + } + } + }, + "hook.secrets.overwriteConfirm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overwrite existing files?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖已存在的文件?" + } + } + } + }, + "hook.secrets.pickEnv" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an environment" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个环境" + } + } + } + }, "Hooks" : { "localizations" : { "zh-Hans" : { @@ -3898,7 +4475,20 @@ } }, "Import Repository" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소 가져오기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入仓库" + } + } + } }, "In progress" : { "localizations" : { @@ -3941,10 +4531,36 @@ } }, "Initialize Git" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git 초기화" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "初始化 Git" + } + } + } }, "Initializing…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초기화 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在初始化…" + } + } + } }, "Install" : { "localizations" : { @@ -3999,10 +4615,36 @@ } }, "Install GitHub App" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 앱 설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 GitHub App" + } + } + } }, "Install GitHub App on another account" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다른 계정에 GitHub 앱 설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在其他账户上安装 GitHub App" + } + } + } }, "Install one CLI, then check again." : { "localizations" : { @@ -4025,7 +4667,20 @@ } }, "Install the GitHub App" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 앱 설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 GitHub App" + } + } + } }, "installed" : { "localizations" : { @@ -4144,7 +4799,20 @@ } }, "Letters, numbers, dot, dash and underscore. Max 32 characters." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문자, 숫자, 점, 하이픈, 밑줄. 최대 32자." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字母、数字、点、短横线和下划线。最多 32 个字符。" + } + } + } }, "Lifecycle" : { "localizations" : { @@ -4177,7 +4845,20 @@ } }, "Load more" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "더 보기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加载更多" + } + } + } }, "Loading catalog..." : { "localizations" : { @@ -4212,7 +4893,20 @@ } }, "Loading more..." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "더 불러오는 중..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载更多..." + } + } + } }, "Loading registry…" : { "localizations" : { @@ -4436,10 +5130,36 @@ } }, "Manage Secrets" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理密钥" + } + } + } }, "Manage your repositories, environments, and secrets." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소, 환경, 시크릿을 관리하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理你的仓库、环境和密钥。" + } + } + } }, "Manual key/value pairs" : { "localizations" : { @@ -4738,7 +5458,20 @@ } }, "Name (e.g. prod)" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이름 (예: prod)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称(例如 prod)" + } + } + } }, "New Chat" : { "localizations" : { @@ -4773,7 +5506,20 @@ } }, "New Environment" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 환경" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建环境" + } + } + } }, "New Hook" : { "extractionState" : "stale", @@ -4828,7 +5574,20 @@ } }, "No .env files found in the project folder." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 폴더에서 .env 파일을 찾을 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在项目文件夹中未找到 .env 文件。" + } + } + } }, "No active runs" : { "localizations" : { @@ -4986,10 +5745,36 @@ } }, "No environments found for this repository. Add secrets from Settings → Secrets first." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 저장소의 환경을 찾을 수 없습니다. 먼저 설정 → 시크릿에서 시크릿을 추가하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到此仓库的环境。请先从“设置 → 密钥”中添加密钥。" + } + } + } }, "No environments yet. Create one to store secrets." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 환경이 없습니다. 시크릿을 저장하려면 환경을 생성하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还没有环境。创建一个以存储密钥。" + } + } + } }, "No hooks yet" : { "localizations" : { @@ -5158,10 +5943,36 @@ } }, "No repos match “%@”" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”과(와) 일치하는 저장소가 없습니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有匹配“%@”的仓库" + } + } + } }, "No repositories found." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소를 찾을 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到仓库。" + } + } + } }, "No results for '%@'" : { "localizations" : { @@ -5208,7 +6019,20 @@ } }, "No secrets yet. Add a .env file or enter values manually." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 시크릿이 없습니다. .env 파일을 추가하거나 값을 직접 입력하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还没有密钥。添加 .env 文件或手动输入值。" + } + } + } }, "No servers" : { "localizations" : { @@ -5314,13 +6138,52 @@ } }, "Not signed in" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인되지 않음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未登录" + } + } + } }, "Not yet managed" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 관리되지 않음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未管理" + } + } + } }, "Nothing written (files already exist)." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록된 내용 없음 (파일이 이미 존재함)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未写入任何内容(文件已存在)。" + } + } + } }, "Notifications" : { "localizations" : { @@ -5429,7 +6292,20 @@ } }, "Open in Editor" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집기에서 열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在编辑器中打开" + } + } + } }, "Open in External Editor" : { "localizations" : { @@ -5462,7 +6338,20 @@ } }, "Open on GitHub" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub에서 열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 GitHub 上打开" + } + } + } }, "Open Project" : { "localizations" : { @@ -5495,7 +6384,20 @@ } }, "Open Pull Request" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pull Request 열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 Pull Request" + } + } + } }, "Open RxCode" : { "localizations" : { @@ -5610,17 +6512,85 @@ } } }, + "Overwrite" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "덮어쓰기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖" + } + } + } + }, "Overwrite existing files" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 파일 덮어쓰기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖现有文件" + } + } + } }, "overwrites" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "덮어쓰기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖" + } + } + } }, "Package" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패키지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "软件包" + } + } + } }, "Package Manager" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패키지 관리자" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "包管理器" + } + } + } }, "Pair a new device" : { "localizations" : { @@ -5838,7 +6808,20 @@ } }, "Precise returns fewer, stronger matches. Balanced is the default. Aggressive includes more related memories." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정밀은 더 적지만 강력한 결과를 반환합니다. 균형이 기본값입니다. 적극적은 더 많은 관련 메모리를 포함합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "“精确”返回更少但更强的匹配。“平衡”为默认。“激进”包含更多相关记忆。" + } + } + } }, "Preference" : { "localizations" : { @@ -5903,7 +6886,20 @@ } }, "Private" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비공개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "私有" + } + } + } }, "Project" : { "localizations" : { @@ -6031,7 +7027,20 @@ } }, "Public" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公开" + } + } + } }, "Push" : { "localizations" : { @@ -6527,11 +7536,36 @@ "state" : "new", "value" : "Response in progress. Todos %1$lld/%2$lld" } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "응답 진행 중. 할 일 %1$lld/%2$lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在响应。待办 %1$lld/%2$lld" + } } } }, "Retrieval" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检索" + } + } + } }, "Retry" : { "localizations" : { @@ -6616,7 +7650,20 @@ } }, "Run git init in this project" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트에서 git init 실행" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在此项目中运行 git init" + } + } + } }, "Run/Debug Configurations" : { "localizations" : { @@ -6639,7 +7686,20 @@ } }, "Runs `%@`. Arguments are appended after the script — use `-- ` to forward flags through npm/pnpm." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "`%@`을(를) 실행합니다. 인수는 스크립트 뒤에 추가됩니다 — `-- `를 사용하여 npm/pnpm으로 플래그를 전달하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运行 `%@`。参数会附加在脚本之后 — 使用 `-- ` 将标志透传给 npm/pnpm。" + } + } + } }, "Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile)." : { "localizations" : { @@ -6725,7 +7785,20 @@ } }, "RxCode needs the GitHub App to see your repositories. Install it for the account or organizations you want to import from." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode가 저장소를 보려면 GitHub 앱이 필요합니다. 가져올 계정 또는 조직에 설치하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 需要 GitHub App 才能查看你的仓库。请为你想要导入的账户或组织安装它。" + } + } + } }, "RxCode runs Claude Code or Codex locally. Install one of them to start your first project." : { "localizations" : { @@ -6842,13 +7915,26 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "范围" + "value" : "范围" + } + } + } + }, + "Script" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크립트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "脚本" } } } - }, - "Script" : { - }, "Search agents" : { "localizations" : { @@ -6957,7 +8043,20 @@ } }, "Search repositories" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索仓库" + } + } + } }, "Search skills..." : { "localizations" : { @@ -7002,7 +8101,20 @@ } }, "Secrets" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥" + } + } + } }, "Select a Project" : { "localizations" : { @@ -7049,7 +8161,20 @@ } }, "Select a script…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크립트 선택…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择脚本…" + } + } + } }, "Select a target…" : { "localizations" : { @@ -7250,7 +8375,20 @@ } }, "Set up encryption" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호화 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置加密" + } + } + } }, "Set up your first MCP server" : { "localizations" : { @@ -7411,7 +8549,20 @@ } }, "Show less" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "간략히 보기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收起" + } + } + } }, "Show menu bar icon" : { "localizations" : { @@ -7504,16 +8655,68 @@ } }, "Sign In" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录" + } + } + } }, "Sign in to rxlab" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab에 로그인" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录 rxlab" + } + } + } }, "Sign in to rxlab to\nimport GitHub repos" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 저장소를 가져오려면\nrxlab에 로그인하세요" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录 rxlab 以\n导入 GitHub 仓库" + } + } + } }, "Sign in to rxlab to\nimport your GitHub repos" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 저장소를 가져오려면\nrxlab에 로그인하세요" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录 rxlab 以\n导入你的 GitHub 仓库" + } + } + } }, "Sign in with GitHub" : { "extractionState" : "stale", @@ -7539,16 +8742,68 @@ } }, "Sign in with rxlab" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab으로 로그인" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 rxlab 登录" + } + } + } }, "Sign in with rxlab to import GitHub repositories and use autopilot features." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab으로 로그인하여 GitHub 저장소를 가져오고 Autopilot 기능을 사용하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 rxlab 登录以导入 GitHub 仓库并使用 Autopilot 功能。" + } + } + } }, "Sign in with rxlab to import GitHub repositories." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab으로 로그인하여 GitHub 저장소를 가져오세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 rxlab 登录以导入 GitHub 仓库。" + } + } + } }, "Sign Out" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그아웃" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出登录" + } + } + } }, "sk-..." : { "localizations" : { @@ -7686,7 +8941,20 @@ } }, "Store .env files end-to-end encrypted with your passkey. Secrets are encrypted on this device and never leave it unencrypted." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패스키로 .env 파일을 종단 간 암호화하여 저장합니다. 시크릿은 이 기기에서 암호화되며 암호화되지 않은 상태로 기기를 떠나지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用你的通行密钥对 .env 文件进行端到端加密存储。密钥在本设备上加密,绝不会以未加密形式离开本设备。" + } + } + } }, "streaming stops" : { "extractionState" : "stale", @@ -7875,7 +9143,20 @@ } }, "This chat will be archived. Archived chats are hidden from the active list and can be restored from Archived." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 채팅이 보관됩니다. 보관된 채팅은 활성 목록에서 숨겨지며 보관함에서 복원할 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此对话将被归档。已归档的对话会从活动列表中隐藏,可从“已归档”中恢复。" + } + } + } }, "This memory will be removed from future agent context." : { "localizations" : { @@ -7898,7 +9179,20 @@ } }, "This project isn't linked to a GitHub repository, so it has no secrets to download." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트는 GitHub 저장소에 연결되어 있지 않으므로 다운로드할 시크릿이 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此项目未关联到 GitHub 仓库,因此没有可下载的密钥。" + } + } + } }, "This session will be deleted. This action cannot be undone." : { "localizations" : { @@ -8386,13 +9680,52 @@ } }, "Welcome to RxCode" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode에 오신 것을 환영합니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "欢迎使用 RxCode" + } + } + } }, "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트의 현재 브랜치에서 CI가 실패하면 에이전트가 수정할 수 있도록 자동으로 스레드를 시작합니다. CI 실패는 항상 알림이 전송되며, 이 설정은 자동 수정만 제어합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当项目当前分支的 CI 失败时,自动开启一个对话以便代理修复。CI 失败始终会收到通知;此项仅控制自动修复。" + } + } + } }, "Will overwrite the existing %@." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 %@을(를) 덮어씁니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将覆盖现有的 %@。" + } + } + } }, "Wipe cached embeddings and re-embed every thread for semantic search. Use this if global search results look stale or empty." : { "localizations" : { @@ -8415,7 +9748,21 @@ } }, "Workflow “%@” failed" : { - "comment" : "CI failure notification body for a single failing workflow. %@ is the workflow name." + "comment" : "CI failure notification body for a single failing workflow. %@ is the workflow name.", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 “%@” 실패" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流“%@”失败" + } + } + } }, "Working Directory" : { "localizations" : { @@ -8428,7 +9775,20 @@ } }, "Wrote %@ to the project." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 프로젝트에 기록했습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已将 %@ 写入项目。" + } + } + } }, "wss://relay.example.com" : { "localizations" : { diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift new file mode 100644 index 0000000..6f17ad3 --- /dev/null +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -0,0 +1,176 @@ +import AppKit +import Foundation +import os +import RxCodeCore +import SwiftUI + +/// 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") + + init(app: AppState) { + self.app = app + } + + // MARK: Chat cards + + func insertCard(sessionKey: String, toolName: String, input: [String: JSONValue]) -> HookCardHandle { + let toolId = UUID().uuidString + let messageId = UUID() + app?.updateState(sessionKey) { state in + let toolCall = ToolCall(id: toolId, name: toolName, input: input, result: nil) + // Synthetic hook cards are NOT part of the agent's streaming turn, so + // they must not carry `isStreaming` — a trailing message flagged + // streaming while the session is not makes `settledOnlyMessages` drop + // the whole last assistant run. The "running" spinner is driven by + // `result == nil` instead (see `ToolResultView`). + state.messages.append(ChatMessage( + id: messageId, + role: .assistant, + blocks: [.toolCall(toolCall)], + isStreaming: false + )) + } + return HookCardHandle(toolId: toolId, messageId: messageId) + } + + func completeCard(_ handle: HookCardHandle, sessionKey: String, result: String, isError: Bool) { + app?.updateState(sessionKey) { state in + guard let idx = state.messages.firstIndex(where: { $0.id == handle.messageId }) else { return } + state.messages[idx].setToolResult(id: handle.toolId, result: result, isError: isError) + state.messages[idx].isStreaming = false + state.messages[idx].isResponseComplete = true + } + } + + func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool) { + app?.threadStore.setHookStatus( + sessionId: sessionKey, + toolId: toolId, + name: name, + trigger: trigger, + output: output, + isError: isError + ) + } + + func enabledHookProfiles(projectId: UUID, trigger: HookTrigger) async -> [HookProfile] { + guard let app else { return [] } + await app.ensureHookProfilesLoaded(for: projectId) + return app.hookProfiles(for: projectId).filter { $0.enabled && $0.trigger == trigger } + } + + // MARK: Queries + + var notificationsEnabled: Bool { app?.notificationsEnabled ?? false } + var isAppActive: Bool { NSApp.isActive } + + func project(for id: UUID) -> Project? { + app?.projects.first(where: { $0.id == id }) + } + + func sessionTitle(sessionId: String) -> String? { + app?.allSessionSummaries.first(where: { $0.id == sessionId })?.title + } + + func responseNotificationFallback(from responseText: String) -> String { + app?.responseNotificationFallback(from: responseText) ?? "" + } + + func responseNotificationSummary(responseText: String, sessionId: String) async -> String? { + guard let app, + let summary = app.allSessionSummaries.first(where: { $0.id == sessionId }) else { return nil } + return await app.generateResponseNotificationSummary(responseText: responseText, summary: summary) + } + + // MARK: Notifications + + func postResponseComplete(title: String, body: String, projectId: UUID, sessionId: String, postLocalBanner: Bool) async { + await NotificationService.shared.postResponseComplete( + title: title, body: body, projectId: projectId, sessionId: sessionId, postLocalBanner: postLocalBanner + ) + } + + func postQuestionNeeded(projectName: String?, projectId: UUID?, sessionId: String?) async { + await NotificationService.shared.postQuestionNeeded(projectName: projectName, projectId: projectId, sessionId: sessionId) + } + + func postPermissionNeeded(toolName: String, projectName: String?, projectId: UUID?, sessionId: String?) async { + await NotificationService.shared.postPermissionNeeded(toolName: toolName, projectName: projectName, projectId: projectId, sessionId: sessionId) + } + + func postMCPDisconnected(name: String, error: String?) async { + await NotificationService.shared.postMCPDisconnected(name: name, error: error) + } + + func postCIFailed(projectName: String?, projectId: UUID?, failingWorkflowNames: [String]) async { + await NotificationService.shared.postCIFailed(projectName: projectName, projectId: projectId, failingWorkflowNames: failingWorkflowNames) + } + + func postRemoteConfigChanged(title: String, body: String) async { + await NotificationService.shared.postRemoteConfigChanged(title: title, body: body) + } + + // MARK: Interactive UI + + func beginProgress(_ status: LocalizedStringKey) { + app?.hookProgressStatus = status + } + + func updateProgress(_ status: LocalizedStringKey) { + app?.hookProgressStatus = status + } + + func endProgress() { + app?.hookProgressStatus = nil + } + + func requestChoice(title: LocalizedStringKey, choices: [HookChoice]) async -> String? { + guard let app else { return nil } + return await withCheckedContinuation { cont in + app.hookChoiceRequest = HookChoiceRequest(title: title, choices: choices, cont: cont) + } + } + + func requestConfirmation(title: LocalizedStringKey, detail: String?) async -> Bool { + guard let app else { return false } + return await withCheckedContinuation { cont in + app.hookConfirmRequest = HookConfirmRequest(title: title, detail: detail, cont: cont) + } + } + + // MARK: Secrets + + func secretEnvironments(repoFullName: String) async -> [HookChoice] { + guard let app else { return [] } + do { + let envs = try await app.secrets.listEnvironments(repo: repoFullName).items + return envs.map { HookChoice(id: $0.id, label: $0.name) } + } catch { + logger.error("secretEnvironments failed: \(error.localizedDescription)") + return [] + } + } + + func fetchSecrets(repoFullName: String, env: String) async throws -> [HookSecretFile] { + guard let app else { return [] } + let bundle = try await app.secrets.bundle(repo: repoFullName, env: env) + let files = try await app.decryptSecretBundle(bundle) + return files.map { HookSecretFile(filename: $0.filename, content: $0.content) } + } + + func writeSecrets(_ files: [HookSecretFile], toPath path: String, overwrite: Bool) throws -> [String] { + guard let app else { return [] } + let directory = URL(fileURLWithPath: path, isDirectory: true) + return try app.writeDecryptedSecrets( + files.map { (filename: $0.filename, content: $0.content) }, + to: directory, + overwrite: overwrite + ) + } +} diff --git a/RxCode/Services/Hooks/HookManager.swift b/RxCode/Services/Hooks/HookManager.swift new file mode 100644 index 0000000..a29c299 --- /dev/null +++ b/RxCode/Services/Hooks/HookManager.swift @@ -0,0 +1,132 @@ +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") + + init(controller: any HookController) { + self.controller = controller + } + + func register(_ hook: any Hook) { + hooks.append(hook) + } + + private var enabledHooks: [any Hook] { hooks.filter(\.isEnabled) } + + // MARK: - Session lifecycle + + func dispatchProjectNewChatStart(_ payload: NewChatStartPayload) async { + for hook in enabledHooks { + _ = await hook.onProjectNewChatStart(payload, controller: controller) + } + } + + func dispatchSessionStart(_ payload: SessionStartPayload) async -> HookAggregateResult { + var outcomes: [HookOutcome] = [] + for hook in enabledHooks { + outcomes.append(await hook.onSessionStart(payload, controller: controller)) + } + return HookAggregateResult.fold(outcomes) + } + + func dispatchBeforeSessionEnd(_ payload: SessionEndPayload) async -> HookAggregateResult { + var outcomes: [HookOutcome] = [] + for hook in enabledHooks { + outcomes.append(await hook.beforeSessionEnd(payload, controller: controller)) + } + return HookAggregateResult.fold(outcomes) + } + + func dispatchAfterSessionEnd(_ payload: SessionEndPayload) async -> HookAggregateResult { + var outcomes: [HookOutcome] = [] + for hook in enabledHooks { + outcomes.append(await hook.afterSessionEnd(payload, controller: controller)) + } + return HookAggregateResult.fold(outcomes) + } + + // MARK: - Repository + + func dispatchRepositoryAdded(_ payload: RepositoryPayload) async { + for hook in enabledHooks { + _ = await hook.onRepositoryAdded(payload, controller: controller) + } + } + + func dispatchRepositoryCloned(_ payload: RepositoryPayload) async { + for hook in enabledHooks { + _ = await hook.onRepositoryCloned(payload, controller: controller) + } + } + + // MARK: - Questions & permissions + + func dispatchQuestionAsk(_ payload: QuestionAskPayload) async { + for hook in enabledHooks { + _ = await hook.onQuestionAsk(payload, controller: controller) + } + } + + func dispatchPermissionAsk(_ payload: PermissionAskPayload) async { + for hook in enabledHooks { + _ = await hook.onPermissionAsk(payload, controller: controller) + } + } + + func dispatchPermissionApprove(_ payload: PermissionDecisionPayload) async { + for hook in enabledHooks { + _ = await hook.onPermissionApprove(payload, controller: controller) + } + } + + func dispatchPermissionDenied(_ payload: PermissionDecisionPayload) async { + for hook in enabledHooks { + _ = await hook.onPermissionDenied(payload, controller: controller) + } + } + + // MARK: - Plan + + func dispatchPlanAccept(_ payload: PlanAcceptPayload) async { + for hook in enabledHooks { + _ = await hook.onPlanAccept(payload, controller: controller) + } + } + + func dispatchPlanReject(_ payload: PlanRejectPayload) async { + for hook in enabledHooks { + _ = await hook.onPlanReject(payload, controller: controller) + } + } + + // MARK: - Integrations + + func dispatchMCPDisconnected(_ payload: MCPDisconnectedPayload) async { + for hook in enabledHooks { + _ = await hook.onMCPDisconnected(payload, controller: controller) + } + } + + func dispatchCIFailed(_ payload: CIFailedPayload) async { + for hook in enabledHooks { + _ = await hook.onCIFailed(payload, controller: controller) + } + } + + func dispatchRemoteConfigChanged(_ payload: RemoteConfigChangedPayload) async { + for hook in enabledHooks { + _ = await hook.onRemoteConfigChanged(payload, controller: controller) + } + } +} diff --git a/RxCode/Services/Hooks/HookUIRequests.swift b/RxCode/Services/Hooks/HookUIRequests.swift new file mode 100644 index 0000000..4dda953 --- /dev/null +++ b/RxCode/Services/Hooks/HookUIRequests.swift @@ -0,0 +1,37 @@ +import Foundation +import RxCodeCore +import SwiftUI + +/// A pending choice prompt driven by a hook. The view presents `choices` and +/// resumes `cont` with the picked id (or nil on cancel) via +/// `AppState.resolveHookChoice`. +struct HookChoiceRequest: Identifiable { + let id = UUID() + let title: LocalizedStringKey + let choices: [HookChoice] + let cont: CheckedContinuation +} + +/// A pending confirm/cancel prompt driven by a hook. +struct HookConfirmRequest: Identifiable { + let id = UUID() + let title: LocalizedStringKey + let detail: String? + let cont: CheckedContinuation +} + +extension AppState { + /// Resume a pending choice prompt and dismiss it. Safe to call once. + func resolveHookChoice(_ id: String?) { + guard let request = hookChoiceRequest else { return } + hookChoiceRequest = nil + request.cont.resume(returning: id) + } + + /// Resume a pending confirmation prompt and dismiss it. Safe to call once. + func resolveHookConfirm(_ ok: Bool) { + guard let request = hookConfirmRequest else { return } + hookConfirmRequest = nil + request.cont.resume(returning: ok) + } +} diff --git a/RxCode/Services/Hooks/hooks/CINotificationHook.swift b/RxCode/Services/Hooks/hooks/CINotificationHook.swift new file mode 100644 index 0000000..de1ad57 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/CINotificationHook.swift @@ -0,0 +1,18 @@ +import Foundation +import RxCodeCore + +/// Posts the "CI failed" banner. The failure-signature dedup and the +/// `notificationsEnabled` gate stay at the dispatch site. +@MainActor +final class CINotificationHook: Hook { + let hookID = "builtin.ciNotification" + + func onCIFailed(_ payload: CIFailedPayload, controller: any HookController) async -> HookOutcome { + await controller.postCIFailed( + projectName: payload.projectName, + projectId: payload.projectId, + failingWorkflowNames: payload.failingWorkflowNames + ) + return .proceed + } +} diff --git a/RxCode/Services/Hooks/hooks/MCPNotificationHook.swift b/RxCode/Services/Hooks/hooks/MCPNotificationHook.swift new file mode 100644 index 0000000..886b619 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/MCPNotificationHook.swift @@ -0,0 +1,14 @@ +import Foundation +import RxCodeCore + +/// Posts the "MCP server disconnected" banner. The connected→failed edge guard +/// (so re-probes don't spam) stays at the dispatch site. +@MainActor +final class MCPNotificationHook: Hook { + let hookID = "builtin.mcpNotification" + + func onMCPDisconnected(_ payload: MCPDisconnectedPayload, controller: any HookController) async -> HookOutcome { + await controller.postMCPDisconnected(name: payload.name, error: payload.error) + return .proceed + } +} diff --git a/RxCode/Services/Hooks/hooks/PermissionNotificationHook.swift b/RxCode/Services/Hooks/hooks/PermissionNotificationHook.swift new file mode 100644 index 0000000..b6ec53a --- /dev/null +++ b/RxCode/Services/Hooks/hooks/PermissionNotificationHook.swift @@ -0,0 +1,18 @@ +import Foundation +import RxCodeCore + +/// Posts the "permission needed" banner when a tool call queues for approval. +@MainActor +final class PermissionNotificationHook: Hook { + let hookID = "builtin.permissionNotification" + + func onPermissionAsk(_ payload: PermissionAskPayload, controller: any HookController) async -> HookOutcome { + await controller.postPermissionNeeded( + toolName: payload.toolName, + projectName: payload.projectName, + projectId: payload.projectId, + sessionId: payload.sessionId + ) + return .proceed + } +} diff --git a/RxCode/Services/Hooks/hooks/QuestionNotificationHook.swift b/RxCode/Services/Hooks/hooks/QuestionNotificationHook.swift new file mode 100644 index 0000000..1b148d9 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/QuestionNotificationHook.swift @@ -0,0 +1,18 @@ +import Foundation +import RxCodeCore + +/// Posts the "assistant has a question" banner when an `AskUserQuestion` tool +/// call arrives. +@MainActor +final class QuestionNotificationHook: Hook { + let hookID = "builtin.questionNotification" + + func onQuestionAsk(_ payload: QuestionAskPayload, controller: any HookController) async -> HookOutcome { + await controller.postQuestionNeeded( + projectName: payload.projectName, + projectId: payload.projectId, + sessionId: payload.sessionId + ) + return .proceed + } +} diff --git a/RxCode/Services/Hooks/hooks/RemoteConfigNotificationHook.swift b/RxCode/Services/Hooks/hooks/RemoteConfigNotificationHook.swift new file mode 100644 index 0000000..62462cb --- /dev/null +++ b/RxCode/Services/Hooks/hooks/RemoteConfigNotificationHook.swift @@ -0,0 +1,14 @@ +import Foundation +import RxCodeCore + +/// Posts a banner confirming a config change applied from a paired mobile device +/// (skill install/remove, skill source add/remove, MCP add). +@MainActor +final class RemoteConfigNotificationHook: Hook { + let hookID = "builtin.remoteConfigNotification" + + func onRemoteConfigChanged(_ payload: RemoteConfigChangedPayload, controller: any HookController) async -> HookOutcome { + await controller.postRemoteConfigChanged(title: payload.title, body: payload.body) + return .proceed + } +} diff --git a/RxCode/Services/Hooks/hooks/ResponseNotificationHook.swift b/RxCode/Services/Hooks/hooks/ResponseNotificationHook.swift new file mode 100644 index 0000000..71cceb4 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/ResponseNotificationHook.swift @@ -0,0 +1,36 @@ +import Foundation +import RxCodeCore + +/// 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. +@MainActor +final class ResponseNotificationHook: Hook { + let hookID = "builtin.responseNotification" + + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + guard payload.reason == .completed, !payload.turnDidError else { return .ignored } + guard controller.notificationsEnabled else { return .ignored } + + let responseText = payload.lastAssistantText + let title = controller.sessionTitle(sessionId: payload.sessionId) ?? "New Session" + let fallbackBody = controller.responseNotificationFallback(from: responseText) + let postLocalBanner = !controller.isAppActive + let projectId = payload.project.id + let sessionId = payload.sessionId + + Task { + let aiSummary = await controller.responseNotificationSummary(responseText: responseText, sessionId: sessionId) + let body = aiSummary ?? fallbackBody + await controller.postResponseComplete( + title: title, + body: body, + projectId: projectId, + sessionId: sessionId, + postLocalBanner: postLocalBanner + ) + } + return .proceed + } +} diff --git a/RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift b/RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift new file mode 100644 index 0000000..592fd5a --- /dev/null +++ b/RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift @@ -0,0 +1,65 @@ +#if os(macOS) +import Foundation +import os +import RxCodeCore +import SwiftUI + +/// When a repository is added to the IDE (including clones — the clone path now +/// also fires `onRepositoryAdded`), check the autopilot secrets endpoint for the +/// project's GitHub repo. If any environments exist, let the user pick one in a +/// loading dialog, then download + decrypt its files into the project folder. +/// Conflicting local files are only overwritten after an explicit confirmation. +@MainActor +final class SecretsAutoDownloadHook: Hook { + let hookID = "builtin.secretsAutoDownload" + private let logger = Logger(subsystem: "com.claudework", category: "SecretsAutoDownloadHook") + + func onRepositoryAdded(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome { + await run(project: payload.project, controller: controller) + } + + private func run(project: Project, controller: any HookController) async -> HookOutcome { + guard let repo = project.gitHubRepo else { return .ignored } + // Always dismiss the dialog, even on early return / thrown error. + defer { controller.endProgress() } + + controller.beginProgress("hook.secrets.checking") + let environments = await controller.secretEnvironments(repoFullName: repo) + guard !environments.isEmpty else { return .ignored } + + // Always show the picker so the user explicitly chooses an environment. + controller.endProgress() + guard let env = await controller.requestChoice( + title: "hook.secrets.pickEnv", + choices: environments + ) else { return .ignored } + + do { + controller.beginProgress("hook.secrets.downloading") + let files = try await controller.fetchSecrets(repoFullName: repo, env: env) + + // Detect files that would clobber something already in the folder. + let conflicts = files + .map(\.filename) + .filter { FileManager.default.fileExists(atPath: (project.path as NSString).appendingPathComponent($0)) } + + var overwrite = false + if !conflicts.isEmpty { + controller.endProgress() + overwrite = await controller.requestConfirmation( + title: "hook.secrets.overwriteConfirm", + detail: conflicts.joined(separator: ", ") + ) + controller.beginProgress("hook.secrets.downloading") + } + + let written = try controller.writeSecrets(files, toPath: project.path, overwrite: overwrite) + logger.info("Downloaded \(written.count) secret file(s) for \(repo)") + return .proceed + } catch { + logger.error("Secret auto-download failed for \(repo): \(error.localizedDescription)") + return .output("Secret download failed: \(error.localizedDescription)", isError: true) + } + } +} +#endif diff --git a/RxCode/Services/Hooks/hooks/UserAddedHook.swift b/RxCode/Services/Hooks/hooks/UserAddedHook.swift new file mode 100644 index 0000000..49ab396 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/UserAddedHook.swift @@ -0,0 +1,77 @@ +import Foundation +import RxCodeCore + +/// Runs the user-configured bash hook profiles. Maps the three persisted +/// `HookTrigger` cases onto the new lifecycle events: +/// - `.beforeSessionStart` → `onSessionStart` +/// - `.beforeSessionStop` → `beforeSessionEnd` +/// - `.afterSessionStop` → `afterSessionEnd` +/// +/// For each matching profile it inserts a live status card (spinner → result), +/// runs the command headless via `HookService`, persists the card as the +/// session's "last hook" so it survives reload, and returns the combined stdout +/// (with a non-zero exit surfaced as `isError`) so a start hook's output is +/// injected into the agent context and a failing stop hook can auto-continue. +@MainActor +final class UserAddedHook: Hook { + let hookID = "builtin.userAddedHook" + + func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { + await run(trigger: .beforeSessionStart, project: payload.project, sessionKey: payload.sessionKey, controller: controller) + } + + func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + await run(trigger: .beforeSessionStop, project: payload.project, sessionKey: payload.sessionKey, controller: controller) + } + + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + await run(trigger: .afterSessionStop, project: payload.project, sessionKey: payload.sessionKey, controller: controller) + } + + private func run( + trigger: HookTrigger, + project: Project, + sessionKey: String, + controller: any HookController + ) async -> HookOutcome { + let hooks = await controller.enabledHookProfiles(projectId: project.id, trigger: trigger) + guard !hooks.isEmpty else { return .ignored } + + var combined: [String] = [] + var anyError = false + for hook in hooks { + let handle = controller.insertCard( + sessionKey: sessionKey, + toolName: AppState.hookToolName(for: hook), + input: [ + "name": .string(hook.name), + "trigger": .string(hook.trigger.displayName), + ] + ) + + let result = await HookService.run(hook, project: project) + let displayOutput = result.output.isEmpty ? "(no output)" : result.output + + controller.completeCard(handle, sessionKey: sessionKey, result: displayOutput, isError: result.isError) + + // Persist as the session's "last hook" so the synthetic card can be + // rebuilt on reload. Each hook overwrites the prior row, so the most + // recent hook (across triggers) is the one that survives. + controller.persistHookStatus( + sessionKey: sessionKey, + toolId: handle.toolId, + name: hook.name, + trigger: hook.trigger.displayName, + output: displayOutput, + isError: result.isError + ) + + if result.isError { anyError = true } + if !result.output.isEmpty { + combined.append("### Hook: \(hook.name)\n\(result.output)") + } + } + + return .output(combined.joined(separator: "\n\n"), isError: anyError) + } +} diff --git a/RxCode/Views/Hooks/HookProgressOverlay.swift b/RxCode/Views/Hooks/HookProgressOverlay.swift new file mode 100644 index 0000000..06c1cd8 --- /dev/null +++ b/RxCode/Views/Hooks/HookProgressOverlay.swift @@ -0,0 +1,108 @@ +import RxCodeCore +import SwiftUI + +/// Hook-driven UI presented at the window root: a loading dialog whose status +/// the running hook controls, a single-choice picker, and a confirmation alert. +/// All three are keyed off `AppState` because hook dispatch (e.g. project-add) +/// is app-level, not scoped to a single window. +struct HookUIModifier: ViewModifier { + @Environment(AppState.self) private var appState + + func body(content: Content) -> some View { + content + .overlay { + if let status = appState.hookProgressStatus { + HookProgressDialog(status: status) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: appState.hookProgressStatus == nil) + .sheet(item: Bindable(appState).hookChoiceRequest, onDismiss: { + // Resume with nil if the sheet was dismissed without a pick. + appState.resolveHookChoice(nil) + }) { request in + HookChoiceSheet(request: request) + .environment(appState) + } + .alert( + appState.hookConfirmRequest?.title ?? "", + isPresented: Binding( + get: { appState.hookConfirmRequest != nil }, + set: { if !$0 { appState.resolveHookConfirm(false) } } + ), + presenting: appState.hookConfirmRequest + ) { _ in + Button("Overwrite", role: .destructive) { appState.resolveHookConfirm(true) } + Button("Cancel", role: .cancel) { appState.resolveHookConfirm(false) } + } message: { request in + if let detail = request.detail { + Text(detail) + } + } + } +} + +extension View { + /// Attach the hook-driven loading dialog / picker / confirm UI. + func hookUI() -> some View { modifier(HookUIModifier()) } +} + +/// The loading card the running hook drives via `HookController.beginProgress`. +private struct HookProgressDialog: View { + let status: LocalizedStringKey + + var body: some View { + ZStack { + Color.black.opacity(0.3).ignoresSafeArea() + VStack(spacing: 16) { + ProgressView().controlSize(.large) + Text(status) + .font(.callout) + .foregroundStyle(ClaudeTheme.textPrimary) + .multilineTextAlignment(.center) + } + .padding(28) + .frame(minWidth: 220) + .background(ClaudeTheme.background, in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(ClaudeTheme.border, lineWidth: 1)) + .shadow(radius: 24) + } + } +} + +/// Single-choice picker presented by `HookController.requestChoice`. +private struct HookChoiceSheet: View { + @Environment(AppState.self) private var appState + let request: HookChoiceRequest + + @State private var selectedId: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(request.title).font(.headline) + + Picker("", selection: $selectedId) { + ForEach(request.choices) { choice in + Text(choice.label).tag(Optional(choice.id)) + } + } + .labelsHidden() + .pickerStyle(.inline) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 0) + + HStack { + Spacer() + Button("Cancel") { appState.resolveHookChoice(nil) } + Button("Choose") { appState.resolveHookChoice(selectedId) } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(selectedId == nil) + } + } + .padding(20) + .frame(width: 420, height: 300) + .onAppear { selectedId = request.choices.first?.id } + } +} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 0d286d6..983dd49 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -95,6 +95,7 @@ struct MainView: View { ) } } + .hookUI() } } diff --git a/RxCode/Views/Secrets/AddSecretSheet.swift b/RxCode/Views/Secrets/AddSecretSheet.swift index 4b5fded..801f933 100644 --- a/RxCode/Views/Secrets/AddSecretSheet.swift +++ b/RxCode/Views/Secrets/AddSecretSheet.swift @@ -1,3 +1,4 @@ +import AppKit import RxCodeCore import SwiftUI import UniformTypeIdentifiers @@ -25,7 +26,6 @@ struct AddSecretSheet: View { @State private var source: Source = .manual @State private var detected: [DetectedEnv] = [] @State private var selectedDetected: Set = [] - @State private var showFileImporter = false @State private var pickedFilename = "" @State private var pickedContent = "" @State private var manualFilename = ".env" @@ -118,7 +118,7 @@ struct AddSecretSheet: View { @ViewBuilder private var fileView: some View { VStack(alignment: .leading, spacing: 8) { Button { - showFileImporter = true + pickFile() } label: { Label(pickedFilename.isEmpty ? "Choose File…" : pickedFilename, systemImage: "folder") } @@ -128,22 +128,33 @@ struct AddSecretSheet: View { Text("Will overwrite the existing \(pickedFilename).") .font(.caption).foregroundStyle(.orange) } + TextEditor(text: $pickedContent) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.3)) + ) } } - .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .text, .plainText]) { result in - switch result { - case .success(let url): - let didAccess = url.startAccessingSecurityScopedResource() - defer { if didAccess { url.stopAccessingSecurityScopedResource() } } - if let data = try? Data(contentsOf: url) { - pickedFilename = url.lastPathComponent - pickedContent = String(decoding: data, as: UTF8.self) - } else { - errorMessage = "Couldn't read the selected file." - } - case .failure(let error): - errorMessage = error.localizedDescription - } + } + + /// Opens an `NSOpenPanel` instead of SwiftUI's `.fileImporter` so hidden + /// dotfiles like `.env` are selectable (`.fileImporter` cannot show them). + private func pickFile() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = true + panel.treatsFilePackagesAsDirectories = false + panel.allowedContentTypes = [.data, .text, .plainText] + guard panel.runModal() == .OK, let url = panel.url else { return } + if let data = try? Data(contentsOf: url) { + pickedFilename = url.lastPathComponent + pickedContent = String(decoding: data, as: UTF8.self) + } else { + errorMessage = "Couldn't read the selected file." } } diff --git a/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift b/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift index d1b690c..8893806 100644 --- a/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift +++ b/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift @@ -45,16 +45,16 @@ struct SecretsEnvironmentDetailView: View { } } if files.isEmpty, !isLoading { - VStack(alignment: .leading, spacing: 10) { - Text("No secrets yet. Add a .env file or enter values manually.") - .foregroundStyle(.secondary) - Button { showAdd = true } label: { - Label("Add Secret", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - .disabled(environmentKey == nil) - } + Text("No secrets yet. Add a .env file or enter values manually.") + .foregroundStyle(.secondary) + } + // Always-visible add affordance: the toolbar button is unreliable + // inside this sheet's navigation chrome, so keep one in the list. + Button { showAdd = true } label: { + Label("Add Secret", systemImage: "plus") } + .buttonStyle(.borderedProminent) + .disabled(environmentKey == nil) } .overlay { if isLoading, files.isEmpty { ProgressView() } } .overlay {