savemytokens 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -35
- package/dist/cli-options.js +0 -1
- package/dist/commands/audit.js +1 -2
- package/dist/commands/control.js +11 -4
- package/dist/commands/install.js +1 -16
- package/dist/commands/privacy.js +1 -1
- package/dist/report/render.js +1 -7
- package/dist/report/settings.js +23 -4
- package/dist/report/views.js +32 -13
- package/dist/runtime/hook.mjs +0 -71
- package/dist/runtime/kernel.mjs +40 -27
- package/dist/runtime/statusline.mjs +2 -1
- package/dist/scheduler/plan.js +28 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# SaveMyTokens
|
|
6
6
|
|
|
7
|
-
### Decide which
|
|
7
|
+
### Decide which projects get your Claude allowance.
|
|
8
8
|
|
|
9
9
|
<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
10
|
<img src="https://img.shields.io/badge/dependencies-0-1a7f37?style=flat-square" alt="zero dependencies">
|
|
@@ -14,43 +14,73 @@
|
|
|
14
14
|
|
|
15
15
|
<br><br>
|
|
16
16
|
|
|
17
|
-
<img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/screenshot.svg" alt="The SaveMyTokens control centre:
|
|
17
|
+
<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
18
|
|
|
19
19
|
<br>
|
|
20
20
|
|
|
21
|
-
<img src="https://raw.githubusercontent.com/offbeatport/savemytokens/main/assets/statusline.svg" alt="A Claude Code status line: a braille bar, then
|
|
21
|
+
<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
22
|
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
25
|
<br>
|
|
26
26
|
|
|
27
|
+
You have four Claude Code windows open. One of them is a throwaway experiment and it is quietly
|
|
28
|
+
eating the afternoon, but there is one usage number for all four, so there is no way to know
|
|
29
|
+
which. By the time the window is gone, the work that mattered is the work that did not finish.
|
|
30
|
+
|
|
27
31
|
## Get started
|
|
28
32
|
|
|
29
33
|
```sh
|
|
30
34
|
npx savemytokens
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
That is the whole install.
|
|
34
|
-
|
|
35
|
-
installed once rather than fetched each time? `npm i -g savemytokens`, then the command is just
|
|
36
|
-
`savemytokens`.
|
|
37
|
+
That is the whole install. On the first run it offers to add four hooks and a status line to Claude
|
|
38
|
+
Code's `settings.json`, backing the file up first.
|
|
37
39
|
|
|
38
|
-
**Say yes.** The status line is the only place
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
- **Say yes.** The status line is the only place your 5h and weekly usage is published, and it is
|
|
41
|
+
what proves a session is still open. Nothing is live without it.
|
|
42
|
+
- **Don't want to see one?** Take **Install, no line**. Everything is installed and the line renders
|
|
43
|
+
nothing, so the numbers still arrive and your terminal looks unchanged.
|
|
44
|
+
- **Rather not fetch it each time?** `npm i -g savemytokens`, then the command is just
|
|
45
|
+
`savemytokens`.
|
|
46
|
+
- **Changed your mind?** `npx savemytokens uninstall` takes every entry back out.
|
|
41
47
|
|
|
42
48
|
<br>
|
|
43
49
|
|
|
44
50
|
| | |
|
|
45
51
|
| :-- | :-- |
|
|
46
|
-
| **The real numbers** | Your 5-hour and weekly usage exactly as
|
|
47
|
-
| **
|
|
48
|
-
| **
|
|
49
|
-
| **
|
|
50
|
-
| **In your status line** |
|
|
52
|
+
| **The real numbers** | Your 5-hour and weekly usage exactly as Claude Code publishes it to the status line. Never an invented *"% remaining"*. |
|
|
53
|
+
| **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. |
|
|
54
|
+
| **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. |
|
|
55
|
+
| **Capacity returned** | A project with nothing running lends its share to the rest and reclaims it when you come back. |
|
|
56
|
+
| **In your status line** | `⣿⣿⣿⣿⣿⣀⣀⣀⣀⣀⣀⣀ 42% of 50% · 5h 42% · resets in 3h52m`. Pick a shape, arrange the pieces yourself, or run it silent. |
|
|
51
57
|
|
|
52
58
|
<br>
|
|
53
59
|
|
|
60
|
+
## A worked example
|
|
61
|
+
|
|
62
|
+
Seven projects open, and the release matters more than the experiment.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npx savemytokens share burningdemand 20 # Set burningdemand target to 20%
|
|
66
|
+
npx savemytokens priority burningdemand high # burningdemand now takes spare capacity first
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The others split what is left. When one of them finishes, whatever it did not spend goes to
|
|
70
|
+
burningdemand, because it is the only project on the high tier.
|
|
71
|
+
|
|
72
|
+
<div align="center">
|
|
73
|
+
|
|
74
|
+
<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="730">
|
|
75
|
+
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
`coldverdict` is at 93% of its slice, so its next turn opens with a line telling it to finish what it
|
|
79
|
+
has and write down the rest. Whatever it drops comes back the next time you open that project.
|
|
80
|
+
|
|
81
|
+
The three pinned rows hold the order you pinned them in, whatever happens to the numbers beside
|
|
82
|
+
them. Nothing here required you to guess how many tokens anything costs.
|
|
83
|
+
|
|
54
84
|
## Commands
|
|
55
85
|
|
|
56
86
|
| | |
|
|
@@ -65,11 +95,21 @@ is what proves a session is still open, so nothing is live without it. Changed y
|
|
|
65
95
|
| `npx savemytokens theme` | eighteen themes, or write your own |
|
|
66
96
|
| `npx savemytokens defer` | work a session pushed to next time |
|
|
67
97
|
| `npx savemytokens audit` | what your last 7 days wasted |
|
|
98
|
+
| `npx savemytokens privacy` | every file it reads and writes |
|
|
68
99
|
| `npx savemytokens uninstall` | remove every trace · `--purge` drops the data too |
|
|
69
100
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
`--codex` points any of these at Codex instead, which publishes its own rate limits and needs no
|
|
102
|
+
hooks. It is metered and shown, but nothing can be injected into it, so there is no advice.
|
|
103
|
+
|
|
104
|
+
In a row, `★` means you pinned it and pinned rows keep the order you pinned them in; a number beside
|
|
105
|
+
it is how many Claude Code windows are open in that project, shown only when there is more than one;
|
|
106
|
+
a dimmed row is holding its share while nothing runs there.
|
|
107
|
+
|
|
108
|
+
Inside the control centre, `?` lists every key. The ones worth knowing: **`←→` moves an allocation,
|
|
109
|
+
and the others move to fit** so the window always adds up. `space` moves a project up a level and
|
|
110
|
+
`x` moves it down, so one you never want to see goes ACTIVE to RECENT to hidden, and `m` shows what
|
|
111
|
+
is hidden. `e` puts everything back to an even split, `p` cycles priority, `⏎` opens a project's
|
|
112
|
+
sessions, `s` opens settings.
|
|
73
113
|
|
|
74
114
|
## Where the numbers come from
|
|
75
115
|
|
|
@@ -77,30 +117,35 @@ Three different kinds of number sit on that screen, and it matters which is whic
|
|
|
77
117
|
|
|
78
118
|
| | |
|
|
79
119
|
| :-- | :-- |
|
|
80
|
-
| **5h and 7d** |
|
|
81
|
-
| **share** | Measured from the transcripts already on your disk, token by token, including subagents. Exact, and independent of anything
|
|
82
|
-
| **used, allocation** |
|
|
120
|
+
| **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. |
|
|
121
|
+
| **share** | Measured from the transcripts already on your disk, token by token, including subagents. Exact, and independent of anything the provider reports. |
|
|
122
|
+
| **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
123
|
|
|
84
124
|
Usage from another machine, or from claude.ai, is invisible to a local tool. When the window moves
|
|
85
|
-
|
|
86
|
-
into a project's figure.
|
|
125
|
+
across five minutes in which nothing local was metered, it is reported on its own line rather than
|
|
126
|
+
folded silently into a project's figure.
|
|
87
127
|
|
|
88
128
|
## How it works
|
|
89
129
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
their own session as it runs
|
|
93
|
-
|
|
94
|
-
byte offset per file, so a prompt costs
|
|
95
|
-
|
|
96
|
-
Nothing
|
|
97
|
-
|
|
130
|
+
- **No daemon.** Three scripts in `~/.savemytokens/hooks`, four hook entries and a status line in
|
|
131
|
+
Claude Code's `settings.json`, which is backed up first.
|
|
132
|
+
- **The hooks meter their own session** as it runs. The status line captures the published window
|
|
133
|
+
every ten seconds. The control centre reads what they leave behind.
|
|
134
|
+
- **Transcripts are read incrementally**, from a stored byte offset per file, so a prompt costs
|
|
135
|
+
milliseconds however long the session has run.
|
|
136
|
+
- **Nothing else is touched.** `uninstall` puts it back, and restores a status line of your own if
|
|
137
|
+
it wrapped one.
|
|
98
138
|
|
|
99
139
|
## Requirements
|
|
100
140
|
|
|
101
|
-
Node 18.17 or newer
|
|
102
|
-
subscription. On an API key or through the
|
|
103
|
-
rather than guessing, and allocation and
|
|
141
|
+
- **Node 18.17 or newer**, and **Claude Code 2.1 or newer**.
|
|
142
|
+
- **A Claude subscription** for the published 5h and 7d figures. On an API key or through the
|
|
143
|
+
Console no window is reported; SaveMyTokens says so rather than guessing, and allocation and
|
|
144
|
+
advice still work from measured usage alone.
|
|
145
|
+
- **macOS** is what it is developed and tested on. **Linux** should behave identically. **Windows**
|
|
146
|
+
is written for but unproven: paths and the home directory resolve portably, and the control centre
|
|
147
|
+
wants a terminal that handles the alternate screen buffer, which Windows Terminal does. If it
|
|
148
|
+
misbehaves there, please [open an issue](https://github.com/offbeatport/savemytokens/issues/new).
|
|
104
149
|
|
|
105
150
|
<details>
|
|
106
151
|
<summary><b>Writing your own theme</b></summary>
|
|
@@ -149,10 +194,13 @@ bar the built-in themes have to clear.
|
|
|
149
194
|
- **It advises; it does not enforce.** The hooks inject text, and a model does not hold a budget the
|
|
150
195
|
way a scheduler holds a lock. A session that ignores the advice keeps running. Hard caps are the
|
|
151
196
|
next step, and will ship only after a period of logging what *would* have been blocked.
|
|
152
|
-
- **It does not predict a lockout.**
|
|
197
|
+
- **It does not predict a lockout.** A percentage and a reset time are published. Anything
|
|
153
198
|
beyond those two facts would be a guess dressed up as a number.
|
|
154
199
|
- **It cannot see usage it did not measure.** Another machine, claude.ai, or work done before you
|
|
155
200
|
installed it shows up as unattributed window, labelled as such.
|
|
201
|
+
- **An idle window still holds its share.** A session with nothing running keeps its slice rather
|
|
202
|
+
than lending it out, so ten open terminals divide the window ten ways. Treating a target as a
|
|
203
|
+
weight among sessions actually consuming is the next fix.
|
|
156
204
|
- **It makes no network call.** No account, no telemetry, no daemon. `savemytokens privacy` prints
|
|
157
205
|
every file it reads and writes.
|
|
158
206
|
|
package/dist/cli-options.js
CHANGED
package/dist/commands/audit.js
CHANGED
|
@@ -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
|
|
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
|
}));
|
package/dist/commands/control.js
CHANGED
|
@@ -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 === "
|
|
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",
|
|
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
|
}
|
package/dist/commands/install.js
CHANGED
|
@@ -156,7 +156,7 @@ export function runInstall(options) {
|
|
|
156
156
|
? ` keeps your existing status line ${dim("(--force wraps it and appends the SMT segment)")}`
|
|
157
157
|
: conflict
|
|
158
158
|
? ` wraps your existing status line, then appends the SMT segment`
|
|
159
|
-
: ` sets the status line, the only place
|
|
159
|
+
: ` sets the status line, the only place your 5h and 7d usage`);
|
|
160
160
|
out.push(` backs up the current settings to ${path.join(HOME, "settings.backup.json")}`);
|
|
161
161
|
if (options.rules)
|
|
162
162
|
out.push(` adds a fenced token-discipline block to ${MEMORY}`);
|
|
@@ -352,18 +352,3 @@ function describeState() {
|
|
|
352
352
|
return "";
|
|
353
353
|
}
|
|
354
354
|
}
|
|
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
|
-
}
|
package/dist/commands/privacy.js
CHANGED
|
@@ -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
|
|
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"));
|
package/dist/report/render.js
CHANGED
|
@@ -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 = [],
|
|
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("");
|
package/dist/report/settings.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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
|
|
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;
|
package/dist/report/views.js
CHANGED
|
@@ -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
|
|
10
|
+
return 26;
|
|
9
11
|
if (columns >= 110)
|
|
10
|
-
return
|
|
12
|
+
return 21;
|
|
11
13
|
if (columns >= 95)
|
|
12
|
-
return
|
|
14
|
+
return 17;
|
|
13
15
|
if (columns >= 80)
|
|
14
|
-
return
|
|
15
|
-
return
|
|
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:
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
403
|
-
: "Not installed, so nothing here is live. It is the only place
|
|
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
|
|
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("");
|
package/dist/runtime/hook.mjs
CHANGED
|
@@ -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") {
|
package/dist/runtime/kernel.mjs
CHANGED
|
@@ -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: [
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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: [
|
|
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
|
|
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
|
|
1399
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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", `
|
|
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
|
-
|
|
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 {
|
package/dist/scheduler/plan.js
CHANGED
|
@@ -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
|
|
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),
|
|
48
|
-
const span = WINDOW_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,
|
|
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
|
-
|
|
82
|
-
|
|
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