diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index c68248b..95bfcd9 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -14,7 +14,7 @@ "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, - "version": "0.4.1", + "version": "0.4.2", "category": "productivity", "tags": ["notifications", "terminal", "warp"] }, diff --git a/README.md b/README.md index 87ffa49..e1f5407 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,39 @@ codex plugin add orchestration@codex-warp Fast shell suite: ```sh bash tests/test-hooks.sh +bash tests/test-notifications.sh ``` This uses a fake `oz` CLI and a temp `CODEX_HOME`. It validates parent-message staging/drain/blocking and plugin manifests. +The notification suite uses isolated pseudo-terminals to check both inherited +and detached hook sessions. It requires Python 3 and does not emit notifications +into your terminal. +## Notification transport +The POSIX notifier uses `/dev/tty` when a controlling terminal is available. +Codex 0.155.0 and later detach hooks from that terminal, so the notifier falls +back to an ancestor's terminal device. Linux uses `/proc` and `readlink`; macOS +uses `ps`. Discovery is bounded, only terminal devices are used, and failure +produces a diagnostic without failing the hook. Notification payloads and the +Windows transport are unchanged. +## One-off notifier testing in an Oz Docker task +Building the Codex CLI sidecar does not include this checkout. The CLI image +contains Codex itself; Warp normally installs this plugin at runtime. +For a disposable running Codex task, replace its cached notifier with your +local copy. Run from this checkout, substituting the task container ID and its +installed plugin version: +```sh +docker cp plugins/warp/scripts/warp-notify.sh :/home/agent/.codex/plugins/cache/codex-warp/warp//scripts/warp-notify.sh +``` +The tested layout uses `/home/agent` and plugin version `0.4.1`; adjust the path +if the task uses another home directory or `CODEX_HOME`. +Send a follow-up to that run and check that its state changes to in-progress, +then back to succeeded. Hooks execute the script on each invocation, so this +notifier-only change does not require restarting Codex. +This modifies only that container's installed copy. It does not publish the +plugin or make newly created tasks use local changes. ## Versioning `plugins/warp/scripts/on-session-start.sh` emits `PLUGIN_VERSION`. -Current plugin version: `0.4.1`. +Current plugin version: `0.4.2`. Keep it in sync with Warp's Codex plugin manager minimum version. ## Skills diff --git a/plugins/warp/.codex-plugin/plugin.json b/plugins/warp/.codex-plugin/plugin.json index 4020360..80b0245 100644 --- a/plugins/warp/.codex-plugin/plugin.json +++ b/plugins/warp/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "warp", "description": "Warp terminal integration for Codex - native notifications, and more to come", - "version": "0.4.1", + "version": "0.4.2", "author": { "name": "Warp", "url": "https://warp.dev" diff --git a/plugins/warp/scripts/on-session-start.ps1 b/plugins/warp/scripts/on-session-start.ps1 index 473b15b..258cf0d 100644 --- a/plugins/warp/scripts/on-session-start.ps1 +++ b/plugins/warp/scripts/on-session-start.ps1 @@ -7,7 +7,7 @@ if (-not (Test-ShouldUseStructured)) { exit 0 } -$pluginVersion = "0.4.1" +$pluginVersion = "0.4.2" $inputJson = Read-HookInput $body = New-WarpPayload $inputJson "session_start" @{ plugin_version = $pluginVersion diff --git a/plugins/warp/scripts/on-session-start.sh b/plugins/warp/scripts/on-session-start.sh index cf72380..f34329c 100755 --- a/plugins/warp/scripts/on-session-start.sh +++ b/plugins/warp/scripts/on-session-start.sh @@ -5,7 +5,7 @@ set -euo pipefail # Bump on every release; keep in sync with Warp's Codex plugin manager. -PLUGIN_VERSION="0.4.1" +PLUGIN_VERSION="0.4.2" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/should-use-structured.sh" diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh index 0ac1c10..b59cd9a 100755 --- a/plugins/warp/scripts/warp-notify.sh +++ b/plugins/warp/scripts/warp-notify.sh @@ -16,6 +16,53 @@ fi TITLE="${1:-Notification}" BODY="${2:-}" -# OSC 777 format: \033]777;notify;;<body>\007 -# Write directly to /dev/tty to ensure it reaches the terminal -printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null || true +write_notification() ( + [ -c "$1" ] || exit 1 + exec 3>"$1" || exit 1 + [ -t 3 ] || exit 1 + printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" >&3 +) 2>/dev/null + +if write_notification /dev/tty; then + exit 0 +fi +write_named_terminal() { + local terminal_path + case "$1" in + /dev/pts/*|/dev/tty*) terminal_path="$1" ;; + pts/*|tty*) terminal_path="/dev/$1" ;; + s[0-9]*) terminal_path="/dev/tty$1" ;; + *) return 1 ;; + esac + write_notification "$terminal_path" +} + +# Codex >= 0.155 detaches hooks with setsid and captures their stdout/stderr. +# The Codex ancestor still owns the terminal; open its device explicitly. +PID="$PPID" +for ((depth = 0; depth < 32; depth++)); do + case "$PID" in + ''|*[!0-9]*) break ;; + esac + [ "$PID" -gt 1 ] || break + if [ -r "/proc/$PID/stat" ]; then + PROCESS_STAT=$(<"/proc/$PID/stat") + read -r _ PARENT_PID _ <<< "${PROCESS_STAT##*) }" + for fd in 0 1 2; do + TERMINAL=$(readlink "/proc/$PID/fd/$fd" 2>/dev/null) || continue + if write_named_terminal "$TERMINAL"; then + exit 0 + fi + done + else + PROCESS_INFO=$(ps -p "$PID" -o ppid= -o tty= 2>/dev/null) || break + read -r PARENT_PID TERMINAL <<< "$PROCESS_INFO" + if write_named_terminal "$TERMINAL"; then + exit 0 + fi + fi + PID="$PARENT_PID" +done + +printf 'Warp notification skipped: no writable terminal found in the hook process ancestry.\n' >&2 +exit 0 diff --git a/tests/notification_tty_test.py b/tests/notification_tty_test.py new file mode 100644 index 0000000..a24b6c7 --- /dev/null +++ b/tests/notification_tty_test.py @@ -0,0 +1,207 @@ +import errno +import json +import os +import pty +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS = Path(__file__).resolve().parents[1] / "plugins" / "warp" / "scripts" +BODY = '{"type":"agent_status","status":"working"}' +EXPECTED = f"\033]777;notify;warp://cli-agent;{BODY}\007".encode() +LAUNCHER = """ +import fcntl +import json +import os +import signal +import subprocess +import sys +import tempfile +import termios + +if sys.argv[2] == "tty": + signal.signal(signal.SIGTTOU, signal.SIG_IGN) + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + os.tcsetpgrp(0, os.getpgrp()) + signal.signal(signal.SIGTTOU, signal.SIG_DFL) +os.environ["TEST_TTY_ROOT_PID"] = str(os.getpid()) +detached = sys.argv[1] == "detached" +with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: + proc = subprocess.Popen( + sys.argv[3:], stdin=subprocess.PIPE, stdout=out, stderr=err, + start_new_session=detached, + ) + try: + proc.communicate(b'{"session_id":"test-session","cwd":"/tmp/test"}', timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + raise + out.seek(0) + err.seek(0) + print(json.dumps({ + "returncode": proc.returncode, + "stdout": out.read().decode(), + "stderr": err.read().decode(), + })) +""" + + +class NotificationTTYTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + real_ps = shutil.which("ps") + real_readlink = shutil.which("readlink") + # End discovery at our synthetic terminal owner, never the real test terminal. + ps = self.root / "ps" + ps.write_text( + """#!/bin/bash +if [ -n "${TEST_TERMINAL_OVERRIDE:-}" ]; then + printf '1 %s\\n' "$TEST_TERMINAL_OVERRIDE" + exit 0 +fi +info=$("$TEST_REAL_PS" "$@") || exit 1 +read -r parent terminal <<< "$info" +if [ "$2" = "$TEST_TTY_ROOT_PID" ]; then + parent=1 +fi +printf '%s %s\\n' "$parent" "$terminal" +""" + ) + ps.chmod(0o755) + readlink = self.root / "readlink" + readlink.write_text( + """#!/bin/bash +if [ -n "${TEST_TERMINAL_OVERRIDE:-}" ]; then + printf '%s\\n' "$TEST_TERMINAL_OVERRIDE" +else + exec "$TEST_REAL_READLINK" "$@" +fi +""" + ) + readlink.chmod(0o755) + self.env = os.environ.copy() + self.env.update( + PATH=f"{self.root}{os.pathsep}{self.env.get('PATH', '')}", + TEST_REAL_PS=real_ps or "", + TEST_REAL_READLINK=real_readlink or "", + WARP_CLIENT_VERSION="v0.2026.09.29.08.29.dev_00", + WARP_CLI_AGENT_PROTOCOL_VERSION="1", + ) + + def run_hook(self, args, detached, terminal=True): + master, slave = pty.openpty() + try: + env = self.env.copy() + if not terminal: + env["TEST_TERMINAL_OVERRIDE"] = "/dev/null" + + proc = subprocess.Popen( + [sys.executable, "-c", LAUNCHER, + "detached" if detached else "inherited", + "tty" if terminal else "none", *args], + stdin=slave if terminal else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=True, + ) + try: + stdout, stderr = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + os.close(master) + master = None + os.close(slave) + slave = None + try: + proc.kill() + except (PermissionError, ProcessLookupError): + pass + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + proc.stdout.close() + proc.stderr.close() + self.fail("hook did not finish within the deadline") + self.assertEqual(proc.returncode, 0, stderr.decode()) + result = json.loads(stdout) + os.set_blocking(master, False) + output = bytearray() + while True: + try: + chunk = os.read(master, 4096) + except OSError as error: + if error.errno in (errno.EIO, errno.EAGAIN): + break + raise + if not chunk: + break + output.extend(chunk) + return result, bytes(output) + finally: + if master is not None: + os.close(master) + if slave is not None: + os.close(slave) + + def test_inherited_and_detached_notifications_reach_terminal(self): + for detached in (False, True): + with self.subTest(detached=detached): + result, output = self.run_hook( + [str(SCRIPTS / "warp-notify.sh"), "warp://cli-agent", BODY], + detached, + ) + self.assertEqual(result, {"returncode": 0, "stdout": "", "stderr": ""}) + self.assertEqual(output, EXPECTED) + + def test_detached_lifecycle_hooks_reach_terminal(self): + for hook in ("on-session-start.sh", "on-prompt-submit.sh", "on-stop.sh"): + with self.subTest(hook=hook): + result, output = self.run_hook([str(SCRIPTS / hook)], detached=True) + self.assertEqual(result, {"returncode": 0, "stdout": "", "stderr": ""}) + prefix = b"\033]777;notify;warp://cli-agent;" + self.assertTrue(output.startswith(prefix), output) + self.assertTrue(output.endswith(b"\007"), output) + payload = json.loads(output[len(prefix):-1]) + self.assertEqual(payload["session_id"], "test-session") + + def test_no_terminal_is_nonfatal_and_diagnosable(self): + result, output = self.run_hook( + [str(SCRIPTS / "warp-notify.sh"), "warp://cli-agent", BODY], + detached=True, + terminal=False, + ) + self.assertEqual(result["returncode"], 0) + self.assertEqual(result["stdout"], "") + self.assertIn("no writable terminal found", result["stderr"]) + self.assertEqual(output, b"") + + def test_unsupported_warp_emits_nothing(self): + self.env.pop("WARP_CLI_AGENT_PROTOCOL_VERSION") + result, output = self.run_hook( + [str(SCRIPTS / "warp-notify.sh"), "warp://cli-agent", BODY], + detached=True, + ) + self.assertEqual(result, {"returncode": 0, "stdout": "", "stderr": ""}) + self.assertEqual(output, b"") + + def test_non_terminal_device_is_not_used(self): + self.env["TEST_TERMINAL_OVERRIDE"] = "/dev/tty-invalid-test" + result, output = self.run_hook( + [str(SCRIPTS / "warp-notify.sh"), "warp://cli-agent", BODY], + detached=True, + ) + self.assertEqual(result["returncode"], 0) + self.assertIn("no writable terminal found", result["stderr"]) + self.assertEqual(output, b"") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test-notifications.sh b/tests/test-notifications.sh new file mode 100644 index 0000000..9cd97dd --- /dev/null +++ b/tests/test-notifications.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 "$TEST_DIR/notification_tty_test.py"