Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,34 @@ in another account's history and resume *nothing* instead of failing.
a repo resolving somewhere its origin does not explain should say why — and
`routes` marks the pinned cell with `*` like the other routers.

**A second session in the same directory is asked about, not refused.** Two
sessions in one directory are two writers on one tree, and a fresh prompt
gives no sign the first is there. `claude()` therefore reads the registry
every profile keeps of its live sessions — `<config dir>/sessions/<pid>.json`,
removed on exit (observed layout, not an interface; postbox reads the same
files) — through `claude_sessions_here`, which checks **every** mapped profile
plus the directory this launch resolved to (a literal `CLAUDE_PROFILE` or
per-repo pin can sit outside every mapping), since the tree is shared
whichever login is running in it. A hit lists each
session (name, pid, status, profile) and asks `Start another here anyway?
[y/N]`. Only a launch somebody is sitting at is asked: no terminal on stdin or
stderr, `-p`/`--print`, a help or version flag, or a first argument shaped like
a subcommand (`claude mcp …` — by shape, one bare lowercase word, because a
list of the CLI's subcommands would go stale; the price is that a one-word
prompt, `claude fix`, is not asked either) passes untouched, so hooks, the
postbox courier and anything piped never block on a question nobody can
answer. It fails open, and it matches the directory exactly: a session started
in a subdirectory of the same checkout is not seen. Running several sessions
in one checkout on purpose is a workflow, not a mistake, so `git config
claude.second-session-check false` turns the question off — policy in
machine-local git config, like the push gates. `claude-doctor`'s `running:`
line lists the sessions the wrapper would find here — not whether a given
launch would be asked, which also depends on how it is invoked — and says
when the check is off. Testing the prompt
needs a pseudo-terminal, and the two obvious tools both fail on macOS — zsh's
`zpty` returns short reads, and `pty.spawn` before Python 3.10 never returns
once the child exits — so `tests/claude-profiles.bats` forks one by hand.

**Each profile must be logged in once** with `claude auth login`; credentials
live in the macOS Keychain and cannot be copied between profiles — verified,
copying `.claude.json` does not carry a session.
Expand Down
74 changes: 72 additions & 2 deletions dot_functions
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,32 @@ function bin_trace() {
# or a literal directory. With no mapping at all, this is plain `claude` on the
# default ~/.claude config dir.
function claude() {
local dir alias opener;
local dir alias opener here line;
local -a sess;
dir="$(claude_profile_dir)" || dir='';
# A second session in a directory that already has one puts two writers
# on the same tree, and nothing about a fresh prompt says the first is
# there. So say so, and ask — a warning with a way past it, never a
# refusal. Only a launch somebody is sitting at is asked: no terminal,
# print mode, a version/help flag, or a first argument shaped like a
# subcommand (`claude mcp …` — the shape, because a list of them would go
# stale) all pass untouched, so hooks, pipes and headless callers never
# block on a question nobody can answer. A helper that fails or finds
# nothing launches as before. Running several sessions in one checkout
# on purpose is a workflow, not a mistake: `git config
# claude.second-session-check false` turns the question off.
if [[ -t 0 && -t 2 && ! $1 =~ '^[a-z][a-z-]*$' ]] && (( ! ${argv[(I)(-p|--print|-h|--help|-v|--version)]} )) && [[ "$(command git config --get claude.second-session-check 2>/dev/null)" != false ]]; then
here="$(claude_sessions_here $dir 2>/dev/null)";
if [[ -n $here ]]; then
print -ru2 -- "claude: already running in $PWD:";
for line in ${(f)here}; do
sess=("${(@ps:\t:)line}");
print -ru2 -- " ${sess[3]} pid ${sess[1]} ${sess[4]} (${sess[2]:t})";
done;
read -q '?Start another here anyway? [y/N] ' || { print -u2; return 1; };
print -u2;
fi;
fi;
(
# The default profile must run with CLAUDE_CONFIG_DIR UNSET, not set
# to its own path — setting the variable at all switches Claude Code
Expand Down Expand Up @@ -419,6 +443,39 @@ function claude() {
)
}

# The live Claude Code sessions whose cwd is this directory, across every
# profile — a second writer in the tree is the problem whichever login it
# runs under. One line each: "<pid>\t<profile dir>\t<name>\t<status>". Read
# from the registry each profile keeps in `<config dir>/sessions/<pid>.json`
# and clears on exit (observed layout, not a documented interface; postbox
# reads the same files). Arguments are further profile directories to look in:
# the one this launch resolved to, which a literal CLAUDE_PROFILE or per-repo
# pin can put outside every mapping. Shared by claude() and claude-doctor.
function claude_sessions_here() {
emulate -L zsh;
local here=${PWD:A} d f json pid name state;
local -aU dirs;
dirs=("$HOME/.claude" $CLAUDE_CONFIG_DIR $@ ${(f)"$(command git config --get-regexp '^claude\.profile\.' 2>/dev/null | command cut -d' ' -f2-)"});
dirs=(${dirs/#\~/$HOME});
for d in $dirs; do
for f in $d/sessions/<->.json(N); do
# ponytail: regex over the JSON text, since this runs on every
# launch and a parser costs a process. A cwd holding a JSON
# escape (a quote, a backslash, non-ASCII) is not matched, so
# that session goes unreported; parse properly if it ever bites.
json="$(<$f)" 2>/dev/null || continue;
[[ $json =~ '"cwd"[[:space:]]*:[[:space:]]*"([^"]*)"' && ${match[1]:A} == $here ]] || continue;
[[ $json =~ '"pid"[[:space:]]*:[[:space:]]*([0-9]+)' ]] || continue;
pid=$match[1];
kill -0 $pid 2>/dev/null || continue;
name=${here:t} state=-;
[[ $json =~ '"name"[[:space:]]*:[[:space:]]*"([^"]*)"' ]] && name=$match[1];
[[ $json =~ '"status"[[:space:]]*:[[:space:]]*"([^"]*)"' ]] && state=$match[1];
print -r -- "$pid"$'\t'"$d"$'\t'"$name"$'\t'"$state";
done;
done;
}

# Can a link opened from this shell land in a browser window somebody is
# looking at? macOS always has `open` and a screen; elsewhere it takes
# xdg-open and a display. Over ssh the answer is no even on a mac — a window
Expand Down Expand Up @@ -573,7 +630,7 @@ function claude-as() {
function claude-doctor() {
local origin acct map dir cred email slug pin;
print -r -- "wrapper: $(whence -w claude)";
print -r -- "defined: $(defined_trace claude_profile_dir claude_browser_alias claude_browser_can_open gh_account_for_cwd git_origin_slug bin_trace)";
print -r -- "defined: $(defined_trace claude_profile_dir claude_sessions_here claude_browser_alias claude_browser_can_open gh_account_for_cwd git_origin_slug bin_trace)";
print -r -- "binary: $(bin="$(whence -p claude)" && bin_trace "$bin" || print -r -- 'NOT FOUND on PATH')";
print -r -- "cwd: $PWD";
origin="$(command git config --get remote.origin.url 2>/dev/null)";
Expand Down Expand Up @@ -606,6 +663,19 @@ function claude-doctor() {
dir="$HOME/.claude"; bare=1;
print -r -- "launch: $dir (bare claude fallback)";
fi;
# What claude() would ask about before launching here: sessions already
# live in this directory, in any profile.
local -a running;
running=(${(f)"$(claude_sessions_here $dir 2>/dev/null)"});
if (( ${#running} )); then
if [[ "$(command git config --get claude.second-session-check 2>/dev/null)" == false ]]; then
print -r -- "running: ${#running} in this directory (pid ${(j:, :)running%%$'\t'*}) — claude.second-session-check is false, so claude does not ask";
else
print -r -- "running: ${#running} in this directory (pid ${(j:, :)running%%$'\t'*}) — claude asks before starting another";
fi;
else
print -r -- "running: <no session in this directory>";
fi;
if [[ $dir == "$HOME/.claude" ]]; then
# The default profile authenticates via the shared Keychain entry and
# records no account metadata on disk — presence is all we can check.
Expand Down
163 changes: 154 additions & 9 deletions tests/claude-profiles.bats
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
# tests/chezmoi.bats guards that the file renders at all.

setup() {
# python3 is resolved to its real interpreter before HOME is sandboxed, and
# re-exposed under a stub on PATH below: an asdf shim (as on this machine)
# resolves relative to $HOME and exits 126 the moment that is overridden.
REAL_PYTHON3=''
REAL_PYTHON3="$(python3 -c 'import sys; print(sys.executable)' 2>/dev/null)" || REAL_PYTHON3=''
export HOME="$BATS_TEST_TMPDIR/home"
mkdir -p "$HOME"
export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig"
Expand Down Expand Up @@ -41,6 +46,10 @@ setup() {
printf '#!/bin/sh\necho "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-UNSET}"\n' \
> "$BATS_TEST_TMPDIR/bin/claude"
chmod +x "$BATS_TEST_TMPDIR/bin/claude"
if [ -n "$REAL_PYTHON3" ]; then
printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3"
chmod +x "$BATS_TEST_TMPDIR/bin/python3"
fi
export PATH="$BATS_TEST_TMPDIR/bin:$PATH"

DOTFUNCTIONS="$BATS_TEST_DIRNAME/../dot_functions"
Expand Down Expand Up @@ -147,7 +156,7 @@ claude_in() {
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor"
[ "$status" -eq 0 ]
[[ "$output" == *"wrapper: claude: function"* ]]
[[ "$output" == *"defined: ok (6 helpers)"* ]]
[[ "$output" == *"defined: ok (7 helpers)"* ]]
[[ "$output" == *"binary: $BATS_TEST_TMPDIR/bin/claude"* ]]
[[ "$output" == *"account: personal-account"* ]]
[[ "$output" == *"launch: $HOME/.claude-personal"* ]]
Expand All @@ -160,14 +169,8 @@ claude_in() {

@test "claude-doctor: names the deny guard's rule count and fail-closed wiring" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
# denyguard itself needs a real python3: an asdf shim (as on this
# machine) resolves relative to $HOME and breaks under the sandboxed one
# these tests already use, which has nothing to do with the doctor line
# under test.
command -v python3 >/dev/null || skip "python3 not installed"
REAL_PYTHON3="$(python3 -c 'import sys; print(sys.executable)')"
printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3"
chmod +x "$BATS_TEST_TMPDIR/bin/python3"
# denyguard itself needs a real python3 (see setup).
[ -n "$REAL_PYTHON3" ] || skip "python3 not installed"
ln -s "$BATS_TEST_DIRNAME/../bin/executable_denyguard" "$BATS_TEST_TMPDIR/bin/denyguard"

mkdir -p "$HOME/.claude-personal"
Expand Down Expand Up @@ -740,3 +743,145 @@ NO_OPENAS_PATH="$BATS_TEST_TMPDIR/bin:/usr/bin:/bin"
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; BROWSER=firefox claude-doctor"
[[ "$output" == *"browser: <no claude.~/.claude.browser pin — links open in BROWSER=firefox>"* ]]
}

# --- a second session in the same directory ---------------------------------
#
# claude() asks before launching where a session is already live. The registry
# it reads is `<profile dir>/sessions/<pid>.json`; these fixtures write that
# file with a pid that is certainly alive (this bats process) or certainly not
# (a reaped child).

# make_session <profile dir> <pid> <cwd> [name]
make_session() {
mkdir -p "$1/sessions"
printf '{"pid":%s,"sessionId":"fixture","cwd":"%s","kind":"interactive","name":"%s","status":"idle"}\n' \
"$2" "$3" "${4:-fixture-session}" > "$1/sessions/$2.json"
}

# Run `claude "$@"` in $PTY_DIR on a pseudo-terminal — the question is only
# asked of a launch somebody is sitting at — and answer it with $PTY_ANSWER,
# typed ahead so a launch that asks nothing simply leaves it unread. A
# hand-rolled fork-and-read rather than the obvious tools: zsh's zpty returns
# short reads on macOS, and pty.spawn before Python 3.10 never returns there
# once the child exits.
pty_claude() {
[ -n "$REAL_PYTHON3" ] || skip "python3 not installed"
printf "source '%s'; cd '%s'; claude %s; print rc=\$?\n" "$DOTFUNCTIONS" "$PTY_DIR" "$*" \
> "$BATS_TEST_TMPDIR/pty-inner.zsh"
run python3 -c '
import os, pty, sys
pid, fd = pty.fork()
if pid == 0:
os.execvp("zsh", ["zsh", sys.argv[1]])
os.write(fd, sys.argv[2].encode())
while True:
try:
data = os.read(fd, 4096)
except OSError:
break
if not data:
break
sys.stdout.buffer.write(data)
os.waitpid(pid, 0)
' "$BATS_TEST_TMPDIR/pty-inner.zsh" "$PTY_ANSWER"
}

@test "sessions: lists a live session in this directory, in any mapped profile" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
true & dead=$!
wait "$dead"
make_session "$HOME/.claude-personal" "$$" "$repo" in-this-dir
make_session "$HOME/.claude-personal" "$dead" "$repo" exited
make_session "$HOME/.claude" "$PPID" "$BATS_TEST_TMPDIR" elsewhere
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here"
[ "$status" -eq 0 ]
[ "$output" = "$$ $HOME/.claude-personal in-this-dir idle" ]
# The other profile's session is found from its own directory too.
run zsh -c "source '$DOTFUNCTIONS'; cd '$BATS_TEST_TMPDIR'; claude_sessions_here"
[ "$output" = "$PPID $HOME/.claude elsewhere idle" ]
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor"
[[ "$output" == *"running: 1 in this directory (pid $$) — claude asks before starting another"* ]]
}

@test "sessions: nothing live here prints nothing, and the doctor says so" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here"
[ "$status" -eq 0 ]
[ -z "$output" ]
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor"
[[ "$output" == *"running: <no session in this directory>"* ]]
}

@test "sessions: found with no profile mapped at all, and in a literal CLAUDE_PROFILE directory" {
# A public-only clone has no claude.profile.* mapping: the default
# directory is still looked in, and so is a directory only this launch names.
git config --file "$GIT_CONFIG_GLOBAL" --unset claude.profile.work-account
git config --file "$GIT_CONFIG_GLOBAL" --unset claude.profile.personal-account
repo=$(make_repo 'git@github.com:someone-else/some-repo.git')
make_session "$HOME/.claude" "$$" "$repo" default-dir
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here"
[ "$output" = "$$ $HOME/.claude default-dir idle" ]
rm -r "$HOME/.claude/sessions"
make_session "$BATS_TEST_TMPDIR/custom-profile" "$$" "$repo" literal-dir
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here"
[ -z "$output" ]
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; CLAUDE_PROFILE='$BATS_TEST_TMPDIR/custom-profile' claude-doctor"
[[ "$output" == *"running: 1 in this directory (pid $$) — claude asks before starting another"* ]]
export CLAUDE_PROFILE="$BATS_TEST_TMPDIR/custom-profile"
PTY_DIR=$repo PTY_ANSWER=n pty_claude
[[ "$output" == *"literal-dir pid $$ idle (custom-profile)"* ]]
[[ "$output" == *"rc=1"* ]]
}

@test "claude: a launch with no terminal is never asked, session or not" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
make_session "$HOME/.claude-personal" "$$" "$repo"
claude_in "$repo"
[ "$status" -eq 0 ]
[ "$output" = "CLAUDE_CONFIG_DIR=$HOME/.claude-personal" ]
}

@test "claude: warns about a live session here, and declining launches nothing" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
make_session "$HOME/.claude-personal" "$$" "$repo" in-this-dir
PTY_DIR=$repo PTY_ANSWER=n pty_claude
[[ "$output" == *"claude: already running in $repo:"* ]]
[[ "$output" == *"in-this-dir pid $$ idle (.claude-personal)"* ]]
[[ "$output" == *"rc=1"* ]]
[[ "$output" != *"CLAUDE_CONFIG_DIR="* ]]
}

@test "claude: answering y gets past the warning and launches" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
make_session "$HOME/.claude-personal" "$$" "$repo"
PTY_DIR=$repo PTY_ANSWER=y pty_claude
[[ "$output" == *"claude: already running in $repo:"* ]]
[[ "$output" == *"CLAUDE_CONFIG_DIR=$HOME/.claude-personal"* ]]
[[ "$output" == *"rc=0"* ]]
}

@test "claude: claude.second-session-check false turns the question off, and the doctor says so" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
make_session "$HOME/.claude-personal" "$$" "$repo"
git config --file "$GIT_CONFIG_GLOBAL" claude.second-session-check false
PTY_DIR=$repo PTY_ANSWER=n pty_claude
[[ "$output" != *"already running"* ]]
[[ "$output" == *"CLAUDE_CONFIG_DIR=$HOME/.claude-personal"* ]]
run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor"
[[ "$output" == *"running: 1 in this directory (pid $$) — claude.second-session-check is false, so claude does not ask"* ]]
}

@test "claude: a subcommand, print mode or a version flag is not a second session" {
repo=$(make_repo 'git@github.com:octo-personal/some-repo.git')
make_session "$HOME/.claude-personal" "$$" "$repo"
for args in 'mcp list' '-p hello' '--version' '--model sonnet --print hello'; do
PTY_DIR=$repo PTY_ANSWER=n pty_claude $args
[[ "$output" != *"already running"* ]]
[[ "$output" == *"CLAUDE_CONFIG_DIR=$HOME/.claude-personal"* ]]
done
# No session here: an ordinary launch is not asked either.
rm -r "$HOME/.claude-personal/sessions"
PTY_DIR=$repo PTY_ANSWER=n pty_claude
[[ "$output" != *"already running"* ]]
[[ "$output" == *"rc=0"* ]]
}
Loading