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>
405 lines
15 KiB
Bash
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
|
|
}
|