From b4217afd4328a2c4163a1064cf3d731988ff31e0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 21:17:20 -0700 Subject: [PATCH 1/9] test: cover nested agent hook notifications --- ...ifyProcessIntegrationRegressionTests.swift | 119 ++++++++++++++++++ tests/test_claude_wrapper_hooks.py | 17 ++- tests/test_codex_feed_hooks.py | 29 ++++- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 7e74e8ebc14f..ad1f87ec87e5 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -305,6 +305,64 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(active["turnId"] as? String, "turn-2") } + func testNestedCodexStopDoesNotTriggerVisibleNotificationOrIdleStatus() throws { + let context = try makeClaudeHookContext(name: "codex-nested-stop") + defer { context.cleanup() } + let serverHandled = startAgentHookMockServer(context: context) + + let result = try runCodexStopHookInFakeAgentTree( + context: context, + nested: true, + standardInput: #"{"session_id":"nested-session","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"# + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, + "Expected nested Codex Stop to remain feed telemetry, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("notify_target") }, + "Nested Codex Stop should not notify, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("set_status codex ") }, + "Nested Codex Stop should not clobber visible Codex status, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("set_agent_pid codex.") }, + "Nested Codex Stop should not register the child PID as the visible owner, saw \(context.state.commands)" + ) + } + + func testDirectCodexStopStillTriggersVisibleNotification() throws { + let context = try makeClaudeHookContext(name: "codex-direct-stop") + defer { context.cleanup() } + let serverHandled = startAgentHookMockServer(context: context) + + let result = try runCodexStopHookInFakeAgentTree( + context: context, + nested: false, + standardInput: #"{"session_id":"direct-session","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"# + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, + "Direct Codex Stop should notify, saw \(context.state.commands)" + ) + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Direct Codex Stop should mark Codex idle, saw \(context.state.commands)" + ) + } + func testRightSidebarCLIForwardsV1SocketCommandsQuietly() throws { let cliPath = try bundledCLIPath() let cases: [(name: String, arguments: [String], expectedCommand: String, response: String, stdout: String)] = [ @@ -978,6 +1036,67 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } } + private func startAgentHookMockServer(context: ClaudeHookContext) -> XCTestExpectation { + startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = self.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + } + + private func runCodexStopHookInFakeAgentTree( + context: ClaudeHookContext, + nested: Bool, + standardInput: String + ) throws -> ProcessRunResult { + let fakeCodex = context.root.appendingPathComponent("codex", isDirectory: false) + try FileManager.default.createSymbolicLink(atPath: fakeCodex.path, withDestinationPath: "/bin/sh") + + let hookCommand = "\(shellQuoteForTest(context.cliPath)) hooks codex stop; status=$?; exit $status" + let outerCommand: String + if nested { + outerCommand = "\(shellQuoteForTest(fakeCodex.path)) -c \(shellQuoteForTest(hookCommand)); status=$?; exit $status" + } else { + outerCommand = hookCommand + } + + return runProcess( + executablePath: fakeCodex.path, + arguments: ["-c", outerCommand], + environment: [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": context.root.path, + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": context.root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: standardInput, + timeout: 5 + ) + } + + private func shellQuoteForTest(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + private func makeClaudeHookContext(name: String) throws -> ClaudeHookContext { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 06299272a324..0d10d12bf947 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -503,7 +503,7 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: failures, ) hooks = settings.get("hooks", {}) - expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PermissionRequest"} + expected_hooks = {"SessionStart", "Stop", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PermissionRequest"} expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) for hook_name, expected_subcommand in { "SessionStart": "session-start", @@ -551,6 +551,21 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: f"PermissionRequest hook should call hooks feed, got {permission_request_hooks}", failures, ) + subagent_stop_hooks = hooks.get("SubagentStop", [{}])[0].get("hooks", [{}]) + expect( + any( + h.get("command") == '"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}" hooks feed --source claude' + and h.get("async") is True + for h in subagent_stop_hooks + ), + f"SubagentStop hook should call hooks feed asynchronously, got {subagent_stop_hooks}", + failures, + ) + expect( + not any("hooks claude stop" in h.get("command", "") for h in subagent_stop_hooks), + f"SubagentStop hook should not call the visible stop hook, got {subagent_stop_hooks}", + failures, + ) # SessionEnd should have a short timeout (session is exiting) session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}]) expect( diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index 0766cc9cf522..f37d36e669d5 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -475,7 +475,7 @@ def test_codex_monitor_survives_transient_owner_rpc_timeout(cli_path: str, root: raise AssertionError(f"monitor exited before publishing transcript failure: {fake.frames!r}") -def run_feed_hook(cli_path: str, socket_path: Path, payload: dict, decision: dict | None) -> tuple[dict, dict]: +def run_feed_hook(cli_path: str, socket_path: Path, payload: dict, decision: dict | None, source: str = "codex") -> tuple[dict, dict]: env = os.environ.copy() env["CMUX_SURFACE_ID"] = FAKE_SURFACE_ID env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID @@ -488,7 +488,7 @@ def run_feed_hook(cli_path: str, socket_path: Path, payload: dict, decision: dic "hooks", "feed", "--source", - "codex", + source, "--event", payload.get("hook_event_name", ""), ], @@ -2038,6 +2038,28 @@ def test_codex_pre_tool_use_is_telemetry_not_actionable(cli_path: str, root: Pat raise AssertionError(f"wrong PreToolUse event: {frame!r}") +def test_claude_subagent_stop_stays_distinct_feed_telemetry(cli_path: str, root: Path) -> None: + stdout, frame = run_feed_hook( + cli_path, + root / "cmux-claude-subagent-stop.sock", + { + "session_id": "claude-session", + "cwd": "/tmp/project", + "hook_event_name": "SubagentStop", + }, + None, + source="claude", + ) + if stdout != {}: + raise AssertionError(f"SubagentStop telemetry should not emit a decision: {stdout!r}") + params = frame["params"] + if params.get("wait_timeout_seconds") != 0: + raise AssertionError(f"SubagentStop should not wait for Feed reply: {frame!r}") + event = params["event"] + if event.get("hook_event_name") != "SubagentStop" or event.get("_source") != "claude": + raise AssertionError(f"SubagentStop should stay distinct in Feed, got {event!r}") + + def main() -> int: try: cli_path = resolve_cmux_cli() @@ -2045,7 +2067,7 @@ def main() -> int: print(f"FAIL: {exc}") return 1 - with tempfile.TemporaryDirectory(prefix="cmux-codex-feed-hooks-") as td: + with tempfile.TemporaryDirectory(prefix="cmux-codex-feed-hooks-", dir="/tmp") as td: root = Path(td) try: test_codex_stop_reaps_transcript_monitor(cli_path, root) @@ -2083,6 +2105,7 @@ def main() -> int: test_permission_reply_uses_codex_permission_request_schema(cli_path, root) test_codex_persistent_permission_modes_degrade_to_once(cli_path, root) test_codex_pre_tool_use_is_telemetry_not_actionable(cli_path, root) + test_claude_subagent_stop_stays_distinct_feed_telemetry(cli_path, root) except Exception as exc: print(f"FAIL: {exc}") return 1 From 2718f79f22d713145f5a41e3189c42384568b1e0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 21:17:27 -0700 Subject: [PATCH 2/9] fix: suppress nested agent hook notifications --- CLI/cmux.swift | 234 +++++++++++++++++++++++++++++++++++-------- Resources/bin/claude | 4 +- 2 files changed, 197 insertions(+), 41 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2544b3d8d1a5..1235c7bd6060 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -711,6 +711,11 @@ private let codexHookWrapperProcessNames: Set = [ "env" ] +private enum HookAgentProcessKind: String { + case codex + case claude +} + enum CLIIDFormat: String { case refs case uuids @@ -16490,15 +16495,11 @@ struct CMUXCLI { client: client ) sendClaudeFeedTelemetry(workspaceId: workspaceId) - let claudePid: Int? = { - guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - let pid = Int(raw), - pid > 0 else { - return nil - } - return pid - }() + let claudePid = claudeAgentPID(from: ProcessInfo.processInfo.environment) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + env: ProcessInfo.processInfo.environment + ) let launchCommand = agentLaunchCommandFromEnvironment( ProcessInfo.processInfo.environment, fallbackPID: claudePid, @@ -16542,13 +16543,13 @@ struct CMUXCLI { workspaceId: workspaceId, telemetry: telemetry ) - if shouldRegisterPID, let claudePid { + if shouldRegisterPID, let claudePid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) } - if isClearSessionStart { + if isClearSessionStart, !suppressVisibleMutations { _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) try setClaudeStatus( client: client, @@ -16579,6 +16580,11 @@ struct CMUXCLI { workspaceId: workspaceId, client: client ) + let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + env: ProcessInfo.processInfo.environment + ) sendClaudeFeedTelemetry(workspaceId: workspaceId) guard shouldApplyClaudeHookVisibleMutation( @@ -16592,6 +16598,12 @@ struct CMUXCLI { return } + guard !suppressVisibleMutations else { + telemetry.breadcrumb("claude-hook.stop.nested-suppressed") + print("OK") + return + } + // Update session with transcript summary and send completion notification. let completion = summarizeClaudeHookStop( parsedInput: parsedInput, @@ -16652,6 +16664,11 @@ struct CMUXCLI { workspaceId: workspaceId, client: client ) + let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + env: ProcessInfo.processInfo.environment + ) sendClaudeFeedTelemetry(workspaceId: workspaceId) let shouldApplyPromptSubmit = shouldApplyClaudeHookVisibleMutation( @@ -16671,6 +16688,11 @@ struct CMUXCLI { print("OK") return } + guard !suppressVisibleMutations else { + telemetry.breadcrumb("claude-hook.prompt-submit.nested-suppressed") + print("OK") + return + } if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, @@ -16704,6 +16726,11 @@ struct CMUXCLI { fallback: workspaceArg, client: client ) + let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + env: ProcessInfo.processInfo.environment + ) sendClaudeFeedTelemetry(workspaceId: workspaceId) guard shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, @@ -16715,6 +16742,11 @@ struct CMUXCLI { print("OK") return } + guard !suppressVisibleMutations else { + telemetry.breadcrumb("claude-hook.notification.nested-suppressed") + print("OK") + return + } if let mappedSession, let savedBody = mappedSession.lastBody, !savedBody.isEmpty, summary.body.contains("needs your attention") || summary.body.contains("needs your input") { @@ -16797,7 +16829,12 @@ struct CMUXCLI { workspaceId: workspaceId, telemetry: telemetry ) - if shouldClearVisibleState { + let claudePid = consumedSession.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + env: ProcessInfo.processInfo.environment + ) + if shouldClearVisibleState, !suppressVisibleMutations { _ = try? sendV1Command( "clear_agent_pid claude_code --tab=\(workspaceId)\(socketPanelOption(consumedSession.surfaceId)) --clear-status", client: client @@ -16833,7 +16870,11 @@ struct CMUXCLI { client: client ) sendClaudeFeedTelemetry(workspaceId: workspaceId) - let claudePid = mappedSession?.pid + let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: claudePid, + env: ProcessInfo.processInfo.environment + ) guard shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, parsedInput: parsedInput, @@ -16844,6 +16885,11 @@ struct CMUXCLI { print("OK") return } + guard !suppressVisibleMutations else { + telemetry.breadcrumb("claude-hook.pre-tool-use.nested-suppressed") + print("OK") + return + } // AskUserQuestion means Claude is about to ask the user something. // Save question text in session so the Notification handler can use it @@ -18854,6 +18900,96 @@ struct CMUXCLI { return candidate > 1 ? Int(candidate) : nil } + private func claudeAgentPID(from env: [String: String]) -> Int? { + guard let raw = env["CMUX_CLAUDE_PID"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + let pid = Int(raw), + pid > 0 else { + return nil + } + return pid + } + + private func shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: Int?, + env: [String: String] + ) -> Bool { + if let override = normalizedHookValue(env["CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS"])?.lowercased(), + ["1", "true", "yes"].contains(override) { + return true + } + + guard let currentAgentPID, currentAgentPID > 1 else { + return false + } + + var candidate = pid_t(currentAgentPID) + var agentProcessCount = 0 + var remainingAncestors = 32 + while candidate > 1, remainingAncestors > 0 { + if nativeAgentProcessKind(for: candidate) != nil { + agentProcessCount += 1 + if agentProcessCount >= 2 { + return true + } + } + let next = parentPID(of: candidate) + guard next > 1, next != candidate else { + break + } + candidate = next + remainingAncestors -= 1 + } + return false + } + + private func nativeAgentProcessKind(for pid: pid_t) -> HookAgentProcessKind? { + let name = processName(for: pid) + let arguments = processArguments(for: pid) ?? [] + return Self.nativeAgentProcessKind(processName: name, arguments: arguments) + } + + private static func nativeAgentProcessKind( + processName: String?, + arguments: [String] + ) -> HookAgentProcessKind? { + let nameBase = agentProcessBasename(processName) + let executableBase = agentProcessBasename(arguments.first) + + // Codex's npm/bun launcher leaves a node process above the native + // Codex binary. That wrapper is part of the same launch, not a + // parent agent, so only native Codex executables count. Claude Code + // can run as a node script, so keep that as an agent process. + if nameBase == "node" || nameBase == "bun" || executableBase == "node" || executableBase == "bun" { + if arguments.dropFirst().contains(where: { argument in + let lowered = argument.lowercased() + return agentProcessBasename(argument) == "claude" + || lowered.contains("/.claude/") + || lowered.contains("/claude/versions/") + }) { + return .claude + } + return nil + } + + let executable = arguments.first?.lowercased() ?? "" + if nameBase == "codex" || executableBase == "codex" || executable.contains("/codex/codex") { + return .codex + } + if nameBase == "claude" || executableBase == "claude" || executable.contains("/claude/versions/") { + return .claude + } + return nil + } + + private static func agentProcessBasename(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + return URL(fileURLWithPath: value).lastPathComponent.lowercased() + } + private func parentPID(of pid: pid_t) -> pid_t { var info = kinfo_proc() var size = MemoryLayout.size @@ -20857,6 +20993,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(preferred: nil, fallback: surfaceArg, workspaceId: workspaceId, client: client) sendAgentFeedTelemetry(workspaceId: workspaceId) let pid = inferredCodexAgentPID() + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, @@ -20873,7 +21010,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { launchCommand: launchCommand ) } - if let pid { + if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client @@ -20891,6 +21028,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) sendAgentFeedTelemetry(workspaceId: workspaceId) let pid = mapped?.pid ?? inferredCodexAgentPID() + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, @@ -20907,18 +21045,22 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { launchCommand: launchCommand ) } - if let pid { + if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) } - _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) - _ = try sendV1Command( - "set_status \(def.statusKey) Running --icon=bolt.fill --color=#4C8DFF --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client - ) - if def.name == "codex", !sessionId.isEmpty { + if !suppressVisibleMutations { + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + _ = try sendV1Command( + "set_status \(def.statusKey) Running --icon=bolt.fill --color=#4C8DFF --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } else { + telemetry.breadcrumb("\(def.name)-hook.prompt-submit.nested-suppressed") + } + if def.name == "codex", !sessionId.isEmpty, !suppressVisibleMutations { let leasePath = createCodexMonitorLease( sessionId: sessionId, turnId: input.turnId, @@ -20965,6 +21107,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(preferred: surfaceArg, fallback: mapped?.surfaceId, workspaceId: workspaceId, client: client) sendAgentFeedTelemetry(workspaceId: workspaceId) let pid = mapped?.pid ?? inferredCodexAgentPID() + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) let codexFailure: CodexHookFailureSummary? if def.name == "codex" { codexFailure = summarizeCodexHookFailure(parsedInput: input, sessionId: sessionId, env: env) @@ -21014,26 +21157,30 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { lastSubtitle: subtitle, lastBody: body) } - if let pid { + if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) } - let payload = notificationPayload(title: def.displayName, subtitle: subtitle, body: body) - _ = try? sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) - if let codexFailure { - _ = try? sendV1Command( - "set_status \(def.statusKey) \(codexFailure.statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client - ) + if suppressVisibleMutations { + telemetry.breadcrumb("\(def.name)-hook.stop.nested-suppressed") } else { - let idleStatus = String(localized: "agent.codex.status.idle", defaultValue: "Idle") - _ = try? sendV1Command( - "set_status \(def.statusKey) \(idleStatus) --icon=pause.circle.fill --color=#8E8E93 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client - ) + let payload = notificationPayload(title: def.displayName, subtitle: subtitle, body: body) + _ = try? sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) + if let codexFailure { + _ = try? sendV1Command( + "set_status \(def.statusKey) \(codexFailure.statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } else { + let idleStatus = String(localized: "agent.codex.status.idle", defaultValue: "Idle") + _ = try? sendV1Command( + "set_status \(def.statusKey) \(idleStatus) --icon=pause.circle.fill --color=#8E8E93 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } } } catch { if shouldIgnoreClaudeHookTeardownError(error) { @@ -21049,10 +21196,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } if let mapped = try? store.consume(sessionId: sessionId, workspaceId: nil, surfaceId: nil) { sendAgentFeedTelemetry(workspaceId: mapped.workspaceId) - _ = try? sendV1Command( - "clear_agent_pid \(pidKey) --tab=\(mapped.workspaceId)\(socketPanelOption(mapped.surfaceId)) --clear-status", - client: client - ) + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: mapped.pid, env: env) + if !suppressVisibleMutations { + _ = try? sendV1Command( + "clear_agent_pid \(pidKey) --tab=\(mapped.workspaceId)\(socketPanelOption(mapped.surfaceId)) --clear-status", + client: client + ) + } } case .noop: @@ -23106,8 +23256,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return ("SessionStart", false) case "SessionEnd": return ("SessionEnd", false) - case "Stop", "SubagentStop": + case "Stop": return ("Stop", false) + case "SubagentStop": + return ("SubagentStop", false) case "Notification": return ("Notification", false) default: @@ -23170,8 +23322,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return ("SessionStart", false) case "SessionEnd": return ("SessionEnd", false) - case "Stop", "SubagentStop": + case "Stop": return ("Stop", false) + case "SubagentStop": + return ("SubagentStop", false) case "Notification": return ("Notification", false) default: diff --git a/Resources/bin/claude b/Resources/bin/claude index 44431169f0ec..7bfa0550450d 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -466,7 +466,9 @@ fi # This is Claude Code's native blocking decision hook. It covers # permissions, ExitPlanMode, and AskUserQuestion without using # PreToolUse denial as a side channel. -HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' +# - SubagentStop: feed telemetry only. It must not run the visible Stop +# hook because a subagent finishing should not notify like the parent. +HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' if [[ "$SKIP_SESSION_ID" == true ]]; then exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" From 8d3ef51d1ec4b08314507a166f95f0bfd4ecfeac Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 22:00:24 -0700 Subject: [PATCH 3/9] feat: add subagent notification suppression setting --- CLI/cmux.swift | 56 ++++++++- Resources/Localizable.xcstrings | 115 ++++++++++++++++++ Sources/CmuxSettingsJSONPathSupport.swift | 1 + .../CommandPaletteSettingsToggle.swift | 23 ++++ Sources/GhosttyTerminalView.swift | 4 + ...rdShortcutSettingsFileStore+Template.swift | 1 + .../KeyboardShortcutSettingsFileStore.swift | 3 + Sources/SettingsNavigation.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + Sources/cmuxApp.swift | 35 ++++++ ...ifyProcessIntegrationRegressionTests.swift | 54 ++++++-- .../CommandPaletteSettingsToggleTests.swift | 24 ++++ cmuxTests/GhosttyConfigTests.swift | 31 +++++ cmuxTests/WorkspaceUnitTests.swift | 46 +++++++ web/app/[locale]/docs/configuration/page.tsx | 4 + web/data/cmux.schema.json | 5 + 16 files changed, 392 insertions(+), 13 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1235c7bd6060..796e919a41a6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -716,6 +716,9 @@ private enum HookAgentProcessKind: String { case claude } +private let suppressSubagentNotificationsDefaultsKey = "suppressSubagentNotifications" +private let suppressSubagentNotificationsEnvironmentKey = "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS" + enum CLIIDFormat: String { case refs case uuids @@ -18919,6 +18922,10 @@ struct CMUXCLI { return true } + guard subagentNotificationSuppressionEnabled(env: env) else { + return false + } + guard let currentAgentPID, currentAgentPID > 1 else { return false } @@ -18943,10 +18950,55 @@ struct CMUXCLI { return false } + private func subagentNotificationSuppressionEnabled(env: [String: String]) -> Bool { + if let raw = normalizedHookValue(env[suppressSubagentNotificationsEnvironmentKey]), + let parsed = Self.parseHookBoolean(raw) { + return parsed + } + for defaults in appDefaultsCandidates(env: env) { + if defaults.object(forKey: suppressSubagentNotificationsDefaultsKey) != nil { + return defaults.bool(forKey: suppressSubagentNotificationsDefaultsKey) + } + } + return true + } + + private func appDefaultsCandidates(env: [String: String]) -> [UserDefaults] { + var candidates: [UserDefaults] = [] + if let bundleId = normalizedHookValue(env["CMUX_BUNDLE_ID"]), + let defaults = UserDefaults(suiteName: bundleId) { + candidates.append(defaults) + } + candidates.append(.standard) + return candidates + } + + private static func parseHookBoolean(_ rawValue: String) -> Bool? { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on", "enabled": + return true + case "0", "false", "no", "off", "disabled": + return false + default: + return nil + } + } + private func nativeAgentProcessKind(for pid: pid_t) -> HookAgentProcessKind? { let name = processName(for: pid) - let arguments = processArguments(for: pid) ?? [] - return Self.nativeAgentProcessKind(processName: name, arguments: arguments) + if let kind = Self.nativeAgentProcessKind(processName: name, arguments: []) { + return kind + } + + let nameBase = Self.agentProcessBasename(name) + guard nameBase == "node" || nameBase == "bun" else { + return nil + } + + return Self.nativeAgentProcessKind( + processName: name, + arguments: processArguments(for: pid) ?? [] + ) } private static func nativeAgentProcessKind( diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a797e3f0dfa3..e736e5de22fe 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -59941,6 +59941,98 @@ } } }, + "settings.automation.suppressSubagentNotifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Suppress Subagent Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブエージェント通知を抑制" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 에이전트 알림 억제" + } + } + } + }, + "settings.automation.suppressSubagentNotifications.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックプロセスの親子関係を使用します。ネストされたCodexまたはClaudeセッションでも完了通知を出したい場合は無効にしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "훅 프로세스의 상위 프로세스 관계를 사용합니다. 중첩된 Codex 또는 Claude 세션도 완료 알림을 보내야 한다면 비활성화하세요." + } + } + } + }, + "settings.automation.suppressSubagentNotifications.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Child agent completions notify like top-level agents." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "子エージェントの完了もトップレベルのエージェントと同様に通知します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 에이전트 완료도 최상위 에이전트처럼 알립니다." + } + } + } + }, + "settings.automation.suppressSubagentNotifications.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Child agent completions stay in Feed without notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "子エージェントの完了は通知せずFeedにのみ残ります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 에이전트 완료는 알림 없이 Feed에만 남습니다." + } + } + } + }, "settings.automation.cursor": { "extractionState": "manual", "localizations": { @@ -77862,6 +77954,29 @@ } } }, + "settings.search.alias.setting.automation.subagent-notifications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "automation.suppressSubagentNotifications サブエージェント ネスト 子エージェント codex claude フック 通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "automation.suppressSubagentNotifications 하위 에이전트 중첩 자식 에이전트 codex claude 훅 알림" + } + } + } + }, "settings.search.alias.setting.automation.cursor": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index e5b78a44a84a..36b165f67396 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -101,6 +101,7 @@ extension CmuxSettingsFileStore { "automation.socketPassword", "automation.claudeCodeIntegration", "automation.claudeBinaryPath", + "automation.suppressSubagentNotifications", "automation.cursorIntegration", "automation.geminiIntegration", "automation.portBase", diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index c30733d73af5..7c38e9365fe1 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -591,6 +591,29 @@ enum CommandPaletteSettingsToggleCommands { defaultValue: ClaudeCodeIntegrationSettings.defaultHooksEnabled, defaultsKey: ClaudeCodeIntegrationSettings.hooksEnabledKey ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "suppressSubagentNotifications", + settingsKey: "automation.suppressSubagentNotifications", + title: { + String( + localized: "settings.automation.suppressSubagentNotifications", + defaultValue: "Suppress Subagent Notifications" + ) + }, + sectionTitle: automation, + keywords: [ + "automation.suppressSubagentNotifications", + "subagent", + "nested", + "agent", + "codex", + "claude", + "notifications", + "hooks", + ], + defaultValue: AgentSubagentNotificationSettings.defaultSuppressNotifications, + defaultsKey: AgentSubagentNotificationSettings.suppressNotificationsKey + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "cursorIntegration", settingsKey: "automation.cursorIntegration", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 669f7203b82b..f1f8d8d28405 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5518,6 +5518,10 @@ final class TerminalSurface: Identifiable, ObservableObject { if let customClaudePath = ClaudeCodeIntegrationSettings.customClaudePath() { setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath) } + setManagedEnvironmentValue( + AgentSubagentNotificationSettings.environmentKey, + AgentSubagentNotificationSettings.suppressNotifications() ? "1" : "0" + ) if !CursorIntegrationSettings.hooksEnabled() { setManagedEnvironmentValue("CMUX_CURSOR_HOOKS_DISABLED", "1") } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 32e82d9834cd..d21da0de886e 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -139,6 +139,7 @@ extension CmuxSettingsFileStore { "socketPassword": "", "claudeCodeIntegration": ClaudeCodeIntegrationSettings.defaultHooksEnabled, "claudeBinaryPath": "", + "suppressSubagentNotifications": AgentSubagentNotificationSettings.defaultSuppressNotifications, "cursorIntegration": CursorIntegrationSettings.defaultHooksEnabled, "geminiIntegration": GeminiIntegrationSettings.defaultHooksEnabled, "portBase": AutomationSettings.defaultPortBase, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 12039da44a6d..c40a0bb64691 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -694,6 +694,9 @@ final class CmuxSettingsFileStore { if let raw = jsonString(section["claudeBinaryPath"]) { snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.customClaudePathKey] = .string(raw) } + if let value = jsonBool(section["suppressSubagentNotifications"]) { + snapshot.managedUserDefaults[AgentSubagentNotificationSettings.suppressNotificationsKey] = .bool(value) + } if let value = jsonBool(section["cursorIntegration"]) { snapshot.managedUserDefaults[CursorIntegrationSettings.hooksEnabledKey] = .bool(value) } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index cb316936debb..f4f36a80e806 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -339,6 +339,7 @@ enum SettingsSearchIndex { setting(.automation, "socket-password", String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), "socket auth credential"), setting(.automation, "claude-code", String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), "agent hooks notifications"), setting(.automation, "claude-path", String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"), "custom claude executable"), + 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, "port-base", String(localized: "settings.automation.portBase", defaultValue: "Port Base"), "CMUX_PORT start"), @@ -432,6 +433,7 @@ enum SettingsSearchIndex { "automation.socketPassword": settingID(for: .automation, idSuffix: "socket-password"), "automation.claudeCodeIntegration": settingID(for: .automation, idSuffix: "claude-code"), "automation.claudeBinaryPath": settingID(for: .automation, idSuffix: "claude-path"), + "automation.suppressSubagentNotifications": settingID(for: .automation, idSuffix: "subagent-notifications"), "automation.cursorIntegration": settingID(for: .automation, idSuffix: "cursor"), "automation.geminiIntegration": settingID(for: .automation, idSuffix: "gemini"), "automation.portBase": settingID(for: .automation, idSuffix: "port-base"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 2ed7d4fc2138..451bf07ad54e 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -88,6 +88,7 @@ enum SettingsSearchAliasIndex { "automation:socket-password": localized("settings.search.alias.setting.automation.socket-password", defaultValue: "automation.socketPassword auth token credential secret password access key"), "automation:claude-code": localized("settings.search.alias.setting.automation.claude-code", defaultValue: "automation.claudeCodeIntegration claude code hooks agent integration status notifications"), "automation:claude-path": localized("settings.search.alias.setting.automation.claude-path", defaultValue: "automation.claudeBinaryPath claude binary executable path cli command custom"), + "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:port-base": localized("settings.search.alias.setting.automation.port-base", defaultValue: "automation.portBase cmux_port start first base env environment variable"), diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6844f3ac82e3..9802b74b4e16 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4681,6 +4681,19 @@ enum ClaudeCodeIntegrationSettings { } } +enum AgentSubagentNotificationSettings { + static let suppressNotificationsKey = "suppressSubagentNotifications" + static let defaultSuppressNotifications = true + static let environmentKey = "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS" + + static func suppressNotifications(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: suppressNotificationsKey) == nil { + return defaultSuppressNotifications + } + return defaults.bool(forKey: suppressNotificationsKey) + } +} + enum CursorIntegrationSettings { static let hooksEnabledKey = "cursorHooksEnabled" static let defaultHooksEnabled = true @@ -5012,6 +5025,8 @@ struct SettingsView: View { private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey) private var customClaudePath = "" + @AppStorage(AgentSubagentNotificationSettings.suppressNotificationsKey) + private var suppressSubagentNotifications = AgentSubagentNotificationSettings.defaultSuppressNotifications @AppStorage(CursorIntegrationSettings.hooksEnabledKey) private var cursorHooksEnabled = CursorIntegrationSettings.defaultHooksEnabled @AppStorage(GeminiIntegrationSettings.hooksEnabledKey) @@ -6591,6 +6606,25 @@ struct SettingsView: View { } } + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.suppressSubagentNotifications"), + String(localized: "settings.automation.suppressSubagentNotifications", defaultValue: "Suppress Subagent Notifications"), + subtitle: suppressSubagentNotifications + ? String(localized: "settings.automation.suppressSubagentNotifications.subtitleOn", defaultValue: "Child agent completions stay in Feed without notifications.") + : String(localized: "settings.automation.suppressSubagentNotifications.subtitleOff", defaultValue: "Child agent completions notify like top-level agents.") + ) { + Toggle("", isOn: $suppressSubagentNotifications) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsSuppressSubagentNotificationsToggle") + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.suppressSubagentNotifications.note", defaultValue: "Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications.")) + } + SettingsCard { SettingsCardRow( configurationReview: .json("automation.cursorIntegration"), @@ -7401,6 +7435,7 @@ struct SettingsView: View { socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled customClaudePath = "" + suppressSubagentNotifications = AgentSubagentNotificationSettings.defaultSuppressNotifications cursorHooksEnabled = CursorIntegrationSettings.defaultHooksEnabled geminiHooksEnabled = GeminiIntegrationSettings.defaultHooksEnabled sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index ad1f87ec87e5..7a17943278be 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -363,6 +363,34 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testNestedCodexStopCanNotifyWhenSubagentSuppressionIsDisabled() throws { + let context = try makeClaudeHookContext(name: "codex-nested-stop-disabled") + defer { context.cleanup() } + let serverHandled = startAgentHookMockServer(context: context) + + let result = try runCodexStopHookInFakeAgentTree( + context: context, + nested: true, + standardInput: #"{"session_id":"nested-session-disabled","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, + extraEnvironment: [ + "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS": "0", + ] + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, + "Nested Codex Stop should notify when suppression is disabled, saw \(context.state.commands)" + ) + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Nested Codex Stop should mark Codex idle when suppression is disabled, saw \(context.state.commands)" + ) + } + func testRightSidebarCLIForwardsV1SocketCommandsQuietly() throws { let cliPath = try bundledCLIPath() let cases: [(name: String, arguments: [String], expectedCommand: String, response: String, stdout: String)] = [ @@ -1058,7 +1086,8 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { private func runCodexStopHookInFakeAgentTree( context: ClaudeHookContext, nested: Bool, - standardInput: String + standardInput: String, + extraEnvironment: [String: String] = [:] ) throws -> ProcessRunResult { let fakeCodex = context.root.appendingPathComponent("codex", isDirectory: false) try FileManager.default.createSymbolicLink(atPath: fakeCodex.path, withDestinationPath: "/bin/sh") @@ -1071,19 +1100,22 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { outerCommand = hookCommand } + var environment = [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": context.root.path, + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": context.root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + environment.merge(extraEnvironment, uniquingKeysWith: { _, new in new }) + return runProcess( executablePath: fakeCodex.path, arguments: ["-c", outerCommand], - environment: [ - "HOME": context.root.path, - "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", - "PWD": context.root.path, - "CMUX_SOCKET_PATH": context.socketPath, - "CMUX_WORKSPACE_ID": context.workspaceId, - "CMUX_SURFACE_ID": context.surfaceId, - "CMUX_AGENT_HOOK_STATE_DIR": context.root.path, - "CMUX_CLI_SENTRY_DISABLED": "1", - ], + environment: environment, standardInput: standardInput, timeout: 5 ) diff --git a/cmuxTests/CommandPaletteSettingsToggleTests.swift b/cmuxTests/CommandPaletteSettingsToggleTests.swift index b9114c98257c..579c4dc40a86 100644 --- a/cmuxTests/CommandPaletteSettingsToggleTests.swift +++ b/cmuxTests/CommandPaletteSettingsToggleTests.swift @@ -142,6 +142,30 @@ final class CommandPaletteSettingsToggleTests: XCTestCase { ) } + func testSuppressSubagentNotificationsCommandTogglesDefaultAndReportsState() throws { + try withTemporaryDefaults { defaults in + let descriptor = try XCTUnwrap( + CommandPaletteSettingsToggleCommands.descriptor( + commandId: "palette.toggleSetting.suppressSubagentNotifications" + ) + ) + + let offState = String(localized: "command.toggleSetting.state.off", defaultValue: "Off") + let onState = String(localized: "command.toggleSetting.state.on", defaultValue: "On") + XCTAssertTrue(descriptor.isOn(defaults)) + XCTAssertTrue(descriptor.commandSubtitle(defaults: defaults).contains(onState)) + + descriptor.toggle(defaults: defaults, notificationCenter: NotificationCenter()) + + XCTAssertEqual( + defaults.object(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) as? Bool, + false + ) + XCTAssertFalse(descriptor.isOn(defaults)) + XCTAssertTrue(descriptor.commandSubtitle(defaults: defaults).contains(offState)) + } + } + func testOpenSidebarPortLinksCommandIsUnavailableWhenPortsAreHidden() throws { try withTemporaryDefaults { defaults in let descriptor = try XCTUnwrap( diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index ad9f79388e02..070560eb7201 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -667,6 +667,37 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) } + func testSubagentNotificationSuppressionDefaultsToEnabledWhenUnset() { + let suiteName = "cmux.tests.subagent-notifications.\(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: AgentSubagentNotificationSettings.suppressNotificationsKey) + XCTAssertTrue(AgentSubagentNotificationSettings.suppressNotifications(defaults: defaults)) + } + + func testSubagentNotificationSuppressionRespectsStoredPreference() { + let suiteName = "cmux.tests.subagent-notifications.\(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(true, forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + XCTAssertTrue(AgentSubagentNotificationSettings.suppressNotifications(defaults: defaults)) + + defaults.set(false, forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + XCTAssertFalse(AgentSubagentNotificationSettings.suppressNotifications(defaults: defaults)) + } + func testTelemetryDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 1b43746d6a8e..9ce99f177820 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -776,6 +776,52 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) } + func testSettingsFileStoreAppliesSubagentNotificationSuppression() throws { + let defaults = UserDefaults.standard + let previousValue = defaults.object(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + } else { + defaults.removeObject(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + defaults.removeObject(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + 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": { + "suppressSubagentNotifications": false + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + defaults.object(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) as? Bool, + false + ) + } + func testSettingsFileStoreAppliesBrowserHiddenWebViewDiscardDelayAtMaximum() throws { let defaults = UserDefaults.standard let previousEnabled = defaults.object(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 60077d6421ed..7e15bc07e35e 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -72,6 +72,10 @@ const settingsFileExample = `{ // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] // }, + // "automation": { + // "suppressSubagentNotifications": true + // }, + // "workspaceColors": { // "colors": { // "Red": "#C0392B", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index b2b0d26429be..9d8ec9f2e962 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -655,6 +655,11 @@ "default": "", "description": "Custom path to the claude binary." }, + "suppressSubagentNotifications": { + "type": "boolean", + "default": true, + "description": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + }, "cursorIntegration": { "type": "boolean", "default": true, From 8aea3124ebdb9c99dcdc35d96903d5630437ca3b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 22:50:44 -0700 Subject: [PATCH 4/9] fix: suppress in-process codex subagent notifications --- CLI/cmux.swift | 246 +++++++++++++++--- Sources/GhosttyTerminalView.swift | 25 +- ...ifyProcessIntegrationRegressionTests.swift | 169 ++++++++++++ cmuxTests/GhosttyConfigTests.swift | 31 +++ 4 files changed, 423 insertions(+), 48 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 796e919a41a6..b2a4374f36d2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -349,6 +349,7 @@ private struct ClaudeHookSessionRecord: Codable { var isRestorable: Bool? var lastSubtitle: String? var lastBody: String? + var activePromptDepth: Int? var startedAt: TimeInterval var updatedAt: TimeInterval } @@ -440,6 +441,88 @@ private final class ClaudeHookSessionStore { } } + @discardableResult + func recordPromptSubmit( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord? + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedState { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: nil, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + lastSubtitle: nil, + lastBody: nil, + now: now + ) + record.activePromptDepth = max(0, record.activePromptDepth ?? 0) + 1 + state.sessions[normalized] = record + return (record.activePromptDepth ?? 0) > 1 + } + } + + @discardableResult + func recordPromptStop( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + lastSubtitle: String?, + lastBody: String? + ) throws -> Bool { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return false } + return try withLockedState { state in + let now = Date().timeIntervalSince1970 + var record = makeSessionRecord( + state: state, + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + now: now + ) + let depthBeforeStop = max(0, record.activePromptDepth ?? 0) + update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: nil, + pid: pid, + launchCommand: launchCommand, + isRestorable: nil, + lastSubtitle: lastSubtitle, + lastBody: lastBody, + now: now + ) + let depthAfterStop = max(0, depthBeforeStop - 1) + record.activePromptDepth = depthAfterStop == 0 ? nil : depthAfterStop + state.sessions[normalized] = record + return depthBeforeStop > 1 + } + } + func upsert( sessionId: String, workspaceId: String, @@ -470,37 +553,23 @@ private final class ClaudeHookSessionStore { isRestorable: nil, lastSubtitle: nil, lastBody: nil, + activePromptDepth: nil, startedAt: now, updatedAt: now ) - record.workspaceId = workspaceId - if !surfaceId.isEmpty { - record.surfaceId = surfaceId - } - if let cwd = normalizeOptional(cwd) { - record.cwd = cwd - } - if let transcriptPath = normalizeOptional(transcriptPath) { - record.transcriptPath = transcriptPath - } - if let pid { - record.pid = pid - } - if let launchCommand, !launchCommand.arguments.isEmpty { - record.launchCommand = launchCommand - } - if let isRestorable { - // Preserve sticky true: a later isRestorable=false must not clear - // record.isRestorable=true from a transcript-backed event. - record.isRestorable = isRestorable || record.isRestorable == true - } - if let subtitle = normalizeOptional(lastSubtitle) { - record.lastSubtitle = subtitle - } - if let body = normalizeOptional(lastBody) { - record.lastBody = body - } - record.updatedAt = now + update( + &record, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + transcriptPath: transcriptPath, + pid: pid, + launchCommand: launchCommand, + isRestorable: isRestorable, + lastSubtitle: lastSubtitle, + lastBody: lastBody, + now: now + ) state.sessions[normalized] = record if markActive, let normalizedWorkspace = normalizeOptional(workspaceId) { state.activeSessionsByWorkspace[normalizedWorkspace] = ClaudeHookActiveSessionRecord( @@ -513,6 +582,73 @@ private final class ClaudeHookSessionStore { } } + private func makeSessionRecord( + state: ClaudeHookSessionStoreFile, + sessionId: String, + workspaceId: String, + surfaceId: String, + now: TimeInterval + ) -> ClaudeHookSessionRecord { + state.sessions[sessionId] ?? ClaudeHookSessionRecord( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: nil, + transcriptPath: nil, + pid: nil, + launchCommand: nil, + isRestorable: nil, + lastSubtitle: nil, + lastBody: nil, + activePromptDepth: nil, + startedAt: now, + updatedAt: now + ) + } + + private func update( + _ record: inout ClaudeHookSessionRecord, + workspaceId: String, + surfaceId: String, + cwd: String?, + transcriptPath: String?, + pid: Int?, + launchCommand: AgentHookLaunchCommandRecord?, + isRestorable: Bool?, + lastSubtitle: String?, + lastBody: String?, + now: TimeInterval + ) { + record.workspaceId = workspaceId + if !surfaceId.isEmpty { + record.surfaceId = surfaceId + } + if let cwd = normalizeOptional(cwd) { + record.cwd = cwd + } + if let transcriptPath = normalizeOptional(transcriptPath) { + record.transcriptPath = transcriptPath + } + if let pid { + record.pid = pid + } + if let launchCommand, !launchCommand.arguments.isEmpty { + record.launchCommand = launchCommand + } + if let isRestorable { + // Preserve sticky true: a later isRestorable=false must not clear + // record.isRestorable=true from a transcript-backed event. + record.isRestorable = isRestorable || record.isRestorable == true + } + if let subtitle = normalizeOptional(lastSubtitle) { + record.lastSubtitle = subtitle + } + if let body = normalizeOptional(lastBody) { + record.lastBody = body + } + record.updatedAt = now + } + /// Returns true when an event belongs to the workspace's active Claude session. /// It fails open when the event cannot identify a session/workspace, when no /// active session is registered yet, or when either side lacks a turnId so @@ -18915,6 +19051,7 @@ struct CMUXCLI { private func shouldSuppressNestedAgentVisibleMutations( currentAgentPID: Int?, + nestedPromptEvent: Bool = false, env: [String: String] ) -> Bool { if let override = normalizedHookValue(env["CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS"])?.lowercased(), @@ -18926,6 +19063,10 @@ struct CMUXCLI { return false } + if nestedPromptEvent { + return true + } + guard let currentAgentPID, currentAgentPID > 1 else { return false } @@ -21080,23 +21221,30 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) sendAgentFeedTelemetry(workspaceId: workspaceId) let pid = mapped?.pid ?? inferredCodexAgentPID() - let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) let launchCommand = agentLaunchCommandFromEnvironment( env, fallbackPID: pid, fallbackKind: def.name, cwd: hookCwd ?? mapped?.cwd ) + let nestedPromptSubmit: Bool if !sessionId.isEmpty { - try? store.upsert( + nestedPromptSubmit = (try? store.recordPromptSubmit( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: hookCwd ?? mapped?.cwd, pid: pid, launchCommand: launchCommand - ) + )) ?? false + } else { + nestedPromptSubmit = false } + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: pid, + nestedPromptEvent: nestedPromptSubmit, + env: env + ) if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", @@ -21159,7 +21307,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(preferred: surfaceArg, fallback: mapped?.surfaceId, workspaceId: workspaceId, client: client) sendAgentFeedTelemetry(workspaceId: workspaceId) let pid = mapped?.pid ?? inferredCodexAgentPID() - let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(currentAgentPID: pid, env: env) let codexFailure: CodexHookFailureSummary? if def.name == "codex" { codexFailure = summarizeCodexHookFailure(parsedInput: input, sessionId: sessionId, env: env) @@ -21197,18 +21344,33 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { def.displayName ) + let launchCommand = agentLaunchCommandFromEnvironment( + env, + fallbackPID: pid, + fallbackKind: def.name, + cwd: cwd + ) + let nestedPromptStop: Bool if !sessionId.isEmpty { - let launchCommand = agentLaunchCommandFromEnvironment( - env, - fallbackPID: pid, - fallbackKind: def.name, - cwd: cwd - ) - try? store.upsert(sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: cwd, pid: pid, - launchCommand: launchCommand, - lastSubtitle: subtitle, - lastBody: body) + let recordedNestedPromptStop = (try? store.recordPromptStop( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + pid: pid, + launchCommand: launchCommand, + lastSubtitle: subtitle, + lastBody: body + )) ?? false + nestedPromptStop = recordedNestedPromptStop + } else { + nestedPromptStop = false } + let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( + currentAgentPID: pid, + nestedPromptEvent: nestedPromptStop, + env: env + ) if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f1f8d8d28405..a08fa0d22929 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3803,6 +3803,19 @@ class GhosttyApp { return initializingRuntimeApp } + static func shouldSuppressAgentManagedDesktopNotification( + agentPIDs: [String: pid_t], + claudeHooksEnabled: Bool = ClaudeCodeIntegrationSettings.hooksEnabled(), + suppressSubagentNotifications: Bool = AgentSubagentNotificationSettings.suppressNotifications() + ) -> Bool { + if claudeHooksEnabled, + agentPIDs.keys.contains(where: { $0 == "claude_code" || $0.hasPrefix("claude_code.") }) { + return true + } + guard suppressSubagentNotifications else { return false } + return agentPIDs.keys.contains { $0 == "codex" || $0.hasPrefix("codex.") } + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || @@ -3821,12 +3834,12 @@ class GhosttyApp { let tabId = tabManager.selectedTabId else { return false } - // Suppress OSC notifications for workspaces with active Claude hook sessions. + // Suppress OSC notifications for workspaces with active hook sessions. // The hook system manages notifications with proper lifecycle tracking; // raw OSC notifications would duplicate or outlive the structured hooks. let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager - if ClaudeCodeIntegrationSettings.hooksEnabled(), let workspace = owningManager.tabs.first(where: { $0.id == tabId }), - workspace.agentPIDs["claude_code"] != nil { + if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), + Self.shouldSuppressAgentManagedDesktopNotification(agentPIDs: workspace.agentPIDs) { return true } let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" @@ -4087,10 +4100,10 @@ class GhosttyApp { let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" performOnMain { - // Suppress OSC notifications for workspaces with active Claude hook sessions. + // Suppress OSC notifications for workspaces with active hook sessions. let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager - if ClaudeCodeIntegrationSettings.hooksEnabled(), let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), - workspace.agentPIDs["claude_code"] != nil { + if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), + Self.shouldSuppressAgentManagedDesktopNotification(agentPIDs: workspace.agentPIDs) { return } let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 7a17943278be..2779ba10afe3 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -338,6 +338,83 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testInProcessCodexSubagentStopDoesNotNotifyUntilParentStop() throws { + let context = try makeClaudeHookContext(name: "codex-same-process-subagent") + defer { context.cleanup() } + + let sessionId = "same-process-session" + startAgentHookMockServerAccepting(context: context, connectionLimit: 32) + let parentPrompt = runCodexHook( + context: context, + subcommand: "prompt-submit", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"ask subagent"}"# + ) + XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("set_status codex Running ") }, + "Expected parent Codex prompt to mark Running, saw \(context.state.commands)" + ) + + let childPromptStart = context.state.commands.count + let childPrompt = runCodexHook( + context: context, + subcommand: "prompt-submit", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"Return exactly 1+1"}"# + ) + XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) + XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) + XCTAssertFalse( + childPromptCommands.contains { $0 == "clear_notifications --tab=\(context.workspaceId)" }, + "Nested in-process Codex prompt should not clear parent notifications, saw \(childPromptCommands)" + ) + XCTAssertFalse( + childPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, + "Nested in-process Codex prompt should not rewrite parent Running status, saw \(childPromptCommands)" + ) + + let childStopStart = context.state.commands.count + let childStop = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"1+1"}"# + ) + XCTAssertFalse(childStop.timedOut, childStop.stderr) + XCTAssertEqual(childStop.status, 0, childStop.stderr) + let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) + XCTAssertTrue( + childStopCommands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, + "Nested in-process Codex Stop should remain feed telemetry, saw \(childStopCommands)" + ) + XCTAssertFalse( + childStopCommands.contains { $0.hasPrefix("notify_target") }, + "Nested in-process Codex Stop should not notify, saw \(childStopCommands)" + ) + XCTAssertFalse( + childStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Nested in-process Codex Stop should not mark Codex idle, saw \(childStopCommands)" + ) + + let parentStopStart = context.state.commands.count + let parentStop = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"# + ) + XCTAssertFalse(parentStop.timedOut, parentStop.stderr) + XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) + XCTAssertTrue( + parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, + "Parent Codex Stop should still notify, saw \(parentStopCommands)" + ) + XCTAssertTrue( + parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Parent Codex Stop should mark Codex idle, saw \(parentStopCommands)" + ) + } + func testDirectCodexStopStillTriggersVisibleNotification() throws { let context = try makeClaudeHookContext(name: "codex-direct-stop") defer { context.cleanup() } @@ -1121,6 +1198,98 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + private func runCodexHook( + context: ClaudeHookContext, + subcommand: String, + standardInput: String, + extraEnvironment: [String: String] = [:] + ) -> ProcessRunResult { + var environment = [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": context.root.path, + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": context.root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + environment.merge(extraEnvironment, uniquingKeysWith: { _, new in new }) + + return runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "codex", subcommand], + environment: environment, + standardInput: standardInput, + timeout: 5 + ) + } + + private func startAgentHookMockServerAccepting( + context: ClaudeHookContext, + connectionLimit: Int + ) { + DispatchQueue.global(qos: .userInitiated).async { + var accepted = 0 + while accepted < connectionLimit { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(context.listenerFD, sockaddrPtr, &clientAddrLen) + } + } + if clientFD < 0 { + if errno == EINTR { continue } + return + } + accepted += 1 + + DispatchQueue.global(qos: .userInitiated).async { + defer { Darwin.close(clientFD) } + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. String { + guard let payload = jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "feed.push": + return v2Response(id: id, ok: true, result: [:]) + default: + return v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + private func shellQuoteForTest(_ value: String) -> String { let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" if value.range(of: safePattern, options: .regularExpression) != nil { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 070560eb7201..726435526b29 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -698,6 +698,37 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertFalse(AgentSubagentNotificationSettings.suppressNotifications(defaults: defaults)) } + func testAgentManagedDesktopNotificationSuppressionCoversCodexHookSessions() { + XCTAssertTrue( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: ["codex.same-process-session": pid_t(123)], + claudeHooksEnabled: false, + suppressSubagentNotifications: true + ) + ) + XCTAssertFalse( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: ["codex.same-process-session": pid_t(123)], + claudeHooksEnabled: false, + suppressSubagentNotifications: false + ) + ) + XCTAssertTrue( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: ["claude_code": pid_t(456)], + claudeHooksEnabled: true, + suppressSubagentNotifications: false + ) + ) + XCTAssertFalse( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: ["custom": pid_t(789)], + claudeHooksEnabled: true, + suppressSubagentNotifications: true + ) + ) + } + func testTelemetryDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { From de32f814ef340523953aacff62496bfa53889bcf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 19 May 2026 02:49:53 -0700 Subject: [PATCH 5/9] fix: mark codex teams subagents for suppression --- CLI/cmux.swift | 24 +++++++ ...ifyProcessIntegrationRegressionTests.swift | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b2a4374f36d2..6a57f0faf19c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -854,6 +854,10 @@ private enum HookAgentProcessKind: String { private let suppressSubagentNotificationsDefaultsKey = "suppressSubagentNotifications" private let suppressSubagentNotificationsEnvironmentKey = "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS" +private let managedSubagentEnvironmentKey = "CMUX_AGENT_MANAGED_SUBAGENT" +private let codexTeamsThreadEnvironmentKey = "CMUX_CODEX_TEAMS_THREAD_ID" +private let codexTeamsParentThreadEnvironmentKey = "CMUX_CODEX_TEAMS_PARENT_THREAD_ID" +private let codexTeamsDepthEnvironmentKey = "CMUX_CODEX_TEAMS_DEPTH" enum CLIIDFormat: String { case refs @@ -13937,6 +13941,8 @@ struct CMUXCLI { codexExecutable: codexExecutable, appServerURL: appServerURL, threadId: thread.id, + parentThreadId: spawn.parentThreadId, + depth: depth, launchPath: launchPath ) guard let startupScript = CMUXCLI.codexTeamsStartupScript(commandText: commandText, cwd: thread.cwd) else { @@ -14066,6 +14072,8 @@ struct CMUXCLI { codexExecutable: String, appServerURL: String, threadId: String, + parentThreadId: String, + depth: Int, launchPath: String? ) -> String { var parts = ["env"] @@ -14075,6 +14083,10 @@ struct CMUXCLI { } parts += [ "CMUX_CODEX_TEAMS_APP_SERVER_URL=\(appServerURL)", + "\(managedSubagentEnvironmentKey)=1", + "\(codexTeamsThreadEnvironmentKey)=\(threadId)", + "\(codexTeamsParentThreadEnvironmentKey)=\(parentThreadId)", + "\(codexTeamsDepthEnvironmentKey)=\(max(1, depth))", codexExecutable, "resume", "--remote", @@ -19067,6 +19079,10 @@ struct CMUXCLI { return true } + if managedSubagentVisibleMutationSuppressionRequested(env: env) { + return true + } + guard let currentAgentPID, currentAgentPID > 1 else { return false } @@ -19091,6 +19107,14 @@ struct CMUXCLI { return false } + private func managedSubagentVisibleMutationSuppressionRequested(env: [String: String]) -> Bool { + guard let raw = normalizedHookValue(env[managedSubagentEnvironmentKey]), + let parsed = Self.parseHookBoolean(raw) else { + return false + } + return parsed + } + private func subagentNotificationSuppressionEnabled(env: [String: String]) -> Bool { if let raw = normalizedHookValue(env[suppressSubagentNotificationsEnvironmentKey]), let parsed = Self.parseHookBoolean(raw) { diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 2779ba10afe3..6c64483ef14b 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -415,6 +415,71 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testManagedCodexTeamsSubagentEnvSuppressesVisibleCompletion() throws { + let context = try makeClaudeHookContext(name: "codex-teams-managed-subagent") + defer { context.cleanup() } + + startAgentHookMockServerAccepting(context: context, connectionLimit: 16) + let result = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"managed-child-session","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, + extraEnvironment: [ + "CMUX_AGENT_MANAGED_SUBAGENT": "1", + "CMUX_CODEX_TEAMS_THREAD_ID": "child-thread", + "CMUX_CODEX_TEAMS_PARENT_THREAD_ID": "root-thread", + "CMUX_CODEX_TEAMS_DEPTH": "1", + ] + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, + "Managed Codex Teams subagent Stop should remain feed telemetry, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("notify_target") }, + "Managed Codex Teams subagent Stop should not notify, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("set_status codex ") }, + "Managed Codex Teams subagent Stop should not clobber visible Codex status, saw \(context.state.commands)" + ) + } + + func testManagedCodexTeamsSubagentEnvCanNotifyWhenSuppressionIsDisabled() throws { + let context = try makeClaudeHookContext(name: "codex-teams-managed-subagent-disabled") + defer { context.cleanup() } + + startAgentHookMockServerAccepting(context: context, connectionLimit: 16) + let result = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"managed-child-session-disabled","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, + extraEnvironment: [ + "CMUX_AGENT_MANAGED_SUBAGENT": "1", + "CMUX_CODEX_TEAMS_THREAD_ID": "child-thread", + "CMUX_CODEX_TEAMS_PARENT_THREAD_ID": "root-thread", + "CMUX_CODEX_TEAMS_DEPTH": "1", + "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS": "0", + ] + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, + "Managed Codex Teams subagent Stop should notify when suppression is disabled, saw \(context.state.commands)" + ) + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Managed Codex Teams subagent Stop should mark Codex idle when suppression is disabled, saw \(context.state.commands)" + ) + } + func testDirectCodexStopStillTriggersVisibleNotification() throws { let context = try makeClaudeHookContext(name: "codex-direct-stop") defer { context.cleanup() } From 4326615490bfb42b8150182efd29aab98724f0f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 19 May 2026 03:04:41 -0700 Subject: [PATCH 6/9] test: cover codex transcript subagent suppression --- ...ifyProcessIntegrationRegressionTests.swift | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 6c64483ef14b..44b81c702169 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -415,6 +415,116 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testCodexSubagentRelayStopSuppressesNotificationButMarksIdle() throws { + let context = try makeClaudeHookContext(name: "codex-subagent-relay") + defer { context.cleanup() } + + let sessionId = "relay-session" + let turnId = "turn-relay" + let transcriptURL = context.root.appendingPathComponent("relay-session.jsonl") + try #""" +{"timestamp":"2026-05-19T09:53:17.830Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\#(turnId)"}} +{"timestamp":"2026-05-19T09:53:44.302Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n{\"agent_path\":\"child\",\"status\":{\"completed\":\"2\"}}\n"}]}} +{"timestamp":"2026-05-19T09:53:49.557Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\#(turnId)","last_agent_message":"Subagent returned: `2`."}} +"""#.write(to: transcriptURL, atomically: true, encoding: .utf8) + + startAgentHookMockServerAccepting(context: context, connectionLimit: 16) + let result = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"\#(turnId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"Subagent returned: `2`."}"# + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, + "Subagent relay Stop should remain feed telemetry, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("notify_target") }, + "Subagent relay Stop should not notify, saw \(context.state.commands)" + ) + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Subagent relay Stop should still mark Codex idle, saw \(context.state.commands)" + ) + } + + func testCodexSubagentRelayStopCanNotifyWhenSuppressionIsDisabled() throws { + let context = try makeClaudeHookContext(name: "codex-subagent-relay-disabled") + defer { context.cleanup() } + + let sessionId = "relay-session-disabled" + let turnId = "turn-relay-disabled" + let transcriptURL = context.root.appendingPathComponent("relay-session-disabled.jsonl") + try #""" +{"timestamp":"2026-05-19T09:53:17.830Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\#(turnId)"}} +{"timestamp":"2026-05-19T09:53:44.302Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n{\"agent_path\":\"child\",\"status\":{\"completed\":\"2\"}}\n"}]}} +{"timestamp":"2026-05-19T09:53:49.557Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\#(turnId)","last_agent_message":"Subagent returned: `2`."}} +"""#.write(to: transcriptURL, atomically: true, encoding: .utf8) + + startAgentHookMockServerAccepting(context: context, connectionLimit: 16) + let result = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"\#(turnId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"Subagent returned: `2`."}"#, + extraEnvironment: [ + "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS": "0", + ] + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, + "Subagent relay Stop should notify when suppression is disabled, saw \(context.state.commands)" + ) + XCTAssertTrue( + context.state.commands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, + "Subagent relay Stop should mark Codex idle when suppression is disabled, saw \(context.state.commands)" + ) + } + + func testCodexThreadSpawnSubagentTranscriptSuppressesVisibleCompletion() throws { + let context = try makeClaudeHookContext(name: "codex-thread-spawn-subagent") + defer { context.cleanup() } + + let sessionId = "child-session" + let turnId = "turn-child" + let transcriptURL = context.root.appendingPathComponent("child-session.jsonl") + try #""" +{"timestamp":"2026-05-19T09:53:39.055Z","type":"session_meta","payload":{"id":"\#(sessionId)","source":{"subagent":{"thread_spawn":{"parent_thread_id":"parent-session","depth":1,"agent_role":"default"}}},"thread_source":"subagent"}} +{"timestamp":"2026-05-19T09:53:40.000Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\#(turnId)"}} +{"timestamp":"2026-05-19T09:53:41.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"\#(turnId)","last_agent_message":"2"}} +"""#.write(to: transcriptURL, atomically: true, encoding: .utf8) + + startAgentHookMockServerAccepting(context: context, connectionLimit: 16) + let result = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"\#(turnId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"2"}"# + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + XCTAssertTrue( + context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, + "Thread-spawned Codex subagent Stop should remain feed telemetry, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("notify_target") }, + "Thread-spawned Codex subagent Stop should not notify, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("set_status codex ") }, + "Thread-spawned Codex subagent Stop should not clobber visible Codex status, saw \(context.state.commands)" + ) + } + func testManagedCodexTeamsSubagentEnvSuppressesVisibleCompletion() throws { let context = try makeClaudeHookContext(name: "codex-teams-managed-subagent") defer { context.cleanup() } From 0119b6f10f614f004ecc7f1bbd85ca440dbccd0f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 19 May 2026 03:04:46 -0700 Subject: [PATCH 7/9] fix: suppress codex subagent transcript notifications --- CLI/cmux.swift | 144 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6a57f0faf19c..7b62e5eca4f3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17856,6 +17856,11 @@ struct CMUXCLI { let question: String? } + private struct CodexTranscriptSubagentSignals { + var isSubagentSession = false + var hasSubagentNotificationRelay = false + } + private enum CodexTranscriptFailureReadResult { case unavailable case pending @@ -18141,6 +18146,117 @@ struct CMUXCLI { return candidate } + private func readCodexTranscriptSubagentSignals( + path: String, + turnId: String? + ) -> CodexTranscriptSubagentSignals { + guard let content = readTextFileTail(path: path, maxBytes: 512 * 1024) else { + return CodexTranscriptSubagentSignals() + } + + let normalizedTurnId = normalizedHookValue(turnId) + var signals = CodexTranscriptSubagentSignals() + var currentTurnId: String? + var currentTurnRelevant = normalizedTurnId == nil + + for line in content.split(separator: "\n", omittingEmptySubsequences: true) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let objectType = object["type"] as? String else { + continue + } + + if objectType == "session_meta", + let payload = object["payload"] as? [String: Any], + codexTranscriptSessionMetaIsSubagent(payload) { + signals.isSubagentSession = true + } + + if objectType == "turn_context", + let payload = object["payload"] as? [String: Any] { + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + currentTurnId = payloadTurnId + currentTurnRelevant = normalizedTurnId.map { $0 == payloadTurnId } ?? true + continue + } + + if objectType == "event_msg", + let payload = object["payload"] as? [String: Any], + let eventType = payload["type"] as? String { + switch eventType { + case "task_started": + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + currentTurnId = payloadTurnId + currentTurnRelevant = normalizedTurnId.map { $0 == payloadTurnId } ?? true + case "task_complete", "turn_complete": + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + if let normalizedTurnId { + if payloadTurnId == normalizedTurnId { + currentTurnRelevant = false + } + } else { + currentTurnRelevant = false + } + default: + break + } + continue + } + + guard currentTurnRelevant || normalizedTurnId == nil || currentTurnId == nil else { + continue + } + guard codexTranscriptLineHasSubagentNotification(object) else { + continue + } + signals.hasSubagentNotificationRelay = true + } + + return signals + } + + private func codexTranscriptSessionMetaIsSubagent(_ payload: [String: Any]) -> Bool { + if firstString(in: payload, keys: ["thread_source", "threadSource"])?.lowercased() == "subagent" { + return true + } + if let source = payload["source"] as? [String: Any], + source["subagent"] != nil { + return true + } + return false + } + + private func codexTranscriptLineHasSubagentNotification(_ object: [String: Any]) -> Bool { + guard (object["type"] as? String) == "response_item", + let payload = object["payload"] as? [String: Any], + (payload["type"] as? String) == "message", + (payload["role"] as? String) == "user" else { + return false + } + return codexTranscriptMessageText(payload) + .map { $0.contains("") } + ?? false + } + + private func codexTranscriptMessageText(_ payload: [String: Any]) -> String? { + if let content = payload["content"] as? String { + let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + guard let content = payload["content"] as? [[String: Any]] else { + return nil + } + let parts = content.compactMap { block -> String? in + let text = (block["text"] as? String) ?? (block["input_text"] as? String) + let normalized = text?.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized?.isEmpty == false ? normalized : nil + } + let joined = parts.joined(separator: "\n") + return joined.isEmpty ? nil : joined + } + private func codexUserInputEventCandidate( from payload: [String: Any], turnId: String?, @@ -19064,6 +19180,7 @@ struct CMUXCLI { private func shouldSuppressNestedAgentVisibleMutations( currentAgentPID: Int?, nestedPromptEvent: Bool = false, + transcriptSubagentSession: Bool = false, env: [String: String] ) -> Bool { if let override = normalizedHookValue(env["CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS"])?.lowercased(), @@ -19083,6 +19200,10 @@ struct CMUXCLI { return true } + if transcriptSubagentSession { + return true + } + guard let currentAgentPID, currentAgentPID > 1 else { return false } @@ -21332,10 +21453,22 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { sendAgentFeedTelemetry(workspaceId: workspaceId) let pid = mapped?.pid ?? inferredCodexAgentPID() let codexFailure: CodexHookFailureSummary? + let codexSubagentSignals: CodexTranscriptSubagentSignals if def.name == "codex" { codexFailure = summarizeCodexHookFailure(parsedInput: input, sessionId: sessionId, env: env) + if subagentNotificationSuppressionEnabled(env: env), + let transcriptPath = normalizedHookValue(input.transcriptPath) + ?? findCodexTranscriptPath(sessionId: sessionId, env: env) { + codexSubagentSignals = readCodexTranscriptSubagentSignals( + path: transcriptPath, + turnId: input.turnId + ) + } else { + codexSubagentSignals = CodexTranscriptSubagentSignals() + } } else { codexFailure = nil + codexSubagentSignals = CodexTranscriptSubagentSignals() } let lastMsg = input.object?["last_assistant_message"] as? String @@ -21393,8 +21526,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations( currentAgentPID: pid, nestedPromptEvent: nestedPromptStop, + transcriptSubagentSession: codexSubagentSignals.isSubagentSession, env: env ) + let suppressCompletionNotification = suppressVisibleMutations + || codexSubagentSignals.hasSubagentNotificationRelay if let pid, !suppressVisibleMutations { _ = try? sendV1Command( "set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", @@ -21405,8 +21541,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if suppressVisibleMutations { telemetry.breadcrumb("\(def.name)-hook.stop.nested-suppressed") } else { - let payload = notificationPayload(title: def.displayName, subtitle: subtitle, body: body) - _ = try? sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) + if suppressCompletionNotification { + telemetry.breadcrumb("\(def.name)-hook.stop.subagent-notification-suppressed") + } else { + let payload = notificationPayload(title: def.displayName, subtitle: subtitle, body: body) + _ = try? sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) + } if let codexFailure { _ = try? sendV1Command( "set_status \(def.statusKey) \(codexFailure.statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", From 60aeb8ba7c6d6bfc121c63a9fca809ce3becad0c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 20 May 2026 18:30:48 -0700 Subject: [PATCH 8/9] Address subagent suppression review feedback --- CLI/cmux.swift | 13 ++- Resources/Localizable.xcstrings | 105 +----------------- Sources/GhosttyTerminalView.swift | 8 +- Sources/Workspace+PanelLifecycle.swift | 12 ++ ...ifyProcessIntegrationRegressionTests.swift | 41 +++++++ .../TerminalNotificationClearAllTests.swift | 33 +++++- web/app/[locale]/docs/configuration/page.tsx | 6 +- web/data/cmux.schema.json | 1 + web/messages/ar.json | 7 +- web/messages/bs.json | 7 +- web/messages/da.json | 7 +- web/messages/de.json | 7 +- web/messages/en.json | 7 +- web/messages/es.json | 7 +- web/messages/fr.json | 7 +- web/messages/it.json | 7 +- web/messages/ja.json | 7 +- web/messages/km.json | 7 +- web/messages/ko.json | 7 +- web/messages/no.json | 7 +- web/messages/pl.json | 7 +- web/messages/pt-BR.json | 7 +- web/messages/ru.json | 7 +- web/messages/th.json | 7 +- web/messages/tr.json | 7 +- web/messages/uk.json | 7 +- web/messages/zh-CN.json | 7 +- web/messages/zh-TW.json | 7 +- 28 files changed, 230 insertions(+), 129 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8757779328cc..e22515a989af 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1052,7 +1052,7 @@ private let agentHookWrapperProcessNames: Set = [ "env" ] -private enum HookAgentProcessKind: String { +private enum HookAgentProcessKind { case codex case claude } @@ -19708,8 +19708,11 @@ struct CMUXCLI { currentTurnRelevant = normalizedTurnId.map { $0 == payloadTurnId } ?? true case "task_complete", "turn_complete": let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + if let payloadTurnId { + currentTurnId = payloadTurnId + } if let normalizedTurnId { - if payloadTurnId == normalizedTurnId { + if payloadTurnId == normalizedTurnId || (payloadTurnId == nil && currentTurnRelevant) { currentTurnRelevant = false } } else { @@ -19721,7 +19724,7 @@ struct CMUXCLI { continue } - guard currentTurnRelevant || normalizedTurnId == nil || currentTurnId == nil else { + guard currentTurnRelevant || currentTurnId == nil else { continue } guard codexTranscriptLineHasSubagentNotification(object) else { @@ -21077,7 +21080,7 @@ struct CMUXCLI { env: [String: String] ) -> Bool { if let override = normalizedHookValue(env["CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS"])?.lowercased(), - ["1", "true", "yes"].contains(override) { + Self.parseHookBoolean(override) == true { return true } @@ -21170,7 +21173,7 @@ struct CMUXCLI { } let nameBase = Self.agentProcessBasename(name) - guard nameBase == "node" || nameBase == "bun" else { + if let nameBase, nameBase != "node", nameBase != "bun" { return nil } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0414ef2f7c6f..05e3c00ae13b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -60505,95 +60505,19 @@ }, "settings.automation.suppressSubagentNotifications": { "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Suppress Subagent Notifications" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "サブエージェント通知を抑制" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "하위 에이전트 알림 억제" - } - } - } + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"bs":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"da":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"de":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"en":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"es":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"fr":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"it":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"ja":{"stringUnit":{"state":"translated","value":"サブエージェント通知を抑制"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 알림 억제"}},"nb":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"pl":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"ru":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"th":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"tr":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"uk":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}}} }, "settings.automation.suppressSubagentNotifications.note": { "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フックプロセスの親子関係を使用します。ネストされたCodexまたはClaudeセッションでも完了通知を出したい場合は無効にしてください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "훅 프로세스의 상위 프로세스 관계를 사용합니다. 중첩된 Codex 또는 Claude 세션도 완료 알림을 보내야 한다면 비활성화하세요." - } - } - } + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"bs":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"da":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"de":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"en":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"es":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"fr":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"it":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"ja":{"stringUnit":{"state":"translated","value":"フックプロセスの親子関係を使用します。ネストされたCodexまたはClaudeセッションでも完了通知を出したい場合は無効にしてください。"}},"ko":{"stringUnit":{"state":"translated","value":"훅 프로세스의 상위 프로세스 관계를 사용합니다. 중첩된 Codex 또는 Claude 세션도 완료 알림을 보내야 한다면 비활성화하세요."}},"nb":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"pl":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"ru":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"th":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"tr":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"uk":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}}} }, "settings.automation.suppressSubagentNotifications.subtitleOff": { "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Child agent completions notify like top-level agents." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "子エージェントの完了もトップレベルのエージェントと同様に通知します。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "하위 에이전트 완료도 최상위 에이전트처럼 알립니다." - } - } - } + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"bs":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"da":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"de":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"en":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"es":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"fr":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"it":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"ja":{"stringUnit":{"state":"translated","value":"子エージェントの完了もトップレベルのエージェントと同様に通知します。"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 완료도 최상위 에이전트처럼 알립니다."}},"nb":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"pl":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"ru":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"th":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"tr":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"uk":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}}} }, "settings.automation.suppressSubagentNotifications.subtitleOn": { "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Child agent completions stay in Feed without notifications." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "子エージェントの完了は通知せずFeedにのみ残ります。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "하위 에이전트 완료는 알림 없이 Feed에만 남습니다." - } - } - } + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"bs":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"da":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"de":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"en":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"es":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"fr":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"it":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"ja":{"stringUnit":{"state":"translated","value":"子エージェントの完了は通知せずFeedにのみ残ります。"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 완료는 알림 없이 Feed에만 남습니다."}},"nb":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"pl":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"ru":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"th":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"tr":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"uk":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}}} }, "settings.automation.cursor": { "extractionState": "manual", @@ -78660,26 +78584,7 @@ }, "settings.search.alias.setting.automation.subagent-notifications": { "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "automation.suppressSubagentNotifications サブエージェント ネスト 子エージェント codex claude フック 通知" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "automation.suppressSubagentNotifications 하위 에이전트 중첩 자식 에이전트 codex claude 훅 알림" - } - } - } + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"bs":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"da":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"de":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"en":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"es":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"fr":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"it":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"ja":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications サブエージェント ネスト 子エージェント codex claude フック 通知"}},"ko":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications 하위 에이전트 중첩 자식 에이전트 codex claude 훅 알림"}},"nb":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"pl":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"pt-BR":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"ru":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"th":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"tr":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"uk":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}}} }, "settings.search.alias.setting.automation.cursor": { "extractionState": "manual", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4bb2bb57ac09..9f801ed8fefb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3975,7 +3975,9 @@ class GhosttyApp { let surfaceId = tabManager.focusedSurfaceId(for: tabId) if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), (workspace.suppressesRawTerminalNotification(panelId: surfaceId) - || Self.shouldSuppressAgentManagedDesktopNotification(agentPIDs: workspace.agentPIDs)) { + || Self.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: workspace.agentPIDs(forPanelId: surfaceId) + )) { return true } let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" @@ -4248,7 +4250,9 @@ class GhosttyApp { let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), (workspace.suppressesRawTerminalNotification(panelId: surfaceId) - || Self.shouldSuppressAgentManagedDesktopNotification(agentPIDs: workspace.agentPIDs)) { + || Self.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: workspace.agentPIDs(forPanelId: surfaceId) + )) { return } let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index f49c27d3a305..3f50cfd9095e 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -133,6 +133,18 @@ extension Workspace { return false } + func agentPIDs(forPanelId panelId: UUID?) -> [String: pid_t] { + var agentPIDsForPanel = agentPIDs.filter { agentPIDPanelIdsByKey[$0.key] == nil } + guard let panelId else { return agentPIDsForPanel } + let panelKeys = agentPIDKeysByPanelId[panelId] ?? [] + for key in panelKeys { + if let pid = agentPIDs[key] { + agentPIDsForPanel[key] = pid + } + } + return agentPIDsForPanel + } + func sidebarStatusEntriesVisibleForDisplay() -> [SidebarStatusEntry] { let visibleStructuredStatusKeys = visibleStructuredAgentStatusKeysByPanel() return statusEntries.values.filter { entry in diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index cb3449bedd50..fcfffde0c135 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -543,6 +543,47 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testCodexStopIgnoresStaleSubagentRelayFromCompletedTurnWithoutTurnId() throws { + let context = try makeClaudeHookContext(name: "codex-stale-relay") + defer { context.cleanup() } + + let sessionId = "codex-stale-relay-session" + let transcriptURL = context.root.appendingPathComponent("codex-stale-relay.jsonl") + try [ + #"{"type":"turn_context","payload":{"turn_id":"old-turn"}}"#, + #"{"type":"event_msg","payload":{"type":"turn_complete","turn_id":"old-turn"}}"#, + #"{"type":"response_item","payload":{"type":"message","role":"user","content":"old child finished"}}"#, + ].joined(separator: "\n").write(to: transcriptURL, atomically: true, encoding: .utf8) + + startAgentHookMockServerAccepting(context: context, connectionLimit: 24) + let launchEnvironment = codexLaunchEnvironment(context: context, sessionId: sessionId) + + let prompt = runCodexHook( + context: context, + subcommand: "prompt-submit", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"top-level"}"#, + extraEnvironment: launchEnvironment + ) + XCTAssertFalse(prompt.timedOut, prompt.stderr) + XCTAssertEqual(prompt.status, 0, prompt.stderr) + + let stopStart = context.state.commands.count + let stop = runCodexHook( + context: context, + subcommand: "stop", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, + extraEnvironment: launchEnvironment + ) + XCTAssertFalse(stop.timedOut, stop.stderr) + XCTAssertEqual(stop.status, 0, stop.stderr) + + let stopCommands = Array(context.state.commands.dropFirst(stopStart)) + XCTAssertTrue( + stopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, + "Stale completed-turn subagent relay should not suppress the parent completion notification, saw \(stopCommands)" + ) + } + func testManagedCodexSubagentSessionEndDoesNotClearParentResumeBinding() throws { let context = try makeClaudeHookContext(name: "codex-managed-end-resume-guard") defer { context.cleanup() } diff --git a/cmuxTests/TerminalNotificationClearAllTests.swift b/cmuxTests/TerminalNotificationClearAllTests.swift index 1f33585b2f39..b3d69a394b17 100644 --- a/cmuxTests/TerminalNotificationClearAllTests.swift +++ b/cmuxTests/TerminalNotificationClearAllTests.swift @@ -313,15 +313,46 @@ final class TerminalNotificationClearAllTests: XCTestCase { workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) ) - workspace.recordAgentPID(key: "grok.grok-session-123", pid: pid_t(12345), panelId: firstPanelId) + workspace.recordAgentPID(key: "codex.codex-session-123", pid: pid_t(12345), panelId: firstPanelId) XCTAssertTrue(workspace.suppressesRawTerminalNotification(panelId: firstPanelId)) XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: secondPanel.id)) XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: nil)) + XCTAssertTrue( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: workspace.agentPIDs(forPanelId: firstPanelId), + claudeHooksEnabled: true, + suppressSubagentNotifications: true + ) + ) + XCTAssertFalse( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: workspace.agentPIDs(forPanelId: secondPanel.id), + claudeHooksEnabled: true, + suppressSubagentNotifications: true + ) + ) workspace.recordAgentPID(key: "custom-tool.session", pid: pid_t(12346), panelId: secondPanel.id) XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: secondPanel.id)) + + workspace.recordAgentPID(key: "codex.unbound-session", pid: pid_t(12347), panelId: nil) + + XCTAssertTrue( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: workspace.agentPIDs(forPanelId: secondPanel.id), + claudeHooksEnabled: true, + suppressSubagentNotifications: true + ) + ) + XCTAssertTrue( + GhosttyApp.shouldSuppressAgentManagedDesktopNotification( + agentPIDs: workspace.agentPIDs(forPanelId: nil), + claudeHooksEnabled: true, + suppressSubagentNotifications: true + ) + ) } func testSidebarStatusOnlyShowsStructuredAgentStatusBackedByLivePanelRuntime() throws { diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 7e15bc07e35e..9ec5f54eaed2 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -11,6 +11,7 @@ import { DocsHeading } from "../../components/docs-heading"; type SchemaProperty = { title?: string; description?: string; + descriptionKey?: string; type?: string | string[]; enum?: string[]; default?: unknown; @@ -164,12 +165,15 @@ function hasComplexDefaultValue(value: unknown): boolean { } function PropertyCard({ path, property }: { path: string; property: SchemaProperty }) { + const t = useTranslations("docs.configuration"); + const description = property.descriptionKey ? t(property.descriptionKey) : property.description; + return (
{path}
- {property.description &&

{property.description}

} + {description &&

{description}

}
Type
diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 0321ca38733b..c62be625f0db 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -668,6 +668,7 @@ "suppressSubagentNotifications": { "type": "boolean", "default": true, + "descriptionKey": "schemaDescriptions.automation.suppressSubagentNotifications", "description": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." }, "cursorIntegration": { diff --git a/web/messages/ar.json b/web/messages/ar.json index 75a7669d032e..b73a06485a61 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "المضيفون لفتحهم في المتصفح المدمج: ينطبق على الروابط المنقورة من مخرجات الطرفية. المضيفون في هذه القائمة يُفتحون في cmux؛ المضيفون الآخرون يُفتحون في متصفحك الافتراضي. يدعم مضيفاً واحداً أو حرف بدل لكل سطر (مثال: example.com، *.internal.example).", "browserHostsHttp": "مضيفو HTTP المسموح بهم في المتصفح المدمج: ينطبق فقط على روابط HTTP (غير HTTPS). المضيفون في هذه القائمة يمكنهم الفتح في cmux بدون تحذير. الافتراضيات تشمل localhost و*.localhost و127.0.0.1 و::1 و0.0.0.0 و*.localtest.me.", "exampleConfig": "مثال على الإعدادات", - "metaTitle": "الإعدادات" + "metaTitle": "الإعدادات", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "أوامر مخصصة", diff --git a/web/messages/bs.json b/web/messages/bs.json index d651461f5f91..aabc92d8bf81 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hostovi za otvaranje u ugrađenom pregledniku: primjenjuje se na linkove kliknute iz terminalskog izlaza. Hostovi na ovoj listi se otvaraju u cmux-u; ostali hostovi se otvaraju u vašem podrazumijevanom pregledniku. Podržava jedan host ili wildcard po liniji (na primjer: example.com, *.internal.example).", "browserHostsHttp": "HTTP hostovi dozvoljeni u ugrađenom pregledniku: primjenjuje se samo na HTTP (ne-HTTPS) URL-ove. Hostovi na ovoj listi se mogu otvoriti u cmux-u bez upozorenja. Podrazumijevane vrijednosti uključuju localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 i *.localtest.me.", "exampleConfig": "Primjer konfiguracije", - "metaTitle": "Konfiguracija" + "metaTitle": "Konfiguracija", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Prilagođene komande", diff --git a/web/messages/da.json b/web/messages/da.json index dae091d20330..7bb38bba0cee 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Værter til åbning i indbygget browser: gælder for links klikket fra terminaloutput. Værter på denne liste åbnes i cmux; andre værter åbnes i din standardbrowser. Understøtter én vært eller wildcard per linje (for eksempel: example.com, *.internal.example).", "browserHostsHttp": "HTTP-værter tilladt i indbygget browser: gælder kun for HTTP (ikke-HTTPS) URL'er. Værter på denne liste kan åbnes i cmux uden en advarsel. Standard inkluderer localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 og *.localtest.me.", "exampleConfig": "Eksempelkonfiguration", - "metaTitle": "Konfiguration" + "metaTitle": "Konfiguration", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Brugerdefinerede kommandoer", diff --git a/web/messages/de.json b/web/messages/de.json index 1aeb6575e403..affa6d88a732 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hosts im eingebetteten Browser öffnen: Gilt für Links, die aus der Terminal-Ausgabe geklickt werden. Hosts in dieser Liste öffnen sich in cmux; andere Hosts öffnen sich in Ihrem Standardbrowser. Unterstützt einen Host oder Wildcard pro Zeile (zum Beispiel: example.com, *.internal.example).", "browserHostsHttp": "Im eingebetteten Browser erlaubte HTTP-Hosts: Gilt nur für HTTP-URLs (nicht HTTPS). Hosts in dieser Liste können in cmux ohne Warnhinweis geöffnet werden. Standardmäßig enthalten sind localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 und *.localtest.me.", "exampleConfig": "Beispielkonfiguration", - "metaTitle": "Konfiguration" + "metaTitle": "Konfiguration", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Benutzerdefinierte Befehle", diff --git a/web/messages/en.json b/web/messages/en.json index e1516354f285..100089ba33ff 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -425,7 +425,12 @@ "settingsFileDesc": "cmux.json stores cmux-owned app settings, shortcuts, actions, commands, and workspace layouts.", "settingsFilePrecedence": "Shortcuts defined in cmux.json override the in-app Settings value. Managed shortcuts stay visible in Settings but become read-only there.", "settingsFileChords": "Use a string for a single shortcut or a two-item array for a chord. Example: [\"ctrl+b\", \"c\"] means press Ctrl+B, then C.", - "exampleConfig": "Example config" + "exampleConfig": "Example config", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Custom Commands", diff --git a/web/messages/es.json b/web/messages/es.json index c3910d711b0c..a065a78305bc 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hosts para abrir en el navegador integrado: se aplica a enlaces clicados desde la salida del terminal. Los hosts en esta lista se abren en cmux; otros hosts se abren en su navegador predeterminado. Soporta un host o comodín por línea (por ejemplo: example.com, *.internal.example).", "browserHostsHttp": "Hosts HTTP permitidos en el navegador integrado: se aplica solo a URLs HTTP (no HTTPS). Los hosts en esta lista pueden abrirse en cmux sin un aviso de advertencia. Los valores predeterminados incluyen localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 y *.localtest.me.", "exampleConfig": "Configuración de ejemplo", - "metaTitle": "Configuración" + "metaTitle": "Configuración", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Comandos personalizados", diff --git a/web/messages/fr.json b/web/messages/fr.json index 6ec2d9e168d7..e2759d72e05b 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hôtes à ouvrir dans le navigateur intégré : s'applique aux liens cliqués depuis la sortie du terminal. Les hôtes de cette liste s'ouvrent dans cmux ; les autres hôtes s'ouvrent dans votre navigateur par défaut. Supporte un hôte ou un joker par ligne (par exemple : example.com, *.internal.example).", "browserHostsHttp": "Hôtes HTTP autorisés dans le navigateur intégré : s'applique uniquement aux URLs HTTP (non-HTTPS). Les hôtes de cette liste peuvent s'ouvrir dans cmux sans avertissement. Les valeurs par défaut incluent localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 et *.localtest.me.", "exampleConfig": "Exemple de configuration", - "metaTitle": "Configuration" + "metaTitle": "Configuration", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Commandes personnalisées", diff --git a/web/messages/it.json b/web/messages/it.json index 910757b25d17..958a53a997cd 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Host da aprire nel browser integrato: si applica ai link cliccati dall'output del terminale. Gli host in questa lista si aprono in cmux; gli altri host si aprono nel browser predefinito. Supporta un host o wildcard per riga (ad esempio: example.com, *.internal.example).", "browserHostsHttp": "Host HTTP consentiti nel browser integrato: si applica solo agli URL HTTP (non-HTTPS). Gli host in questa lista possono aprirsi in cmux senza avviso. I valori predefiniti includono localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 e *.localtest.me.", "exampleConfig": "Esempio di configurazione", - "metaTitle": "Configurazione" + "metaTitle": "Configurazione", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Comandi personalizzati", diff --git a/web/messages/ja.json b/web/messages/ja.json index b28010ba57af..91118d3b5206 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -358,7 +358,12 @@ "settingsFilePrecedence": "cmux.json で定義したショートカットはアプリ内設定より優先されます。管理対象のショートカットは設定画面に表示されたままですが、そこでの編集は無効になります。", "settingsFileChords": "単発ショートカットは文字列、コードは2要素の配列で指定します。例: [\"ctrl+b\", \"c\"] は Ctrl+B の後に C を押します。", "exampleConfig": "設定例", - "metaTitle": "設定" + "metaTitle": "設定", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "カスタムコマンド", diff --git a/web/messages/km.json b/web/messages/km.json index 5fc55a33cc48..443729baea34 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Host ដែលត្រូវបើកក្នុងកម្មវិធីរុករកដែលបង្កប់: អនុវត្តចំពោះតំណដែលចុចពីលទ្ធផលទែមីណល។ Host ក្នុងបញ្ជីនេះបើកក្នុង cmux; host ផ្សេងទៀតបើកក្នុងកម្មវិធីរុករកលំនាំដើមរបស់អ្នក។ គាំទ្រ host មួយ ឬ wildcard ក្នុងមួយជួរ (ឧទាហរណ៍: example.com, *.internal.example)។", "browserHostsHttp": "HTTP Host ដែលអនុញ្ញាតក្នុងកម្មវិធីរុករកដែលបង្កប់: អនុវត្តតែចំពោះ HTTP (មិនមែន HTTPS) URL។ Host ក្នុងបញ្ជីនេះអាចបើកក្នុង cmux ដោយមិនមានការព្រមានទេ។ លំនាំដើមរួមមាន localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0, និង *.localtest.me។", "exampleConfig": "ឧទាហរណ៍កំណត់រចនាសម្ព័ន្ធ", - "metaTitle": "ការកំណត់រចនាសម្ព័ន្ធ" + "metaTitle": "ការកំណត់រចនាសម្ព័ន្ធ", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "ពាក្យបញ្ជាផ្ទាល់ខ្លួន", diff --git a/web/messages/ko.json b/web/messages/ko.json index 3975f0914ac6..9c8c506c9f64 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "내장 브라우저에서 열 호스트: 터미널 출력에서 클릭한 링크에 적용됩니다. 이 목록의 호스트는 cmux에서 열리고, 다른 호스트는 기본 브라우저에서 열립니다. 줄당 하나의 호스트 또는 와일드카드를 지원합니다(예: example.com, *.internal.example).", "browserHostsHttp": "내장 브라우저에서 허용되는 HTTP 호스트: HTTP(비 HTTPS) URL에만 적용됩니다. 이 목록의 호스트는 경고 프롬프트 없이 cmux에서 열 수 있습니다. 기본값에는 localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me가 포함됩니다.", "exampleConfig": "예시 설정", - "metaTitle": "설정" + "metaTitle": "설정", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "사용자 정의 명령어", diff --git a/web/messages/no.json b/web/messages/no.json index e8807e30ab87..3d67bf627b37 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Verter å åpne i innebygd nettleser: gjelder lenker klikket fra terminalutdata. Verter på denne listen åpnes i cmux; andre verter åpnes i standardnettleseren din. Støtter én vert eller jokertegn per linje (for eksempel: example.com, *.internal.example).", "browserHostsHttp": "HTTP-verter tillatt i innebygd nettleser: gjelder kun HTTP (ikke-HTTPS) URL-er. Verter på denne listen kan åpnes i cmux uten advarsel. Standardverdier inkluderer localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 og *.localtest.me.", "exampleConfig": "Eksempelkonfigurasjon", - "metaTitle": "Konfigurasjon" + "metaTitle": "Konfigurasjon", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Egendefinerte kommandoer", diff --git a/web/messages/pl.json b/web/messages/pl.json index 1a9cf948043f..68b05c822ed1 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hosty do otwarcia we wbudowanej przeglądarce: dotyczy linków klikniętych z wyjścia terminala. Hosty na tej liście otwierają się w cmux; pozostałe hosty otwierają się w domyślnej przeglądarce. Obsługuje jeden host lub wildcard na linię (na przykład: example.com, *.internal.example).", "browserHostsHttp": "Hosty HTTP dozwolone we wbudowanej przeglądarce: dotyczy tylko URL-i HTTP (nie-HTTPS). Hosty na tej liście mogą otwierać się w cmux bez monitu ostrzegawczego. Domyślnie obejmuje localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 i *.localtest.me.", "exampleConfig": "Przykładowa konfiguracja", - "metaTitle": "Konfiguracja" + "metaTitle": "Konfiguracja", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Niestandardowe polecenia", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 1c269873b80c..ccb8948852ff 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hosts para abrir no navegador integrado: aplica-se a links clicados na saída do terminal. Hosts nesta lista abrem no cmux; outros hosts abrem no seu navegador padrão. Suporta um host ou wildcard por linha (por exemplo: example.com, *.internal.example).", "browserHostsHttp": "Hosts HTTP permitidos no navegador integrado: aplica-se apenas a URLs HTTP (não HTTPS). Hosts nesta lista podem abrir no cmux sem aviso. Os padrões incluem localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 e *.localtest.me.", "exampleConfig": "Exemplo de configuração", - "metaTitle": "Configuração" + "metaTitle": "Configuração", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Comandos personalizados", diff --git a/web/messages/ru.json b/web/messages/ru.json index 74674f0b239f..28050dce6ccf 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Хосты для открытия во встроенном браузере: применяется к ссылкам, нажатым в выводе терминала. Хосты из этого списка открываются в cmux; остальные хосты открываются в твоём браузере по умолчанию. Поддерживает один хост или wildcard на строку (например: example.com, *.internal.example).", "browserHostsHttp": "HTTP-хосты, разрешённые во встроенном браузере: применяется только к HTTP (не HTTPS) URL-ам. Хосты из этого списка могут открываться в cmux без предупреждения. По умолчанию включают localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 и *.localtest.me.", "exampleConfig": "Пример конфигурации", - "metaTitle": "Конфигурация" + "metaTitle": "Конфигурация", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Пользовательские команды", diff --git a/web/messages/th.json b/web/messages/th.json index e4c5e4d11a4a..f97841d9b3dc 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Hosts to Open in Embedded Browser: ใช้กับลิงก์ที่คลิกจากเอาต์พุตเทอร์มินัล Host ในรายการนี้เปิดใน cmux ส่วน host อื่นเปิดในเบราว์เซอร์เริ่มต้นของคุณ รองรับ host เดี่ยวหรือ wildcard ต่อบรรทัด (เช่น: example.com, *.internal.example)", "browserHostsHttp": "HTTP Hosts Allowed in Embedded Browser: ใช้เฉพาะกับ URL แบบ HTTP (ไม่ใช่ HTTPS) Host ในรายการนี้เปิดใน cmux ได้โดยไม่มีข้อความเตือน ค่าเริ่มต้นรวม localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 และ *.localtest.me", "exampleConfig": "ตัวอย่างคอนฟิก", - "metaTitle": "การตั้งค่า" + "metaTitle": "การตั้งค่า", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "คำสั่งที่กำหนดเอง", diff --git a/web/messages/tr.json b/web/messages/tr.json index 2c11ecb9ba2a..63436f24fb46 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "Gömülü Tarayıcıda Açılacak Ana Bilgisayarlar: terminal çıktısından tıklanan bağlantılara uygulanır. Bu listedeki ana bilgisayarlar cmux'ta açılır; diğer ana bilgisayarlar varsayılan tarayıcınızda açılır. Satır başına bir ana bilgisayar veya joker karakter destekler (örneğin: example.com, *.internal.example).", "browserHostsHttp": "Gömülü Tarayıcıda İzin Verilen HTTP Ana Bilgisayarları: yalnızca HTTP (HTTPS olmayan) URL'lere uygulanır. Bu listedeki ana bilgisayarlar cmux'ta uyarı istemeden açılabilir. Varsayılanlar localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 ve *.localtest.me'yi içerir.", "exampleConfig": "Örnek yapılandırma", - "metaTitle": "Yapılandırma" + "metaTitle": "Yapılandırma", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Özel Komutlar", diff --git a/web/messages/uk.json b/web/messages/uk.json index 38f150f30fc7..b00628041409 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -300,7 +300,12 @@ "browserLinkDesc": "У Параметри > Браузер cmux надає два списки хостів з різним призначенням:", "browserHostsEmbed": "Хости для відкриття у вбудованому браузері: застосовується до посилань, натиснутих з виводу терміналу. Хости з цього списку відкриваються в cmux; інші хости відкриваються у вашому стандартному браузері. Підтримується один хост або маска на рядок (наприклад: example.com, *.internal.example).", "browserHostsHttp": "HTTP-хости, дозволені у вбудованому браузері: застосовується тільки до HTTP (не HTTPS) URL. Хости з цього списку можуть відкриватися в cmux без попередження. За замовчуванням включено localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0 та *.localtest.me.", - "exampleConfig": "Приклад конфігурації" + "exampleConfig": "Приклад конфігурації", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "Користувацькі команди", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 8c67839c8ee0..92a25c810ab4 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "在内嵌浏览器中打开的主机:适用于从终端输出点击的链接。此列表中的主机在 cmux 中打开;其他主机在默认浏览器中打开。支持单个主机或通配符,每行一个(例如:example.com、*.internal.example)。", "browserHostsHttp": "内嵌浏览器允许的 HTTP 主机:仅适用于 HTTP(非 HTTPS)URL。此列表中的主机可以在 cmux 中打开而不显示警告提示。默认包括 localhost、*.localhost、127.0.0.1、::1、0.0.0.0 和 *.localtest.me。", "exampleConfig": "配置示例", - "metaTitle": "配置" + "metaTitle": "配置", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "自定义命令", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 0ff43b9ba429..d8ec5ca0e908 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -300,7 +300,12 @@ "browserHostsEmbed": "在內建瀏覽器中開啟的主機:適用於從終端機輸出點擊的連結。此列表中的主機會在 cmux 中開啟;其他主機會在你的預設瀏覽器中開啟。支援每行一個主機或萬用字元(例如:example.com、*.internal.example)。", "browserHostsHttp": "內建瀏覽器中允許的 HTTP 主機:僅適用於 HTTP(非 HTTPS)URL。此列表中的主機可以在 cmux 中開啟而不會出現警告提示。預設包含 localhost、*.localhost、127.0.0.1、::1、0.0.0.0 和 *.localtest.me。", "exampleConfig": "範例設定", - "metaTitle": "設定" + "metaTitle": "設定", + "schemaDescriptions": { + "automation": { + "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + } + } }, "customCommands": { "title": "自訂指令", From bed1feb138d8b615051cc44ccf4771f91e244845 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 20 May 2026 19:58:38 -0700 Subject: [PATCH 9/9] Address subagent suppression review feedback --- CLI/cmux.swift | 8 ++- Resources/Localizable.xcstrings | 10 ++-- Sources/GhosttyTerminalView.swift | 28 +++------- Sources/TabManager.swift | 2 + Sources/TerminalController.swift | 8 ++- ...minalControllerV2ParamParsingSupport.swift | 12 +++++ Sources/Workspace+PanelLifecycle.swift | 32 ++++++----- Sources/Workspace.swift | 4 +- cmuxTests/GhosttyConfigTests.swift | 31 ----------- ...erminalControllerSocketSecurityTests.swift | 53 +++++++++++++++++++ .../TerminalNotificationClearAllTests.swift | 36 +++---------- web/messages/ar.json | 2 +- web/messages/bs.json | 2 +- web/messages/da.json | 2 +- web/messages/de.json | 2 +- web/messages/es.json | 2 +- web/messages/fr.json | 2 +- web/messages/it.json | 2 +- web/messages/ja.json | 2 +- web/messages/km.json | 2 +- web/messages/ko.json | 2 +- web/messages/no.json | 2 +- web/messages/pl.json | 2 +- web/messages/pt-BR.json | 2 +- web/messages/ru.json | 2 +- web/messages/th.json | 2 +- web/messages/tr.json | 2 +- web/messages/uk.json | 2 +- web/messages/zh-CN.json | 2 +- web/messages/zh-TW.json | 2 +- 30 files changed, 142 insertions(+), 120 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e22515a989af..58c12d86dc27 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15232,7 +15232,13 @@ struct CMUXCLI { "direction": direction, "focus": false, "initial_command": startupScript, - "tmux_start_command": commandText + "tmux_start_command": commandText, + "startup_environment": [ + managedSubagentEnvironmentKey: "1", + codexTeamsThreadEnvironmentKey: thread.id, + codexTeamsParentThreadEnvironmentKey: spawn.parentThreadId, + codexTeamsDepthEnvironmentKey: String(max(1, depth)) + ] ] if let cwd = thread.cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 05e3c00ae13b..9bf32ef26992 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -60505,19 +60505,19 @@ }, "settings.automation.suppressSubagentNotifications": { "extractionState": "manual", - "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"bs":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"da":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"de":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"en":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"es":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"fr":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"it":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"ja":{"stringUnit":{"state":"translated","value":"サブエージェント通知を抑制"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 알림 억제"}},"nb":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"pl":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"ru":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"th":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"tr":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"uk":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}}} + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"إخفاء إشعارات الوكلاء الفرعيين"}},"bs":{"stringUnit":{"state":"translated","value":"Sakrij obavještenja podagenata"}},"da":{"stringUnit":{"state":"translated","value":"Skjul underagent-notifikationer"}},"de":{"stringUnit":{"state":"translated","value":"Unteragent-Benachrichtigungen unterdrücken"}},"en":{"stringUnit":{"state":"translated","value":"Suppress Subagent Notifications"}},"es":{"stringUnit":{"state":"translated","value":"Suprimir notificaciones de subagentes"}},"fr":{"stringUnit":{"state":"translated","value":"Masquer les notifications des sous-agents"}},"it":{"stringUnit":{"state":"translated","value":"Nascondi notifiche dei sottoagenti"}},"ja":{"stringUnit":{"state":"translated","value":"サブエージェント通知を抑制"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 알림 억제"}},"nb":{"stringUnit":{"state":"translated","value":"Undertrykk underagentvarsler"}},"pl":{"stringUnit":{"state":"translated","value":"Ukryj powiadomienia podagentów"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Suprimir notificações de subagentes"}},"ru":{"stringUnit":{"state":"translated","value":"Подавлять уведомления подагентов"}},"th":{"stringUnit":{"state":"translated","value":"ซ่อนการแจ้งเตือนของเอเจนต์ย่อย"}},"tr":{"stringUnit":{"state":"translated","value":"Alt ajan bildirimlerini gizle"}},"uk":{"stringUnit":{"state":"translated","value":"Приховати сповіщення підлеглих агентів"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"隐藏子代理通知"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"隱藏子代理通知"}}} }, "settings.automation.suppressSubagentNotifications.note": { "extractionState": "manual", - "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"bs":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"da":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"de":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"en":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"es":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"fr":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"it":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"ja":{"stringUnit":{"state":"translated","value":"フックプロセスの親子関係を使用します。ネストされたCodexまたはClaudeセッションでも完了通知を出したい場合は無効にしてください。"}},"ko":{"stringUnit":{"state":"translated","value":"훅 프로세스의 상위 프로세스 관계를 사용합니다. 중첩된 Codex 또는 Claude 세션도 완료 알림을 보내야 한다면 비활성화하세요."}},"nb":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"pl":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"ru":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"th":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"tr":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"uk":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}}} + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"يستخدم تسلسل العمليات الأبوي من عمليات الخطافات. عطّله إذا كان يجب أن تطلق جلسات Codex أو Claude المتداخلة إشعارات الإكمال."}},"bs":{"stringUnit":{"state":"translated","value":"Koristi hijerarhiju procesa iz hook procesa. Isključite ako ugniježđene Codex ili Claude sesije trebaju slati obavještenja o završetku."}},"da":{"stringUnit":{"state":"translated","value":"Bruger proceshierarki fra hook-processer. Slå fra, hvis indlejrede Codex- eller Claude-sessioner skal udløse fuldførelsesnotifikationer."}},"de":{"stringUnit":{"state":"translated","value":"Verwendet die Prozessabstammung der Hook-Prozesse. Deaktivieren, wenn verschachtelte Codex- oder Claude-Sitzungen Abschlussbenachrichtigungen auslösen sollen."}},"en":{"stringUnit":{"state":"translated","value":"Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications."}},"es":{"stringUnit":{"state":"translated","value":"Usa la ascendencia de procesos de los hooks. Desactívalo si las sesiones anidadas de Codex o Claude deben generar notificaciones de finalización."}},"fr":{"stringUnit":{"state":"translated","value":"Utilise l’ascendance des processus de hooks. Désactivez cette option si les sessions Codex ou Claude imbriquées doivent déclencher des notifications de fin."}},"it":{"stringUnit":{"state":"translated","value":"Usa la gerarchia dei processi dei hook. Disattiva se le sessioni Codex o Claude annidate devono inviare notifiche di completamento."}},"ja":{"stringUnit":{"state":"translated","value":"フックプロセスの親子関係を使用します。ネストされた Codex または Claude セッションでも完了通知を出したい場合は無効にしてください。"}},"ko":{"stringUnit":{"state":"translated","value":"훅 프로세스의 상위 프로세스 관계를 사용합니다. 중첩된 Codex 또는 Claude 세션도 완료 알림을 보내야 한다면 비활성화하세요."}},"nb":{"stringUnit":{"state":"translated","value":"Bruker prosesshierarki fra hook-prosesser. Slå av hvis innebygde Codex- eller Claude-økter skal utløse fullføringsvarsler."}},"pl":{"stringUnit":{"state":"translated","value":"Używa hierarchii procesów hooków. Wyłącz, jeśli zagnieżdżone sesje Codex lub Claude mają wywoływać powiadomienia o zakończeniu."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Usa a hierarquia de processos dos hooks. Desative se sessões aninhadas do Codex ou Claude devem disparar notificações de conclusão."}},"ru":{"stringUnit":{"state":"translated","value":"Использует иерархию процессов из hook-процессов. Отключите, если вложенные сеансы Codex или Claude должны отправлять уведомления о завершении."}},"th":{"stringUnit":{"state":"translated","value":"ใช้ลำดับชั้นของโปรเซสจากโปรเซส hook ปิดตัวเลือกนี้หากเซสชัน Codex หรือ Claude แบบซ้อนควรส่งการแจ้งเตือนเมื่อเสร็จ"}},"tr":{"stringUnit":{"state":"translated","value":"Hook süreçlerinin üst işlem zincirini kullanır. İç içe Codex veya Claude oturumları tamamlanma bildirimi göndermeliyse kapatın."}},"uk":{"stringUnit":{"state":"translated","value":"Використовує ієрархію процесів із hook-процесів. Вимкніть, якщо вкладені сеанси Codex або Claude мають надсилати сповіщення про завершення."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"使用 hook 进程的进程层级。若嵌套的 Codex 或 Claude 会话应触发完成通知,请将其关闭。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"使用 hook 程序的程序層級。若巢狀 Codex 或 Claude 工作階段應觸發完成通知,請將其關閉。"}}} }, "settings.automation.suppressSubagentNotifications.subtitleOff": { "extractionState": "manual", - "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"bs":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"da":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"de":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"en":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"es":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"fr":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"it":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"ja":{"stringUnit":{"state":"translated","value":"子エージェントの完了もトップレベルのエージェントと同様に通知します。"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 완료도 최상위 에이전트처럼 알립니다."}},"nb":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"pl":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"ru":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"th":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"tr":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"uk":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}}} + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تُرسل إكمالات الوكلاء الفرعيين إشعارات مثل الوكلاء الأعلى مستوى."}},"bs":{"stringUnit":{"state":"translated","value":"Završeci dječijih agenata šalju obavještenja kao agenti najvišeg nivoa."}},"da":{"stringUnit":{"state":"translated","value":"Underagenters fuldførelser giver besked som topniveau-agenter."}},"de":{"stringUnit":{"state":"translated","value":"Abschlüsse von Unteragenten benachrichtigen wie Top-Level-Agenten."}},"en":{"stringUnit":{"state":"translated","value":"Child agent completions notify like top-level agents."}},"es":{"stringUnit":{"state":"translated","value":"Las finalizaciones de agentes secundarios notifican como agentes de nivel superior."}},"fr":{"stringUnit":{"state":"translated","value":"Les fins d’agents enfants notifient comme les agents de premier niveau."}},"it":{"stringUnit":{"state":"translated","value":"I completamenti degli agenti figli inviano notifiche come gli agenti di primo livello."}},"ja":{"stringUnit":{"state":"translated","value":"子エージェントの完了もトップレベルのエージェントと同様に通知します。"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 완료도 최상위 에이전트처럼 알립니다."}},"nb":{"stringUnit":{"state":"translated","value":"Fullføringer fra underagenter varsler som toppnivåagenter."}},"pl":{"stringUnit":{"state":"translated","value":"Ukończenia agentów podrzędnych powiadamiają jak agenci najwyższego poziomu."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Conclusões de agentes filhos notificam como agentes de nível superior."}},"ru":{"stringUnit":{"state":"translated","value":"Завершения дочерних агентов уведомляют как агенты верхнего уровня."}},"th":{"stringUnit":{"state":"translated","value":"การทำงานเสร็จของเอเจนต์ลูกจะแจ้งเตือนเหมือนเอเจนต์ระดับบนสุด"}},"tr":{"stringUnit":{"state":"translated","value":"Alt ajan tamamlanmaları üst düzey ajanlar gibi bildirim gönderir."}},"uk":{"stringUnit":{"state":"translated","value":"Завершення дочірніх агентів сповіщають як агенти верхнього рівня."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"子代理完成时会像顶层代理一样通知。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"子代理完成時會像頂層代理一樣通知。"}}} }, "settings.automation.suppressSubagentNotifications.subtitleOn": { "extractionState": "manual", - "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"bs":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"da":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"de":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"en":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"es":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"fr":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"it":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"ja":{"stringUnit":{"state":"translated","value":"子エージェントの完了は通知せずFeedにのみ残ります。"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 완료는 알림 없이 Feed에만 남습니다."}},"nb":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"pl":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"ru":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"th":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"tr":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"uk":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}}} + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبقى إكمالات الوكلاء الفرعيين في Feed بدون إشعارات."}},"bs":{"stringUnit":{"state":"translated","value":"Završeci dječijih agenata ostaju u Feed-u bez obavještenja."}},"da":{"stringUnit":{"state":"translated","value":"Underagenters fuldførelser bliver i Feed uden notifikationer."}},"de":{"stringUnit":{"state":"translated","value":"Abschlüsse von Unteragenten bleiben ohne Benachrichtigungen im Feed."}},"en":{"stringUnit":{"state":"translated","value":"Child agent completions stay in Feed without notifications."}},"es":{"stringUnit":{"state":"translated","value":"Las finalizaciones de agentes secundarios permanecen en Feed sin notificaciones."}},"fr":{"stringUnit":{"state":"translated","value":"Les fins d’agents enfants restent dans Feed sans notifications."}},"it":{"stringUnit":{"state":"translated","value":"I completamenti degli agenti figli restano in Feed senza notifiche."}},"ja":{"stringUnit":{"state":"translated","value":"子エージェントの完了は通知せずFeedにのみ残ります。"}},"ko":{"stringUnit":{"state":"translated","value":"하위 에이전트 완료는 알림 없이 Feed에만 남습니다."}},"nb":{"stringUnit":{"state":"translated","value":"Fullføringer fra underagenter blir i Feed uten varsler."}},"pl":{"stringUnit":{"state":"translated","value":"Ukończenia agentów podrzędnych pozostają w Feed bez powiadomień."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Conclusões de agentes filhos ficam no Feed sem notificações."}},"ru":{"stringUnit":{"state":"translated","value":"Завершения дочерних агентов остаются в Feed без уведомлений."}},"th":{"stringUnit":{"state":"translated","value":"การทำงานเสร็จของเอเจนต์ลูกจะอยู่ใน Feed โดยไม่มีการแจ้งเตือน"}},"tr":{"stringUnit":{"state":"translated","value":"Alt ajan tamamlanmaları bildirim olmadan Feed’de kalır."}},"uk":{"stringUnit":{"state":"translated","value":"Завершення дочірніх агентів залишаються у Feed без сповіщень."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"子代理完成事件会留在 Feed 中且不通知。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"子代理完成事件會留在 Feed 中且不通知。"}}} }, "settings.automation.cursor": { "extractionState": "manual", @@ -78584,7 +78584,7 @@ }, "settings.search.alias.setting.automation.subagent-notifications": { "extractionState": "manual", - "localizations": {"ar":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"bs":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"da":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"de":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"en":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"es":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"fr":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"it":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"ja":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications サブエージェント ネスト 子エージェント codex claude フック 通知"}},"ko":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications 하위 에이전트 중첩 자식 에이전트 codex claude 훅 알림"}},"nb":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"pl":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"pt-BR":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"ru":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"th":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"tr":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"uk":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}}} + "localizations": {"ar":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications وكيل فرعي متداخل طفل codex claude hook إشعارات"}},"bs":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications podagent ugniježđen dječiji agent codex claude hook obavještenja"}},"da":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications underagent indlejret børneagent codex claude hooks notifikationer"}},"de":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications unteragent verschachtelt kindagent codex claude hooks benachrichtigungen"}},"en":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"}},"es":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagente anidado agente secundario codex claude hooks notificaciones"}},"fr":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications sous-agent imbriqué agent enfant codex claude hooks notifications"}},"it":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications sottoagente annidato agente figlio codex claude hook notifiche"}},"ja":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications サブエージェント ネスト 子エージェント codex claude フック 通知"}},"ko":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications 하위 에이전트 중첩 자식 에이전트 codex claude 훅 알림"}},"nb":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications underagent innebygd barneagent codex claude hooks varsler"}},"pl":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications podagent zagnieżdżony agent podrzędny codex claude hooki powiadomienia"}},"pt-BR":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications subagente aninhado agente filho codex claude hooks notificações"}},"ru":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications подагент вложенный дочерний агент codex claude hooks уведомления"}},"th":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications เอเจนต์ย่อย ซ้อน เอเจนต์ลูก codex claude hook การแจ้งเตือน"}},"tr":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications alt ajan iç içe çocuk ajan codex claude hook bildirimler"}},"uk":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications підагент вкладений дочірній агент codex claude hooks сповіщення"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications 子代理 嵌套 子级代理 codex claude hooks 通知"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"automation.suppressSubagentNotifications 子代理 巢狀 子級代理 codex claude hooks 通知"}}} }, "settings.search.alias.setting.automation.cursor": { "extractionState": "manual", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9f801ed8fefb..793377d31385 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3940,19 +3940,6 @@ class GhosttyApp { return initializingRuntimeApp } - static func shouldSuppressAgentManagedDesktopNotification( - agentPIDs: [String: pid_t], - claudeHooksEnabled: Bool = ClaudeCodeIntegrationSettings.hooksEnabled(), - suppressSubagentNotifications: Bool = AgentSubagentNotificationSettings.suppressNotifications() - ) -> Bool { - if claudeHooksEnabled, - agentPIDs.keys.contains(where: { $0 == "claude_code" || $0.hasPrefix("claude_code.") }) { - return true - } - guard suppressSubagentNotifications else { return false } - return agentPIDs.keys.contains { $0 == "codex" || $0.hasPrefix("codex.") } - } - private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || @@ -3974,10 +3961,7 @@ class GhosttyApp { let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager let surfaceId = tabManager.focusedSurfaceId(for: tabId) if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), - (workspace.suppressesRawTerminalNotification(panelId: surfaceId) - || Self.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: workspace.agentPIDs(forPanelId: surfaceId) - )) { + workspace.suppressesRawTerminalNotification(panelId: surfaceId) { return true } let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" @@ -4249,10 +4233,7 @@ class GhosttyApp { performOnMain { let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), - (workspace.suppressesRawTerminalNotification(panelId: surfaceId) - || Self.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: workspace.agentPIDs(forPanelId: surfaceId) - )) { + workspace.suppressesRawTerminalNotification(panelId: surfaceId) { return } let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" @@ -4938,6 +4919,11 @@ final class TerminalSurface: Identifiable, ObservableObject { private(set) var lastSearchNeedle = "" private var searchNeedleCancellable: AnyCancellable? var currentKeyStateIndicatorText: String? { surfaceView.currentKeyStateIndicatorText } + + func startupEnvironmentValue(_ key: String) -> String? { + additionalEnvironment[key] ?? initialEnvironmentOverrides[key] + } + init( tabId: UUID, context: ghostty_surface_context_e, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 64fb62102fec..24eb0971f2ac 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -7218,6 +7218,7 @@ class TabManager: ObservableObject { workingDirectory: String? = nil, initialCommand: String? = nil, tmuxStartCommand: String? = nil, + startupEnvironment: [String: String] = [:], initialDividerPosition: CGFloat? = nil ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } @@ -7229,6 +7230,7 @@ class TabManager: ObservableObject { workingDirectory: workingDirectory, initialCommand: initialCommand, tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, initialDividerPosition: initialDividerPosition )?.id } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7b85dd18c001..daefc4c31ef8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6923,6 +6923,7 @@ class TerminalController { let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") let initialCommand = v2OptionalTrimmedRawString(params, "initial_command") let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") + let startupEnvironment = v2TrimmedStringMap(params, keys: ["startup_environment", "initial_env"]) let parsedInitialDivider = v2InitialDividerPosition(params) if let error = parsedInitialDivider.error { return error @@ -6980,6 +6981,7 @@ class TerminalController { workingDirectory: workingDirectory, initialCommand: initialCommand, tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, initialDividerPosition: initialDividerPosition.map { CGFloat($0) } ) } @@ -7015,6 +7017,7 @@ class TerminalController { let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") let initialCommand = v2OptionalTrimmedRawString(params, "initial_command") let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") + let startupEnvironment = v2TrimmedStringMap(params, keys: ["startup_environment", "initial_env"]) if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { return v2BrowserDisabledExternalOpenResult(rawURL: urlStr, url: url, tabManager: tabManager) } @@ -7056,7 +7059,8 @@ class TerminalController { focus: focus, workingDirectory: workingDirectory, initialCommand: initialCommand, - tmuxStartCommand: tmuxStartCommand + tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment )?.id } @@ -8329,6 +8333,7 @@ class TerminalController { let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") let initialCommand = v2OptionalTrimmedRawString(params, "initial_command") let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") + let startupEnvironment = v2TrimmedStringMap(params, keys: ["startup_environment", "initial_env"]) if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { return v2BrowserDisabledExternalOpenResult(rawURL: urlStr, url: url, tabManager: tabManager) } @@ -8377,6 +8382,7 @@ class TerminalController { workingDirectory: workingDirectory, initialCommand: initialCommand, tmuxStartCommand: tmuxStartCommand, + startupEnvironment: startupEnvironment, initialDividerPosition: initialDividerPosition.map { CGFloat($0) } )?.id } diff --git a/Sources/TerminalControllerV2ParamParsingSupport.swift b/Sources/TerminalControllerV2ParamParsingSupport.swift index cb9f633928ee..b6059e5a6b23 100644 --- a/Sources/TerminalControllerV2ParamParsingSupport.swift +++ b/Sources/TerminalControllerV2ParamParsingSupport.swift @@ -44,6 +44,18 @@ extension TerminalController { return nil } + func v2TrimmedStringMap(_ params: [String: Any], keys: [String]) -> [String: String] { + for key in keys { + guard let raw = v2StringMap(params, key) else { continue } + return raw.reduce(into: [String: String]()) { result, pair in + let normalizedKey = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKey.isEmpty else { return } + result[normalizedKey] = pair.value + } + } + return [:] + } + func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { guard let action = v2String(params, key) else { return nil } return action.lowercased().replacingOccurrences(of: "-", with: "_") diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 3f50cfd9095e..932a56f63058 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -20,6 +20,8 @@ extension Workspace { "qoder", "rovodev", ] + private static let managedSubagentEnvironmentKey = "CMUX_AGENT_MANAGED_SUBAGENT" + private static let truthyStartupEnvironmentValues: Set = ["1", "true", "yes", "on", "enabled"] func agentRuntimeState(forPanelId panelId: UUID) -> DetachedAgentRuntimeState? { let pidKeys = agentPIDKeysByPanelId[panelId] ?? [] @@ -125,24 +127,28 @@ extension Workspace { } func suppressesRawTerminalNotification(panelId: UUID?) -> Bool { - if let panelId { - let panelKeys = agentPIDKeysByPanelId[panelId] ?? [] - return panelKeys.contains { isStructuredAgentHookPIDKey($0) } + guard let panelId else { + return false } - return false - } + if AgentSubagentNotificationSettings.suppressNotifications(), + terminalPanelHasManagedSubagentStartupEnvironment(panelId: panelId) { + return true + } - func agentPIDs(forPanelId panelId: UUID?) -> [String: pid_t] { - var agentPIDsForPanel = agentPIDs.filter { agentPIDPanelIdsByKey[$0.key] == nil } - guard let panelId else { return agentPIDsForPanel } let panelKeys = agentPIDKeysByPanelId[panelId] ?? [] - for key in panelKeys { - if let pid = agentPIDs[key] { - agentPIDsForPanel[key] = pid - } + return panelKeys.contains { isStructuredAgentHookPIDKey($0) } + } + + private func terminalPanelHasManagedSubagentStartupEnvironment(panelId: UUID) -> Bool { + guard let rawValue = terminalPanel(for: panelId)? + .surface + .startupEnvironmentValue(Self.managedSubagentEnvironmentKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() else { + return false } - return agentPIDsForPanel + return Self.truthyStartupEnvironmentValues.contains(rawValue) } func sidebarStatusEntriesVisibleForDisplay() -> [SidebarStatusEntry] { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8743d296599f..0959bc7a89f1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10430,6 +10430,7 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: String? = nil, initialCommand: String? = nil, tmuxStartCommand: String? = nil, + startupEnvironment: [String: String] = [:], initialDividerPosition: CGFloat? = nil ) -> TerminalPanel? { #if DEBUG @@ -10510,7 +10511,8 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal, initialCommand: startupCommand, - tmuxStartCommand: tmuxStartCommand + tmuxStartCommand: tmuxStartCommand, + additionalEnvironment: startupEnvironment ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 13a22ab25128..f0e297c05808 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -782,37 +782,6 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertFalse(AgentSubagentNotificationSettings.suppressNotifications(defaults: defaults)) } - func testAgentManagedDesktopNotificationSuppressionCoversCodexHookSessions() { - XCTAssertTrue( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: ["codex.same-process-session": pid_t(123)], - claudeHooksEnabled: false, - suppressSubagentNotifications: true - ) - ) - XCTAssertFalse( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: ["codex.same-process-session": pid_t(123)], - claudeHooksEnabled: false, - suppressSubagentNotifications: false - ) - ) - XCTAssertTrue( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: ["claude_code": pid_t(456)], - claudeHooksEnabled: true, - suppressSubagentNotifications: false - ) - ) - XCTAssertFalse( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: ["custom": pid_t(789)], - claudeHooksEnabled: true, - suppressSubagentNotifications: true - ) - ) - } - func testTelemetryDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 5cc13af5265b..f5d00e6fa7fd 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -519,6 +519,59 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) } + func testPaneCreateStartupEnvironmentMarksManagedSubagentForRawNotificationSuppression() async throws { + let socketPath = makeSocketPath("pane-env") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + let defaults = UserDefaults.standard + let previousSuppressionDefault = defaults.object(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + + defaults.set(true, forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + if let previousSuppressionDefault { + defaults.set(previousSuppressionDefault, forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + } else { + defaults.removeObject(forKey: AgentSubagentNotificationSettings.suppressNotificationsKey) + } + } + + let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: sourcePanelId)) + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let response = try await sendV2RequestAsync( + method: "pane.create", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": sourcePanelId.uuidString, + "direction": "right", + "startup_environment": [ + "CMUX_AGENT_MANAGED_SUBAGENT": "1" + ] + ], + to: socketPath + ) + + XCTAssertEqual(response["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(response)") + let result = try XCTUnwrap(response["result"] as? [String: Any], "Unexpected JSON-RPC response: \(response)") + let newSurfaceIDString = try XCTUnwrap(result["surface_id"] as? String) + let newSurfaceID = try XCTUnwrap(UUID(uuidString: newSurfaceIDString)) + let newPanel = try XCTUnwrap(workspace.panels[newSurfaceID] as? TerminalPanel) + + XCTAssertEqual(newPanel.surface.startupEnvironmentValue("CMUX_AGENT_MANAGED_SUBAGENT"), "1") + XCTAssertTrue(workspace.suppressesRawTerminalNotification(panelId: newSurfaceID)) + XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: sourcePanelId)) + } + func testSurfaceRelayRPCsReturnResolvedFocusedSurfaceWhenSurfaceIDOmitted() async throws { let socketPath = makeSocketPath("relay-fallback") let manager = TabManager() diff --git a/cmuxTests/TerminalNotificationClearAllTests.swift b/cmuxTests/TerminalNotificationClearAllTests.swift index b3d69a394b17..bd6af331a325 100644 --- a/cmuxTests/TerminalNotificationClearAllTests.swift +++ b/cmuxTests/TerminalNotificationClearAllTests.swift @@ -318,41 +318,21 @@ final class TerminalNotificationClearAllTests: XCTestCase { XCTAssertTrue(workspace.suppressesRawTerminalNotification(panelId: firstPanelId)) XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: secondPanel.id)) XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: nil)) - XCTAssertTrue( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: workspace.agentPIDs(forPanelId: firstPanelId), - claudeHooksEnabled: true, - suppressSubagentNotifications: true - ) - ) - XCTAssertFalse( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: workspace.agentPIDs(forPanelId: secondPanel.id), - claudeHooksEnabled: true, - suppressSubagentNotifications: true - ) - ) workspace.recordAgentPID(key: "custom-tool.session", pid: pid_t(12346), panelId: secondPanel.id) XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: secondPanel.id)) - workspace.recordAgentPID(key: "codex.unbound-session", pid: pid_t(12347), panelId: nil) - - XCTAssertTrue( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: workspace.agentPIDs(forPanelId: secondPanel.id), - claudeHooksEnabled: true, - suppressSubagentNotifications: true - ) - ) - XCTAssertTrue( - GhosttyApp.shouldSuppressAgentManagedDesktopNotification( - agentPIDs: workspace.agentPIDs(forPanelId: nil), - claudeHooksEnabled: true, - suppressSubagentNotifications: true + let managedSubagentPanel = try XCTUnwrap( + workspace.newTerminalSplit( + from: secondPanel.id, + orientation: .horizontal, + startupEnvironment: ["CMUX_AGENT_MANAGED_SUBAGENT": "1"] ) ) + + XCTAssertTrue(workspace.suppressesRawTerminalNotification(panelId: managedSubagentPanel.id)) + XCTAssertFalse(workspace.suppressesRawTerminalNotification(panelId: secondPanel.id)) } func testSidebarStatusOnlyShowsStructuredAgentStatusBackedByLivePanelRuntime() throws { diff --git a/web/messages/ar.json b/web/messages/ar.json index b73a06485a61..91462ad6d060 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -303,7 +303,7 @@ "metaTitle": "الإعدادات", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "قم بإخفاء إشعارات الإكمال المرئية وتغييرات الحالة من وكلاء Codex أو Claude الفرعيين المتداخلين مع إبقاء أحداثهم في قياسات Feed." } } }, diff --git a/web/messages/bs.json b/web/messages/bs.json index aabc92d8bf81..302bc998d366 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -303,7 +303,7 @@ "metaTitle": "Konfiguracija", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Sakrij vidljiva obavještenja o završetku i promjene statusa od ugniježđenih Codex ili Claude podagenata, uz zadržavanje njihovih događaja u Feed telemetriji." } } }, diff --git a/web/messages/da.json b/web/messages/da.json index 7bb38bba0cee..84b7a2f9071f 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -303,7 +303,7 @@ "metaTitle": "Konfiguration", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Skjul synlige fuldførelsesnotifikationer og statusændringer fra indlejrede Codex- eller Claude-underagenter, mens deres hændelser bevares i Feed-telemetri." } } }, diff --git a/web/messages/de.json b/web/messages/de.json index affa6d88a732..dea42e50a8f6 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -303,7 +303,7 @@ "metaTitle": "Konfiguration", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Unterdrücke sichtbare Abschlussbenachrichtigungen und Statusänderungen von verschachtelten Codex- oder Claude-Unteragenten, während ihre Ereignisse in der Feed-Telemetrie bleiben." } } }, diff --git a/web/messages/es.json b/web/messages/es.json index a065a78305bc..f1b2a8d75ba8 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -303,7 +303,7 @@ "metaTitle": "Configuración", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Suprime las notificaciones visibles de finalización y los cambios de estado de agentes secundarios anidados de Codex o Claude, manteniendo sus eventos en la telemetría de Feed." } } }, diff --git a/web/messages/fr.json b/web/messages/fr.json index e2759d72e05b..3844e13c6fe8 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -303,7 +303,7 @@ "metaTitle": "Configuration", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Masque les notifications visibles de fin et les changements d’état des agents enfants Codex ou Claude imbriqués, tout en conservant leurs événements dans la télémétrie Feed." } } }, diff --git a/web/messages/it.json b/web/messages/it.json index 958a53a997cd..a838045ff274 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -303,7 +303,7 @@ "metaTitle": "Configurazione", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Nasconde le notifiche visibili di completamento e le modifiche di stato degli agenti figli Codex o Claude annidati, mantenendo i loro eventi nella telemetria Feed." } } }, diff --git a/web/messages/ja.json b/web/messages/ja.json index 91118d3b5206..157bd35e81dc 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -361,7 +361,7 @@ "metaTitle": "設定", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "ネストされた Codex または Claude の子エージェントからの表示される完了通知とステータス変更を抑制し、それらのイベントは Feed テレメトリに残します。" } } }, diff --git a/web/messages/km.json b/web/messages/km.json index 443729baea34..56df46c65de6 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -303,7 +303,7 @@ "metaTitle": "ការកំណត់រចនាសម្ព័ន្ធ", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "បិទការជូនដំណឹងបញ្ចប់ដែលបង្ហាញ និងការផ្លាស់ប្តូរស្ថានភាពពីភ្នាក់ងារកូន Codex ឬ Claude ដែលបានបង្កប់ ខណៈពេលរក្សាព្រឹត្តិការណ៍របស់វានៅក្នុងទេលេមេទ្រី Feed។" } } }, diff --git a/web/messages/ko.json b/web/messages/ko.json index 9c8c506c9f64..7fa55f015ab9 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -303,7 +303,7 @@ "metaTitle": "설정", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "중첩된 Codex 또는 Claude 하위 에이전트의 표시되는 완료 알림과 상태 변경을 억제하면서 해당 이벤트는 Feed 텔레메트리에 유지합니다." } } }, diff --git a/web/messages/no.json b/web/messages/no.json index 3d67bf627b37..72b75849751a 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -303,7 +303,7 @@ "metaTitle": "Konfigurasjon", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Undertrykk synlige fullføringsvarsler og statusendringer fra innebygde Codex- eller Claude-underagenter, samtidig som hendelsene deres beholdes i Feed-telemetri." } } }, diff --git a/web/messages/pl.json b/web/messages/pl.json index 68b05c822ed1..a9a66a12c7bf 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -303,7 +303,7 @@ "metaTitle": "Konfiguracja", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Ukrywa widoczne powiadomienia o zakończeniu i zmiany statusu zagnieżdżonych agentów podrzędnych Codex lub Claude, zachowując ich zdarzenia w telemetrii Feed." } } }, diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index ccb8948852ff..c051ea51cb88 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -303,7 +303,7 @@ "metaTitle": "Configuração", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Suprime notificações visíveis de conclusão e alterações de status de agentes filhos aninhados do Codex ou Claude, mantendo seus eventos na telemetria do Feed." } } }, diff --git a/web/messages/ru.json b/web/messages/ru.json index 28050dce6ccf..adb770eecae0 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -303,7 +303,7 @@ "metaTitle": "Конфигурация", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Подавляет видимые уведомления о завершении и изменения статуса от вложенных дочерних агентов Codex или Claude, сохраняя их события в телеметрии Feed." } } }, diff --git a/web/messages/th.json b/web/messages/th.json index f97841d9b3dc..e5b2b0e2f25d 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -303,7 +303,7 @@ "metaTitle": "การตั้งค่า", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "ซ่อนการแจ้งเตือนการทำงานเสร็จและการเปลี่ยนสถานะที่มองเห็นได้จากเอเจนต์ลูก Codex หรือ Claude แบบซ้อน โดยยังคงเก็บเหตุการณ์ไว้ในเทเลเมทรี Feed" } } }, diff --git a/web/messages/tr.json b/web/messages/tr.json index 63436f24fb46..67bbfab015e3 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -303,7 +303,7 @@ "metaTitle": "Yapılandırma", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "İç içe Codex veya Claude alt ajanlarından gelen görünür tamamlanma bildirimlerini ve durum değişikliklerini gizler, olaylarını Feed telemetrisinde tutar." } } }, diff --git a/web/messages/uk.json b/web/messages/uk.json index b00628041409..7ce4ab090ce2 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -303,7 +303,7 @@ "exampleConfig": "Приклад конфігурації", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "Приховує видимі сповіщення про завершення та зміни статусу від вкладених дочірніх агентів Codex або Claude, зберігаючи їхні події в телеметрії Feed." } } }, diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 92a25c810ab4..d0e43001ff09 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -303,7 +303,7 @@ "metaTitle": "配置", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "隐藏嵌套 Codex 或 Claude 子代理的可见完成通知和状态变更,同时将其事件保留在 Feed 遥测中。" } } }, diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index d8ec5ca0e908..63b670fe9af4 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -303,7 +303,7 @@ "metaTitle": "設定", "schemaDescriptions": { "automation": { - "suppressSubagentNotifications": "Suppress visible completion notifications and status mutations from nested Codex or Claude child agents while keeping their events in Feed telemetry." + "suppressSubagentNotifications": "隱藏巢狀 Codex 或 Claude 子代理的可見完成通知和狀態變更,同時將其事件保留在 Feed 遙測中。" } } },