-
Notifications
You must be signed in to change notification settings - Fork 1
Update: statusline にサブエージェントのコンテキスト使用量を並べる #806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5dee79f
Update: statusline にサブエージェントのコンテキスト使用量を並べる
takemi-ohama 6f8a573
Update: statusline の再描画の間隔と 500k 超の警告を足し、ラベルを 4 文字にする
takemi-ohama d8b66ea
Fix: 子を待つ supervisor がサブエージェントの表示から消える
takemi-ohama 93ce550
Fix: statusline のパス分割とメインの Haiku 閾値を直し、表示のテストを足す
takemi-ohama 76fa06e
Fix: サブエージェントの説明に含まれる制御文字を statusline に出さない
takemi-ohama File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,60 +1,101 @@ | ||
| #!/bin/bash | ||
| # ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) | ||
| # NDF plugin 標準 statusline: | ||
| # <コンテナ名 or ホスト名> <project_dir> [<モデル名>: 使用トークン / 全体 (使用率%)] | ||
| # <project_dir> [<モデル名> 使用トークン │ <サブエージェントの説明> 使用トークン · ...] | ||
| input=$(cat) | ||
|
|
||
| # コンテナ名を取得する。コンテナでなければホスト名にフォールバック | ||
| container_name="" | ||
|
|
||
| # Docker/コンテナ環境かどうかを /.dockerenv で判定 | ||
| if [ -f /.dockerenv ]; then | ||
| # CONTAINER_NAME 環境変数が明示設定されていればそちらを優先 | ||
| container_name="${CONTAINER_NAME:-}" | ||
|
|
||
| # Docker ソケットが使えれば docker inspect で compose 上のコンテナ名を取得 | ||
| # (/etc/hostname はコンテナIDなので、それをキーに引く) | ||
| if [ -z "$container_name" ] && [ -S /var/run/docker.sock ] && command -v docker >/dev/null 2>&1; then | ||
| container_id=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') | ||
| if [ -n "$container_id" ]; then | ||
| container_name=$(docker inspect --format '{{.Name}}' "$container_id" 2>/dev/null | sed 's|^/||') | ||
| fi | ||
| fi | ||
|
|
||
| # 取れなければ /etc/hostname(コンテナID)をフォールバックとして使用 | ||
| if [ -z "$container_name" ] && [ -f /etc/hostname ]; then | ||
| container_name=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') | ||
| fi | ||
| fi | ||
|
|
||
| # コンテナ名が取れなければ hostname コマンドにフォールバック | ||
| if [ -z "$container_name" ]; then | ||
| container_name=$(hostname -s 2>/dev/null || hostname) | ||
| fi | ||
|
|
||
| dir="$container_name" | ||
|
|
||
| # claude root のパスを取得(project_dir を優先し、なければ current_dir を使用) | ||
| # jq 不在や無効な JSON 入力時に stderr が statusLine 描画に漏れないよう 2>/dev/null で抑制 | ||
| claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) | ||
|
|
||
| total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) | ||
| ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) | ||
| used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) | ||
| transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) | ||
|
|
||
| # モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック | ||
| model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null) | ||
| # モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック。 | ||
| # 現行モデルの上限は 1M なので、"Opus 5 (1M context)" の括弧と空白は落として "Opus5" にする | ||
| model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null | sed 's/ *(.*)//; s/ //g') | ||
| ctx_label="${model_name:-ctx}" | ||
|
|
||
| # 使用量がこの値を超えたら赤で知らせる。上限は出さないため、色で危険な水準を示す | ||
| WARN_TOKENS=500000 | ||
| WARN_COLOR='\033[0;31m' | ||
|
|
||
| ctx_info="" | ||
| if [ -n "$total_input" ] && [ -n "$ctx_size" ] && [ -n "$used_pct" ]; then | ||
| total_input_k=$(awk "BEGIN { printf \"%.1f\", $total_input / 1000 }") | ||
| ctx_size_k=$(awk "BEGIN { printf \"%.0f\", $ctx_size / 1000 }") | ||
| ctx_info=$(printf " \033[0;36m[%s: %sk / %sk tokens (%.0f%%)]" "$ctx_label" "$total_input_k" "$ctx_size_k" "$used_pct") | ||
| if [ -n "$total_input" ]; then | ||
| main_used="$((total_input / 1000))k" | ||
| # 上限が 200K 以下のモデル(Haiku 4.5)は 150k で知らせる。モデル名ではなく入力の上限で決める | ||
| main_limit=$WARN_TOKENS | ||
| [ -n "$ctx_size" ] && [ "$ctx_size" -le 200000 ] 2>/dev/null && main_limit=150000 | ||
| [ "$total_input" -gt "$main_limit" ] && main_used=$(printf "$WARN_COLOR%s\033[0;36m" "$main_used") | ||
| ctx_info=$(printf " \033[0;36m[%s %s" "$ctx_label" "$main_used") | ||
|
|
||
| # 実行中のサブエージェントのコンテキスト使用量を並べる。statusLine の JSON は | ||
| # メインセッションの値しか持たないため、サブエージェントの記録から読む。 | ||
| # 記録は <transcript_path から .jsonl を除いたもの>/subagents/agent-<id>.jsonl にある | ||
| sub_dir="${transcript%.jsonl}/subagents" | ||
| rows="" | ||
| if [ -n "$transcript" ] && [ -d "$sub_dir" ]; then | ||
| now=$(date +%s) | ||
| # 直近 60 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。 | ||
| # 更新の時刻では決めない。子を待つ supervisor や長いコマンドを待つ担当は、実行中でも | ||
| # 何分も書き足さない | ||
| # NUL 区切りで読む。空白を含むパスでも 1 ファイルとして扱う | ||
| while IFS= read -r -d '' f; do | ||
| # 末尾だけを読む。記録は長くなるため全体を走査しない。 | ||
| # 出力: モデル / 使用量 / 状態 (run | done | idle) | ||
| # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 | ||
| # end_turn が付いていれば終了。付いていなければ tool_use の直前の text の途中かもしれない (idle) | ||
| st=$(tail -n 50 "$f" | jq -rs ' | ||
| (map(select(.type == "assistant" or .type == "user")) | last) as $l | ||
| | (map(select(.message.usage?)) | last) as $u | ||
| | if $u == null then empty else | ||
| [ ($u.message.model // ""), | ||
| ($u.message.usage | (.input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.cache_read_input_tokens // 0)), | ||
| (if $l.type == "assistant" and ([$l.message.content[]?.type] | index("tool_use") | not) | ||
| then (if $l.message.stop_reason == "end_turn" then "done" else "idle" end) | ||
| else "run" end) ] | ||
| | @tsv end' 2>/dev/null) | ||
| [ -n "$st" ] || continue | ||
| IFS=$'\t' read -r model tokens state <<<"$st" | ||
| [ "$state" = done ] && continue | ||
| if [ "$state" = idle ]; then | ||
| # 30 秒以上書き足されていなければ終わったとみなす (GNU / BSD の stat の両方に対応) | ||
| mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) | ||
| [ -n "$mtime" ] && [ $((now - mtime)) -ge 30 ] && continue | ||
| fi | ||
| # 説明の先頭 4 文字をラベルにする。種類名は general-purpose がほとんどで見分けに使えない。 | ||
| # 空白と制御文字(\p{Cc} = U+0000-001F / U+007F-009F)を除く。ESC などを端末へ出さないため | ||
| label=$(jq -r '.description // empty | gsub("[\\p{Cc}\\s]"; "") | .[0:4]' "${f%.jsonl}.meta.json" 2>/dev/null) | ||
| [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:4}; } | ||
| # 500k を超えたら赤で知らせる。1M 未満のモデルは Haiku(200K)だけなので、Haiku は 150k で知らせる | ||
| limit=$WARN_TOKENS | ||
| case "$model" in *haiku*) limit=150000 ;; esac | ||
| warn=0 | ||
| [ "$tokens" -gt "$limit" ] && warn=1 | ||
| rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' | ||
| done < <(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 -print0 2>/dev/null) | ||
| fi | ||
| # 使用量の多い順に 3 本まで並べ、残りは本数だけを出す。80 桁の端末に収めるため | ||
| if [ -n "$rows" ]; then | ||
| subs="" | ||
| n=0 | ||
| while IFS=$'\t' read -r tokens warn label; do | ||
| n=$((n + 1)) | ||
| [ "$n" -gt 3 ] && continue | ||
| entry="$label $((tokens / 1000))k" | ||
| [ "$warn" = 1 ] && entry=$(printf "$WARN_COLOR%s\033[0;36m" "$entry") | ||
| subs="${subs:+$subs · }$entry" | ||
| done < <(printf "%s" "$rows" | sort -t $'\t' -k1,1nr) | ||
| [ "$n" -gt 3 ] && subs="$subs +$((n - 3))" | ||
| ctx_info="$ctx_info │ $subs" | ||
| fi | ||
| ctx_info="$ctx_info]" | ||
| fi | ||
|
|
||
| # コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる | ||
| if [ -n "$claude_root" ]; then | ||
| printf "\033[01;34m%s\033[00m \033[0;33m%s\033[00m%s" "$dir" "$claude_root" "$ctx_info" | ||
| printf "\033[0;33m%s\033[00m%s" "$claude_root" "$ctx_info" | ||
| else | ||
| printf "\033[01;34m%s\033[00m%s" "$dir" "$ctx_info" | ||
| printf "%s" "${ctx_info# }" | ||
| fi | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
plugins/ndf/skills/statusline/tests/test_statusline_render.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| """statusline.sh の表示(メインとサブエージェントの使用量)を検証する。 | ||
|
|
||
| 擬似の transcript(<tmp>/sess.jsonl)とサブエージェントの記録 | ||
| (<tmp>/sess/subagents/agent-<id>.jsonl / .meta.json)を作り、標準入力へ JSON を渡して | ||
| `bash statusline.sh` の出力を突き合わせる。 | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
| import time | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| for _cmd in ("bash", "jq"): | ||
| if shutil.which(_cmd) is None: | ||
| pytest.skip(f"{_cmd} not available", allow_module_level=True) | ||
|
|
||
| STATUSLINE = Path(__file__).resolve().parents[3] / "scripts" / "statusline.sh" | ||
| RED = "\033[0;31m" | ||
| ANSI = re.compile(r"\033\[[0-9;]*m") | ||
|
|
||
|
|
||
| def _usage(tokens: int) -> dict: | ||
| return {"input_tokens": tokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0} | ||
|
|
||
|
|
||
| def write_agent(root: Path, agent_id: str, *, tokens: int = 10_000, end: str = "tool_use", | ||
| model: str = "claude-opus-5", description: str | None = None, | ||
| age: float = 0) -> Path: | ||
| """end: tool_use(実行中)/ end_turn(終了)/ text(stop_reason 無しの text で終わる)""" | ||
| sub = root / "sess" / "subagents" | ||
| sub.mkdir(parents=True, exist_ok=True) | ||
| content = [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}] if end == "tool_use" \ | ||
| else [{"type": "text", "text": "done"}] | ||
| stop = "end_turn" if end == "end_turn" else None | ||
| lines = [ | ||
| {"type": "user", "message": {"role": "user", "content": "go"}}, | ||
| {"type": "assistant", "message": {"model": model, "content": content, | ||
| "stop_reason": stop, "usage": _usage(tokens)}}, | ||
| ] | ||
| f = sub / f"agent-{agent_id}.jsonl" | ||
| f.write_text("".join(json.dumps(line) + "\n" for line in lines)) | ||
| if description is not None: | ||
| (sub / f"agent-{agent_id}.meta.json").write_text(json.dumps({"description": description})) | ||
| if age: | ||
| t = time.time() - age | ||
| os.utime(f, (t, t)) | ||
| return f | ||
|
|
||
|
|
||
| def render(root: Path, total: int = 100_000, size: int | None = None) -> str: | ||
| cw: dict = {"total_input_tokens": total} | ||
| if size is not None: | ||
| cw["context_window_size"] = size | ||
| payload = {"transcript_path": str(root / "sess.jsonl"), | ||
| "model": {"display_name": "Opus 5 (1M context)"}, "context_window": cw} | ||
| r = subprocess.run(["bash", str(STATUSLINE)], input=json.dumps(payload), | ||
| capture_output=True, text=True, check=True) | ||
| return r.stdout | ||
|
|
||
|
|
||
| def plain(out: str) -> str: | ||
| return ANSI.sub("", out) | ||
|
|
||
|
|
||
| def test_tool_use_is_shown_and_end_turn_is_not(tmp_path): | ||
| write_agent(tmp_path, "aaaa1111", tokens=42_000, description="実行中の担当") | ||
| write_agent(tmp_path, "bbbb2222", tokens=77_000, end="end_turn", description="終わった担当") | ||
| out = plain(render(tmp_path)) | ||
| assert "実行中の 42k" in out | ||
| assert "終わった" not in out | ||
|
|
||
|
|
||
| def test_text_older_than_30s_is_hidden(tmp_path): | ||
| write_agent(tmp_path, "aaaa1111", end="text", age=60, description="古いtext") | ||
| write_agent(tmp_path, "bbbb2222", end="text", description="新しいtext") | ||
| out = plain(render(tmp_path)) | ||
| assert "古いte" not in out | ||
| assert "新しいt" in out | ||
|
|
||
|
|
||
| def test_top3_and_rest_count(tmp_path): | ||
| for i, tok in enumerate([10_000, 20_000, 30_000, 40_000, 50_000]): | ||
| write_agent(tmp_path, f"id{i}xxxx", tokens=tok, description=f"担当{i}号機") | ||
| out = plain(render(tmp_path)) | ||
| assert "│ 担当4号 50k · 担当3号 40k · 担当2号 30k +2]" in out | ||
|
|
||
|
|
||
| def test_haiku_warns_at_150k_but_opus_does_not(tmp_path): | ||
| write_agent(tmp_path, "aaaa1111", tokens=160_000, model="claude-haiku-4-5", description="はいく") | ||
| out = render(tmp_path) | ||
| assert f"{RED}はいく 160k" in out | ||
| shutil.rmtree(tmp_path / "sess") | ||
| write_agent(tmp_path, "aaaa1111", tokens=160_000, model="claude-opus-5", description="おーぱす") | ||
| assert RED not in render(tmp_path) | ||
|
|
||
|
|
||
| def test_main_warns_over_500k(tmp_path): | ||
| assert RED + "553k" in render(tmp_path, total=553_000) | ||
| assert RED not in render(tmp_path, total=160_000) | ||
|
|
||
|
|
||
| def test_main_with_200k_window_warns_at_150k(tmp_path): | ||
| assert RED + "160k" in render(tmp_path, total=160_000, size=200_000) | ||
| assert RED not in render(tmp_path, total=160_000, size=1_000_000) | ||
|
|
||
|
|
||
| def test_label_from_description_or_id(tmp_path): | ||
| write_agent(tmp_path, "abcd9999", tokens=12_000, description="Fix PR comments") | ||
| write_agent(tmp_path, "wxyz8888", tokens=11_000) | ||
| out = plain(render(tmp_path)) | ||
| assert "FixP 12k" in out | ||
| assert "wxyz 11k" in out | ||
|
|
||
|
|
||
| def test_control_chars_in_description_are_dropped(tmp_path): | ||
| # 説明に ESC などの制御文字があっても端末へ出さない(画面消去などを実行させない) | ||
| write_agent(tmp_path, "aaaa1111", tokens=21_000, description="\u001b[2J\u009b画面消去") | ||
| out = render(tmp_path) | ||
| assert "\u001b[2J" not in out | ||
| assert "\u009b" not in out | ||
| assert "[2J画 21k" in plain(out) | ||
|
|
||
| def test_path_with_spaces(tmp_path): | ||
| root = tmp_path / "my project dir" | ||
| write_agent(root, "aaaa1111", tokens=33_000, description="空白下") | ||
| assert "空白下 33k" in plain(render(root)) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.