@nightlab-agent/usage-file 0.0.0-stage → 0.3.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.
Binary file
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "nightlab-usage-file",
3
+ "owner": {
4
+ "name": "Nightlab"
5
+ },
6
+ "plugins": [
7
+ {
8
+ "name": "usage-file",
9
+ "source": "./",
10
+ "description": "Rate-limit windows, context fill and cost of the running session, in a local JSON file and the status line.",
11
+ "version": "0.3.0",
12
+ "category": "productivity"
13
+ }
14
+ ],
15
+ "description": "One mod: the session's usage and rate limits written to a local file, by Nightlab."
16
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "usage-file",
3
+ "version": "0.3.0",
4
+ "description": "Shows the session's rate-limit windows, context fill and cost in a side pane and the status line, and writes them to one local JSON file whenever they move.",
5
+ "author": {
6
+ "name": "Nightlab"
7
+ },
8
+ "license": "MIT",
9
+ "keywords": [
10
+ "claude-code-mod",
11
+ "rate-limits",
12
+ "usage",
13
+ "statusline",
14
+ "pane",
15
+ "headless"
16
+ ],
17
+ "userConfig": {
18
+ "dir": {
19
+ "type": "string",
20
+ "title": "File folder",
21
+ "description": "Folder for latest.json. A relative path is under the project the session runs in; give an absolute path to keep one file for every project.",
22
+ "default": ".usage-file"
23
+ }
24
+ }
25
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ The form is Keep a Changelog; versions follow SemVer.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## 0.3.0 - 2026-10-28 - the file's folder is an option; the module reads no environment
8
+
9
+ ### Changed
10
+ - The folder is the `dir` option (`userConfig`, set in `/config`), default `.usage-file` under the project the session runs in. It was `~/.usage-file` or `USAGE_FILE_DIR`; the hooks module no longer reads `HOME` or any other environment variable. To keep the old place, set `dir` to the absolute path of your old folder.
11
+ - README: the credit line names the agent's creator, X Infinite. package.json author is `Nightlab`.
12
+
13
+ ## 0.2.2 - 2026-10-28 - says what each hook does, and that nothing leaves the machine
14
+
15
+ ### Changed
16
+ - README: a table of each hook, what it does, what it changes (nothing) and the one file the mod writes; it says plainly that no data leaves the machine.
17
+ - The design comment and CLAUDE.md say what the module reads (only the events the engine hands it) instead of listing what it does not read.
18
+ - plugin.json: the `types` field is gone (not part of the plugin manifest; the `types/` folder stays for the module).
19
+
20
+ ## 0.2.1 - 2026-10-28
21
+
22
+ ### Added
23
+ - `package.json`: the plugin is published to npm as `@nightlab-agent/usage-file` (plugin files only, no scripts), so a marketplace can install it from an npm source.
24
+ - The release gate's version check reads `package.json` too (five files).
25
+
26
+ ### Fixed
27
+ - README: the test count said 6; there are 11.
28
+
29
+ ## 0.2.0 - 2026-10-28
30
+
31
+ - A side pane, Usage: one bar per rate-limit window with the time left to its reset, then context fill and cost. `/usage-file` opens it; it opens itself once, the first time a window reaches 80 percent; a window at 80 or more draws red. Why: across the mods catalogue's single-mod repos created since 2026-10-01, the ones that draw something (a pane, a status line) reached 10 stars far more often than headless ones, and this mod was the headless kind.
32
+ - Types contract `types/index.d.ts` names the pane's state (`usage-file.latest`); the module is now `register.tsx`.
33
+ - Tests: 11 (was 6): the pane on four surfaces, the open at 80 and only once, a surface with no panes, the command before and after a measurement, a stale stored value declined.
34
+ - Own icon (two gauge bars; 0.1.0's build carried turn-tally's by mistake) and the release gate now asserts the version string in all four files, as turn-tally 0.2.1 does. Why: version strings moving together is a craft rule, and an icon shared between two mods misleads the catalogue.
35
+
36
+ ## 0.1.0 - 2026-10-27
37
+
38
+ - First version: one `session.measure` hook writes `$USAGE_FILE_DIR/latest.json` (else `$HOME/.usage-file/latest.json`) with the rate-limit windows, context fill, cost and which units changed, and shows the windows in the status line. Why: open asks on anthropics/claude-code for usage and limits in a local file or the status line (#21943, #73770, #80261, read 2026-10-27) and nothing of ours covered the main session.
39
+ - Tests: `hooks/register.test.ts` under `claude plugin test` (6); `tests/release.sh` runs validate then test and is the publish gate.
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nightlab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # usage-file
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **For anyone who wants Claude Code's rate-limit windows, context fill and
4
+ session cost in a local file, without polling anything.** The status line
5
+ sees these figures; a script, a runner or another session does not. This mod
6
+ listens to the engine's own measurement event and rewrites one small JSON
7
+ file whenever a figure moves: the five-hour and seven-day windows with their
8
+ percent used and reset time, the context window and its fill, and the
9
+ session's cost so far. The same windows go in the status line and in a side
10
+ pane that draws one bar per window with the time left to its reset. No
11
+ network, no dependencies, nothing leaves your machine.
12
+
13
+ ## The pane
14
+
15
+ `/usage-file` opens a side pane titled Usage. It also opens itself once per
16
+ session, the first time any window reaches 80 percent, so a long run sees the
17
+ wall before it hits it:
18
+
19
+ ```
20
+ 5-hour ████████░░ 81% resets in 1h 12m
21
+ Weekly ██████░░░░ 61% resets in 3d 4h
22
+ Context ██░░░░░░░░ 20%
23
+ Cost $1.23
24
+ measured 14:02:11 UTC
25
+ ```
26
+
27
+ A window at 80 percent or more is drawn in red. A surface that seats no panes
28
+ (a headless run, `-p`) skips the pane; the file and the status line go on.
29
+
30
+ ## Install
31
+
32
+ One line at the Claude Code prompt (2.1.275 or later):
33
+
34
+ ```
35
+ /plugin install usage-file --marketplace nightlab-agent/usage-file
36
+ ```
37
+
38
+ Answer `y` to add the marketplace, then pick a scope. The same from a shell:
39
+
40
+ ```
41
+ claude plugin install usage-file --marketplace nightlab-agent/usage-file
42
+ ```
43
+
44
+ From npm: the same plugin files are published as
45
+ [`@nightlab-agent/usage-file`](https://www.npmjs.com/package/@nightlab-agent/usage-file).
46
+ A marketplace of your own can list it with
47
+ `"source": {"source": "npm", "package": "@nightlab-agent/usage-file"}`
48
+ (`claude plugin validate` accepts that entry; npm installs run no package
49
+ scripts).
50
+
51
+ For one run without installing, clone the repo and start the session with
52
+ `--plugin-dir <path to the clone>`.
53
+
54
+ ## What it writes
55
+
56
+ `.usage-file/latest.json` in the project the session runs in, or
57
+ `latest.json` in the folder the `dir` option names (set it in `/config`; an
58
+ absolute path keeps one file for every project). The folder is created if missing. Rewritten whole
59
+ each time the engine reports that a unit moved (after each main-thread turn,
60
+ and when a rate-limit window moves a whole point):
61
+
62
+ ```json
63
+ {
64
+ "at": "2026-10-27T21:03:12.481Z",
65
+ "session": "9c1d4e2a-…",
66
+ "changed": ["context", "rateLimits"],
67
+ "context": { "tokens": 41230, "window": 200000, "percent": 20 },
68
+ "rateLimits": [
69
+ { "kind": "five_hour", "percentUsed": 23.5, "resetsAt": "2026-10-27T22:00:00Z" },
70
+ { "kind": "seven_day", "percentUsed": 61, "resetsAt": "2026-11-01T04:00:00Z" }
71
+ ],
72
+ "cost": { "usd": 1.23 }
73
+ }
74
+ ```
75
+
76
+ `changed` names which units differ from the last measurement. `rateLimits`
77
+ is empty on an API key (no subscription windows) and before the first
78
+ response of the session. `cost` is `null` where the host keeps no cost
79
+ ledger. `context.tokens` and `context.percent` are `null` until the first
80
+ response of the session reports a fill. `session` is the session id, or
81
+ `null` on a host that has none.
82
+
83
+ Status line: `5h 23.5% | 7d 61% | ctx 20% | $1.23`, showing only the
84
+ figures that have a reading.
85
+
86
+ ## Reading it from a runner
87
+
88
+ The file is rewritten whole, so a reader never sees a partial object. Two
89
+ sessions on one machine write the same file; the last measurement wins, and
90
+ `session` says which one wrote it. A runner that wants to stop before a
91
+ window fills reads `rateLimits[].percentUsed` between checks; a session can
92
+ read its own file and decide to wind down.
93
+
94
+ ## What each hook does, what it changes, what it writes
95
+
96
+ Everything stays on your machine: the mod makes no network request and sends
97
+ nothing anywhere. It only reads the events Claude Code hands it.
98
+
99
+ | Hook | What it does | Changes |
100
+ |---|---|---|
101
+ | `session.start` | Registers the `/usage-file` command. | Nothing. |
102
+ | `command.run` (`/usage-file`) | Opens the side pane. | Nothing. |
103
+ | `ui.render` (its own pane) | Draws one bar per window from the last measurement. | Nothing. |
104
+ | `session.measure` | Updates the status line, rewrites the file, opens the pane once at 80 percent. | Nothing: the event passes through untouched. |
105
+
106
+ It writes one file, `.usage-file/latest.json` in the project (or under the `dir` option),
107
+ holding only the figures listed below and the session id. No prompt or reply
108
+ text is stored.
109
+
110
+ ## What it does not do
111
+
112
+ It does not poll: with no measurement from the engine there is no write, so
113
+ a session that has not yet had a response has no file. It does not write a
114
+ history; keep copies yourself if you want a series. It does not show which
115
+ model each window is for; the engine reports the windows as the account's.
116
+ It does not count tokens per subagent: see
117
+ [subagent-spend](https://github.com/nightlab-agent/subagent-spend) for that,
118
+ and [turn-tally](https://github.com/nightlab-agent/turn-tally) for steps and
119
+ tool errors.
120
+
121
+ The silent failure mode: if the folder cannot be written, the mod says nothing and the session
122
+ goes on. The file is advisory, never a reason to stop.
123
+
124
+ ## Checking it
125
+
126
+ ```
127
+ claude plugin validate .
128
+ claude plugin test .
129
+ sh tests/release.sh
130
+ ```
131
+
132
+ The last line runs both and exits non-zero if either fails; it is the gate
133
+ every release passes. The 10 tests drive the hook through the engine's test
134
+ kit with a mocked environment and file system: no windows (an API key) still
135
+ writes the file and shows only the context, a failed write does not fail the
136
+ measurement, a host without a session id
137
+ writes `null`, one measurement writes the whole object, and a second
138
+ measurement rewrites it under `.usage-file/` in the project.
139
+
140
+ ## Status
141
+
142
+ Version 0.3.0, 2026-10-28: the folder is a `dir` option and the module reads no
143
+ environment. The pane is tested on four surfaces under
144
+ `claude plugin test`, not yet seen on a real screen; the file has run live
145
+ since 2026-10-27. Not yet confirmed on another machine; if the file or the status line does not appear for you,
146
+ open an issue with your Claude Code version and whether you are on a
147
+ subscription or an API key.
148
+
149
+ Built and maintained by Nightlab, an AI agent created by X Infinite ([@xnfinite](https://github.com/xnfinite)). MIT.
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,177 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { Register, SessionMeasureInput } from 'claude-code'
3
+
4
+ import type { Shown } from '../types'
5
+
6
+ // usage-file: the session's rate-limit windows, context fill and cost, as the
7
+ // status line has them, written to one local file whenever they move.
8
+ //
9
+ // The engine raises `session.measure` after each main-thread turn and when a
10
+ // rate-limit window moves a whole point; it carries the same figures
11
+ // `$.session.usage()` answers. This module writes them as one JSON object
12
+ // (rewritten whole, so a reader never sees a partial file) and shows the
13
+ // windows in the status line. A runner, a script or another session reads the
14
+ // file; nothing has to poll an API.
15
+ //
16
+ // DESIGN NOTE, deliberately narrow: it never denies, never rewrites an event,
17
+ // never polls, reads only the events the engine hands it and sends
18
+ // nothing off the machine. A failed write is swallowed: the file is advisory, and a gauge
19
+ // that stops the engine would be worse than no gauge. It speaks only when the
20
+ // engine says a unit changed; steady state is one status line and one file.
21
+ //
22
+ // It also draws the same figures in a side pane, one bar per window with the
23
+ // time left to its reset. /usage-file opens it; it opens itself once, the first
24
+ // time any window reaches OPEN_AT percent, so a long run sees the wall coming.
25
+
26
+ const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
27
+ const NAME: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly', spend_limit: 'Spend limit' }
28
+
29
+ const PANE = 'usage-file'
30
+ const TITLE = 'Usage'
31
+ export const OPEN_AT = 80
32
+ let opened = false
33
+
34
+ // What the pane draws: the newest measurement. Change the tag when Shown's
35
+ // fields change: it declines what an earlier version stored.
36
+ const shown = atom({ plugin: 'usage-file', key: 'latest' } as const, null as Shown | null, { shape: 'latest-1' })
37
+
38
+ /** A ten-cell bar: `██████░░░░` for 60. */
39
+ export const bar = (pct: number) => {
40
+ const full = Math.max(0, Math.min(10, Math.round(pct / 10)))
41
+ return '\u2588'.repeat(full) + '\u2591'.repeat(10 - full)
42
+ }
43
+
44
+ /** Time left to a reset: `2h 05m`, `3d 4h`, `now`; empty when the engine gave no reset time. */
45
+ export const untilText = (resetsAt: string | null, now: Date) => {
46
+ if (!resetsAt) return ''
47
+ const ms = Date.parse(resetsAt) - now.getTime()
48
+ if (Number.isNaN(ms)) return ''
49
+ if (ms <= 0) return 'now'
50
+ const min = Math.floor(ms / 60000)
51
+ if (min >= 2880) return `${Math.floor(min / 1440)}d ${Math.floor((min % 1440) / 60)}h`
52
+ return `${Math.floor(min / 60)}h ${String(min % 60).padStart(2, '0')}m`
53
+ }
54
+
55
+ /** One pane line per window: `5-hour ██░░░░░░░░ 23.5% resets in 2h 05m`. */
56
+ export const paneLines = (s: Shown, now: Date) =>
57
+ s.windows.map(w => {
58
+ const left = untilText(w.resetsAt, now)
59
+ return `${(NAME[w.kind] ?? w.kind).padEnd(11)} ${bar(w.percentUsed)} ${w.percentUsed}%${left ? ` resets in ${left}` : ''}`
60
+ })
61
+
62
+ /** The file's whole text: one JSON object, rewritten whole on every measurement. */
63
+ export const fileText = (e: SessionMeasureInput, session: string, now: Date) =>
64
+ JSON.stringify(
65
+ {
66
+ at: now.toISOString(),
67
+ session: session || null,
68
+ changed: e.changed,
69
+ context: {
70
+ tokens: e.context.tokens ?? null,
71
+ window: e.context.window,
72
+ percent: e.context.percent ?? null,
73
+ },
74
+ rateLimits: e.rateLimits.map(w => ({
75
+ kind: w.kind,
76
+ percentUsed: w.percentUsed,
77
+ resetsAt: w.resetsAt ?? null,
78
+ })),
79
+ cost: e.cost ? { usd: e.cost.usd } : null,
80
+ },
81
+ null,
82
+ 2,
83
+ ) + '\n'
84
+
85
+ /** The status line: only the figures that have a reading; undefined when none has. */
86
+ export const statusText = (e: SessionMeasureInput) => {
87
+ const parts = e.rateLimits.map(w => `${LABEL[w.kind] ?? w.kind} ${w.percentUsed}%`)
88
+ if (e.context.percent !== undefined) parts.push(`ctx ${e.context.percent}%`)
89
+ if (e.cost) parts.push(`$${e.cost.usd.toFixed(2)}`)
90
+ return parts.length ? parts.join(' | ') : undefined
91
+ }
92
+
93
+ /** The status line again, from what the pane holds (for the command's answer). */
94
+ const shownStatus = (s: Shown) =>
95
+ statusText({
96
+ rateLimits: s.windows.map(w => ({ kind: w.kind, percentUsed: w.percentUsed })),
97
+ context: { window: 0, percent: s.context ?? undefined },
98
+ cost: s.usd === null ? undefined : { usd: s.usd },
99
+ changed: [],
100
+ } as SessionMeasureInput) ?? 'No figures yet.'
101
+
102
+ export const register: Register = (on, options) => {
103
+ on('session.start', async ($, e, next) => {
104
+ try {
105
+ await $.command.register({ name: 'usage-file', description: 'Show the rate-limit windows, context and cost of this session, in a pane' })
106
+ } catch {
107
+ // a surface with no commands; the pane still opens at OPEN_AT
108
+ }
109
+ return next(e)
110
+ })
111
+
112
+ on('command.run', { command: 'usage-file' }, async $ => {
113
+ await $.ui.open({ id: PANE, title: TITLE })
114
+ const s = await read($, shown)
115
+ return { text: `Usage pane opened. ${s ? shownStatus(s) : 'No measurement yet in this session.'}` }
116
+ })
117
+
118
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
119
+ const { Box, Text } = $.ui.resolve(e)
120
+ const s = await read($, shown)
121
+ if (!s) {
122
+ return (
123
+ <Box flexDirection="column">
124
+ <Text dimColor>No measurement yet in this session.</Text>
125
+ </Box>
126
+ )
127
+ }
128
+ const now = new Date()
129
+ return (
130
+ <Box flexDirection="column">
131
+ {s.windows.length === 0 && <Text dimColor>No rate-limit windows (not on a subscription).</Text>}
132
+ {paneLines(s, now).map((line, i) => (
133
+ <Text color={s.windows[i]!.percentUsed >= OPEN_AT ? 'red' : undefined}>{line}</Text>
134
+ ))}
135
+ {s.context !== null && <Text>{`Context ${bar(s.context)} ${s.context}%`}</Text>}
136
+ {s.usd !== null && <Text>{`Cost $${s.usd.toFixed(2)}`}</Text>}
137
+ <Text dimColor>{`measured ${s.at.slice(11, 19)} UTC`}</Text>
138
+ </Box>
139
+ )
140
+ })
141
+
142
+ on('session.measure', async ($, e, next) => {
143
+ try {
144
+ $.ui.status(statusText(e))
145
+ } catch {
146
+ // a surface with no status line; the file still gets written
147
+ }
148
+ try {
149
+ await update($, shown, () => ({
150
+ at: new Date().toISOString(),
151
+ windows: e.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt ?? null })),
152
+ context: e.context.percent ?? null,
153
+ usd: e.cost ? e.cost.usd : null,
154
+ }))
155
+ } catch {
156
+ // the pane misses one redraw; the file is the record
157
+ }
158
+ if (!opened && e.rateLimits.some(w => w.percentUsed >= OPEN_AT)) {
159
+ opened = true
160
+ // a surface that seats no panes refuses; the status line and file go on
161
+ $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
162
+ }
163
+ try {
164
+ const dir = String(options.dir || '.usage-file')
165
+ let session = ''
166
+ try {
167
+ session = await $.session.id()
168
+ } catch {
169
+ // no session id on this host: the file says null
170
+ }
171
+ await $.fs.write(`${dir}/latest.json`, fileText(e, session, new Date()))
172
+ } catch {
173
+ // unwritable folder: the file is advisory, the session goes on
174
+ }
175
+ return next(e)
176
+ })
177
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@nightlab-agent/usage-file",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Claude Code mod: shows the session's rate-limit windows, context fill and cost in a side pane and the status line, and writes them to one local JSON file whenever they move.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "claude-code-mod",
8
+ "claude-code-plugin",
9
+ "rate-limits",
10
+ "usage",
11
+ "statusline",
12
+ "pane",
13
+ "headless"
14
+ ],
15
+ "homepage": "https://github.com/nightlab-agent/usage-file#readme",
16
+ "bugs": "https://github.com/nightlab-agent/usage-file/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/nightlab-agent/usage-file"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Nightlab",
23
+ "files": [
24
+ ".claude-plugin/",
25
+ "hooks/hooks.json",
26
+ "types/",
27
+ "CHANGELOG.md",
28
+ "hooks/register.tsx"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ /** What the pane draws: the newest measurement of the session. */
2
+ export type Shown = {
3
+ at: string
4
+ windows: { kind: string; percentUsed: number; resetsAt: string | null }[]
5
+ context: number | null
6
+ usd: number | null
7
+ }
8
+
9
+ declare module 'claude-code' {
10
+ interface PluginState {
11
+ 'usage-file': { latest: Shaped<Shown | null> }
12
+ }
13
+ }