Identify the claude process by argument, not by substring
Testing the reboot case exposed a live regression, and the identity check added for reboots is what made it visible. Matching "claude" anywhere in an ancestor's command line was too loose. The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line contains "claude" -- in the path to this very script -- while being a shell that exits milliseconds later. That shell's pid was being recorded as the session's. Existence checks alone hid it: the pid was dead, the file was deleted, the next event recreated it, and the session flickered. Once the pid was pinned to a process start time the session vanished outright. The rule was broadened in the first place to cover npm-style installs that run `node .../claude-code/cli.js`, which was a real gap. It is now matched per argument instead: argv[0] named claude, or a cli.js under a claude path. Both installs pass, and the spawning shell does not. The pid is also resolved before the unchanged-check rather than after, so a pid that has changed forces a write. A session resumed under a new pid -- which is exactly what --resume does, and what this session had done -- kept its old pid for as long as its state happened not to change, and the reader would drop it as dead. The debug log now records the process ancestry, which is what made this diagnosable at all rather than guessable.
This commit is contained in:
@@ -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 часов.
|
||||
|
||||
+43
-12
@@ -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, {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user