Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions plugins/ndf/scripts/statusline-switch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,20 @@ update_settings() {
fi
}

# 再描画の間隔 (秒)。メインが待機中でもイベントが起きず描き直されないため、
# サブエージェントの使用量を追うには時間で描き直す必要がある
NDF_REFRESH_INTERVAL=5

set_ndf_statusline() {
update_settings --arg cmd "$NDF_COMMAND" \
'.statusLine = {type: "command", command: $cmd}'
update_settings --arg cmd "$NDF_COMMAND" --argjson ri "$NDF_REFRESH_INTERVAL" \
'.statusLine = {type: "command", command: $cmd, refreshInterval: $ri}'
}

# 既に NDF 標準を使っている設定へ、refreshInterval が無ければ足す。利用者が決めた値は残す
ensure_refresh_interval() {
if [ "$(jq -r '.statusLine | has("refreshInterval")' "$SETTINGS" 2>/dev/null)" = false ]; then
update_settings --argjson ri "$NDF_REFRESH_INTERVAL" '.statusLine.refreshInterval = $ri'
fi
}

# NDF 由来の旧 statusline を検出した際に、既存設定をバックアップした上で
Expand All @@ -124,8 +135,9 @@ cmd_ensure() {
deploy_script
# 既に statusLine が設定されている場合
if [ -n "$(jq -r '.statusLine // empty' "$SETTINGS" 2>/dev/null)" ]; then
# 正規パスを指していれば deploy_script で本体が追従済み (何もしない)
# 正規パスを指していれば deploy_script で本体が追従済み。再描画の間隔だけ補う
if is_ndf_statusline; then
ensure_refresh_interval
return 0
fi
# NDF が過去に配置したコピー (マーカー付き or レガシー statusline-command.sh) を
Expand All @@ -146,6 +158,7 @@ cmd_ensure() {
cmd_set() {
deploy_script
if is_ndf_statusline; then
ensure_refresh_interval
echo "[ndf:statusline] 既に NDF 標準 statusline が設定されています"
return 0
fi
Expand Down
121 changes: 81 additions & 40 deletions plugins/ndf/scripts/statusline.sh
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 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。
Comment thread
takemi-ohama marked this conversation as resolved.
# 更新の時刻では決めない。子を待つ 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
21 changes: 16 additions & 5 deletions plugins/ndf/skills/statusline/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@ allowed-tools:

# Statusline 切り替えコマンド

NDF 標準 statusline (コンテナ名/ホスト名 + project_dir + コンテキスト使用率) と
NDF 標準 statusline (project_dir + メインとサブエージェントのコンテキスト使用量) と
既存のカスタム statusline を切り替える。

## 表示内容

```
<コンテナ名|ホスト名> <project_dir> [<モデル名>: 12.3k / 200k tokens (6%)]
<project_dir> [Opus5 61k │ 修正:PR 167k · 検証:#8 42k]
```

- コンテナ環境 (`/.dockerenv` あり) ではコンテナ名、それ以外ではホスト名を表示
- `CONTAINER_NAME` 環境変数があればそちらを優先
- 角括弧内のラベルは利用中モデルの表示名 (例: `Opus 4.8`)。取得できない場合は `ctx` にフォールバック
- コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる
- 角括弧の先頭は利用中モデルの表示名と、メインセッションのコンテキスト使用量。表示名の括弧と空白は落とす (`Opus 5 (1M context)` → `Opus5`)。取得できない場合は `ctx` にフォールバック
- **上限と使用率は出さない。** 現行モデルの上限は Haiku 4.5 (200K) を除いて 1M で、使用量だけで足りる
- `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`<transcript_path から .jsonl を除いたもの>/subagents/agent-<id>.jsonl` の最後の `usage` から読む
- 直近 60 分以内に記録が更新され、終わっていないものを実行中とみなす。更新の時刻では決めない (子を待つ supervisor や長いコマンドを待つ担当は、実行中でも何分も書き足さない)。止められた担当は、記録が `tool_use` で終わったまま 60 分残ることがある。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす
- 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため
- ラベルは `agent-<id>.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す
- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。上限が 200K 以下のモデル (Haiku 4.5) は 150k で赤にする。メインは入力の `context_window.context_window_size`、サブエージェントは記録のモデル名で判定する
- サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない

## 使用方法

Expand Down Expand Up @@ -76,3 +82,8 @@ statusline の変更は次回セッション開始時 (または statusline 再
既に statusline が設定されている場合はそちらが優先され、何も変更しない。
NDF 標準 statusline の利用中は、プラグイン更新時にスクリプト
(`~/.claude/ndf-statusline.sh`) の内容が自動で追従する。

NDF 標準 statusline は `refreshInterval: 5` (秒) を持つ。メインセッションがバックグラウンドの
サブエージェントを待つ間は再描画のイベントが起きず、サブエージェントの使用量が止まって見える
ためである。既に NDF 標準を使っている設定に `refreshInterval` が無ければ、`ensure` と `set` が
足す。利用者が書いた値は変えない。
132 changes: 132 additions & 0 deletions plugins/ndf/skills/statusline/tests/test_statusline_render.py
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))
Loading
Loading