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)"