LLM
Yesterday

Как обновлять 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, для этого у нее есть отдельная вкладка на панели:

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-команды.

  1. Claude Code запускает claude-restart как дочерний процесс сессии.
  2. Скрипт идёт вверх по дереву процессов и находит pid своего claude.
  3. Скрипт читает sessionId из реестра, флаги запуска из ps и вкладку из окружения процесса.
  4. Если у сессии есть фоновые shell-команды, скрипт отказывает с кодом 1.
  5. Скрипт запускает помощника в новом сеансе процессов через setsid и завершается.
  6. Помощник ждёт, пока статус сессии перестанет быть busy.
  7. Помощник посылает процессу SIGTERM и ждёт его завершения до 30 секунд.
  8. Помощник вводит во вкладку claude --resume <sessionId> с исходными флагами.

Помощник нужен потому, что скрипт работает внутри сессии и умирает вместе с ней. setsid выводит помощника из группы процессов Claude Code.

Код

Файл ~/.local/bin/claude-restart. Зависимости: orca, jq, perl.

Установка

  1. Сохраните скрипт в ~/.local/bin/claude-restart.
  2. Выполните chmod +x ~/.local/bin/claude-restart.
  3. Откройте в Orca Settings → Quick Commands.
  4. Создайте команду с меткой Restart Claude, областью Global и текстом ! claude-restart.
  5. Включите флаг отправки Enter.
Настройки Quick Command

Сводка пробного запуска:

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 отказа. Один отказ это ошибка с меню подсказок. Второй это намеренный тест предела ожидания.

Скрипты

claude-code-upgrade

#!/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>

claude-restart

#!/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 "$@"