feat(infra): PreToolUse hook for Telegram progress feed + rate-limited batching

Hook /usr/local/bin/cc-tg-notify-pretool читает JSON через stdin
(tool_name + tool_input) и шлёт короткую строку прогресса в Telegram
перед каждым tool-вызовом — чтобы юзер видел «он работает» а не
просто ждал финального ответа от Stop hook.

Форматы (по требованию ТЗ):
- Bash       → 🔨 ${description} либо 🔨 Bash: ${command[:80]}
- Edit/Write/Read → ✏️/📝/📖 + basename(file_path)
- Grep/Glob  → 🔍/🌐 + pattern[:30/50]
- WebFetch/Search → 🌍/🔎 + url|query[:60]
- Task       → 🎯 + description[:60]
- TodoWrite  → skip (шумно)
- прочие     → 🔧 ${tool_name}

Дебаунс через flock + фоновый sleep:
- Каждый вызов append'ит строку в /tmp/cc-tg-pretool-buffer.txt и
  бампит /tmp/cc-tg-pretool-last (epoch ms) под flock'ом.
- Спавнит фоновый sleep 1.5s; после пробуждения проверяет,
  чей timestamp в LAST — если не его, выходит молча. Только
  «последний» hook реально шлёт батч одним сообщением.
- Это даёт пачке tool-вызовов один Telegram-апдейт, а не 5–10.
- Если буфер длиннее 20 строк — режется хвостом (свежие важнее).

Off-switch: touch /tmp/cc-tg-quiet — pretool-hook сразу выходит.
Stop hook продолжает работать.

Hook регистрируется в ~/.claude/settings.json под PreToolUse с
matcher="" (все tools без фильтра); фильтр TodoWrite — внутри
скрипта.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 13:12:11 +05:00
parent a7fcc9f9e1
commit 92d2eb0432

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Claude Code PreToolUse hook: шлёт короткую строку в Telegram перед
# каждым tool-call'ом для ощущения «активности». Дебаунс 1.5с — пока
# tool-вызовы летят пачкой, копим в /tmp буфер и шлём одним сообщением
# через 1.5 секунды тишины.
#
# Конфиг — /etc/food-market/telegram.env. Логи — /var/log/cc-tg-notify.log.
# Off-switch: создать /tmp/cc-tg-quiet — все pretool-уведомления
# скипаются (Stop hook продолжает работать).
set -u
ENV_FILE="/etc/food-market/telegram.env"
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
BUF="/tmp/cc-tg-pretool-buffer.txt"
LAST="/tmp/cc-tg-pretool-last"
LOCK="/tmp/cc-tg-pretool.lock"
QUIET_FLAG="/tmp/cc-tg-quiet"
DEBOUNCE_SEC="1.5"
MAX_LINES=20
log() { printf '%s [pretool] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
[[ -f "$QUIET_FLAG" ]] && exit 0
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
[[ -z "$TOKEN" || -z "$CHAT_ID" ]] && exit 0
INPUT_JSON=""
if [[ ! -t 0 ]]; then INPUT_JSON="$(cat)"; fi
[[ -z "$INPUT_JSON" ]] && exit 0
TOOL="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_name // empty' 2>/dev/null)"
[[ -z "$TOOL" || "$TOOL" == "TodoWrite" ]] && exit 0
# Извлекаем поле tool_input под нужный тип. cut -c обрезает многобайтные
# UTF-8 неаккуратно, но для urlencode результат остаётся валидным.
LINE=""
case "$TOOL" in
Bash)
DESC="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | tr '\n' ' ' | head -c 100)"
CMD="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.command // empty' 2>/dev/null | tr '\n' ' ' | head -c 80)"
if [[ -n "$DESC" ]]; then LINE="🔨 $DESC"; else LINE="🔨 Bash: $CMD"; fi
;;
Edit)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="✏️ Edit: $(basename "${FP:-?}")"
;;
Write)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="📝 Write: $(basename "${FP:-?}")"
;;
Read)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="📖 Read: $(basename "${FP:-?}")"
;;
Grep)
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 30)"
LINE="🔍 Grep: \"$P\""
;;
Glob)
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 50)"
LINE="🌐 Glob: $P"
;;
WebFetch)
U="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.url // empty' 2>/dev/null | head -c 60)"
LINE="🌍 Fetch: $U"
;;
WebSearch)
Q="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.query // empty' 2>/dev/null | head -c 60)"
LINE="🔎 Search: $Q"
;;
Task)
D="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | head -c 60)"
LINE="🎯 Task: $D"
;;
*)
LINE="🔧 $TOOL"
;;
esac
[[ -z "$LINE" ]] && exit 0
NOW="$(date +%s%N | cut -c1-13)"
# Append + bump LAST под flock'ом — конкурентные hook'и не теряют строки.
(
flock 9
echo "$LINE" >> "$BUF"
echo "$NOW" > "$LAST"
) 9>"$LOCK"
# Дебаунс-flusher в фоне. Каждый hook спавнит свой sleep, но только
# тот, чей NOW совпал с финальным LAST после задержки, реально шлёт —
# остальные тихо выходят.
(
sleep "$DEBOUNCE_SEC"
(
flock 9
LAST_TS="$(cat "$LAST" 2>/dev/null || echo 0)"
if [[ "$LAST_TS" != "$NOW" ]]; then
# Пришёл более свежий tool — он заfflushит сам.
exit 0
fi
[[ -s "$BUF" ]] || exit 0
# Если буфер длиннее MAX_LINES — режем хвост (свежие строки важнее).
BODY="$(tail -n "$MAX_LINES" "$BUF")"
: > "$BUF"
curl -fsS -m 10 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${BODY}" \
--data-urlencode "disable_notification=true" \
--data-urlencode "disable_web_page_preview=true" \
>/dev/null 2>&1 || log "send failed"
) 9>"$LOCK"
) &
# Не ждём фоновую задачу — Claude Code продолжает выполнение tool'а.
disown 2>/dev/null || true
exit 0