# 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//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//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//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 </dev/null; then ok "stopped $pid ($cwd)" acted=$((acted + 1)) else err "could not stop $pid" fi done </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--` 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 <