Repository navigation
Description
Summary
The Windows fix for #665 (PR #678, ADR-1096) walks the process table from the hook's own pid up to claude.exe. In real hooks that walk can meet a parent that has already exited. process_ancestry.claude_ancestor_pid then returns None. This is intended, and a test pins it.
On main this has a visible cost. window_pid() raises WindowIdentityUnavailableError. SessionStart logs an ERROR and starts no consolidate cycle (ADR-1097). The per-window registry is not written either.
Part of this is my fault. The process tree measured in ADR-1096 is the one from my report on #665: claude.exe -> bash.exe -> bash.exe -> bash.exe -> python.exe. I measured it from my own shell, not from a real hook. A real hook's chain can be broken.
The desktop app exports CLAUDE_PID to the processes it starts. I propose to try it before the walk. A patch doing that has run on my machine since 2026-10-07. Numbers below cover 2026-10-21 to 2026-10-29.
Why the chain breaks
The hook command is bash -c '... "$PY" "$ROOT/scripts/launcher.py" <module>', with PY=$(command -v python3 || command -v python).
On 2026-10-07 I ran that exact command and logged the chain: python.exe <- bash.exe <- <exited pid>. Since then my patch logs probe_failed whenever the walk meets such a gap.
My explanation, not measured step by step:
bash -cexecs its last command.- On my machine,
python3is a bash shim:exec "<path to python>" "$@". - Under MSYS2,
execstarts a new Windows process, and thebash.exethat ran it then exits.
The shim is specific to my setup. A bash -c whose last command is an exec can produce the same gap. I have not measured how common it is.
What main does with that chain
Read at 190c6f1 (2026-10-29):
mcp_server/infrastructure/process_ancestry.py, lines 71 to 72:parent = table.get(current.ppid), thenif parent is None or parent.pid == current.pid:returnsNone.tests_py/infrastructure/test_process_ancestry.py, lines 64 to 65,test_parent_missing_from_table_returns_none: rows(2, 77, "bash.exe", 2), (3, 2, "python.exe", 3) # 77 exited. This is exactly the real-hook case.mcp_server/infrastructure/session_registry.py, lines 101 to 108: on Windows,find_claude_ancestor()is only this walk. TheCLAUDE_PIDenvironment variable is read nowhere. In the tests,CLAUDE_PIDis only the name of a fake pid.mcp_server/infrastructure/groomer_identity.py:41:window_pid()raises when the walk returnsNone.mcp_server/hooks/session_start_consolidate.py, lines 110 to 114: "window identity unavailable … no consolidate cycle started this session".
The hook commands in .claude-plugin/plugin.json still use this bash -c form.
I read this in the code. I did not run main in real hooks. I only saw the broken chain in real hooks, on 4.23.5.
Proposal: CLAUDE_PID first, the walk as fallback
CLAUDE_PID holds the pid of the session's claude.exe. I saw it in desktop-app hooks, Claude Code 2.1.289 to 2.1.309. I did not find it documented. So check it, and keep the walk as fallback.
A sketch on top of main, untested in this form:
def _claude_pid_from_env() -> int | None:
try:
pid = int(os.environ.get("CLAUDE_PID", ""))
except ValueError:
return None
row = next((r for r in win32_process.snapshot_rows() if r.pid == pid), None)
if row is None or row.exe_name.lower() != "claude.exe":
return None
return pidclaude_ancestor_pid would call it first and walk only when it returns None. Checking the name rejects a reused pid held by another program. It does not rule out another claude.exe, nor the desktop app itself. Comparing creation times, as the walk does, would close that gap.
Second case: SessionEnd after the window closed
When the user closes a window, or the whole app, claude.exe can exit before the SessionEnd hook runs. Then CLAUDE_PID names an exited process and the walk fails too.
My local patch accepts an exited CLAUDE_PID at SessionEnd only (allow_exited=True). The process is gone, but its pid still identifies the window in the registry. Without it, the SessionEnd of a closed window cannot find its registration.
Third case: sessions started from a terminal
In the terminal sessions I logged (2026-10-13 and 2026-10-19), hooks had no CLAUDE_PID. The CLI on my PATH is the npm build 2.1.140, an old version. I do not know whether a current CLI exports it. The walk logged probe_failed there too.
My local patch then scans the process table for a claude.exe started from a terminal. If there is exactly one, it takes it. With several, it matches the session id already registered, then the newest unclaimed one under 30 s old. Otherwise it gives up. This is a workaround for my machine, not a proposal. Two terminals started within 30 s of each other can still be confused.
Verified in real hooks (local patch on 4.23.5)
Logged by each hook from 2026-10-21 11:19 to 2026-10-29 11:50 (8 days, 9 active days):
- 892 window lookups by
auto_recall,session_startandsession_lifecycle. 879 went through a liveCLAUDE_PID. 11 went through an exited one at SessionEnd. 2 went through the terminal scan, in one terminal session. No lookup failed. - 624 registry writes, 0 failures. 48 distinct sessions, 101 windows, some open at the same time.
- 8 desktop app instances, so 7 restarts. Four Claude Code versions crossed: 2.1.295, 2.1.304, 2.1.308 and 2.1.309.
- 76 session ends, all complete up to the dream cycle. In 12 seconds, two or more session ends began together. The maximum was 6, when the whole app closed.
- The MCP server side needs no change in the desktop app. Its environment has no
CLAUDE_PID. Its chain is intact:claude.exe -> cmd.exe -> python.exe, as I measured on 2026-10-07. - In this period a Claude-launched server started once. The walk found the window one level above its parent.
- Six other server starts, at night, failed the walk. No Claude hook ran within 120 s of them. I assume another MCP client on the same machine, and excluded them.
Counted on 2026-10-29 at 11:55. The log keeps growing.
Not covered: a terminal session with a current CLI, and the end of a terminal session in this period.
Activity
cdeust commentedon Nov 6, 2026
CI autopilot : première tranche dans #909 (
Refs #835, brouillon).find_claude_ancestoressaieCLAUDE_PIDavant la marche d'ADR-1096 (ADR-1124). La valeur n'est retenue que si elle désigne une ligneclaude.exede la table des processus, de date de création lisible et pas plus jeune que l'appelant. Sinon, la marche répond comme aujourd'hui. Les tests tournent sur une table factice, et le gate de mutation passe localement.CLAUDE_PIDrepose sur les mesures de cette issue.claude.exeest déjà sorti (accepter unCLAUDE_PIDsorti au SessionEnd seulement, avec la clé de registre comme preuve d'identité) ;CLAUDE_PID. Le balayage de la table décrit ici est présenté comme un contournement local. Il faudrait d'abord mesurer si un CLI récent exporte la variable.Generated by Claude Code
fix(windows): CLAUDE_PID names the window before the ancestor walk (#835
cdeust commentedon Nov 12, 2026
CI autopilot : les deux tranches restantes attendent une décision, mise en
autopilot:hold.La tranche 1 (#909, ADR-1124) est fusionnée. J'ai relu
mainà de778c4 pour la tranche suivante.Tranche 2, SessionEnd d'une fenêtre dont le
claude.exeest déjà sorti. Voici ce quemainfait aujourd'hui, lu dans le code et non exécuté sous Windows :session_lifecycle.mainlance_tombstone_session_registry()puis_deregister_groomer_coordinator()avant de lire stdin. Quandwindow_pid()lèveWindowIdentityUnavailableError, les deux étapes l'attrapent et journalisent « skipped (non-fatal) ».claude.exeest sorti.current_window_sessionrejette déjà une entrée dont le pid n'est plus vivant (_pid_alive, puis la comparaison des dates de création), etpurge_dead_entriesla supprime au SessionStart suivant.sessions/<pid>.json, est récupérée parlive_session_count(), qui supprime les pids morts. Etis_running()relitgroomer.pidd'après la vivacité du pid.stopped_last_exitn'est pas écrite dansruns.log, et le journal du hook contient une ligne « skipped ».Accepter un
CLAUDE_PIDsorti oblige à une preuve d'identité. Lesession_idde l'événement SessionEnd, comparé à celui de l'entréeregistry_path(pid), en serait une. Mais il faut alors lire l'événement avant ces deux étapes, alors qu'elles sont aujourd'hui volontairement indépendantes du contenu de l'événement (docstring de_tombstone_session_registry). Votre correctif local a un autre effet, puisqu'il « retrouve son inscription ». Je ne sais pas s'il touche du code qui n'existe pas surmain.Question à trancher (cdeust) : sur
main, est-ce que ce gain vaut une lecture de pid non documentée, acceptée sans que le processus soit vivant ? Ou faut-il d'abord une mesure qui montre un dommage autre que la lignestopped_last_exitmanquante ?Tranche 3, sessions de terminal sans
CLAUDE_PID. L'issue la présente comme un contournement propre à une machine. Elle demande d'abord de mesurer si une version récente de la CLI exporte la variable. Ici, il n'y a ni hôte Windows ni CLI récente.Je n'ai rien poussé et je n'ai rien vérifié sous Windows.
Generated by Claude Code