diff --git a/README.md b/README.md index d0ef0b7..cfb6a38 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,18 @@ dump-layout`: рабочий каталог сессии сопоставляе Порог в пять минут для `.tmp` тоже осмысленный: хук может писать такой файл прямо сейчас, и удаление свежего стоило бы потерянной записи. +Опознание самого процесса claude — отдельная тонкость. Хук запускается как +`/bin/sh -c /…/claude-status-hook.py`, поэтому в командной строке его родителя +слово «claude» **есть**, а сам он — не claude и живёт миллисекунды. Поэтому +совпадение ищется по отдельным аргументам (`argv[0]` называется `claude`, либо +`cli.js` внутри пути с `claude`), а не по строке целиком. Если опознать не +удалось, пишется pid 0. + +Найденный pid сверяется на **каждом** событии, а не только при записи: сессия, +поднятая через `--resume`, получает новый pid, и без этой сверки файл сохранял бы +старый до ближайшей смены состояния — а читатель, не найдя того процесса, убрал бы +живую сессию из панели. + Если хук вообще не смог опознать процесс claude, он пишет pid 0 — «неизвестно», что никогда не путается с «мёртв»; такие записи истекают по возрасту, через 36 часов. diff --git a/hooks/claude-status-hook.py b/hooks/claude-status-hook.py index b0ddd89..8aa0349 100755 --- a/hooks/claude-status-hook.py +++ b/hooks/claude-status-hook.py @@ -78,7 +78,13 @@ def debug_log(event): return try: with open(marker, "a") as fh: - stamped = dict(event, _at=time.strftime("%H:%M:%S")) + chain, pid = [], os.getppid() + for _ in range(6): + if pid <= 1: + break + chain.append("%d %s" % (pid, read_cmdline(pid)[:90])) + pid = parent_of(pid) + stamped = dict(event, _at=time.strftime("%H:%M:%S"), _ancestry=chain) fh.write(json.dumps(stamped, sort_keys=True)[:2000] + "\n") except OSError: pass @@ -145,25 +151,43 @@ def parent_of(pid): return 0 +def looks_like_claude(cmdline): + """Is this command line 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 + 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 + base = os.path.basename(token) + if base == "claude": + return True + # npm-style install: node /path/to/claude-code/cli.js + if base == "cli.js" and "claude" in token: + return True + return False + + def find_claude_pid(): """Nearest ancestor that is the claude process itself, or 0 if unknown. - The hook is spawned through a shell, so the immediate parent is usually not - claude. Walking beyond a handful of levels risks latching onto an outer - claude when one session drives another, so the search stops early. + 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 pid is gone, and the obvious fallback -- the shell that spawned this - hook -- exits milliseconds later, which would make the session flicker in - and out of the panel forever. + whose process is gone, and the obvious fallback -- the shell that spawned + this hook -- exits immediately. """ pid = os.getppid() for _ in range(6): if pid <= 1: break - # Matched anywhere in the command line, not just argv[0]: installs that - # run it as `node .../claude/cli.js` are just as valid as a direct one. - if "claude" in read_cmdline(pid): + if looks_like_claude(read_cmdline(pid)): return pid pid = parent_of(pid) return 0 @@ -296,6 +320,13 @@ def apply_event(event, state, path, now): # terminal that does not need you. state = "busy" + # 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 + # 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 @@ -309,10 +340,10 @@ def apply_event(event, state, path, now): if (previous.get("state") == state and previous.get("message", "") == message and previous.get("agents", 0) == agents - and bool(previous.get("stopped")) == stopped): + and bool(previous.get("stopped")) == stopped + and previous.get("pid", 0) == claude_pid): return - claude_pid = find_claude_pid() env = read_environ(claude_pid) if claude_pid else {} write_atomic(path, { diff --git a/tests/test-hook.sh b/tests/test-hook.sh index 7af2115..db11dc6 100755 --- a/tests/test-hook.sh +++ b/tests/test-hook.sh @@ -32,6 +32,31 @@ ev() { # event [extra json] echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}" } +# --- identifying the claude process ---------------------------------------- +# The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its +# parent's command line contains "claude" in a path without being claude. +# Matching on the raw string latches onto that shell, which exits at once. +python3 - "$HOOK" <<'PY' +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), +] +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)) + bad += got != want +sys.exit(1 if bad else 0) +PY +check "command lines classified correctly" "0" "$?" + # --- state machine --------------------------------------------------------- emit "$(ev SessionStart '"source":"startup"')" check "SessionStart -> waiting" "waiting" "$(field state)"