diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6f24a80..0f250f2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,8 +6,6 @@ concurrency: on: push: - branches: - - main release: types: - created @@ -90,7 +88,15 @@ jobs: # CURRENT_PROJECT_VERSION is overridden per-build so every CI build # has a unique build number (github.run_number), without committing # churn to project.pbxproj. - set -o pipefail && xcodebuild -destination platform=macOS \ + # The provisioning profile is scoped to the RxCode target's Release + # build settings in project.pbxproj (PROVISIONING_PROFILE_SPECIFIER), + # NOT passed on the command line. A command-line PROVISIONING_PROFILE* + # applies to every target in the graph — including SPM package targets + # that don't support provisioning profiles — which fails the archive. + # CODE_SIGN_STYLE/IDENTITY stay global: Manual signing needs no team + # and library targets sign with the identity but need no profile. + set -o pipefail + xcodebuild -destination platform=macOS \ -project RxCode.xcodeproj \ -scheme RxCode \ -configuration Release \ @@ -98,11 +104,15 @@ jobs: -allowProvisioningUpdates \ CODE_SIGN_IDENTITY="${{ secrets.SIGNING_CERTIFICATE_NAME }}" \ CODE_SIGN_STYLE=Manual \ - PROVISIONING_PROFILE="$MACOS_PROFILE_UUID" \ - PROVISIONING_PROFILE_SPECIFIER="$MACOS_PROFILE_SPECIFIER" \ OTHER_CODE_SIGN_FLAGS="--options=runtime --timestamp" \ CURRENT_PROJECT_VERSION=${{ github.run_number }} \ - archive | xcpretty + archive 2>&1 | tee xcodebuild-archive.log | xcpretty || { + status=${PIPESTATUS[0]} + echo "::group::Full xcodebuild output (archive failed)" + cat xcodebuild-archive.log + echo "::endgroup::" + exit "$status" + } - name: Sign Sparkle run: ./scripts/ci/sign-sparkle.sh diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 99e9b52..b50f4e4 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -155,6 +155,15 @@ struct MessageListView: View { .onChange(of: chatBridge.messages.last?.id) { _, _ in handleLastMessageChange() } + // Synthetic hook/auto-continue cards mutate the last message in + // place (result: nil → result) without changing its id, content, or + // the session-level isStreaming flag, so none of the other rebuild + // triggers fire. They flip isResponseComplete when the result lands; + // observe it so the running card refreshes to its result live + // instead of only after a reload. + .onChange(of: chatBridge.messages.last?.isResponseComplete) { _, _ in + handleLastMessageChange() + } .onChange(of: chatBridge.messages.last?.content) { _, _ in guard isSessionReady else { return } requestScrollToBottom() diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 8ccd00a..1cfdbf4 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -104,7 +104,10 @@ struct ToolResultView: View { Spacer() Group { - if displayIsError { + if isAutoContinueTool { + // System action, not a pass/fail result — no status badge. + EmptyView() + } else if displayIsError { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(ClaudeTheme.statusError) .font(.caption) @@ -114,7 +117,7 @@ struct ToolResultView: View { .foregroundStyle(ClaudeTheme.statusSuccess) .font(.caption) .accessibilityLabel("Completed") - } else if isMessageStreaming { + } else if isMessageStreaming || isRunningHookCard { ProgressView() .controlSize(.mini) .accessibilityLabel("Running") @@ -235,7 +238,7 @@ struct ToolResultView: View { inputSummaryView(maximumNumberOfLines: 1) - if toolCall.result == nil && isMessageStreaming { + if toolCall.result == nil && (isMessageStreaming || isRunningHookCard) { ProgressView() .controlSize(.mini) .accessibilityLabel("Running") @@ -289,7 +292,28 @@ struct ToolResultView: View { } private var isCardTool: Bool { - isEditTool + isEditTool || isHookTool || isAutoContinueTool + } + + /// Lifecycle hooks are emitted as tool calls named "Hook: " so they + /// render through the same status/result card as real tools. + private var isHookTool: Bool { + toolNameLower.hasPrefix("hook:") + } + + /// The synthetic card inserted when a failing before-session-stop hook + /// re-prompts the agent. Rendered as its own card (not a user bubble) so it + /// reads as a system action rather than something the user typed. + private var isAutoContinueTool: Bool { + toolNameLower == ToolCall.autoContinueToolName.lowercased() + } + + /// A hook card whose command is still running. Hook cards are synthetic and + /// never carry the message-level `isStreaming` flag (a stop hook runs after + /// the turn is finalized), so the "running" spinner keys off the absent + /// result instead of `isMessageStreaming`. + private var isRunningHookCard: Bool { + isHookTool && toolCall.result == nil } // MARK: - Edit Diff @@ -481,6 +505,8 @@ struct ToolResultView: View { // MARK: - Helpers private var sfSymbol: String { + if isAutoContinueTool { return "arrow.triangle.2.circlepath" } + if isHookTool { return "bolt.horizontal.circle" } switch toolNameLower { case "agent": return "cpu" case "read": return "doc.text" @@ -497,6 +523,8 @@ struct ToolResultView: View { } private var iconColor: Color { + if isAutoContinueTool { return ClaudeTheme.accent } + if isHookTool { return ClaudeTheme.accent } switch toolNameLower { case "agent", "bash", InteractiveTerminalState.toolName: return ClaudeTheme.accent @@ -583,6 +611,11 @@ struct ToolResultView: View { } private var inputSummary: String { + if isAutoContinueTool { + return toolCall.input["summary"]?.stringValue + ?? "Stop hook failed — sent back to the agent to continue." + } + if toolNameLower == "agent" { if let desc = toolCall.input["description"]?.stringValue { return desc diff --git a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift index 8f3affd..e0edbde 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift @@ -235,6 +235,10 @@ public struct ToolCall: Identifiable, Codable, Sendable, Equatable { result.map { !$0.isEmpty } ?? false } + /// Tool name for the synthetic card shown when a failing before-session-stop + /// hook auto-continues the agent. Rendered specially by `ToolResultView`. + public static let autoContinueToolName = "Auto-continue" + /// Tool names that must stay in the message block even without a result — /// either because the result would be empty by design, or because the UI /// needs to render them before the user/CLI produces a result. diff --git a/Packages/Sources/RxCodeCore/Models/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift new file mode 100644 index 0000000..2d48540 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/HookProfile.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Lifecycle point at which a hook fires. +public enum HookTrigger: String, Codable, Sendable, CaseIterable, Hashable { + /// Fires once, when a brand-new thread begins its first turn. The hook's + /// stdout is injected into the agent's context for that turn. + case beforeSessionStart + /// Fires when streaming stops, before the thread is finalized. The hook's + /// output is shown and saved as a context block in the thread. + case beforeSessionStop + /// Fires after the thread is finalized/saved when streaming stops. The + /// hook's output is shown only — nothing is passed back to the session. + case afterSessionStop + + public var displayName: String { + switch self { + case .beforeSessionStart: return "Before Session Start" + case .beforeSessionStop: return "Before Session Stop" + case .afterSessionStop: return "After Session Stop" + } + } +} + +/// What a hook runs. Bash-only today; typed so other kinds can be added later. +public enum HookType: String, Codable, Sendable, CaseIterable, Hashable { + case bash +} + +/// A single project-scoped automation that runs a command at a session +/// lifecycle point. Modeled on `RunProfile`; reuses `BashRunConfig` for the +/// command, working directory, and environment presets. +public struct HookProfile: Identifiable, Codable, Sendable, Hashable { + public var id: UUID + public var projectId: UUID + public var name: String + public var enabled: Bool + public var trigger: HookTrigger + public var type: HookType + public var bash: BashRunConfig + public var createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + projectId: UUID, + name: String, + enabled: Bool = true, + trigger: HookTrigger = .beforeSessionStart, + type: HookType = .bash, + bash: BashRunConfig = BashRunConfig(), + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.projectId = projectId + self.name = name + self.enabled = enabled + self.trigger = trigger + self.type = type + self.bash = bash + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift new file mode 100644 index 0000000..d1c4dd0 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +/// Persisted status of the most recent hook card for a session. One row per +/// `sessionId` — we only keep the last hook (start/stop) so its card survives a +/// reload, mirroring how `PlanDecisionRecord` keeps plan decisions alive across +/// CLI-backed session reloads. Hook cards are synthetic `ChatMessage`s injected +/// by `runHooks`; they never reach the CLI's jsonl transcript, so without this +/// sidecar they vanish when messages are reloaded from disk. +@Model +public final class HookStatusRecord { + @Attribute(.unique) public var sessionId: String + /// The original tool-call id of the hook card, so the rebuilt card dedupes + /// against a live in-memory one. + public var toolId: String + public var name: String + /// `HookTrigger.displayName` at the time the hook ran. + public var trigger: String + public var output: String + public var isError: Bool + public var updatedAt: Date + + public init( + sessionId: String, + toolId: String, + name: String, + trigger: String, + output: String, + isError: Bool, + updatedAt: Date = .now + ) { + self.sessionId = sessionId + self.toolId = toolId + self.name = name + self.trigger = trigger + self.output = output + self.isError = isError + self.updatedAt = updatedAt + } +} diff --git a/Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift b/Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift new file mode 100644 index 0000000..ac7c07d --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A JavaScript/TypeScript package manager (or runtime task runner) capable of +/// running a named script from `package.json` (or `deno.json` tasks). +public enum PackageManager: String, Codable, Sendable, CaseIterable, Hashable { + case npm + case yarn + case pnpm + case bun + case deno + + /// Command prefix that precedes the script name. npm and pnpm require an + /// explicit `run`; yarn/bun take the script directly; deno uses `deno task`. + public var runPrefix: String { + switch self { + case .npm: return "npm run" + case .yarn: return "yarn" + case .pnpm: return "pnpm run" + case .bun: return "bun run" + case .deno: return "deno task" + } + } + + /// Executable name used for install detection (`command -v `). + public var executable: String { rawValue } + + public var displayName: String { rawValue } +} + +/// Configuration for a `.packageScript` run profile: run a named script through +/// a selected package manager, with optional extra arguments appended verbatim +/// after the script (e.g. `bun run dev -- --port 3000`). +public struct PackageRunConfig: Codable, Sendable, Hashable { + public var packageManager: PackageManager + /// The script/task name, e.g. `dev`. + public var script: String + /// Extra arguments appended after the script. Free-form so the user can pass + /// `-- --port 3000` (npm-style separator) or plain flags. + public var arguments: String + + public init( + packageManager: PackageManager = .npm, + script: String = "", + arguments: String = "" + ) { + self.packageManager = packageManager + self.script = script + self.arguments = arguments + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RunProfile.swift b/Packages/Sources/RxCodeCore/Models/RunProfile.swift index bbea1eb..9cef8dc 100644 --- a/Packages/Sources/RxCodeCore/Models/RunProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/RunProfile.swift @@ -4,6 +4,10 @@ public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable { case bash case xcode case make + /// Runs a `package.json` / `deno.json` script through a selected package + /// manager. Named `packageScript` to avoid the `package` access-control + /// keyword; the rawValue stays `"package"` so it renders as "Package". + case packageScript = "package" } public struct RunProfile: Identifiable, Codable, Sendable, Hashable { @@ -18,6 +22,9 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { /// Populated when `type == .make`. Optional so existing on-disk profiles /// (written before the Make type existed) decode cleanly. public var make: MakeRunConfig? + /// Populated when `type == .packageScript`. Optional so existing on-disk + /// profiles (written before the Package type existed) decode cleanly. + public var package: PackageRunConfig? public var beforeSteps: [RunStep] public var afterSteps: [RunStep] public var createdAt: Date @@ -31,6 +38,7 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { bash: BashRunConfig = BashRunConfig(), xcode: XcodeRunConfig? = nil, make: MakeRunConfig? = nil, + package: PackageRunConfig? = nil, beforeSteps: [RunStep] = [], afterSteps: [RunStep] = [], createdAt: Date = Date(), @@ -43,6 +51,7 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { self.bash = bash self.xcode = xcode self.make = make + self.package = package self.beforeSteps = beforeSteps self.afterSteps = afterSteps self.createdAt = createdAt diff --git a/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift b/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift index 12a86cb..9f6d62c 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift @@ -27,6 +27,10 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { /// Present when `source == .make`. The dialog materializes these as /// `.make`-typed profiles instead of bash configurations. public let make: MakeRunConfig? + /// Present when `source == .npm`. The dialog materializes these as + /// `.packageScript`-typed profiles, carrying the detected package manager + /// and script name. + public let package: PackageRunConfig? public init( id: String, @@ -34,7 +38,8 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { displayName: String, command: String, xcode: XcodeRunConfig? = nil, - make: MakeRunConfig? = nil + make: MakeRunConfig? = nil, + package: PackageRunConfig? = nil ) { self.id = id self.source = source @@ -42,6 +47,7 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { self.command = command self.xcode = xcode self.make = make + self.package = package } } diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index 1400d69..ce7fd45 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -150,9 +150,26 @@ public enum RunTaskExecutor { case .make: guard let make = profile.make else { return [] } return makeScriptLines(make, projectPath: projectPath) + case .packageScript: + guard let pkg = profile.package else { return [] } + return packageScriptLines(pkg) } } + /// Synthesize ` run