codebyplan 1.10.3 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via the cbp-subagent-statusline.sh dispatcher)
3
+ # Claude Code Subagent Status Line — python renderer. Byte-identical output to the
4
+ # bash renderer in cbp-subagent-statusline.sh and the node renderer in
5
+ # cbp-subagent-statusline.mjs. Output protocol: one {"id":...,"content":...} JSON
6
+ # line per task. See the .sh file for the full option/env/seam contract.
7
+ # Never throws to stdout.
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+
16
+ def main():
17
+ raw_input = sys.stdin.read()
18
+ try:
19
+ data = json.loads(raw_input)
20
+ except Exception:
21
+ data = {}
22
+ if not isinstance(data, dict):
23
+ data = {}
24
+
25
+ root = os.environ.get("CBP_STATUSLINE_ROOT") or (sys.argv[1] if len(sys.argv) > 1 else "")
26
+ if not root:
27
+ root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
28
+
29
+ try:
30
+ columns = math.trunc(float(data.get("columns") or 0))
31
+ except (TypeError, ValueError):
32
+ columns = 0
33
+ tasks = data.get("tasks")
34
+ if not isinstance(tasks, list):
35
+ tasks = []
36
+
37
+ # ---- Config: no_color from statusline.json -------------------------------
38
+ cfg_no_color = False
39
+ try:
40
+ with open(os.path.join(root, ".codebyplan", "statusline.json"), "r", encoding="utf-8") as fh:
41
+ parsed = json.load(fh)
42
+ if isinstance(parsed, dict) and isinstance(parsed.get("no_color"), bool):
43
+ cfg_no_color = parsed["no_color"]
44
+ except Exception:
45
+ pass
46
+
47
+ no_color = (
48
+ (os.environ.get("NO_COLOR") not in (None, ""))
49
+ or os.environ.get("CBP_SUBAGENT_STATUSLINE_NO_COLOR") == "1"
50
+ or cfg_no_color is True
51
+ )
52
+ if no_color:
53
+ RST = DIM = BOLD = GREEN = RED = BLUE = ""
54
+ else:
55
+ RST = "\x1b[0m"
56
+ DIM = "\x1b[2m"
57
+ BOLD = "\x1b[1m"
58
+ GREEN = "\x1b[32m"
59
+ RED = "\x1b[31m"
60
+ BLUE = "\x1b[34m"
61
+
62
+ def fmt_k(val):
63
+ v = float(val)
64
+ if v >= 1000000:
65
+ t = math.floor((v + 50000) / 100000)
66
+ return "%d.%dM" % (t // 10, t % 10)
67
+ elif v >= 1000:
68
+ t = math.floor((v + 50) / 100)
69
+ return "%d.%dK" % (t // 10, t % 10)
70
+ return str(int(v))
71
+
72
+ def cbp_now():
73
+ nv = os.environ.get("CBP_STATUSLINE_NOW")
74
+ if nv not in (None, ""):
75
+ return math.trunc(float(nv))
76
+ return math.floor(time.time())
77
+
78
+ def fmt_age_secs(start):
79
+ delta = cbp_now() - math.trunc(float(start))
80
+ if delta < 0:
81
+ delta = 0
82
+ if delta >= 3600:
83
+ return "%dh%dm" % (delta // 3600, (delta % 3600) // 60)
84
+ if delta >= 60:
85
+ return "%dm%ds" % (delta // 60, delta % 60)
86
+ return "%ds" % delta
87
+
88
+ def is_ascii_letter(b):
89
+ return (0x41 <= b <= 0x5A) or (0x61 <= b <= 0x7A)
90
+
91
+ def truncate(rendered):
92
+ # ANSI-aware byte-based truncation — mirrors the bash byte walk exactly.
93
+ if not (columns > 0):
94
+ return rendered
95
+ buf = rendered.encode("utf-8")
96
+ raw_len = len(buf)
97
+ target = columns - 3
98
+ if target < 0:
99
+ target = 0
100
+ vis = 0
101
+ i = 0
102
+ in_esc = False
103
+ while i < raw_len and vis < target:
104
+ b = buf[i]
105
+ if in_esc:
106
+ if is_ascii_letter(b):
107
+ in_esc = False
108
+ elif b == 0x1B:
109
+ in_esc = True
110
+ else:
111
+ vis += 1
112
+ i += 1
113
+ while i < raw_len:
114
+ b = buf[i]
115
+ if in_esc:
116
+ if is_ascii_letter(b):
117
+ in_esc = False
118
+ i += 1
119
+ elif b == 0x1B:
120
+ in_esc = True
121
+ i += 1
122
+ else:
123
+ break
124
+ if i < raw_len:
125
+ head = buf[:i]
126
+ tail = (RST + "...").encode("utf-8")
127
+ return (head + tail).decode("utf-8", errors="replace")
128
+ return rendered
129
+
130
+ out = []
131
+ for task in tasks:
132
+ if not isinstance(task, dict):
133
+ continue
134
+ t_id = "" if task.get("id") is None else str(task.get("id"))
135
+ if t_id == "":
136
+ continue
137
+ t_name = "" if task.get("name") is None else str(task.get("name"))
138
+ t_type = "" if task.get("type") is None else str(task.get("type"))
139
+ t_status = "" if task.get("status") is None else str(task.get("status"))
140
+ t_desc = "" if task.get("description") is None else str(task.get("description"))
141
+ try:
142
+ t_start = math.trunc(float(task.get("startTime") or 0))
143
+ except (TypeError, ValueError):
144
+ t_start = 0
145
+ try:
146
+ t_tokens = math.trunc(float(task.get("tokenCount") or 0))
147
+ except (TypeError, ValueError):
148
+ t_tokens = 0
149
+
150
+ if t_status == "running":
151
+ icon = "%s●%s" % (GREEN, RST)
152
+ elif t_status == "pending":
153
+ icon = "%s○%s" % (DIM, RST)
154
+ elif t_status == "completed":
155
+ icon = "%s✓%s" % (BLUE, RST)
156
+ elif t_status == "failed":
157
+ icon = "%s✗%s" % (RED, RST)
158
+ else:
159
+ icon = "%s•%s" % (DIM, RST)
160
+
161
+ row = "%s %s%s%s" % (icon, BOLD, t_name, RST)
162
+ if t_type:
163
+ row += " %s(%s)%s" % (DIM, t_type, RST)
164
+ if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION") != "1" and t_desc:
165
+ row += " %s—%s %s" % (DIM, RST, t_desc)
166
+ if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS") != "1" and t_tokens > 0:
167
+ row += " %s·%s %stokens:%s%s" % (DIM, RST, DIM, RST, fmt_k(t_tokens))
168
+ if t_start > 0:
169
+ row += " %s·%s %sage:%s%s" % (DIM, RST, DIM, RST, fmt_age_secs(t_start))
170
+
171
+ rendered = truncate(row)
172
+ out.append(
173
+ '{"id":%s,"content":%s}'
174
+ % (json.dumps(t_id, ensure_ascii=False), json.dumps(rendered, ensure_ascii=False))
175
+ )
176
+
177
+ sys.stdout.write(("\n".join(out) + "\n") if out else "")
178
+
179
+
180
+ try:
181
+ main()
182
+ except Exception:
183
+ sys.exit(0)
@@ -1,22 +1,65 @@
1
1
  #!/bin/bash
2
2
  # Portability: bash 3.2+ (macOS default /bin/bash). UTF-8 multibyte chars in
3
3
  # user-controlled fields are byte-iterated under 3.2 (over-counts visible width
4
- # for non-ASCII content); acceptable safe-direction approximation.
5
- # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked by settings.json subagentStatusLine.command)
6
- # Claude Code Subagent Status Line one JSON line per active subagent
7
- # Purpose: Renders one row per running subagent. Output protocol per upstream docs:
4
+ # for non-ASCII content); acceptable safe-direction approximation — and the node
5
+ # (.mjs) / python (.py) renderers iterate the SAME UTF-8 bytes so all three agree.
6
+ # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via settings.json subagentStatusLine.command)
7
+ # Claude Code Subagent Status Line multi-runtime dispatcher + bash renderer.
8
+ # One JSON line per active subagent. Output protocol per upstream docs:
8
9
  # {"id": "<task_id>", "content": "<rendered_body>"}
9
10
  # - Omitted tasks keep their default Claude Code rendering
10
11
  # - Empty content strings hide the row
11
12
  # NOT a PreToolUse/PostToolUse/Notification hook — do NOT register in hooks[].
12
13
  #
13
- # ENV-VAR TOGGLES set to 1 to suppress a column / color
14
+ # RENDERER SELECTION (per-device, gitignored .codebyplan/statusline.local.json):
15
+ # { "renderer": "bash" | "node" | "python" } — default bash, falls back to bash
16
+ # when the chosen runtime is unavailable. Set via `codebyplan statusline …`.
17
+ #
18
+ # CONFIG: honours `no_color` from the committed .codebyplan/statusline.json. The six
19
+ # main-statusline line toggles do NOT apply to subagent rows.
20
+ #
21
+ # ENV-VAR OVERRIDES (env > config > default)
14
22
  # CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION=1 omit DESCRIPTION segment
15
23
  # CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS=1 omit tokens:N segment
16
24
  # CBP_SUBAGENT_STATUSLINE_NO_COLOR=1 strip all ANSI codes (also honoured by $NO_COLOR)
25
+ #
26
+ # TEST SEAMS (no effect in normal use)
27
+ # CBP_STATUSLINE_ROOT=<dir> override the .codebyplan/ lookup root (default: repo root)
28
+ # CBP_STATUSLINE_NOW=<epoch> override "now" for age rendering (determinism)
29
+
30
+ # ============================================================
31
+ # DISPATCHER — resolve root + renderer, delegate when possible
32
+ # ============================================================
33
+ CBP_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || CBP_HOOK_DIR="."
34
+ CBP_ROOT="${CBP_STATUSLINE_ROOT:-}"
35
+ if [ -z "$CBP_ROOT" ]; then
36
+ CBP_ROOT="$(cd "$CBP_HOOK_DIR/../.." && pwd 2>/dev/null)" || CBP_ROOT="$PWD"
37
+ fi
38
+
39
+ CBP_RENDERER="bash"
40
+ CBP_LOCAL_CFG="$CBP_ROOT/.codebyplan/statusline.local.json"
41
+ if [ -f "$CBP_LOCAL_CFG" ] && command -v jq >/dev/null 2>&1; then
42
+ _r="$(jq -r '.renderer // empty' "$CBP_LOCAL_CFG" 2>/dev/null)"
43
+ case "$_r" in bash|node|python) CBP_RENDERER="$_r" ;; esac
44
+ fi
45
+
46
+ if [ "$CBP_RENDERER" = "node" ] && command -v node >/dev/null 2>&1 \
47
+ && [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs" ]; then
48
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec node "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs"
49
+ fi
50
+ if [ "$CBP_RENDERER" = "python" ] && command -v python3 >/dev/null 2>&1 \
51
+ && [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.py" ]; then
52
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec python3 "$CBP_HOOK_DIR/cbp-subagent-statusline.py"
53
+ fi
54
+ # Fall through → inline bash renderer (default + universal fallback).
17
55
 
18
56
  INPUT=$(cat)
19
57
 
58
+ # Byte-deterministic string ops: force the C locale so the truncation walk below
59
+ # counts UTF-8 BYTES (locale-independent), matching the .mjs/.py byte iteration.
60
+ # Output bytes are unaffected (multibyte content passes through verbatim).
61
+ export LC_ALL=C
62
+
20
63
  # One jq pass for base fields (cwd, session_id, columns)
21
64
  eval "$(echo "$INPUT" | jq -r '
22
65
  @sh "CWD=\(.cwd // "")",
@@ -24,9 +67,17 @@ eval "$(echo "$INPUT" | jq -r '
24
67
  @sh "COLUMNS=\(.columns // 0)"
25
68
  ')"
26
69
 
27
- # ---- Colour setup ----------------------------------------------------------
70
+ # ---- Config: no_color from .codebyplan/statusline.json -----------------------
71
+ CFG_NO_COLOR=false
72
+ CBP_CFG="$CBP_ROOT/.codebyplan/statusline.json"
73
+ if [ -f "$CBP_CFG" ] && command -v jq >/dev/null 2>&1; then
74
+ # `== true` (NOT jq `//`): only an explicit true enables no_color.
75
+ eval "$(jq -r '"CFG_NO_COLOR=\(.no_color == true)"' "$CBP_CFG" 2>/dev/null)"
76
+ fi
77
+
78
+ # ---- Colour setup (env > config) ---------------------------------------------
28
79
  # Only the colours referenced by the render block below are defined here.
29
- if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ]; then
80
+ if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ] || [ "$CFG_NO_COLOR" = "true" ]; then
30
81
  RST=''; DIM=''; BOLD=''; GREEN=''; RED=''; BLUE=''
31
82
  else
32
83
  RST='\033[0m'
@@ -37,25 +88,29 @@ else
37
88
  BLUE='\033[34m'
38
89
  fi
39
90
 
40
- # ---- Token/size formatter K / M (verbatim from cbp-statusline.sh L96-105) ---
91
+ # ---- Token/size formatter K / M integer round-half-up (cross-runtime) ------
41
92
  fmt_k() {
42
93
  local val=$1
43
94
  if [ "$val" -ge 1000000 ] 2>/dev/null; then
44
- printf "%.1fM" "$(echo "$val / 1000000" | bc -l)"
95
+ local t=$(( (val + 50000) / 100000 ))
96
+ printf "%d.%dM" $(( t / 10 )) $(( t % 10 ))
45
97
  elif [ "$val" -ge 1000 ] 2>/dev/null; then
46
- printf "%.1fK" "$(echo "$val / 1000" | bc -l)"
98
+ local t=$(( (val + 50) / 100 ))
99
+ printf "%d.%dK" $(( t / 10 )) $(( t % 10 ))
47
100
  else
48
101
  printf "%d" "$val"
49
102
  fi
50
103
  }
51
104
 
52
- # ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" -----------------
53
- # NEW helper — cannot reuse fmt_dur (which divides by 1000 for ms input).
54
- # startTime is a Unix epoch in seconds, same as rate_limits.*.resets_at.
105
+ # ---- "now" with test seam ----------------------------------------------------
106
+ cbp_now() {
107
+ if [ -n "${CBP_STATUSLINE_NOW:-}" ]; then printf '%s' "$CBP_STATUSLINE_NOW"; else date +%s; fi
108
+ }
109
+
110
+ # ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" ------------------
55
111
  fmt_age_secs() {
56
112
  local start=$1
57
- local now
58
- now=$(date +%s)
113
+ local now; now=$(cbp_now)
59
114
  local delta=$(( now - start ))
60
115
  [ "$delta" -lt 0 ] && delta=0
61
116
  if [ "$delta" -ge 3600 ]; then
@@ -67,18 +122,16 @@ fmt_age_secs() {
67
122
  fi
68
123
  }
69
124
 
70
- # ---- Strip ANSI codes for visible-length measurement (column truncation) ----
71
- strip_ansi() {
72
- printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g'
73
- }
74
-
75
125
  # ---- JSON-string-escape a bash variable (embed in {"id":..., "content":...}) -
76
126
  json_escape() {
77
127
  printf '%s' "$1" | jq -Rs '.'
78
128
  }
79
129
 
80
- # ---- Per-task render: one jq TSV emission, looped in bash ------------------
81
- # Single jq pass for tasks[] (locked decision #3 two jq calls total).
130
+ # ---- Per-task render: one jq US-delimited emission, looped in bash ----------
131
+ # Join with the Unit Separator (U+001F, non-whitespace) rather than @tsv: a tab is
132
+ # IFS-whitespace, so `IFS=$'\t' read` collapses consecutive tabs and an EMPTY field
133
+ # (e.g. blank description) would shift every later field. US is non-whitespace, so
134
+ # empty fields are preserved — matching the .mjs/.py object iteration.
82
135
  echo "$INPUT" | jq -r '
83
136
  .tasks // [] | .[] | [
84
137
  .id // "",
@@ -88,8 +141,8 @@ echo "$INPUT" | jq -r '
88
141
  .description // "",
89
142
  (.startTime // 0 | tostring),
90
143
  (.tokenCount // 0 | tostring)
91
- ] | @tsv
92
- ' | while IFS=$'\t' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
144
+ ] | join("\u001f")
145
+ ' | while IFS=$'\037' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
93
146
  # Skip blank rows (empty tasks[] → no output)
94
147
  [ -z "$T_ID" ] && continue
95
148
 
@@ -123,14 +176,12 @@ echo "$INPUT" | jq -r '
123
176
  # Apply printf to interpret escape sequences, then truncate to $COLUMNS
124
177
  RENDERED=$(printf '%b' "$ROW")
125
178
  if [ "$COLUMNS" -gt 0 ] 2>/dev/null; then
126
- # ANSI-aware iterative truncation: walk RENDERED char-by-char treating
127
- # \033[...letter escape sequences as zero-width and ordinary chars as
179
+ # ANSI-aware iterative truncation: walk RENDERED byte-by-byte treating
180
+ # \033[...letter escape sequences as zero-width and ordinary bytes as
128
181
  # one-width. Stop when the visible counter reaches COLUMNS - 3, then
129
- # append RST + ellipsis. Correct under any ANSI-density distribution,
130
- # unlike a single OVERHEAD-add formula which assumes uniform spread.
131
- # Pure bash (no awk/perl subprocess); byte-based slicing means non-ASCII
132
- # multi-byte UTF-8 in user-controlled descriptions counts as multiple
133
- # visible units — acceptable approximation for the ASCII-dominant case.
182
+ # append RST + ellipsis. Byte-based slicing means non-ASCII multi-byte
183
+ # UTF-8 counts as multiple visible units the .mjs/.py renderers iterate
184
+ # the same UTF-8 bytes, so all three stay byte-identical.
134
185
  TARGET=$(( COLUMNS - 3 ))
135
186
  [ "$TARGET" -lt 0 ] && TARGET=0
136
187
  RAW_LEN=${#RENDERED}
@@ -150,12 +201,11 @@ echo "$INPUT" | jq -r '
150
201
  fi
151
202
  i=$(( i + 1 ))
152
203
  done
153
- # Post-loop drain: every rendered row ends with a trailing ${RST} from the
154
- # last segment. Without this drain, a row whose visible content fits within
155
- # COLUMNS still has `i < RAW_LEN` (pointing inside the trailing reset),
156
- # which would spuriously trigger truncation. Walk past any in-progress
157
- # escape and any further escape sequences until we hit a real visible byte
158
- # or end-of-string.
204
+ # Post-loop drain: every rendered row ends with a trailing ${RST}. Without
205
+ # this drain a row whose visible content fits within COLUMNS still has
206
+ # `i < RAW_LEN` (pointing inside the trailing reset), spuriously triggering
207
+ # truncation. Walk past any in-progress escape and any further escapes until
208
+ # a real visible byte or end-of-string.
159
209
  while [ $i -lt $RAW_LEN ]; do
160
210
  ch="${RENDERED:$i:1}"
161
211
  if [ "$in_esc" = "1" ]; then
@@ -171,8 +221,6 @@ echo "$INPUT" | jq -r '
171
221
  fi
172
222
  done
173
223
  if [ $i -lt $RAW_LEN ]; then
174
- # printf '%b' converts the literal-text RST (`\033[0m`) into an actual
175
- # ESC byte sequence, matching the byte semantics of RENDERED's prefix.
176
224
  RENDERED="${RENDERED:0:$i}$(printf '%b' "$RST")..."
177
225
  fi
178
226
  fi