From 67d7b14cf54654f4bed3b479c0817cc7441b0c62 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 9 Aug 2026 21:35:04 +0300 Subject: [PATCH] Keep headless runs out of the panel `claude -p` was showing up as a session. It prints one answer and exits: there is no input line, it cannot be blocked on you, and there is nowhere to walk over to. A script that runs a few dozen of them turned the panel into a flicker of chips that were gone before they could be read. The Agent SDK and editor integrations drive claude the same way and are covered by the same rule. The flag is looked for in the arguments of the already-identified claude process, by exact token, so nothing new has to be discovered -- the pid was resolved on every event anyway. That resolution moved from apply_event up into main, which is where the decision has to be made: a headless run is dropped before the state file is touched at all, so it never creates one and correspondingly never deletes one on SessionEnd. Its events still reach the debug log, otherwise "why is my session missing from the panel" would have nothing to answer with. Arguments are now read by splitting /proc//cmdline on its NUL separators rather than on spaces. A prompt is an ordinary argument, and `claude "when do I need -p"` is an interactive session that keeps its chip; the old space-joined string could not tell the two apart. looks_like_claude takes the list too, which is what it always wanted -- it was splitting the joined string back apart itself. Verified end to end as well as in the classifier: a fake claude runs the hook as a child through /proc, with -p leaving no file and without -p leaving one. A real `claude -p` against the installed hook added nothing to the state directory. --- README.md | 23 +++++++++- hooks/claude-status-hook.py | 83 ++++++++++++++++++++++++++----------- tests/test-hook.sh | 47 +++++++++++++++++---- 3 files changed, 118 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 1544fbf..2327e0d 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,9 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me `Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку -с выходом, убивается раньше, чем успевает записать: у `claude -p` это наблюдалось -как сессия, навсегда застрявшая в `busy`. +с выходом, убивается раньше, чем успевает записать. Наблюдалось это на `claude -p` +— теперь такие запуски вообще не отслеживаются (см. ниже), но гонка та же самая у +любой сессии, закрытой сразу после ответа, и стоила бы навсегда застрявшего `busy`. Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить — записать» идёт под `flock` на `.json.lock`, а событие старше @@ -146,6 +147,24 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё устаревшее решение после него. +## Неинтерактивные запуски + +`claude -p` (и `--print`) в панель не попадает. Такой запуск печатает один ответ +и завершается: строки ввода у него нет, заблокироваться на вас он не может, идти +к нему некуда. Скрипт из пары десятков таких вызовов превращал бы панель в +мельтешение чипов, исчезающих раньше, чем их успеешь прочесть; так же ведут себя +Agent SDK и интеграции с редакторами. + +Флаг ищется в аргументах опознанного процесса claude, по точному совпадению +токена. Аргументы читаются из `/proc//cmdline` по разделителю `\0`, а не +разбиением по пробелам: промпт — обычный аргумент, и `claude "когда нужен -p"` — +это интерактивная сессия, которая свой чип сохраняет. + +Отбрасывание происходит до всякой работы с файлом состояния — headless-сессия не +создаёт его и, соответственно, ничего не удаляет на `SessionEnd`. В отладочный +лог (см. ниже) её события при этом попадают: иначе «почему сессии нет в панели» +было бы нечем объяснить. + ## Сабагенты Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и diff --git a/hooks/claude-status-hook.py b/hooks/claude-status-hook.py index 18d9a86..eeaa887 100755 --- a/hooks/claude-status-hook.py +++ b/hooks/claude-status-hook.py @@ -53,6 +53,10 @@ NOTIFICATION_STATES = { # inflate the count with TaskCreate, TaskUpdate and the like. AGENT_TOOLS = {"Agent", "Task"} +# Flags that mean "run one prompt and exit" rather than "open a session". See +# is_headless: such a run has no terminal to be called over to. +HEADLESS_FLAGS = {"-p", "--print"} + # Events that mean the question has been dealt with. Anything else leaves a # "blocked" session blocked: a subagent finishing, or the next tool starting, # says nothing about the prompt still sitting on your screen, and clearing it @@ -169,12 +173,23 @@ def read_environ(pid): return env -def read_cmdline(pid): +def read_argv(pid): + """Command line of a process as a list of arguments, empty if it is gone. + + Split on the NUL separators the kernel actually puts there, not on spaces: + an argument may contain spaces of its own, and `claude "when to use -p"` + must not read as an argument list containing a bare "-p". + """ try: with open("/proc/%d/cmdline" % pid, "rb") as fh: - return fh.read().replace(b"\0", b" ").decode("utf-8", "replace") + raw = fh.read() except OSError: - return "" + return [] + return [arg.decode("utf-8", "replace") for arg in raw.split(b"\0") if arg] + + +def read_cmdline(pid): + return " ".join(read_argv(pid)) def parent_of(pid): @@ -188,18 +203,16 @@ def parent_of(pid): return 0 -def looks_like_claude(cmdline): - """Is this command line the claude binary itself? +def looks_like_claude(argv): + """Is this argument list the claude binary itself? - Matched per argument, never against the raw string. The hook is spawned as - `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line + Matched per argument, never against the joined string. The hook is spawned + as `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line contains the word "claude" -- in a path -- without being claude at all. Latching onto that shell records a pid that exits milliseconds later, and the session then flickers in and out of the panel. """ - for token in cmdline.split(" "): - if not token: - continue + for token in argv: base = os.path.basename(token) if base == "claude": return True @@ -209,12 +222,28 @@ def looks_like_claude(cmdline): return False -def find_claude_pid(): - """Nearest ancestor that is the claude process itself, or 0 if unknown. +def is_headless(argv): + """Was this claude started to print one answer and exit? - The hook is spawned through a shell, so the immediate parent is not claude. - Walking beyond a handful of levels risks latching onto an outer claude when - one session drives another, so the search stops early. + `claude -p` has no input line and nobody sitting in front of it, so it can + never be blocked on you and there is nothing to walk over to. Left in, a + script that runs a few dozen of them turns the panel into a flicker of chips + that are gone before they can be read -- and the same goes for the Agent SDK + and editor integrations, which drive claude the same way. + + Only exact tokens count. A prompt is an ordinary argument, and `claude "what + does -p do"` is an interactive session that must keep its chip. + """ + return any(token in HEADLESS_FLAGS for token in argv) + + +def find_claude(): + """Nearest ancestor that is the claude process itself, with its arguments. + + Returns (pid, argv), or (0, []) if it cannot be identified. The hook is + spawned through a shell, so the immediate parent is not claude. Walking + beyond a handful of levels risks latching onto an outer claude when one + session drives another, so the search stops early. Returning 0 rather than guessing matters: the reader deletes state files whose process is gone, and the obvious fallback -- the shell that spawned @@ -224,10 +253,11 @@ def find_claude_pid(): for _ in range(6): if pid <= 1: break - if looks_like_claude(read_cmdline(pid)): - return pid + argv = read_argv(pid) + if looks_like_claude(argv): + return pid, argv pid = parent_of(pid) - return 0 + return 0, [] def pid_start_time(pid): @@ -324,7 +354,7 @@ def open_lock(path): raise -def apply_event(event, state, path, now): +def apply_event(event, state, path, now, claude_pid): """Read the current state, decide, and write. Must run under the lock.""" if event.get("hook_event_name") == "SessionStart": # Swept before any early return: a resumed session keeps its id, so its @@ -416,13 +446,11 @@ def apply_event(event, state, path, now): state = "blocked" message = previous.get("message", "") - # Resolved before the unchanged-check, not after, so that a pid which has - # changed forces a write. A session resumed under a new pid, or one whose + # The pid is resolved by the caller and compared in the unchanged-check + # below, not merely stored. A session resumed under a new pid, or one whose # pid was recorded wrongly, would otherwise keep the stale value for as # long as its state happens not to change -- and the reader, finding that # process gone, would drop a perfectly live session from the panel. - claude_pid = find_claude_pid() - if previous: # Auto-compaction raises SessionStart again, in the middle of a turn the # session is still working on. Taking it at face value would flip a busy @@ -485,6 +513,13 @@ def main(): if state is None: return 0 + # After the debug log, so that a session missing from the panel can still be + # explained by the log, and before the state file is touched at all: a + # headless run must not even delete on SessionEnd, since it never wrote. + claude_pid, claude_argv = find_claude() + if is_headless(claude_argv): + return 0 + os.makedirs(STATE_DIR, exist_ok=True) path = os.path.join(STATE_DIR, "%s.json" % session_id) @@ -498,7 +533,7 @@ def main(): return 0 with lock: fcntl.flock(lock, fcntl.LOCK_EX) - apply_event(event, state, path, now) + apply_event(event, state, path, now, claude_pid) return 0 diff --git a/tests/test-hook.sh b/tests/test-hook.sh index 74a75e2..93a2ec9 100755 --- a/tests/test-hook.sh +++ b/tests/test-hook.sh @@ -41,22 +41,51 @@ import importlib.util, sys spec = importlib.util.spec_from_file_location("h", sys.argv[1]) h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h) cases = [ - ("/bin/sh -c /home/u/claude-code-gnome-extension/hooks/claude-status-hook.py ", False), - ("/home/u/.local/bin/claude --resume ", True), - ("bash /home/u/bin/claude ", True), - ("node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js ", True), - ("/home/u/bin/zellij --server /run/user/1000/zellij/x ", False), - ("nvim /home/u/.claude/settings.json ", False), + (["/bin/sh", "-c", "/home/u/claude-code-gnome-extension/hooks/claude-status-hook.py"], False), + (["/home/u/.local/bin/claude", "--resume"], True), + (["bash", "/home/u/bin/claude"], True), + (["node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"], True), + (["/home/u/bin/zellij", "--server", "/run/user/1000/zellij/x"], False), + (["nvim", "/home/u/.claude/settings.json"], False), ] bad = 0 -for cmd, want in cases: - got = h.looks_like_claude(cmd) - print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got)) +for argv, want in cases: + got = h.looks_like_claude(argv) + print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (" ".join(argv)[:46], got)) + bad += got != want + +# A one-shot run has no input line and no human in front of it. The prompt is +# an ordinary argument, so a prompt that merely mentions -p must not count. +headless = [ + (["claude"], False), + (["claude", "--resume"], False), + (["claude", "-p", "summarise this"], True), + (["claude", "--print", "--output-format", "stream-json"], True), + (["claude", "explain what -p does"], False), + (["claude", "--permission-mode", "plan"], False), +] +for argv, want in headless: + got = h.is_headless(argv) + print(("ok " if got == want else "FAIL ") + "headless %r -> %s" % (" ".join(argv)[:46], got)) bad += got != want sys.exit(1 if bad else 0) PY check "command lines classified correctly" "0" "$?" +# End to end, through /proc rather than through the classifier: a fake "claude" +# runs the hook as a child, exactly as the real one does. +FAKE="$XDG_STATE_HOME/claude" +printf '#!/bin/sh\n"$1"\n' > "$FAKE" +chmod +x "$FAKE" +printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \ + | "$FAKE" "$HOOK" -p +[ -e "$DIR/headless.json" ]; check "claude -p leaves no state file" "1" "$?" + +printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \ + | "$FAKE" "$HOOK" +[ -e "$DIR/headless.json" ]; check "the same session without -p is recorded" "0" "$?" +rm -f "$DIR/headless.json" "$DIR/headless.json.lock" + # --- state machine --------------------------------------------------------- emit "$(ev SessionStart '"source":"startup"')" check "SessionStart -> waiting" "waiting" "$(field state)"