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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ 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
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.
Expand Down
80 changes: 38 additions & 42 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14413,29 +14413,11 @@ struct CMUXCLI {
var compact: [String: Any] = [:]

for key in [
"tool_name",
"turn_id",
"turnId",
"last_assistant_message",
"lastAssistantMessage",
"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
Expand Down Expand Up @@ -14494,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
Expand All @@ -14524,7 +14495,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
Expand Down Expand Up @@ -14648,16 +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) {
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.
Expand All @@ -14675,6 +14655,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?
}
Expand Down
34 changes: 34 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 4 additions & 2 deletions Resources/bin/claude
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" "$@"
Expand Down
180 changes: 180 additions & 0 deletions tests/test_claude_hook_stop_last_assistant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/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:
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")))
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() + 2.0
buffer += chunk
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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())
5 changes: 5 additions & 0 deletions tests/test_claude_wrapper_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down