Как обновлять Claude Code, не теряя 20+ живых сессий
Для работы с агентом я использую прекрасный инструмент Orca, о котором узнал от Сережи Риса, читая его отличный блог.
Claude Code обновляется часто, Homebrew обновляет бинарник на диске, но старые процессы продолжают жить на старой версии; закрывать 20+ сессий вручную - много лишних операций.
Автоматизация обновления бинарника
Для того чтобы не обновлять бинарник руками Claude собрал мне такой шелл-скрипт:
~/.local/bin/claude-code-upgrade
В скрипте три решения, которые легко пропустить:
launchdзапускает задачи с минимальнымPATH. Без строкиexport PATH=…brew не находит свои утилиты.brew upgradeобновляет метаданные не чаще одного раза за 24 часа. Ежедневная задача из-за этого может пропустить сборку. Поэтому скрипт сам вызываетbrew updateи отключает автообновление метаданных.- При ошибке скрипт пишет в лог код возврата и весь вывод команды. При успехе пишет одну строку.
LaunchAgent
Для автозапуска скрипта использовал launchd:
~/Library/LaunchAgents/com.ihumster.claude-code-upgrade.plist
Установка и размещение
Скрипт обновления кладем в ~/.local/bin/, LaunchAgent складываем в ~/Library/LaunchAgents/.
chmod +x ~/.local/bin/claude-code-upgrade launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.ihumster.claude-code-upgrade.plist launchctl print "gui/$(id -u)/com.ihumster.claude-code-upgrade" | grep -E 'state|last exit'
Если Mac спал в 08:00, launchd запускает пропущенную задачу после пробуждения. RunAtLoad добавляет запуск при входе в систему.
2026-10-05 13:39:33 OK upgraded 2.1.288 -> 2.1.289 2026-10-06 08:00:52 OK upgraded 2.1.289 -> 2.1.290 2026-10-06 16:17:03 OK upgraded 2.1.290 -> 2.1.291
Часть 2. Проблема: бинарник новый, сессии старые
Brew меняет файл на диске. Запущенный процесс продолжает работать на том бинарнике, с которого стартовал. Каталог старой версии brew переименовывает в <версия>.upgrading, и процесс держит его открытым.
Команда показывает, на какой версии работает каждая сессия:
jq -r '[.status, .version] | @tsv' ~/.claude/sessions/*.json | sort | uniq -c
Результат на 2026-10-05, установлена версия 2.1.289:
1 busy 2.1.288 1 busy 2.1.289 4 idle 2.1.276 5 idle 2.1.280 8 idle 2.1.283 4 idle 2.1.288
Самые старые сессии работали с 2026-09-22. Закрыть вкладку и открыть новую нельзя, потому что в сессии лежит контекст задачи. Нет, Orca конечно позволяет открыть сессию из Agent Session History, для этого у нее есть отдельная вкладка на панели:
И даже более того у Orca теперь есть поиск по всем сессиям проекта - очень удобно порой. История сессий решает задачу восстановления, но для регулярного обновления десятков открытых вкладок это слишком много ручных действий.
Почему не автоматический перезапуск в 08:00
Первая идея была такой: после обновления скрипт сам перезапускает все простаивающие сессии. Я от неё отказался. Перезапуск убивает всё, что сессия держит в фоне: фоновые команды, субагентов, Monitor и запланированные пробуждения /loop. Статус idle в реестре этого не показывает. Сессия в статусе idle может ждать ответа на вопрос или окончания фоновой задачи.
Решение о перезапуске принимает человек, по одной вкладке. Значит, нужна кнопка.
Часть 3. Что известно о сессии снаружи
Для перезапуска нужны четыре вещи: идентификатор сессии, флаги запуска, вкладка Orca и способ ввести команду во вкладку. Все четыре можно получить без участия модели.
- Идентификатор сессии, статус и версия. Они лежат в файле
~/.claude/sessions/<pid>.json, в поляхsessionId,statusиversion. - Флаги запуска. Их показывает команда
ps -o args= -p <pid>. - Вкладка Orca. Её задают переменные
ORCA_PANE_KEYиORCA_TERMINAL_HANDLEв окружении процессаclaude. - Ввод во вкладку. Его делает команда
orca terminal send --terminal <handle> --text … --enter.
Ещё один факт упрощает задачу. Родитель каждого процесса claude во вкладке Orca это zsh. После выхода из Claude Code вкладка остаётся открытой, в ней работает shell. В него можно ввести claude --resume <sessionId>.
ORCA_TERMINAL_HANDLE перестаёт работать после перезапуска Orca. ORCA_PANE_KEY в формате tabId:leafId остаётся верным, по нему скрипт ищет актуальный handle в orca terminal list --json.
Часть 4. Quick Commands в Orca
Quick Command это сохранённая строка текста с меткой. Команды создают в Settings → Quick Commands или кнопкой Add command в панели вкладок. У команды есть область Global или Project и флаг «нажать Enter после».
Способ запуска меняет поведение:
Для перезапуска подходит только второй способ. В новой вкладке нет агента, которого нужно перезапустить.
У Quick Commands в версии Orca 1.4.220 три ограничения:
- В тексте команды нет переменных,
sessionIdподставить нельзя. - Одна команда это одна строка. Последовательность «выйти, подождать, возобновить» в неё не помещается.
- Из CLI командами управлять нельзя. Группа
orca quick-commandесть только в открытом PR #20162.
Поэтому Quick Command содержит один вызов скрипта, а всю работу делает скрипт.
Часть 5. Скрипт claude-restart
Схема
Текст Quick Command: ! claude-restart. Знак ! в Claude Code (да и многих других харнесах) включает режим shell-команды.
- Claude Code запускает
claude-restartкак дочерний процесс сессии. - Скрипт идёт вверх по дереву процессов и находит pid своего
claude. - Скрипт читает
sessionIdиз реестра, флаги запуска изpsи вкладку из окружения процесса. - Если у сессии есть фоновые shell-команды, скрипт отказывает с кодом 1.
- Скрипт запускает помощника в новом сеансе процессов через
setsidи завершается. - Помощник ждёт, пока статус сессии перестанет быть
busy. - Помощник посылает процессу
SIGTERMи ждёт его завершения до 30 секунд. - Помощник вводит во вкладку
claude --resume <sessionId>с исходными флагами.
Помощник нужен потому, что скрипт работает внутри сессии и умирает вместе с ней. setsid выводит помощника из группы процессов Claude Code.
Код
Файл ~/.local/bin/claude-restart. Зависимости: orca, jq, perl.
Установка
- Сохраните скрипт в
~/.local/bin/claude-restart. - Выполните
chmod +x ~/.local/bin/claude-restart. - Откройте в Orca Settings → Quick Commands.
- Создайте команду с меткой
Restart Claude, областью Global и текстом! claude-restart. - Включите флаг отправки Enter.
Сводка пробного запуска:
pid: 90335 (busy) version: 2.1.289 -> 2.1.289 session: 2e7c11a7-ea70-4ac1-8a07-276f397aa29a pane: 7fd32fe7-e7bf-4ff3-a972-fe2780610d5a:f2c231d4-218a-4889-b9bb-f2c47fca10d0 terminal: term_62415b02-fb9a-4c85-ad3d-9e0879d7b063 resume: claude --resume 2e7c11a7-ea70-4ac1-8a07-276f397aa29a background: none dry run: nothing sent
Лог боевого запуска в ~/Library/Logs/claude-restart.log:
2026-10-05 18:48:00 exit pid=86624 handle=term_9cf9f3f2-0e80-477b-b3de-a41395a4fe82 2026-10-05 18:48:02 resume: claude --resume 5fd45495-3a66-421f-9a3b-df09fba7bf0b 2026-10-05 18:48:04 OK
Часть 6. Три неочевидные проблемы
Ход модели после !
Вывод shell-команды попадает в контекст, и модель начинает ход: пересказывает сводку. В первой версии помощник ждал 2 секунды и посылал команду выхода. При длинном ходе команда пришла бы посреди ответа.
Теперь помощник читает поле status в реестре и ждёт, пока оно перестанет быть busy. Предел ожидания 120 секунд. После предела помощник ничего не посылает и пишет FAIL в лог.
Меню подсказок перехватывает /exit
Первая версия вводила в TUI текст /exit и Enter. Claude Code на символ / открывает меню команд и скиллов. Указатель мыши над меню меняет выбранный пункт, и Enter запускает другую команду. Так одна рабочая сессия получила чужую команду вместо выхода. Скрипт подождал 30 секунд и не стал вводить команду возобновления:
2026-10-05 17:48:04 exit pid=56293 handle=term_b861a059-2184-40b9-bb4a-17c8fb2ef4eb 2026-10-05 17:48:37 FAIL pid=56293 still alive after 30s, resume not sent
Теперь скрипт ничего не вводит в TUI. Он посылает процессу SIGTERM. Claude Code по этому сигналу завершается штатно и возвращает терминал в обычный режим.
Проверка на 30 секунд в этой истории сработала как задумано. Без неё команда claude --resume … попала бы в живую сессию как запрос к модели.
Вкладка без tabId
Из 22 сессий две не нашлись по ORCA_PANE_KEY. Их вкладки не были смонтированы в окне. Для таких вкладок orca terminal list возвращает tabId и leafId в виде pty:<worktree>@@<id>. Скрипт теперь ищет вкладку по ключу панели и по handle из окружения. После правки нашлись все 22.
Ограничения
- Не полагайтесь на предохранитель для субагентов,
Monitorи/loop. Он видит только фоновые shell-команды. Перед нажатием проверьте их сами. - Скрипт берёт флаги запуска из
ps. Аргумент с пробелом внутри теряет кавычки. - Команда
! claude-restartстоит один ход модели. В проверке это 6 секунд. - Команду возобновления скрипт вводит в
zshкак текст. Плагин автодополнения с выбором мышью может вмешаться так же, как меню Claude Code. - Реестр
~/.claude/sessionsи переменныеORCA_*это внутреннее устройство двух программ. Версии указаны в начале статьи. - Запускайте Quick Command только из контекстного меню терминала. Кнопка в панели вкладок открывает новую вкладку, и скрипт завершается с ошибкой
not running inside a claude session.
Итог
Вечером 2026-10-05 сессии работали на пяти версиях, от 2.1.276 до 2.1.289. На следующий день после двух обновлений осталось две версии: 12 сессий на 2.1.290 и 10 на 2.1.291. В логе claude-restart за это время 8 успешных перезапусков и 2 отказа. Один отказ это ошибка с меню подсказок. Второй это намеренный тест предела ожидания.
Скрипты
#!/bin/bash
# claude-code-upgrade — upgrades the claude-code@latest Homebrew cask.
# Runs from a LaunchAgent daily at 08:00 and at login:
# ~/Library/LaunchAgents/com.ihumster.claude-code-upgrade.plist
set -uo pipefail
# launchd PATH is minimal; brew and its helpers need the full one.
export PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin
export HOMEBREW_NO_ENV_HINTS=1
# Metadata is refreshed by the explicit `brew update` below.
export HOMEBREW_NO_AUTO_UPDATE=1
readonly CASK="claude-code@latest"
# --- Tunables (override via environment) ----------------------------------
BREW="${CCU_BREW:-/opt/homebrew/bin/brew}"
LOG_FILE="${CCU_LOG_FILE:-$HOME/Library/Logs/claude-code-upgrade.log}"
log() {
printf '%s %s\n' "$(/bin/date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG_FILE"
}
# Prints the installed cask version, or "unknown" when brew cannot tell.
cask_version() {
local version
version=$("$BREW" list --cask --versions "$CASK" 2>/dev/null | /usr/bin/awk '{print $2}')
printf '%s\n' "${version:-unknown}"
}
# Runs a command; on failure logs its exit code and output, returns non-zero.
run_step() {
local name="$1" output rc
shift
output=$("$@" 2>&1)
rc=$?
if ((rc != 0)); then
log "FAIL $name rc=$rc"
while IFS= read -r line; do log " $line"; done <<<"$output"
fi
return "$rc"
}
main() {
local before after
before=$(cask_version)
# `brew upgrade` alone refreshes metadata at most once per
# HOMEBREW_AUTO_UPDATE_SECS (24h), so a daily job could miss a build.
run_step update "$BREW" update || exit 1
run_step upgrade "$BREW" upgrade --cask "$CASK" || exit 1
after=$(cask_version)
if [[ "$before" == "$after" ]]; then
log "OK up to date $after"
else
log "OK upgraded $before -> $after"
fi
}
main "$@"com.ihumster.claude-code-upgrade.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ihumster.claude-code-upgrade</string>
<key>ProgramArguments</key>
<array>
<string>/Users/ihumster/.local/bin/claude-code-upgrade</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>8</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/ihumster/Library/Logs/claude-code-upgrade.stderr.log</string>
<key>StandardErrorPath</key>
<string>/Users/ihumster/Library/Logs/claude-code-upgrade.stderr.log</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>#!/bin/bash
# claude-restart — restarts the Claude Code session of one Orca tab on the
# currently installed binary: SIGTERM, then `claude --resume <id>`.
#
# Meant to be run from inside the session it restarts, as `! claude-restart`
# (Orca Quick Command, inserted into the agent tab). A detached helper does
# the typing through `orca terminal send`, because the script itself dies
# together with the session.
#
# claude-restart [--dry-run] [--force] [--pid <claude-pid>]
#
# --dry-run print what would happen, change nothing
# --force restart even when background shell commands are running
# --pid target this claude process instead of the calling session
set -uo pipefail
export PATH=/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
# --- Tunables (override via environment) ----------------------------------
ORCA="${CCR_ORCA:-orca}"
SESSIONS_DIR="${CCR_SESSIONS_DIR:-$HOME/.claude/sessions}"
LOG_FILE="${CCR_LOG_FILE:-$HOME/Library/Logs/claude-restart.log}"
EXIT_TIMEOUT="${CCR_EXIT_TIMEOUT:-30}"
IDLE_TIMEOUT="${CCR_IDLE_TIMEOUT:-120}"
log() {
printf '%s %s\n' "$(/bin/date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG_FILE"
}
die() {
echo "claude-restart: $*" >&2
exit 1
}
comm_of() {
basename "$(ps -o comm= -p "$1" 2>/dev/null)" 2>/dev/null
}
# Prints the pids from this process up to launchd, one per line.
ancestors() {
local pid=$
while [[ -n "$pid" && "$pid" -gt 1 ]]; do
echo "$pid"
pid=$(ps -o ppid= -p "$pid" | tr -d ' ')
done
}
# Prints the nearest ancestor that is a claude process.
calling_claude_pid() {
local pid
for pid in $(ancestors); do
if [[ "$(comm_of "$pid")" == claude ]]; then
echo "$pid"
return 0
fi
done
return 1
}
# Reads one variable from the environment of another process.
env_of() {
ps eww -o command= -p "$1" | tr ' ' '\n' | sed -n "s/^$2=//p" | head -1
}
# Resolves the live terminal handle of a session. The pane key survives an
# Orca restart, the launch-time handle does not; a tab that is not mounted
# is listed without its pane key, so the handle is the fallback.
handle_of_session() {
local pane="$1" launch_handle="$2"
"$ORCA" terminal list --json --limit 500 2>/dev/null |
jq -r --arg pane "$pane" --arg handle "$launch_handle" \
'[.result.terminals[] | select(.tabId + ":" + .leafId == $pane or .handle == $handle)]
| sort_by(.handle == $handle) | .[0].handle // empty'
}
# Prints the launch flags of a claude process without the session selectors.
launch_flags() {
ps -o args= -p "$1" | awk '{
out = ""
for (i = 2; i <= NF; i++) {
if ($i == "--resume" || $i == "-r") { i++; continue }
if ($i == "--continue" || $i == "-c") continue
out = out " " $i
}
print substr(out, 2)
}'
}
# Prints shell children of the session that are not part of this call:
# those are background commands the restart would kill.
background_shells() {
local claude_pid="$1" child mine
mine=" $(ancestors | tr '\n' ' ')"
for child in $(pgrep -P "$claude_pid"); do
[[ "$mine" == *" $child "* ]] && continue
case "$(comm_of "$child")" in
zsh | bash | sh | -zsh | -bash) echo "$child $(ps -o args= -p "$child" | cut -c1-100)" ;;
esac
done
}
send() {
"$ORCA" terminal send --terminal "$1" --text "$2" --enter --json >>"$LOG_FILE" 2>&1
}
# Detached part: runs after the calling shell command has returned.
helper() {
local pid="$1" handle="$2" resume_cmd="$3" waited=0 idle_waited=0
# The `!` output starts a model turn; the signal goes out once it is over.
sleep 2
while [[ "$(jq -r '.status // empty' "$SESSIONS_DIR/$pid.json" 2>/dev/null)" == busy ]]; do
if ((idle_waited >= IDLE_TIMEOUT)); then
log "FAIL pid=$pid still busy after ${IDLE_TIMEOUT}s, nothing sent"
exit 1
fi
sleep 1
idle_waited=$((idle_waited + 1))
done
# A signal, not a typed `/exit`: typed text opens the slash-command menu,
# where a mouse hover can change which command Enter runs.
log "exit pid=$pid handle=$handle"
kill -TERM "$pid" 2>/dev/null || { log "FAIL kill -TERM $pid"; exit 1; }
while kill -0 "$pid" 2>/dev/null; do
if ((waited >= EXIT_TIMEOUT)); then
# Typing the resume command now would land in the TUI as a prompt.
log "FAIL pid=$pid still alive after ${EXIT_TIMEOUT}s, resume not sent"
exit 1
fi
sleep 1
waited=$((waited + 1))
done
sleep 1
log "resume: $resume_cmd"
send "$handle" "$resume_cmd" || { log "FAIL send resume"; exit 1; }
log "OK"
}
main() {
local dry_run=0 force=0 pid=""
while (($#)); do
case "$1" in
--dry-run) dry_run=1 ;;
--force) force=1 ;;
--pid) pid="${2:-}"; shift ;;
--helper) shift; helper "$@"; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
shift
done
[[ -n "$pid" ]] || pid=$(calling_claude_pid) || die "not running inside a claude session; pass --pid"
[[ "$(comm_of "$pid")" == claude ]] || die "pid $pid is not a claude process"
local registry="$SESSIONS_DIR/$pid.json" session_id status version
[[ -r "$registry" ]] || die "no session registry entry: $registry"
session_id=$(jq -r '.sessionId // empty' "$registry")
status=$(jq -r '.status // "unknown"' "$registry")
version=$(jq -r '.version // "unknown"' "$registry")
[[ "$session_id" =~ ^[0-9a-f-]{36}$ ]] || die "bad sessionId in $registry"
local pane handle
pane=$(env_of "$pid" ORCA_PANE_KEY)
[[ -n "$pane" ]] || die "pid $pid has no ORCA_PANE_KEY: not an Orca tab"
handle=$(handle_of_session "$pane" "$(env_of "$pid" ORCA_TERMINAL_HANDLE)")
[[ -n "$handle" ]] || die "no live Orca terminal for pane $pane"
local flags resume_cmd shells installed
flags=$(launch_flags "$pid")
resume_cmd="claude --resume $session_id${flags:+ $flags}"
shells=$(background_shells "$pid")
installed=$(claude --version 2>/dev/null | awk '{print $1}')
cat <<EOF
pid: $pid ($status)
version: $version -> ${installed:-unknown}
session: $session_id
pane: $pane
terminal: $handle
resume: $resume_cmd
background: ${shells:-none}
EOF
if [[ -n "$shells" && "$force" -eq 0 ]]; then
die "background shell commands are running; finish them or pass --force"
fi
if ((dry_run)); then
echo "dry run: nothing sent"
return 0
fi
# New session, so the helper outlives the claude process tree.
/usr/bin/perl -MPOSIX -e 'fork and exit; POSIX::setsid(); exec @ARGV' \
"$0" --helper "$pid" "$handle" "$resume_cmd" </dev/null >/dev/null 2>&1
echo "restarting in 2s; log: $LOG_FILE"
}
main "$@"