@inbrace-tech/tokenline 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -40,6 +40,8 @@ npx @inbrace-tech/tokenline@latest update --global # or: update (project), upd
40
40
 
41
41
  `update` finds the script your `statusLine` points at and replaces it. It never edits `settings.json`, and there's nothing to restart.
42
42
 
43
+ Because of that, a feature that needs a new settings key isn't switched on by `update` alone. Installed before [subagent rows](#subagent-rows-claude-code)? `update` says so and prints the one command that adds them, for the same scope and `--dir`, e.g. `npx -y @inbrace-tech/tokenline@latest init --global`. Re-running `init` is safe: it confirms your existing `statusLine` and only adds `subagentStatusLine`.
44
+
43
45
  You don't have to remember to check. An installed copy looks up the latest published version in the background: when you open a new session (if the last check is over an hour old), and at least every 6 hours in a long one. When a newer version exists, it adds one gray line to the statusline:
44
46
 
45
47
  ```
@@ -56,12 +58,28 @@ The check is a single `GET` to `registry.npmjs.org` for the version number, made
56
58
 
57
59
  ![Tokenline Preview](https://raw.githubusercontent.com/inbrace-tech/tokenline/main/assets/tokenline.png)
58
60
 
59
- - **Line 1** — model · context used (tokens + %) · cache TTL with a live HOT→COLD countdown.
61
+ - **Line 1** — model · reasoning effort (the session's current level, coloured from green at `low`/`medium` to red at `max`, and updated when you change it with `/effort`) · context used (tokens + %) · cache TTL with a live HOT→COLD countdown.
60
62
  - **Line 2** — per-turn token economics: `read / write / new / output`, the equivalent billed tokens (`eq`), and the **`saving %`** you get from prompt caching.
61
63
  - **Line 3** — 5h and 7d rate-limit bars with reset ETA and a **pace marker** (`!!` = you're burning the window faster than it refills).
62
64
 
63
65
  > Lines 2 and 3 appear only when there's something to show (a turn happened, limits exist), so the bar stays quiet when idle.
64
66
 
67
+ ### Subagent rows (Claude Code)
68
+
69
+ The same script also renders Claude Code's agent panel, one row per subagent, each with **its own** cache countdown:
70
+
71
+ ```
72
+ code-reviewer ● 3m32s ctx: 45.2k (22%) [5m] cache: 3:28 HOT waiting: Bash Review the auth diff
73
+ general-purpose ● 15m22s ctx: 170.0k (85%) [5m] cache: COLD last: Read Run the e2e suite
74
+ Explore ✓ 1m22s ctx: 8.0k [5m] cache: 4:18 HOT Find callers
75
+ ```
76
+
77
+ Subagent requests write the **5-minute** cache. That's fine while a subagent keeps working, since every turn refreshes it. But a subagent that waits (on a long test run, a slow build, a question to you) goes cold without a sound, and its next turn pays a full cache write instead of a cheap read. The row shows it: the countdown runs while `waiting: Bash` names what it's waiting on (`waiting: Bash (bg)` for a subagent idling on its own background job, which Claude Code reports as finished until the job wakes it), and an active subagent that goes cold blinks `COLD`. Finished subagents keep their countdown (they can be resumed) but never blink.
78
+
79
+ The countdown tracks the subagent's **own** last request. The part of the prompt it shares with siblings on the same model (system prompt, tools) can stay warm because of them, so after a `COLD` the next turn still reads that shared part from cache and re-writes the rest: its own conversation. Claude Code refreshes the agent panel every ~5 seconds, so the countdown moves in 5-second steps.
80
+
81
+ Columns: agent type · status (`●` running, `✓` done, `✗` failed, `■` stopped) · model · effort · elapsed · context · cache · last tool · description. A field Claude Code hasn't sent yet (an unresolved model, an inherited effort, no transcript before the first response) stays blank or shows `cache: --`, never a guess. On a narrow panel the description is cut first, then effort, model, context, elapsed and activity are dropped whole, in that order; type, status and cache always stay. `init` wires this in as `subagentStatusLine`; pass `--no-subagents` to skip it.
82
+
65
83
  ## About
66
84
 
67
85
  `tokenline` turns the one-line status bar of your AI coding CLI into a live cockpit:
@@ -87,7 +105,7 @@ LLMs are stateless — by default, they must reread your entire codebase context
87
105
 
88
106
  **Prompt Caching** solves this by saving that processed state in the provider's memory:
89
107
  - **Cache Write (Cold):** The model reads everything and stores the state. This costs slightly more than base tokens.
90
- - **Cache Hit (Warm):** If your prompt prefix matches the cached state exactly, the model skips the reading phase. This is **~90% cheaper** (**97.5%** on Claude Fable 5.1 / Mythos 5.1, which bill cache hits at 0.025x base input, and **95%** on Claude Opus 5.5 at 0.05x) and starts responding almost instantly. `tokenline` picks the right multiplier from the active model.
108
+ - **Cache Hit (Warm):** If your prompt prefix matches the cached state exactly, the model skips the reading phase. This is **~90% cheaper** (**97.5%** on Claude Fable 5.1 / Mythos 5.1, which bill cache hits at 0.025x base input, and **95%** on Claude Opus 5.5 and Claude Sonnet 5.5 at 0.05x; Claude Haiku 5.5 keeps the standard 0.1x; Gemini 4 Argon bills cache hits at 0.05x too, against 0.25x on other Gemini models) and starts responding almost instantly. `tokenline` picks the right multiplier from the active model.
91
109
  - **TTL (Time-To-Live):** The cache operates as a **sliding window**. Every cache hit resets the countdown to its full duration for free. The secret to minimizing rate-limit drain is adjusting your workflow pace to keep the cache continuously **HOT**.
92
110
 
93
111
  ### 💡 Pro-tip: Forcing a 5m TTL to save rate limits
@@ -144,9 +162,15 @@ Add the printed block to your project's `.claude/settings.json` (or `~/.claude/s
144
162
  "type": "command",
145
163
  "command": "bash /absolute/path/to/tokenline/tokenline.sh",
146
164
  "refreshInterval": 1
165
+ },
166
+ "subagentStatusLine": {
167
+ "type": "command",
168
+ "command": "bash /absolute/path/to/tokenline/tokenline.sh"
147
169
  }
148
170
  ```
149
171
 
172
+ (`subagentStatusLine` is Claude Code only — leave it out for the Antigravity CLI.)
173
+
150
174
  Then restart Claude Code.
151
175
 
152
176
  ### What the installer does
@@ -154,10 +178,10 @@ Then restart Claude Code.
154
178
  `npx @inbrace-tech/tokenline init` is deliberately transparent about touching your config:
155
179
 
156
180
  - **Writes** `tokenline.sh` to `./.claude/` (or `~/.claude/` with `--global`, or `~/.gemini/antigravity-cli/` with `--antigravity`).
157
- - **Merges** only the `statusLine` key into `settings.json` — every other setting is preserved.
181
+ - **Merges** only the `statusLine` key (and, for Claude Code, `subagentStatusLine`) into `settings.json` — every other setting is preserved.
158
182
  - **Backs up** `settings.json` to `settings.json.bak` before writing.
159
183
  - **Never clobbers** invalid JSON: if it can't parse your `settings.json`, it stops and prints the block to paste manually.
160
- - Is **idempotent**, and won't replace a different existing `statusLine` unless you pass `--force`.
184
+ - Is **idempotent**, and won't replace a different existing `statusLine` unless you pass `--force`. A different existing `subagentStatusLine` is kept (with a note) rather than failing the install; `--force` replaces it too.
161
185
  - **Stamps** the copy with the installed version and the command that updates it (two lines near the top of the script). The repo's own `tokenline.sh` stays unstamped.
162
186
 
163
187
  Other commands: `update` (replace the installed script, see [Updating](#updating)), `doctor` (check dependencies and config, change nothing) and `uninstall` (remove the block; `--purge` also deletes the script).
@@ -168,7 +192,7 @@ Other commands: `update` (replace the installed script, see [Updating](#updating
168
192
 
169
193
  ## How it works
170
194
 
171
- On every refresh the host CLI pipes a JSON snapshot of the session to the script over stdin. `tokenline` parses it in a single `jq` pass (to keep the per-second refresh cheap), reads the last turn's timestamp from the transcript to drive the cache countdown, and renders up to three lines. Per-turn timestamps are cached in a per-user `0700` directory under `$XDG_RUNTIME_DIR` (tmpfs, cleared on logout).
195
+ On every refresh the host CLI pipes a JSON snapshot of the session to the script over stdin. `tokenline` parses it in a single `jq` pass (to keep the per-second refresh cheap), reads the last turn's timestamp from the transcript to drive the cache countdown, and renders up to three lines. Per-turn timestamps (and, for subagent rows, each subagent's last-turn time, TTL and last tool name) are cached in a per-user `0700` directory under `$XDG_RUNTIME_DIR` (tmpfs, cleared on logout); the payload itself never touches disk.
172
196
 
173
197
  ## Troubleshooting
174
198
 
package/dist/cli.js CHANGED
@@ -1,54 +1,61 @@
1
1
  #!/usr/bin/env node
2
- "use strict";var Q=require("fs"),X=require("path");var _=require("os"),u=require("path"),D=(0,u.join)(__dirname,"..","tokenline.sh"),A=t=>t.targetCli==="antigravity"?(0,u.join)((0,_.homedir)(),".gemini","antigravity-cli"):t.global?(0,u.join)((0,_.homedir)(),".claude"):(0,u.resolve)(".claude");var w=t=>t.dir?(0,u.resolve)(t.dir,"tokenline.sh"):(0,u.join)(A(t),"tokenline.sh"),g=t=>(0,u.join)(A(t),"settings.json"),J=t=>t.targetCli==="antigravity"?"antigravity (global)":t.global?"claude (global)":"claude (project)",q=t=>t.includes(" ")?`bash "${t}"`:`bash ${t}`;var b=require("fs");function m(t){if(!(0,b.existsSync)(t))return{exists:!1,data:{}};let e=(0,b.readFileSync)(t,"utf8");if(e.trim()==="")return{exists:!0,data:{},raw:e};try{return{exists:!0,data:JSON.parse(e),raw:e}}catch{return{exists:!0,data:null,raw:e}}}function C(t){let e=`${t}.bak`;return(0,b.copyFileSync)(t,e),e}var L=t=>typeof t=="string"&&/tokenline\.sh/.test(t);var P=require("child_process"),F=require("os");var tt=!!process.stdout.isTTY&&!process.env.NO_COLOR,x=(t,e)=>tt?`\x1B[${t}m${e}\x1B[0m`:e,a=t=>x("1",t),et=t=>x("2",t),h=t=>x("32",t),nt=t=>x("31",t),rt=t=>x("33",t),k=t=>console.log(`${h("\u2713")} ${t}`),l=t=>console.log(`${rt("!")} ${t}`),p=t=>console.error(`${nt("\u2717")} ${t}`),i=t=>console.log(`${et("\u2192")} ${t}`);function T(){let t=(0,F.platform)();return t==="linux"||t==="darwin"?(k(`platform: ${t} (supported)`),!0):(l(`platform: ${t} \u2014 supported on Linux/WSL2 and macOS; the bash statusline likely won't render yet (see roadmap). Use --force to install anyway.`),!1)}function O(){let t=(0,P.spawnSync)("jq",["--version"],{encoding:"utf8"});return t.status===0?(k(String(t.stdout).trim()),!0):(l("jq not found \u2014 required at runtime. Install it (apt install jq / brew install jq)."),!1)}function N(){let t=(0,P.spawnSync)("bash",["--version"],{encoding:"utf8"});if(t.status===0){let e=String(t.stdout).match(/version (\d+)\.(\d+)/);return e&&Number(e[1])>=4?(k(`bash ${e[1]}.${e[2]}`),!0):(l(`bash ${e?`${e[1]}.${e[2]}`:"?"} found \u2014 the script needs bash 4+.`),!1)}return l("bash not found \u2014 the statusline runs as a bash script."),!1}function B(){console.log(a(`
2
+ "use strict";var lt=require("fs"),ct=require("path");var D=require("os"),d=require("path"),B=(0,d.join)(__dirname,"..","tokenline.sh"),F=t=>t.targetCli==="antigravity"?(0,d.join)((0,D.homedir)(),".gemini","antigravity-cli"):t.global?(0,d.join)((0,D.homedir)(),".claude"):(0,d.resolve)(".claude");var w=t=>t.dir?(0,d.resolve)(t.dir,"tokenline.sh"):(0,d.join)(F(t),"tokenline.sh"),m=t=>(0,d.join)(F(t),"settings.json"),M=t=>t.targetCli==="antigravity"?"antigravity (global)":t.global?"claude (global)":"claude (project)",K=t=>t.includes(" ")?`bash "${t}"`:`bash ${t}`;var x=require("fs");function p(t){if(!(0,x.existsSync)(t))return{exists:!1,data:{}};let e=(0,x.readFileSync)(t,"utf8");if(e.trim()==="")return{exists:!0,data:{},raw:e};try{return{exists:!0,data:JSON.parse(e),raw:e}}catch{return{exists:!0,data:null,raw:e}}}function O(t){let e=`${t}.bak`;return(0,x.copyFileSync)(t,e),e}var h=t=>typeof t=="string"&&/tokenline\.sh/.test(t);function V(t,e,n){return t===void 0?"add":t.command===e?"confirm":typeof t.command=="string"&&h(t.command)||n?"replace":"keep"}function W(t){if(typeof t=="object"&&t!==null&&"command"in t){let{command:e}=t;if(typeof e=="string")return e}return"no command"}function J(t){let e=[];return h(t.statusLine?.command)&&(delete t.statusLine,e.push("statusLine")),h(t.subagentStatusLine?.command)&&(delete t.subagentStatusLine,e.push("subagentStatusLine")),e}var A=require("child_process"),H=require("os");var dt=!!process.stdout.isTTY&&!process.env.NO_COLOR,L=(t,e)=>dt?`\x1B[${t}m${e}\x1B[0m`:e,u=t=>L("1",t),gt=t=>L("2",t),b=t=>L("32",t),mt=t=>L("31",t),pt=t=>L("33",t),y=t=>console.log(`${b("\u2713")} ${t}`),a=t=>console.log(`${pt("!")} ${t}`),g=t=>console.error(`${mt("\u2717")} ${t}`),s=t=>console.log(`${gt("\u2192")} ${t}`);function N(){let t=(0,H.platform)();return t==="linux"||t==="darwin"?(y(`platform: ${t} (supported)`),!0):(a(`platform: ${t} \u2014 supported on Linux/WSL2 and macOS; the bash statusline likely won't render yet (see roadmap). Use --force to install anyway.`),!1)}function R(){let t=(0,A.spawnSync)("jq",["--version"],{encoding:"utf8"});return t.status===0?(y(String(t.stdout).trim()),!0):(a("jq not found \u2014 required at runtime. Install it (apt install jq / brew install jq)."),!1)}function E(){let t=(0,A.spawnSync)("bash",["--version"],{encoding:"utf8"});if(t.status===0){let e=String(t.stdout).match(/version (\d+)\.(\d+)/);return e&&Number(e[1])>=4?(y(`bash ${e[1]}.${e[2]}`),!0):(a(`bash ${e?`${e[1]}.${e[2]}`:"?"} found \u2014 the script needs bash 4+.`),!1)}return a("bash not found \u2014 the statusline runs as a bash script."),!1}function G(){console.log(u(`
3
3
  tokenline \u2014 environment check
4
- `)),T(),N(),O();let t=[{global:!1,dir:null,targetCli:"claude"},{global:!0,dir:null,targetCli:"claude"},{global:!0,dir:null,targetCli:"antigravity"}];for(let e of t){let n=J(e),o=g(e),r=m(o);r.exists&&r.data&&L(r.data.statusLine?.command)?k(`${n} settings: tokenline configured (${o})`):r.exists&&r.data===null?l(`${n} settings: invalid JSON (${o})`):i(`${n} settings: not configured (${o})`)}console.log()}var I=require("fs"),V=require("path");var ot='TOKENLINE_VERSION=""',it='TOKENLINE_UPDATE_CMD=""',st=/^\d+\.\d+\.\d+$/;var E=t=>{let e="npx -y @inbrace-tech/tokenline@latest update";return t.targetCli==="antigravity"?`${e} --antigravity`:t.global?`${e} --global`:e};function M(t,e,n){if(!st.test(e))throw new Error(`Invalid version: ${e}`);if(n.includes("'"))throw new Error("Update command must not contain a single quote");let o=t.split(`
5
- `),r=(d,c)=>{let f=o.flatMap((S,v)=>S===d?[v]:[]);if(f.length!==1)throw new Error(`Expected exactly one "${d}" line in tokenline.sh`);o[f[0]]=c};return r(ot,`TOKENLINE_VERSION="${e}"`),r(it,`TOKENLINE_UPDATE_CMD='${n}'`),o.join(`
6
- `)}function K(t){if(typeof t!="string")return null;let e=/^bash (?:"([^"$`\\]+)"|([^\s"'$`\\]+))$/.exec(t),n=e?e[1]??e[2]:void 0;return n!==void 0&&n.endsWith("/tokenline.sh")?n:null}var s=require("fs"),y=require("path");function R(t,e,n){let o=M((0,s.readFileSync)(D,"utf8"),e,n);at(t,o)}function at(t,e){let n=(0,s.existsSync)(t)?(0,s.realpathSync)(t):t;(0,s.mkdirSync)((0,y.dirname)(n),{recursive:!0});let o=(0,y.join)((0,y.dirname)(n),`.${(0,y.basename)(n)}.${process.pid}.tmp`);(0,s.writeFileSync)(o,e,{flag:"wx",mode:493});try{(0,s.chmodSync)(o,493),(0,s.renameSync)(o,n)}catch(r){throw(0,s.rmSync)(o,{force:!0}),r}}function W(t,e){if(console.log(a(`
4
+ `)),N(),E(),R();let t=[{global:!1,dir:null,targetCli:"claude"},{global:!0,dir:null,targetCli:"claude"},{global:!0,dir:null,targetCli:"antigravity"}];for(let e of t){let n=M(e),r=m(e),o=p(r);if(o.exists&&o.data&&h(o.data.statusLine?.command)?y(`${n} settings: tokenline configured (${r})`):o.exists&&o.data===null?a(`${n} settings: invalid JSON (${r})`):s(`${n} settings: not configured (${r})`),e.targetCli==="claude"&&o.data){let i=o.data.subagentStatusLine;h(i?.command)?y(`${n} subagent rows: tokenline configured`):i!==void 0?s(`${n} subagent rows: another command (${W(i)})`):h(o.data.statusLine?.command)&&s(`${n} subagent rows: not configured (re-run init to add)`)}}console.log()}var j=require("fs"),X=require("path");var ft='TOKENLINE_VERSION=""',ht='TOKENLINE_UPDATE_CMD=""',bt=/^\d+\.\d+\.\d+$/;var I=t=>{let e="npx -y @inbrace-tech/tokenline@latest update";return t.targetCli==="antigravity"?`${e} --antigravity`:t.global?`${e} --global`:e};function Y(t,e,n){if(!bt.test(e))throw new Error(`Invalid version: ${e}`);if(n.includes("'"))throw new Error("Update command must not contain a single quote");let r=t.split(`
5
+ `),o=(i,c)=>{let f=r.flatMap((C,T)=>C===i?[T]:[]);if(f.length!==1)throw new Error(`Expected exactly one "${i}" line in tokenline.sh`);r[f[0]]=c};return o(ft,`TOKENLINE_VERSION="${e}"`),o(ht,`TOKENLINE_UPDATE_CMD='${n}'`),r.join(`
6
+ `)}function z(t){if(typeof t!="string")return null;let e=/^bash (?:"([^"$`\\]+)"|([^\s"'$`\\]+))$/.exec(t),n=e?e[1]??e[2]:void 0;return n!==void 0&&n.endsWith("/tokenline.sh")?n:null}var l=require("fs"),k=require("path");function _(t,e,n){let r=Y((0,l.readFileSync)(B,"utf8"),e,n);yt(t,r)}function yt(t,e){let n=(0,l.existsSync)(t)?(0,l.realpathSync)(t):t;(0,l.mkdirSync)((0,k.dirname)(n),{recursive:!0});let r=(0,k.join)((0,k.dirname)(n),`.${(0,k.basename)(n)}.${process.pid}.tmp`);(0,l.writeFileSync)(r,e,{flag:"wx",mode:493});try{(0,l.chmodSync)(r,493),(0,l.renameSync)(r,n)}catch(o){throw(0,l.rmSync)(r,{force:!0}),o}}function Q(t){a(`kept the existing subagentStatusLine (${JSON.stringify(t)}); re-run with --force to use tokenline for subagent rows.`)}function Z(t,e){if(console.log(u(`
7
7
  tokenline \u2014 installing the statusline
8
- `)),!T()&&!t.force){p("Unsupported platform. Re-run with --force to install anyway."),process.exitCode=1;return}N(),O();let o=w(t),r=g(t),d={type:"command",command:q(o),refreshInterval:1},c=m(r);if(c.exists&&c.data===null){p(`Could not parse ${r} (invalid JSON). Leaving it untouched.`),console.log(`
8
+ `)),!N()&&!t.force){g("Unsupported platform. Re-run with --force to install anyway."),process.exitCode=1;return}E(),R();let r=w(t),o=m(t),i={type:"command",command:K(r),refreshInterval:1},c=p(o);if(c.exists&&c.data===null){g(`Could not parse ${o} (invalid JSON). Leaving it untouched.`),console.log(`
9
9
  Add this block manually, inside the top-level object:
10
- `),console.log(JSON.stringify({statusLine:d},null,2)+`
11
- `),process.exitCode=1;return}let f=c.data?c.data.statusLine:void 0,S=f!==void 0&&f.command===d.command,v=f!==void 0&&!S;if(v&&!t.force){p(`A different statusLine is already configured in ${r}:`),console.log(` ${JSON.stringify(f)}`),l("Re-run with --force to replace it."),process.exitCode=1;return}if(t.dryRun){console.log(a(`
12
- [dry-run] would:`)),i(`write script \u2192 ${o}`),i(`${c.exists?"patch":"create"} settings \u2192 ${r}`),c.exists&&i(`backup \u2192 ${r}.bak`),console.log(`
10
+ `);let U=t.targetCli==="claude"&&t.subagents?{statusLine:i,subagentStatusLine:{type:"command",command:i.command}}:{statusLine:i};console.log(JSON.stringify(U,null,2)+`
11
+ `),process.exitCode=1;return}let f=c.data?c.data.statusLine:void 0,C=f!==void 0&&f.command===i.command,T=f!==void 0&&!C;if(T&&!t.force){g(`A different statusLine is already configured in ${o}:`),console.log(` ${JSON.stringify(f)}`),a("Re-run with --force to replace it."),process.exitCode=1;return}let S=t.targetCli==="claude"&&t.subagents?{type:"command",command:i.command}:null,$=S?V(c.data?.subagentStatusLine,S.command,t.force):null;if(t.dryRun){console.log(u(`
12
+ [dry-run] would:`)),s(`write script \u2192 ${r}`),s(`${c.exists?"patch":"create"} settings \u2192 ${o}`),c.exists&&s(`backup \u2192 ${o}.bak`),console.log(`
13
13
  statusLine block:
14
- ${JSON.stringify(d,null,2)}
15
- `);return}R(o,e,E(t)),i(`wrote ${o} (v${e})`);let U=c.data??{};c.exists?(C(r),i(`backed up ${r} \u2192 settings.json.bak`)):(0,I.mkdirSync)((0,V.dirname)(r),{recursive:!0}),U.statusLine=d,(0,I.writeFileSync)(r,JSON.stringify(U,null,2)+`
16
- `),i(`${S?"confirmed":v?"replaced":"added"} statusLine in ${r}`);let Z=t.targetCli==="antigravity"?"Antigravity CLI":"Claude Code";console.log(`
17
- ${h("Done.")} Restart ${Z} to see the statusline.
18
- `)}var $=require("fs");function G(t){console.log(a(`
14
+ ${JSON.stringify(i,null,2)}
15
+ `),S&&$!=="keep"&&console.log(`subagentStatusLine block:
16
+ ${JSON.stringify(S,null,2)}
17
+ `),$==="keep"&&Q(c.data?.subagentStatusLine);return}_(r,e,I(t)),s(`wrote ${r} (v${e})`);let P=c.data??{};if(c.exists?(O(o),s(`backed up ${o} \u2192 settings.json.bak`)):(0,j.mkdirSync)((0,X.dirname)(o),{recursive:!0}),P.statusLine=i,S&&$!=="keep"&&(P.subagentStatusLine=S),(0,j.writeFileSync)(o,JSON.stringify(P,null,2)+`
18
+ `),s(`${C?"confirmed":T?"replaced":"added"} statusLine in ${o}`),$==="keep")Q(c.data?.subagentStatusLine);else if($!==null){let U={add:"added",confirm:"confirmed",replace:"replaced"}[$];s(`${U} subagentStatusLine in ${o}`)}let ut=t.targetCli==="antigravity"?"Antigravity CLI":"Claude Code";console.log(`
19
+ ${b("Done.")} Restart ${ut} to see the statusline.
20
+ `)}var v=require("fs");function tt(t){console.log(u(`
19
21
  tokenline \u2014 uninstall
20
- `));let e=g(t),n=m(e);if(!n.exists||n.data===null?l(`No usable settings at ${e} \u2014 nothing to remove.`):L(n.data.statusLine?.command)?t.dryRun?i(`[dry-run] would remove statusLine from ${e}`):(C(e),delete n.data.statusLine,(0,$.writeFileSync)(e,JSON.stringify(n.data,null,2)+`
21
- `),i(`removed statusLine from ${e} (backup: settings.json.bak)`)):i(`No tokenline statusLine in ${e} \u2014 left untouched.`),t.purge){let r=w(t);(0,$.existsSync)(r)&&(t.dryRun?i(`[dry-run] would delete ${r}`):((0,$.unlinkSync)(r),i(`deleted ${r}`)))}let o=t.targetCli==="antigravity"?"Antigravity CLI":"Claude Code";console.log(`
22
- ${h("Done.")} Restart ${o}.
23
- `)}var Y=require("fs");function z(t,e){console.log(a(`
22
+ `));let e=m(t),n=p(e),r=(n.data?J({...n.data}):[]).join(" + ");if(!n.exists||n.data===null?a(`No usable settings at ${e} \u2014 nothing to remove.`):r!==""?t.dryRun?s(`[dry-run] would remove ${r} from ${e}`):(O(e),J(n.data),(0,v.writeFileSync)(e,JSON.stringify(n.data,null,2)+`
23
+ `),s(`removed ${r} from ${e} (backup: settings.json.bak)`)):s(`No tokenline statusLine in ${e} \u2014 left untouched.`),t.purge){let i=w(t);(0,v.existsSync)(i)&&(t.dryRun?s(`[dry-run] would delete ${i}`):((0,v.unlinkSync)(i),s(`deleted ${i}`)))}let o=t.targetCli==="antigravity"?"Antigravity CLI":"Claude Code";console.log(`
24
+ ${b("Done.")} Restart ${o}.
25
+ `)}var ot=require("fs");var et=require("path");function kt(t,e){let n=["npx -y @inbrace-tech/tokenline@latest init"];if(t.targetCli==="antigravity"?n.push("--antigravity"):t.global&&n.push("--global"),e!==w({...t,dir:null})){let r=(0,et.dirname)(e);n.push(r.includes(" ")?`--dir "${r}"`:`--dir ${r}`)}return n.join(" ")}function nt(t,e,n){return e.targetCli!=="claude"||t===null||t.subagentStatusLine!==void 0?null:kt(e,n)}function it(t,e){console.log(u(`
24
26
  tokenline \u2014 updating the statusline
25
- `));let n=g(t),o=m(n),r=K(o.data?.statusLine?.command);if(r===null||!(0,Y.existsSync)(r)){p(`No installed tokenline.sh found through ${n}.`);let d=t.targetCli==="antigravity"||t.global?"":" (or --global)";console.log(`
26
- Install it first: npx @inbrace-tech/tokenline init${d}
27
- `),process.exitCode=1;return}if(t.dryRun){i(`[dry-run] would overwrite ${r} with v${e}`);return}R(r,e,E(t)),i(`updated ${r} \u2192 v${e}`),console.log(`
28
- ${h("Done.")} The statusline picks up the new version on its next refresh.
29
- `)}var j=JSON.parse((0,Q.readFileSync)((0,X.join)(__dirname,"..","package.json"),"utf8"));function lt(t){let e={_:[],targetCli:"claude",dir:null,global:!1,dryRun:!1,force:!1,purge:!1,help:!1,version:!1,unknown:[]};for(let n=0;n<t.length;n++){let o=t[n];switch(o){case"--dir":e.dir=t[++n]??null;break;case"--antigravity":e.targetCli="antigravity",e.global=!0;break;case"--global":e.global=!0;break;case"--dry-run":e.dryRun=!0;break;case"--force":e.force=!0;break;case"--purge":e.purge=!0;break;case"-h":case"--help":e.help=!0;break;case"-v":case"--version":e.version=!0;break;default:o!==void 0&&o.startsWith("-")?e.unknown.push(o):o!==void 0&&e._.push(o)}}return e}function H(){console.log(`
30
- ${a("tokenline")} \u2014 a cache-aware statusline for AI coding CLIs
27
+ `));let n=m(t),r=p(n),o=z(r.data?.statusLine?.command);if(o===null||!(0,ot.existsSync)(o)){g(`No installed tokenline.sh found through ${n}.`);let c=t.targetCli==="antigravity"||t.global?"":" (or --global)";console.log(`
28
+ Install it first: npx @inbrace-tech/tokenline init${c}
29
+ `),process.exitCode=1;return}let i=nt(r.data,t,o);if(t.dryRun){s(`[dry-run] would overwrite ${o} with v${e}`),i&&rt(i);return}_(o,e,I(t)),s(`updated ${o} \u2192 v${e}`),console.log(`
30
+ ${b("Done.")} The statusline picks up the new version on its next refresh.
31
+ `),i&&rt(i)}function rt(t){a("Subagent rows are available but not wired in these settings."),console.log(` Each subagent in the agent panel can show its own cache countdown.
32
+ Turn them on (your statusLine stays as is):
31
33
 
32
- ${a("Usage")}
34
+ ${t}
35
+ `)}function st(t){let e={_:[],targetCli:"claude",dir:null,global:!1,dryRun:!1,force:!1,purge:!1,subagents:!0,help:!1,version:!1,unknown:[]};for(let n=0;n<t.length;n++){let r=t[n];switch(r){case"--dir":e.dir=t[++n]??null;break;case"--antigravity":e.targetCli="antigravity",e.global=!0;break;case"--global":e.global=!0;break;case"--dry-run":e.dryRun=!0;break;case"--force":e.force=!0;break;case"--purge":e.purge=!0;break;case"--no-subagents":e.subagents=!1;break;case"-h":case"--help":e.help=!0;break;case"-v":case"--version":e.version=!0;break;default:r!==void 0&&r.startsWith("-")?e.unknown.push(r):r!==void 0&&e._.push(r)}}return e}var q=JSON.parse((0,lt.readFileSync)((0,ct.join)(__dirname,"..","package.json"),"utf8"));function at(){console.log(`
36
+ ${u("tokenline")} \u2014 a cache-aware statusline for AI coding CLIs
37
+
38
+ ${u("Usage")}
33
39
  npx @inbrace-tech/tokenline <command> [options]
34
40
 
35
- ${a("Commands")}
41
+ ${u("Commands")}
36
42
  init Install tokenline.sh and wire it into settings
37
43
  update Replace the installed tokenline.sh with this version (run it as @latest)
38
44
  doctor Check dependencies and current config \u2014 changes nothing
39
- uninstall Remove the tokenline statusLine block from settings
45
+ uninstall Remove the tokenline blocks (statusLine, subagentStatusLine) from settings
40
46
 
41
- ${a("Options")}
47
+ ${u("Options")}
42
48
  --antigravity Target Antigravity CLI (~/.gemini/antigravity-cli)
43
49
  --global Target ~/.claude (global) instead of ./.claude (project)
44
50
  --dir <path> Write tokenline.sh to a custom directory
45
51
  --dry-run Show what would happen without writing anything
46
52
  --force Proceed on an unsupported platform / replace an existing statusLine
47
53
  --purge (uninstall) also delete the installed tokenline.sh
54
+ --no-subagents (init) skip the per-subagent cache rows in the agent panel
48
55
  -h, --help Show this help
49
56
  -v, --version Show version
50
57
 
51
- ${a("Notes")}
58
+ ${u("Notes")}
52
59
  The statusline runs as 'bash tokenline.sh' \u2014 no Node in the per-second hot path.
53
60
  jq is required at runtime; this installer checks for it but cannot install it.
54
- `)}function ct(){let t=lt(process.argv.slice(2));if(t.unknown.length>0&&t.unknown.forEach(e=>l(`ignoring unknown option: ${e}`)),t.version){console.log(j.version);return}if(t.help||t._.length===0){H();return}try{switch(t._[0]){case"init":W(t,j.version);break;case"update":z(t,j.version);break;case"doctor":B();break;case"uninstall":case"remove":G(t);break;default:p(`Unknown command: ${t._[0]}`),H(),process.exitCode=1}}catch(e){p(e instanceof Error?e.message:String(e)),process.exitCode=1}}ct();
61
+ `)}function St(){let t=st(process.argv.slice(2));if(t.unknown.length>0&&t.unknown.forEach(e=>a(`ignoring unknown option: ${e}`)),t.version){console.log(q.version);return}if(t.help||t._.length===0){at();return}try{switch(t._[0]){case"init":Z(t,q.version);break;case"update":it(t,q.version);break;case"doctor":G();break;case"uninstall":case"remove":tt(t);break;default:g(`Unknown command: ${t._[0]}`),at(),process.exitCode=1}}catch(e){g(e instanceof Error?e.message:String(e)),process.exitCode=1}}St();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inbrace-tech/tokenline",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A cache-aware statusline for AI coding CLIs — installs tokenline.sh and wires it into your Claude Code settings.",
5
5
  "bin": {
6
6
  "tokenline": "dist/cli.js"
@@ -40,18 +40,18 @@
40
40
  "@changesets/changelog-github": "1.0.1",
41
41
  "@changesets/cli": "^3.0.2",
42
42
  "@eslint/js": "10.0.1",
43
- "@types/node": "24.13.5",
43
+ "@types/node": "24.19.2",
44
44
  "@vitest/eslint-plugin": "1.6.27",
45
- "eslint": "10.10.0",
45
+ "eslint": "10.12.0",
46
46
  "eslint-config-prettier": "10.1.8",
47
- "eslint-plugin-n": "18.3.0",
47
+ "eslint-plugin-n": "18.4.1",
48
48
  "eslint-plugin-prettier": "5.5.6",
49
49
  "eslint-plugin-simple-import-sort": "13.0.0",
50
- "globals": "17.12.0",
51
- "prettier": "3.9.8",
50
+ "globals": "17.13.0",
51
+ "prettier": "3.9.10",
52
52
  "tsup": "^8.5.1",
53
53
  "typescript": "5.9.3",
54
- "typescript-eslint": "8.70.0",
54
+ "typescript-eslint": "8.71.1",
55
55
  "vitest": "4.1.11"
56
56
  },
57
57
  "scripts": {
package/tokenline.sh CHANGED
@@ -4,8 +4,10 @@
4
4
  # tokenline — a cache-aware statusline for AI coding CLIs
5
5
  #
6
6
  # Cross-CLI (Claude Code, Antigravity) and cross-provider (Anthropic, Gemini).
7
- # Renders: model · context · cache TTL (HOT/COLD) · per-turn token economics
7
+ # Renders: model · effort · context · cache TTL (HOT/COLD) · per-turn token economics
8
8
  # (read / write / new / output / eq / saving %) · 5h + 7d rate-limit pacing.
9
+ # Wired as Claude Code's subagentStatusLine, renders one row per subagent in
10
+ # the agent panel instead, each with its own cache countdown (section 9).
9
11
  #
10
12
  # Repo: https://github.com/inbrace-tech/tokenline
11
13
  # License: MIT
@@ -97,12 +99,8 @@ mkdir -p "$_runtime_dir" 2>/dev/null && chmod 700 "$_runtime_dir" 2>/dev/null
97
99
 
98
100
  # --- 1. Parse JSON Standard Input and Prepare State Variables ---
99
101
  parse_and_prepare_paths() {
100
- # Read full stdin JSON representing active session state from the CLI client
101
- local input
102
- input=$(cat)
103
-
104
102
  # Single jq execution to parse all required fields into an array at once (reduces forks)
105
- mapfile -t _f < <(printf '%s' "$input" | jq -r '
103
+ mapfile -t _f < <(printf '%s' "$_input" | jq -r '
106
104
  (.model.display_name // ""),
107
105
  (.context_window.used_percentage // ""),
108
106
  (.context_window.context_window_size // ""),
@@ -116,7 +114,8 @@ parse_and_prepare_paths() {
116
114
  (.context_window.current_usage.output_tokens // 0),
117
115
  (.context_window.current_usage.cache_creation_input_tokens // 0),
118
116
  (.context_window.current_usage.cache_read_input_tokens // 0),
119
- (.model.id // "")' 2>/dev/null)
117
+ (.model.id // ""),
118
+ (.effort.level // "")' 2>/dev/null)
120
119
 
121
120
  # Malformed or empty stdin: jq emits nothing, so the array is empty. Degrade to
122
121
  # a silent no-op render rather than leaking parse errors or rendering garbage —
@@ -137,6 +136,7 @@ parse_and_prepare_paths() {
137
136
  cur_cwrite="${_f[11]}"
138
137
  cur_cread="${_f[12]}"
139
138
  model_id="${_f[13]}"
139
+ effort_level="${_f[14]}"
140
140
 
141
141
  # Computed: total input-only tokens used in the current context window
142
142
  tokens_used=$((cur_input + cur_cwrite + cur_cread))
@@ -163,16 +163,27 @@ parse_and_prepare_paths() {
163
163
  is_gemini=true
164
164
  fi
165
165
 
166
+ # Gemini 4 Argon bills cache hits at 0.05x base input and output at 5x (the same
167
+ # ratios at the introductory and the regular price). Match the API id
168
+ # (gemini-4-argon) or a display name containing Argon; other Gemini models keep
169
+ # 0.25x / 4x.
170
+ is_gemini_argon=false
171
+ if [ "$is_gemini" = true ] && { [[ "${model_id,,}" == *argon* ]] || [[ "${model,,}" == *argon* ]]; }; then
172
+ is_gemini_argon=true
173
+ fi
174
+
166
175
  # Cache hits bill at 0.1x base input, except Claude Fable 5.1 / Mythos 5.1 (0.025x)
167
- # and Claude Opus 5.5 (0.05x). Match the API id (claude-fable-5-1[1m]) or the
168
- # display name (Fable 5.1) so either field alone is enough. Pin the full version:
169
- # claude-opus-5 must stay 0.1x, and a later version's price is confirmed, not guessed.
176
+ # and Claude Opus 5.5 / Sonnet 5.5 (0.05x). Claude Haiku 5.5 is confirmed at 0.1x in
177
+ # both of its prompt-length price tiers. Match the API id (claude-fable-5-1[1m]) or
178
+ # the display name (Fable 5.1) so either field alone is enough. Pin the full version:
179
+ # claude-opus-5 / claude-sonnet-5 must stay 0.1x, and a later version's price is
180
+ # confirmed, not guessed.
170
181
  claude_read_mult="0.1"
171
182
  local fable_re='(fable|mythos)[- ]5[-.]1([^0-9]|$)'
172
- local opus_re='opus[- ]5[-.]5([^0-9]|$)'
183
+ local half_re='(opus|sonnet)[- ]5[-.]5([^0-9]|$)'
173
184
  if [[ "${model_id,,}" =~ $fable_re ]] || [[ "${model,,}" =~ $fable_re ]]; then
174
185
  claude_read_mult="0.025"
175
- elif [[ "${model_id,,}" =~ $opus_re ]] || [[ "${model,,}" =~ $opus_re ]]; then
186
+ elif [[ "${model_id,,}" =~ $half_re ]] || [[ "${model,,}" =~ $half_re ]]; then
176
187
  claude_read_mult="0.05"
177
188
  fi
178
189
 
@@ -333,6 +344,24 @@ compute_context_info() {
333
344
  fi
334
345
  }
335
346
 
347
+ # --- 4b. Reasoning Effort ---
348
+ compute_effort_info() {
349
+ # Claude Code sends the live session effort (it follows a mid-session /effort)
350
+ # and omits it for models without the effort parameter. Coloured by level so
351
+ # an effort left raised after a hard step stands out.
352
+ effort_info=""
353
+ [[ "$effort_level" =~ ^[A-Za-z0-9_-]+$ ]] || return 0
354
+ local effort_color
355
+ case "$effort_level" in
356
+ low|medium) effort_color="$COLOR_GREEN" ;;
357
+ high) effort_color="$COLOR_YELLOW" ;;
358
+ xhigh) effort_color="$COLOR_ORANGE" ;;
359
+ max) effort_color="$COLOR_RED" ;;
360
+ *) effort_color="$COLOR_GRAY" ;;
361
+ esac
362
+ effort_info=$(printf '%seffort: %s%s%s' "$COLOR_GRAY" "$effort_color" "$effort_level" "$COLOR_RESET")
363
+ }
364
+
336
365
  # --- 5. Rate Limit Windows Heuristics and Bars ---
337
366
  rl_color_for_pct() {
338
367
  local pct=$1
@@ -435,6 +464,10 @@ compute_turn_breakdown() {
435
464
  write_mult="1.0"
436
465
  input_mult="1"
437
466
  output_mult="4"
467
+ if [ "$is_gemini_argon" = true ]; then
468
+ read_mult="0.05"
469
+ output_mult="5"
470
+ fi
438
471
  else
439
472
  read_mult="$claude_read_mult"
440
473
  write_mult="1.25"
@@ -559,6 +592,8 @@ render_statusline() {
559
592
  display_header="${model}"
560
593
  fi
561
594
 
595
+ [ -n "$effort_info" ] && display_header="$display_header · $effort_info"
596
+
562
597
  local line1="$display_header"
563
598
  [ -n "$ctx_info" ] && line1="$line1 | $ctx_info"
564
599
  [ -n "$cache_info" ] && line1="$line1 | $cache_info"
@@ -583,10 +618,415 @@ render_statusline() {
583
618
  fi
584
619
  }
585
620
 
621
+ # --- 9. Subagent Rows (subagentStatusLine) ---
622
+ # Wired as `subagentStatusLine`, Claude Code runs this same script for its agent
623
+ # panel: stdin is then {session_id, transcript_path, columns, tasks: [...]} and
624
+ # stdout is one {"id","content"} JSON line per row. Each row carries that
625
+ # subagent's own prompt-cache countdown, read from its own transcript.
626
+ # Subagent requests write the 5-minute cache, so a subagent that waits on a long
627
+ # tool call (a test suite, a build) or on a human answer goes COLD quietly, and
628
+ # its next turn pays a full cache write instead of a cache read.
629
+
630
+ _sa_sep=$'\x1f'
631
+ # Bump when scan_subagent_transcript's output changes (see load_subagent_state).
632
+ _sa_scan_version=3
633
+
634
+ # LC_ALL=C (top of file) makes ${#s} count bytes, so 'é' or '…' would throw the
635
+ # columns off. Width math switches to a UTF-8 locale when one exists; without
636
+ # one it falls back to bytes, which only over-counts (rows get shorter, never
637
+ # wider than the panel).
638
+ _utf8_locale=""
639
+ probe_utf8_locale() {
640
+ local loc
641
+ for loc in C.UTF-8 C.utf8 en_US.UTF-8 en_US.utf8 UTF-8; do
642
+ if { _utf8_len_probe "$loc"; } 2>/dev/null; then
643
+ _utf8_locale="$loc"
644
+ return
645
+ fi
646
+ done
647
+ }
648
+ _utf8_len_probe() {
649
+ local LC_ALL="$1" s='é'
650
+ [ "${#s}" -eq 1 ]
651
+ }
652
+
653
+ strip_ansi() {
654
+ # SGR sequences only: the only escapes this script ever emits.
655
+ local s="$1" re=$'\033''\[[0-9;]*m'
656
+ while [[ "$s" =~ $re ]]; do s="${s/"${BASH_REMATCH[0]}"/}"; done
657
+ printf '%s' "$s"
658
+ }
659
+
660
+ vis_len() {
661
+ # Visible width of a (possibly colored) string, in characters.
662
+ local plain; plain=$(strip_ansi "$1")
663
+ { local LC_ALL="${_utf8_locale:-C}"; } 2>/dev/null
664
+ printf '%s' "${#plain}"
665
+ }
666
+
667
+ fit() {
668
+ # Plain text cut to $2 characters, with a trailing ellipsis when cut.
669
+ local s="$1" n="$2"
670
+ { local LC_ALL="${_utf8_locale:-C}"; } 2>/dev/null
671
+ if [ "$n" -le 0 ]; then return; fi
672
+ if [ "${#s}" -le "$n" ]; then printf '%s' "$s"; return; fi
673
+ if [ -n "$_utf8_locale" ]; then
674
+ printf '%s…' "${s:0:n-1}"
675
+ else
676
+ printf '%s' "${s:0:n}"
677
+ fi
678
+ }
679
+
680
+ pad() {
681
+ # Right-pads a colored string to $2 visible characters.
682
+ local s="$1" w="$2" len
683
+ len=$(vis_len "$s")
684
+ printf '%s%*s' "$s" $((w > len ? w - len : 0)) ''
685
+ }
686
+
687
+ fmt_elapsed() {
688
+ local s=${1:-0}
689
+ if [ "$s" -lt 60 ]; then printf '%ds' "$s"
690
+ elif [ "$s" -lt 3600 ]; then printf '%dm%02ds' $((s / 60)) $((s % 60))
691
+ else printf '%dh%02dm' $((s / 3600)) $(((s % 3600) / 60))
692
+ fi
693
+ }
694
+
695
+ # Scans one subagent transcript for: the last assistant turn's timestamp (the
696
+ # moment its cache was last touched), the TTL its cache writes used, and its
697
+ # last tool call — "waiting" when that call has no result yet. Lines are parsed
698
+ # one by one with fromjson?, so the half-written line at the end of a live
699
+ # transcript is skipped instead of failing the whole scan.
700
+ scan_subagent_transcript() {
701
+ tail -n 400 "$1" 2>/dev/null | jq -Rrn '
702
+ [inputs | fromjson? | select(type == "object")] as $all
703
+ | [$all[] | select(.type == "assistant")] as $a
704
+ | ([$a[] | .message.usage.cache_creation // {}
705
+ | if (.ephemeral_1h_input_tokens // 0) > 0 then "1h"
706
+ elif (.ephemeral_5m_input_tokens // 0) > 0 then "5m"
707
+ else empty end] | last // "") as $ttl
708
+ | ([$a[] | .message.content[]? | select(.type == "tool_use")] | last) as $call
709
+ | ($call.name // "") as $tool
710
+ | ([$all[] | select(.type == "assistant" or .type == "user")]) as $turns
711
+ | ($turns | last) as $tail
712
+ | (if $tail.type == "assistant"
713
+ and ([$tail.message.content[]? | select(.type == "tool_use")] | length) > 0
714
+ then "waiting"
715
+ # A background call answers at once ("running in background"); the
716
+ # subagent may say a line, then idles until the job reports. Until a new
717
+ # user entry that is not a tool result (the job notification) arrives,
718
+ # it is still waiting — just not inside a call.
719
+ elif ($call.input.run_in_background == true)
720
+ and ([$turns | to_entries[]
721
+ | select(.value.type == "assistant"
722
+ and ([.value.message.content[]? | select(.type == "tool_use")] | length) > 0)
723
+ | .key] | last) as $at
724
+ | ($turns[($at + 1):]
725
+ | all(.type == "assistant"
726
+ or ([.message.content[]? | select(.type == "tool_result")] | length) > 0))
727
+ then "waiting-bg"
728
+ else "last" end) as $mode
729
+ | [($a | last | .timestamp // ""), $ttl, $tool, $mode]
730
+ | map(tostring | gsub("[[:cntrl:]]"; " "))
731
+ | join("\u001f")' 2>/dev/null
732
+ }
733
+
734
+ # Sets sa_last_ts / sa_ttl_label / sa_tool / sa_mode for one subagent. The scan
735
+ # result is kept per subagent in the private runtime dir, keyed on the
736
+ # transcript's mtime, so an unchanged transcript costs one stat per refresh
737
+ # instead of a tail + jq.
738
+ load_subagent_state() {
739
+ local aid="$1" dir="$2"
740
+ sa_last_ts="" sa_ttl_label="" sa_tool="" sa_mode=""
741
+
742
+ local transcript="" candidate
743
+ if [ -f "$dir/agent-$aid.jsonl" ]; then
744
+ transcript="$dir/agent-$aid.jsonl"
745
+ else
746
+ # Workflow-spawned agents live one level down: subagents/workflows/<run>/.
747
+ for candidate in "$dir"/workflows/*/"agent-$aid.jsonl"; do
748
+ [ -f "$candidate" ] && { transcript="$candidate"; break; }
749
+ done
750
+ fi
751
+ [ -n "$transcript" ] && [ -r "$transcript" ] || return 0
752
+
753
+ # mtime alone has 1s resolution, so a line appended within the second of the
754
+ # last scan would be missed: key on mtime and size together (one stat call).
755
+ local mtime
756
+ if [ "$_stat_gnu" -eq 1 ]; then
757
+ mtime=$(stat -c '%Y:%s' "$transcript" 2>/dev/null)
758
+ else
759
+ mtime=$(stat -f '%m:%z' "$transcript" 2>/dev/null)
760
+ fi
761
+ local state_file=""
762
+ if [ "$_runtime_private" = true ] && [ -n "$sa_session_key" ]; then
763
+ # The version tag retires every cached scan when the scan itself changes,
764
+ # so an updated script never serves a result the old one computed.
765
+ state_file="$_runtime_dir/sa$_sa_scan_version-$sa_session_key-$aid"
766
+ fi
767
+
768
+ local s_mtime="" s_ts="" s_ttl="" s_tool="" s_mode=""
769
+ if [ -n "$state_file" ] && [ -f "$state_file" ]; then
770
+ IFS="$_sa_sep" read -r s_mtime s_ts s_ttl s_tool s_mode < "$state_file"
771
+ fi
772
+
773
+ if [ -n "$mtime" ] && [ "$mtime" = "$s_mtime" ]; then
774
+ sa_last_ts="$s_ts" sa_ttl_label="$s_ttl" sa_tool="$s_tool" sa_mode="$s_mode"
775
+ return 0
776
+ fi
777
+
778
+ local iso ttl tool mode
779
+ IFS="$_sa_sep" read -r iso ttl tool mode < <(scan_subagent_transcript "$transcript")
780
+ [ -n "$iso" ] && sa_last_ts=$(epoch_from_iso "$iso")
781
+ # A turn that only read the cache reports no write TTL: keep the last known one.
782
+ sa_ttl_label="${ttl:-$s_ttl}"
783
+ sa_tool="$tool"
784
+ sa_mode="$mode"
785
+
786
+ if [ -n "$state_file" ]; then
787
+ printf '%s\n' "$mtime$_sa_sep$sa_last_ts$_sa_sep$sa_ttl_label$_sa_sep$sa_tool$_sa_sep$sa_mode" \
788
+ > "$state_file" 2>/dev/null
789
+ fi
790
+ }
791
+
792
+ subagent_cache_segment() {
793
+ # Same wording and gradient as the main line's cache segment. A finished
794
+ # subagent's countdown still matters (it can be resumed), but it never
795
+ # blinks: the alarm is reserved for a running subagent going cold.
796
+ local status="$1"
797
+ if [ -z "$sa_last_ts" ]; then
798
+ printf '%scache: --%s' "$COLOR_DARK_GRAY" "$COLOR_RESET"
799
+ return
800
+ fi
801
+ local label="${sa_ttl_label:-5m}" ttl=300
802
+ [ "$label" = "1h" ] && ttl=3600
803
+ local remaining=$((ttl - (now - sa_last_ts)))
804
+ local active=false
805
+ [ "$status" = "running" ] && active=true
806
+
807
+ if [ "$remaining" -gt 0 ]; then
808
+ local pct10=$((remaining * 10 / ttl)) fg
809
+ if [ "$pct10" -ge 8 ]; then fg="$COLOR_GREEN"
810
+ elif [ "$pct10" -ge 6 ]; then fg=$'\033[38;5;154m'
811
+ elif [ "$pct10" -ge 4 ]; then fg="$COLOR_YELLOW"
812
+ elif [ "$pct10" -ge 2 ]; then fg="$COLOR_ORANGE"
813
+ elif [ "$pct10" -ge 1 ] || [ "$active" = false ]; then fg="$COLOR_RED"
814
+ else fg="${COLOR_RED}${STYLE_BLINK}"
815
+ fi
816
+ printf '%s[%s] cache: %s%d:%02d HOT%s' "$COLOR_GRAY" "$label" "$fg" \
817
+ $((remaining / 60)) $((remaining % 60)) "$COLOR_RESET"
818
+ elif [ "$active" = true ]; then
819
+ printf '%s[%s] cache: %s%sCOLD%s' "$COLOR_GRAY" "$label" "$STYLE_BLINK" "$COLOR_RED" "$COLOR_RESET"
820
+ else
821
+ printf '%s[%s] cache: cold%s' "$COLOR_DARK_GRAY" "$label" "$COLOR_RESET"
822
+ fi
823
+ }
824
+
825
+ short_model() {
826
+ # claude-haiku-4-5-20251001 -> haiku 4.5, claude-opus-5-5[1m] -> opus 5.5,
827
+ # us.anthropic.claude-sonnet-5 -> sonnet 5. Anything else is shown as sent.
828
+ local m="${1##*claude-}"
829
+ m="${m%%\[*}"
830
+ [[ "$m" =~ ^(.*)-[0-9]{8}$ ]] && m="${BASH_REMATCH[1]}"
831
+ if [[ "$m" =~ ^([a-z]+)-([0-9]+)-([0-9]+)$ ]]; then m="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
832
+ elif [[ "$m" =~ ^([a-z]+)-([0-9]+)$ ]]; then m="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
833
+ fi
834
+ printf '%s' "$m"
835
+ }
836
+
837
+ status_glyph() {
838
+ case "$1" in
839
+ running) printf '%s●%s' $'\033[38;5;39m' "$COLOR_RESET" ;;
840
+ completed) printf '%s✓%s' "$COLOR_GREEN" "$COLOR_RESET" ;;
841
+ failed|error) printf '%s✗%s' "$COLOR_RED" "$COLOR_RESET" ;;
842
+ stopped|killed) printf '%s■%s' "$COLOR_GRAY" "$COLOR_RESET" ;;
843
+ *) printf '%s·%s' "$COLOR_GRAY" "$COLOR_RESET" ;;
844
+ esac
845
+ }
846
+
847
+ render_subagent_rows() {
848
+ # $1: the payload's jq extraction (header line, then one line per task).
849
+ local header rows=()
850
+ { IFS= read -r header; mapfile -t rows; } <<< "$1"
851
+
852
+ local session_id_in parent_transcript columns
853
+ IFS="$_sa_sep" read -r _ session_id_in parent_transcript columns <<< "$header"
854
+ [[ "$columns" =~ ^[0-9]+$ ]] || columns="${COLUMNS:-100}"
855
+ [[ "$columns" =~ ^[0-9]+$ ]] || columns=100
856
+
857
+ # Ids end up in file names: accept only a conservative alphabet.
858
+ local safe='^[A-Za-z0-9_-]+$'
859
+ sa_session_key=""
860
+ [[ "$session_id_in" =~ $safe ]] && sa_session_key="$session_id_in"
861
+ local subagents_dir=""
862
+ [ -n "$parent_transcript" ] && subagents_dir="${parent_transcript%.jsonl}/subagents"
863
+
864
+ _runtime_private=false
865
+ [ "$_runtime_dir" != "/tmp" ] && [ -O "$_runtime_dir" ] && _runtime_private=true
866
+
867
+ now=$(date +%s)
868
+ probe_utf8_locale
869
+
870
+ # Pass 1: build every row's segments, tracking each column's widest cell so
871
+ # the rows line up under each other.
872
+ local -a ids types glyphs models efforts elapsed ctx cache activity descs
873
+ local w_type=0 w_model=0 w_effort=0 w_elapsed=0 w_ctx=0 w_cache=0 w_act=0
874
+ local line id name type status desc start_ms tokens window model effort aid
875
+ for line in "${rows[@]}"; do
876
+ IFS="$_sa_sep" read -r id name type status desc start_ms tokens window model effort <<< "$line"
877
+ [ -z "$id" ] && continue
878
+
879
+ aid="${id#agent-}"
880
+ sa_last_ts="" sa_ttl_label="" sa_tool="" sa_mode=""
881
+ if [[ "$aid" =~ $safe ]] && [ -n "$subagents_dir" ]; then
882
+ load_subagent_state "$aid" "$subagents_dir"
883
+ fi
884
+ # Claude Code reports a subagent idling on its own background job as
885
+ # "completed": its turn ended. It resumes when the job reports, so its
886
+ # cache still matters — show it as running (activity, blinking COLD).
887
+ [ "$sa_mode" = "waiting-bg" ] && status="running"
888
+
889
+ local t="${type:-$name}"
890
+ t=$(fit "${t:-agent}" 24)
891
+
892
+ # A finished subagent's clock stops at its last turn, not at "now".
893
+ local el="" until="$now"
894
+ [ "$status" != "running" ] && [ -n "$sa_last_ts" ] && until="$sa_last_ts"
895
+ if [[ "$start_ms" =~ ^[0-9]+$ ]] && [ "$start_ms" -gt 0 ]; then
896
+ local secs=$((until - start_ms / 1000))
897
+ [ "$secs" -lt 0 ] && secs=0
898
+ el=$(printf '%s%s%s' "$COLOR_GRAY" "$(fmt_elapsed "$secs")" "$COLOR_RESET")
899
+ fi
900
+
901
+ local cx=""
902
+ if [[ "$tokens" =~ ^[0-9]+$ ]] && [ "$tokens" -gt 0 ]; then
903
+ if [[ "$window" =~ ^[0-9]+$ ]] && [ "$window" -gt 0 ]; then
904
+ local pct=$((tokens * 100 / window)) cc=$'\033[01;32m'
905
+ if [ "$pct" -ge 80 ]; then cc=$'\033[01;31m'
906
+ elif [ "$pct" -ge 50 ]; then cc=$'\033[01;33m'
907
+ fi
908
+ cx=$(printf '%sctx: %s%s (%d%%)%s' "$COLOR_GRAY" "$cc" "$(fmt_k "$tokens")" "$pct" "$COLOR_RESET")
909
+ else
910
+ cx=$(printf '%sctx: %s%s' "$COLOR_GRAY" "$(fmt_k "$tokens")" "$COLOR_RESET")
911
+ fi
912
+ fi
913
+
914
+ local act=""
915
+ if [ -n "$sa_tool" ] && [ "$status" = "running" ]; then
916
+ if [ "$sa_mode" = "waiting" ]; then
917
+ act=$(printf '%swaiting: %s%s' "$COLOR_YELLOW" "$(fit "$sa_tool" 16)" "$COLOR_RESET")
918
+ elif [ "$sa_mode" = "waiting-bg" ]; then
919
+ act=$(printf '%swaiting: %s (bg)%s' "$COLOR_YELLOW" "$(fit "$sa_tool" 16)" "$COLOR_RESET")
920
+ else
921
+ act=$(printf '%slast: %s%s' "$COLOR_DARK_GRAY" "$(fit "$sa_tool" 16)" "$COLOR_RESET")
922
+ fi
923
+ fi
924
+
925
+ local cs; cs=$(subagent_cache_segment "$status")
926
+
927
+ # Model is absent until Claude Code resolves it; effort is absent when the
928
+ # subagent inherits the session's. Both stay blank rather than guessed.
929
+ local md="" ef=""
930
+ [ -n "$model" ] && md=$(printf '%s%s%s' "$COLOR_CYAN" "$(fit "$(short_model "$model")" 14)" "$COLOR_RESET")
931
+ if [[ "$effort" =~ ^[0-9]+$ ]]; then
932
+ # A numeric effort is a thinking-token budget; "tok" keeps it from
933
+ # reading as a token count.
934
+ local budget; budget=$(fmt_k "$effort")
935
+ ef=$(printf '%s%s tok%s' "$COLOR_GRAY" "${budget/.0k/k}" "$COLOR_RESET")
936
+ elif [ -n "$effort" ]; then
937
+ ef=$(printf '%s%s%s' "$COLOR_GRAY" "$(fit "$effort" 8)" "$COLOR_RESET")
938
+ fi
939
+
940
+ ids+=("$id"); types+=("$t"); glyphs+=("$(status_glyph "$status")")
941
+ models+=("$md"); efforts+=("$ef")
942
+ elapsed+=("$el"); ctx+=("$cx"); cache+=("$cs"); activity+=("$act"); descs+=("$desc")
943
+
944
+ local l
945
+ l=$(vis_len "$t"); [ "$l" -gt "$w_type" ] && w_type=$l
946
+ l=$(vis_len "$md"); [ "$l" -gt "$w_model" ] && w_model=$l
947
+ l=$(vis_len "$ef"); [ "$l" -gt "$w_effort" ] && w_effort=$l
948
+ l=$(vis_len "$el"); [ "$l" -gt "$w_elapsed" ] && w_elapsed=$l
949
+ l=$(vis_len "$cx"); [ "$l" -gt "$w_ctx" ] && w_ctx=$l
950
+ l=$(vis_len "$cs"); [ "$l" -gt "$w_cache" ] && w_cache=$l
951
+ l=$(vis_len "$act"); [ "$l" -gt "$w_act" ] && w_act=$l
952
+ done
953
+
954
+ # Pass 2: fit to the panel width. Type, status and cache always stay (the
955
+ # cache countdown is the point of the row); the type name shrinks when even
956
+ # those three don't fit. Optional columns are admitted whole, most useful
957
+ # first — activity (what a cold cache is waiting on), elapsed, context,
958
+ # model, effort — so every row keeps the same layout, and the description
959
+ # takes what is left. A column no row has a value for takes no space.
960
+ local i
961
+ local max_type=$((columns - 5 - w_cache))
962
+ [ "$max_type" -lt 4 ] && max_type=4
963
+ if [ "$w_type" -gt "$max_type" ]; then
964
+ w_type=$max_type
965
+ for i in "${!types[@]}"; do types[i]=$(fit "${types[i]}" "$w_type"); done
966
+ fi
967
+ local fixed=$((w_type + 2 + 1 + 2 + w_cache))
968
+ local show_el=false show_ctx=false show_act=false show_model=false show_effort=false
969
+ [ "$w_act" -gt 0 ] && [ $((fixed + 2 + w_act)) -le "$columns" ] && { show_act=true; fixed=$((fixed + 2 + w_act)); }
970
+ [ "$w_elapsed" -gt 0 ] && [ $((fixed + 2 + w_elapsed)) -le "$columns" ] && { show_el=true; fixed=$((fixed + 2 + w_elapsed)); }
971
+ [ "$w_ctx" -gt 0 ] && [ $((fixed + 2 + w_ctx)) -le "$columns" ] && { show_ctx=true; fixed=$((fixed + 2 + w_ctx)); }
972
+ [ "$w_model" -gt 0 ] && [ $((fixed + 2 + w_model)) -le "$columns" ] && { show_model=true; fixed=$((fixed + 2 + w_model)); }
973
+ [ "$w_effort" -gt 0 ] && [ $((fixed + 2 + w_effort)) -le "$columns" ] && { show_effort=true; fixed=$((fixed + 2 + w_effort)); }
974
+ local desc_room=$((columns - fixed - 2))
975
+
976
+ local out content
977
+ out=""
978
+ for i in "${!ids[@]}"; do
979
+ content="$(printf '%s' $'\033[38;5;141m')$(pad "${types[i]}" "$w_type")${COLOR_RESET} ${glyphs[i]}"
980
+ [ "$show_model" = true ] && content+=" $(pad "${models[i]}" "$w_model")"
981
+ [ "$show_effort" = true ] && content+=" $(pad "${efforts[i]}" "$w_effort")"
982
+ [ "$show_el" = true ] && content+=" $(pad "${elapsed[i]}" "$w_elapsed")"
983
+ [ "$show_ctx" = true ] && content+=" $(pad "${ctx[i]}" "$w_ctx")"
984
+ content+=" $(pad "${cache[i]}" "$w_cache")"
985
+ [ "$show_act" = true ] && content+=" $(pad "${activity[i]}" "$w_act")"
986
+ if [ "$desc_room" -ge 8 ] && [ -n "${descs[i]}" ]; then
987
+ content+=" ${COLOR_GRAY}$(fit "${descs[i]}" "$desc_room")${COLOR_RESET}"
988
+ fi
989
+ # Trailing padding is noise in a row the host renders as-is.
990
+ while [[ "$content" == *' ' ]]; do content="${content% }"; done
991
+ out+="${ids[i]}$_sa_sep$content"$'\n'
992
+ done
993
+
994
+ # One jq call encodes every row as JSON (escapes, ANSI and all).
995
+ [ -n "$out" ] && printf '%s' "$out" \
996
+ | jq -Rc 'split("\u001f") | {id: .[0], content: (.[1] // "")}' 2>/dev/null
997
+ }
998
+
999
+ # Runs the subagent renderer when the payload carries a `tasks` array. The cheap
1000
+ # substring test keeps the main statusline at a single jq call per refresh; the
1001
+ # jq below is the authoritative check, so a main payload that merely mentions
1002
+ # "tasks" in some string still falls through to the main render.
1003
+ maybe_render_subagents() {
1004
+ [[ "$_input" == *'"tasks"'* ]] || return 1
1005
+ local extracted
1006
+ extracted=$(printf '%s' "$_input" | jq -r '
1007
+ def clean: tostring | gsub("[[:cntrl:]]"; " ");
1008
+ if type == "object" and (.tasks | type) == "array" then
1009
+ ([ "subagents", (.session_id // ""), (.transcript_path // ""), (.columns // "") ]
1010
+ | map(clean) | join("\u001f")),
1011
+ (.tasks[] | select(type == "object")
1012
+ | [ (.id // ""), (.name // ""), (.type // ""), (.status // ""),
1013
+ (.description // .label // ""), (.startTime // 0),
1014
+ (.tokenCount // 0), (.contextWindowSize // 0),
1015
+ (.model // ""), (.effort // "") ]
1016
+ | map(clean) | join("\u001f"))
1017
+ else empty end' 2>/dev/null)
1018
+ [[ "$extracted" == subagents* ]] || return 1
1019
+ render_subagent_rows "$extracted"
1020
+ return 0
1021
+ }
1022
+
586
1023
  # --- Orchestrated Execution Flow ---
1024
+ _input=$(cat)
1025
+ maybe_render_subagents && exit 0
587
1026
  parse_and_prepare_paths
588
1027
  compute_cache_timer
589
1028
  compute_context_info
1029
+ compute_effort_info
590
1030
  compute_rate_limits
591
1031
  compute_turn_breakdown
592
1032
  compute_update_notice