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/<pid>/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.
This commit is contained in:
av
2026-08-09 21:35:04 +03:00
parent 2565d45bb5
commit 67d7b14cf5
3 changed files with 118 additions and 35 deletions
+59 -24
View File
@@ -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