@inbrace-tech/tokenline 1.3.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 +33 -7
- package/dist/cli.js +36 -29
- package/package.json +7 -7
- package/tokenline.sh +480 -21
package/README.md
CHANGED
|
@@ -40,12 +40,16 @@ 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
|
-
|
|
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
|
+
|
|
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
|
```
|
|
46
|
-
↑ tokenline 1.
|
|
48
|
+
↑ tokenline 1.4.0 available · update: ! npx -y @inbrace-tech/tokenline@latest update --global
|
|
47
49
|
```
|
|
48
50
|
|
|
51
|
+
In Claude Code, type that line (with the leading `!`) at the prompt to run it without leaving the session. The next refresh shows the new version.
|
|
52
|
+
|
|
49
53
|
The check is a single `GET` to `registry.npmjs.org` for the version number, made only if `curl` is installed. It runs detached, so rendering never waits on the network. Nothing about your session is sent, and nothing downloaded is executed: only a plain `x.y.z` version is accepted and shown. To turn it off, set `TOKENLINE_NO_UPDATE_CHECK=1` (`DO_NOT_TRACK=1` works too).
|
|
50
54
|
|
|
51
55
|
---
|
|
@@ -54,12 +58,28 @@ The check is a single `GET` to `registry.npmjs.org` for the version number, made
|
|
|
54
58
|
|
|
55
59
|

|
|
56
60
|
|
|
57
|
-
- **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.
|
|
58
62
|
- **Line 2** — per-turn token economics: `read / write / new / output`, the equivalent billed tokens (`eq`), and the **`saving %`** you get from prompt caching.
|
|
59
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).
|
|
60
64
|
|
|
61
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.
|
|
62
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
|
+
|
|
63
83
|
## About
|
|
64
84
|
|
|
65
85
|
`tokenline` turns the one-line status bar of your AI coding CLI into a live cockpit:
|
|
@@ -85,7 +105,7 @@ LLMs are stateless — by default, they must reread your entire codebase context
|
|
|
85
105
|
|
|
86
106
|
**Prompt Caching** solves this by saving that processed state in the provider's memory:
|
|
87
107
|
- **Cache Write (Cold):** The model reads everything and stores the state. This costs slightly more than base tokens.
|
|
88
|
-
- **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.
|
|
89
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**.
|
|
90
110
|
|
|
91
111
|
### 💡 Pro-tip: Forcing a 5m TTL to save rate limits
|
|
@@ -142,9 +162,15 @@ Add the printed block to your project's `.claude/settings.json` (or `~/.claude/s
|
|
|
142
162
|
"type": "command",
|
|
143
163
|
"command": "bash /absolute/path/to/tokenline/tokenline.sh",
|
|
144
164
|
"refreshInterval": 1
|
|
165
|
+
},
|
|
166
|
+
"subagentStatusLine": {
|
|
167
|
+
"type": "command",
|
|
168
|
+
"command": "bash /absolute/path/to/tokenline/tokenline.sh"
|
|
145
169
|
}
|
|
146
170
|
```
|
|
147
171
|
|
|
172
|
+
(`subagentStatusLine` is Claude Code only — leave it out for the Antigravity CLI.)
|
|
173
|
+
|
|
148
174
|
Then restart Claude Code.
|
|
149
175
|
|
|
150
176
|
### What the installer does
|
|
@@ -152,10 +178,10 @@ Then restart Claude Code.
|
|
|
152
178
|
`npx @inbrace-tech/tokenline init` is deliberately transparent about touching your config:
|
|
153
179
|
|
|
154
180
|
- **Writes** `tokenline.sh` to `./.claude/` (or `~/.claude/` with `--global`, or `~/.gemini/antigravity-cli/` with `--antigravity`).
|
|
155
|
-
- **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.
|
|
156
182
|
- **Backs up** `settings.json` to `settings.json.bak` before writing.
|
|
157
183
|
- **Never clobbers** invalid JSON: if it can't parse your `settings.json`, it stops and prints the block to paste manually.
|
|
158
|
-
- 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.
|
|
159
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.
|
|
160
186
|
|
|
161
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).
|
|
@@ -166,7 +192,7 @@ Other commands: `update` (replace the installed script, see [Updating](#updating
|
|
|
166
192
|
|
|
167
193
|
## How it works
|
|
168
194
|
|
|
169
|
-
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.
|
|
170
196
|
|
|
171
197
|
## Troubleshooting
|
|
172
198
|
|
package/dist/cli.js
CHANGED
|
@@ -1,54 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";var
|
|
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
|
-
`)),
|
|
5
|
-
`),
|
|
6
|
-
`)}function
|
|
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
|
-
`)),!
|
|
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(
|
|
11
|
-
`),process.exitCode=1;return}let f=c.data?c.data.statusLine:void 0,
|
|
12
|
-
[dry-run] would:`)),
|
|
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(
|
|
15
|
-
`)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`)}
|
|
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=
|
|
21
|
-
`),
|
|
22
|
-
${
|
|
23
|
-
`)}var
|
|
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=
|
|
26
|
-
Install it first: npx @inbrace-tech/tokenline init${
|
|
27
|
-
`),process.exitCode=1;return}if(t.dryRun){
|
|
28
|
-
${
|
|
29
|
-
`)
|
|
30
|
-
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
|
45
|
+
uninstall Remove the tokenline blocks (statusLine, subagentStatusLine) from settings
|
|
40
46
|
|
|
41
|
-
${
|
|
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
|
-
${
|
|
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
|
|
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.
|
|
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.
|
|
43
|
+
"@types/node": "24.19.2",
|
|
44
44
|
"@vitest/eslint-plugin": "1.6.27",
|
|
45
|
-
"eslint": "10.
|
|
45
|
+
"eslint": "10.12.0",
|
|
46
46
|
"eslint-config-prettier": "10.1.8",
|
|
47
|
-
"eslint-plugin-n": "18.
|
|
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.
|
|
51
|
-
"prettier": "3.9.
|
|
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.
|
|
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' "$
|
|
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 // "")
|
|
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).
|
|
168
|
-
#
|
|
169
|
-
#
|
|
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
|
|
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,,}" =~ $
|
|
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"
|
|
@@ -492,9 +525,11 @@ version_gt() {
|
|
|
492
525
|
|
|
493
526
|
compute_update_notice() {
|
|
494
527
|
# Installed (stamped) copies only; the repo copy has no version and never checks.
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
#
|
|
528
|
+
# A detached curl stores the latest published version in the private runtime
|
|
529
|
+
# dir, and the render path only reads that file. It runs when a session that
|
|
530
|
+
# hasn't checked yet finds the last check 1h+ old (so a new release shows up
|
|
531
|
+
# when you open a new session), and at least every 6h in a long session.
|
|
532
|
+
# Nothing from the session is sent, and nothing downloaded is ever executed.
|
|
498
533
|
# Opt out with TOKENLINE_NO_UPDATE_CHECK=1 (DO_NOT_TRACK=1 is honored too).
|
|
499
534
|
update_info=""
|
|
500
535
|
local semver='^[0-9]+\.[0-9]+\.[0-9]+$'
|
|
@@ -505,12 +540,25 @@ compute_update_notice() {
|
|
|
505
540
|
[ "$_runtime_dir" != "/tmp" ] && [ -O "$_runtime_dir" ] || return 0
|
|
506
541
|
|
|
507
542
|
local latest_file="$_runtime_dir/latest-version"
|
|
508
|
-
local
|
|
543
|
+
local session_file="$_runtime_dir/latest-version.session"
|
|
544
|
+
local checked_at checked_by="" due=false age
|
|
509
545
|
checked_at=$(file_mtime "$latest_file")
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
546
|
+
read -r checked_by 2>/dev/null < "$session_file"
|
|
547
|
+
if [ -z "$checked_at" ]; then
|
|
548
|
+
due=true
|
|
549
|
+
else
|
|
550
|
+
age=$((now - checked_at))
|
|
551
|
+
if [ "$age" -ge 21600 ]; then
|
|
552
|
+
due=true
|
|
553
|
+
elif [ "$age" -ge 3600 ] && [ -n "$session_id" ] && [ "$session_id" != "$checked_by" ]; then
|
|
554
|
+
due=true
|
|
555
|
+
fi
|
|
556
|
+
fi
|
|
557
|
+
if [ "$due" = true ] && command -v curl >/dev/null 2>&1; then
|
|
558
|
+
# Mark the check first (time and session), so the refreshes during the fetch
|
|
559
|
+
# don't start another one.
|
|
513
560
|
touch "$latest_file" 2>/dev/null
|
|
561
|
+
printf '%s\n' "$session_id" 2>/dev/null > "$session_file"
|
|
514
562
|
(
|
|
515
563
|
v=$(curl -fsS --max-time 5 https://registry.npmjs.org/@inbrace-tech/tokenline/latest \
|
|
516
564
|
| jq -r '.version // empty')
|
|
@@ -524,8 +572,12 @@ compute_update_notice() {
|
|
|
524
572
|
read -r latest 2>/dev/null < "$latest_file"
|
|
525
573
|
[[ "$latest" =~ $semver ]] || return 0
|
|
526
574
|
version_gt "$latest" "$TOKENLINE_VERSION" || return 0
|
|
527
|
-
|
|
528
|
-
|
|
575
|
+
# In Claude Code, a leading "!" runs the command from the prompt, so the
|
|
576
|
+
# update never needs another terminal.
|
|
577
|
+
local run_hint=""
|
|
578
|
+
[ "$cli_client" = "claude-code" ] && run_hint="! "
|
|
579
|
+
update_info=$(printf '%s↑ tokenline %s available · update: %s%s%s' \
|
|
580
|
+
"$COLOR_DARK_GRAY" "$latest" "$run_hint" "$TOKENLINE_UPDATE_CMD" "$COLOR_RESET")
|
|
529
581
|
}
|
|
530
582
|
|
|
531
583
|
# --- 8. Compose and Render Output ---
|
|
@@ -540,6 +592,8 @@ render_statusline() {
|
|
|
540
592
|
display_header="${model}"
|
|
541
593
|
fi
|
|
542
594
|
|
|
595
|
+
[ -n "$effort_info" ] && display_header="$display_header · $effort_info"
|
|
596
|
+
|
|
543
597
|
local line1="$display_header"
|
|
544
598
|
[ -n "$ctx_info" ] && line1="$line1 | $ctx_info"
|
|
545
599
|
[ -n "$cache_info" ] && line1="$line1 | $cache_info"
|
|
@@ -564,10 +618,415 @@ render_statusline() {
|
|
|
564
618
|
fi
|
|
565
619
|
}
|
|
566
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
|
+
|
|
567
1023
|
# --- Orchestrated Execution Flow ---
|
|
1024
|
+
_input=$(cat)
|
|
1025
|
+
maybe_render_subagents && exit 0
|
|
568
1026
|
parse_and_prepare_paths
|
|
569
1027
|
compute_cache_timer
|
|
570
1028
|
compute_context_info
|
|
1029
|
+
compute_effort_info
|
|
571
1030
|
compute_rate_limits
|
|
572
1031
|
compute_turn_breakdown
|
|
573
1032
|
compute_update_notice
|