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/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..8d2f91a 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` / `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. | Setting | Default | | diff --git a/notify.conf.example b/notify.conf.example index 897c1b4..55076db 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` / +# `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 + # 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..650dc4f 100755 --- a/notify.sh +++ b/notify.sh @@ -1041,6 +1041,62 @@ play_windows() { || powershell -c "[console]::beep(${freq},${dur})" 2>/dev/null } +# 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. +# +# 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 + pid="$AGENT_PID" + fi + [[ -z "$pid" ]] && return 1 + local args + args="$(ps -p "$pid" -o args= 2>/dev/null)" + case "$AGENT" in + claude*) + [[ " $args " =~ \ (-p|--print)(\ |=) ]] && return 0 + ;; + codex*) + 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 +} +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..cccb8b7 --- /dev/null +++ b/scripts/test-notify-headless.py @@ -0,0 +1,165 @@ +#!/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. + +Most cases seed the agent through NUDGE_AGENT_PID: a stand-in process whose +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 +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")) +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), + ("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"), + # 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 +# 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 = 16 + + +# 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". + 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]) + + +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 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.lexists(claude): + os.symlink("/bin/bash", claude) + 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_real, 0o755) + real_notify = harness.NOTIFY + 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") + 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) + # 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-") + 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, codex_binary) + 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 == ran else 1 + + +if __name__ == "__main__": + sys.exit(main())