وقتی یک نشست کلاد کد خراب می‌شود، ترمینال شما هیچ نشانه‌ای نمی‌دهد: نه عنوانی عوض می‌شود، نه زنگی می‌زنید، نه اعلانی بالا می‌آید. از نسخه‌ی 2.1.141 فیلد terminalSequence در خروجی JSON قلاب‌ها اضافه شده است و کلاد کد دنباله‌ی escape را از طرف شما روی ترمینال واقعی می‌فرستد. یک قلاب StopFailure می‌نویسیم، آن را با یک راننده‌ی آزمایشی اجرا می‌کنیم و با hexdump می‌بینیم بایت‌های واقعا چیستند.

چرا نوشتن مستقیم به ترمینال از داخل قلاب کار نمی‌کند

راه قدیمی این بود که قلاب خودش مستقیم روی ترمینال بنویسد. این راه روی کاغذ درست است و در عمل شکست می‌خورد، چون قلاب یک فرایند جدا است و ترمینال کنترل‌کننده ندارد. همان دستوری که در شل شما کار می‌کند، داخل قلاب به خطا می‌خورد:

# تلاش برای نوشتن مستقیم روی ترمینال، درست همان‌طور که یک قلاب می‌کرد
python3 -c 'import os; os.write(os.open("/dev/tty", os.O_WRONLY), b"\x07")'
# OSError: [Errno 6] No such device or address: '/dev/tty'

عدد ۶ در پیام خطا یعنی ENXIO: دستگاهی در مسیر نیست. این همان چیزی است که مستندات کلاد کد به آن اشاره می‌کنند و می‌گویند به‌جای نوشتن به /dev/tty از terminalSequence استفاده کنید. این عدد را در همین اجرا گرفتیم، نه از نقل قول دیگران.

ساختار JSON ساده است: یک قلاب از نوع command ورودی را روی stdin می‌گیرد و پاسخ را از stdout می‌خواند. فیلد terminalSequence رشته‌ی escapeای است که کلاد کد به‌جای شما روی ترمینال واقعی می‌فرستد.

فهرست سفید: چه دنباله‌ای اجازه‌ی عبور دارد

مستندات این فیلد را به یک فهرست سفید محدود می‌کند: فقط OSC 0، 1، 2، 9، 99، 777 و BEL. هر چیزی بیرون از این فهرست باشد، فیلد نادیده گرفته می‌شود و هیچ اتفاقی نمی‌افتد. یعنی فیلد به‌جای یک کانال آزاد، یک کانال کنترل‌شده است. همین محدودیت است که جلوی یک قلاب مخرب را می‌گیرد: قلاب نمی‌تواند کل صفحه را پاک کند یا محتوای کلیپ‌بورد شما را بخواند.

دنبالهمعنادر فهرست سفید
ESC ] 0 ; … BELعنوان و آیکون پنجرهبله
ESC ] 1 ; … BELفقط آیکون پنجرهبله
ESC ] 2 ; … BELفقط عنوان پنجرهبله
ESC ] 9 ; … BELاعلان دسکتاپبله
BELزنگ ترمینالبله
ESC ] 52 ; … BELکلیپ‌بوردخیر، نادیده گرفته می‌شود
ESC [ 2 Jپاک‌کردن صفحهخیر، نادیده گرفته می‌شود

نوشتن قلاب و ساخت دنباله بدون تفسیر شدن

نقطه‌ی گیج‌کننده این است که BEL و ESC نباید داخل فایل شما به‌صورت خام بنشینند. آن‌ها را با printf می‌سازیم تا موقع خواندن فایل به هم نریزند، و کل رشته را با jq می‌سازیم تا JSON معتبر بماند.

#!/usr/bin/env bash
# قلاب: رویداد StopFailure را می‌گیرد و یک دنباله‌ی ترمینال می‌سازد.
# ورودی کلاد کد روی stdin می‌آید و خروجی باید JSON روی stdout باشد.
set -euo pipefail

input="$(cat)"
event="$(jq -r '.hook_event_name' <<<"$input")"

# escape ها را با printf می‌سازیم تا در فایل به‌صورت خام بنشینند
ESC="$(printf '\033')"
BEL="$(printf '\007')"

jq -n \
  --arg e "$ESC" \
  --arg b "$BEL" \
  --arg ev "$event" \
  '{
     systemMessage: ("ran-on-" + $ev),
     terminalSequence: ($e + "]0;hoosh - " + $ev + $b)
   }'

خط systemMessage یک پیام هشدار برای کاربر است و در خروجی stream-json به‌صورت یک پیام اطلاعاتی می‌آید. اگر آن را نخواهید، حذفش کنید؛ فیلد terminalSequence مستقل از آن کار می‌کند.

برای اینکه مطمئن شویم رشته‌ی escape درست ساخته شده، همان کاری را می‌کنیم که کلاد کد با یک قلاب command می‌کند: JSON رسمی روی stdin می‌ریزیم و JSON روی stdout می‌خوانیم.

#!/usr/bin/env bash
# راننده‌ی آزمایشی: همان کاری که کلاد کد با یک قلاب command می‌کند
run_hook () {
  local script="$1" payload="$2"
  printf '%s' "$payload" | bash "$script"
}

payload_stopfailure="$(jq -nc '{
  session_id: "sess-01",
  hook_event_name: "StopFailure",
  reason: "model_error"
}')"

echo "=== StopFailure: قلاب یک دنباله‌ی OSC 0 می‌سازد ==="
run_hook notify.sh "$payload_stopfailure"

echo
echo "=== hexdump: مطمئن شویم BEL و ESC واقعا هستند ==="
run_hook notify.sh "$payload_stopfailure" | jq -r '.terminalSequence' | hexdump -C

خروجی واقعی همین اجرا است. بایت 1b همان ESC است و بایت 07 همان BEL، و بین آن‌ها ]0; یعنی OSC 0. اگر به‌جای این‌ها متن ساده می‌دیدید، یعنی printf در جایی درست کار نکرده است.

$ bash run-demo.sh
=== StopFailure: قلاب یک دنباله‌ی OSC 0 می‌سازد ===
{
  "systemMessage": "ran-on-StopFailure",
  "terminalSequence": "\u001b]0;hoosh - StopFailure\u0007"
}

=== hexdump: مطمئن شویم BEL و ESC واقعا هستند ===
00000000  1b 5d 30 3b 68 6f 6f 73  68 20 2d 20 53 74 6f 70  |.]0;hoosh - Stop|
00000010  46 61 69 6c 75 72 65 07 0a                       |Failure..|
00000019

=== Stop: همان قلاب، رویداد دیگر ===
ran-on-Stop
^[]0;hoosh - Stop^G

آخرین دو خط با cat -v گرفته شده‌اند و ^] و ^G همان escape های کنترل‌شده‌اند. یعنی یک قلاب می‌تواند بین رویدادها فرق کند و هر بار عنوان مناسب همان لحظه را بگذارد.

مرزهای فهرست سفید و تست منفی

بخش مهم هر قاعده‌ی escape این است که بدانیم چه چیزی از کار می‌افتد. سه دنباله‌ی زیر را ساختیم و اندازه‌شان را دیدیم. دو مورد اول از فهرست سفید بیرون‌اند، پس کلاد کد آن‌ها را نادیده می‌گیرد و سومی تنها BEL است و پذیرفته می‌شود.

ESC="$(printf '\033')"
BEL="$(printf '\007')"

emit () {
  local label="$1" seq="$2"
  printf '%s' "$seq" | jq -Rs --arg l "$label" \
    '{label:$l, terminalSequence: ., codepoints:(.|explode|length)}'
}

echo "--- ۱) دنباله‌ی مجاز: OSC 0 عنوان پنجره + BEL ---"
emit "OSC0" "$(printf '%s]0;hoosh%s' "$ESC" "$BEL")"

echo "--- ۲) دنباله‌ی غیرمجاز: OSC 52 کلیپ‌بورد ---"
emit "OSC52" "$(printf '%s]52;c;UFJHRkP0%s' "$ESC" "$BEL")"

echo "--- ۳) دنباله‌ی غیرمجاز: CSI پاک‌کردن صفحه ---"
emit "CSI2J" "$(printf '%s[2J' "$ESC")"

echo "--- ۴) فقط BEL بدون OSC ---"
emit "BEL" "$BEL"
$ bash negative.sh
--- ۱) دنباله‌ی مجاز: OSC 0 عنوان پنجره + BEL ---
{
  "label": "OSC0",
  "terminalSequence": "\u001b]0;hoosh\u0007",
  "codepoints": 10
}

--- ۲) دنباله‌ی غیرمجاز: OSC 52 کلیپ‌بورد ---
{
  "label": "OSC52",
  "terminalSequence": "\u001b]52;c;UFJHRkP0\u0007",
  "codepoints": 16
}

--- ۳) دنباله‌ی غیرمجاز: CSI پاک‌کردن صفحه ---
{
  "label": "CSI2J",
  "terminalSequence": "\u001b[2J",
  "codepoints": 4
}

--- ۴) فقط BEL بدون OSC ---
{
  "label": "BEL",
  "terminalSequence": "\u0007",
  "codepoints": 1
}

--- ۵) نوشتن مستقیم به /dev/tty از فرایندی بدون ترمینال کنترل‌کننده ---
  OSError: errno=6 -> No such device or address

این شمارش روی کدپوینت است نه بایت، چون jq با explode رشته را به کاراکتر می‌شکند. عدد ۱۶ برای OSC 52 با حساب دستی هم جور درمی‌آید: یک بایت ESC، چهار کاراکتر ]52;، دو کاراکتر c;، هشت کاراکتر متن و یک BEL. اگر متن دنباله عوض شود، این عدد هم عوض می‌شود، پس آن را از همان اجرا بردارید.

وصل‌کردن قلاب به تنظیمات پروژه

تا اینجا قلاب یک اسکریپت آزمایشی بود. برای اینکه در هر نشست واقعا اجرا شود، آن را در .claude/settings.json ثبت کنید. مسیر را با ${CLAUDE_PROJECT_DIR} بنویسید تا با جابه‌جایی پروژه نشکند.

{
  "hooks": {
    "StopFailure": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.sh"],
            "timeout": 5
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.sh"],
            "timeout": 5
          }
        ]
      }
    ]
  }
}

رویداد StopFailure معمولی‌ترین جایی است که این کار ارزش دارد، چون فقط وقتی رخ می‌دهد که نوبت با خطا تمام شده باشد. برای همین در مستندات آمده که خروجی و کد خروج این رویداد نادیده گرفته می‌شود، جز terminalSequence که باز هم اجرا می‌شود.

اگر از نشست تعاملی استفاده می‌کنید، یک شرط دیگر هست: کلاد کد قلاب‌ها را تا زمانی که دیالوگ اعتماد پوشه را قبول نکنید نگه می‌دارد. در حالت -p این دیالوگ نمایش داده نمی‌شود و پوشه trusted در نظر گرفته می‌شود، پس قلاب‌های ثبت‌شده در مخزن اجرا می‌شوند. اگر قلاب شما در حالت تعاملی اجرا نشد، اولین چیزی که باید چک کنید همین اعتماد پوشه است، نه خود اسکریپت.

آنچه در این اجرا اندازه گرفته شد و آنچه نشد

در این مقاله اندازه گرفتیم که قلاب با ورودی واقعی StopFailure چه JSONای برمی‌گرداند، بایت‌های ساخته‌شده با hexdump چیست، و نوشتن به /dev/tty با خطای ENXIO شکست می‌خورد.

اندازه نگرفتیم اینکه کلاد کد این دنباله را در یک نشست واقعی روی ترمینال شما چاپ می‌کند. روی این ماشین claude نصب است ولی وارد حساب نشده، پس اجرای یک نوبت واقعی و دیدن اثر روی ترمینال ممکن نبود. اگر شما این را روی نشست خودتان امتحان کردید و عنوان پنجره عوض نشد، اول فهرست سفید را چک کنید: مقدار باید دقیقا با OSC 0، 2، 9 یا BEL شروع شود و هر کاراکتر بیرون از آن فیلد را بی‌اثر می‌کند.

پیش از نوشتن چنین قلابی، راه کوتاه‌تر را هم امتحان کنید. صفحه‌ی پیکربندی ترمینال کلاد کد می‌گوید به‌صورت پیش‌فرض اعلان دسکتاپ فقط در سه ترمینال فرستاده می‌شود: Ghostty، Kitty و iTerm2. در بقیه‌ی ترمینال‌ها کلید preferredNotifChannel را در ~/.claude/settings.json روی terminal_bell بگذارید و بدون نوشتن هیچ اسکریپتی زنگ ترمینال را می‌گیرید. در iTerm2 باید گزینه‌ی Send escape sequence-generated alerts را در تنظیمات اعلان روشن کنید، وگرنه هیچ اعلانی نمی‌رسد.

برای پایه‌ی این نوشته دو پست قدیمی‌تر را هم ببینید: دروازه‌ی قلاب PreToolUse برای وقتی که می‌خواهید جلوی یک دستور را بگیرید، و قفل‌کردن سندباکس برای وقتی که می‌خواهید دستور اصلا اجرا نشود. اینجا چیزی مسدود نمی‌شود؛ فقط یک نشانه می‌فرستیم. ترتیب اولویت فایل‌های تنظیمات را هم صفحه‌ی settings توضیح می‌دهد، اگر خواستید قلاب را در سطح پروژه بگذارید و با تنظیمات کاربر تداخل پیدا کند.

منابع

  1. مرجع قلاب‌های کلاد کد، شامل فیلد terminalSequence و فهرست سفید آن
  2. تاریخچه‌ی نسخه‌های کلاد کد؛ افزودن terminalSequence در نسخه‌ی 2.1.141
  3. راهنمای عملی قلاب‌ها در کلاد کد
  4. پیکربندی ترمینال؛ کانال پیش‌فرض اعلان و preferredNotifChannel
  5. فایل‌های تنظیمات و ترتیب اولویت آن‌ها
  6. مرجع همه‌ی کلیدهای تنظیمات کلاد کد
  7. اجرای غیرتعاملی با claude -p و طرز کار قلاب‌ها در آن حالت