Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
3 changes: 1 addition & 2 deletions CLI/CMUXCLI+AgentHookCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,7 @@ extension CMUXCLI {
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
aliases: ["agy"],
sessionEndIsTurnBoundary: true,
feedHookEvents: ["PreToolUse", "PostToolUse"]
sessionEndIsTurnBoundary: true
),
AgentHookDef(
name: "rovodev", displayName: "Rovo Dev", statusKey: "rovodev",
Expand Down
10 changes: 5 additions & 5 deletions CLI/CMUXCLI+AgentHookDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import Foundation
extension CMUXCLI {
// MARK: - Generic agent hook system

// The client deadline must fire before the generated agent-hook timeout.
static let feedHookProcessTimeoutMilliseconds = 120_000
static let feedHookClientDeadlineSeconds = Double(feedHookProcessTimeoutMilliseconds) / 1_000 - 2
static let feedHookDecisionWaitSeconds = feedHookClientDeadlineSeconds - 3
/// Configuration for a hook-based agent integration.
struct AgentHookDef {
let name: String // CLI name: "cursor", "gemini", etc.
Expand Down Expand Up @@ -40,11 +44,7 @@ extension CMUXCLI {
/// separate `session-finalize` subcommand / ``AgentHookAction/sessionFinalize``
/// action, which performs the destructive cleanup this flag suppresses.
let sessionEndIsTurnBoundary: Bool
/// Feed-hook events. Each entry installs a second hook for
/// `agentEvent` that invokes `cmux hooks feed --source <name>`
/// with a 120s timeout so the socket reply wait doesn't trip the
/// agent's default hook timeout when the user takes time to
/// approve/deny a permission / plan / question.
/// Events that install a `cmux hooks feed --source <name>` bridge.
let feedHookEvents: [String]
let postInstallAction: PostInstallAction?
/// Optional CLI note printed after a successful install (or
Expand Down
56 changes: 35 additions & 21 deletions CLI/FeedEventClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ struct FeedEventClassifier {
///
/// - Parameters:
/// - source: The agent id that emitted the event (`claude`, `codex`,
/// `hermes-agent`, …). Unregistered sources use the generic table.
/// `hermes-agent`, …). Unregistered sources are telemetry-only.
/// - event: The agent's raw hook event name.
/// - toolName: The tool the event refers to, used only for the two
/// tool-dependent semantics.
Expand Down Expand Up @@ -83,14 +83,14 @@ struct FeedEventClassifier {
case unknown
}

/// Resolves the semantic for a `(source, event)` pair. A registered
/// source uses its own table (unmatched events fall to ``FeedEventSemantic/unknown``);
/// unregistered sources use the generic table.
/// Resolves the semantic for a `(source, event)` pair. Only registered
/// sources can opt in to blocking decisions. Unregistered sources retain
/// useful lifecycle names but always use telemetry-only semantics.
private static func feedEventSemantic(
source: String,
event: String
) -> FeedEventSemantic {
let table = feedEventSemanticRegistry[source] ?? genericFeedEventSemantics
let table = feedEventSemanticRegistry[source] ?? telemetryOnlyFeedEventSemantics
return table[event] ?? .unknown
}

Expand Down Expand Up @@ -160,14 +160,10 @@ struct FeedEventClassifier {
/// for that agent's `(event) -> semantic` mapping; events absent here
/// resolve to ``FeedEventSemantic/unknown``.
///
/// The key distinction the registry encodes: agents with a *dedicated*
/// approval event (Claude `PermissionRequest`, Codex `PermissionRequest`,
/// Hermes `pre_approval_request`) classify their pre-tool event as
/// ``FeedEventSemantic/toolStart`` (always telemetry). Agents whose only
/// signal is the pre-tool event (gemini, copilot, …, handled by
/// ``genericFeedEventSemantics``) use
/// ``FeedEventSemantic/toolStartMaybeApproval`` so side-effecting tools
/// still escalate. Conflating the two is the bug behind #4985.
/// Blocking is an explicit capability: a source must be registered with
/// ``FeedEventSemantic/toolStartMaybeApproval`` or
/// ``FeedEventSemantic/approvalRequest``. New and incompatible sources
/// fail neutral instead of inheriting a synchronous approval wait.
private static let feedEventSemanticRegistry: [String: [String: FeedEventSemantic]] = [
"claude": [
"PermissionRequest": .approvalRequest,
Expand Down Expand Up @@ -231,14 +227,14 @@ struct FeedEventClassifier {
"on_session_end": .sessionEnd,
"on_session_finalize": .sessionEnd,
],
// Gemini CLI consumes the generic PreToolUse decision schema and has
// no separate approval event, so it deliberately opts in to blocking.
"gemini": approvalCapableFeedEventSemantics,
// 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.
// its lowercase event names are absent from the shared tables.
"kiro": [
"preToolUse": .toolStartMaybeApproval,
"postToolUse": .toolEnd,
Expand All @@ -248,10 +244,10 @@ struct FeedEventClassifier {
],
]

/// Fallback table for agents without a dedicated entry in
/// ``feedEventSemanticRegistry``. These agents expose only a pre-tool
/// event, so it carries ``FeedEventSemantic/toolStartMaybeApproval``.
private static let genericFeedEventSemantics: [String: FeedEventSemantic] = [
/// Shared event spellings for sources that have a verified blocking
/// decision contract. Registration is required; this table is never the
/// fallback for an unknown source.
private static let approvalCapableFeedEventSemantics: [String: FeedEventSemantic] = [
"PreToolUse": .toolStartMaybeApproval,
"beforeShellExecution": .toolStartMaybeApproval,
"PermissionRequest": .approvalRequest,
Expand All @@ -267,6 +263,24 @@ struct FeedEventClassifier {
"Notification": .statusNotification,
]

/// Safe fallback for unregistered sources. Familiar event names preserve
/// Feed telemetry classification, but none can create a blocking request.
private static let telemetryOnlyFeedEventSemantics: [String: FeedEventSemantic] = [
"PreToolUse": .toolStart,
"beforeShellExecution": .toolStart,
"PermissionRequest": .toolStart,
"PostToolUse": .toolEnd,
"PreCompact": .preCompact,
"PostCompact": .postCompact,
"UserPromptSubmit": .promptSubmit,
"SessionStart": .sessionStart,
"SessionEnd": .sessionEnd,
"Stop": .response,
"SubagentStart": .subagentStart,
"SubagentStop": .subagentResponse,
"Notification": .statusNotification,
]

/// Tools that mutate state and deserve a user-visible approve/
/// deny prompt in Feed. Keyed on the canonical tool names Claude,
/// Codex, and similar agents emit. Read-only tools (Read, Grep,
Expand Down
Loading