savemytokens 0.2.0 → 0.2.2

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
@@ -4,7 +4,9 @@
4
4
 
5
5
  # SaveMyTokens
6
6
 
7
- ### Decide which project gets your Claude allowance.
7
+ ### Decide which projects get your Claude allowance.
8
+
9
+ <sub>A capacity scheduler for Claude Code · Node 18.17+ · nothing leaves your machine</sub>
8
10
 
9
11
  <a href="https://www.npmjs.com/package/savemytokens"><img src="https://img.shields.io/npm/v/savemytokens?style=flat-square&color=1a7f37&label=npm" alt="npm version"></a>
10
12
  <img src="https://img.shields.io/badge/dependencies-0-1a7f37?style=flat-square" alt="zero dependencies">
@@ -14,62 +16,93 @@
14
16
 
15
17
  <br><br>
16
18
 
17
- <img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/screenshot.svg" alt="The SaveMyTokens control centre: three live projects sharing one 5-hour window, each row showing its allocation, how much of that allocation it has spent, its priority and its last prompt." width="880">
19
+ <img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/screenshot.svg" alt="The SaveMyTokens control centre: seven projects sharing one 5-hour window, three of them pinned to the top, each row showing its allocation, how much of that allocation it has spent, its priority and its last prompt, above a RECENT table of six more." width="955">
18
20
 
19
21
  <br>
20
22
 
21
- <img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/statusline.svg" alt="A Claude Code status line: a braille bar, then 21%/50%, 5h 42%, in 3h52." width="520">
23
+ <img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/statusline.svg" alt="A Claude Code status line: a braille bar, then 42% of 50%, 5h 42%, resets in 3h52m." width="520">
22
24
 
23
25
  </div>
24
26
 
25
27
  <br>
26
28
 
29
+ You have four Claude Code windows open. One of them is a throwaway experiment and it is quietly
30
+ eating the afternoon, but there is one usage number for all four, so there is no way to know
31
+ which. By the time the window is gone, the work that mattered is the work that did not finish.
32
+
27
33
  ## Get started
28
34
 
29
35
  ```sh
30
36
  npx savemytokens
31
37
  ```
32
38
 
33
- That is the whole install. It opens the control centre and, on the first run, offers to add four
34
- hooks and a status line to Claude Code's `settings.json`, backing the file up first. Prefer it
35
- installed once rather than fetched each time? `npm i -g savemytokens`, then the command is just
36
- `savemytokens`.
39
+ That is the whole install. On the first run it offers to add four hooks and a status line to Claude
40
+ Code's `settings.json`, backing the file up first.
37
41
 
38
- **Say yes.** The status line is the only place Anthropic publishes your 5h and weekly usage, and it
39
- is what proves a session is still open, so nothing is live without it. Changed your mind?
40
- `npx savemytokens uninstall` takes every entry back out.
42
+ - **Say yes.** The status line is the only place your 5h and weekly usage is published, and it is
43
+ what proves a session is still open. Nothing is live without it.
44
+ - **Don't want to see one?** Take **Install, no line**. Everything is installed and the line renders
45
+ nothing, so the numbers still arrive and your terminal looks unchanged.
46
+ - **Rather not fetch it each time?** `npm i -g savemytokens`, then the command is just
47
+ `savemytokens`.
48
+ - **Changed your mind?** `npx savemytokens uninstall` takes every entry back out.
41
49
 
42
50
  <br>
43
51
 
44
52
  | | |
45
53
  | :-- | :-- |
46
- | **The real numbers** | Your 5-hour and weekly usage exactly as Anthropic publishes it. Never an invented *"% remaining"*. |
47
- | **Two lists you control** | **ACTIVE** is what shares your window; **RECENT** is everything else it has seen. A project joins ACTIVE on its own when you open Claude Code there and stays after you close it, so a target survives the session. `a` moves one up, `x` sends it back. |
48
- | **Claude working to it** | As a session eats into its slice it is told to narrow scope and finish, and to write down whatever it drops. That comes back the next time you work on the project. |
49
- | **Unused capacity returned** | A project with nothing running lends its share to the rest and takes it back when you return. Mark one done and what it did not spend goes to the others immediately. |
50
- | **In your status line** | `21%/50% · 5h 42% · in 3h52`. How much of your share you have spent, where the window is, when it comes back. Pick a shape, or arrange the pieces yourself. |
54
+ | **The real numbers** | Your 5-hour and weekly usage exactly as Claude Code publishes it to the status line. Never an invented *"% remaining"*. |
55
+ | **A split you control** | Give each project a target and a priority. Move one and the others move to fit, so the window always adds up. |
56
+ | **The agent working to it** | As a session eats into its slice it is told to narrow scope and finish, and to write down whatever it drops. That comes back the next time you open the project. |
57
+ | **Capacity returned** | A project with nothing running lends its share to the rest and reclaims it when you come back. |
58
+ | **In your status line** | `⣿⣿⣿⣿⣿⣀⣀⣀⣀⣀⣀⣀ 42% of 50% · 5h 42% · resets in 3h52m`. Pick a shape, arrange the pieces yourself, or run it silent. |
51
59
 
52
60
  <br>
53
61
 
62
+ ## A worked example
63
+
64
+ Seven projects open, and the release matters more than the experiment.
65
+
66
+ ```sh
67
+ npx savemytokens share burningdemand 20 # Set burningdemand target to 20%
68
+ npx savemytokens priority burningdemand high # burningdemand now takes spare capacity first
69
+ ```
70
+
71
+ The others split what is left. When one of them finishes, whatever it did not spend goes to
72
+ burningdemand, because it is the only project on the high tier.
73
+
74
+ <div align="center">
75
+
76
+ <img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/example.svg" alt="Seven projects in the ACTIVE table. Three are pinned, in the order they were pinned: burningdemand at 20% and 71% through it on HIGH, autonomykernel at 18% and 44% through, coldverdict at 12% and 93% through on LOW. Below them the unpinned ones share what is left." width="955">
77
+
78
+ </div>
79
+
80
+ `coldverdict` is at 93% of its slice, so its next turn opens with a line telling it to finish what it
81
+ has and write down the rest. Whatever it drops comes back the next time you open that project.
82
+
83
+ The three pinned rows hold the order you pinned them in, whatever happens to the numbers beside
84
+ them. Nothing here required you to guess how many tokens anything costs.
85
+
54
86
  ## Commands
55
87
 
56
- | | |
57
- | :-- | :-- |
58
- | `npx savemytokens` | the control centre |
59
- | `npx savemytokens status` | one plain-text snapshot · `--json` for a script |
60
- | `npx savemytokens share buydiff 40` | pin a project's allocation |
61
- | `npx savemytokens priority buydiff high` | who gets spare capacity first |
62
- | `npx savemytokens release buydiff` | hand its unused share back |
63
- | `npx savemytokens policy strict` | wind down earlier when it gets tight |
64
- | `npx savemytokens hud` | pick a status line, previewed on your numbers |
65
- | `npx savemytokens theme` | eighteen themes, or write your own |
66
- | `npx savemytokens defer` | work a session pushed to next time |
67
- | `npx savemytokens audit` | what your last 7 days wasted |
68
- | `npx savemytokens uninstall` | remove every trace · `--purge` drops the data too |
69
-
70
- Inside the control centre, `?` lists every key. The ones worth knowing: `←→` moves an allocation,
71
- `space` moves a project up a level and `x` moves it down, so a project you never want to see goes
72
- ACTIVE to RECENT to hidden. `p` cycles priority, `⏎` opens a project's sessions, `s` opens settings.
88
+ <div align="center">
89
+
90
+ <img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/commands.svg" alt="The command list: npx savemytokens for the control centre, status, share, priority, release, policy, hud, theme, defer, audit, privacy and uninstall." width="955">
91
+
92
+ </div>
93
+
94
+ `--codex` points any of these at Codex instead, which publishes its own rate limits and needs no
95
+ hooks. It is metered and shown, but nothing can be injected into it, so there is no advice.
96
+
97
+ In a row, `★` means you pinned it and pinned rows keep the order you pinned them in; a number beside
98
+ it is how many Claude Code windows are open in that project, shown only when there is more than one;
99
+ a dimmed row is holding its share while nothing runs there.
100
+
101
+ Inside the control centre, `?` lists every key. The ones worth knowing: **`←→` moves an allocation,
102
+ and the others move to fit** so the window always adds up. `space` moves a project up a level and
103
+ `x` moves it down, so one you never want to see goes ACTIVE to RECENT to hidden, and `m` shows what
104
+ is hidden. `e` puts everything back to an even split, `p` cycles priority, `⏎` opens a project's
105
+ sessions, `s` opens settings.
73
106
 
74
107
  ## Where the numbers come from
75
108
 
@@ -77,30 +110,35 @@ Three different kinds of number sit on that screen, and it matters which is whic
77
110
 
78
111
  | | |
79
112
  | :-- | :-- |
80
- | **5h and 7d** | Anthropic's own figures. Claude Code publishes them to the status line, so SaveMyTokens installs one, reads them, and stores each reading with its timestamp. Nothing is estimated. |
81
- | **share** | Measured from the transcripts already on your disk, token by token, including subagents. Exact, and independent of anything Anthropic reports. |
82
- | **used, allocation** | Anthropic's percentage, split by that measured share. The total is theirs. The division between projects is ours, and it is the only inferred number on the screen. |
113
+ | **5h and 7d** | Published figures, not ours. Claude Code writes them to the status line, so SaveMyTokens installs one, reads them, and stores each reading with its timestamp. Nothing is estimated. |
114
+ | **share** | Measured from the transcripts already on your disk, token by token, including subagents. Exact, and independent of anything the provider reports. |
115
+ | **used, allocation** | The published percentage, split by that measured share. The total is not ours. The division between projects is ours, and it is the only inferred number on the screen. |
83
116
 
84
117
  Usage from another machine, or from claude.ai, is invisible to a local tool. When the window moves
85
- while none of your projects had a turn, that is reported on its own line rather than folded silently
86
- into a project's figure.
118
+ across five minutes in which nothing local was metered, it is reported on its own line rather than
119
+ folded silently into a project's figure.
87
120
 
88
121
  ## How it works
89
122
 
90
- `install` writes three scripts to `~/.savemytokens/hooks`, adds four hook entries and a status line
91
- to Claude Code's `settings.json`, and backs that file up first. There is no daemon. The hooks meter
92
- their own session as it runs, the status line captures the published window every ten seconds, and
93
- the control centre reads what they leave behind. Transcripts are read incrementally, from a stored
94
- byte offset per file, so a prompt costs milliseconds however long the session has run.
95
-
96
- Nothing outside `~/.savemytokens` and Claude Code's own settings is touched. `uninstall` puts it
97
- back, and restores a status line of your own if it wrapped one.
123
+ - **No daemon.** Three scripts in `~/.savemytokens/hooks`, four hook entries and a status line in
124
+ Claude Code's `settings.json`, which is backed up first.
125
+ - **The hooks meter their own session** as it runs. The status line captures the published window
126
+ every ten seconds. The control centre reads what they leave behind.
127
+ - **Transcripts are read incrementally**, from a stored byte offset per file, so a prompt costs
128
+ milliseconds however long the session has run.
129
+ - **Nothing else is touched.** `uninstall` puts it back, and restores a status line of your own if
130
+ it wrapped one.
98
131
 
99
132
  ## Requirements
100
133
 
101
- Node 18.17 or newer, and Claude Code 2.1 or newer. The published 5h and 7d figures come with a Claude
102
- subscription. On an API key or through the Console, Anthropic reports no window; SaveMyTokens says so
103
- rather than guessing, and allocation and advice still work from measured usage alone.
134
+ - **Node 18.17 or newer**, and **Claude Code 2.1 or newer**.
135
+ - **A Claude subscription** for the published 5h and 7d figures. On an API key or through the
136
+ Console no window is reported; SaveMyTokens says so rather than guessing, and allocation and
137
+ advice still work from measured usage alone.
138
+ - **macOS** is what it is developed and tested on. **Linux** should behave identically. **Windows**
139
+ is written for but unproven: paths and the home directory resolve portably, and the control centre
140
+ wants a terminal that handles the alternate screen buffer, which Windows Terminal does. If it
141
+ misbehaves there, please [open an issue](https://github.com/offbeatport/savemytokens/issues/new).
104
142
 
105
143
  <details>
106
144
  <summary><b>Writing your own theme</b></summary>
@@ -149,10 +187,13 @@ bar the built-in themes have to clear.
149
187
  - **It advises; it does not enforce.** The hooks inject text, and a model does not hold a budget the
150
188
  way a scheduler holds a lock. A session that ignores the advice keeps running. Hard caps are the
151
189
  next step, and will ship only after a period of logging what *would* have been blocked.
152
- - **It does not predict a lockout.** Anthropic publishes a percentage and a reset time. Anything
190
+ - **It does not predict a lockout.** A percentage and a reset time are published. Anything
153
191
  beyond those two facts would be a guess dressed up as a number.
154
192
  - **It cannot see usage it did not measure.** Another machine, claude.ai, or work done before you
155
193
  installed it shows up as unattributed window, labelled as such.
194
+ - **An idle window still holds its share.** A session with nothing running keeps its slice rather
195
+ than lending it out, so ten open terminals divide the window ten ways. Treating a target as a
196
+ weight among sessions actually consuming is the next fix.
156
197
  - **It makes no network call.** No account, no telemetry, no daemon. `savemytokens privacy` prints
157
198
  every file it reads and writes.
158
199
 
@@ -31,7 +31,6 @@ export function parseArgs(argv) {
31
31
  interval: 60,
32
32
  dryRun: false,
33
33
  force: false,
34
- window: "five_hour",
35
34
  adapter: "claude-code",
36
35
  rules: false,
37
36
  purge: false,
@@ -7,7 +7,7 @@ import { collect } from "../collect.js";
7
7
  import { renderAudit } from "../report/render.js";
8
8
  import { loadRuns, previousRun, saveRun } from "../storage/store.js";
9
9
  import { dim } from "../util/ansi.js";
10
- import { hookInstalled, nudgeStats } from "./install.js";
10
+ import { hookInstalled } from "./install.js";
11
11
  function progress(done, total) {
12
12
  if (!process.stderr.isTTY || total < 12)
13
13
  return;
@@ -58,7 +58,6 @@ export async function runAudit(options) {
58
58
  audit,
59
59
  previous,
60
60
  history: runs,
61
- nudges: nudgeStats(),
62
61
  installed: hookInstalled(),
63
62
  verbose: options.verbose,
64
63
  }));
@@ -1,13 +1,12 @@
1
1
  import { detailRows, helpOverlay, labelsFor, planRows } from "../report/views.js";
2
2
  import { PRESERVE_KINDS, renderSettings, selectableRows, settingsRows } from "../report/settings.js";
3
3
  import { keyActions, splitKeys } from "../scheduler/keys.js";
4
- import { buildPlan, cyclePriority, demote, promote, selectionIndex, equalize, cyclePolicy, cyclePreset, cycleTheme, moveSegment, nextShare, saveCustomAdvice, toggleColumn, togglePreserve, toggleSegment, setPriority, setShare, setPinned, resetPreferences, setState, visibleRows, } from "../scheduler/plan.js";
4
+ import { buildPlan, cyclePriority, cycleStep, cycleWindow, demote, promote, selectionIndex, equalize, cyclePolicy, cyclePreset, cycleTheme, moveSegment, nextShare, saveCustomAdvice, toggleColumn, togglePreserve, toggleSegment, setPriority, setShare, setPinned, resetPreferences, setState, visibleRows, } from "../scheduler/plan.js";
5
5
  import { HUD_PRESETS, builtinThemes, loadConfig, loadTheme, paint, saveConfig, userThemes, windowBounds, } from "../runtime/kernel.mjs";
6
6
  import { hookInstalled, installPlan, runInstall } from "./install.js";
7
7
  import { colorEnabled, padEndVisible, visibleWidth } from "../util/ansi.js";
8
8
  import { ago } from "../util/fmt.js";
9
9
  const REFRESH_MS = 2500;
10
- const STEP = 0.05;
11
10
  const MAX_CUSTOM = 200;
12
11
  const ALT_ON = "\u001b[?1049h";
13
12
  const ALT_OFF = "\u001b[?1049l";
@@ -504,6 +503,10 @@ export async function runControl(options) {
504
503
  else if (activate) {
505
504
  if (current.kind === "reset")
506
505
  resetPreferences();
506
+ else if (current.kind === "window")
507
+ cycleWindow();
508
+ else if (current.kind === "step")
509
+ cycleStep(1);
507
510
  else if (current.kind === "column")
508
511
  toggleColumn(current.id);
509
512
  else if (current.kind === "segment")
@@ -519,7 +522,11 @@ export async function runControl(options) {
519
522
  }
520
523
  else if (action.kind === "share") {
521
524
  const delta = action.delta > 0 ? 1 : -1;
522
- if (current.kind === "theme")
525
+ if (current.kind === "window")
526
+ cycleWindow();
527
+ else if (current.kind === "step")
528
+ cycleStep(delta);
529
+ else if (current.kind === "theme")
523
530
  cycleTheme(current.surface, delta, names);
524
531
  else if (current.kind === "policy")
525
532
  cyclePolicy(delta);
@@ -644,7 +651,7 @@ export async function runControl(options) {
644
651
  draw();
645
652
  return;
646
653
  }
647
- for (const action of keyActions(String(chunk), mode === "settings" ? "prefs" : "plan", STEP)) {
654
+ for (const action of keyActions(String(chunk), mode === "settings" ? "prefs" : "plan", control.config.step ?? 0.05)) {
648
655
  if (!apply(action))
649
656
  return;
650
657
  }
@@ -105,6 +105,9 @@ function writeRules() {
105
105
  export function rulesInstalled() {
106
106
  return readMemory().includes(RULES_START);
107
107
  }
108
+ export function claudeCodeFound() {
109
+ return fs.existsSync(CLAUDE_HOME) || fs.existsSync(SETTINGS);
110
+ }
108
111
  export function hookInstalled() {
109
112
  return fs.existsSync(hookPath("hook.mjs"));
110
113
  }
@@ -140,6 +143,7 @@ export function runInstall(options) {
140
143
  process.exitCode = 1;
141
144
  return;
142
145
  }
146
+ const foundClaudeCode = claudeCodeFound();
143
147
  const settings = parsed;
144
148
  const existingStatusLine = settings.statusLine?.command;
145
149
  const ours = ourCommand(existingStatusLine);
@@ -156,7 +160,7 @@ export function runInstall(options) {
156
160
  ? ` keeps your existing status line ${dim("(--force wraps it and appends the SMT segment)")}`
157
161
  : conflict
158
162
  ? ` wraps your existing status line, then appends the SMT segment`
159
- : ` sets the status line, the only place Anthropic publishes your 5h and 7d usage`);
163
+ : ` sets the status line, the only place your 5h and 7d usage`);
160
164
  out.push(` backs up the current settings to ${path.join(HOME, "settings.backup.json")}`);
161
165
  if (options.rules)
162
166
  out.push(` adds a fenced token-discipline block to ${MEMORY}`);
@@ -223,6 +227,12 @@ export function runInstall(options) {
223
227
  writeSettings(settings);
224
228
  out.push(`${green("Installed.")} It takes effect in sessions you start from now on.`);
225
229
  out.push(dim(" Open the control centre with: npx savemytokens"));
230
+ if (!foundClaudeCode) {
231
+ out.push("");
232
+ out.push(yellow(" Claude Code was not found."));
233
+ out.push(dim(` Nothing was seen at ${CLAUDE_HOME}, so this machine may not have it installed.`));
234
+ out.push(dim(" The hooks are in place and will start working the first time you run it."));
235
+ }
226
236
  out.push("");
227
237
  if (!options.quiet)
228
238
  process.stdout.write(out.join("\n") + "\n");
@@ -352,18 +362,3 @@ function describeState() {
352
362
  return "";
353
363
  }
354
364
  }
355
- export function nudgeStats() {
356
- try {
357
- const parsed = JSON.parse(fs.readFileSync(path.join(HOME, "nudges.json"), "utf8"));
358
- if (!Array.isArray(parsed.events) || parsed.events.length === 0)
359
- return null;
360
- return {
361
- installedAt: parsed.installedAt ?? parsed.events[0]?.at ?? Date.now(),
362
- fired: parsed.events.length,
363
- usdAtStake: parsed.events.reduce((sum, event) => sum + (event.usd ?? 0), 0),
364
- };
365
- }
366
- catch {
367
- return null;
368
- }
369
- }
@@ -9,7 +9,7 @@ export function runPrivacy() {
9
9
  out.push("");
10
10
  out.push(` ${displayHome()}/claimants/ ${dim("one file per session: label, target share, priority, state")}`);
11
11
  out.push(` ${displayHome()}/meter/ ${dim("token counts in five-minute buckets, and how far each transcript was read")}`);
12
- out.push(` ${displayHome()}/quota/ ${dim("the 5h and 7d percentages Anthropic publishes to the status line")}`);
12
+ out.push(` ${displayHome()}/quota/ ${dim("the 5h and 7d percentages published to the status line")}`);
13
13
  out.push(` ${displayHome()}/hooks/ ${dim("the three scripts install puts there")}`);
14
14
  out.push(` ${displayHome()}/cache/ ${dim("per-session audit measurements, so repeat runs are fast")}`);
15
15
  out.push(dim(" · includes the first 120 characters of each prompt, so a finding or a row can"));
@@ -70,7 +70,7 @@ function footer(audit, previous, history, verbose) {
70
70
  return parts.length > 0 ? [dim(parts.join(" · "))] : [];
71
71
  }
72
72
  export function renderAudit(input) {
73
- const { audit, previous, history = [], nudges = null, installed = false, verbose = false } = input;
73
+ const { audit, previous, history = [], installed = false, verbose = false } = input;
74
74
  const columns = width();
75
75
  const out = [""];
76
76
  if (audit.totals.sessions === 0) {
@@ -125,12 +125,6 @@ export function renderAudit(input) {
125
125
  out.push(...wrap(dim(`${money(claudeUsd)} more is how Claude works, not how you work: ${claudes.map((f) => f.title.toLowerCase()).join(", ")}. Nothing for you to do by hand; install writes the rules that fix them.`), ""));
126
126
  out.push("");
127
127
  }
128
- if (nudges && installed) {
129
- out.push(`${green("Since you installed:")} ${nudges.fired} ${plural(nudges.fired, "warning")}, ${money(nudges.usdAtStake)} at stake ${dim(`(${ago(nudges.installedAt)})`)}`);
130
- }
131
- else if (!installed) {
132
- out.push(`${green("Do this:")} ${bold("npx savemytokens install")} ${dim("→ warns you before the next one, and gives each session a target share")}`);
133
- }
134
128
  out.push(...footer(audit, previous, history, verbose));
135
129
  if (verbose) {
136
130
  out.push("");
@@ -15,17 +15,17 @@ const SEGMENT_ABOUT = {
15
15
  target: "its allocation, as a percentage of the window",
16
16
  used: "how much of the window this session has spent",
17
17
  share: "its part of the tokens measured on disk",
18
- pair: "used and allocation together, as 21%/50%",
18
+ pair: "how much of your allocation is gone, and what it is, as 84% of 17%",
19
19
  bar: "how far through its allocation it is",
20
20
  priority: "HIGH, NORMAL or LOW",
21
- "5h": "the 5-hour window Anthropic publishes",
21
+ "5h": "the 5-hour window that is published",
22
22
  "7d": "the weekly window",
23
23
  spend: "a gateway spend limit, when you have one",
24
- reset: "how long until the 5-hour window resets",
24
+ reset: "how long until the 5-hour window comes back",
25
25
  meter5h: "the 5-hour window drawn as a bar",
26
26
  spark: "the shape of the window so far",
27
27
  pace: "how far ahead or behind the clock you are",
28
- empty: "when you run dry at this rate",
28
+ empty: "when you run dry, at this rate",
29
29
  };
30
30
  export function settingsRows(config) {
31
31
  const rows = [];
@@ -33,6 +33,10 @@ export function settingsRows(config) {
33
33
  for (const id of COLUMNS)
34
34
  rows.push({ kind: "column", id });
35
35
  rows.push({ kind: "blank" });
36
+ rows.push({ kind: "header", label: "THE WINDOW", hint: "← → changes it" });
37
+ rows.push({ kind: "window" });
38
+ rows.push({ kind: "step" });
39
+ rows.push({ kind: "blank" });
36
40
  rows.push({ kind: "header", label: "THEME", hint: "← → changes it" });
37
41
  rows.push({ kind: "theme", surface: "tui" });
38
42
  rows.push({ kind: "theme", surface: "hud" });
@@ -104,6 +108,7 @@ function themeNames() {
104
108
  return [...new Set([...builtinThemes(), ...userThemes()])];
105
109
  }
106
110
  const INDENT = 4;
111
+ export const STEPS = [0.01, 0.05, 0.1];
107
112
  function clockAt(ms) {
108
113
  const at = new Date(ms);
109
114
  return `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
@@ -213,6 +218,20 @@ export function renderSettings(config, rows, cursor, editing, draft, preview, th
213
218
  }
214
219
  continue;
215
220
  }
221
+ if (row.kind === "window") {
222
+ const weekly = config.window === "seven_day";
223
+ const shown = `${weekly ? paint(theme, "dim", "5h", color) : paint(theme, "accent", "[5h]", color)} ${weekly ? paint(theme, "accent", "[7d]", color) : paint(theme, "dim", "7d", color)}`;
224
+ const about = weekly ? "allocate against the weekly window" : "allocate against the 5-hour window";
225
+ out.push(` ${mark} ${padEndVisible("window", 15)} ${shown} ${paint(theme, "dim", clip(about, Math.max(0, width - 34)), color)}`);
226
+ continue;
227
+ }
228
+ if (row.kind === "step") {
229
+ const shown = STEPS.map((value) => Math.abs(value - config.step) < 1e-9
230
+ ? paint(theme, "accent", `[${Math.round(value * 100)}%]`, color)
231
+ : paint(theme, "dim", `${Math.round(value * 100)}%`, color)).join(" ");
232
+ out.push(` ${mark} ${padEndVisible("arrow step", 15)} ${shown} ${paint(theme, "dim", "how far ← → moves a target", color)}`);
233
+ continue;
234
+ }
216
235
  if (row.kind === "reset") {
217
236
  out.push(` ${mark} ${paint(theme, "warn", "reset everything to defaults", color)} ${paint(theme, "dim", "columns, themes, status line, policy. Your allocations stay.", color)}`);
218
237
  continue;
@@ -4,15 +4,17 @@ import { clip, padEndVisible, padStartVisible, visibleWidth } from "../util/ansi
4
4
  import { ago, compactNumber } from "../util/fmt.js";
5
5
  import { emptyBar, heatStrip, miniSpark, percentLabel, smallBar } from "./graphs.js";
6
6
  function barCellsFor(columns) {
7
+ if (columns >= 150)
8
+ return 32;
7
9
  if (columns >= 130)
8
- return 18;
10
+ return 26;
9
11
  if (columns >= 110)
10
- return 15;
12
+ return 21;
11
13
  if (columns >= 95)
12
- return 12;
14
+ return 17;
13
15
  if (columns >= 80)
14
- return 9;
15
- return 6;
16
+ return 12;
17
+ return 8;
16
18
  }
17
19
  const UNATTRIBUTED_FLOOR = 5;
18
20
  export function labelsFor(views) {
@@ -38,8 +40,10 @@ function capacityRow(control, context) {
38
40
  }
39
41
  return [` ${paint(theme, "warn", clip(head, context.columns - 2), color)}`];
40
42
  }
43
+ const wide = Math.max(12, Math.min(24, Math.floor((context.columns - 58) / 2)));
41
44
  const levels = [
42
- { bar: 12, reset: "clock", gap: 4 },
45
+ { bar: wide, reset: "clock", gap: 4 },
46
+ { bar: wide, reset: "long", gap: 4 },
43
47
  { bar: 12, reset: "long", gap: 4 },
44
48
  { bar: 10, reset: "short", gap: 3 },
45
49
  { bar: 6, reset: "short", gap: 2 },
@@ -133,11 +137,12 @@ function row(view, index, context, widths, columns) {
133
137
  const role = pressureRole(view.pressure.value);
134
138
  const cursor = context.interactive && context.selected === index ? paint(theme, "accent", theme.tui?.cursor ?? "❯", color) : " ";
135
139
  const pin = view.settings.pinned ? paint(theme, "accent", theme.tui?.pin ?? "★", color) : " ";
136
- const open = view.bucket === "active";
140
+ const live = view.bucket === "active";
141
+ const open = view.consuming ?? live;
137
142
  const tone = (want) => (open ? want : "dim");
138
143
  const sessions = view.liveSessions > 1 ? paint(theme, "dim", `${view.liveSessions}`, color) : " ";
139
144
  const label = padEndVisible(paint(theme, tone("fg"), clip(view.label, widths.label - 1), color), widths.label - 1);
140
- const held = view.allocation.target > 0 ? view.allocation.target : (view.settings.share ?? 0);
145
+ const held = open && view.allocation.target > 0 ? view.allocation.target : (view.settings.share ?? view.allocation.target);
141
146
  const asked = view.settings.share;
142
147
  const squeezed = asked != null && asked - held > 0.005;
143
148
  const allocationCell = padStartVisible(held > 0
@@ -161,8 +166,15 @@ function row(view, index, context, widths, columns) {
161
166
  cells.push(tokens);
162
167
  if (columns.includes("priority"))
163
168
  cells.push(priority);
169
+ const leaving = live && view.settings.kept === false;
164
170
  if (columns.includes("last prompt") && widths.prompt > 0) {
165
- cells.push(paint(theme, "dim", clip(view.prompt || "-", widths.prompt), color));
171
+ const tag = leaving && widths.prompt >= 18 ? "leaving" : "";
172
+ const room = tag ? widths.prompt - tag.length - 2 : widths.prompt;
173
+ const prompt = padEndVisible(clip(view.prompt || "-", room), tag ? room : 0);
174
+ cells.push(`${paint(theme, "dim", prompt, color)}${tag ? ` ${paint(theme, "warn", tag, color)}` : ""}`);
175
+ }
176
+ else if (leaving) {
177
+ cells.push(paint(theme, "warn", "leaving", color));
166
178
  }
167
179
  return cells.join(" ");
168
180
  }
@@ -200,14 +212,19 @@ function idleRow(view, index, context, widths, now, columns) {
200
212
  function footerNote(control, context) {
201
213
  const { theme, color } = context;
202
214
  const set = workingSet(control.schedule, context.expanded);
203
- const open = set.members.filter((view) => view.bucket === "active");
215
+ const open = set.members.filter((view) => view.consuming ?? view.bucket === "active");
204
216
  const sessions = open.reduce((sum, view) => sum + view.liveSessions, 0);
205
217
  const spare = Math.round(control.schedule.unusedPool * 100);
206
218
  const room = Math.max(10, context.columns - 2);
219
+ const leaving = set.members.filter((view) => view.bucket === "active" && view.settings.kept === false).length;
207
220
  const waiting = set.members.length - open.length;
208
221
  const long = `${open.length} of ${set.members.length} open, across ${sessions} ${sessions === 1 ? "session" : "sessions"}${waiting > 0 ? `, ${waiting} waiting` : ""}${spare > 0 ? `, ${spare}% unclaimed` : ""}.`;
209
222
  const short = `${open.length}/${set.members.length} open · ${sessions}s${spare > 0 ? ` · ${spare}% spare` : ""}`;
210
223
  const out = [` ${paint(theme, "dim", long.length <= room ? long : short, color)}`];
224
+ if (leaving > 0) {
225
+ const note = leaving === 1 ? "One project leaves ACTIVE when its window closes." : `${leaving} projects leave ACTIVE when their windows close.`;
226
+ out.push(` ${paint(theme, "dim", clip(note, room), color)}`);
227
+ }
211
228
  const drift = control.unattributed ?? 0;
212
229
  if (drift >= UNATTRIBUTED_FLOOR) {
213
230
  const head = `${Math.round(drift)}% of the window was spent outside these projects`;
@@ -385,6 +402,8 @@ export function helpOverlay(control, context) {
385
402
  out.push(` ${gutter} ${paint(theme, "dim", text, color)}`);
386
403
  }
387
404
  }
405
+ out.push(...helpSection("reading a row", context));
406
+ out.push(...helpProse("A star means you pinned it, and pinned rows keep the order you pinned them in. A number after it is how many Claude Code windows are open in that project, shown only when there is more than one. A dimmed row is holding its share while nothing runs there.", context));
388
407
  out.push(...helpSection("the two tables", context));
389
408
  out.push(...helpProse("ACTIVE is what shares your window. A project joins it on its own the moment you open Claude Code there, and stays after you close it, holding whatever target you gave it.", context));
390
409
  out.push("");
@@ -399,10 +418,10 @@ export function helpOverlay(control, context) {
399
418
  out.push(...helpProse("So allocation is your intent and priority is the tie-break. If pinned targets already add up to the whole window there is no spare, and priority does nothing.", context));
400
419
  out.push(...helpSection("the status line", context));
401
420
  out.push(...helpProse(control.installed
402
- ? "Installed. It is the only place Anthropic publishes your 5h and 7d usage, and it is what proves a session is still open, so without it nothing here is live. Change its shape with P."
403
- : "Not installed, so nothing here is live. It is the only place Anthropic publishes your 5h and 7d usage, and it is what proves a session is still open. Run: npx savemytokens install", context));
421
+ ? "Installed. It is the only place your 5h and 7d usage is published, and it is what proves a session is still open, so without it nothing here is live. Change its shape with P."
422
+ : "Not installed, so nothing here is live. It is the only place your 5h and 7d usage is publishes your 5h and 7d usage, and it is what proves a session is still open. Run: npx savemytokens install", context));
404
423
  out.push(...helpSection("the numbers", context));
405
- out.push(...helpProse("5h and 7d are Anthropic's own. Share is measured from the tokens in your transcripts. Used of it is their number split by that share, so the split between rows is ours, not theirs.", context));
424
+ out.push(...helpProse("5h and 7d are published figures, not ours. Share is measured from the tokens in your transcripts. Used of it is their number split by that share, so the split between rows is ours, not theirs.", context));
406
425
  const drift = control.unattributed ?? 0;
407
426
  if (drift >= UNATTRIBUTED_FLOOR) {
408
427
  out.push("");
@@ -21,13 +21,6 @@ import {
21
21
  } from "./kernel.mjs";
22
22
 
23
23
  const ADAPTER = "claude-code";
24
- const NUDGE_FILE = path.join(HOME, "nudges.json");
25
- const NUDGE_THRESHOLD = 150000;
26
- const NUDGE_COOLDOWN_MS = 30 * 60 * 1000;
27
- const NUDGE_HORIZON_TURNS = 10;
28
- const NUDGE_TAIL_BYTES = 262144;
29
- const RATE_PER_TOKEN = 5 / 1000000;
30
- const CACHE_READ_WEIGHT = 0.1;
31
24
  const MAX_SUBAGENT_DEPTH = 4;
32
25
  const ANAPHORIC =
33
26
  /^\s*(ok|okay|now|also|and|then|next|again|yes|no|nope|yep|same|do the same|the other|these|those|them|it|that|this|continue|carry on|keep going|go on|more|another|fix (it|that|this)|try again|redo|revert|undo|hmm|wait|great|nice|thanks|perfect|good)\b/i;
@@ -102,68 +95,6 @@ function guidance(id, project, now) {
102
95
  });
103
96
  }
104
97
 
105
- function selfContained(prompt) {
106
- const text = String(prompt || "").trim();
107
- return text.length >= 40 && !ANAPHORIC.test(text);
108
- }
109
-
110
- function tailContext(file) {
111
- let fd;
112
- try {
113
- fd = fs.openSync(file, "r");
114
- } catch {
115
- return 0;
116
- }
117
- try {
118
- const size = fs.fstatSync(fd).size;
119
- const start = Math.max(0, size - NUDGE_TAIL_BYTES);
120
- const buffer = Buffer.alloc(size - start);
121
- fs.readSync(fd, buffer, 0, buffer.length, start);
122
- const lines = buffer.toString("utf8").split("\n");
123
- for (let i = lines.length - 1; i >= 0; i--) {
124
- const line = lines[i];
125
- if (!line || !line.includes('"usage"')) continue;
126
- let record;
127
- try {
128
- record = JSON.parse(line);
129
- } catch {
130
- continue;
131
- }
132
- const usage = record?.message?.usage;
133
- if (!usage) continue;
134
- const total =
135
- (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
136
- if (total > 0) return total;
137
- }
138
- return 0;
139
- } catch {
140
- return 0;
141
- } finally {
142
- try {
143
- fs.closeSync(fd);
144
- } catch {}
145
- }
146
- }
147
-
148
- function deadCarryNudge(payload, now) {
149
- if (!selfContained(payload.prompt)) return "";
150
- const transcript = payload.transcript_path;
151
- if (typeof transcript !== "string" || !fs.existsSync(transcript)) return "";
152
- const context = tailContext(transcript);
153
- if (context < NUDGE_THRESHOLD) return "";
154
-
155
- const state = readJson(NUDGE_FILE, null);
156
- const events = Array.isArray(state?.events) ? state.events : [];
157
- const previous = events.filter((event) => event.session === payload.session_id).pop();
158
- if (previous && now - previous.at < NUDGE_COOLDOWN_MS) return "";
159
-
160
- const horizon = context * CACHE_READ_WEIGHT * RATE_PER_TOKEN * NUDGE_HORIZON_TURNS;
161
- events.push({ at: now, session: payload.session_id, context, usd: horizon });
162
- writeJson(NUDGE_FILE, { installedAt: state?.installedAt ?? now, events: events.slice(-500) });
163
-
164
- return `[savemytokens] This reads as a new task and ${Math.round(context / 1000)}k tokens of earlier work are still in context, about $${horizon.toFixed(2)} per ${NUDGE_HORIZON_TURNS} turns from here. Open your reply with one short line saying so, and that /clear or a fresh session drops it. Then do what was asked.`;
165
- }
166
-
167
98
  function run() {
168
99
  const event = process.argv[2] ?? "";
169
100
  const payload = readInput();
@@ -212,8 +143,6 @@ function run() {
212
143
  });
213
144
  const advice = guidance(id, project, now);
214
145
  if (advice) lines.push(advice);
215
- const nudge = deadCarryNudge(payload, now);
216
- if (nudge) lines.push(nudge);
217
146
  }
218
147
 
219
148
  if (event === "stop") {
@@ -25,6 +25,7 @@ const LOCKOUT_GAP_MS = 5 * 60 * 1000;
25
25
  const STALE_MS = 45 * 60 * 1000;
26
26
  const HEARTBEAT_MS = 60 * 1000;
27
27
  const RECENT_MS = 24 * 60 * 60 * 1000;
28
+ const IDLE_MS = 10 * 60 * 1000;
28
29
  const DEFER_LIMIT = 12;
29
30
  const DEFER_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
30
31
  const CHUNK = 1 << 20;
@@ -61,6 +62,8 @@ function listJson(dir) {
61
62
  }
62
63
  }
63
64
 
65
+ const DEFAULT_SEGMENTS = ["bar", "pair", "5h", "reset"];
66
+
64
67
  export const DEFAULT_CONFIG = {
65
68
  version: 1,
66
69
  createdAt: 0,
@@ -69,10 +72,12 @@ export const DEFAULT_CONFIG = {
69
72
  primerSeenAt: 0,
70
73
  theme: { tui: "default", hud: "default" },
71
74
  layout: { hud: "allocation" },
75
+ window: "five_hour",
76
+ step: 0.05,
72
77
  policy: "finish",
73
78
  policyFor: {},
74
79
  columns: ["allocation", "used", "priority", "last prompt"],
75
- hud: { segments: ["project", "pair", "5h", "reset"] },
80
+ hud: { segments: [...DEFAULT_SEGMENTS] },
76
81
  preserveFor: {},
77
82
  customAdvice: {},
78
83
  wrappedStatusLine: null,
@@ -104,12 +109,13 @@ export function loadConfig() {
104
109
  ...(stored.theme?.hud && THEME_RENAMES[stored.theme.hud] ? { hud: THEME_RENAMES[stored.theme.hud] } : {}),
105
110
  },
106
111
  layout: { ...DEFAULT_CONFIG.layout, ...(stored.layout || {}) },
112
+ window: stored.window === "seven_day" ? "seven_day" : "five_hour",
113
+ step: [0.01, 0.05, 0.1].includes(stored.step) ? stored.step : DEFAULT_CONFIG.step,
107
114
  columns: migrateColumns(stored.columns),
108
115
  hud: {
109
- segments:
110
- Array.isArray(stored.hud?.segments) && stored.hud.segments.length > 0
111
- ? stored.hud.segments
112
- : presetSegments(stored.layout?.hud) ?? DEFAULT_CONFIG.hud.segments,
116
+ segments: Array.isArray(stored.hud?.segments)
117
+ ? stored.hud.segments
118
+ : presetSegments(stored.layout?.hud) ?? DEFAULT_CONFIG.hud.segments,
113
119
  },
114
120
  policyFor: { ...(stored.policyFor || {}) },
115
121
  preserveFor: { ...(stored.preserveFor || {}) },
@@ -315,7 +321,7 @@ export function projectKey(project) {
315
321
  }
316
322
 
317
323
  function blankProject(project) {
318
- return { schema: 1, project, label: project ? projectLabel(project) : "unknown", share: null, priority: "normal", cap: null, pinned: false, parked: false, kept: null };
324
+ return { schema: 1, project, label: project ? projectLabel(project) : "unknown", share: null, priority: "normal", cap: null, pinned: false, pinnedAt: 0, parked: false, kept: null };
319
325
  }
320
326
 
321
327
  export function loadProject(adapter, project) {
@@ -810,17 +816,17 @@ export function schedule(adapter, now = Date.now(), key = "five_hour", quotaOver
810
816
  group.lastSeen = Math.max(group.lastSeen, claimant.lastSeen ?? 0);
811
817
  }
812
818
 
813
- const entries = [...groups.values()].map((group) => {
814
- const running = group.sessions.some((session) => session.bucket === "active");
815
- return {
816
- id: group.project,
817
- share: group.settings.share,
818
- priority: group.settings.priority,
819
- state: running ? "active" : "done",
820
- consumed: live ? (live.usedPercent / 100) * group.observed : 0,
821
- cap: group.settings.cap,
822
- };
823
- });
819
+ const consumingNow = (group) =>
820
+ group.sessions.some((session) => session.bucket === "active") && now - (group.lastSeen ?? 0) <= IDLE_MS;
821
+
822
+ const entries = [...groups.values()].map((group) => ({
823
+ id: group.project,
824
+ share: group.settings.share,
825
+ priority: group.settings.priority,
826
+ state: consumingNow(group) ? "active" : "done",
827
+ consumed: live ? (live.usedPercent / 100) * group.observed : 0,
828
+ cap: group.settings.cap,
829
+ }));
824
830
 
825
831
  const eligible = entries.filter((entry) => entry.state === "active").length;
826
832
  const { targets, unusedPool } = allocate(entries);
@@ -863,6 +869,7 @@ export function schedule(adapter, now = Date.now(), key = "five_hour", quotaOver
863
869
  })
864
870
  .sort((a, b) => b.observed - a.observed);
865
871
 
872
+ const consuming = consumingNow(group);
866
873
  const bucket = sessions.some((session) => session.bucket === "active")
867
874
  ? "active"
868
875
  : group.settings.parked
@@ -881,6 +888,7 @@ export function schedule(adapter, now = Date.now(), key = "five_hour", quotaOver
881
888
  usage: { tokens: group.tokens, weighted: group.weighted, requests: group.requests },
882
889
  lastSeen: group.lastSeen,
883
890
  bucket,
891
+ consuming,
884
892
  attributedPercent: live ? live.usedPercent * group.observed : null,
885
893
  pressure:
886
894
  live || eligible > 1
@@ -1288,7 +1296,7 @@ export function formatCountdown(resetsAt, now = Date.now()) {
1288
1296
  if (minutes < 60) return `${minutes}m`;
1289
1297
  const hours = Math.floor(minutes / 60);
1290
1298
  const rest = minutes % 60;
1291
- if (hours < 24) return rest === 0 ? `${hours}h` : `${hours}h${String(rest).padStart(2, "0")}`;
1299
+ if (hours < 24) return rest === 0 ? `${hours}h` : `${hours}h${String(rest).padStart(2, "0")}m`;
1292
1300
  const days = Math.floor(hours / 24);
1293
1301
  return `${days}d${hours % 24 > 0 ? `${hours % 24}h` : ""}`;
1294
1302
  }
@@ -1328,19 +1336,21 @@ export const HUD_SEGMENTS = [
1328
1336
  ];
1329
1337
 
1330
1338
  export const HUD_PRESETS = {
1331
- default: ["bar", "pair", "5h", "reset"],
1339
+ default: [...DEFAULT_SEGMENTS],
1332
1340
  minimal: ["bar", "pair"],
1333
1341
  window: ["5h", "reset", "7d"],
1334
1342
  pacing: ["bar", "pace", "5h", "reset"],
1335
1343
  everything: ["project", "target", "used", "priority", "meter5h", "5h", "7d", "reset"],
1344
+ off: [],
1336
1345
  };
1337
1346
 
1338
1347
  export const HUD_PRESET_ABOUT = {
1339
1348
  default: "a bar for your share, then the numbers",
1340
1349
  minimal: "the bar and your share, nothing else",
1341
- window: "only Anthropic's numbers, no per-project detail",
1350
+ window: "only the published numbers, no per-project detail",
1342
1351
  pacing: "whether you are ahead of or behind the clock",
1343
1352
  everything: "every number there is",
1353
+ off: "nothing is drawn, and the numbers still arrive",
1344
1354
  };
1345
1355
 
1346
1356
  const HUD_PRESET_ALIASES = {
@@ -1395,11 +1405,12 @@ const SEGMENTS = {
1395
1405
  },
1396
1406
  share: (view, theme, on) => `${paint(theme, "dim", "share", on)} ${percentText((view.observed ?? 0) * 100)}`,
1397
1407
  pair: (view, theme, on) => {
1398
- const value = typeof view.used === "number" ? view.used : (view.observed ?? 0) * 100;
1399
- return `${paint(theme, pressureRole(view.pressure ?? 0), percentText(value), on)}${paint(theme, "dim", `/${percentText((view.target ?? 0) * 100)}`, on)}`;
1408
+ const target = (view.target ?? 0) * 100;
1409
+ const spent = (view.pressure ?? 0) * 100;
1410
+ return `${paint(theme, pressureRole(view.pressure ?? 0), percentText(spent), on)}${paint(theme, "dim", ` of ${percentText(target)}`, on)}`;
1400
1411
  },
1401
1412
  bar: (view, theme, on) =>
1402
- hudMeter(theme, view.pressure ?? 0, 8, pressureRole(view.pressure ?? 0), on, theme.glyphs?.hudFull ?? "\u28ff", theme.glyphs?.hudEmpty ?? "\u28c0"),
1413
+ hudMeter(theme, view.pressure ?? 0, 12, pressureRole(view.pressure ?? 0), on, theme.glyphs?.hudFull ?? "\u28ff", theme.glyphs?.hudEmpty ?? "\u28c0"),
1403
1414
  priority: (view, theme, on) => paint(theme, "dim", String(view.priority ?? "normal").toUpperCase(), on),
1404
1415
  "5h": (view, theme, on) => {
1405
1416
  const window = windowOf(view, "five_hour");
@@ -1417,11 +1428,11 @@ const SEGMENTS = {
1417
1428
  const window = windowOf(view, "five_hour");
1418
1429
  if (!window || typeof window.resetsAt !== "number") return "";
1419
1430
  const left = formatCountdown(window.resetsAt, view.now);
1420
- return left ? paint(theme, "dim", `in ${left}`, on) : "";
1431
+ return left ? paint(theme, "dim", `resets in ${left}`, on) : "";
1421
1432
  },
1422
1433
  meter5h: (view, theme, on) => {
1423
1434
  const window = windowOf(view, "five_hour");
1424
- return window ? hudMeter(theme, window.usedPercent / 100, 8, pressureRole(window.usedPercent / 100), on) : "";
1435
+ return window ? hudMeter(theme, window.usedPercent / 100, 12, pressureRole(window.usedPercent / 100), on) : "";
1425
1436
  },
1426
1437
  spark: (view, theme, on) => {
1427
1438
  const points = Array.isArray(view.history) ? view.history.slice(-12) : [];
@@ -1436,7 +1447,9 @@ const SEGMENTS = {
1436
1447
  if (!window || typeof view.from !== "number" || typeof view.to !== "number") return "";
1437
1448
  const elapsed = Math.max(0, Math.min(1, (view.now - view.from) / Math.max(1, view.to - view.from))) * 100;
1438
1449
  const ahead = window.usedPercent - elapsed;
1439
- return paint(theme, ahead > 5 ? "warn" : "ok", `${ahead >= 0 ? "+" : ""}${Math.round(ahead)} vs pace`, on);
1450
+ const points = Math.abs(Math.round(ahead));
1451
+ if (points < 1) return paint(theme, "ok", "on pace", on);
1452
+ return paint(theme, ahead > 5 ? "warn" : "ok", `${points}% ${ahead > 0 ? "ahead of" : "behind"} the clock`, on);
1440
1453
  },
1441
1454
  empty: (view, theme, on) => {
1442
1455
  const window = windowOf(view, "five_hour");
@@ -1444,7 +1457,7 @@ const SEGMENTS = {
1444
1457
  const at = view.now + ((100 - window.usedPercent) / view.rate) * 3600000;
1445
1458
  const resetsAt = typeof window.resetsAt === "number" ? window.resetsAt * 1000 : null;
1446
1459
  if (resetsAt !== null && at >= resetsAt) return paint(theme, "ok", "lasts the window", on);
1447
- return paint(theme, "danger", `empty ${formatReset(Math.floor(at / 1000), view.now)}`, on);
1460
+ return paint(theme, "danger", `runs dry ${formatReset(Math.floor(at / 1000), view.now)}`, on);
1448
1461
  },
1449
1462
  };
1450
1463
 
@@ -234,7 +234,8 @@ function run() {
234
234
  );
235
235
 
236
236
  const prefix = wrappedOutput(config, raw);
237
- process.stdout.write(prefix ? `${prefix} ${line}\n` : `${line}\n`);
237
+ if (!prefix && !line) return;
238
+ process.stdout.write(prefix ? (line ? `${prefix} ${line}\n` : `${prefix}\n`) : `${line}\n`);
238
239
  }
239
240
 
240
241
  try {
@@ -41,14 +41,15 @@ function unattributedPercent(plan) {
41
41
  }
42
42
  return drift > 0 ? drift : null;
43
43
  }
44
- export function buildPlan(now = Date.now(), withSweep = true, window = "five_hour", adapter = "claude-code") {
44
+ export function buildPlan(now = Date.now(), withSweep = true, window, adapter = "claude-code") {
45
45
  const provider = providerFor(adapter);
46
+ const chosen = window ?? (loadConfig().window === "seven_day" ? "seven_day" : "five_hour");
46
47
  if (withSweep) {
47
- const bounds = windowBounds(loadQuota(provider.id), window, now);
48
- const span = WINDOW_MS[window] ?? FIVE_HOUR_MS;
48
+ const bounds = windowBounds(loadQuota(provider.id), chosen, now);
49
+ const span = WINDOW_MS[chosen] ?? FIVE_HOUR_MS;
49
50
  provider.sweep(Math.min(bounds.from, now - span), now);
50
51
  }
51
- const plan = schedule(provider.id, now, window, null, provider.dataDir ?? null);
52
+ const plan = schedule(provider.id, now, chosen, null, provider.dataDir ?? null);
52
53
  const others = detectedProviders()
53
54
  .filter((other) => other.id !== provider.id)
54
55
  .map((other) => ({ id: other.id, label: other.label, resources: other.resources(now) }));
@@ -78,8 +79,15 @@ function byInterest(a, b) {
78
79
  const live = Number(b.bucket === "active") - Number(a.bucket === "active");
79
80
  if (live !== 0)
80
81
  return live;
81
- return (Number(b.settings.pinned) - Number(a.settings.pinned) ||
82
- (b.allocation.target || b.settings.share || 0) - (a.allocation.target || a.settings.share || 0) ||
82
+ const pinned = Number(b.settings.pinned) - Number(a.settings.pinned);
83
+ if (pinned !== 0)
84
+ return pinned;
85
+ if (a.settings.pinned && b.settings.pinned) {
86
+ const order = (a.settings.pinnedAt || 0) - (b.settings.pinnedAt || 0);
87
+ if (order !== 0)
88
+ return order;
89
+ }
90
+ return ((b.allocation.target || b.settings.share || 0) - (a.allocation.target || a.settings.share || 0) ||
83
91
  b.lastSeen - a.lastSeen);
84
92
  }
85
93
  export function workingSet(plan, full = false) {
@@ -181,6 +189,19 @@ export function resetPreferences() {
181
189
  wrappedStatusLine: config.wrappedStatusLine,
182
190
  });
183
191
  }
192
+ export function cycleWindow() {
193
+ const config = loadConfig();
194
+ config.window = config.window === "seven_day" ? "five_hour" : "seven_day";
195
+ saveConfig(config);
196
+ }
197
+ export function cycleStep(delta) {
198
+ const config = loadConfig();
199
+ const steps = [0.01, 0.05, 0.1];
200
+ const at = steps.findIndex((value) => Math.abs(value - config.step) < 1e-9);
201
+ const next = steps[(((at === -1 ? 1 : at) + (delta > 0 ? 1 : steps.length - 1)) % steps.length)];
202
+ config.step = next ?? 0.05;
203
+ saveConfig(config);
204
+ }
184
205
  export function toggleColumn(id) {
185
206
  const config = loadConfig();
186
207
  const columns = withToggled(config.columns, id);
@@ -248,7 +269,7 @@ export function saveCustomAdvice(project, text) {
248
269
  saveConfig(config);
249
270
  }
250
271
  export function setPinned(project, pinned, adapter = "claude-code") {
251
- upsertProject(adapter, project, { pinned });
272
+ upsertProject(adapter, project, { pinned, pinnedAt: pinned ? Date.now() : 0 });
252
273
  }
253
274
  export function setParked(project, parked, adapter = "claude-code") {
254
275
  upsertProject(adapter, project, { parked });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "savemytokens",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Give every Claude Code session a target share of your Claude window, and keep Claude aware of it. Local, private, zero setup.",
5
5
  "keywords": [
6
6
  "claude",
@@ -40,8 +40,11 @@
40
40
  "build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs",
41
41
  "dev": "npm run build && node dist/cli.js",
42
42
  "test": "npm run build && node --test test/*.test.js",
43
- "screenshot": "npm run build && COLORTERM=truecolor node scripts/screenshot.mjs",
44
- "prepublishOnly": "npm run build && npm test"
43
+ "screenshot": "npm run build && COLORTERM=truecolor node scripts/screenshot.mjs && rsvg-convert -w 1280 -h 640 assets/social.svg -o assets/social.png && cp assets/screenshot.svg assets/statusline.svg assets/f-numbers.svg assets/f-share.svg assets/f-agent.svg assets/f-resting.svg assets/f-defer.svg web/ && node scripts/stamp-assets.mjs",
44
+ "prepublishOnly": "npm run build && npm test",
45
+ "postpublish": "node scripts/release.mjs",
46
+ "release": "node scripts/release.mjs",
47
+ "drift": "node scripts/drift.mjs"
45
48
  },
46
49
  "devDependencies": {
47
50
  "@types/node": "^22.10.2",