diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 1346387ea7e8..e5c82ff746e1 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -45,10 +45,15 @@ extension CMUXCLI { /// approve/deny a permission / plan / question. let feedHookEvents: [String] let postInstallAction: PostInstallAction? + /// Optional CLI note printed after a successful install (or + /// "already up to date") to guide a required activation step — e.g. + /// Kiro applies its hooks only when run as the `cmux` agent. + let postInstallNote: String? enum HookFormat { case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1} case nested(timeoutMs: Int) // Codex/Gemini: nested with type/command/timeout + case kiroAgentJSON(timeoutMs: Int) // ~/.kiro/agents/*.json flat command entries with timeout_ms case antigravityJSON(timeoutSeconds: Int) // ~/.gemini/config/hooks.json named hook groups case rovoDevYAML case hermesAgentYAML @@ -100,7 +105,8 @@ extension CMUXCLI { publishesStopNotification: Bool = true, sessionEndIsTurnBoundary: Bool = false, feedHookEvents: [String] = [], - postInstallAction: PostInstallAction? = nil) { + postInstallAction: PostInstallAction? = nil, + postInstallNote: String? = nil) { self.name = name; self.displayName = displayName; self.statusKey = statusKey self.configDir = configDir; self.configFile = configFile self.configDirEnvOverride = configDirEnvOverride @@ -117,6 +123,7 @@ extension CMUXCLI { }) self.feedHookEvents = feedHookEvents self.postInstallAction = postInstallAction + self.postInstallNote = postInstallNote } } @@ -220,6 +227,24 @@ extension CMUXCLI { ], feedHookEvents: ["PreToolUse"] ), + AgentHookDef( + name: "kiro", displayName: "Kiro", statusKey: "kiro", + configDir: ".kiro/agents", configFile: "cmux.json", + configDirEnvOverride: "KIRO_HOME", configDirEnvOverrideSubpath: "agents", + createConfigDirIfMissing: true, binaryName: "kiro-cli", + sessionStoreSuffix: "kiro", disableEnvVar: "CMUX_KIRO_HOOKS_DISABLED", + hookMarker: "cmux hooks kiro", format: .kiroAgentJSON(timeoutMs: 5000), + events: [ + .init(agentEvent: "agentSpawn", cmuxSubcommand: "session-start"), + .init(agentEvent: "userPromptSubmit", cmuxSubcommand: "prompt-submit"), + .init(agentEvent: "stop", cmuxSubcommand: "stop"), + ], + feedHookEvents: ["preToolUse", "postToolUse"], + postInstallNote: String( + localized: "cli.hooks.kiro.postInstallNote", + defaultValue: "Kiro applies these hooks only when run as the cmux agent. Start Kiro with `kiro-cli chat --agent cmux`, or make it the default with `kiro-cli settings chat.defaultAgent cmux`." + ) + ), AgentHookDef( name: "antigravity", displayName: "Antigravity", statusKey: "antigravity", configDir: ".gemini/config", configFile: "hooks.json", @@ -332,7 +357,15 @@ extension CMUXCLI { } static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { - agentHookShellCommand("cmux hooks feed --source \(def.name) --event \(agentEvent)", for: def) + switch def.format { + case .kiroAgentJSON: + return exitTwoPropagatingAgentHookShellCommand( + "cmux hooks feed --source \(def.name) --event \(agentEvent)", + for: def + ) + default: + return agentHookShellCommand("cmux hooks feed --source \(def.name) --event \(agentEvent)", for: def) + } } private static let grokPinnedHookMarker = "cmux-grok-hook-v2" @@ -346,6 +379,11 @@ extension CMUXCLI { return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then { if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; } || echo '{}'; else echo '{}'; fi" } + private static func exitTwoPropagatingAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { + let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command + return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; status=$?; if [ \"$status\" -eq 2 ]; then exit 2; fi; if [ \"$status\" -ne 0 ]; then echo '{}'; fi; else echo '{}'; fi" + } + private static func usesPinnedHookDispatch(_ def: AgentHookDef) -> Bool { def.name == "grok" || def.name == "antigravity" } diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index d42b18fbdc7a..37fe256329bc 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -37,7 +37,7 @@ struct FeedEventClassifier { toolName: String ) -> (String, Bool) { let semantic = feedEventSemantic(source: source, event: event) - return wireMapping(for: semantic, toolName: toolName) + return wireMapping(for: semantic, source: source, toolName: toolName) } /// User-attention semantic of a hook/feed event, independent of the @@ -104,6 +104,7 @@ struct FeedEventClassifier { /// semantics. private static func wireMapping( for semantic: FeedEventSemantic, + source: String, toolName: String ) -> (String, Bool) { switch semantic { @@ -117,7 +118,7 @@ struct FeedEventClassifier { // permission request so the user can approve/deny from the // Feed sidebar. Read-only tools stay non-actionable // telemetry so we don't flood the Actionable view. - if Self.sideEffectingTools.contains(toolName) { + if Self.isSideEffectingTool(toolName, source: source) { return ("PermissionRequest", true) } return ("PreToolUse", false) @@ -197,6 +198,21 @@ struct FeedEventClassifier { "on_session_end": .sessionEnd, "on_session_finalize": .sessionEnd, ], + // Kiro emits camelCase hook events and has no dedicated approval + // event, so its pre-tool event escalates side-effecting tools to an + // approval (resolved against the kiro tool aliases in + // ``isSideEffectingTool``). Registering kiro explicitly is required: + // its lowercase event names are absent from + // ``genericFeedEventSemantics`` and would otherwise resolve to + // ``FeedEventSemantic/unknown`` (non-actionable), silently dropping + // every kiro approval. + "kiro": [ + "preToolUse": .toolStartMaybeApproval, + "postToolUse": .toolEnd, + "userPromptSubmit": .promptSubmit, + "agentSpawn": .sessionStart, + "stop": .response, + ], ] /// Fallback table for agents without a dedicated entry in @@ -241,4 +257,50 @@ struct FeedEventClassifier { "manage_subagents", "generate_image", ] + + /// Kiro emits lowercase / internal tool names (`fs_write`, + /// `execute_bash`, `use_aws`, …) absent from ``sideEffectingTools``. + /// Matched case-insensitively, but only for the `kiro` source, so another + /// agent's lowercase tool name is never broadened into an approval prompt. + private static let kiroSideEffectingToolAliases: Set = [ + "bash", + "write", + "edit", + "multiedit", + "notebookedit", + "apply_patch", + "shell", + "execute_bash", + "fs_write", + "use_aws", + "aws", + "terminal", + "run_command", + "write_to_file", + "replace_file_content", + "multi_replace_file_content", + "manage_task", + "schedule", + "ask_permission", + "invoke_subagent", + "define_subagent", + "manage_subagents", + "generate_image", + ] + + /// Whether a tool mutates state and deserves an approval prompt. Exact + /// match against ``sideEffectingTools`` for every source; the `kiro` + /// source additionally matches its case-insensitive internal aliases. + /// Kept source-scoped so another agent's lowercase tool name is not + /// escalated into an approval. + static func isSideEffectingTool(_ toolName: String, source: String) -> Bool { + guard !toolName.isEmpty else { return false } + if sideEffectingTools.contains(toolName) { + return true + } + if source == "kiro" { + return kiroSideEffectingToolAliases.contains(toolName.lowercased()) + } + return false + } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f9d4be9205bb..2282e91c204f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12755,7 +12755,7 @@ struct CMUXCLI { agent. Claude Code hooks are injected automatically by the cmux Claude wrapper. Agents: - codex, grok, opencode, pi, amp, cursor, gemini, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder + codex, grok, opencode, pi, amp, cursor, gemini, kiro, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder Hook targets: setup Install hooks for all supported agents on PATH @@ -12770,6 +12770,7 @@ struct CMUXCLI { ~/.config/opencode/plugins/cmux-feed.js ~/.pi/agent/extensions/cmux-session.ts ~/.config/amp/plugins/cmux-session.ts + ~/.kiro/agents/cmux.json See docs/agent-hooks.md for the full integration matrix. Examples: @@ -24442,6 +24443,11 @@ struct CMUXCLI { return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "cursor-agent", option: "--resume", sessionId: sessionId) case "gemini": return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "gemini", option: "--resume", sessionId: sessionId) + case "kiro": + let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "kiro-cli") + return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { + [original.executable, "chat", "--resume-id", sessionId] + $0 + } case "antigravity": return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "agy", option: "--conversation", sessionId: sessionId) case "opencode": @@ -24736,6 +24742,13 @@ struct CMUXCLI { var entries = result[event.agentEvent] as? [[String: Any]] ?? [] entries.append(["command": cmd]) result[event.agentEvent] = entries + case .kiroAgentJSON(let timeoutMs): + var entries = result[event.agentEvent] as? [[String: Any]] ?? [] + entries.append([ + "command": cmd, + "timeout_ms": max(timeoutMs, 1), + ] as [String: Any]) + result[event.agentEvent] = entries case .nested(let timeoutMs): var groups = result[event.agentEvent] as? [[String: Any]] ?? [] let timeout = nestedHookTimeout(timeoutMs, for: def) @@ -24767,6 +24780,13 @@ struct CMUXCLI { var entries = result[agentEvent] as? [[String: Any]] ?? [] entries.append(["command": feedCmd]) result[agentEvent] = entries + case .kiroAgentJSON: + var entries = result[agentEvent] as? [[String: Any]] ?? [] + entries.append([ + "command": feedCmd, + "timeout_ms": feedTimeoutMs, + ] as [String: Any]) + result[agentEvent] = entries case .nested: var groups = result[agentEvent] as? [[String: Any]] ?? [] let timeout = nestedHookTimeout(feedTimeoutMs, for: def) @@ -25777,7 +25797,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { var cmuxInsertionIndexes: [String: [Int]] = [:] for (event, value) in hooks { switch def.format { - case .flat: + case .flat, .kiroAgentJSON: guard let entries = value as? [[String: Any]] else { continue } var rewrittenEntries: [[String: Any]] = [] for entry in entries { @@ -25834,7 +25854,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { // Add new cmux entries for (event, value) in newHooks { switch def.format { - case .flat: + case .flat, .kiroAgentJSON: var entries = hooks[event] as? [[String: Any]] ?? [] if let newEntries = value as? [[String: Any]] { if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { @@ -25861,6 +25881,23 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { existing["hooks"] = hooks if case .flat = def.format { existing["version"] = 1 } + if case .kiroAgentJSON = def.format { + if existing["name"] == nil { + existing["name"] = "cmux" + } + if existing["description"] == nil { + existing["description"] = "CMUX notification and Feed bridge hooks for Kiro CLI." + } + if existing["tools"] == nil { + // Grant the full tool set so `kiro-cli chat --agent cmux` is + // actually usable. A Kiro custom agent with no `tools` field is + // restricted to no tools, so the model can't run anything and the + // preToolUse/postToolUse Feed-approval hooks would never fire + // (verified against kiro-cli 2.5.0). Only defaulted on fresh + // install; an existing user `tools` list is preserved. + existing["tools"] = ["*"] + } + } let codexHookTrustEntries = Self.codexHookTrustEntries( hooks: hooks, hooksFilePath: filePath, @@ -25908,6 +25945,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { print("\(def.displayName) hooks installed at \(filePath)") } + if let note = def.postInstallNote { + print(note) + } + try pruneLegacyGrokHookFileIfNeeded(def: def, configDir: configDir, primaryFilePath: filePath) // Post-install actions @@ -26089,7 +26130,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } for (event, value) in hooks { switch def.format { - case .flat: + case .flat, .kiroAgentJSON: guard var entries = value as? [[String: Any]] else { continue } let before = entries.count entries.removeAll { isCmuxOwnedCommand($0["command"] as? String ?? "") } @@ -28296,6 +28337,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case "rovodev": envKey = "CMUX_ROVODEV_PID" case "hermes-agent": envKey = "CMUX_HERMES_AGENT_PID" case "copilot": envKey = "CMUX_COPILOT_PID" + case "kiro": envKey = "CMUX_KIRO_PID" default: envKey = "" } if !envKey.isEmpty, @@ -28524,10 +28566,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { var summary: String? if lower == "bash" { summary = firstString(in: dict, keys: ["description", "command"]) - } else if lower == "run_command" { + } else if lower == "run_command" || lower == "execute_bash" || lower == "shell" { summary = firstString(in: dict, keys: ["CommandLine", "commandLine", "command", "Cwd", "cwd"]) - } else if ["write", "edit", "multiedit", "read"].contains(lower) { - summary = firstString(in: dict, keys: ["file_path", "path"]) + } else if ["write", "edit", "multiedit", "read", "fs_read", "fs_write"].contains(lower) { + summary = firstString(in: dict, keys: ["file_path", "path"]) ?? firstOperationPath(in: dict) } else if ["view_file", "write_to_file", "replace_file_content", "multi_replace_file_content"].contains(lower) { summary = firstString(in: dict, keys: ["AbsolutePath", "TargetFile", "SearchPath", "DirectoryPath", "path"]) } else if lower == "askuserquestion" || lower == "ask_question" { @@ -28544,6 +28586,16 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return context.isEmpty ? nil : context } + private func firstOperationPath(in dict: [String: Any]) -> String? { + guard let operations = dict["operations"] as? [[String: Any]] else { return nil } + for operation in operations { + if let path = firstString(in: operation, keys: ["path", "file_path", "filePath"]) { + return path + } + } + return nil + } + private func feedToolInputDictionary(_ raw: Any?) -> [String: Any]? { if let dict = raw as? [String: Any] { return dict @@ -30065,13 +30117,23 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { event: rawEvent, toolName: toolName ) + let env = ProcessInfo.processInfo.environment + if Self.shouldSuppressKiroFeedEvent( + source: source, + hookEventName: hookEventName, + toolName: toolName, + isActionable: isActionable, + env: env + ) { + print("{}") + return + } // Capture the agent's PID (not our subprocess PID) so the // Feed can auto-expire pending cards when the agent is // killed/crashed. Agent wrappers export CMUX__PID. // Other agents fall back to getppid() which walks up one // level — close enough to catch most kill scenarios. - let env = ProcessInfo.processInfo.environment let agentPid = agentPidForFeedSource(source, env: env) let sessionId = firstString( in: stdinObj, @@ -30163,6 +30225,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let status = result["status"] as? String ?? "acknowledged" if status == "resolved", let decision = result["decision"] as? [String: Any] { + if source == "kiro", Self.emitKiroDecisionIfHandled(decision: decision) { + return + } let out = Self.renderAgentDecision( source: source, hookEventName: hookEventName, @@ -30177,6 +30242,57 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { print("{}") } + private static func shouldSuppressKiroFeedEvent( + source: String, + hookEventName: String, + toolName: String, + isActionable: Bool, + env: [String: String] + ) -> Bool { + guard source == "kiro" else { return false } + guard env["CMUX_KIRO_NOTIFICATION_LEVEL"] != nil || hookEventName == "PreToolUse" || hookEventName == "PostToolUse" else { + return false + } + guard !isActionable else { return false } + let level = env["CMUX_KIRO_NOTIFICATION_LEVEL"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "standard" + switch level { + case "minimal": + return hookEventName == "PreToolUse" || hookEventName == "PostToolUse" + case "verbose": + return false + default: + if hookEventName == "PreToolUse" || hookEventName == "PostToolUse" { + return !FeedEventClassifier.isSideEffectingTool(toolName, source: source) + } + return false + } + } + + private static func emitKiroDecisionIfHandled(decision: [String: Any]) -> Bool { + guard (decision["kind"] as? String) == "permission" else { return false } + let mode = (decision["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // Feed permission decisions carry a WorkstreamPermissionMode raw value: + // `once` / `always` / `all` / `bypass` all allow the tool; `deny` + // blocks. Fail closed on anything else — missing, empty, or an + // unrecognized/typo mode — so a malformed decision blocks the tool + // (exit 2 is Kiro's preToolUse deny signal) rather than silently + // allowing work the user never approved. + let allowModes: Set = ["once", "always", "all", "bypass"] + if let mode, allowModes.contains(mode) { + print("{}") + return true + } + if mode == "deny" { + fputs("User denied permission via cmux Feed.\n", stderr) + } else { + fputs("cmux Feed returned an unrecognized Kiro permission decision; denying for safety.\n", stderr) + } + fflush(stderr) + exit(2) + } + private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" /// Encodes the user's decision in the agent's expected hook stdout diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index f101d4ae18a7..9f1a6a70cb23 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -66,6 +66,9 @@ public enum AgentLaunchEnvironmentPolicy { "GROK_SANDBOX", "HERMES_CODEX_BASE_URL", "HERMES_HOME", + "KIRO_HOME", + "KIRO_LOG_LEVEL", + "KIRO_LOG_NO_COLOR", "NODE_OPTIONS", "OPENCODE_CONFIG_DIR", "PI_CACHE_RETENTION", diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 6a581e5269c1..8e0bbc3e73a3 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -103,6 +103,15 @@ public enum AgentLaunchSanitizer { return preserveOptions(tail, policy: cursorPolicy) case "gemini": return preserveOptions(args, policy: geminiPolicy) + case "kiro": + var tail = args + if tail.first == "chat" { + tail.removeFirst() + } else if let command = tail.first, + !command.hasPrefix("-") { + return nil + } + return preserveOptions(tail, policy: kiroPolicy) case "antigravity": return preserveOptions(args, policy: antigravityPolicy) case "opencode": diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift index f28fbef6711c..8379ebb2aeaf 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift @@ -347,6 +347,63 @@ extension AgentLaunchSanitizer { ] ) + // kiro-cli flag widths for session restore. `--resume` / `-r` are boolean + // (resume the previous conversation from the current directory; they take + // no value), so they live in droppedOptions only — dropping them must not + // consume a following token. The session-id variant `--resume-id ` + // takes a value and is in both valueOptions and droppedOptions so the id is + // dropped with the flag. kiro-cli exposes no optional-value or variadic + // (single flag carrying multiple space-separated values) flags, so those + // Policy fields are intentionally omitted. + static let kiroPolicy = Policy( + valueOptions: [ + "--agent", + "--delete-session", + "--format", + "-f", + "--resume-id", + "--trust-tools", + "--wrap" + ], + nonRestorableCommands: [ + "agent", + "diagnostic", + "doctor", + "inline", + "integrations", + "issue", + "login", + "logout", + "mcp", + "settings", + "theme", + "translate", + "update", + "version", + "whoami" + ], + droppedOptions: [ + "--delete-session", + "--format", + "-f", + "--resume", + "-r", + "--resume-id" + ], + droppedOptionPrefixes: [ + "--delete-session=", + "--format=", + "-f=", + "--resume-id=" + ], + rejectOptions: [ + "--list-models", + "--list-sessions", + "--no-interactive", + "--resume-picker" + ] + ) + static let rovoDevPolicy = Policy( valueOptions: [ "--config", diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift index 396505708c15..fe3fb7ba5169 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift @@ -49,6 +49,18 @@ public struct AutomationCatalogSection: SettingCatalogSection { userDefaultsKey: "geminiHooksEnabled" ) + public let kiroIntegration = DefaultsKey( + id: "automation.kiroIntegration", + defaultValue: true, + userDefaultsKey: "kiroHooksEnabled" + ) + + public let kiroNotificationLevel = DefaultsKey( + id: "automation.kiroNotificationLevel", + defaultValue: "standard", + userDefaultsKey: "kiroNotificationLevel" + ) + public let portBase = DefaultsKey( id: "automation.portBase", defaultValue: 9100, diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift index 42f5c5c3ddaa..91fa63ce8011 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift @@ -27,6 +27,21 @@ public struct IntegrationsCatalogSection: SettingCatalogSection { userDefaultsKey: "geminiHooksEnabled" ) + public let kiroHooksEnabled = DefaultsKey( + id: "integrations.kiro.hooksEnabled", + defaultValue: true, + userDefaultsKey: "kiroHooksEnabled" + ) + + // Stored as the raw `minimal` / `standard` / `verbose` string so it stays + // in sync with the `cmux` CLI's `CMUX_KIRO_NOTIFICATION_LEVEL` env var and + // the `automation.kiroNotificationLevel` config key. + public let kiroNotificationLevel = DefaultsKey( + id: "integrations.kiro.notificationLevel", + defaultValue: "standard", + userDefaultsKey: "kiroNotificationLevel" + ) + public let ripgrepCustomBinaryPath = DefaultsKey( id: "integrations.ripgrep.customBinaryPath", defaultValue: "", diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift index 99ec65bab334..fa00a034fe4b 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift @@ -19,6 +19,8 @@ public struct AutomationSection: View { @State private var suppressSubagentModel: DefaultsValueModel @State private var cursorModel: DefaultsValueModel @State private var geminiModel: DefaultsValueModel + @State private var kiroModel: DefaultsValueModel + @State private var kiroLevelModel: DefaultsValueModel @State private var portBaseModel: DefaultsValueModel @State private var portRangeModel: DefaultsValueModel @State private var socketPasswordDraft: String = "" @@ -51,6 +53,8 @@ public struct AutomationSection: View { _suppressSubagentModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.suppressSubagentNotifications)) _cursorModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.cursorHooksEnabled)) _geminiModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.geminiHooksEnabled)) + _kiroModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.kiroHooksEnabled)) + _kiroLevelModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.kiroNotificationLevel)) _portBaseModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.portBase)) _portRangeModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.portRange)) } @@ -68,6 +72,7 @@ public struct AutomationSection: View { suppressSubagentCard cursorCard geminiCard + kiroCard portCard } .confirmationDialog( @@ -312,6 +317,42 @@ public struct AutomationSection: View { } } + @ViewBuilder + private var kiroCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.kiroIntegration"), + String(localized: "settings.automation.kiro", defaultValue: "Kiro CLI Integration"), + subtitle: kiroModel.current + ? String(localized: "settings.automation.kiro.subtitleOn", defaultValue: "Sidebar shows Kiro session status, notifications, and Feed tool events.") + : String(localized: "settings.automation.kiro.subtitleOff", defaultValue: "Kiro runs without cmux integration.") + ) { + Toggle("", isOn: Binding(get: { kiroModel.current }, set: { kiroModel.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsKiroHooksToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("automation.kiroNotificationLevel"), + String(localized: "settings.automation.kiro.notificationLevel", defaultValue: "Kiro Notification Level"), + subtitle: String(localized: "settings.automation.kiro.notificationLevel.subtitle", defaultValue: "Controls how many Kiro tool events appear in Feed."), + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding(get: { kiroLevelModel.current }, set: { kiroLevelModel.set($0) })) { + Text(String(localized: "settings.automation.kiro.notificationLevel.minimal", defaultValue: "Minimal")).tag("minimal") + Text(String(localized: "settings.automation.kiro.notificationLevel.standard", defaultValue: "Standard")).tag("standard") + Text(String(localized: "settings.automation.kiro.notificationLevel.verbose", defaultValue: "Verbose")).tag("verbose") + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("SettingsKiroNotificationLevelPicker") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.kiro.note", defaultValue: "Hooks must be installed with `cmux hooks kiro install`, then run Kiro with `kiro-cli chat --agent cmux` (or set it as your default agent). They no-op outside cmux terminals.")) + } + } + @ViewBuilder private var portCard: some View { SettingsCard { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1c3ad69c7c35..af9dea1e311f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,281 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "cli.hooks.kiro.postInstallNote": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kiro applies these hooks only when run as the cmux agent. Start Kiro with `kiro-cli chat --agent cmux`, or make it the default with `kiro-cli settings chat.defaultAgent cmux`." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kiroフックはcmuxエージェントとして実行した場合のみ適用されます。Kiroを`kiro-cli chat --agent cmux`で起動するか、`kiro-cli settings chat.defaultAgent cmux`でデフォルトに設定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kiro 훅은 cmux 에이전트로 실행할 때만 적용됩니다. `kiro-cli chat --agent cmux`로 Kiro를 실행하거나 `kiro-cli settings chat.defaultAgent cmux`로 기본값으로 설정하세요." + } + } + } + }, + "settings.automation.kiro": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kiro CLI Integration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kiro CLI連携" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kiro CLI 연동" + } + } + } + }, + "settings.automation.kiro.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hooks must be installed with `cmux hooks kiro install`, then run Kiro with `kiro-cli chat --agent cmux` (or set it as your default agent). They no-op outside cmux terminals." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックは`cmux hooks kiro install`でインストールし、Kiroを`kiro-cli chat --agent cmux`で起動(またはデフォルトエージェントに設定)してください。cmuxターミナル外では動作しません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "`cmux hooks kiro install`로 훅을 설치한 다음 `kiro-cli chat --agent cmux`로 Kiro를 실행(또는 기본 에이전트로 설정)하세요. cmux 터미널 외부에서는 작동하지 않습니다." + } + } + } + }, + "settings.automation.kiro.notificationLevel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kiro Notification Level" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kiro通知レベル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kiro 알림 수준" + } + } + } + }, + "settings.automation.kiro.notificationLevel.minimal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최소" + } + } + } + }, + "settings.automation.kiro.notificationLevel.standard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표준" + } + } + } + }, + "settings.automation.kiro.notificationLevel.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls how many Kiro tool events appear in Feed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Feedに表示するKiroツールイベントの量を制御します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Feed에 표시되는 Kiro 도구 이벤트 수를 제어합니다." + } + } + } + }, + "settings.automation.kiro.notificationLevel.verbose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verbose" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자세히" + } + } + } + }, + "settings.automation.kiro.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kiro runs without cmux integration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kiroはcmux連携なしで動作します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kiro가 cmux 연동 없이 실행됩니다." + } + } + } + }, + "settings.automation.kiro.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar shows Kiro session status, notifications, and Feed tool events." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにKiroセッションのステータス、通知、Feedツールイベントが表示されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바에 Kiro 세션 상태, 알림, Feed 도구 이벤트가 표시됩니다." + } + } + } + }, + "settings.search.alias.setting.automation.kiro": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "automation.kiroIntegration kiro cli amazon q agent hooks notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "automation.kiroIntegration kiro cli amazon q agent hooks notifications kiro cli amazon q エージェント フック 通知 連携" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "automation.kiroIntegration kiro cli amazon q agent hooks notifications 자동화 kiro cli amazon q 에이전트 훅 알림 연동" + } + } + } + }, + "settings.search.alias.setting.automation.kiro-notification-level": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "automation.kiroNotificationLevel kiro cli notification verbosity minimal standard verbose tool events" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "automation.kiroNotificationLevel kiro cli notification verbosity minimal standard verbose tool events kiro 通知 レベル 詳細 最小 標準 ツール イベント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "automation.kiroNotificationLevel kiro cli notification verbosity minimal standard verbose tool events kiro 알림 수준 자세히 최소 표준 도구 이벤트" + } + } + } + }, "remoteDaemon.error.missingPersistentPTYCapability": { "localizations": { "en": { diff --git a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift index d926132b5f8a..6d957a31a0da 100644 --- a/Sources/AgentHibernation/AgentHibernationLifecycleState.swift +++ b/Sources/AgentHibernation/AgentHibernationLifecycleState.swift @@ -58,6 +58,7 @@ enum AgentHibernationLifecycleStatusKeys { "gemini", "grok", "hermes-agent", + "kiro", "opencode", "pi", "qoder", diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index e0f904527611..16443965e8ba 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -144,6 +144,8 @@ extension CmuxSettingsFileStore { "automation.suppressSubagentNotifications", "automation.cursorIntegration", "automation.geminiIntegration", + "automation.kiroIntegration", + "automation.kiroNotificationLevel", "automation.portBase", "automation.portRange", "browser.defaultSearchEngine", diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 767eb95dd91a..f1b15425afab 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -728,6 +728,17 @@ enum CommandPaletteSettingsToggleCommands { defaultValue: GeminiIntegrationSettings.defaultHooksEnabled, defaultsKey: GeminiIntegrationSettings.hooksEnabledKey ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "kiroIntegration", + settingsKey: "automation.kiroIntegration", + title: { + String(localized: "settings.automation.kiro", defaultValue: "Kiro CLI Integration") + }, + sectionTitle: automation, + keywords: ["automation.kiroIntegration", "kiro", "cli", "hooks", "agent", "integration"], + defaultValue: KiroIntegrationSettings.defaultHooksEnabled, + defaultsKey: KiroIntegrationSettings.hooksEnabledKey + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "browserSearchSuggestions", settingsKey: "browser.showSearchSuggestions", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 59630e069b3d..311ccee14492 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6293,6 +6293,13 @@ final class TerminalSurface: Identifiable, ObservableObject { if !GeminiIntegrationSettings.hooksEnabled() { setManagedEnvironmentValue("CMUX_GEMINI_HOOKS_DISABLED", "1") } + if !KiroIntegrationSettings.hooksEnabled() { + setManagedEnvironmentValue("CMUX_KIRO_HOOKS_DISABLED", "1") + } + setManagedEnvironmentValue( + "CMUX_KIRO_NOTIFICATION_LEVEL", + KiroIntegrationSettings.notificationLevel().rawValue + ) if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { let currentPath = env["PATH"] diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 34a3d614912e..72c0973fd860 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -165,6 +165,8 @@ extension CmuxSettingsFileStore { "suppressSubagentNotifications": AgentSubagentNotificationSettings.defaultSuppressNotifications, "cursorIntegration": CursorIntegrationSettings.defaultHooksEnabled, "geminiIntegration": GeminiIntegrationSettings.defaultHooksEnabled, + "kiroIntegration": KiroIntegrationSettings.defaultHooksEnabled, + "kiroNotificationLevel": KiroIntegrationSettings.defaultNotificationLevel.rawValue, "portBase": AutomationSettings.defaultPortBase, "portRange": AutomationSettings.defaultPortRange, ], diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 3af14d490293..2c9114fd40de 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -830,6 +830,16 @@ final class CmuxSettingsFileStore { if let value = jsonBool(section["geminiIntegration"]) { snapshot.managedUserDefaults[GeminiIntegrationSettings.hooksEnabledKey] = .bool(value) } + if let value = jsonBool(section["kiroIntegration"]) { + snapshot.managedUserDefaults[KiroIntegrationSettings.hooksEnabledKey] = .bool(value) + } + if let raw = jsonString(section["kiroNotificationLevel"]) { + if KiroIntegrationSettings.NotificationLevel(rawValue: raw) != nil { + snapshot.managedUserDefaults[KiroIntegrationSettings.notificationLevelKey] = .string(raw) + } else { + logInvalid("automation.kiroNotificationLevel", sourcePath: sourcePath) + } + } if let value = jsonInt(section["portBase"]) { guard value > 0 else { logInvalid("automation.portBase", sourcePath: sourcePath) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 8152d8c72432..207848a53ad7 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -531,6 +531,10 @@ enum AgentResumeCommandBuilder { option: "--resume", sessionId: sessionId ) + case .kiro: + let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "kiro-cli") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "kiro", args: original.tail) else { return nil } + return [original.executable, "chat", "--resume-id", sessionId] + preserved case .antigravity: return resumeWithOption( kind: "antigravity", diff --git a/Sources/RestorableAgentTypes.swift b/Sources/RestorableAgentTypes.swift index bcdd2bde2bf1..dd3e089d6de7 100644 --- a/Sources/RestorableAgentTypes.swift +++ b/Sources/RestorableAgentTypes.swift @@ -8,6 +8,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case amp case cursor case gemini + case kiro case antigravity case opencode case rovodev @@ -26,6 +27,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { .amp, .cursor, .gemini, + .kiro, // Antigravity is registry-owned so the built-in Vault registration can be // overridden by project config while direct .antigravity values still encode. .opencode, @@ -47,6 +49,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case "amp": self = .amp case "cursor": self = .cursor case "gemini": self = .gemini + case "kiro": self = .kiro case "antigravity": self = .antigravity case "opencode": self = .opencode case "rovodev": self = .rovodev @@ -70,6 +73,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case .amp: return "amp" case .cursor: return "cursor" case .gemini: return "gemini" + case .kiro: return "kiro" case .antigravity: return "antigravity" case .opencode: return "opencode" case .rovodev: return "rovodev" @@ -98,6 +102,7 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { case .amp: return "Amp" case .cursor: return "Cursor" case .gemini: return "Gemini" + case .kiro: return "Kiro" case .antigravity: return "Antigravity" case .opencode: return "OpenCode" case .rovodev: return "Rovo Dev" diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 81323b2989bb..f5a35b64f8e4 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -101,7 +101,7 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { case .betaFeatures: return "\(title) beta experimental unstable feed dock right sidebar" case .automation: - return "\(title) socket integrations hooks ports claude cursor gemini" + return "\(title) socket integrations hooks ports claude cursor gemini kiro" case .browser: return "\(title) search engine links history theme" case .browserImport: @@ -375,6 +375,8 @@ enum SettingsSearchIndex { setting(.automation, "subagent-notifications", String(localized: "settings.automation.suppressSubagentNotifications", defaultValue: "Suppress Subagent Notifications"), "nested child agent codex claude hooks notifications"), setting(.automation, "cursor", String(localized: "settings.automation.cursor", defaultValue: "Cursor Integration"), "agent hooks notifications"), setting(.automation, "gemini", String(localized: "settings.automation.gemini", defaultValue: "Gemini CLI Integration"), "agent hooks notifications"), + setting(.automation, "kiro", String(localized: "settings.automation.kiro", defaultValue: "Kiro CLI Integration"), "agent hooks notifications"), + setting(.automation, "kiro-notification-level", String(localized: "settings.automation.kiro.notificationLevel", defaultValue: "Kiro Notification Level"), "agent hooks notifications verbosity tool events"), setting(.automation, "port-base", String(localized: "settings.automation.portBase", defaultValue: "Port Base"), "CMUX_PORT start"), setting(.automation, "port-range", String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), "CMUX_PORT_END workspace ports"), setting(.browser, "search-engine", String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), "address bar query google duckduckgo bing kagi brave startpage perplexity exa yahoo ecosia qwant mojeek wikipedia github baidu yandex custom search provider"), @@ -487,6 +489,8 @@ enum SettingsSearchIndex { "automation.suppressSubagentNotifications": settingID(for: .automation, idSuffix: "subagent-notifications"), "automation.cursorIntegration": settingID(for: .automation, idSuffix: "cursor"), "automation.geminiIntegration": settingID(for: .automation, idSuffix: "gemini"), + "automation.kiroIntegration": settingID(for: .automation, idSuffix: "kiro"), + "automation.kiroNotificationLevel": settingID(for: .automation, idSuffix: "kiro-notification-level"), "automation.portBase": settingID(for: .automation, idSuffix: "port-base"), "automation.portRange": settingID(for: .automation, idSuffix: "port-range"), "browser.enabled": settingID(for: .browser, idSuffix: "enable-browser"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index ff4b262bd061..84d8047cd4e4 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -114,6 +114,8 @@ enum SettingsSearchAliasIndex { "automation:subagent-notifications": localized("settings.search.alias.setting.automation.subagent-notifications", defaultValue: "automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"), "automation:cursor": localized("settings.search.alias.setting.automation.cursor", defaultValue: "automation.cursorIntegration cursor ide agent hooks notifications"), "automation:gemini": localized("settings.search.alias.setting.automation.gemini", defaultValue: "automation.geminiIntegration gemini cli google agent hooks notifications"), + "automation:kiro": localized("settings.search.alias.setting.automation.kiro", defaultValue: "automation.kiroIntegration kiro cli amazon q agent hooks notifications"), + "automation:kiro-notification-level": localized("settings.search.alias.setting.automation.kiro-notification-level", defaultValue: "automation.kiroNotificationLevel kiro cli notification verbosity minimal standard verbose tool events"), "automation:port-base": localized("settings.search.alias.setting.automation.port-base", defaultValue: "automation.portBase cmux_port start first base env environment variable"), "automation:port-range": localized("settings.search.alias.setting.automation.port-range", defaultValue: "automation.portRange cmux_port_end range size count env ports"), "browser:enable-browser": localized("settings.search.alias.setting.browser.enable-browser", defaultValue: "browser.enabled enable disable webview embedded browser tabs links"), diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index c99a802d3285..72dffd0fc42f 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -611,6 +611,14 @@ struct CmuxTaskManagerCodingAgentDefinition: Equatable { directBasenames: ["gemini"], argumentNeedles: ["gemini"] ), + CmuxTaskManagerCodingAgentDefinition( + id: "kiro", + displayName: "Kiro", + assetName: nil, + launchKinds: ["kiro"], + directBasenames: ["kiro", "kiro-cli"], + argumentNeedles: ["kiro", "kiro-cli"] + ), CmuxTaskManagerCodingAgentDefinition( id: "antigravity", displayName: "Antigravity", diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index b717e33d9141..85cd3a73f1c9 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5019,6 +5019,47 @@ enum GeminiIntegrationSettings { } } +enum KiroIntegrationSettings { + enum NotificationLevel: String, CaseIterable, Identifiable { + case minimal + case standard + case verbose + + var id: String { rawValue } + + var title: String { + switch self { + case .minimal: + return String(localized: "settings.automation.kiro.notificationLevel.minimal", defaultValue: "Minimal") + case .standard: + return String(localized: "settings.automation.kiro.notificationLevel.standard", defaultValue: "Standard") + case .verbose: + return String(localized: "settings.automation.kiro.notificationLevel.verbose", defaultValue: "Verbose") + } + } + } + + static let hooksEnabledKey = "kiroHooksEnabled" + static let defaultHooksEnabled = true + static let notificationLevelKey = "kiroNotificationLevel" + static let defaultNotificationLevel = NotificationLevel.standard + + static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: hooksEnabledKey) == nil { + return defaultHooksEnabled + } + return defaults.bool(forKey: hooksEnabledKey) + } + + static func notificationLevel(defaults: UserDefaults = .standard) -> NotificationLevel { + guard let raw = defaults.string(forKey: notificationLevelKey), + let level = NotificationLevel(rawValue: raw) else { + return defaultNotificationLevel + } + return level + } +} + enum WelcomeSettings { static let shownKey = "cmuxWelcomeShown" } @@ -5342,6 +5383,14 @@ struct SettingsView: View { @Setting(\.integrations.suppressSubagentNotifications) private var suppressSubagentNotifications @Setting(\.integrations.cursorHooksEnabled) private var cursorHooksEnabled @Setting(\.integrations.geminiHooksEnabled) private var geminiHooksEnabled + // Kiro stays on @AppStorage here: this legacy SettingsView is no longer + // the presented settings window (CmuxSettingsUI's AutomationSection is), + // and Kiro's live toggle lives there. Both read the same UserDefaults + // keys, so they stay in sync. + @AppStorage(KiroIntegrationSettings.hooksEnabledKey) + private var kiroHooksEnabled = KiroIntegrationSettings.defaultHooksEnabled + @AppStorage(KiroIntegrationSettings.notificationLevelKey) + private var kiroNotificationLevel = KiroIntegrationSettings.defaultNotificationLevel.rawValue @Setting(\.app.sendAnonymousTelemetry) private var sendAnonymousTelemetry @Setting(\.app.preferredEditor) private var preferredEditorCommand @Setting(\.app.openSupportedFilesInCmux) private var openSupportedFilesInCmux @@ -7600,6 +7649,41 @@ struct SettingsView: View { SettingsCardNote(String(localized: "settings.automation.gemini.note", defaultValue: "Hooks must be installed with `cmux hooks gemini install`. They no-op outside cmux terminals.")) } + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.kiroIntegration"), + String(localized: "settings.automation.kiro", defaultValue: "Kiro CLI Integration"), + subtitle: kiroHooksEnabled + ? String(localized: "settings.automation.kiro.subtitleOn", defaultValue: "Sidebar shows Kiro session status, notifications, and Feed tool events.") + : String(localized: "settings.automation.kiro.subtitleOff", defaultValue: "Kiro runs without cmux integration.") + ) { + Toggle("", isOn: $kiroHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsKiroHooksToggle") + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("automation.kiroNotificationLevel"), + String(localized: "settings.automation.kiro.notificationLevel", defaultValue: "Kiro Notification Level"), + subtitle: String(localized: "settings.automation.kiro.notificationLevel.subtitle", defaultValue: "Controls how many Kiro tool events appear in Feed.") + ) { + Picker("", selection: $kiroNotificationLevel) { + ForEach(KiroIntegrationSettings.NotificationLevel.allCases) { level in + Text(level.title).tag(level.rawValue) + } + } + .labelsHidden() + .frame(width: 150) + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.kiro.note", defaultValue: "Hooks must be installed with `cmux hooks kiro install`. They no-op outside cmux terminals.")) + } + SettingsCard { SettingsCardRow(configurationReview: .json("automation.portBase"), String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { TextField("", value: $cmuxPortBase, format: .number) @@ -8429,6 +8513,8 @@ struct SettingsView: View { suppressSubagentNotifications = AgentSubagentNotificationSettings.defaultSuppressNotifications cursorHooksEnabled = CursorIntegrationSettings.defaultHooksEnabled geminiHooksEnabled = GeminiIntegrationSettings.defaultHooksEnabled + kiroHooksEnabled = KiroIntegrationSettings.defaultHooksEnabled + kiroNotificationLevel = KiroIntegrationSettings.defaultNotificationLevel.rawValue sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry preferredEditorCommand = "" CmdClickSupportedFileRouteSettings.setEnabled(CmdClickSupportedFileRouteSettings.defaultValue) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 200c9f3a3076..4e0bc0c7b736 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -71,6 +71,35 @@ extension CLINotifyProcessIntegrationRegressionTests { ], expectedEnvironment: ["GEMINI_CLI_HOME": "/tmp/gemini home"] ), + GenericHookPersistenceScenario( + agent: "kiro", + subcommand: "session-start", + sessionId: "kiro-session-123", + executable: "/Users/example/.cargo/bin/kiro-cli", + launchArguments: [ + "/Users/example/.cargo/bin/kiro-cli", + "chat", + "--agent", + "cmux", + "--resume-id", + "old-session", + "--trust-tools", + "fs_read,fs_write", + "initial prompt should not persist" + ], + extraEnvironment: [ + "KIRO_HOME": "/tmp/kiro home", + "AWS_ACCESS_KEY_ID": "secret" + ], + expectedArguments: [ + "/Users/example/.cargo/bin/kiro-cli", + "--agent", + "cmux", + "--trust-tools", + "fs_read,fs_write" + ], + expectedEnvironment: ["KIRO_HOME": "/tmp/kiro home"] + ), GenericHookPersistenceScenario( agent: "antigravity", subcommand: "session-start", @@ -919,6 +948,329 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(cmuxGroup["PostToolUse"]) } + func testKiroHookInstallUsesAgentConfigShapeAndPreservesDenyExit() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-kiro-hook-install-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "kiro", "install", "--yes"], + environment: [ + "HOME": root.path, + "KIRO_HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue( + result.stdout.contains("kiro-cli chat --agent cmux"), + "Expected Kiro install to print the --agent cmux activation hint, saw: \(result.stdout)" + ) + + let hookURL = root + .appendingPathComponent("agents", isDirectory: true) + .appendingPathComponent("cmux.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) + XCTAssertEqual(json["name"] as? String, "cmux") + XCTAssertNil(json["version"], "Kiro agent configs should not receive Cursor's hooks version field") + XCTAssertEqual( + json["tools"] as? [String], ["*"], + "Kiro cmux agent must grant the full tool set so `--agent cmux` can run tools and fire preToolUse hooks" + ) + + let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) + let preToolUse = try XCTUnwrap(hooks["preToolUse"] as? [[String: Any]]) + XCTAssertTrue( + preToolUse.contains { + ($0["command"] as? String)?.contains("hooks feed --source kiro --event preToolUse") == true + && ($0["timeout_ms"] as? Int) == 120_000 + && (($0["command"] as? String)?.contains("|| echo '{}'") == false) + && (($0["command"] as? String)?.contains("status=$?") == true) + && (($0["command"] as? String)?.contains("exit 2") == true) + }, + "Expected Kiro preToolUse feed hook to preserve cmux's exit status for deny decisions, saw \(preToolUse)" + ) + XCTAssertNotNil(hooks["agentSpawn"]) + XCTAssertNotNil(hooks["userPromptSubmit"]) + XCTAssertNotNil(hooks["postToolUse"]) + XCTAssertNotNil(hooks["stop"]) + } + + func testKiroFeedDenyUsesPreToolUseExitCodeTwo() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("kiro-feed-deny") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-kiro-feed-deny-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "33333333-3333-3333-3333-333333333333" + let surfaceId = "44444444-4444-4444-4444-444444444444" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return self.malformedRequestResponse(raw: line) + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + XCTAssertEqual(method, "feed.push") + return self.v2Response( + id: id, + ok: true, + result: [ + "status": "resolved", + "decision": [ + "kind": "permission", + "mode": "deny", + ], + ] + ) + } + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "kiro", "--event", "preToolUse"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_KIRO_PID": "525252", + "CMUX_KIRO_NOTIFICATION_LEVEL": "standard", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"hook_event_name":"preToolUse","session_id":"kiro-session-123","cwd":"\#(root.path)","tool_name":"fs_write","tool_input":{"operations":[{"mode":"Line","path":"\#(root.appendingPathComponent("README.md").path)"}]}}"#, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 2, result.stderr) + XCTAssertTrue(result.stderr.contains("User denied permission via cmux Feed."), result.stderr) + + let feedEvents = state.commands.compactMap { command -> [String: Any]? in + guard let payload = self.jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any], + let event = params["event"] as? [String: Any] else { + return nil + } + return event + } + XCTAssertEqual(feedEvents.count, 1, "Expected one Kiro Feed event, saw \(state.commands)") + XCTAssertEqual(feedEvents.first?["hook_event_name"] as? String, "PermissionRequest") + XCTAssertEqual(feedEvents.first?["_source"] as? String, "kiro") + XCTAssertEqual(feedEvents.first?["_ppid"] as? Int, 525252) + } + + /// The Feed permission modes that allow a tool (`once` / `always` / `all` + /// / `bypass`, the WorkstreamPermissionMode raw values) must exit 0 so + /// Kiro proceeds; an unrecognized/malformed mode must fail closed with + /// exit 2 rather than silently allowing the tool. + func testKiroFeedAllowModesProceedAndUnknownModeDenies() throws { + func runKiroDecision(mode: String) throws -> ProcessRunResult { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("kiro-feed-mode") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-kiro-feed-mode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), let id = payload["id"] as? String else { + return self.malformedRequestResponse(raw: line) + } + return self.v2Response( + id: id, + ok: true, + result: [ + "status": "resolved", + "decision": ["kind": "permission", "mode": mode], + ] + ) + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "kiro", "--event", "preToolUse"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": "33333333-3333-3333-3333-333333333333", + "CMUX_SURFACE_ID": "44444444-4444-4444-4444-444444444444", + "CMUX_KIRO_PID": "525252", + "CMUX_KIRO_NOTIFICATION_LEVEL": "standard", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"hook_event_name":"preToolUse","session_id":"kiro-session-mode","cwd":"\#(root.path)","tool_name":"fs_write","tool_input":{"operations":[{"mode":"Line","path":"\#(root.appendingPathComponent("README.md").path)"}]}}"#, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + for mode in ["once", "always", "all", "bypass"] { + let result = try runKiroDecision(mode: mode) + XCTAssertFalse(result.timedOut, "\(mode): \(result.stderr)") + XCTAssertEqual(result.status, 0, "mode \(mode) should allow (exit 0): \(result.stderr)") + XCTAssertEqual(result.stdout, "{}\n", "mode \(mode) should print {}") + } + + let unknown = try runKiroDecision(mode: "totally-bogus-mode") + XCTAssertFalse(unknown.timedOut, unknown.stderr) + XCTAssertEqual(unknown.status, 2, "unrecognized mode must fail closed (exit 2): \(unknown.stderr)") + XCTAssertTrue(unknown.stderr.contains("unrecognized"), unknown.stderr) + } + + /// At the default `standard` notification level, Kiro read-only tool + /// events (`fs_read`) are suppressed (no Feed telemetry) while mutating + /// tools (`fs_write`) still emit. Guards that suppression keys off the + /// classified wire name (`PostToolUse`) rather than the raw camelCase hook + /// event — i.e. the suppression actually triggers for real Kiro events. + func testKiroStandardLevelSuppressesReadOnlyToolFeedEvents() throws { + func feedPushCount(forTool tool: String) throws -> Int { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("kiro-suppress") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-kiro-suppress-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), let id = payload["id"] as? String else { + return self.malformedRequestResponse(raw: line) + } + return self.v2Response(id: id, ok: true, result: ["status": "acknowledged"]) + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "kiro", "--event", "postToolUse"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": "33333333-3333-3333-3333-333333333333", + "CMUX_SURFACE_ID": "44444444-4444-4444-4444-444444444444", + "CMUX_KIRO_PID": "525252", + "CMUX_KIRO_NOTIFICATION_LEVEL": "standard", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"hook_event_name":"postToolUse","session_id":"kiro-suppress","cwd":"\#(root.path)","tool_name":"\#(tool)"}"#, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, "\(tool): \(result.stderr)") + XCTAssertEqual(result.status, 0, "\(tool): \(result.stderr)") + XCTAssertEqual(result.stdout, "{}\n", "\(tool) stdout") + // A non-suppressed event sends one feed.push, so wait for the + // server to record it (generous timeout to avoid flaking on the + // socket/process round-trip under CI load). A suppressed event + // sends nothing, so this wait simply times out silently. + _ = XCTWaiter().wait(for: [serverHandled], timeout: 5) + return state.commands.filter { $0.contains("feed.push") }.count + } + + XCTAssertEqual(try feedPushCount(forTool: "fs_read"), 0, + "read-only kiro tool at standard level must be suppressed") + XCTAssertGreaterThan(try feedPushCount(forTool: "fs_write"), 0, + "mutating kiro tool at standard level must still emit telemetry") + } + + func testLowercaseGenericFeedToolsStayTelemetryOutsideKiro() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("generic-lowercase-feed-tool") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-generic-lowercase-feed-tool-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "33333333-3333-3333-3333-333333333333" + let surfaceId = "44444444-4444-4444-4444-444444444444" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return self.malformedRequestResponse(raw: line) + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + XCTAssertEqual(method, "feed.push") + return self.v2Response(id: id, ok: true, result: ["status": "acknowledged"]) + } + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "gemini", "--event", "PreToolUse"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_GEMINI_PID": "626262", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"hook_event_name":"PreToolUse","session_id":"gemini-session-123","cwd":"\#(root.path)","tool_name":"write","tool_input":{"path":"\#(root.appendingPathComponent("README.md").path)"}}"#, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + + let feedPushes = state.commands.compactMap { command -> [String: Any]? in + guard let payload = self.jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any] else { + return nil + } + return params + } + XCTAssertEqual(feedPushes.count, 1, "Expected one generic Feed event, saw \(state.commands)") + let event = try XCTUnwrap(feedPushes.first?["event"] as? [String: Any]) + let waitTimeout = try XCTUnwrap(feedPushes.first?["wait_timeout_seconds"] as? NSNumber) + XCTAssertEqual(event["hook_event_name"] as? String, "PreToolUse") + XCTAssertEqual(event["_source"] as? String, "gemini") + XCTAssertEqual(event["tool_name"] as? String, "write") + XCTAssertEqual(event["_ppid"] as? Int, 626262) + XCTAssertEqual(waitTimeout.doubleValue, 0) + } + func testAntigravityFeedHookMissingSessionIdUsesStableFallback() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("antigravity-feed-stable-session") @@ -2752,6 +3104,8 @@ extension CLINotifyProcessIntegrationRegressionTests { switch method { case "surface.list": return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "surface.resume.set": + return self.v2Response(id: id, ok: true, result: ["ok": true]) case "feed.push": return self.v2Response(id: id, ok: true, result: [:]) default: @@ -2804,5 +3158,31 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(launchCommand["arguments"] as? [String], scenario.expectedArguments) XCTAssertEqual(launchCommand["workingDirectory"] as? String, workspace.path) XCTAssertEqual(launchCommand["environment"] as? [String: String], scenario.expectedEnvironment) + + if scenario.agent == "kiro" { + let resumeSetRequests = state.commands.compactMap { command -> [String: Any]? in + guard let payload = self.jsonObject(command), + payload["method"] as? String == "surface.resume.set" else { + return nil + } + return payload["params"] as? [String: Any] + } + XCTAssertEqual(resumeSetRequests.count, 1, state.commands.joined(separator: "\n")) + let params = try XCTUnwrap(resumeSetRequests.first) + XCTAssertEqual(params["kind"] as? String, "kiro") + XCTAssertEqual(params["checkpoint_id"] as? String, scenario.sessionId) + XCTAssertEqual(params["auto_resume"] as? Bool, true) + XCTAssertEqual( + params["command"] as? String, + "cd '\(workspace.path)' && '\(scenario.executable)' 'chat' '--resume-id' '\(scenario.sessionId)' '--agent' 'cmux' '--trust-tools' 'fs_read,fs_write'" + ) + XCTAssertEqual(params["environment"] as? [String: String], scenario.expectedEnvironment) + XCTAssertFalse( + state.commands.contains { command in + self.jsonObject(command)?["method"] as? String == "surface.resume.clear" + }, + "Kiro should publish a resume binding instead of clearing it: \(state.commands)" + ) + } } } diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 01675158c8df..5515a3b62479 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -118,4 +118,40 @@ struct FeedEventClassificationTests { @Test func unknownSourceUnknownEventIsSafe() { #expect(classify("totally-new-agent", "some_future_event", tool: "Bash").actionable == false) } + + // MARK: Kiro (camelCase events, no dedicated approval event) + + /// Kiro has no dedicated approval event, so its `preToolUse` escalates + /// side-effecting tools to an approval — resolved against Kiro's internal + /// tool names (`fs_write`, `execute_bash`, `use_aws`). Read-only `fs_read` + /// stays telemetry. Registering kiro is required because its camelCase + /// event names are absent from the generic table and would otherwise + /// resolve to `.unknown` (non-actionable), silently dropping approvals. + @Test func kiroPreToolUseEscalatesSideEffectingTools() { + #expect(classify("kiro", "preToolUse", tool: "fs_write").name == "PermissionRequest") + #expect(classify("kiro", "preToolUse", tool: "fs_write").actionable == true) + #expect(classify("kiro", "preToolUse", tool: "execute_bash").actionable == true) + #expect(classify("kiro", "preToolUse", tool: "use_aws").actionable == true) + #expect(classify("kiro", "preToolUse", tool: "fs_read").actionable == false) + #expect(classify("kiro", "preToolUse", tool: "fs_read").name == "PreToolUse") + } + + /// Kiro lifecycle + post-tool events are telemetry only and map to the + /// right wire names despite their camelCase spelling. + @Test func kiroLifecycleEventsClassifyCorrectly() { + #expect(classify("kiro", "postToolUse", tool: "fs_write").name == "PostToolUse") + #expect(classify("kiro", "postToolUse", tool: "fs_write").actionable == false) + #expect(classify("kiro", "agentSpawn").name == "SessionStart") + #expect(classify("kiro", "userPromptSubmit").name == "UserPromptSubmit") + #expect(classify("kiro", "stop").name == "Stop") + } + + /// Kiro's case-insensitive tool aliases must stay scoped to kiro: another + /// agent emitting a lowercase `fs_write` / `write` must NOT be escalated + /// (guards the resolved "lowercase tools broaden Feed prompts" fix). + @Test func kiroToolAliasesDoNotLeakToOtherAgents() { + #expect(classify("gemini", "PreToolUse", tool: "fs_write").actionable == false) + #expect(classify("gemini", "PreToolUse", tool: "write").actionable == false) + #expect(classify("gemini", "PreToolUse", tool: "execute_bash").actionable == false) + } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 4d01e943f16e..d8e748f8b340 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1208,6 +1208,41 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) } + func testKiroIntegrationDefaultsToEnabledWithStandardNotificationsWhenUnset() { + let suiteName = "cmux.tests.kiro-hooks.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.removeObject(forKey: KiroIntegrationSettings.hooksEnabledKey) + defaults.removeObject(forKey: KiroIntegrationSettings.notificationLevelKey) + XCTAssertTrue(KiroIntegrationSettings.hooksEnabled(defaults: defaults)) + XCTAssertEqual(KiroIntegrationSettings.notificationLevel(defaults: defaults), .standard) + } + + func testKiroIntegrationRespectsStoredPreferenceAndNotificationLevel() { + let suiteName = "cmux.tests.kiro-hooks.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set(false, forKey: KiroIntegrationSettings.hooksEnabledKey) + defaults.set(KiroIntegrationSettings.NotificationLevel.verbose.rawValue, forKey: KiroIntegrationSettings.notificationLevelKey) + XCTAssertFalse(KiroIntegrationSettings.hooksEnabled(defaults: defaults)) + XCTAssertEqual(KiroIntegrationSettings.notificationLevel(defaults: defaults), .verbose) + + defaults.set("unsupported", forKey: KiroIntegrationSettings.notificationLevelKey) + XCTAssertEqual(KiroIntegrationSettings.notificationLevel(defaults: defaults), .standard) + } + func testSubagentNotificationSuppressionDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.subagent-notifications.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 1bf75daedca7..1037906068c0 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -275,6 +275,33 @@ extension SocketListenerAcceptPolicyTests { source: "process" ) ) + let kiro = SessionRestorableAgentSnapshot( + kind: .kiro, + sessionId: "kiro-session-123", + workingDirectory: "/tmp/kiro repo", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "kiro", + executablePath: "/Users/example/.cargo/bin/kiro-cli", + arguments: [ + "/Users/example/.cargo/bin/kiro-cli", + "chat", + "--agent", + "cmux", + "--resume-id", + "old-session", + "--trust-tools", + "fs_read,fs_write", + "initial prompt should not replay" + ], + workingDirectory: "/tmp/kiro repo", + environment: [ + "KIRO_HOME": "/tmp/kiro home", + "AWS_SECRET_ACCESS_KEY": "secret" + ], + capturedAt: 123, + source: "process" + ) + ) let grok = SessionRestorableAgentSnapshot( kind: .grok, sessionId: "grok-session-123", @@ -357,6 +384,10 @@ extension SocketListenerAcceptPolicyTests { qoder.resumeCommand, "{ cd -- '/tmp/qoder repo' 2>/dev/null || [ ! -d '/tmp/qoder repo' ]; } && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan'" ) + XCTAssertEqual( + kiro.resumeCommand, + "cd '/tmp/kiro repo' && 'env' 'KIRO_HOME=/tmp/kiro home' '/Users/example/.cargo/bin/kiro-cli' 'chat' '--resume-id' 'kiro-session-123' '--agent' 'cmux' '--trust-tools' 'fs_read,fs_write'" + ) XCTAssertEqual( grok.resumeCommand, "{ cd -- '/tmp/grok repo' 2>/dev/null || [ ! -d '/tmp/grok repo' ]; } && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto'" @@ -639,5 +670,29 @@ extension SocketListenerAcceptPolicyTests { "/tmp/qoder repo" ] ) + XCTAssertEqual( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "/Users/example/.cargo/bin/kiro-cli", + "chat", + "--agent", + "cmux", + "--resume-id", + "old-session", + "--trust-tools", + "fs_read,fs_write", + "initial prompt should not replay" + ], + launcher: "kiro", + fallbackKind: "kiro" + ), + [ + "/Users/example/.cargo/bin/kiro-cli", + "--agent", + "cmux", + "--trust-tools", + "fs_read,fs_write" + ] + ) } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 875fca0ea244..ee0f88b941db 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1386,6 +1386,15 @@ final class SessionPersistenceTests: XCTestCase { "gemini-2.5-pro", ] ), + ( + .kiro, + [ + "/usr/local/bin/kiro-cli", + "chat", + "--agent", + "cmux", + ] + ), ( .opencode, [ @@ -1594,6 +1603,8 @@ final class SessionPersistenceTests: XCTestCase { resolvedEnvironment = [:] case .gemini: resolvedEnvironment = ["GEMINI_CLI_HOME": "/tmp/gemini"] + case .kiro: + resolvedEnvironment = ["KIRO_HOME": "/tmp/kiro"] case .antigravity: resolvedEnvironment = ["GEMINI_CLI_HOME": "/tmp/gemini"] case .opencode: diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 7883bfdadc44..f55b6bd75f77 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -906,6 +906,67 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { ) } + func testSettingsFileStoreInvalidKiroNotificationLevelDoesNotSkipLaterAutomationKeys() throws { + let defaults = UserDefaults.standard + let previousKiroLevel = defaults.object(forKey: KiroIntegrationSettings.notificationLevelKey) + let previousPortBase = defaults.object(forKey: AutomationSettings.portBaseKey) + let previousPortRange = defaults.object(forKey: AutomationSettings.portRangeKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousKiroLevel { + defaults.set(previousKiroLevel, forKey: KiroIntegrationSettings.notificationLevelKey) + } else { + defaults.removeObject(forKey: KiroIntegrationSettings.notificationLevelKey) + } + if let previousPortBase { + defaults.set(previousPortBase, forKey: AutomationSettings.portBaseKey) + } else { + defaults.removeObject(forKey: AutomationSettings.portBaseKey) + } + if let previousPortRange { + defaults.set(previousPortRange, forKey: AutomationSettings.portRangeKey) + } else { + defaults.removeObject(forKey: AutomationSettings.portRangeKey) + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + defaults.removeObject(forKey: KiroIntegrationSettings.notificationLevelKey) + defaults.removeObject(forKey: AutomationSettings.portBaseKey) + defaults.removeObject(forKey: AutomationSettings.portRangeKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "automation": { + "kiroNotificationLevel": "loud", + "portBase": 32100, + "portRange": 42 + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertNil(defaults.object(forKey: KiroIntegrationSettings.notificationLevelKey)) + XCTAssertEqual(defaults.integer(forKey: AutomationSettings.portBaseKey), 32100) + XCTAssertEqual(defaults.integer(forKey: AutomationSettings.portRangeKey), 42) + } + func testSettingsFileStoreAppliesBrowserHiddenWebViewDiscardDelayAtMaximum() throws { let defaults = UserDefaults.standard let previousEnabled = defaults.object(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 5111f3a1a135..780fd2a541f3 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -11,7 +11,7 @@ cmux hooks setup --agent cmux hooks uninstall ``` -Supported agent names are `codex`, `grok`, `opencode`, `pi`, `amp`, `cursor`, `gemini`, `rovodev` (or `rovo`), `copilot`, `codebuddy`, `factory`, and `qoder`. `cmux hooks setup` skips agents whose binary is not on `PATH` and prints a summary. +Supported agent names are `codex`, `grok`, `opencode`, `pi`, `amp`, `cursor`, `gemini`, `kiro`, `rovodev` (or `rovo`), `copilot`, `codebuddy`, `factory`, and `qoder`. `cmux hooks setup` skips agents whose binary is not on `PATH` and prints a summary. ## Integrations @@ -25,6 +25,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `amp`, `cursor`, `g | Amp | `amp` | `~/.config/amp/plugins/cmux-session.ts` | `amp threads continue ` | none | | Cursor CLI | `cursor-agent` | `~/.cursor/hooks.json` | `cursor-agent --resume ` | beforeShellExecution | | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | +| Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | | Copilot | `copilot` | `~/.copilot/config.json` | `copilot --resume ` | PreToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | @@ -116,6 +117,7 @@ and browser state. Restored agent terminals stay idle until you resume them manu | Amp | none | `CMUX_AMP_HOOKS_DISABLED=1` | | Cursor CLI | none | `CMUX_CURSOR_HOOKS_DISABLED=1` | | Gemini | none | `CMUX_GEMINI_HOOKS_DISABLED=1` | +| Kiro CLI | `KIRO_HOME` | `CMUX_KIRO_HOOKS_DISABLED=1` | | Rovo Dev | none | `CMUX_ROVODEV_HOOKS_DISABLED=1` | | Copilot | `COPILOT_HOME` | `CMUX_COPILOT_HOOKS_DISABLED=1` | | CodeBuddy | `CODEBUDDY_CONFIG_DIR` | `CMUX_CODEBUDDY_HOOKS_DISABLED=1` | @@ -124,6 +126,10 @@ and browser state. Restored agent terminals stay idle until you resume them manu Pi uses Pi's extension system, not the legacy Pi hooks API. The installed extension is auto-discovered from `~/.pi/agent/extensions/` or `$PI_CODING_AGENT_DIR/extensions/`. +Kiro stores hooks inside agent configuration files. The cmux installer creates or updates a `cmux` agent config with lifecycle, tool, and completion hooks; merge the generated `hooks` block into another Kiro agent config if you want the same cmux notifications on that agent. + +Kiro Feed verbosity follows **Settings > Automation > Kiro Notification Level** or `automation.kiroNotificationLevel` in `cmux.json`. `minimal` keeps actionable approval cards only, `standard` also keeps mutating tool events, and `verbose` keeps every Kiro tool event. + ## Troubleshooting Run `cmux hooks install --yes` to reinstall one integration. Run `cmux hooks uninstall --yes` before editing generated files by hand. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 7891119da297..45de2a84fcad 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -881,6 +881,21 @@ "default": true, "description": "Enable cmux integration hooks for Gemini." }, + "kiroIntegration": { + "type": "boolean", + "default": true, + "description": "Enable cmux integration hooks for Kiro CLI." + }, + "kiroNotificationLevel": { + "type": "string", + "enum": [ + "minimal", + "standard", + "verbose" + ], + "default": "standard", + "description": "Controls how many Kiro tool events appear in Feed." + }, "portBase": { "type": "integer", "minimum": 1,