From ee5c3f8a37c1e4cbd5e9a547c823ac59910869d9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 14:33:43 -0700 Subject: [PATCH 1/6] test: cover Claude wrapper notification channel --- .github/workflows/ci.yml | 1 + tests/test_claude_wrapper_hooks.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5312351fabc..7482a49aa1ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,6 +306,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_contract_help.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py + python3 tests/test_claude_wrapper_hooks.py tests-build-and-lag: # Build the full cmux scheme and run the lag regression on WarpBuild. diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index c87599f4b163..b2ed7b8cc8b0 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -260,6 +260,11 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None: expect(hook_cmux_bin.endswith("/bundled cli/cmux"), f"live socket: expected bundled cmux pin, got {hook_cmux_bin!r}", failures) settings = parse_settings_arg(real_argv) + expect( + settings.get("preferredNotifChannel") == "notifications_disabled", + f"expected Claude notifications disabled in generated settings, got {settings}", + failures, + ) hooks = settings.get("hooks", {}) expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PermissionRequest"} expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) From 2b8978acf5f1c06baaeb47c0500a98db6a0b390f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 14:33:46 -0700 Subject: [PATCH 2/6] fix: disable Claude OSC notifications in wrapper --- Resources/bin/claude | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Resources/bin/claude b/Resources/bin/claude index 59a472059582..268f3c733ff9 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -253,8 +253,10 @@ if GUARD_PATH="$(ensure_node_options_restore_module)"; then export NODE_OPTIONS="$(merge_node_options "$GUARD_PATH")" fi -# Build hooks settings JSON. +# Build Claude settings JSON. # Claude Code merges --settings additively with the user's own settings.json. +# - preferredNotifChannel: disables Claude Code's OSC/terminal notification +# channel inside cmux so hooks are the only notification source. # - SessionStart/Stop/Notification: existing lifecycle hooks # - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire) # - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt @@ -263,7 +265,7 @@ 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='{"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":"","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}]}]}}' +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":"","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" "${CMUX_BYPASS_AVAILABILITY_ARGS[@]}" --settings "$HOOKS_JSON" "$@" From aa61f9ebe941e3b624442376a390ed3dcd61f949 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 14:52:12 -0700 Subject: [PATCH 3/6] test: cover Claude stop final message --- .github/workflows/ci.yml | 1 + tests/test_claude_hook_stop_last_assistant.py | 178 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/test_claude_hook_stop_last_assistant.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7482a49aa1ef..9395270d4445 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,6 +307,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_contract_help.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py python3 tests/test_claude_wrapper_hooks.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py tests-build-and-lag: # Build the full cmux scheme and run the lag regression on WarpBuild. diff --git a/tests/test_claude_hook_stop_last_assistant.py b/tests/test_claude_hook_stop_last_assistant.py new file mode 100644 index 000000000000..172145e0a663 --- /dev/null +++ b/tests/test_claude_hook_stop_last_assistant.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Regression: Claude Stop notifications should use the final assistant text.""" + +from __future__ import annotations + +import glob +import json +import os +import shutil +import socket +import subprocess +import tempfile +import threading +import time +import uuid + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [path for path in candidates if os.path.exists(path) and os.access(path, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +class CapturingSocketServer: + def __init__(self) -> None: + self.commands: list[str] = [] + self.ready = threading.Event() + self.stop = threading.Event() + self.error: Exception | None = None + self.root = tempfile.TemporaryDirectory(prefix="cmux-claude-stop-") + self.socket_path = os.path.join(self.root.name, "cmux.sock") + self.thread = threading.Thread(target=self._run, daemon=True) + self.server: socket.socket | None = None + + def __enter__(self) -> "CapturingSocketServer": + self.thread.start() + if not self.ready.wait(timeout=2.0): + raise RuntimeError("socket server did not become ready") + if self.error is not None: + raise self.error + return self + + def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + self.stop.set() + if self.server is not None: + self.server.close() + self.thread.join(timeout=2.0) + self.root.cleanup() + + def _run(self) -> None: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + self.server = server + server.bind(self.socket_path) + server.listen(4) + server.settimeout(0.1) + self.ready.set() + while not self.stop.is_set(): + try: + conn, _ = server.accept() + except socket.timeout: + continue + except OSError: + return + threading.Thread(target=self._handle, args=(conn,), daemon=True).start() + except Exception as exc: + self.error = exc + self.ready.set() + + def _handle(self, conn: socket.socket) -> None: + with conn: + conn.settimeout(0.1) + buffer = b"" + idle_deadline = time.time() + 6.0 + while not self.stop.is_set() and time.time() < idle_deadline: + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + break + idle_deadline = time.time() + 0.5 + buffer += chunk + while b"\n" in buffer: + raw_line, buffer = buffer.split(b"\n", 1) + if not raw_line: + continue + line = raw_line.decode("utf-8", errors="replace") + self.commands.append(line) + conn.sendall((self._response_for(line) + "\n").encode("utf-8")) + + def _response_for(self, line: str) -> str: + if line.startswith("{"): + try: + request = json.loads(line) + return json.dumps({"id": request.get("id"), "ok": True, "result": {}}) + except json.JSONDecodeError: + pass + return "OK" + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + workspace_id = str(uuid.uuid4()).upper() + surface_id = str(uuid.uuid4()).upper() + payload = { + "session_id": f"sess-{uuid.uuid4().hex}", + "hook_event_name": "Stop", + "cwd": "/Users/lawrence/fun", + "last_assistant_message": "2", + } + + with CapturingSocketServer() as server: + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = server.socket_path + env["CMUX_WORKSPACE_ID"] = workspace_id + env["CMUX_SURFACE_ID"] = surface_id + env["CMUX_CLAUDE_HOOK_STATE_PATH"] = os.path.join(server.root.name, "state.json") + env["CMUX_CLI_SENTRY_DISABLED"] = "1" + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + proc = subprocess.run( + [cli_path, "--socket", server.socket_path, "claude-hook", "stop"], + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + timeout=8, + check=False, + ) + + if proc.returncode != 0: + print("FAIL: claude-hook stop failed") + print(f"stdout={proc.stdout!r}") + print(f"stderr={proc.stderr!r}") + print(f"commands={server.commands!r}") + return 1 + + notify_commands = [line for line in server.commands if line.startswith("notify_target_async ")] + if not notify_commands: + print("FAIL: expected notify_target_async command") + print(f"commands={server.commands!r}") + return 1 + + notify = notify_commands[-1] + expected_payload = f"notify_target_async {workspace_id} {surface_id} Claude Code|Completed in fun|2" + if notify != expected_payload: + print("FAIL: expected stop notification to use final assistant text") + print(f"expected={expected_payload!r}") + print(f"actual={notify!r}") + print(f"commands={server.commands!r}") + return 1 + + print("PASS: Claude Stop notification uses final assistant text") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 28f1a92788282256a2efabd97b099cde562b4afb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 14:52:53 -0700 Subject: [PATCH 4/6] fix: use Claude stop final message --- CLI/cmux.swift | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3b013997799d..5d18ec024d93 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14418,6 +14418,8 @@ struct CMUXCLI { "turnId", "last_assistant_message", "lastAssistantMessage", + "assistantPreamble", + "assistant_preamble", "event", "event_name", "hook_event_name", @@ -14524,7 +14526,7 @@ struct CMUXCLI { switch key { case "tool_name", "turn_id", "turnId", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason": return 80 - case "last_assistant_message", "lastAssistantMessage", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description": + case "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description": return 240 default: return 160 @@ -14649,6 +14651,14 @@ struct CMUXCLI { return tail.isEmpty ? path : tail }() + if let assistantMessage = claudeAssistantMessageFromHookPayload(parsedInput.object) { + var subtitle = "Completed" + if let projectName, !projectName.isEmpty { + subtitle = "Completed in \(projectName)" + } + return (subtitle, truncate(assistantMessage, maxLength: 200)) + } + // Try reading the transcript JSONL for a richer summary. let transcript = transcriptPath.flatMap { readTranscriptSummary(path: $0) } @@ -14675,6 +14685,22 @@ struct CMUXCLI { return ("Completed", body) } + private func claudeAssistantMessageFromHookPayload(_ object: [String: Any]?) -> String? { + guard let object else { return nil } + let message = firstString( + in: object, + keys: [ + "last_assistant_message", + "lastAssistantMessage", + "assistantPreamble", + "assistant_preamble", + ] + ) + guard let message else { return nil } + let normalized = normalizedSingleLine(message) + return normalized.isEmpty ? nil : normalized + } + private struct TranscriptSummary { let lastAssistantMessage: String? } From db7a6ab7635b82645a528b0ec4272c15bce1a97c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 14:59:04 -0700 Subject: [PATCH 5/6] chore: keep Claude hook change under line budget --- CLI/cmux.swift | 45 +++++++-------------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5d18ec024d93..30ec337c1bcc 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14413,31 +14413,11 @@ struct CMUXCLI { var compact: [String: Any] = [:] for key in [ - "tool_name", - "turn_id", - "turnId", - "last_assistant_message", - "lastAssistantMessage", - "assistantPreamble", - "assistant_preamble", - "event", - "event_name", - "hook_event_name", - "type", - "kind", - "notification_type", - "matcher", - "reason", - "message", - "body", - "text", - "prompt", - "error", - "codex_error_info", - "codexErrorInfo", - "additional_details", - "additionalDetails", - "description", + "tool_name", "turn_id", "turnId", + "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", + "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", + "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", + "additional_details", "additionalDetails", "description", ] { if let value = compactClaudeHookValue(object[key], key: key) { compact[key] = value @@ -14496,19 +14476,8 @@ struct CMUXCLI { guard let nested = object[key] as? [String: Any] else { continue } var compactNested: [String: Any] = [:] for nestedKey in [ - "type", - "kind", - "reason", - "message", - "body", - "text", - "prompt", - "error", - "codex_error_info", - "codexErrorInfo", - "additional_details", - "additionalDetails", - "description", + "type", "kind", "reason", "message", "body", "text", "prompt", "error", + "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description", ] { if let value = compactClaudeHookValue(nested[nestedKey], key: nestedKey) { compactNested[nestedKey] = value From dcb167fe62ef88df3d758b8b3edb0f5a12379091 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 15:49:49 -0700 Subject: [PATCH 6/6] fix: address Claude hook review feedback --- CLI/cmux.swift | 21 ++++++------ Resources/Localizable.xcstrings | 34 +++++++++++++++++++ tests/test_claude_hook_stop_last_assistant.py | 8 +++-- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4bb29f3d403a..ce67c6e6278d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14619,24 +14619,25 @@ struct CMUXCLI { let tail = URL(fileURLWithPath: path).lastPathComponent return tail.isEmpty ? path : tail }() + let completedSubtitle: String = { + guard let projectName, !projectName.isEmpty else { + return String(localized: "agent.claude.completion.subtitle.completed", defaultValue: "Completed") + } + return String.localizedStringWithFormat( + String(localized: "agent.claude.completion.subtitle.completedInProject", defaultValue: "Completed in %@"), + projectName + ) + }() if let assistantMessage = claudeAssistantMessageFromHookPayload(parsedInput.object) { - var subtitle = "Completed" - if let projectName, !projectName.isEmpty { - subtitle = "Completed in \(projectName)" - } - return (subtitle, truncate(assistantMessage, maxLength: 200)) + return (completedSubtitle, truncate(assistantMessage, maxLength: 200)) } // Try reading the transcript JSONL for a richer summary. let transcript = transcriptPath.flatMap { readTranscriptSummary(path: $0) } if let lastMsg = transcript?.lastAssistantMessage { - var subtitle = "Completed" - if let projectName, !projectName.isEmpty { - subtitle = "Completed in \(projectName)" - } - return (subtitle, truncate(lastMsg, maxLength: 200)) + return (completedSubtitle, truncate(lastMsg, maxLength: 200)) } // Fallback: use session record data. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4dc14a746324..6d478139d0f2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -19465,6 +19465,40 @@ } } }, + "agent.claude.completion.subtitle.completed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Completed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "agent.claude.completion.subtitle.completedInProject": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Completed in %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ で完了" + } + } + } + }, "agent.codex.completion.body.sessionCompleted": { "extractionState": "manual", "localizations": { diff --git a/tests/test_claude_hook_stop_last_assistant.py b/tests/test_claude_hook_stop_last_assistant.py index 172145e0a663..be234cc807d2 100644 --- a/tests/test_claude_hook_stop_last_assistant.py +++ b/tests/test_claude_hook_stop_last_assistant.py @@ -17,8 +17,10 @@ def resolve_cmux_cli() -> str: explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") - if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): - return explicit + if explicit: + if os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + raise RuntimeError(f"Configured cmux CLI is not executable: {explicit}") candidates: list[str] = [] candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) @@ -93,7 +95,7 @@ def _handle(self, conn: socket.socket) -> None: continue if not chunk: break - idle_deadline = time.time() + 0.5 + idle_deadline = time.time() + 2.0 buffer += chunk while b"\n" in buffer: raw_line, buffer = buffer.split(b"\n", 1)