پروتکل وضعیت برنامه یک دنباله‌ی فرار ترمینال است که برنامه به ترمینال می‌گوید الان چه می‌کند: بیکار، مشغول، منتظر تأیید کاربر، تمام یا خطادار. کلاد کد در نسخه‌ی 2.1.295 این دنباله را می‌فرستد. در این پست دنباله را با یک pty واقعی می‌گیریم، بایت‌های خام را باز می‌کنیم و می‌بینیم چرا ترمینال دیگر مجبور نیست عنوان پنجره را حدس بزند.

چرا این پروتکل ساخته شد و چه شکلی دارد

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

روش اول، حدس از روی عنوان پنجره است. هِردر برای تشخیص وضعیت کلاد کد، اولین قاعده‌ی تشخیصش را روی کاراکتر اسپینر می‌گذارد: عنوان پنجره اگر با یک کاراکتر بریل شروع شود یعنی «مشغول». همان فایل قاعده در سه ماه ده بار تغییر کرده است.

روش دوم، سوکت اختصاصی هر ابزار است. هِردر یک cmux notify و یک API سوکت جداگانه دارد، یعنی هر برنامه باید جداگانه به هر صندوق‌ورودی وصل شود. سوکت محلی از روی SSH یا از داخل کانتینر هم کار نمی‌کند، مگر اینکه پل بزنی. اما pty از پیش هست و همه‌جا کار می‌کند.

میچل هرش از همین وضعیت، نام «صندوق‌ورودی ایجنتی» را ساخت: یک نما که همه‌ی ایجنت‌های در حال اجرا را نشان می‌دهد و مشخص می‌کند کدام کار می‌کند، کدام تمام شده و کدام منتظر شماست. مشکل این بود که تا وقتی پروتکلی نباشد، هر ابزار این وضعیت را یا با حدس درمی‌آورد یا با API اختصاصی خودش.

پاسخ، دنباله‌ی فراری است که با ESC ] شروع می‌شود، شماره‌ی ۷۵۰۱ را می‌آورد و با ESC \ یا BEL بسته می‌شود. بدنه، فهرستی از جفت‌های کلید=مقدار است که با دونقطه از هم جدا شده‌اند.

کلیداجباری؟معنا
stateبلهیکی از idle، working، done، blocked، error و clear
appنهنام ماشین‌خوان و پایدار برنامه، مثل claude-code
kindنهوقتی حالت blocked است: permission، question یا auth
msgنهیک خط متن قابل‌خواندن برای آدم، کدشده به base64
progressنهدرصد پیشرفت، فقط کنار working

پیام به base64 کد می‌شود چون متن ممکن است خط جدید، دونقطه یا هر بایت دلخواهی داشته باشد و نباید ساختار دنباله را خراب کند. یعنی ترمینال وضعیت را به شکل خوانا نگه می‌دارد، ولی خودش تصمیم می‌گیرد چطور نشانش دهد.

حالت‌ها یک عمر مشخص دارند که در مشخصات آمده است. working تا وقتی که جایگزین، پاک یا برنامه تمام شود زنده می‌ماند، اما done و error از خروج برنامه و رسیدن به خط فرمان بعدی جان سالم به در می‌برند. دلیلش ساده است: نتیجه‌ای که هنوز کسی نخوانده، باید بعد از بستن برنامه هم دیده شود.

اولین گام: برنامه‌ای که وضعیت می‌فرستد

کار از سمت برنامه شروع می‌شود و نیازی به SDK، سوکت یا متغیر محیطی ندارد. همین تابع شل، کل پیاده‌سازی لازم برای یک اسکریپت است و از دستور پخت خود مشخصات است.

# اول این تابع را در اسکریپت خود تعریف کنید؛ همان چیزی است که در ادامه صدا می‌زنیم
status() {
  # msg را base64 می‌کنیم تا متن خام ساختار دنباله را خراب نکند
  printf '\e]7501;state=%s:msg=%s\e\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')"
}

# حالا اسکریپت را اجرا کنید تا ترمینال وضعیت را زنده ببیند
bash ./report.sh

# ترمینال با همین خط می‌فهمد ترافو با پیام ما چه می‌کند
status working "Syncing photos"
rsync -a ~/Photos backup:/photos && status done "Photos synced" || status error "rsync failed"

همین یازده خط، کل پروتکل است. اگر ترمینال شما OSC 7501 را نشناسد، دنباله را نادیده می‌گیرد و هیچ اتفاقی نمی‌افتد. همین ویژگی باعث می‌شود بتوانید همین کد را بدون ترس در اسکریپت‌های قدیمی بگذارید.

گام دوم: گرفتن بایت‌های خام از یک pty واقعی

حالا باید مطمئن شویم واقعاً بایتی روی خروجی می‌رود. ساده‌ترین راه، بستن همان بایت‌ها در یک pty و خواندنشان است. این اسکریپت هر دنباله‌ی ۷۵۰۱ را بیرون می‌کشد.

import os, pty, re, select, time

# یک دنباله: ESC ] 7501 ; بدنه  که با ESC \ یا BEL بسته می‌شود
FRAME = re.compile(rb"\x1b\]7501;([^\x07\x1b]*)(?:\x07|\x1b\\)")

pid, fd = pty.fork()            # یک pty واقعی، همان که ترمینال به برنامه می‌دهد
if pid == 0:
    os.environ["TERM"] = "xterm-256color"
    os.execvp("python3", ["python3", "psp_emit.py", "3"])
    os._exit(127)

raw = bytearray()
end = time.time() + 5
while time.time() < end:
    r, _, _ = select.select([fd], [], [], 0.2)   # بدون این، حلقه بی‌پایان است
    if fd in r:
        try:
            chunk = os.read(fd, 65536)
        except OSError:
            break
        if not chunk:
            break
        raw.extend(chunk)

open("/tmp/psp_raw.bin", "wb").write(bytes(raw))
print("raw bytes written: %d" % len(raw))

اجرای همین کد، چهار دنباله را می‌گیرد و ۲۸۰ بایت می‌نویسد.

$ python3 osc7501.py 5 -- python3 psp_emit.py 3
program      : python3 psp_emit.py 3
bytes        : 280
osc_codes    : 7501
osc7501_count: 4
  [0] state=working:app=terraform:msg=U3luY2luZyBwaG90b3M=
  [1] state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/
  [2] state=working:progress=60:app=terraform
  [3] state=done:app=terraform:msg=UGhvdG9zIHN5bmNlZA==

دقت کنید چهار دنباله با هم در یک جریان ۲۸۰ بایتی آمده‌اند. این همان چیزی است که ترمینال واقعی در لحظه دریافت می‌کند: برنامه وضعیت‌ها را پشت سر هم روی همان خروجی استاندارد می‌نویسد.

و خود بایت‌ها این‌طورند. اولین بایت ۲۷ یعنی ESC و بعد کاراکتر ۵۳ یعنی ] می‌آید.

$ xxd /tmp/psp_raw.bin | head -4
00000000: 1b5d 3735 3031 3b73 7461 7465 3d77 6f72  .]7501;state=wor
00000010: 6b69 6e67 3a61 7070 3d74 6572 7261 666f  king:app=terrafo
00000020: 726d 3a6d 7367 3d55 336c 7559 326c 755a  rm:msg=U3luY2luZ
00000030: 7942 7761 4739 3062 334d 3d1b            yBwaG90b3M=

گام سوم: خواندن دنباله‌ها برگرد

سمت ترمینال هم باید بتواند این بایت‌ها را بخواند، چون مشخصات می‌گوید نمایش با ترمینال است. همین خواننده، دنباله‌ها را می‌گیرد، پیام‌های base64 را باز می‌کند و یک خط خوانا می‌سازد.

import base64, re, sys

FRAME = re.compile(rb"\x1b\]7501;([^\x07\x1b]*)(?:\x07|\x1b\\)")
PAIR  = re.compile(r"([A-Za-z_]+)=([^:]*)")   # هر جفت کلید=مقدار در بدنه

for body in FRAME.findall(open(sys.argv[1], "rb").read()):
    rec = {k: v for k, v in PAIR.findall(body.decode())}
    # پیام روی سیم base64 بوده؛ اینجا برای آدم برش می‌خوریم
    msg = base64.b64decode(rec.get("msg", "") + "==").decode("utf-8", "replace")
    print(rec.get("state", "?"), "|", rec.get("app", "-"), "|", msg)

خروجی واقعی همین خواننده روی همان فایل ۲۸۰ بایتی:

$ python3 psp_read.py /tmp/psp_raw.bin
frames read from file : 4
 0  working  app=terraform  "Syncing photos"
 1  blocked  app=terraform  kind=permission  "Apply 3 to add, 1 to change, 0 to destroy?"
 2  working  app=terraform  progress=60%
 3  done  app=terraform  "Photos synced"

final status: DONE -> result is waiting to be read

خط blocked همان چیزی است که مشکل را حل می‌کند. با روش قدیمی، ترمینال می‌دید یک اسپینر یا متنی روی صفحه هست و نمی‌دانست آیا برنامه کار می‌کند یا منتظر جواب شماست. حالا خود برنامه می‌گوید منتظر مجوز است و پیامش را هم می‌فرستد.

کدام ایجنت‌ها این را می‌فرستند

کلاد کد در نسخه‌ی 2.1.295 پشتیبانی از پروتکل وضعیت برنامه را اضافه کرد: ترمینال‌هایی که آن را پیاده کرده باشند می‌توانند نشان دهند کلاد کد مشغول است، منتظر شماست یا کارش تمام شده.

میچل هرش نوشته که همین پروتکل را به‌عنوان نمونه‌ی اثباتی در کلاد کد، کدکس، ترافو و هوم‌بریو پیاده کرده است، و در هر کدام پیاده‌سازی بیشتر از دوازده خط نبوده.

حالا نکته‌ی مهم. این پیام‌ها فقط از اجرای تعاملی بیرون می‌آیند. من همین پروبِر را روی کدکس نصب‌شده‌ی همین سرور اندازه گرفتم و نتیجه را با احتیاط می‌خوانم، چون اجرای غیرفارتعالی که به هیچ مدلی وصل نیست وضعیت واقعی یک نشست زنده را نشان نمی‌دهد.

$ python3 osc7501.py 15 -- codex
program      : codex
bytes        : 826
osc_codes    : 10,11
osc7501_count: 0

کدکس دنباله‌های OSC 10 و OSC 11 برای پرسیدن رنگ ترمینال فرستاد. این یعنی ابزار درست کار می‌کند و پروبِر درست دنبال دنباله می‌گردد، اما در این اجرا هیچ دنباله‌ی ۷۵۰۱ دیده نشد.

چیزی که این اندازه‌گیری واقعا ثابت می‌کند، مثبت بودن آن است نه منفی بودنش: همین اسکریپت روی یک فرستنده‌ی واقعی، چهار دنباله گرفت. پس وقتی روی ترمینال شما همین پروبِر صفر می‌دهد، نتیجه‌اش را نمی‌شود به پشتیبانی از پروتکل نسبت داد.

چک‌لیست عملی

اگر می‌خواهید این پروتکل را در برنامه‌ی خودتان به کار ببرید، چهار نکته را نگه دارید.

  1. بدنه را با دونقطه بین جفت‌ها بسازید و پیام را base64 کنید، وگرنه متن خام ساختار دنباله را خراب می‌کند.
  2. state را همیشه بفرستید؛ بقیه‌ی کلیدها اختیاری‌اند و clear رکورد را پاک می‌کند.
  3. برای «منتظر مجوز» از blocked با kind=permission استفاده کنید، نه از working؛ این تنها حالتی است که به آدم نیاز دارد.
  4. اگر ترمینال شما دنباله را نادیده می‌گیرد، هیچ چیز خراب نمی‌شود؛ ترمینال‌های سالم دنباله‌ی ناشناخته را دور می‌ریزند.

اگر همین امروز می‌خواهید یک ابزار موجود را از حالت حدس خارج کنید، مقاله‌ی قلاب terminalSequence مسیر مشابهی را از سمت کلاد کد نشان می‌دهد: به‌جای اینکه ترمینال صفحه را بخواند، خود برنامه وضعیت را اعلام کند.

منابع

  1. Claude Code changelog — افزودن پشتیبانی OSC 7501 در نسخه‌ی 2.1.295
  2. anthropics/claude-code CHANGELOG.md — متن اصلی یادداشت انتشار
  3. مشخصات پروتکل وضعیت برنامه (OSC 7501) — حالت‌ها، کلیدها و قواعد عمر رکورد
  4. میچل هرش: یک پروتکل ترمینالی برای وضعیت برنامه — چرا حدس از روی عنوان پنجره شکننده است
  5. مخزن هِردر — نمونه‌ی واقعی صندوق‌ورودی ایجنتی و قواعد تشخیص وضعیت
  6. پیاده‌سازی پروتکل در libghostty
  7. cmux: اعلان‌ها و API غیرترمینالی
  8. agent-deck — نمونه‌ی دیگر صندوق‌ورودی ایجنتی
  9. یادداشت‌های انتشار کدکس