From 5dee79f115ea4332a69267ccd79aeb2f54c76c56 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 13:42:59 +0000 Subject: [PATCH 1/5] =?UTF-8?q?Update:=20statusline=20=E3=81=AB=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=82=A8=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=B3=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=87=8F=E3=82=92=E4=B8=A6=E3=81=B9=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 実行中のサブエージェントの使用量を `│` の後に使用量の多い順で 3 本まで並べ、残りは本数だけを出す - 終わったサブエージェントは記録の末尾から判定して外す - ラベルは description の先頭を端末の 8 桁で切る(全角は 2 桁) - メインの表示から上限と使用率を外し、モデル名を詰める(Opus5 61k) - コンテナ名・ホスト名の表示を外す Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 116 ++++++++++++++++--------- plugins/ndf/skills/statusline/SKILL.md | 16 ++-- 2 files changed, 86 insertions(+), 46 deletions(-) diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index 19d7c098..a71d64fd 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -1,60 +1,94 @@ #!/bin/bash # ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) # NDF plugin 標準 statusline: -# <コンテナ名 or ホスト名> [<モデル名>: 使用トークン / 全体 (使用率%)] +# [<モデル名> 使用トークン │ <サブエージェントの説明> 使用トークン · ...] 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}" 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 + ctx_info=$(printf " \033[0;36m[%s %sk" "$ctx_label" "$((total_input / 1000))") + + # 実行中のサブエージェントのコンテキスト使用量を並べる。statusLine の JSON は + # メインセッションの値しか持たないため、サブエージェントの記録から読む。 + # 記録は /subagents/agent-.jsonl にある + sub_dir="${transcript%.jsonl}/subagents" + rows="" + if [ -n "$transcript" ] && [ -d "$sub_dir" ]; then + now=$(date +%s) + # 直近 2 分以内に更新された記録だけを候補にする + for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -2 2>/dev/null); 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 + # 説明の先頭を端末の 8 桁までに切ってラベルにする。種類名は general-purpose が + # ほとんどで見分けに使えない。全角は 2 桁を取るため、文字数ではなく桁数で切る + # (U+2E80 以降を全角とみなす近似) + label=$(jq -r ' + def w: if . >= 11904 then 2 else 1 end; + .description // empty | gsub("\\s"; "") | explode as $c + | if ($c | map(w) | add // 0) <= 8 then . + else (reduce $c[] as $x ({s: [], n: 0, full: false}; + if .full or .n + ($x | w) > 7 then .full = true + else .s += [$x] | .n += ($x | w) end) + | .s | implode) + "…" end' "${f%.jsonl}.meta.json" 2>/dev/null) + [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:7}; } + # 1M 未満のモデルは Haiku(200K)だけなので、150k を超えたら黄色で知らせる + warn=0 + case "$model" in *haiku*) [ "$tokens" -gt 150000 ] && warn=1 ;; esac + rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' + done + 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 "\033[0;33m%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 diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 4e94cdab..913ebd6a 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -9,18 +9,24 @@ allowed-tools: # Statusline 切り替えコマンド -NDF 標準 statusline (コンテナ名/ホスト名 + project_dir + コンテキスト使用率) と +NDF 標準 statusline (project_dir + メインとサブエージェントのコンテキスト使用量) と 既存のカスタム statusline を切り替える。 ## 表示内容 ``` -<コンテナ名|ホスト名> [<モデル名>: 12.3k / 200k tokens (6%)] + [Opus5 61k │ 修正:PR8… 167k · 検証:#8… 42k] ``` -- コンテナ環境 (`/.dockerenv` あり) ではコンテナ名、それ以外ではホスト名を表示 -- `CONTAINER_NAME` 環境変数があればそちらを優先 -- 角括弧内のラベルは利用中モデルの表示名 (例: `Opus 4.8`)。取得できない場合は `ctx` にフォールバック +- コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる +- 角括弧の先頭は利用中モデルの表示名と、メインセッションのコンテキスト使用量。表示名の括弧と空白は落とす (`Opus 5 (1M context)` → `Opus5`)。取得できない場合は `ctx` にフォールバック +- **上限と使用率は出さない。** 現行モデルの上限は Haiku 4.5 (200K) を除いて 1M で、使用量だけで足りる +- `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む + - 直近 2 分以内に記録が更新され、終わっていないものを実行中とみなす。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす + - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため + - ラベルは `agent-.meta.json` の `description` の先頭で、空白を除いて端末の 8 桁までに切る (全角は 2 桁)。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す + - Haiku で 150k を超えたものだけ黄色で表示する + - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 From 6f8a5732fb5cdacd401d656fd10a18be19c361eb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 13:59:37 +0000 Subject: [PATCH 2/5] =?UTF-8?q?Update:=20statusline=20=E3=81=AE=E5=86=8D?= =?UTF-8?q?=E6=8F=8F=E7=94=BB=E3=81=AE=E9=96=93=E9=9A=94=E3=81=A8=20500k?= =?UTF-8?q?=20=E8=B6=85=E3=81=AE=E8=AD=A6=E5=91=8A=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=97=E3=80=81=E3=83=A9=E3=83=99=E3=83=AB=E3=82=92=204=20?= =?UTF-8?q?=E6=96=87=E5=AD=97=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NDF 標準の statusLine に refreshInterval: 5 を持たせる。既存の設定には ensure / set が足し、利用者の値は変えない - メイン・サブエージェントとも 500k を超えた使用量を赤で表示する(Haiku は 150k) - サブエージェントのラベルを説明の先頭 4 文字にし、空白を挟んで使用量を続ける Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline-switch.sh | 19 ++++++- plugins/ndf/scripts/statusline.sh | 31 +++++------ plugins/ndf/skills/statusline/SKILL.md | 11 +++- .../tests/test_statusline_switch.py | 55 +++++++++++++++++++ 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/plugins/ndf/scripts/statusline-switch.sh b/plugins/ndf/scripts/statusline-switch.sh index bccb616a..35657541 100755 --- a/plugins/ndf/scripts/statusline-switch.sh +++ b/plugins/ndf/scripts/statusline-switch.sh @@ -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 を検出した際に、既存設定をバックアップした上で @@ -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) を @@ -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 diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index a71d64fd..baad2567 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -16,9 +16,15 @@ transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) 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" ]; then - ctx_info=$(printf " \033[0;36m[%s %sk" "$ctx_label" "$((total_input / 1000))") + main_used="$((total_input / 1000))k" + [ "$total_input" -gt "$WARN_TOKENS" ] && 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 は # メインセッションの値しか持たないため、サブエージェントの記録から読む。 @@ -51,21 +57,14 @@ if [ -n "$total_input" ]; then mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) [ -n "$mtime" ] && [ $((now - mtime)) -ge 30 ] && continue fi - # 説明の先頭を端末の 8 桁までに切ってラベルにする。種類名は general-purpose が - # ほとんどで見分けに使えない。全角は 2 桁を取るため、文字数ではなく桁数で切る - # (U+2E80 以降を全角とみなす近似) - label=$(jq -r ' - def w: if . >= 11904 then 2 else 1 end; - .description // empty | gsub("\\s"; "") | explode as $c - | if ($c | map(w) | add // 0) <= 8 then . - else (reduce $c[] as $x ({s: [], n: 0, full: false}; - if .full or .n + ($x | w) > 7 then .full = true - else .s += [$x] | .n += ($x | w) end) - | .s | implode) + "…" end' "${f%.jsonl}.meta.json" 2>/dev/null) - [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:7}; } - # 1M 未満のモデルは Haiku(200K)だけなので、150k を超えたら黄色で知らせる + # 説明の先頭 4 文字をラベルにする。種類名は general-purpose がほとんどで見分けに使えない + label=$(jq -r '.description // empty | gsub("\\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 - case "$model" in *haiku*) [ "$tokens" -gt 150000 ] && warn=1 ;; esac + [ "$tokens" -gt "$limit" ] && warn=1 rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' done fi @@ -77,7 +76,7 @@ if [ -n "$total_input" ]; then n=$((n + 1)) [ "$n" -gt 3 ] && continue entry="$label $((tokens / 1000))k" - [ "$warn" = 1 ] && entry=$(printf "\033[0;33m%s\033[0;36m" "$entry") + [ "$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))" diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 913ebd6a..729db1ec 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -15,7 +15,7 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ ## 表示内容 ``` - [Opus5 61k │ 修正:PR8… 167k · 検証:#8… 42k] + [Opus5 61k │ 修正:PR 167k · 検証:#8 42k] ``` - コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる @@ -24,8 +24,8 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ - `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む - 直近 2 分以内に記録が更新され、終わっていないものを実行中とみなす。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため - - ラベルは `agent-.meta.json` の `description` の先頭で、空白を除いて端末の 8 桁までに切る (全角は 2 桁)。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す - - Haiku で 150k を超えたものだけ黄色で表示する + - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す +- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。Haiku 4.5 (200K) のサブエージェントは 150k で赤にする - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 @@ -82,3 +82,8 @@ statusline の変更は次回セッション開始時 (または statusline 再 既に statusline が設定されている場合はそちらが優先され、何も変更しない。 NDF 標準 statusline の利用中は、プラグイン更新時にスクリプト (`~/.claude/ndf-statusline.sh`) の内容が自動で追従する。 + +NDF 標準 statusline は `refreshInterval: 5` (秒) を持つ。メインセッションがバックグラウンドの +サブエージェントを待つ間は再描画のイベントが起きず、サブエージェントの使用量が止まって見える +ためである。既に NDF 標準を使っている設定に `refreshInterval` が無ければ、`ensure` と `set` が +足す。利用者が書いた値は変えない。 diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf/skills/statusline/tests/test_statusline_switch.py index c875a371..afcac8b9 100644 --- a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py +++ b/plugins/ndf/skills/statusline/tests/test_statusline_switch.py @@ -148,3 +148,58 @@ def test_unset_statusline_gets_ndf_default(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + + +def test_unset_statusline_gets_refresh_interval(tmp_path: Path) -> None: + """新規設定では refreshInterval も書く。待機中もサブエージェントの表示を更新するため。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text("{}") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 5 + + +def test_official_ndf_path_gets_missing_refresh_interval(tmp_path: Path) -> None: + """既に NDF 標準を使っていて refreshInterval が無ければ足す。""" + _claude(tmp_path) + _write_settings(tmp_path, NDF_COMMAND) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 5 + + +def test_existing_refresh_interval_is_kept(tmp_path: Path) -> None: + """利用者が決めた refreshInterval は上書きしない。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": NDF_COMMAND, + "refreshInterval": 30, + } + } + ) + ) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 30 + + +def test_user_custom_gets_no_refresh_interval(tmp_path: Path) -> None: + """利用者独自の statusline には refreshInterval を足さない。""" + claude = _claude(tmp_path) + (claude / "mybar.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/mybar.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert "refreshInterval" not in _settings(tmp_path)["statusLine"] From d8b66ea485417bce26bd33a426f51fd0c797b1b9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:03:43 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Fix:=20=E5=AD=90=E3=82=92=E5=BE=85=E3=81=A4?= =?UTF-8?q?=20supervisor=20=E3=81=8C=E3=82=B5=E3=83=96=E3=82=A8=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E3=81=AE=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=81=8B=E3=82=89=E6=B6=88=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新の時刻(直近 2 分)で実行中を決めていたため、子や長いコマンドを待って記録を書き足さない supervisor が外れていた。候補を直近 60 分に広げ、実行中かどうかは記録の末尾で決める。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 6 ++++-- plugins/ndf/skills/statusline/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index baad2567..cb279664 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -33,8 +33,10 @@ if [ -n "$total_input" ]; then rows="" if [ -n "$transcript" ] && [ -d "$sub_dir" ]; then now=$(date +%s) - # 直近 2 分以内に更新された記録だけを候補にする - for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -2 2>/dev/null); do + # 直近 60 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。 + # 更新の時刻では決めない。子を待つ supervisor や長いコマンドを待つ担当は、実行中でも + # 何分も書き足さない + for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 2>/dev/null); do # 末尾だけを読む。記録は長くなるため全体を走査しない。 # 出力: モデル / 使用量 / 状態 (run | done | idle) # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 729db1ec..85fc0881 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -22,7 +22,7 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ - 角括弧の先頭は利用中モデルの表示名と、メインセッションのコンテキスト使用量。表示名の括弧と空白は落とす (`Opus 5 (1M context)` → `Opus5`)。取得できない場合は `ctx` にフォールバック - **上限と使用率は出さない。** 現行モデルの上限は Haiku 4.5 (200K) を除いて 1M で、使用量だけで足りる - `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む - - 直近 2 分以内に記録が更新され、終わっていないものを実行中とみなす。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす + - 直近 60 分以内に記録が更新され、終わっていないものを実行中とみなす。更新の時刻では決めない (子を待つ supervisor や長いコマンドを待つ担当は、実行中でも何分も書き足さない)。止められた担当は、記録が `tool_use` で終わったまま 60 分残ることがある。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す - メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。Haiku 4.5 (200K) のサブエージェントは 150k で赤にする From 93ce55004cfe489906200a19bd0ec29a88160c99 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:24:26 +0000 Subject: [PATCH 4/5] =?UTF-8?q?Fix:=20statusline=20=E3=81=AE=E3=83=91?= =?UTF-8?q?=E3=82=B9=E5=88=86=E5=89=B2=E3=81=A8=E3=83=A1=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=AE=20Haiku=20=E9=96=BE=E5=80=A4=E3=82=92=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=80=81=E8=A1=A8=E7=A4=BA=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - サブエージェントの記録を find -print0 と NUL 区切りで読み、空白を含むパスでも表示する - メインの赤表示の閾値を context_window.context_window_size が 200K 以下なら 150k にする - statusline.sh の表示を擬似の記録で確かめる test_statusline_render.py を追加する Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 11 +- plugins/ndf/skills/statusline/SKILL.md | 2 +- .../tests/test_statusline_render.py | 124 ++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 plugins/ndf/skills/statusline/tests/test_statusline_render.py diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index cb279664..06cf0a10 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -9,6 +9,7 @@ input=$(cat) 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) transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) # モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック。 @@ -23,7 +24,10 @@ WARN_COLOR='\033[0;31m' ctx_info="" if [ -n "$total_input" ]; then main_used="$((total_input / 1000))k" - [ "$total_input" -gt "$WARN_TOKENS" ] && main_used=$(printf "$WARN_COLOR%s\033[0;36m" "$main_used") + # 上限が 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 は @@ -36,7 +40,8 @@ if [ -n "$total_input" ]; then # 直近 60 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。 # 更新の時刻では決めない。子を待つ supervisor や長いコマンドを待つ担当は、実行中でも # 何分も書き足さない - for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 2>/dev/null); do + # NUL 区切りで読む。空白を含むパスでも 1 ファイルとして扱う + while IFS= read -r -d '' f; do # 末尾だけを読む。記録は長くなるため全体を走査しない。 # 出力: モデル / 使用量 / 状態 (run | done | idle) # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 @@ -68,7 +73,7 @@ if [ -n "$total_input" ]; then warn=0 [ "$tokens" -gt "$limit" ] && warn=1 rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' - done + done < <(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 -print0 2>/dev/null) fi # 使用量の多い順に 3 本まで並べ、残りは本数だけを出す。80 桁の端末に収めるため if [ -n "$rows" ]; then diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 85fc0881..33ad4329 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -25,7 +25,7 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ - 直近 60 分以内に記録が更新され、終わっていないものを実行中とみなす。更新の時刻では決めない (子を待つ supervisor や長いコマンドを待つ担当は、実行中でも何分も書き足さない)。止められた担当は、記録が `tool_use` で終わったまま 60 分残ることがある。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す -- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。Haiku 4.5 (200K) のサブエージェントは 150k で赤にする +- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。上限が 200K 以下のモデル (Haiku 4.5) は 150k で赤にする。メインは入力の `context_window.context_window_size`、サブエージェントは記録のモデル名で判定する - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_render.py b/plugins/ndf/skills/statusline/tests/test_statusline_render.py new file mode 100644 index 00000000..2651b2f5 --- /dev/null +++ b/plugins/ndf/skills/statusline/tests/test_statusline_render.py @@ -0,0 +1,124 @@ +"""statusline.sh の表示(メインとサブエージェントの使用量)を検証する。 + +擬似の transcript(/sess.jsonl)とサブエージェントの記録 +(/sess/subagents/agent-.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_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)) From 76fa06ee97acc239bd02b21dd90cf7b09bef10e8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:32:17 +0000 Subject: [PATCH 5/5] =?UTF-8?q?Fix:=20=E3=82=B5=E3=83=96=E3=82=A8=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E3=81=AE=E8=AA=AC=E6=98=8E?= =?UTF-8?q?=E3=81=AB=E5=90=AB=E3=81=BE=E3=82=8C=E3=82=8B=E5=88=B6=E5=BE=A1?= =?UTF-8?q?=E6=96=87=E5=AD=97=E3=82=92=20statusline=20=E3=81=AB=E5=87=BA?= =?UTF-8?q?=E3=81=95=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ラベル化の jq で空白に加えて \p{Cc}(U+0000-001F / U+007F-009F)を除く。 説明に ESC を含む場合のテストを足す。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 5 +++-- .../ndf/skills/statusline/tests/test_statusline_render.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index 06cf0a10..290bb880 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -64,8 +64,9 @@ if [ -n "$total_input" ]; then 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 がほとんどで見分けに使えない - label=$(jq -r '.description // empty | gsub("\\s"; "") | .[0:4]' "${f%.jsonl}.meta.json" 2>/dev/null) + # 説明の先頭 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 diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_render.py b/plugins/ndf/skills/statusline/tests/test_statusline_render.py index 2651b2f5..7d3f0318 100644 --- a/plugins/ndf/skills/statusline/tests/test_statusline_render.py +++ b/plugins/ndf/skills/statusline/tests/test_statusline_render.py @@ -118,6 +118,14 @@ def test_label_from_description_or_id(tmp_path): 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="空白下")