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: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"version": "0.4.1",
"version": "0.4.2",
"category": "productivity",
"tags": ["notifications", "terminal", "warp"]
},
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container-id>:/home/agent/.codex/plugins/cache/codex-warp/warp/<version>/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

Expand Down
2 changes: 1 addition & 1 deletion plugins/warp/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/warp/scripts/on-session-start.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/warp/scripts/on-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 50 additions & 3 deletions plugins/warp/scripts/warp-notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,53 @@ fi
TITLE="${1:-Notification}"
BODY="${2:-}"

# OSC 777 format: \033]777;notify;<title>;<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
207 changes: 207 additions & 0 deletions tests/notification_tty_test.py
Original file line number Diff line number Diff line change
@@ -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()
5 changes: 5 additions & 0 deletions tests/test-notifications.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
set -euo pipefail

TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 "$TEST_DIR/notification_tty_test.py"
Loading