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:
av
2026-08-09 19:47:19 +03:00
parent 75bfe77950
commit f4a06cdc44
3 changed files with 80 additions and 12 deletions
+43 -12
View File
@@ -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, {