@inbrace-tech/tokenline 1.4.0 → 1.5.1

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), or under `$TMPDIR` / `/tmp` when that is unset or doesn't exist (WSL2 without systemd, containers, macOS); 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.1",
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
@@ -89,20 +91,33 @@ file_mtime() {
89
91
  # Per-turn timestamp / TTL are cached between the 1s refreshes. Prefer a
90
92
  # per-user dir (0700) under XDG_RUNTIME_DIR: it avoids predictable, world-
91
93
  # readable paths in shared /tmp (and the symlink/collision risks they carry),
92
- # and is tmpfs cleared on logout — so no orphan-file cleanup is needed. Falls
93
- # back to /tmp when XDG_RUNTIME_DIR is unset.
94
- _runtime_dir="${XDG_RUNTIME_DIR:-/tmp}/tokenline-${UID:-$(id -u)}"
95
- mkdir -p "$_runtime_dir" 2>/dev/null && chmod 700 "$_runtime_dir" 2>/dev/null
96
- [ -d "$_runtime_dir" ] || _runtime_dir="/tmp"
94
+ # and is tmpfs cleared on logout — so no orphan-file cleanup is needed.
95
+ # XDG_RUNTIME_DIR can be unset, or set to a dir that doesn't exist (WSL2
96
+ # without systemd, containers, su/sudo shells), so the candidates are tried in
97
+ # order: $XDG_RUNTIME_DIR/tokenline-<uid> when that parent is a dir we own, then
98
+ # ${TMPDIR:-/tmp}/tokenline-<uid>. Bare /tmp is the last resort, and every
99
+ # feature that needs a private dir (update notice, subagent state) stays off there.
100
+ _use_runtime_dir() {
101
+ # Accepts $1 only as a real directory (not a symlink) owned by this user,
102
+ # creating it 0700 when missing. No -p: the parent must already exist.
103
+ local d="$1"
104
+ [ -d "$d" ] || mkdir -m 700 "$d" 2>/dev/null
105
+ [ -d "$d" ] && [ ! -L "$d" ] && [ -O "$d" ] || return 1
106
+ chmod 700 "$d" 2>/dev/null
107
+ _runtime_dir="$d"
108
+ }
109
+ _runtime_dir="/tmp"
110
+ _uid="${UID:-$(id -u)}"
111
+ if ! { [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && [ -O "$XDG_RUNTIME_DIR" ] \
112
+ && _use_runtime_dir "${XDG_RUNTIME_DIR%/}/tokenline-$_uid"; }; then
113
+ _tmp_base="${TMPDIR:-/tmp}"
114
+ _use_runtime_dir "${_tmp_base%/}/tokenline-$_uid"
115
+ fi
97
116
 
98
117
  # --- 1. Parse JSON Standard Input and Prepare State Variables ---
99
118
  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
119
  # Single jq execution to parse all required fields into an array at once (reduces forks)
105
- mapfile -t _f < <(printf '%s' "$input" | jq -r '
120
+ mapfile -t _f < <(printf '%s' "$_input" | jq -r '
106
121
  (.model.display_name // ""),
107
122
  (.context_window.used_percentage // ""),
108
123
  (.context_window.context_window_size // ""),
@@ -116,7 +131,8 @@ parse_and_prepare_paths() {
116
131
  (.context_window.current_usage.output_tokens // 0),
117
132
  (.context_window.current_usage.cache_creation_input_tokens // 0),
118
133
  (.context_window.current_usage.cache_read_input_tokens // 0),
119
- (.model.id // "")' 2>/dev/null)
134
+ (.model.id // ""),
135
+ (.effort.level // "")' 2>/dev/null)
120
136
 
121
137
  # Malformed or empty stdin: jq emits nothing, so the array is empty. Degrade to
122
138
  # a silent no-op render rather than leaking parse errors or rendering garbage —
@@ -137,6 +153,7 @@ parse_and_prepare_paths() {
137
153
  cur_cwrite="${_f[11]}"
138
154
  cur_cread="${_f[12]}"
139
155
  model_id="${_f[13]}"
156
+ effort_level="${_f[14]}"
140
157
 
141
158
  # Computed: total input-only tokens used in the current context window
142
159
  tokens_used=$((cur_input + cur_cwrite + cur_cread))
@@ -163,16 +180,27 @@ parse_and_prepare_paths() {
163
180
  is_gemini=true
164
181
  fi
165
182
 
183
+ # Gemini 4 Argon bills cache hits at 0.05x base input and output at 5x (the same
184
+ # ratios at the introductory and the regular price). Match the API id
185
+ # (gemini-4-argon) or a display name containing Argon; other Gemini models keep
186
+ # 0.25x / 4x.
187
+ is_gemini_argon=false
188
+ if [ "$is_gemini" = true ] && { [[ "${model_id,,}" == *argon* ]] || [[ "${model,,}" == *argon* ]]; }; then
189
+ is_gemini_argon=true
190
+ fi
191
+
166
192
  # 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.
193
+ # and Claude Opus 5.5 / Sonnet 5.5 (0.05x). Claude Haiku 5.5 is confirmed at 0.1x in
194
+ # both of its prompt-length price tiers. Match the API id (claude-fable-5-1[1m]) or
195
+ # the display name (Fable 5.1) so either field alone is enough. Pin the full version:
196
+ # claude-opus-5 / claude-sonnet-5 must stay 0.1x, and a later version's price is
197
+ # confirmed, not guessed.
170
198
  claude_read_mult="0.1"
171
199
  local fable_re='(fable|mythos)[- ]5[-.]1([^0-9]|$)'
172
- local opus_re='opus[- ]5[-.]5([^0-9]|$)'
200
+ local half_re='(opus|sonnet)[- ]5[-.]5([^0-9]|$)'
173
201
  if [[ "${model_id,,}" =~ $fable_re ]] || [[ "${model,,}" =~ $fable_re ]]; then
174
202
  claude_read_mult="0.025"
175
- elif [[ "${model_id,,}" =~ $opus_re ]] || [[ "${model,,}" =~ $opus_re ]]; then
203
+ elif [[ "${model_id,,}" =~ $half_re ]] || [[ "${model,,}" =~ $half_re ]]; then
176
204
  claude_read_mult="0.05"
177
205
  fi
178
206
 
@@ -333,6 +361,24 @@ compute_context_info() {
333
361
  fi
334
362
  }
335
363
 
364
+ # --- 4b. Reasoning Effort ---
365
+ compute_effort_info() {
366
+ # Claude Code sends the live session effort (it follows a mid-session /effort)
367
+ # and omits it for models without the effort parameter. Coloured by level so
368
+ # an effort left raised after a hard step stands out.
369
+ effort_info=""
370
+ [[ "$effort_level" =~ ^[A-Za-z0-9_-]+$ ]] || return 0
371
+ local effort_color
372
+ case "$effort_level" in
373
+ low|medium) effort_color="$COLOR_GREEN" ;;
374
+ high) effort_color="$COLOR_YELLOW" ;;
375
+ xhigh) effort_color="$COLOR_ORANGE" ;;
376
+ max) effort_color="$COLOR_RED" ;;
377
+ *) effort_color="$COLOR_GRAY" ;;
378
+ esac
379
+ effort_info=$(printf '%seffort: %s%s%s' "$COLOR_GRAY" "$effort_color" "$effort_level" "$COLOR_RESET")
380
+ }
381
+
336
382
  # --- 5. Rate Limit Windows Heuristics and Bars ---
337
383
  rl_color_for_pct() {
338
384
  local pct=$1
@@ -435,6 +481,10 @@ compute_turn_breakdown() {
435
481
  write_mult="1.0"
436
482
  input_mult="1"
437
483
  output_mult="4"
484
+ if [ "$is_gemini_argon" = true ]; then
485
+ read_mult="0.05"
486
+ output_mult="5"
487
+ fi
438
488
  else
439
489
  read_mult="$claude_read_mult"
440
490
  write_mult="1.25"
@@ -559,6 +609,8 @@ render_statusline() {
559
609
  display_header="${model}"
560
610
  fi
561
611
 
612
+ [ -n "$effort_info" ] && display_header="$display_header · $effort_info"
613
+
562
614
  local line1="$display_header"
563
615
  [ -n "$ctx_info" ] && line1="$line1 | $ctx_info"
564
616
  [ -n "$cache_info" ] && line1="$line1 | $cache_info"
@@ -583,10 +635,415 @@ render_statusline() {
583
635
  fi
584
636
  }
585
637
 
638
+ # --- 9. Subagent Rows (subagentStatusLine) ---
639
+ # Wired as `subagentStatusLine`, Claude Code runs this same script for its agent
640
+ # panel: stdin is then {session_id, transcript_path, columns, tasks: [...]} and
641
+ # stdout is one {"id","content"} JSON line per row. Each row carries that
642
+ # subagent's own prompt-cache countdown, read from its own transcript.
643
+ # Subagent requests write the 5-minute cache, so a subagent that waits on a long
644
+ # tool call (a test suite, a build) or on a human answer goes COLD quietly, and
645
+ # its next turn pays a full cache write instead of a cache read.
646
+
647
+ _sa_sep=$'\x1f'
648
+ # Bump when scan_subagent_transcript's output changes (see load_subagent_state).
649
+ _sa_scan_version=3
650
+
651
+ # LC_ALL=C (top of file) makes ${#s} count bytes, so 'é' or '…' would throw the
652
+ # columns off. Width math switches to a UTF-8 locale when one exists; without
653
+ # one it falls back to bytes, which only over-counts (rows get shorter, never
654
+ # wider than the panel).
655
+ _utf8_locale=""
656
+ probe_utf8_locale() {
657
+ local loc
658
+ for loc in C.UTF-8 C.utf8 en_US.UTF-8 en_US.utf8 UTF-8; do
659
+ if { _utf8_len_probe "$loc"; } 2>/dev/null; then
660
+ _utf8_locale="$loc"
661
+ return
662
+ fi
663
+ done
664
+ }
665
+ _utf8_len_probe() {
666
+ local LC_ALL="$1" s='é'
667
+ [ "${#s}" -eq 1 ]
668
+ }
669
+
670
+ strip_ansi() {
671
+ # SGR sequences only: the only escapes this script ever emits.
672
+ local s="$1" re=$'\033''\[[0-9;]*m'
673
+ while [[ "$s" =~ $re ]]; do s="${s/"${BASH_REMATCH[0]}"/}"; done
674
+ printf '%s' "$s"
675
+ }
676
+
677
+ vis_len() {
678
+ # Visible width of a (possibly colored) string, in characters.
679
+ local plain; plain=$(strip_ansi "$1")
680
+ { local LC_ALL="${_utf8_locale:-C}"; } 2>/dev/null
681
+ printf '%s' "${#plain}"
682
+ }
683
+
684
+ fit() {
685
+ # Plain text cut to $2 characters, with a trailing ellipsis when cut.
686
+ local s="$1" n="$2"
687
+ { local LC_ALL="${_utf8_locale:-C}"; } 2>/dev/null
688
+ if [ "$n" -le 0 ]; then return; fi
689
+ if [ "${#s}" -le "$n" ]; then printf '%s' "$s"; return; fi
690
+ if [ -n "$_utf8_locale" ]; then
691
+ printf '%s…' "${s:0:n-1}"
692
+ else
693
+ printf '%s' "${s:0:n}"
694
+ fi
695
+ }
696
+
697
+ pad() {
698
+ # Right-pads a colored string to $2 visible characters.
699
+ local s="$1" w="$2" len
700
+ len=$(vis_len "$s")
701
+ printf '%s%*s' "$s" $((w > len ? w - len : 0)) ''
702
+ }
703
+
704
+ fmt_elapsed() {
705
+ local s=${1:-0}
706
+ if [ "$s" -lt 60 ]; then printf '%ds' "$s"
707
+ elif [ "$s" -lt 3600 ]; then printf '%dm%02ds' $((s / 60)) $((s % 60))
708
+ else printf '%dh%02dm' $((s / 3600)) $(((s % 3600) / 60))
709
+ fi
710
+ }
711
+
712
+ # Scans one subagent transcript for: the last assistant turn's timestamp (the
713
+ # moment its cache was last touched), the TTL its cache writes used, and its
714
+ # last tool call — "waiting" when that call has no result yet. Lines are parsed
715
+ # one by one with fromjson?, so the half-written line at the end of a live
716
+ # transcript is skipped instead of failing the whole scan.
717
+ scan_subagent_transcript() {
718
+ tail -n 400 "$1" 2>/dev/null | jq -Rrn '
719
+ [inputs | fromjson? | select(type == "object")] as $all
720
+ | [$all[] | select(.type == "assistant")] as $a
721
+ | ([$a[] | .message.usage.cache_creation // {}
722
+ | if (.ephemeral_1h_input_tokens // 0) > 0 then "1h"
723
+ elif (.ephemeral_5m_input_tokens // 0) > 0 then "5m"
724
+ else empty end] | last // "") as $ttl
725
+ | ([$a[] | .message.content[]? | select(.type == "tool_use")] | last) as $call
726
+ | ($call.name // "") as $tool
727
+ | ([$all[] | select(.type == "assistant" or .type == "user")]) as $turns
728
+ | ($turns | last) as $tail
729
+ | (if $tail.type == "assistant"
730
+ and ([$tail.message.content[]? | select(.type == "tool_use")] | length) > 0
731
+ then "waiting"
732
+ # A background call answers at once ("running in background"); the
733
+ # subagent may say a line, then idles until the job reports. Until a new
734
+ # user entry that is not a tool result (the job notification) arrives,
735
+ # it is still waiting — just not inside a call.
736
+ elif ($call.input.run_in_background == true)
737
+ and ([$turns | to_entries[]
738
+ | select(.value.type == "assistant"
739
+ and ([.value.message.content[]? | select(.type == "tool_use")] | length) > 0)
740
+ | .key] | last) as $at
741
+ | ($turns[($at + 1):]
742
+ | all(.type == "assistant"
743
+ or ([.message.content[]? | select(.type == "tool_result")] | length) > 0))
744
+ then "waiting-bg"
745
+ else "last" end) as $mode
746
+ | [($a | last | .timestamp // ""), $ttl, $tool, $mode]
747
+ | map(tostring | gsub("[[:cntrl:]]"; " "))
748
+ | join("\u001f")' 2>/dev/null
749
+ }
750
+
751
+ # Sets sa_last_ts / sa_ttl_label / sa_tool / sa_mode for one subagent. The scan
752
+ # result is kept per subagent in the private runtime dir, keyed on the
753
+ # transcript's mtime, so an unchanged transcript costs one stat per refresh
754
+ # instead of a tail + jq.
755
+ load_subagent_state() {
756
+ local aid="$1" dir="$2"
757
+ sa_last_ts="" sa_ttl_label="" sa_tool="" sa_mode=""
758
+
759
+ local transcript="" candidate
760
+ if [ -f "$dir/agent-$aid.jsonl" ]; then
761
+ transcript="$dir/agent-$aid.jsonl"
762
+ else
763
+ # Workflow-spawned agents live one level down: subagents/workflows/<run>/.
764
+ for candidate in "$dir"/workflows/*/"agent-$aid.jsonl"; do
765
+ [ -f "$candidate" ] && { transcript="$candidate"; break; }
766
+ done
767
+ fi
768
+ [ -n "$transcript" ] && [ -r "$transcript" ] || return 0
769
+
770
+ # mtime alone has 1s resolution, so a line appended within the second of the
771
+ # last scan would be missed: key on mtime and size together (one stat call).
772
+ local mtime
773
+ if [ "$_stat_gnu" -eq 1 ]; then
774
+ mtime=$(stat -c '%Y:%s' "$transcript" 2>/dev/null)
775
+ else
776
+ mtime=$(stat -f '%m:%z' "$transcript" 2>/dev/null)
777
+ fi
778
+ local state_file=""
779
+ if [ "$_runtime_private" = true ] && [ -n "$sa_session_key" ]; then
780
+ # The version tag retires every cached scan when the scan itself changes,
781
+ # so an updated script never serves a result the old one computed.
782
+ state_file="$_runtime_dir/sa$_sa_scan_version-$sa_session_key-$aid"
783
+ fi
784
+
785
+ local s_mtime="" s_ts="" s_ttl="" s_tool="" s_mode=""
786
+ if [ -n "$state_file" ] && [ -f "$state_file" ]; then
787
+ IFS="$_sa_sep" read -r s_mtime s_ts s_ttl s_tool s_mode < "$state_file"
788
+ fi
789
+
790
+ if [ -n "$mtime" ] && [ "$mtime" = "$s_mtime" ]; then
791
+ sa_last_ts="$s_ts" sa_ttl_label="$s_ttl" sa_tool="$s_tool" sa_mode="$s_mode"
792
+ return 0
793
+ fi
794
+
795
+ local iso ttl tool mode
796
+ IFS="$_sa_sep" read -r iso ttl tool mode < <(scan_subagent_transcript "$transcript")
797
+ [ -n "$iso" ] && sa_last_ts=$(epoch_from_iso "$iso")
798
+ # A turn that only read the cache reports no write TTL: keep the last known one.
799
+ sa_ttl_label="${ttl:-$s_ttl}"
800
+ sa_tool="$tool"
801
+ sa_mode="$mode"
802
+
803
+ if [ -n "$state_file" ]; then
804
+ printf '%s\n' "$mtime$_sa_sep$sa_last_ts$_sa_sep$sa_ttl_label$_sa_sep$sa_tool$_sa_sep$sa_mode" \
805
+ > "$state_file" 2>/dev/null
806
+ fi
807
+ }
808
+
809
+ subagent_cache_segment() {
810
+ # Same wording and gradient as the main line's cache segment. A finished
811
+ # subagent's countdown still matters (it can be resumed), but it never
812
+ # blinks: the alarm is reserved for a running subagent going cold.
813
+ local status="$1"
814
+ if [ -z "$sa_last_ts" ]; then
815
+ printf '%scache: --%s' "$COLOR_DARK_GRAY" "$COLOR_RESET"
816
+ return
817
+ fi
818
+ local label="${sa_ttl_label:-5m}" ttl=300
819
+ [ "$label" = "1h" ] && ttl=3600
820
+ local remaining=$((ttl - (now - sa_last_ts)))
821
+ local active=false
822
+ [ "$status" = "running" ] && active=true
823
+
824
+ if [ "$remaining" -gt 0 ]; then
825
+ local pct10=$((remaining * 10 / ttl)) fg
826
+ if [ "$pct10" -ge 8 ]; then fg="$COLOR_GREEN"
827
+ elif [ "$pct10" -ge 6 ]; then fg=$'\033[38;5;154m'
828
+ elif [ "$pct10" -ge 4 ]; then fg="$COLOR_YELLOW"
829
+ elif [ "$pct10" -ge 2 ]; then fg="$COLOR_ORANGE"
830
+ elif [ "$pct10" -ge 1 ] || [ "$active" = false ]; then fg="$COLOR_RED"
831
+ else fg="${COLOR_RED}${STYLE_BLINK}"
832
+ fi
833
+ printf '%s[%s] cache: %s%d:%02d HOT%s' "$COLOR_GRAY" "$label" "$fg" \
834
+ $((remaining / 60)) $((remaining % 60)) "$COLOR_RESET"
835
+ elif [ "$active" = true ]; then
836
+ printf '%s[%s] cache: %s%sCOLD%s' "$COLOR_GRAY" "$label" "$STYLE_BLINK" "$COLOR_RED" "$COLOR_RESET"
837
+ else
838
+ printf '%s[%s] cache: cold%s' "$COLOR_DARK_GRAY" "$label" "$COLOR_RESET"
839
+ fi
840
+ }
841
+
842
+ short_model() {
843
+ # claude-haiku-4-5-20251001 -> haiku 4.5, claude-opus-5-5[1m] -> opus 5.5,
844
+ # us.anthropic.claude-sonnet-5 -> sonnet 5. Anything else is shown as sent.
845
+ local m="${1##*claude-}"
846
+ m="${m%%\[*}"
847
+ [[ "$m" =~ ^(.*)-[0-9]{8}$ ]] && m="${BASH_REMATCH[1]}"
848
+ if [[ "$m" =~ ^([a-z]+)-([0-9]+)-([0-9]+)$ ]]; then m="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
849
+ elif [[ "$m" =~ ^([a-z]+)-([0-9]+)$ ]]; then m="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
850
+ fi
851
+ printf '%s' "$m"
852
+ }
853
+
854
+ status_glyph() {
855
+ case "$1" in
856
+ running) printf '%s●%s' $'\033[38;5;39m' "$COLOR_RESET" ;;
857
+ completed) printf '%s✓%s' "$COLOR_GREEN" "$COLOR_RESET" ;;
858
+ failed|error) printf '%s✗%s' "$COLOR_RED" "$COLOR_RESET" ;;
859
+ stopped|killed) printf '%s■%s' "$COLOR_GRAY" "$COLOR_RESET" ;;
860
+ *) printf '%s·%s' "$COLOR_GRAY" "$COLOR_RESET" ;;
861
+ esac
862
+ }
863
+
864
+ render_subagent_rows() {
865
+ # $1: the payload's jq extraction (header line, then one line per task).
866
+ local header rows=()
867
+ { IFS= read -r header; mapfile -t rows; } <<< "$1"
868
+
869
+ local session_id_in parent_transcript columns
870
+ IFS="$_sa_sep" read -r _ session_id_in parent_transcript columns <<< "$header"
871
+ [[ "$columns" =~ ^[0-9]+$ ]] || columns="${COLUMNS:-100}"
872
+ [[ "$columns" =~ ^[0-9]+$ ]] || columns=100
873
+
874
+ # Ids end up in file names: accept only a conservative alphabet.
875
+ local safe='^[A-Za-z0-9_-]+$'
876
+ sa_session_key=""
877
+ [[ "$session_id_in" =~ $safe ]] && sa_session_key="$session_id_in"
878
+ local subagents_dir=""
879
+ [ -n "$parent_transcript" ] && subagents_dir="${parent_transcript%.jsonl}/subagents"
880
+
881
+ _runtime_private=false
882
+ [ "$_runtime_dir" != "/tmp" ] && [ -O "$_runtime_dir" ] && _runtime_private=true
883
+
884
+ now=$(date +%s)
885
+ probe_utf8_locale
886
+
887
+ # Pass 1: build every row's segments, tracking each column's widest cell so
888
+ # the rows line up under each other.
889
+ local -a ids types glyphs models efforts elapsed ctx cache activity descs
890
+ local w_type=0 w_model=0 w_effort=0 w_elapsed=0 w_ctx=0 w_cache=0 w_act=0
891
+ local line id name type status desc start_ms tokens window model effort aid
892
+ for line in "${rows[@]}"; do
893
+ IFS="$_sa_sep" read -r id name type status desc start_ms tokens window model effort <<< "$line"
894
+ [ -z "$id" ] && continue
895
+
896
+ aid="${id#agent-}"
897
+ sa_last_ts="" sa_ttl_label="" sa_tool="" sa_mode=""
898
+ if [[ "$aid" =~ $safe ]] && [ -n "$subagents_dir" ]; then
899
+ load_subagent_state "$aid" "$subagents_dir"
900
+ fi
901
+ # Claude Code reports a subagent idling on its own background job as
902
+ # "completed": its turn ended. It resumes when the job reports, so its
903
+ # cache still matters — show it as running (activity, blinking COLD).
904
+ [ "$sa_mode" = "waiting-bg" ] && status="running"
905
+
906
+ local t="${type:-$name}"
907
+ t=$(fit "${t:-agent}" 24)
908
+
909
+ # A finished subagent's clock stops at its last turn, not at "now".
910
+ local el="" until="$now"
911
+ [ "$status" != "running" ] && [ -n "$sa_last_ts" ] && until="$sa_last_ts"
912
+ if [[ "$start_ms" =~ ^[0-9]+$ ]] && [ "$start_ms" -gt 0 ]; then
913
+ local secs=$((until - start_ms / 1000))
914
+ [ "$secs" -lt 0 ] && secs=0
915
+ el=$(printf '%s%s%s' "$COLOR_GRAY" "$(fmt_elapsed "$secs")" "$COLOR_RESET")
916
+ fi
917
+
918
+ local cx=""
919
+ if [[ "$tokens" =~ ^[0-9]+$ ]] && [ "$tokens" -gt 0 ]; then
920
+ if [[ "$window" =~ ^[0-9]+$ ]] && [ "$window" -gt 0 ]; then
921
+ local pct=$((tokens * 100 / window)) cc=$'\033[01;32m'
922
+ if [ "$pct" -ge 80 ]; then cc=$'\033[01;31m'
923
+ elif [ "$pct" -ge 50 ]; then cc=$'\033[01;33m'
924
+ fi
925
+ cx=$(printf '%sctx: %s%s (%d%%)%s' "$COLOR_GRAY" "$cc" "$(fmt_k "$tokens")" "$pct" "$COLOR_RESET")
926
+ else
927
+ cx=$(printf '%sctx: %s%s' "$COLOR_GRAY" "$(fmt_k "$tokens")" "$COLOR_RESET")
928
+ fi
929
+ fi
930
+
931
+ local act=""
932
+ if [ -n "$sa_tool" ] && [ "$status" = "running" ]; then
933
+ if [ "$sa_mode" = "waiting" ]; then
934
+ act=$(printf '%swaiting: %s%s' "$COLOR_YELLOW" "$(fit "$sa_tool" 16)" "$COLOR_RESET")
935
+ elif [ "$sa_mode" = "waiting-bg" ]; then
936
+ act=$(printf '%swaiting: %s (bg)%s' "$COLOR_YELLOW" "$(fit "$sa_tool" 16)" "$COLOR_RESET")
937
+ else
938
+ act=$(printf '%slast: %s%s' "$COLOR_DARK_GRAY" "$(fit "$sa_tool" 16)" "$COLOR_RESET")
939
+ fi
940
+ fi
941
+
942
+ local cs; cs=$(subagent_cache_segment "$status")
943
+
944
+ # Model is absent until Claude Code resolves it; effort is absent when the
945
+ # subagent inherits the session's. Both stay blank rather than guessed.
946
+ local md="" ef=""
947
+ [ -n "$model" ] && md=$(printf '%s%s%s' "$COLOR_CYAN" "$(fit "$(short_model "$model")" 14)" "$COLOR_RESET")
948
+ if [[ "$effort" =~ ^[0-9]+$ ]]; then
949
+ # A numeric effort is a thinking-token budget; "tok" keeps it from
950
+ # reading as a token count.
951
+ local budget; budget=$(fmt_k "$effort")
952
+ ef=$(printf '%s%s tok%s' "$COLOR_GRAY" "${budget/.0k/k}" "$COLOR_RESET")
953
+ elif [ -n "$effort" ]; then
954
+ ef=$(printf '%s%s%s' "$COLOR_GRAY" "$(fit "$effort" 8)" "$COLOR_RESET")
955
+ fi
956
+
957
+ ids+=("$id"); types+=("$t"); glyphs+=("$(status_glyph "$status")")
958
+ models+=("$md"); efforts+=("$ef")
959
+ elapsed+=("$el"); ctx+=("$cx"); cache+=("$cs"); activity+=("$act"); descs+=("$desc")
960
+
961
+ local l
962
+ l=$(vis_len "$t"); [ "$l" -gt "$w_type" ] && w_type=$l
963
+ l=$(vis_len "$md"); [ "$l" -gt "$w_model" ] && w_model=$l
964
+ l=$(vis_len "$ef"); [ "$l" -gt "$w_effort" ] && w_effort=$l
965
+ l=$(vis_len "$el"); [ "$l" -gt "$w_elapsed" ] && w_elapsed=$l
966
+ l=$(vis_len "$cx"); [ "$l" -gt "$w_ctx" ] && w_ctx=$l
967
+ l=$(vis_len "$cs"); [ "$l" -gt "$w_cache" ] && w_cache=$l
968
+ l=$(vis_len "$act"); [ "$l" -gt "$w_act" ] && w_act=$l
969
+ done
970
+
971
+ # Pass 2: fit to the panel width. Type, status and cache always stay (the
972
+ # cache countdown is the point of the row); the type name shrinks when even
973
+ # those three don't fit. Optional columns are admitted whole, most useful
974
+ # first — activity (what a cold cache is waiting on), elapsed, context,
975
+ # model, effort — so every row keeps the same layout, and the description
976
+ # takes what is left. A column no row has a value for takes no space.
977
+ local i
978
+ local max_type=$((columns - 5 - w_cache))
979
+ [ "$max_type" -lt 4 ] && max_type=4
980
+ if [ "$w_type" -gt "$max_type" ]; then
981
+ w_type=$max_type
982
+ for i in "${!types[@]}"; do types[i]=$(fit "${types[i]}" "$w_type"); done
983
+ fi
984
+ local fixed=$((w_type + 2 + 1 + 2 + w_cache))
985
+ local show_el=false show_ctx=false show_act=false show_model=false show_effort=false
986
+ [ "$w_act" -gt 0 ] && [ $((fixed + 2 + w_act)) -le "$columns" ] && { show_act=true; fixed=$((fixed + 2 + w_act)); }
987
+ [ "$w_elapsed" -gt 0 ] && [ $((fixed + 2 + w_elapsed)) -le "$columns" ] && { show_el=true; fixed=$((fixed + 2 + w_elapsed)); }
988
+ [ "$w_ctx" -gt 0 ] && [ $((fixed + 2 + w_ctx)) -le "$columns" ] && { show_ctx=true; fixed=$((fixed + 2 + w_ctx)); }
989
+ [ "$w_model" -gt 0 ] && [ $((fixed + 2 + w_model)) -le "$columns" ] && { show_model=true; fixed=$((fixed + 2 + w_model)); }
990
+ [ "$w_effort" -gt 0 ] && [ $((fixed + 2 + w_effort)) -le "$columns" ] && { show_effort=true; fixed=$((fixed + 2 + w_effort)); }
991
+ local desc_room=$((columns - fixed - 2))
992
+
993
+ local out content
994
+ out=""
995
+ for i in "${!ids[@]}"; do
996
+ content="$(printf '%s' $'\033[38;5;141m')$(pad "${types[i]}" "$w_type")${COLOR_RESET} ${glyphs[i]}"
997
+ [ "$show_model" = true ] && content+=" $(pad "${models[i]}" "$w_model")"
998
+ [ "$show_effort" = true ] && content+=" $(pad "${efforts[i]}" "$w_effort")"
999
+ [ "$show_el" = true ] && content+=" $(pad "${elapsed[i]}" "$w_elapsed")"
1000
+ [ "$show_ctx" = true ] && content+=" $(pad "${ctx[i]}" "$w_ctx")"
1001
+ content+=" $(pad "${cache[i]}" "$w_cache")"
1002
+ [ "$show_act" = true ] && content+=" $(pad "${activity[i]}" "$w_act")"
1003
+ if [ "$desc_room" -ge 8 ] && [ -n "${descs[i]}" ]; then
1004
+ content+=" ${COLOR_GRAY}$(fit "${descs[i]}" "$desc_room")${COLOR_RESET}"
1005
+ fi
1006
+ # Trailing padding is noise in a row the host renders as-is.
1007
+ while [[ "$content" == *' ' ]]; do content="${content% }"; done
1008
+ out+="${ids[i]}$_sa_sep$content"$'\n'
1009
+ done
1010
+
1011
+ # One jq call encodes every row as JSON (escapes, ANSI and all).
1012
+ [ -n "$out" ] && printf '%s' "$out" \
1013
+ | jq -Rc 'split("\u001f") | {id: .[0], content: (.[1] // "")}' 2>/dev/null
1014
+ }
1015
+
1016
+ # Runs the subagent renderer when the payload carries a `tasks` array. The cheap
1017
+ # substring test keeps the main statusline at a single jq call per refresh; the
1018
+ # jq below is the authoritative check, so a main payload that merely mentions
1019
+ # "tasks" in some string still falls through to the main render.
1020
+ maybe_render_subagents() {
1021
+ [[ "$_input" == *'"tasks"'* ]] || return 1
1022
+ local extracted
1023
+ extracted=$(printf '%s' "$_input" | jq -r '
1024
+ def clean: tostring | gsub("[[:cntrl:]]"; " ");
1025
+ if type == "object" and (.tasks | type) == "array" then
1026
+ ([ "subagents", (.session_id // ""), (.transcript_path // ""), (.columns // "") ]
1027
+ | map(clean) | join("\u001f")),
1028
+ (.tasks[] | select(type == "object")
1029
+ | [ (.id // ""), (.name // ""), (.type // ""), (.status // ""),
1030
+ (.description // .label // ""), (.startTime // 0),
1031
+ (.tokenCount // 0), (.contextWindowSize // 0),
1032
+ (.model // ""), (.effort // "") ]
1033
+ | map(clean) | join("\u001f"))
1034
+ else empty end' 2>/dev/null)
1035
+ [[ "$extracted" == subagents* ]] || return 1
1036
+ render_subagent_rows "$extracted"
1037
+ return 0
1038
+ }
1039
+
586
1040
  # --- Orchestrated Execution Flow ---
1041
+ _input=$(cat)
1042
+ maybe_render_subagents && exit 0
587
1043
  parse_and_prepare_paths
588
1044
  compute_cache_timer
589
1045
  compute_context_info
1046
+ compute_effort_info
590
1047
  compute_rate_limits
591
1048
  compute_turn_breakdown
592
1049
  compute_update_notice