feat: полная изоляция моделей между ai-* и гибридный persistence effort

Раньше все ai-* лаунчеры делили один ~/.claude и общий settings.json, из-за
чего кастомная модель (openai/gpt-5.5) из ai-openrouter протекала в пикер
ai-claude. Теперь каждый сторонний провайдер изолирован в своём
CLAUDE_CONFIG_DIR (~/.config/ai-setup/cfg/<launcher>) - свои settings.json и
.claude.json, ноль протечек. ai-claude остаётся на ~/.claude (нативный логин).

Пикеры /model приведены к требуемому виду:
- ai-deepseek: только DeepSeek V4 Pro (opus) и DeepSeek V4 Flash (haiku),
  дефолт Pro; через availableModels + ANTHROPIC_DEFAULT_*_MODEL_NAME
- ai-kimi: только Kimi K2.6 (opus)
- ai-claude: только нативные модели Claude
Общие skills и CLAUDE.md шарятся симлинком из ~/.claude.

Persistence effort - гибрид:
- low/medium/high/xhigh живут нативно в settings.json лаунчера, /effort
  внутри сессии работает свободно и уровень сохраняется
- max нельзя сохранить в settings.json (session-only), поэтому он
  восстанавливается через CLAUDE_CODE_EFFORT_LEVEL; в такой max-сессии
  /effort залочен (ограничение Claude Code), выход - AI_EFFORT=<lvl> ai-*
Текущий уровень ловит статусбар в ~/.cache/ai-setup/effort_<launcher>.

Удалён устаревший effort-save-hook (заменён нативным persistence + гибридом),
почищен из ~/.claude/settings.json и осиротевший кэш model_*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:30 +03:00
parent 07983ea84e
commit f8465580e0
5 changed files with 166 additions and 121 deletions

View File

@@ -83,8 +83,34 @@ MINIMAL → LOW → MEDIUM → HIGH
| Kimi K2.6 | Moonshot API | На стороне сервера |
| Gemini 3.x | antigravity-claude-proxy | npm пакет |
## Persistence effort между сессиями
Каждый лаунчер (`ai-claude`, `ai-deepseek`, `ai-kimi`, `ai-openrouter`) запоминает свой
уровень effort отдельно. Логика гибридная:
- **`low` / `medium` / `high` / `xhigh`** живут нативно в `settings.json` лаунчера.
`/effort` внутри сессии работает как обычно, уровень сохраняется между сессиями.
- **`max`** — единственный, который Claude Code **не сохраняет** в `settings.json`
(он session-only). Поэтому его восстанавливаем через `CLAUDE_CODE_EFFORT_LEVEL`.
Текущий уровень (включая `max`) статусбар пишет в `~/.cache/ai-setup/effort_<launcher>`.
**Важное следствие (только для `max`):** когда восстановлена `max`-сессия, выставлена
`CLAUDE_CODE_EFFORT_LEVEL=max`, и `/effort` внутри неё **не сменит** уровень
(env-переменная — жёсткий override Claude Code). На остальных уровнях `/effort` свободен.
**Как выйти из `max` (или форсить любой уровень):** перезапусти лаунчер с `AI_EFFORT`:
```bash
AI_EFFORT=max ai-deepseek # включить и запомнить max
AI_EFFORT=high ai-deepseek # вернуться на high (выйти из max)
ai-deepseek # без флага - восстанавливает последний уровень
```
Дефолты при пустом кэше: `xhigh` для `ai-claude`, `high` для остальных.
## Рекомендации
- **Для повседневной работы:** `high` или `xhigh` — работает одинаково хорошо у всех провайдеров
- **`max` effort:** имеет реальный эффект только у **Anthropic** и **DeepSeek**. Для GPT маппится в `xhigh`, для Gemini и Kimi — в их максимальный уровень
- **`low`/`medium`:** у DeepSeek и Kimi фактически не снижают reasoning — DeepSeek поднимет до `high`, Kimi просто включит thinking
- **Смена уровня:** на `low..xhigh` обычным `/effort`; из `max` — через `AI_EFFORT=<lvl> ai-<launcher>` (в max-сессии `/effort` залочен env-переменной, см. «Persistence effort»)

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Сохраняет текущий effortLevel в кэш лаунчера при завершении сессии.
# /effort внутри Claude Code обновляет settings.json - мы читаем оттуда.
launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && exit 0
cat /dev/stdin > /dev/null 2>&1 # drain stdin (Claude Code передаёт JSON)
mkdir -p "$HOME/.cache/ai-setup"
python3 - "$HOME/.claude/settings.json" "$HOME/.cache/ai-setup" "$launcher" <<'PYEOF'
import json, os, sys
settings_path, cache_dir, launcher = sys.argv[1], sys.argv[2], sys.argv[3]
if not os.path.exists(settings_path):
sys.exit(0)
try:
d = json.load(open(settings_path))
except Exception:
sys.exit(0)
effort = d.get('effortLevel', '')
if effort:
open(os.path.join(cache_dir, f'effort_{launcher}'), 'w').write(effort)
model = d.get('model', '')
if model:
open(os.path.join(cache_dir, f'model_{launcher}'), 'w').write(model)
PYEOF
exit 0

View File

@@ -42,11 +42,12 @@ printf "\033[38;5;252m%s\033[00m" "$short_cwd"
if [ -n "$model" ]; then
brand_color=$(_brand_color "${AI_LAUNCHER:-}")
effort=$(echo "$input" | jq -r ".effort.level // empty")
# Сохраняем effort для persistence между сессиями одного лаунчера
if [ -n "${AI_LAUNCHER:-}" ] && [ -n "$effort" ]; then
# Ловим выбранный уровень в кэш лаунчера (чтобы запомнить max между сессиями).
# Когда CLAUDE_CODE_EFFORT_LEVEL выставлена (восстановленная max-сессия) - уровень
# форсится env, кэш НЕ трогаем, чтобы дисплей-баг (.effort.level=xhigh) не затёр max.
if [ -n "${AI_LAUNCHER:-}" ] && [ -z "${CLAUDE_CODE_EFFORT_LEVEL:-}" ] && [ -n "$effort" ]; then
effort_file="$HOME/.cache/ai-setup/effort_${AI_LAUNCHER}"
prev_effort=$(cat "$effort_file" 2>/dev/null)
if [ "$effort" != "$prev_effort" ]; then
if [ "$effort" != "$(cat "$effort_file" 2>/dev/null)" ]; then
mkdir -p "$HOME/.cache/ai-setup"
echo "$effort" > "$effort_file"
fi

View File

@@ -683,39 +683,39 @@ else
warn "Файл $STATUSLINE_SRC не найден, пропускаю"
fi
# ── 6.7.0. Хук effort-save (сохраняет effort при завершении сессии) ──
info "Деплою хук effort-save..."
EFFORT_HOOK_SRC="$SCRIPT_DIR/home-configs/claude/hooks/effort-save-hook.sh"
EFFORT_HOOK_DST="$HOME/.claude/hooks/effort-save-hook.sh"
mkdir -p "$HOME/.claude/hooks"
if [ -f "$EFFORT_HOOK_SRC" ]; then
cp "$EFFORT_HOOK_SRC" "$EFFORT_HOOK_DST"
chmod +x "$EFFORT_HOOK_DST"
python3 - "$HOME/.claude/settings.json" "$EFFORT_HOOK_DST" <<'PYEOF'
# ── 6.7.0. Чистка устаревшего хука effort-save ──────────────
# effort/model теперь персистятся нативно в settings.json каждого CLAUDE_CONFIG_DIR
# (полная изоляция лаунчеров), самопальный кэш ~/.cache/ai-setup/{effort,model}_* не нужен.
# Удаляем старый хук с диска и из ~/.claude/settings.json (нотифаер в Stop сохраняем).
info "Удаляю устаревший хук effort-save..."
rm -f "$HOME/.claude/hooks/effort-save-hook.sh"
# Осиротевший кэш моделей (источник старой протечки между ai-*); effort_* НЕ трогаем -
# он снова используется для персиста effort (включая max) через CLAUDE_CODE_EFFORT_LEVEL.
rm -f "$HOME"/.cache/ai-setup/model_*
python3 - "$HOME/.claude/settings.json" <<'PYEOF'
import sys, json, os
settings_path, hook_path = sys.argv[1], sys.argv[2]
data = {}
if os.path.exists(settings_path):
settings_path = sys.argv[1]
if not os.path.exists(settings_path):
sys.exit(0)
try:
with open(settings_path) as f:
try: data = json.load(f)
except json.JSONDecodeError: pass
data.setdefault("hooks", {}).setdefault("Stop", [{"hooks": []}])
hook_cmd = f'bash "{hook_path}"'
stop_hooks = data["hooks"]["Stop"]
already = any(
any(h.get("command", "") == hook_cmd for h in entry.get("hooks", []))
for entry in stop_hooks
)
if not already:
stop_hooks[0]["hooks"].append({"type": "command", "command": hook_cmd})
data = json.load(f)
except Exception:
sys.exit(0)
stop = data.get("hooks", {}).get("Stop")
if isinstance(stop, list):
for entry in stop:
hooks = entry.get("hooks")
if isinstance(hooks, list):
entry["hooks"] = [h for h in hooks if "effort-save-hook" not in h.get("command", "")]
data["hooks"]["Stop"] = [e for e in stop if e.get("hooks")]
if not data["hooks"]["Stop"]:
del data["hooks"]["Stop"]
with open(settings_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
PYEOF
success "Хук effort-save установлен"
else
warn "Файл $EFFORT_HOOK_SRC не найден, пропускаю"
fi
success "Старый хук effort-save удалён"
# ── 6.7.1. Хук switch-account ───────────────────────────────────
info "Деплою хук switch-account..."
@@ -1077,71 +1077,95 @@ _open_browser() {
else echo "Откройте вручную: $url"; fi
}
# _restore_effort: читает сохранённый effort для текущего AI_LAUNCHER из кэша
# и записывает его в settings.json, чтобы Claude Code подхватил нужный уровень.
# Не передаём --effort через CLI, чтобы /effort внутри сессии работал без блокировки.
_restore_effort() {
local default_effort="${1:-high}"
local launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && return
local effort_file="$HOME/.cache/ai-setup/effort_${launcher}"
local effort
effort=$(cat "$effort_file" 2>/dev/null)
[ -z "$effort" ] && effort="$default_effort"
# _setup_isolated_config: готовит изолированную папку CLAUDE_CONFIG_DIR для лаунчера.
# Каждый сторонний провайдер получает собственные settings.json и .claude.json,
# поэтому выбор модели и кэш кастом-моделей НЕ протекают между ai-* лаунчерами.
# Общие ресурсы (skills, CLAUDE.md) шарятся симлинком из ~/.claude.
# model и effortLevel сидируются как дефолты - выбор юзера через /model и /effort
# (для low/medium/high/xhigh) сохраняется нативно в этом же settings.json.
# Уровень max обрабатывается отдельно в _apply_effort (settings.json его не хранит).
# Использование: _setup_isolated_config <launcher> <default_model> <default_effort> <available_models_json>
_setup_isolated_config() {
local launcher="$1" default_model="$2" default_effort="${3:-high}" avail="${4:-}"
local cfg="$HOME/.config/ai-setup/cfg/$launcher"
mkdir -p "$cfg"
# Общие ресурсы из ~/.claude (единый источник правды)
ln -sfn "$HOME/.claude/skills" "$cfg/skills"
ln -sfn "$HOME/.claude/CLAUDE.md" "$cfg/CLAUDE.md"
[ -e "$HOME/.claude/agents" ] && ln -sfn "$HOME/.claude/agents" "$cfg/agents"
# .claude.json в свежей папке: пропускаем онбординг
[ -f "$cfg/.claude.json" ] || echo '{"hasCompletedOnboarding": true}' > "$cfg/.claude.json"
python3 - "$cfg/settings.json" "$HOME/.claude/statusline-command.sh" \
"$default_model" "$default_effort" "$avail" <<'PYEOF'
import sys, json, os
cfg_settings, statusline, model, effort, avail = sys.argv[1:6]
data = {}
if os.path.exists(cfg_settings):
try:
with open(cfg_settings) as f:
data = json.load(f)
except Exception:
pass
# Структурные настройки лаунчера (переустанавливаем всегда)
data["statusLine"] = {"type": "command", "command": f"bash {statusline}"}
data["skipDangerousModePermissionPrompt"] = True
data.setdefault("hooks", {})["SessionStart"] = [{"hooks": [{"type": "command", "command": "true"}]}]
# availableModels - белый список пикера (политика лаунчера)
if avail:
data["availableModels"] = json.loads(avail)
else:
data.pop("availableModels", None)
# model/effortLevel - сидируем дефолты, не перетирая выбор юзера (нативный persistence)
if model:
data.setdefault("model", model)
data.setdefault("effortLevel", effort)
with open(cfg_settings, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
PYEOF
}
# _apply_effort: per-launcher persistence уровня effort (гибрид).
# - low/medium/high/xhigh живут нативно в settings.json лаунчера -> /effort работает,
# уровень сохраняется между сессиями, env-переменная НЕ ставится.
# - max единственный нельзя сохранить в settings.json (он session-only), поэтому
# его восстанавливаем через CLAUDE_CODE_EFFORT_LEVEL. В такой max-сессии /effort
# залочен env-переменной (ограничение Claude Code).
# Текущий уровень (вкл. max) ловит статусбар в ~/.cache/ai-setup/effort_<launcher>.
# Сменить уровень из max: AI_EFFORT=<lvl> ai-<launcher>.
# Использование: _apply_effort <launcher> <default_effort>
_apply_effort() {
local launcher="$1" default_effort="${2:-high}"
local f="$HOME/.cache/ai-setup/effort_${launcher}"
local settings="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"
local eff
if [ -n "${AI_EFFORT:-}" ]; then
# Явный override: запоминаем и применяем
eff="$AI_EFFORT"
mkdir -p "$HOME/.cache/ai-setup"
python3 - "$HOME/.claude/settings.json" "$effort" <<'PYEOF'
echo "$eff" > "$f"
else
eff=$(cat "$f" 2>/dev/null)
fi
if [ "$eff" = "max" ]; then
# Единственный способ восстановить max между сессиями
export CLAUDE_CODE_EFFORT_LEVEL=max
elif [ -n "${AI_EFFORT:-}" ] && [ -n "$eff" ]; then
# Явный сброс на low/medium/high/xhigh - пишем нативно в settings.json лаунчера
python3 - "$settings" "$eff" <<'PYEOF'
import sys, json, os
settings_path, effort = sys.argv[1], sys.argv[2]
data = {}
if os.path.exists(settings_path):
try:
with open(settings_path) as f:
data = json.load(f)
except Exception:
pass
data['effortLevel'] = effort
with open(settings_path, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
p, eff = sys.argv[1], sys.argv[2]
d = {}
if os.path.exists(p):
try: d = json.load(open(p))
except Exception: pass
d["effortLevel"] = eff
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w") as fp:
json.dump(d, fp, indent=2, ensure_ascii=False); fp.write("\n")
PYEOF
}
_restore_model() {
local default_model="${1:-}"
local launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && return
local model_file="$HOME/.cache/ai-setup/model_${launcher}"
local model
model=$(cat "$model_file" 2>/dev/null)
[ -z "$model" ] && model="$default_model"
[ -z "$model" ] && return
python3 - "$HOME/.claude/settings.json" "$model" <<'PYEOF'
import sys, json, os
settings_path, model = sys.argv[1], sys.argv[2]
data = {}
if os.path.exists(settings_path):
try:
with open(settings_path) as f:
data = json.load(f)
except Exception:
pass
data['model'] = model
with open(settings_path, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
PYEOF
}
# _restore_model_str: возвращает сохранённую модель строкой (для ANTHROPIC_MODEL env var)
_restore_model_str() {
local default_model="${1:-}"
local launcher="${AI_LAUNCHER:-}"
[ -z "$launcher" ] && echo "$default_model" && return
local model_file="$HOME/.cache/ai-setup/model_${launcher}"
local model
model=$(cat "$model_file" 2>/dev/null)
[ -z "$model" ] && model="$default_model"
echo "$model"
fi
# Иначе (≤xhigh без AI_EFFORT): ничего не делаем - effortLevel уже персистнут нативно.
}
_build_ai_sys_prompt() {
@@ -1266,13 +1290,19 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=deepseek
_restore_effort high
export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/deepseek"
# Пикер: только DeepSeek V4 Pro (opus) и DeepSeek V4 Flash (haiku), дефолт - Pro
_setup_isolated_config deepseek opus high '["opus", "haiku"]'
_apply_effort deepseek high
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="DeepSeek V4 Pro" \
ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="DeepSeek V4 Pro - флагман для сложных задач" \
ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash \
ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="DeepSeek V4 Flash" \
ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="DeepSeek V4 Flash - быстрый и дешёвый" \
CLAUDE_CODE_SUBAGENT_MODEL=deepseek-v4-flash \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@"
@@ -1340,11 +1370,15 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=kimi
_restore_effort high
export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/kimi"
# Пикер: единственная модель Kimi K2.6 (под алиасом opus)
_setup_isolated_config kimi opus high '["opus"]'
_apply_effort kimi high
ANTHROPIC_BASE_URL=https://api.kimi.com/coding \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=kimi-k2.6 \
ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.6 \
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="Kimi K2.6" \
ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="Kimi K2.6 (Moonshot AI)" \
ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.6 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.6 \
CLAUDE_CODE_SUBAGENT_MODEL=kimi-k2.6 \
@@ -1414,11 +1448,16 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=openrouter
_restore_effort high
_MODEL=$(_restore_model_str "openai/gpt-5.5")
export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/openrouter"
# openrouter - гибкий лаунчер: пикер не ограничиваем (availableModels пустой),
# gpt-5.5 добавляем отдельным пунктом и делаем дефолтом
export ANTHROPIC_CUSTOM_MODEL_OPTION="openai/gpt-5.5"
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="GPT-5.5"
export ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="OpenRouter: openai/gpt-5.5"
_setup_isolated_config openrouter "openai/gpt-5.5" high ''
_apply_effort openrouter high
ANTHROPIC_BASE_URL=https://openrouter.ai/api \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=$_MODEL \
ANTHROPIC_DEFAULT_OPUS_MODEL=anthropic/claude-4.8-opus \
ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-4.6-sonnet \
ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-5.5 \
@@ -1469,8 +1508,11 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX)
trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM
_build_ai_sys_prompt > "$_PROMPT_FILE"
export AI_LAUNCHER=claude
_restore_effort xhigh
_restore_model "sonnet"
# ai-claude работает в дефолтном ~/.claude (нативный логин и аккаунты).
# Модель хранится нативно в ~/.claude/settings.json; другие ai-* лаунчеры теперь
# изолированы в своих CLAUDE_CONFIG_DIR, поэтому в пикер не протекают чужие модели -
# показываются только нативные модели Claude Code.
_apply_effort claude xhigh
claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@"
CLAUDEEOF
chmod +x "$BIN_DIR/ai-claude"

View File

@@ -48,7 +48,7 @@ test_kimi_claude_launcher() {
# ── ai-kimi: uses official Kimi API ──────────────────────────────────────
test_kimi_official_api() {
if echo "$KIMI_SECTION" | grep -q 'api.kimi.com/coding' \
&& echo "$KIMI_SECTION" | grep -q 'ANTHROPIC_MODEL=kimi-k2.6' \
&& echo "$KIMI_SECTION" | grep -q 'ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.6' \
&& ! echo "$KIMI_SECTION" | grep -q 'artemox'; then
ok "ai-kimi: uses official Kimi API and model"
else