Skip to content

Windows: the ADR-1096 ancestor walk returns None in real hooks when an intermediate bash has exited; CLAUDE_PID identifies the window #835

Open

Description

@BillOTheWild

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 -c execs its last command.
  • On my machine, python3 is a bash shim: exec "<path to python>" "$@".
  • Under MSYS2, exec starts a new Windows process, and the bash.exe that 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), then if parent is None or parent.pid == current.pid: returns None.
  • 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. The CLAUDE_PID environment variable is read nowhere. In the tests, CLAUDE_PID is only the name of a fake pid.
  • mcp_server/infrastructure/groomer_identity.py:41: window_pid() raises when the walk returns None.
  • 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 pid

claude_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_start and session_lifecycle. 879 went through a live CLAUDE_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

  1. cdeust commented on Nov 6, 2026

    @cdeust
    Owner

    CI autopilot : première tranche dans #909 (Refs #835, brouillon).

    • Tranche 1, dans fix(windows): CLAUDE_PID names the window before the ancestor walk (#835, slice 1) #909 : sous Windows, find_claude_ancestor essaie CLAUDE_PID avant la marche d'ADR-1096 (ADR-1124). La valeur n'est retenue que si elle désigne une ligne claude.exe de 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.
    • Non vérifié : aucun hôte Windows ni vrai hook ici. La présence de CLAUDE_PID repose sur les mesures de cette issue.
    • Tranches restantes :
      1. le SessionEnd d'une fenêtre dont le claude.exe est déjà sorti (accepter un CLAUDE_PID sorti au SessionEnd seulement, avec la clé de registre comme preuve d'identité) ;
      2. les sessions de terminal sans 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

  2. cdeust commented on Nov 12, 2026

    @cdeust
    Owner

    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.exe est déjà sorti. Voici ce que main fait aujourd'hui, lu dans le code et non exécuté sous Windows :

    • session_lifecycle.main lance _tombstone_session_registry() puis _deregister_groomer_coordinator() avant de lire stdin. Quand window_pid() lève WindowIdentityUnavailableError, les deux étapes l'attrapent et journalisent « skipped (non-fatal) ».
    • La pierre tombale du registre n'a pas d'effet quand le claude.exe est sorti. current_window_session rejette déjà une entrée dont le pid n'est plus vivant (_pid_alive, puis la comparaison des dates de création), et purge_dead_entries la supprime au SessionStart suivant.
    • L'inscription au coordinateur, sessions/<pid>.json, est récupérée par live_session_count(), qui supprime les pids morts. Et is_running() relit groomer.pid d'après la vivacité du pid.
    • Le seul effet visible est donc qu'à la fermeture de la dernière fenêtre, la ligne stopped_last_exit n'est pas écrite dans runs.log, et le journal du hook contient une ligne « skipped ».

    Accepter un CLAUDE_PID sorti oblige à une preuve d'identité. Le session_id de l'événement SessionEnd, comparé à celui de l'entrée registry_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 sur main.

    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 ligne stopped_last_exit manquante ?

    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

  3. added
    autopilot:holdCI autopilot stood down: a human decision is required
    on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    autopilot:holdCI autopilot stood down: a human decision is required

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions