Files
smoidoandClaude Opus 5 441166d003 Split the POSIX CLI into modules
linux/claude-mode was 3,035 lines. It is now 177: the paths and flags, a
loader, and the command dispatch. Everything else moved, verbatim, into
twelve files under linux/lib/, one per concern - output, core, preflight,
sessions, switch, catalogue, commands, doctor, presets, menu, setup, repair.
The move was done by line range with a check that every original line landed
in exactly one file; the only thing that changed place is the switch's
running-sessions question, which now sits with session detection.

The CLI finds lib/, cm-json.py and cm-vault.sh beside itself, following the
~/.local/bin symlink, so a checkout runs its own code rather than the
installed version's (it used to mix the two). The key helper path written
into settings.json is still the installed one.

linux/install.sh ships bin/lib/, clearing old modules first so a removed one
cannot linger. The package build copies linux/ recursively, which a flat copy
would not.

tests/cli/test_install.sh runs the real installer into a sandbox home: every
file lands, the symlink runs, a reinstall keeps edited presets and drops a
stale module. docs/architecture.md lists the modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:00:10 +03:00

405 lines
15 KiB
Bash

# shellcheck shell=bash
# linux/lib/sessions.sh - finding running Claude Code sessions, and asking about them before a switch.
#
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
# CM_* paths, PY and JSON used here. Not meant to run on its own.
# ---------------------------------------------------------------------------
# Running sessions
#
# A switch breaks running sessions. Not "leaves them on the old provider" -
# breaks them, and it is worth being exact about why, because the two halves of
# the config behave differently.
#
# The static half - base URL, model ids, the env block - really is read once at
# startup, and a running session keeps the values it started with.
#
# The credential is not. It is fetched by running apiKeyHelper, which Claude
# Code re-invokes on a timer (CLAUDE_CODE_API_KEY_HELPER_TTL_MS, present in
# 2.1.251), and the helper answers for whatever state.json says *now*. So a
# switch reaches into a live session through the one thing that was never
# cached:
#
# -> anthropic the helper returns nothing at all, by design, and the
# session's next refresh comes back with no credential
# -> another provider
# the helper hands over the new provider's key while the
# session is still pointed at the old base URL, which rejects
# it
#
# Either way the session starts failing its calls, at whatever moment the TTL
# happens to expire - mid-turn as easily as between turns. The one case that
# does survive is a switch between two presets of the same provider sharing a
# keyRef: same key, same endpoint, and the session simply carries on with the
# model ids it started with.
#
# Sessions are found through /proc/<pid>/exe rather than by matching process
# names. `claude` is a real ELF binary here, so the symlink resolves to it
# exactly, and a name match would sweep up every shell that merely mentions
# claude in its command line - including the ones this tool is invoked from.
# ---------------------------------------------------------------------------
cm_is_claude_pid() {
local exe
exe="$(readlink "/proc/$1/exe" 2>/dev/null)" || return 1
case "$exe" in
*/claude|*/claude-code) return 0 ;;
*) return 1 ;;
esac
}
# /proc/<pid>/stat has the command name in parentheses, and it may contain
# spaces - so fields are only safe to count after the last ')'. Everything below
# indexes into that remainder, where field 1 is the process state.
cm_stat_rest() {
local s
s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1
printf '%s' "${s##*) }"
}
cm_ppid_of() {
local r; r="$(cm_stat_rest "$1")" || return 1
printf '%s' "$r" | cut -d' ' -f2
}
# utime + stime, in jiffies. Sampled twice to tell a session that is thinking
# from one that is sitting at a prompt.
cm_cputime_of() {
local r u s; r="$(cm_stat_rest "$1")" || return 1
u="$(printf '%s' "$r" | cut -d' ' -f12)"
s="$(printf '%s' "$r" | cut -d' ' -f13)"
case "$u$s" in ''|*[!0-9]*) printf '0'; return 0 ;; esac
printf '%s' $((u + s))
}
# The session this very command is running inside, if any. It is listed like the
# others but never acted on by default: killing the session that asked for the
# kill is not a thing anyone means.
cm_self_session() {
local p="${PPID:-0}" guard=0
while [ "$p" -gt 1 ] && [ "$guard" -lt 40 ]; do
if cm_is_claude_pid "$p"; then printf '%s' "$p"; return 0; fi
p="$(cm_ppid_of "$p")" || return 1
case "$p" in ''|*[!0-9]*) return 1 ;; esac
guard=$((guard + 1))
done
return 1
}
# TSV: pid \t ppid \t tty \t busy \t cwd \t self \t parent-cmd
# Session discovery reads /proc, so it is Linux-only. Elsewhere the tool
# cannot see running sessions at all - which has to be said rather than
# silently reported as "none running", since that is the answer that gets
# people to switch out from under a live session.
cm_sessions_supported() { [ -d /proc/self ]; }
cm_session_rows() {
local self pid ppid tty cwd busy isself d
cm_sessions_supported || return 0
self="$(cm_self_session 2>/dev/null || true)"
local pids=() before=() after=() pp
for d in /proc/[0-9]*; do
pid="${d#/proc/}"
cm_is_claude_pid "$pid" || continue
# A session's parent is a terminal or a shell. A busy session also forks
# children off its own binary while it works, and those inherit the same
# /proc/<pid>/exe - so without this the count climbs and falls with how
# hard the machine is thinking, and the list fills with pids that are
# gone a second later. Anything whose parent is itself claude is one of
# those, not a session.
pp="$(cm_ppid_of "$pid")" || continue
cm_is_claude_pid "$pp" && continue
pids+=("$pid")
before+=("$(cm_cputime_of "$pid")")
done
[ "${#pids[@]}" -gt 0 ] || return 0
# A single shared sample window rather than one per process, so the whole
# listing costs 300ms no matter how many sessions are open.
sleep 0.3
local i
for i in "${!pids[@]}"; do after+=("$(cm_cputime_of "${pids[$i]}")"); done
for i in "${!pids[@]}"; do
pid="${pids[$i]}"
[ -d "/proc/$pid" ] || continue
ppid="$(cm_ppid_of "$pid")"
cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null)"; [ -n "$cwd" ] || cwd='?'
tty="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')"; [ -n "$tty" ] || tty='?'
# 0.3s of wall clock is ~30 jiffies at the usual 100Hz; a few of them
# spent is a session doing work rather than waiting on a keystroke.
busy=no
[ $(( ${after[$i]:-0} - ${before[$i]:-0} )) -ge 3 ] && busy=yes
isself=no; [ "$pid" = "$self" ] && isself=yes
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$pid" "$ppid" "$tty" "$busy" "$cwd" "$isself" \
"$(tr '\0' ' ' < "/proc/$ppid/cmdline" 2>/dev/null | sed 's/[[:space:]]*$//')"
done
}
cm_session_count() { cm_session_rows | grep -c . || true; }
cmd_sessions() {
local action=list dry=0 assume=0 json=0 a
for a in "$@"; do
case "$a" in
--json) json=1 ;;
--stop) action=stop ;;
--restart) action=restart ;;
--dry-run) dry=1 ;;
-y|--yes) assume=1 ;;
list|'') ;;
*) err "unknown option '$a'"; return 1 ;;
esac
done
local rows n busy
rows="$(cm_session_rows)"
n="$(printf '%s' "$rows" | grep -c . || true)"
if [ "$json" -eq 1 ]; then
printf '%s\n' "$rows" | "$PY" "$JSON" sessions-json
return 0
fi
if ! cm_sessions_supported; then
head_ 'running sessions'
warn 'session control needs /proc, so it is Linux-only'
say 'on macOS, restart Claude Code yourself after a switch'
return 0
fi
if [ "${n:-0}" -eq 0 ]; then
head_ 'running sessions'
ok 'no Claude Code sessions running'
return 0
fi
head_ "running sessions ($n)"
local pid ppid tty b cwd isself pcmd tag
while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
[ -n "$pid" ] || continue
tag=''
[ "$b" = yes ] && tag=" ${C_YELLOW}working${C_RESET}"
[ "$isself" = yes ] && tag="$tag ${C_DIM}(this session - never touched)${C_RESET}"
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
done <<EOF_ROWS
$rows
EOF_ROWS
if [ "$action" = "list" ]; then
printf '\n %stheir credential is re-fetched on a timer, so a switch breaks them%s\n' "$C_DIM" "$C_RESET"
printf ' %sclaude-mode sessions --stop close them%s\n' "$C_DIM" "$C_RESET"
printf ' %sclaude-mode sessions --restart close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET"
printf ' %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET"
return 0
fi
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
if [ "$dry" -eq 1 ]; then
printf '\n %sdry run - nothing will be signalled%s\n' "$C_DIM" "$C_RESET"
cm_session_act "$action" "$rows" 1
return 0
fi
# Stopping someone's editor mid-thought is not undoable, so an interactive
# run asks first. --yes is for the bar widget, which has already asked in
# its own dialog and would otherwise hang here with nowhere to type.
if [ "$assume" -eq 0 ]; then
if ! ui_interactive; then
err 'refusing to stop sessions without a confirmation'
say 'pass --yes if you mean it, or --dry-run to see what would happen'
return 1
fi
printf '\n'
if [ "${busy:-0}" -gt 0 ]; then
warn "$busy of these is mid-request and will lose that turn"
fi
if [ "$action" = restart ]; then
printf ' close and reopen these sessions? [y/N] '
else
printf ' close these sessions? [y/N] '
fi
local reply; IFS= read -r reply
case "$reply" in
y|Y|yes|YES) ;;
*) say 'left alone'; return 0 ;;
esac
fi
cm_session_act "$action" "$rows" 0
}
# Terminate, and optionally reopen. SIGTERM only: Claude Code cleans up its
# transcript on the way out, and SIGKILL would cost that for no gain.
cm_session_act() {
local action="$1" rows="$2" dry="${3:-0}" pid ppid tty busy cwd isself pcmd acted=0 skipped=0
local -a relaunch_cwd relaunch_cmd
while IFS=$'\t' read -r pid ppid tty busy cwd isself pcmd; do
[ -n "$pid" ] || continue
if [ "$isself" = yes ]; then
warn "skipping $pid - that is the session running this command"
skipped=$((skipped + 1))
continue
fi
if [ "$action" = "restart" ]; then
relaunch_cwd+=("$cwd")
relaunch_cmd+=("$pcmd")
fi
if [ "$dry" -eq 1 ]; then
say "would stop $pid ($cwd)"
acted=$((acted + 1))
elif kill -TERM "$pid" 2>/dev/null; then
ok "stopped $pid ($cwd)"
acted=$((acted + 1))
else
err "could not stop $pid"
fi
done <<EOF_ROWS
$rows
EOF_ROWS
[ "$acted" -gt 0 ] && [ "$dry" -eq 0 ] && sleep 0.6
if [ "$action" = "restart" ] && [ "${#relaunch_cwd[@]}" -gt 0 ]; then
local i c t
for i in "${!relaunch_cwd[@]}"; do
c="${relaunch_cwd[$i]}"; t="${relaunch_cmd[$i]}"
[ -d "$c" ] || c="$HOME"
# The parent of a session started from the app launcher is the
# terminal that was told to run claude, so re-running its command
# line reproduces the session exactly - same terminal, same flags.
# A session started by hand inside an existing shell has no such
# parent to copy, and there is no way to type into that shell from
# here, so it gets a fresh terminal in the same directory instead.
case "$t" in
*" -e "*claude*|*" --command"*claude*) ;;
*) t="$(cm_terminal_cmd) -e claude" ;;
esac
if [ "$dry" -eq 1 ]; then
say "would reopen in $c: $t"
else
( cd "$c" && setsid nohup $t >/dev/null 2>&1 & )
ok "reopened in $c"
fi
done
fi
[ "$skipped" -gt 0 ] && say 'this session was left running'
return 0
}
cm_terminal_cmd() {
local t
for t in "${TERMINAL:-}" foot alacritty ghostty kitty; do
[ -n "$t" ] || continue
command -v "$t" >/dev/null 2>&1 && { printf '%s' "$t"; return 0; }
done
printf 'xterm'
}
# ---------------------------------------------------------------------------
# Live sessions: asked before the write, not reported after it
#
# The damage a switch does to a running session is not limited to it failing
# calls. If the session takes even one completion from the new provider before
# anything notices, that provider's message-id format lands in its transcript -
# OpenRouter issues `gen-<epoch>-<rand>` where Anthropic issues `msg_...` - and
# native Anthropic then refuses to resume the session at all:
#
# API Error: 400 diagnostics.previous_message_id: must be the `id` from a
# prior /v1/messages response (starts with `msg_`)
#
# There is no supported way back from that. The only fix is to truncate the
# transcript to the last message Anthropic issued, losing everything after it
# (see `claude-mode repair-session`). A confirmation that costs one keystroke is
# cheap against a failure that costs an afternoon of conversation.
# ---------------------------------------------------------------------------
CM_ASSUME_YES=0
CM_SESSION_ACTION=none
CM_SESSION_ROWS=''
cm_confirm_sessions() {
local mode="$1" rows n busy reply
CM_SESSION_ACTION=none
CM_SESSION_ROWS=''
# Re-applying the active preset after a tier edit: same endpoint, same key,
# so running sessions are not at risk - see reapply_if_active.
[ "$CM_SAME_ENDPOINT" -eq 1 ] && return 0
if ! cm_sessions_supported; then
printf '\n'
warn 'cannot list running sessions here (no /proc) - the switch will not wait for them'
return 0
fi
rows="$(cm_session_rows)"
n="$(printf '%s' "$rows" | grep -c . || true)"
[ "${n:-0}" -gt 0 ] || return 0
CM_SESSION_ROWS="$rows"
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
printf '\n'
warn "$n Claude Code session(s) are running right now"
local pid ppid tty b cwd isself pcmd tag
while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
[ -n "$pid" ] || continue
tag=''
[ "$b" = yes ] && tag=" ${C_YELLOW}working${C_RESET}"
[ "$isself" = yes ] && tag="$tag ${C_DIM}(this one)${C_RESET}"
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
done <<EOF_ROWS
$rows
EOF_ROWS
printf '\n'
say 'Their key is re-fetched on a timer and will resolve to the new mode,'
say 'which the endpoint they are still pointed at will not accept. If one'
say 'of them takes a reply from the new provider first, that provider'"'"'s'
say 'message-id format goes into its transcript and Anthropic will then'
say 'refuse to resume that session at all - recoverable only by truncating'
say 'it (claude-mode repair-session), which loses the turns after the cut.'
[ "${busy:-0}" -gt 0 ] && warn "$busy of them is mid-request and is the most likely to be caught"
if [ "$CM_ASSUME_YES" -eq 1 ]; then
say 'proceeding (--yes)'
return 0
fi
if ! ui_interactive; then
printf '\n'
err 'refusing to switch while sessions are running'
say 'restart or close them first, or pass --yes to switch anyway'
return 1
fi
printf '\n'
say 'r switch, then close and reopen them on the new mode (safest)'
say 'c switch, then close them'
say 's switch and leave them running (risks the above)'
say 'a abort'
printf '\n [r/c/s/A] '
IFS= read -r reply
case "$reply" in
r|R) CM_SESSION_ACTION=restart; return 0 ;;
c|C) CM_SESSION_ACTION=stop; return 0 ;;
s|S) CM_SESSION_ACTION=none; return 0 ;;
*) say 'aborted; nothing was changed'; return 1 ;;
esac
}
# Run after the write, never before: a session reopened first would come back up
# on the mode being left behind.
cm_apply_session_action() {
[ "$CM_SESSION_ACTION" = "none" ] && return 0
[ -n "$CM_SESSION_ROWS" ] || return 0
printf '\n'
cm_session_act "$CM_SESSION_ACTION" "$CM_SESSION_ROWS" 0
}