Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions CLI/CMUXCLI+AgentHookDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -117,6 +123,7 @@ extension CMUXCLI {
})
self.feedHookEvents = feedHookEvents
self.postInstallAction = postInstallAction
self.postInstallNote = postInstallNote
}
}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
}

Comment thread
austinywang marked this conversation as resolved.
private static let grokPinnedHookMarker = "cmux-grok-hook-v2"
Expand All @@ -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"
Comment thread
austinywang marked this conversation as resolved.
}

private static func usesPinnedHookDispatch(_ def: AgentHookDef) -> Bool {
def.name == "grok" || def.name == "antigravity"
}
Expand Down
66 changes: 64 additions & 2 deletions CLI/FeedEventClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,6 +104,7 @@ struct FeedEventClassifier {
/// semantics.
private static func wireMapping(
for semantic: FeedEventSemantic,
source: String,
toolName: String
) -> (String, Bool) {
switch semantic {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = [
"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
}
}
Loading
Loading