@@ -45,7 +45,10 @@ set -euo pipefail
4545[ " ${CLAUDE_CODE_REMOTE:- } " = " true" ] || exit 0
4646
4747VENV=" ${PYAUTO_SESSION_VENV:- $HOME / .pyauto/ session-py312} "
48- BASE_DEPS=(pytest PyYAML)
48+ # pytest-xdist is a base dep, not a nicety: a remote container has 4 cores and
49+ # the Brain suite takes 96s on one of them and 28s on four. A session that has
50+ # to `pip install` before it can run tests fast will simply not run them fast.
51+ BASE_DEPS=(pytest PyYAML pytest-xdist)
4952
5053# Which checkout is this copy of the hook installed in?
5154#
@@ -100,7 +103,7 @@ find_base_python() {
100103venv_ready () {
101104 is_py312 " $VENV /bin/python" \
102105 && [ -x " $VENV /bin/pip" ] \
103- && " $VENV /bin/python" -c ' import pytest, yaml' > /dev/null 2>&1
106+ && " $VENV /bin/python" -c ' import pytest, yaml, xdist ' > /dev/null 2>&1
104107}
105108
106109# 1. The interpreter a session types: python, python3, pip, pytest.
@@ -117,13 +120,19 @@ ensure_venv() {
117120 log " building $VENV on $base_python "
118121 rm -rf " $VENV "
119122 mkdir -p " $( dirname " $VENV " ) "
123+ # --system-site-packages makes the venv a strict SUPERSET of the base
124+ # interpreter. That is what lets `point_system_default` make this venv the
125+ # session's `python3` without silently removing anything the image
126+ # installed: an isolated venv would swap one set of missing modules for
127+ # another, and the session would find out through a ModuleNotFoundError
128+ # that reads like broken code.
120129 if command -v uv > /dev/null 2>&1 ; then
121130 # --seed puts pip inside the venv too, so `pip install` targets 3.12
122131 # rather than falling through to the container's 3.11 /usr/bin/pip.
123- uv venv --seed --python " $base_python " " $VENV " >&2
132+ uv venv --seed --system-site-packages -- python " $base_python " " $VENV " >&2
124133 uv pip install --python " $VENV /bin/python" --quiet " ${BASE_DEPS[@]} " >&2
125134 else
126- " $base_python " -m venv " $VENV " >&2
135+ " $base_python " -m venv --system-site-packages " $VENV " >&2
127136 " $VENV /bin/python" -m pip install --quiet --upgrade pip >&2
128137 " $VENV /bin/python" -m pip install --quiet " ${BASE_DEPS[@]} " >&2
129138 fi
@@ -152,16 +161,117 @@ ensure_repo_extras() {
152161}
153162
154163# 2. What PATH means without this session's env file.
164+ #
165+ # This leg is the one that matters most, because the env file is exactly what a
166+ # multi-repo session does NOT get: Claude Code registers project hooks from the
167+ # session's project directory, that directory is the repos' parent, and no hook
168+ # runs there (see leg 5). So every Bash call in such a session resolves whatever
169+ # the image put on PATH, and these two names are the fallback that has to be
170+ # right on its own.
171+ #
172+ # It used to point them at the BASE interpreter — `point_system_default
173+ # "$(readlink -f "$VENV/bin/python")"` resolved the venv's python straight
174+ # through to /usr/bin/python3.12. That satisfied the version question and lost
175+ # everything else: `python3 -m pytest` answered "No module named pytest", and
176+ # the session's own venv, sitting one directory away with pytest in it, was
177+ # unreachable from any shell.
178+ #
179+ # Two details make the fix work:
180+ #
181+ # * A wrapper script, not a symlink. Python resolves a symlinked executable
182+ # before looking for `pyvenv.cfg`, so a symlink to $VENV/bin/python lands on
183+ # the base interpreter's prefix and the venv is lost again — silently, and
184+ # in the same shape as the bug being fixed. `exec`ing it keeps the venv.
185+ # * --system-site-packages on the venv (see ensure_venv), so pointing the
186+ # system default here adds the session's packages without removing the
187+ # image's.
188+ # Bounded, because this predicate is what runs a candidate interpreter — and
189+ # the one failure mode worth surviving is an interpreter that never returns.
190+ venv_backed () {
191+ [ -x " $1 " ] || return 1
192+ [ " $( timeout 20 " $1 " -c ' import sys; print(sys.prefix)' 2> /dev/null) " = " $VENV " ]
193+ }
194+
195+ # Does following $1's symlinks pass through $2? A venv's python legitimately
196+ # RESOLVES to the same base interpreter a system default points at, so comparing
197+ # endpoints refuses safe rewrites. The question that matters is narrower: is the
198+ # path being rewritten a link in the target's own chain — because then the
199+ # wrapper execs itself.
200+ links_through () {
201+ local node=" $1 " needle=" $2 " hops=0
202+ needle=" $( cd " $( dirname " $needle " ) " 2> /dev/null && printf ' %s/%s' " $( pwd -P) " " $( basename " $needle " ) " ) "
203+ while [ -L " $node " ] && [ " $hops " -lt 40 ]; do
204+ node=" $( cd " $( dirname " $node " ) " && cd " $( dirname " $( readlink " $node " ) " ) " 2> /dev/null && printf ' %s/%s' " $( pwd -P) " " $( basename " $( readlink " $node " ) " ) " ) "
205+ [ " $node " = " $needle " ] && return 0
206+ hops=$(( hops + 1 ))
207+ done
208+ return 1
209+ }
210+
211+ # `rm -f` first, and it is not tidiness. /usr/local/bin/python3 is a SYMLINK to
212+ # the real interpreter, and a redirect opens the link's TARGET: `cat >` there
213+ # overwrites /usr/bin/python3.12 itself with this wrapper. The venv's own
214+ # python symlinks to that same file, so the wrapper then execs itself — the
215+ # container loses its interpreter and every `python3` spins at 100% CPU. Writing
216+ # a fresh file at the path replaces the link instead of following it.
217+ #
218+ # `links_through` is the second half of the same guard: a target that reaches
219+ # the destination through its own symlink chain builds the identical loop by the
220+ # other route, so refuse rather than write it.
221+ write_venv_shim () {
222+ local dest=" $1 " target=" $2 "
223+ [ -w " $( dirname " $dest " ) " ] || { log " WARNING: cannot rewrite $dest (not writable)" ; return 1; }
224+ [ -x " $target " ] || { log " WARNING: $target is not executable; leaving $dest alone" ; return 1; }
225+ if links_through " $target " " $dest " ; then
226+ log " WARNING: refusing to point $dest at $target — it links back through $dest "
227+ return 1
228+ fi
229+ rm -f " $dest "
230+ cat > " $dest " << SHIM
231+ #!/bin/sh
232+ # GENERATED by PyAutoMind's session-start hook. A wrapper, not a symlink:
233+ # Python resolves a symlink before reading pyvenv.cfg, which loses the venv.
234+ exec "$target " "\$ @"
235+ SHIM
236+ chmod 0755 " $dest "
237+ }
238+
155239point_system_default () {
156- local base_python=" $1 " link
157- for link in /usr/local/bin/python /usr/local/bin/python3; do
158- is_py312 " $link " && continue
159- [ -w " $( dirname " $link " ) " ] || { log " WARNING: cannot rewrite $link (not writable)" ; continue ; }
160- ln -sfn " $base_python " " $link "
240+ local link bin=" ${PYAUTO_SESSION_SYSTEM_BIN:-/ usr/ local/ bin} "
241+ for link in " $bin /python" " $bin /python3" ; do
242+ venv_backed " $link " && continue
243+ write_venv_shim " $link " " $VENV /bin/python" || continue
244+ done
245+ venv_backed " $bin /python3" \
246+ && log " $bin /python{,3} -> $VENV /bin/python (venv, with pytest and yaml)" \
247+ || log " WARNING: $bin /python3 is still $( timeout 20 " $bin /python3" -V 2>&1 ) "
248+ }
249+
250+ # 2b. The `pytest` that PATH actually finds.
251+ #
252+ # $HOME/.local/bin precedes /usr/local/bin, and it holds uv's tool shims — so
253+ # fixing python3 alone still leaves bare `pytest` resolving to uv's ISOLATED
254+ # pytest, which by design cannot see PyYAML or a repo's own extras. In this
255+ # workspace that made `pytest` exit on four collection ImportErrors that read
256+ # like broken source, in a session where the suite was in fact green.
257+ #
258+ # A session should have exactly one pytest, and it should be the venv's.
259+ # Confined to uv's own shim directory: a distro-packaged pytest is not ours to
260+ # overwrite. Runs after retool_uv_tools, which rewrites these same shims.
261+ point_pytest_at_venv () {
262+ local shim_dir=" ${HOME} /.local/bin" name path
263+ [ -x " $VENV /bin/pytest" ] || return 0
264+ for name in pytest py.test; do
265+ path=" $( command -v " $name " 2> /dev/null) " || continue
266+ [ -n " $path " ] || continue
267+ case " $path " in
268+ " $VENV " /* ) continue ;;
269+ " $shim_dir " /* ) ;;
270+ * ) log " WARNING: $name resolves to $path , outside uv's shim dir — left alone" ; continue ;;
271+ esac
272+ write_venv_shim " $path " " $VENV /bin/$name " \
273+ && log " $path -> $VENV /bin/$name (so \` $name \` sees this repo's deps)"
161274 done
162- is_py312 /usr/local/bin/python3 \
163- && log " /usr/local/bin/python{,3} -> $base_python " \
164- || log " WARNING: /usr/local/bin/python3 is still $( /usr/local/bin/python3 -V 2>&1 ) "
165275}
166276
167277# 3. The uv-managed tools — rebuilt on 3.12 only where they are not already.
@@ -232,16 +342,34 @@ ensure_full_clone() {
232342# stays "each repo owns its hook" and this file stays generated-from-one-source.
233343# It takes effect on the next session start in this container.
234344install_workspace_settings () {
235- # Only in the multi-repo layout: when the project dir IS this repo, Claude
236- # Code already found the repo's own settings and there is nothing to add.
237- [ -n " ${CLAUDE_PROJECT_DIR:- } " ] || return 0
238- [ " $( readlink -f " $CLAUDE_PROJECT_DIR " ) " != " $( readlink -f " $REPO_DIR " ) " ] || return 0
345+ # Target the WORKSPACE ROOT, derived from this checkout — not
346+ # $CLAUDE_PROJECT_DIR, and not only when the two differ.
347+ #
348+ # The early return this replaces ("only in the multi-repo layout") made the
349+ # whole leg unreachable. Writing the fan-out requires the hook to be
350+ # running; the hook runs only in a session whose project dir is a repo —
351+ # that is, a SINGLE-repo session — and the early return then skipped it as
352+ # having nothing to add. So the one session type that could seed the
353+ # container never did, and the multi-repo session that needed it never ran
354+ # the hook to find out. Observed directly: a container with two single-repo
355+ # sessions behind it still had no workspace-root settings, and the next
356+ # session — three organs, project dir /home/user — fired no hook, ran on
357+ # PATH's pytest, and left both clones shallow for three minutes until an
358+ # unrelated verb happened to knock on session_bootstrap.sh.
359+ #
360+ # A single-repo session has the same sibling layout one directory up, so it
361+ # can seed the root for free. Skip only if the root is itself a repo (then
362+ # it owns its own hook) or is not writable.
363+ local root=" $WORKSPACE_ROOT "
364+ [ -n " $root " ] && [ -d " $root " ] || return 0
365+ [ -d " $root /.git" ] && return 0
239366
240- local settings=" $CLAUDE_PROJECT_DIR /.claude/settings.json"
241- local fanout=" $CLAUDE_PROJECT_DIR /.claude/hooks/session-start.sh"
242- [ -w " $CLAUDE_PROJECT_DIR " ] || { log " WARNING: $CLAUDE_PROJECT_DIR not writable; multi-repo sessions will keep skipping the hook" ; return 0; }
367+ local settings=" $root /.claude/settings.json"
368+ local fanout=" $root /.claude/hooks/session-start.sh"
369+ [ -w " $root " ] || { log " WARNING: $root not writable; multi-repo sessions will keep skipping the hook" ; return 0; }
243370
244371 mkdir -p " $( dirname " $fanout " ) "
372+ rm -f " $fanout " # never write through a symlink; see write_venv_shim
245373 cat > " $fanout " << 'FANOUT '
246374#!/usr/bin/env bash
247375# GENERATED at session start by a PyAuto repo's own session-start hook.
@@ -283,6 +411,14 @@ SETTINGS
283411# Git history and hook reachability have nothing to do with the interpreter, so
284412# they run first and unconditionally: a container where the 3.12 venv cannot be
285413# built still wants honest ancestry and a hook that fires next session.
414+ # PYAUTO_SESSION_DEFINE_ONLY=1 defines every function and performs no action —
415+ # the seam this hook's own tests use to drive one leg at a time against a
416+ # temporary directory, instead of against the container they run in. Sourcing
417+ # returns; executing exits.
418+ if [ " ${PYAUTO_SESSION_DEFINE_ONLY:- } " = " 1" ]; then
419+ return 0 2> /dev/null || exit 0
420+ fi
421+
286422ensure_full_clone
287423install_workspace_settings
288424
295431
296432if ensure_venv; then
297433 ensure_repo_extras
298- point_system_default " $( readlink -f " $VENV /bin/python " ) "
434+ point_system_default
299435 retool_uv_tools
436+ point_pytest_at_venv
300437 # Every repo in the session registers this hook, so the second copy must not
301438 # prepend the venv a second time.
302439 if [ -n " ${CLAUDE_ENV_FILE:- } " ] && ! grep -qs ' PYAUTO_SESSION_PY312=' " $CLAUDE_ENV_FILE " ; then
0 commit comments