From 2ac24e8df63758853de4bc84e321b9e57ab456e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:57:20 +0000 Subject: [PATCH 1/6] fix(notify): leave headless agent runs alone A script driving an agent (`claude -p` / `--print`, `codex exec`) fires the same hooks as a person at a terminal. Its PermissionRequest became a prompt nobody could answer, with the hook holding the run for up to 550s on the FIFO; its Stop became a "finished" nudge every time the script ran. notify.sh now exits without a decision when the agent process behind the hook is a headless run, so the agent's own permission setting decides. STACKNUDGE_HEADLESS_NUDGES=on keeps the old behaviour. Covered by scripts/test-notify-headless.py, now part of `make test-notify`. Co-Authored-By: Claude Opus 5.5 --- Makefile | 1 + README.md | 2 ++ notify.conf.example | 8 +++++ notify.sh | 23 ++++++++++++ scripts/test-notify-headless.py | 64 +++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100755 scripts/test-notify-headless.py diff --git a/Makefile b/Makefile index 3ede664..6d011af 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,7 @@ test: test-notify: @python3 ./scripts/test-notify-subagents.py @python3 ./scripts/test-notify-permission-blocking.py + @python3 ./scripts/test-notify-headless.py @python3 ./scripts/test-hook-wiring.py # Compile-check the XCTest sources without Xcode. Catches the breakage `make diff --git a/README.md b/README.md index c72153a..32c5ede 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ Reminders only fire for prompts stack-nudge can *prove* are unanswered. Claude C **Prompts raised by a subagent say so.** Claude Code's `Task` agents and Codex's `spawn_agent` children run their tool calls through the same permission hook as the main thread, so a prompt from an `Explore` agent used to be indistinguishable from one you'd asked for; that's the case where attribution matters most, since the reaction to an unexpected approval prompt is *"I didn't ask for that"*. Those banners now read **Claude Code · Explore** (plugin-scoped agent names are shown unscoped). If you'd rather not hear from them at all, set `STACKNUDGE_SUBAGENT_NUDGES=off` and they're dropped: the agent still prompts in its own terminal, so nothing is auto-approved, but nothing tells you it's waiting either, which is why tagging is the default. Turn-end nudges never needed this: both agents convert a subagent's turn end to their own `SubagentStop` event, which stack-nudge doesn't wire. +**Headless runs stay quiet.** A script driving an agent (`claude -p` / `--print`, `codex exec`) fires the same hooks as a person at a terminal, but nobody can answer its prompts, and a held hook would stall the script for up to 550 seconds. stack-nudge posts nothing for those runs and leaves the decision to the agent's own permission setting; their turns ending aren't nudged either. Set `STACKNUDGE_HEADLESS_NUDGES=on` to treat them like any other session. + **Slack, for when you're not at the Mac.** Banners only work if you're looking at the screen. Point stack-nudge at a Slack bot token and a permission prompt also arrives as a DM **from StackNudge** — which, unlike a message you send yourself, actually notifies you. | Setting | Default | | diff --git a/notify.conf.example b/notify.conf.example index 897c1b4..cfe8964 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -74,6 +74,14 @@ # Default: tag #STACKNUDGE_SUBAGENT_NUDGES=off +# Nudges from a headless agent run: `claude -p` / `--print` or `codex exec`, +# a script driving the agent with nobody at a terminal. Off by default: such a +# run's prompts can't be answered by a person, and holding its hook stalls the +# script, so the agent's own permission setting decides instead. "on" treats +# them like any other session. +# Default: off +#STACKNUDGE_HEADLESS_NUDGES=on + # Fair-usage budget for pi, in tokens per day, per lane. pi enforces no quota # of its own — API models bill per token against your keys, local models are # free — so these are your own allowances and nothing cuts you off at them. The diff --git a/notify.sh b/notify.sh index 9a26fa9..90483ed 100755 --- a/notify.sh +++ b/notify.sh @@ -1041,6 +1041,29 @@ play_windows() { || powershell -c "[console]::beep(${freq},${dur})" 2>/dev/null } +# A headless run (`claude -p` / `--print`, `codex exec`) is a script driving +# the agent, with nobody at a terminal: a prompt from it can't be answered by a +# person, and holding its hook for the 550s FIFO wait stalls the script. Leave +# it alone and let the agent's own permission setting decide. Its turns ending +# aren't news either. STACKNUDGE_HEADLESS_NUDGES=on brings both back. +agent_is_headless() { + local pid="${NUDGE_AGENT_PID:-}" + if [[ -z "$pid" ]]; then + walk_session_chain + pid="$AGENT_PID" + fi + [[ -z "$pid" ]] && return 1 + local args + args=" $(ps -p "$pid" -o args= 2>/dev/null) " + [[ "$args" =~ \ (-p|--print)(\ |=) ]] && return 0 + [[ "$AGENT" == codex* && "$args" =~ \ exec\ ]] && return 0 + return 1 +} +if [[ "${STACKNUDGE_HEADLESS_NUDGES:-off}" != "on" ]] && agent_is_headless; then + nudge_debug "nudge skipped: the agent is a headless run" + exit 0 +fi + TITLE="$(agent_label "$AGENT")" # Say which subagent a nudge came from, so "I didn't ask for that" has an diff --git a/scripts/test-notify-headless.py b/scripts/test-notify-headless.py new file mode 100755 index 0000000..8777c51 --- /dev/null +++ b/scripts/test-notify-headless.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Behaviour tests for notify.sh's headless-run check. + +A script driving an agent (`claude -p`, `codex exec`) fires the same hooks as a +person at a terminal, but nobody can answer its prompts, and a held hook stalls +the script for up to 550s. notify.sh therefore posts nothing for a headless run +unless STACKNUDGE_HEADLESS_NUDGES=on. + +The agent is a stand-in process whose command line carries the flags under +test, seeded through NUDGE_AGENT_PID; the run itself reuses the harness in +test-notify-subagents.py (throwaway $HOME, fake panel socket). + +Usage: scripts/test-notify-headless.py [path/to/notify.sh] +""" + +import importlib.util +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location("harness", os.path.join(HERE, "test-notify-subagents.py")) +harness = importlib.util.module_from_spec(spec) +spec.loader.exec_module(harness) + +# name, agent, stand-in argv after the program, event, env, expected title (None = nothing posted) +CASES = [ + ("interactive claude prompt", "claude-code", ["--resume", "abc"], "permission", {}, "Claude Code"), + ("claude -p prompt", "claude-code", ["-p", "--output-format", "json"], "permission", {}, None), + ("claude --print stop", "claude-code", ["--print", "hi"], "stop", {}, None), + ("codex exec prompt", "codex", ["exec", "fix it"], "permission", {}, None), + ("claude -p, opted back in", "claude-code", ["-p"], "permission", + {"STACKNUDGE_HEADLESS_NUDGES": "on"}, "Claude Code"), +] + +# Written out so deleting a case can't quietly lower the bar. +EXPECTED_CASE_COUNT = 5 + + +def main(): + if sys.platform != "darwin": + print("skipped: notify.sh only posts to the panel on macOS") + return 0 + passed = 0 + for name, agent, argv, event, env, expected in CASES: + # A sleeping python whose args read like the agent's; only the args are inspected. + stand_in = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", *argv]) + try: + actual = harness.posted_title(agent, {}, dict(env, NUDGE_AGENT_PID=str(stand_in.pid)), + expected is not None, False, event) + finally: + stand_in.kill() + stand_in.wait() + ok = actual == expected + passed += ok + print("%-4s %-30s expected=%-16r actual=%r" % ("PASS" if ok else "FAIL", name, expected, actual)) + if len(CASES) != EXPECTED_CASE_COUNT: + print("FAIL ran %d cases, expected %d" % (len(CASES), EXPECTED_CASE_COUNT)) + return 1 + return 0 if passed == len(CASES) else 1 + + +if __name__ == "__main__": + sys.exit(main()) From de91c1f46f60eff7b258fae9334cb5a9e0b37a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:59:21 +0000 Subject: [PATCH 2/6] fix(notify): address review feedback - Match the headless flags per agent: `-p` / `--print` for claude only, since codex's `-p` is `--profile`; for codex, only the first argument after the binary counts. - Treat `codex e` (the exec alias) and `codex review` as headless too. - Test the $PPID walk: run the hook under a process named `claude`, with and without `-p`, NUDGE_AGENT_PID unset. Add `codex -p work` (still posts), `codex e` and `codex review` cases. - Run the headless tests in CI next to the other notify.sh scripts. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + README.md | 2 +- notify.conf.example | 10 +-- notify.sh | 32 +++++++--- scripts/test-notify-headless.py | 109 ++++++++++++++++++++++++++------ 5 files changed, 123 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ffd875..0cbced4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,9 @@ jobs: - name: notify.sh permission-blocking tests run: python3 scripts/test-notify-permission-blocking.py + - name: notify.sh headless tests + run: python3 scripts/test-notify-headless.py + # install.sh's hook helpers: the code that removes entries from agent configs. - name: install.sh hook wiring tests run: python3 scripts/test-hook-wiring.py diff --git a/README.md b/README.md index 32c5ede..8d2f91a 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Reminders only fire for prompts stack-nudge can *prove* are unanswered. Claude C **Prompts raised by a subagent say so.** Claude Code's `Task` agents and Codex's `spawn_agent` children run their tool calls through the same permission hook as the main thread, so a prompt from an `Explore` agent used to be indistinguishable from one you'd asked for; that's the case where attribution matters most, since the reaction to an unexpected approval prompt is *"I didn't ask for that"*. Those banners now read **Claude Code · Explore** (plugin-scoped agent names are shown unscoped). If you'd rather not hear from them at all, set `STACKNUDGE_SUBAGENT_NUDGES=off` and they're dropped: the agent still prompts in its own terminal, so nothing is auto-approved, but nothing tells you it's waiting either, which is why tagging is the default. Turn-end nudges never needed this: both agents convert a subagent's turn end to their own `SubagentStop` event, which stack-nudge doesn't wire. -**Headless runs stay quiet.** A script driving an agent (`claude -p` / `--print`, `codex exec`) fires the same hooks as a person at a terminal, but nobody can answer its prompts, and a held hook would stall the script for up to 550 seconds. stack-nudge posts nothing for those runs and leaves the decision to the agent's own permission setting; their turns ending aren't nudged either. Set `STACKNUDGE_HEADLESS_NUDGES=on` to treat them like any other session. +**Headless runs stay quiet.** A script driving an agent (`claude -p` / `--print`, `codex exec` / `e` / `review`) fires the same hooks as a person at a terminal, but nobody can answer its prompts, and a held hook would stall the script for up to 550 seconds. stack-nudge posts nothing for those runs and leaves the decision to the agent's own permission setting; their turns ending aren't nudged either. Set `STACKNUDGE_HEADLESS_NUDGES=on` to treat them like any other session. **Slack, for when you're not at the Mac.** Banners only work if you're looking at the screen. Point stack-nudge at a Slack bot token and a permission prompt also arrives as a DM **from StackNudge** — which, unlike a message you send yourself, actually notifies you. diff --git a/notify.conf.example b/notify.conf.example index cfe8964..55076db 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -74,11 +74,11 @@ # Default: tag #STACKNUDGE_SUBAGENT_NUDGES=off -# Nudges from a headless agent run: `claude -p` / `--print` or `codex exec`, -# a script driving the agent with nobody at a terminal. Off by default: such a -# run's prompts can't be answered by a person, and holding its hook stalls the -# script, so the agent's own permission setting decides instead. "on" treats -# them like any other session. +# Nudges from a headless agent run: `claude -p` / `--print` or `codex exec` / +# `e` / `review`, a script driving the agent with nobody at a terminal. Off by +# default: such a run's prompts can't be answered by a person, and holding its +# hook stalls the script, so the agent's own permission setting decides +# instead. "on" treats them like any other session. # Default: off #STACKNUDGE_HEADLESS_NUDGES=on diff --git a/notify.sh b/notify.sh index 90483ed..ebe8e0c 100755 --- a/notify.sh +++ b/notify.sh @@ -1041,11 +1041,17 @@ play_windows() { || powershell -c "[console]::beep(${freq},${dur})" 2>/dev/null } -# A headless run (`claude -p` / `--print`, `codex exec`) is a script driving -# the agent, with nobody at a terminal: a prompt from it can't be answered by a -# person, and holding its hook for the 550s FIFO wait stalls the script. Leave -# it alone and let the agent's own permission setting decide. Its turns ending -# aren't news either. STACKNUDGE_HEADLESS_NUDGES=on brings both back. +# A headless run (`claude -p` / `--print`, `codex exec` / `e` / `review`) is a +# script driving the agent, with nobody at a terminal: a prompt from it can't be +# answered by a person, and holding its hook for the 550s FIFO wait stalls the +# script. Leave it alone and let the agent's own permission setting decide. Its +# turns ending aren't news either. STACKNUDGE_HEADLESS_NUDGES=on brings both +# back. +# +# The flags differ per agent: codex's `-p` is `--profile`, so only its +# subcommand counts, and only in first-argument position. ps flattens argv into +# one string, so a claude prompt that itself contains " -p " still reads as +# headless. agent_is_headless() { local pid="${NUDGE_AGENT_PID:-}" if [[ -z "$pid" ]]; then @@ -1054,9 +1060,19 @@ agent_is_headless() { fi [[ -z "$pid" ]] && return 1 local args - args=" $(ps -p "$pid" -o args= 2>/dev/null) " - [[ "$args" =~ \ (-p|--print)(\ |=) ]] && return 0 - [[ "$AGENT" == codex* && "$args" =~ \ exec\ ]] && return 0 + args="$(ps -p "$pid" -o args= 2>/dev/null)" + case "$AGENT" in + claude*) + [[ " $args " =~ \ (-p|--print)(\ |=) ]] && return 0 + ;; + codex*) + local subcommand + read -r _ subcommand _ <<< "$args" + case "$subcommand" in + exec|e|review) return 0 ;; + esac + ;; + esac return 1 } if [[ "${STACKNUDGE_HEADLESS_NUDGES:-off}" != "on" ]] && agent_is_headless; then diff --git a/scripts/test-notify-headless.py b/scripts/test-notify-headless.py index 8777c51..8b3b814 100755 --- a/scripts/test-notify-headless.py +++ b/scripts/test-notify-headless.py @@ -6,17 +6,22 @@ the script for up to 550s. notify.sh therefore posts nothing for a headless run unless STACKNUDGE_HEADLESS_NUDGES=on. -The agent is a stand-in process whose command line carries the flags under -test, seeded through NUDGE_AGENT_PID; the run itself reuses the harness in -test-notify-subagents.py (throwaway $HOME, fake panel socket). +Most cases seed the agent through NUDGE_AGENT_PID: a stand-in process whose +command line carries the flags under test. One case leaves NUDGE_AGENT_PID unset +and runs the hook as the child of a process named `claude`, so notify.sh finds +the agent by walking up from $PPID, as it does under a real agent. The run +itself reuses the harness in test-notify-subagents.py (throwaway $HOME, fake +panel socket). Usage: scripts/test-notify-headless.py [path/to/notify.sh] """ import importlib.util import os +import shutil import subprocess import sys +import tempfile HERE = os.path.dirname(os.path.abspath(__file__)) spec = importlib.util.spec_from_file_location("harness", os.path.join(HERE, "test-notify-subagents.py")) @@ -29,35 +34,101 @@ ("claude -p prompt", "claude-code", ["-p", "--output-format", "json"], "permission", {}, None), ("claude --print stop", "claude-code", ["--print", "hi"], "stop", {}, None), ("codex exec prompt", "codex", ["exec", "fix it"], "permission", {}, None), + ("codex e prompt", "codex", ["e"], "permission", {}, None), + ("codex review prompt", "codex", ["review"], "permission", {}, None), + # codex's -p is --profile, not a headless flag. + ("codex -p profile prompt", "codex", ["-p", "work"], "permission", {}, "Codex"), ("claude -p, opted back in", "claude-code", ["-p"], "permission", {"STACKNUDGE_HEADLESS_NUDGES": "on"}, "Claude Code"), ] +# Hook run as the child of `claude `, NUDGE_AGENT_PID unset. The +# interactive run is the control: it shows the stand-in parent is found at all, +# so the headless run passing means the flag was read, not that the walk missed. +PARENT_CASES = [ + ("hook under interactive claude", "--resume", "Claude Code"), + ("hook under claude -p", "-p", None), +] + # Written out so deleting a case can't quietly lower the bar. -EXPECTED_CASE_COUNT = 5 +EXPECTED_CASE_COUNT = 10 + +# Scripts a codex stand-in runs: bash treats its first non-option argument as a +# script file, so each subcommand above needs a file of that name. +CODEX_SCRIPTS = ["exec", "e", "review", "work"] + + +def start_stand_in(agent, argv, workdir): + """Start a sleeping process whose `ps -o args=` reads like the agent's.""" + if agent == "codex": + # The codex check reads the first argument after the program, so the + # argv has to start right there: bash runs under argv[0] "codex". + return subprocess.Popen(["codex", *argv], executable="/bin/bash", cwd=workdir) + # The claude check matches the flags anywhere, so a python stand-in will do. + return subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", *argv]) + + +def run_under_claude_parent(workdir, flag, expect_event): + """Run a permission hook as the child of `claude `, NUDGE_AGENT_PID unset. + + ps reports a process by its executable's file name, so the parent is a copy + of bash called `claude`. macOS kills a copied system binary on launch until + it is re-signed, hence the ad-hoc codesign. + """ + claude = os.path.join(workdir, "claude") + if not os.path.exists(claude): + shutil.copy("/bin/bash", claude) + subprocess.run(["codesign", "-s", "-", "-f", claude], check=True, capture_output=True) + wrapper = os.path.join(workdir, "under-claude.sh") + with open(wrapper, "w", encoding="utf-8") as wrapper_file: + # The trailing `exit` keeps bash from exec'ing notify.sh in its own + # place, which would drop `claude` out of the process tree. + wrapper_file.write('#!/bin/sh\nexec "$CLAUDE_STAND_IN" -c \'"$@"; exit $?\' "$CLAUDE_FLAG" "$REAL_NOTIFY" "$@"\n') + os.chmod(wrapper, 0o755) + real_notify = harness.NOTIFY + harness.NOTIFY = wrapper + try: + env = {"CLAUDE_STAND_IN": claude, "CLAUDE_FLAG": flag, "REAL_NOTIFY": real_notify} + return harness.posted_title("claude-code", {}, env, expect_event, False, "permission") + finally: + harness.NOTIFY = real_notify def main(): if sys.platform != "darwin": print("skipped: notify.sh only posts to the panel on macOS") return 0 + os.environ.pop("NUDGE_AGENT_PID", None) + workdir = tempfile.mkdtemp(prefix="stack-nudge-test-headless-") + for script in CODEX_SCRIPTS: + with open(os.path.join(workdir, script), "w", encoding="utf-8") as script_file: + script_file.write("sleep 120; :\n") passed = 0 - for name, agent, argv, event, env, expected in CASES: - # A sleeping python whose args read like the agent's; only the args are inspected. - stand_in = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", *argv]) - try: - actual = harness.posted_title(agent, {}, dict(env, NUDGE_AGENT_PID=str(stand_in.pid)), - expected is not None, False, event) - finally: - stand_in.kill() - stand_in.wait() - ok = actual == expected - passed += ok - print("%-4s %-30s expected=%-16r actual=%r" % ("PASS" if ok else "FAIL", name, expected, actual)) - if len(CASES) != EXPECTED_CASE_COUNT: - print("FAIL ran %d cases, expected %d" % (len(CASES), EXPECTED_CASE_COUNT)) + try: + for name, agent, argv, event, env, expected in CASES: + stand_in = start_stand_in(agent, argv, workdir) + try: + actual = harness.posted_title(agent, {}, dict(env, NUDGE_AGENT_PID=str(stand_in.pid)), + expected is not None, False, event) + finally: + stand_in.kill() + stand_in.wait() + ok = actual == expected + passed += ok + print("%-4s %-30s expected=%-16r actual=%r" % ("PASS" if ok else "FAIL", name, expected, actual)) + + for name, flag, expected in PARENT_CASES: + actual = run_under_claude_parent(workdir, flag, expected is not None) + ok = actual == expected + passed += ok + print("%-4s %-30s expected=%-16r actual=%r" % ("PASS" if ok else "FAIL", name, expected, actual)) + finally: + shutil.rmtree(workdir, ignore_errors=True) + ran = len(CASES) + len(PARENT_CASES) + if ran != EXPECTED_CASE_COUNT: + print("FAIL ran %d cases, expected %d" % (ran, EXPECTED_CASE_COUNT)) return 1 - return 0 if passed == len(CASES) else 1 + return 0 if passed == ran else 1 if __name__ == "__main__": From ad4b02cc98f44767f858b898781ab8a839d82c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:42:19 +0000 Subject: [PATCH 3/6] test(notify): run the claude stand-in through a symlink On the CI runner the hook never ran under the re-signed copy of /bin/bash, so the interactive control case posted nothing. A symlink named `claude` gives ps the same name with no copy and no codesign. Co-Authored-By: Claude Opus 5.5 --- scripts/test-notify-headless.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/test-notify-headless.py b/scripts/test-notify-headless.py index 8b3b814..18d5824 100755 --- a/scripts/test-notify-headless.py +++ b/scripts/test-notify-headless.py @@ -71,14 +71,14 @@ def start_stand_in(agent, argv, workdir): def run_under_claude_parent(workdir, flag, expect_event): """Run a permission hook as the child of `claude `, NUDGE_AGENT_PID unset. - ps reports a process by its executable's file name, so the parent is a copy - of bash called `claude`. macOS kills a copied system binary on launch until - it is re-signed, hence the ad-hoc codesign. + ps reports a process by the name it was launched under, so the parent is + bash run through a symlink called `claude`. A copy of bash would need + re-signing to launch at all, and on the CI runner the re-signed copy never + ran the hook. """ claude = os.path.join(workdir, "claude") - if not os.path.exists(claude): - shutil.copy("/bin/bash", claude) - subprocess.run(["codesign", "-s", "-", "-f", claude], check=True, capture_output=True) + if not os.path.lexists(claude): + os.symlink("/bin/bash", claude) wrapper = os.path.join(workdir, "under-claude.sh") with open(wrapper, "w", encoding="utf-8") as wrapper_file: # The trailing `exit` keeps bash from exec'ing notify.sh in its own From 9bfda392a5e3d06471b550e452cef626937f3aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:45:55 +0000 Subject: [PATCH 4/6] test(notify): keep the wrapper script inside its temp dir Applies the path guard Aikido suggested on the wrapper write, with the stray escaped quote in its sh line dropped. Co-Authored-By: Claude Opus 5.5 --- scripts/test-notify-headless.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/test-notify-headless.py b/scripts/test-notify-headless.py index 18d5824..c432d9d 100755 --- a/scripts/test-notify-headless.py +++ b/scripts/test-notify-headless.py @@ -79,14 +79,17 @@ def run_under_claude_parent(workdir, flag, expect_event): claude = os.path.join(workdir, "claude") if not os.path.lexists(claude): os.symlink("/bin/bash", claude) - wrapper = os.path.join(workdir, "under-claude.sh") - with open(wrapper, "w", encoding="utf-8") as wrapper_file: + base_real = os.path.realpath(workdir) + wrapper_real = os.path.realpath(os.path.join(workdir, "under-claude.sh")) + if os.path.commonpath([base_real, wrapper_real]) != base_real: + raise Exception("Invalid file path") + with open(wrapper_real, "w", encoding="utf-8") as wrapper_file: # The trailing `exit` keeps bash from exec'ing notify.sh in its own # place, which would drop `claude` out of the process tree. wrapper_file.write('#!/bin/sh\nexec "$CLAUDE_STAND_IN" -c \'"$@"; exit $?\' "$CLAUDE_FLAG" "$REAL_NOTIFY" "$@"\n') - os.chmod(wrapper, 0o755) + os.chmod(wrapper_real, 0o755) real_notify = harness.NOTIFY - harness.NOTIFY = wrapper + harness.NOTIFY = wrapper_real try: env = {"CLAUDE_STAND_IN": claude, "CLAUDE_FLAG": flag, "REAL_NOTIFY": real_notify} return harness.posted_title("claude-code", {}, env, expect_event, False, "permission") From 2be4e4e92dc96fc62dfc07d035ce24cee97ade4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:47:53 +0000 Subject: [PATCH 5/6] fix(notify): address review feedback - Detect a headless claude run from CLAUDE_CODE_ENTRYPOINT (sdk-* is headless, any other value is not) and read argv only when the variable is unset, so a prompt containing " -p " no longer reads as headless. - Skip codex's global flags and their values before reading the subcommand, so `codex -c model=o3 exec` and `codex --model m e` count as headless while `codex -m exec` still posts. Co-Authored-By: Claude Opus 5.5 --- notify.sh | 35 +++++++++++++++++++++-------- scripts/test-notify-headless.py | 39 +++++++++++++++++++++------------ 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/notify.sh b/notify.sh index ebe8e0c..650dc4f 100755 --- a/notify.sh +++ b/notify.sh @@ -1048,11 +1048,21 @@ play_windows() { # turns ending aren't news either. STACKNUDGE_HEADLESS_NUDGES=on brings both # back. # -# The flags differ per agent: codex's `-p` is `--profile`, so only its -# subcommand counts, and only in first-argument position. ps flattens argv into -# one string, so a claude prompt that itself contains " -p " still reads as -# headless. +# Claude Code says how it was started in CLAUDE_CODE_ENTRYPOINT, which its +# hooks inherit: `sdk-cli` for `-p` and the other `sdk-*` values for the SDKs, +# `cli` for a terminal session, and whatever an IDE or other host set. Only an +# older Claude Code without the variable falls back to reading argv, and ps +# flattens argv into one string, so there a prompt that itself contains " -p " +# still reads as headless. +# +# codex has no such variable, so its subcommand decides. Its `-p` is +# `--profile`, and global flags can come before the subcommand, so skip them +# and their values and read the first positional word. agent_is_headless() { + if [[ "$AGENT" == claude* && -n "${CLAUDE_CODE_ENTRYPOINT:-}" ]]; then + [[ "$CLAUDE_CODE_ENTRYPOINT" == sdk-* ]] + return + fi local pid="${NUDGE_AGENT_PID:-}" if [[ -z "$pid" ]]; then walk_session_chain @@ -1066,11 +1076,18 @@ agent_is_headless() { [[ " $args " =~ \ (-p|--print)(\ |=) ]] && return 0 ;; codex*) - local subcommand - read -r _ subcommand _ <<< "$args" - case "$subcommand" in - exec|e|review) return 0 ;; - esac + local -a words + read -ra words <<< "$args" + local i=1 + while (( i < ${#words[@]} )); do + case "${words[i]}" in + -c|--config|-m|--model|-p|--profile|-a|--ask-for-approval|-s|--sandbox|-C|--cd|-i|--image) + (( i += 2 )) ;; + -*) (( i += 1 )) ;; + exec|e|review) return 0 ;; + *) return 1 ;; + esac + done ;; esac return 1 diff --git a/scripts/test-notify-headless.py b/scripts/test-notify-headless.py index c432d9d..79de7b4 100755 --- a/scripts/test-notify-headless.py +++ b/scripts/test-notify-headless.py @@ -7,7 +7,8 @@ unless STACKNUDGE_HEADLESS_NUDGES=on. Most cases seed the agent through NUDGE_AGENT_PID: a stand-in process whose -command line carries the flags under test. One case leaves NUDGE_AGENT_PID unset +command line carries the flags under test. Claude cases that set +CLAUDE_CODE_ENTRYPOINT check that the variable wins over the command line. One case leaves NUDGE_AGENT_PID unset and runs the hook as the child of a process named `claude`, so notify.sh finds the agent by walking up from $PPID, as it does under a real agent. The run itself reuses the harness in test-notify-subagents.py (throwaway $HOME, fake @@ -40,6 +41,19 @@ ("codex -p profile prompt", "codex", ["-p", "work"], "permission", {}, "Codex"), ("claude -p, opted back in", "claude-code", ["-p"], "permission", {"STACKNUDGE_HEADLESS_NUDGES": "on"}, "Claude Code"), + # The entrypoint decides when it's set: a prompt that mentions -p is still + # an interactive session, and an SDK run is headless whatever its argv. + ("claude cli, prompt says -p", "claude-code", ["fix", "-p", "parsing"], "permission", + {"CLAUDE_CODE_ENTRYPOINT": "cli"}, "Claude Code"), + ("claude sdk-cli prompt", "claude-code", ["--resume", "abc"], "permission", + {"CLAUDE_CODE_ENTRYPOINT": "sdk-cli"}, None), + ("claude sdk-ts prompt", "claude-code", [], "permission", + {"CLAUDE_CODE_ENTRYPOINT": "sdk-ts"}, None), + # Global flags and their values come before codex's subcommand. + ("codex -c value exec prompt", "codex", ["-c", "model=o3", "exec"], "permission", {}, None), + ("codex --model value e prompt", "codex", ["--model", "m", "e"], "permission", {}, None), + # A model named exec is the flag's value, not the subcommand. + ("codex -m exec prompt", "codex", ["-m", "exec"], "permission", {}, "Codex"), ] # Hook run as the child of `claude `, NUDGE_AGENT_PID unset. The @@ -51,19 +65,16 @@ ] # Written out so deleting a case can't quietly lower the bar. -EXPECTED_CASE_COUNT = 10 +EXPECTED_CASE_COUNT = 16 -# Scripts a codex stand-in runs: bash treats its first non-option argument as a -# script file, so each subcommand above needs a file of that name. -CODEX_SCRIPTS = ["exec", "e", "review", "work"] - -def start_stand_in(agent, argv, workdir): +def start_stand_in(agent, argv): """Start a sleeping process whose `ps -o args=` reads like the agent's.""" if agent == "codex": - # The codex check reads the first argument after the program, so the - # argv has to start right there: bash runs under argv[0] "codex". - return subprocess.Popen(["codex", *argv], executable="/bin/bash", cwd=workdir) + # The codex check reads the words after the program, so the argv has + # to start right there, under argv[0] "codex". yes parses no options, + # and blocks once the pipe nobody reads is full. + return subprocess.Popen(["codex", *argv], executable="/usr/bin/yes", stdout=subprocess.PIPE) # The claude check matches the flags anywhere, so a python stand-in will do. return subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", *argv]) @@ -102,14 +113,14 @@ def main(): print("skipped: notify.sh only posts to the panel on macOS") return 0 os.environ.pop("NUDGE_AGENT_PID", None) + # Set when the tests themselves run under Claude Code; it would decide + # every claude case. + os.environ.pop("CLAUDE_CODE_ENTRYPOINT", None) workdir = tempfile.mkdtemp(prefix="stack-nudge-test-headless-") - for script in CODEX_SCRIPTS: - with open(os.path.join(workdir, script), "w", encoding="utf-8") as script_file: - script_file.write("sleep 120; :\n") passed = 0 try: for name, agent, argv, event, env, expected in CASES: - stand_in = start_stand_in(agent, argv, workdir) + stand_in = start_stand_in(agent, argv) try: actual = harness.posted_title(agent, {}, dict(env, NUDGE_AGENT_PID=str(stand_in.pid)), expected is not None, False, event) From 483ad80498bad48e19f753e354c253697e421389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:01:26 +0000 Subject: [PATCH 6/6] test(notify): run the codex stand-in as a compiled no-op macOS 15's yes writes a newline over the end of argv[1], so ps showed `codex exec fix it` as `codex exec?fix it` and the bare-subcommand cases failed on CI. A program that reads none of its arguments keeps argv as given. Co-Authored-By: Claude Opus 5.5 --- scripts/test-notify-headless.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/test-notify-headless.py b/scripts/test-notify-headless.py index 79de7b4..cccb8b7 100755 --- a/scripts/test-notify-headless.py +++ b/scripts/test-notify-headless.py @@ -68,13 +68,28 @@ EXPECTED_CASE_COUNT = 16 -def start_stand_in(agent, argv): +# The codex stand-in. It reads none of its arguments, so any codex argv runs +# as given. A system tool won't do: an interpreter parses `-c` and `--model` +# as its own options, and macOS 15's yes writes a newline over the end of +# argv[1], which ps then shows merged with argv[2]. +CODEX_STAND_IN_C = "#include \nint main(void) { for (;;) pause(); }\n" + + +def build_codex_stand_in(workdir): + source = os.path.join(workdir, "codex.c") + binary = os.path.join(workdir, "codex") + with open(source, "w", encoding="utf-8") as source_file: + source_file.write(CODEX_STAND_IN_C) + subprocess.run(["cc", "-o", binary, source], check=True) + return binary + + +def start_stand_in(agent, argv, codex_binary): """Start a sleeping process whose `ps -o args=` reads like the agent's.""" if agent == "codex": # The codex check reads the words after the program, so the argv has - # to start right there, under argv[0] "codex". yes parses no options, - # and blocks once the pipe nobody reads is full. - return subprocess.Popen(["codex", *argv], executable="/usr/bin/yes", stdout=subprocess.PIPE) + # to start right there, under argv[0] "codex". + return subprocess.Popen(["codex", *argv], executable=codex_binary) # The claude check matches the flags anywhere, so a python stand-in will do. return subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", *argv]) @@ -119,8 +134,9 @@ def main(): workdir = tempfile.mkdtemp(prefix="stack-nudge-test-headless-") passed = 0 try: + codex_binary = build_codex_stand_in(workdir) for name, agent, argv, event, env, expected in CASES: - stand_in = start_stand_in(agent, argv) + stand_in = start_stand_in(agent, argv, codex_binary) try: actual = harness.posted_title(agent, {}, dict(env, NUDGE_AGENT_PID=str(stand_in.pid)), expected is not None, False, event)