sprag-cli 3.48.0 → 3.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +21 -1
- package/package.json +2 -1
- package/src/effort-palette.js +145 -0
- package/src/formatters/statusline.js +72 -5
- package/src/glyphs.js +8 -0
- package/src/stdin-payload.js +40 -0
- package/src/ultracode.js +202 -0
package/bin/cli.js
CHANGED
|
@@ -34,9 +34,13 @@ import {
|
|
|
34
34
|
readStdinJson,
|
|
35
35
|
extractCaps,
|
|
36
36
|
extractContextUsage,
|
|
37
|
+
extractEffort,
|
|
37
38
|
extractModel,
|
|
39
|
+
extractTranscriptPath,
|
|
38
40
|
} from '../src/stdin-payload.js';
|
|
39
41
|
|
|
42
|
+
import { effortChipLevel } from '../src/ultracode.js';
|
|
43
|
+
|
|
40
44
|
import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
|
|
41
45
|
import {
|
|
42
46
|
dailyTrend,
|
|
@@ -514,6 +518,9 @@ async function main() {
|
|
|
514
518
|
const stdinJson = readStdinJson();
|
|
515
519
|
const caps = extractCaps(stdinJson);
|
|
516
520
|
const model = extractModel(stdinJson);
|
|
521
|
+
// Resolved the same way as on the populated line below, so the chip does
|
|
522
|
+
// not change its mind about the level when the analysis window empties.
|
|
523
|
+
const effort = effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson));
|
|
517
524
|
if (caps || model) {
|
|
518
525
|
try {
|
|
519
526
|
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
@@ -521,7 +528,7 @@ async function main() {
|
|
|
521
528
|
} catch (e) { debug('caps-cache:persist', e); }
|
|
522
529
|
}
|
|
523
530
|
console.log(formatNoSession(
|
|
524
|
-
{ caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
|
|
531
|
+
{ caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
|
|
525
532
|
{ color: colorOk, mode: labelMode },
|
|
526
533
|
));
|
|
527
534
|
return;
|
|
@@ -567,6 +574,18 @@ async function main() {
|
|
|
567
574
|
const stdinJson = readStdinJson();
|
|
568
575
|
let caps = extractCaps(stdinJson);
|
|
569
576
|
let model = extractModel(stdinJson);
|
|
577
|
+
// Not persisted to the caps cache the way caps/model are: the cache exists so
|
|
578
|
+
// the table view (which pipes no stdin) can still show them, and a level the
|
|
579
|
+
// user has since changed would be worse there than no chip at all. The effort
|
|
580
|
+
// chip is a live-statusline reading only.
|
|
581
|
+
//
|
|
582
|
+
// `ultracode` is the one level the payload cannot name — Claude Code raises it
|
|
583
|
+
// to xhigh and reports that — so the transcript settles which of the two this
|
|
584
|
+
// session is on. Only an xhigh payload can resolve to ultracode, so a session
|
|
585
|
+
// on any other level pays nothing for the check (see src/ultracode.js).
|
|
586
|
+
const effort = isStatusline
|
|
587
|
+
? effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson))
|
|
588
|
+
: extractEffort(stdinJson);
|
|
570
589
|
const ctxLive = extractContextUsage(stdinJson);
|
|
571
590
|
if (isStatusline && (caps || model)) {
|
|
572
591
|
try {
|
|
@@ -758,6 +777,7 @@ async function main() {
|
|
|
758
777
|
spikeChip,
|
|
759
778
|
caps,
|
|
760
779
|
model,
|
|
780
|
+
effort,
|
|
761
781
|
delegationSaved,
|
|
762
782
|
delegationTotals,
|
|
763
783
|
doc2mdTotals,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.50.0",
|
|
4
4
|
"description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"postinstall": "node bin/cli.js install || true",
|
|
14
14
|
"publish:legacy": "node scripts/publish-legacy.mjs",
|
|
15
15
|
"release:notes": "node scripts/release-notes.mjs",
|
|
16
|
+
"site:docs": "node scripts/build-site-docs.mjs",
|
|
16
17
|
"test": "node --test"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effort palette — Claude Code's own colors for the effort levels.
|
|
3
|
+
*
|
|
4
|
+
* The statusline used to tint the effort chip from this tool's muted palette,
|
|
5
|
+
* which meant the level you picked in `/effort` and the level the chip reported
|
|
6
|
+
* were the same word in different colors. These are the app's values instead, so
|
|
7
|
+
* the chip echoes the color the picker showed.
|
|
8
|
+
*
|
|
9
|
+
* The mapping is not per-level color keys — Claude Code 2.1.276 keeps a slider
|
|
10
|
+
* table whose entries name SEMANTIC theme colors, which is why grepping for
|
|
11
|
+
* `effort*` keys finds only the last row:
|
|
12
|
+
*
|
|
13
|
+
* lo=[{value:"low", label:"low", color:"warning"},
|
|
14
|
+
* {value:"medium",label:"medium",color:"success"},
|
|
15
|
+
* {value:"high", label:"high", color:"permission"},
|
|
16
|
+
* {value:"xhigh", label:"xhigh", color:"autoAccept-shimmer"},
|
|
17
|
+
* {value:"max", label:"max", color:"rainbow-animated"}]
|
|
18
|
+
*
|
|
19
|
+
* and ultracode is painted separately, in `effortUltra`:
|
|
20
|
+
*
|
|
21
|
+
* function qCe(h){if(h){let E=Vre(Po("theme","dark").value);return bt("effortUltra",E)("ultracode")}}
|
|
22
|
+
*
|
|
23
|
+
* Two of those five are animated, and the statusline cannot animate: Claude Code
|
|
24
|
+
* re-runs the command on an interval measured in seconds (`statusLine.refreshInterval`,
|
|
25
|
+
* clamped to `Math.max(1,n)*1000`) and each run is a new process with no frame
|
|
26
|
+
* state, so a cycling color would read as a flicker rather than a shimmer. Both
|
|
27
|
+
* are therefore rendered as their still form — `xhigh` takes the `autoAccept`
|
|
28
|
+
* base color, and `max` takes the rainbow as a gradient across the word instead
|
|
29
|
+
* of a cycle over time.
|
|
30
|
+
*
|
|
31
|
+
* VALUES are lifted verbatim from the six theme tables in the 2.1.276 binary.
|
|
32
|
+
* `dark` is the default (`Po("theme","dark")`), where `autoAccept` and
|
|
33
|
+
* `effortUltra` are the same color: Claude Code separates `xhigh` from
|
|
34
|
+
* `ultracode` by shimmering one of them, not by hue.
|
|
35
|
+
*
|
|
36
|
+
* Dropping the animation would therefore leave the top two levels identical, so
|
|
37
|
+
* `ultracode` takes the shimmer's own two endpoints as a GLOW instead — a ramp
|
|
38
|
+
* from `autoAccept` up to `autoAcceptShimmer` and back down, spread across the
|
|
39
|
+
* word. Same device as `max`: what the app says over time, the chip says across
|
|
40
|
+
* the letters. No new colors are invented, and the level that costs the most is
|
|
41
|
+
* the one that looks lit.
|
|
42
|
+
*
|
|
43
|
+
* An 8-color terminal has no ramp to spend, so there `ultracode` stays the flat
|
|
44
|
+
* bright magenta and the level word is the only thing separating it from xhigh.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { readFileSync } from 'node:fs';
|
|
48
|
+
import { homedir } from 'node:os';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
|
|
51
|
+
/** The theme names Claude Code accepts, from its own settings enum. */
|
|
52
|
+
export const THEME_NAMES = ['dark', 'light', 'light-daltonized', 'dark-daltonized', 'light-ansi', 'dark-ansi'];
|
|
53
|
+
|
|
54
|
+
/** 24-bit values, per theme, for the four semantic keys the slider names. */
|
|
55
|
+
const RGB = {
|
|
56
|
+
dark: { warning: [255, 193, 7], success: [78, 186, 101], permission: [177, 185, 249], autoAccept: [175, 135, 255], ultra: [175, 135, 255] },
|
|
57
|
+
light: { warning: [150, 108, 30], success: [44, 122, 57], permission: [87, 105, 247], autoAccept: [135, 0, 255], ultra: [135, 0, 255] },
|
|
58
|
+
'dark-daltonized': { warning: [255, 204, 0], success: [51, 153, 255], permission: [153, 204, 255], autoAccept: [175, 135, 255], ultra: [175, 135, 255] },
|
|
59
|
+
'light-daltonized': { warning: [255, 153, 0], success: [0, 102, 153], permission: [51, 102, 255], autoAccept: [135, 0, 255], ultra: [135, 0, 255] },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The 8-color forms, from Claude Code's two ANSI themes. These are what a
|
|
64
|
+
* terminal without 24-bit color gets, and they are also the whole palette for a
|
|
65
|
+
* user who picked an ANSI theme on purpose.
|
|
66
|
+
*/
|
|
67
|
+
const ANSI = {
|
|
68
|
+
dark: { warning: '\x1b[93m', success: '\x1b[92m', permission: '\x1b[94m', autoAccept: '\x1b[95m', ultra: '\x1b[95m' },
|
|
69
|
+
light: { warning: '\x1b[33m', success: '\x1b[32m', permission: '\x1b[34m', autoAccept: '\x1b[35m', ultra: '\x1b[35m' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `autoAcceptShimmer` — the bright end of the pair Claude Code shimmers between,
|
|
74
|
+
* per theme. Only the dark/light split matters here, since the daltonized themes
|
|
75
|
+
* carry the same purple pair as their plain counterparts.
|
|
76
|
+
*/
|
|
77
|
+
const SHIMMER = {
|
|
78
|
+
dark: [208, 180, 255],
|
|
79
|
+
light: [208, 180, 255],
|
|
80
|
+
'dark-daltonized': [208, 180, 255],
|
|
81
|
+
'light-daltonized': [208, 180, 255],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The glow ramp for `ultracode`: base → shimmer → base, seven stops, so a word
|
|
86
|
+
* painted with it brightens toward the middle and settles again. Built by
|
|
87
|
+
* interpolation rather than hand-picked, so a theme whose purple differs still
|
|
88
|
+
* gets a smooth ramp.
|
|
89
|
+
*/
|
|
90
|
+
function glowRamp(base, peak) {
|
|
91
|
+
const t = [0, 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0];
|
|
92
|
+
return t.map((k) => base.map((ch, i) => Math.round(ch + (peak[i] - ch) * k)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `max` — rainbow_red … rainbow_violet. One table for every theme. */
|
|
96
|
+
const RAINBOW_RGB = [
|
|
97
|
+
[235, 95, 87], [245, 139, 87], [250, 195, 95], [145, 200, 130], [130, 170, 220], [155, 130, 200], [200, 130, 180],
|
|
98
|
+
];
|
|
99
|
+
/** Same seven, in the order the ANSI themes name them (red, redBright, yellow, green, cyan, blue, magenta). */
|
|
100
|
+
const RAINBOW_ANSI = ['\x1b[31m', '\x1b[91m', '\x1b[33m', '\x1b[32m', '\x1b[36m', '\x1b[34m', '\x1b[35m'];
|
|
101
|
+
|
|
102
|
+
const esc = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The theme Claude Code is running, resolved the way it resolves it: the config
|
|
106
|
+
* value if set, `dark` otherwise. Read best-effort — an unreadable or absent
|
|
107
|
+
* config is the default theme, never an error, because this feeds a statusline.
|
|
108
|
+
*/
|
|
109
|
+
export function claudeTheme() {
|
|
110
|
+
for (const path of [join(homedir(), '.claude.json'), join(homedir(), '.claude', 'settings.json')]) {
|
|
111
|
+
try {
|
|
112
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
113
|
+
const theme = raw && typeof raw.theme === 'string' ? raw.theme : null;
|
|
114
|
+
if (theme && THEME_NAMES.includes(theme)) return theme;
|
|
115
|
+
} catch { /* absent or malformed — keep looking, then default */ }
|
|
116
|
+
}
|
|
117
|
+
return 'dark';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Escapes for every effort level, keyed by level name. `max` is an ARRAY of
|
|
122
|
+
* escapes — one per rainbow stop — and every other level is a single escape, so
|
|
123
|
+
* a caller paints `max` character by character and the rest in one go.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {string} [opts.theme] - a Claude Code theme name; anything unknown reads as `dark`.
|
|
127
|
+
* @param {boolean} [opts.truecolor=true] - false emits the ANSI forms.
|
|
128
|
+
*/
|
|
129
|
+
export function effortTones({ theme = claudeTheme(), truecolor = true } = {}) {
|
|
130
|
+
const ansiTheme = theme.startsWith('light') ? 'light' : 'dark';
|
|
131
|
+
// An ANSI theme is a deliberate choice, so it wins over terminal capability.
|
|
132
|
+
const useAnsi = !truecolor || theme.endsWith('-ansi');
|
|
133
|
+
const keys = useAnsi ? ANSI[ansiTheme] : (RGB[theme] || RGB.dark);
|
|
134
|
+
const tone = (key) => (useAnsi ? keys[key] : esc(keys[key]));
|
|
135
|
+
return {
|
|
136
|
+
low: tone('warning'),
|
|
137
|
+
medium: tone('success'),
|
|
138
|
+
high: tone('permission'),
|
|
139
|
+
xhigh: tone('autoAccept'),
|
|
140
|
+
max: useAnsi ? RAINBOW_ANSI : RAINBOW_RGB.map(esc),
|
|
141
|
+
ultracode: useAnsi
|
|
142
|
+
? keys.ultra
|
|
143
|
+
: glowRamp(keys.ultra, SHIMMER[theme] || SHIMMER.dark).map(esc),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -23,6 +23,7 @@ import { labelForKey } from '../window-labels.js';
|
|
|
23
23
|
import { harnessStatusForStatusline } from '../harness.js';
|
|
24
24
|
import { loadConfig } from '../config.js';
|
|
25
25
|
import { koreanStyleEnabled } from '../korean-style.js';
|
|
26
|
+
import { effortTones } from '../effort-palette.js';
|
|
26
27
|
import { MIN_VOTES } from '../model-alias.js';
|
|
27
28
|
|
|
28
29
|
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
@@ -208,6 +209,63 @@ function buildKoreanSeg(c, isIcon, verbose, g) {
|
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Effort tones are not this palette's to choose: they are Claude Code's own, so
|
|
214
|
+
* the chip shows a level in the color its `/effort` picker showed when the level
|
|
215
|
+
* was set. src/effort-palette.js holds the values and the evidence for them,
|
|
216
|
+
* including why `xhigh` and `ultracode` share a color once the shimmer that
|
|
217
|
+
* separates them in the app is dropped.
|
|
218
|
+
*
|
|
219
|
+
* This replaced a scheme of this tool's own devising (gray below default, the
|
|
220
|
+
* model chip's violet at default, bold above it). That scheme was internally
|
|
221
|
+
* tidy and still wrong in the only way that matters: the user picks a level in
|
|
222
|
+
* one UI and reads it back in another, and the two disagreed about what the
|
|
223
|
+
* level looks like. Matching the app also means the chip now uses yellow at
|
|
224
|
+
* `low`, which this line otherwise reserves for "act on this eventually" — a
|
|
225
|
+
* deliberate exception, since the color is quoting Claude Code rather than
|
|
226
|
+
* making a claim about the level.
|
|
227
|
+
*
|
|
228
|
+
* An unlisted level (a new one Anthropic ships) reads as `high`, which is what
|
|
229
|
+
* Claude Code resolves an unset effort to.
|
|
230
|
+
*/
|
|
231
|
+
const EFFORT_TONES = effortTones({ truecolor: TRUECOLOR });
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Effort chip builder — the level from Claude Code's stdin payload
|
|
235
|
+
* (`effort.level`, see extractEffort), or `ultracode` when the entry point
|
|
236
|
+
* resolved that from the transcript (see effortChipLevel).
|
|
237
|
+
*
|
|
238
|
+
* The level is a word ("high"), so icon modes need no label beside the glyph:
|
|
239
|
+
* `🔬 high` already reads. Text mode names the field, because a bare "high"
|
|
240
|
+
* next to a model name would be anyone's guess.
|
|
241
|
+
*/
|
|
242
|
+
function buildEffortSeg(effort, c, isIcon, g) {
|
|
243
|
+
if (typeof effort !== 'string' || effort.length === 0) return null;
|
|
244
|
+
// `hasOwn` rather than a bare index, for the reason model-alias.js gives at
|
|
245
|
+
// its own lookup: the level is a string off a JSON payload, and a level of
|
|
246
|
+
// 'constructor' or '__proto__' would resolve through Object.prototype to a
|
|
247
|
+
// function or an object. A `||` fallback cannot catch that, since both are
|
|
248
|
+
// truthy — the tone would be interpolated as
|
|
249
|
+
// `function Object() { [native code] }` and take the whole line with it.
|
|
250
|
+
const tone = Object.hasOwn(EFFORT_TONES, effort) ? EFFORT_TONES[effort] : EFFORT_TONES.high;
|
|
251
|
+
const head = isIcon ? `${g.effort} ` : 'Effort ';
|
|
252
|
+
// `max` arrives as the rainbow's seven stops (Claude Code cycles them; see
|
|
253
|
+
// effort-palette.js for why a statusline cannot). The stops are spread across
|
|
254
|
+
// the word rather than cycled through it: "max" is three characters, so
|
|
255
|
+
// cycling would show only the first three stops — red, orange, yellow, which
|
|
256
|
+
// reads as a warm gradient and not as a rainbow. Spreading walks the whole
|
|
257
|
+
// arc whatever the word's length.
|
|
258
|
+
if (Array.isArray(tone)) {
|
|
259
|
+
const chars = [...effort];
|
|
260
|
+
const last = Math.max(1, chars.length - 1);
|
|
261
|
+
const word = chars
|
|
262
|
+
.map((ch, i) => `${c(tone[Math.round((i * (tone.length - 1)) / last)])}${ch}`)
|
|
263
|
+
.join('');
|
|
264
|
+
return `${c(tone[0])}${head}${word}${c(RESET)}`;
|
|
265
|
+
}
|
|
266
|
+
return `${c(tone)}${head}${effort}${c(RESET)}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
211
269
|
/**
|
|
212
270
|
* Version segment builder — "which copy of this tool am I looking at", plus
|
|
213
271
|
* the upgrade nudge when a newer one has been published.
|
|
@@ -289,9 +347,10 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
|
|
|
289
347
|
* The stdin payload (rate limits, model) is still live in that case, and a
|
|
290
348
|
* 90%+ cap warning is exactly the kind of signal that must not disappear
|
|
291
349
|
* just because the user has been idle past the window — so cap-warn,
|
|
292
|
-
* harness, and
|
|
350
|
+
* harness, model, and effort chips still render around the "no session data"
|
|
351
|
+
* note.
|
|
293
352
|
*/
|
|
294
|
-
export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
353
|
+
export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
295
354
|
const c = (v) => (color ? v : '');
|
|
296
355
|
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
297
356
|
// the same branches from here on.
|
|
@@ -307,6 +366,8 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
307
366
|
if (typeof model === 'string' && model.length > 0) {
|
|
308
367
|
segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
309
368
|
}
|
|
369
|
+
const effortSeg = buildEffortSeg(effort, c, isIcon, g);
|
|
370
|
+
if (effortSeg) segs.push(effortSeg);
|
|
310
371
|
segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
|
|
311
372
|
if (versionSeg && !(update && update.available)) segs.push(versionSeg);
|
|
312
373
|
return segs.join(' · ') + (color ? '\x1b[K' : '');
|
|
@@ -319,7 +380,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
319
380
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
320
381
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
321
382
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
322
|
-
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, month, saved, delegated, doc2md, ctx, period, `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
|
|
383
|
+
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, effort, hit, ttl, month, saved, delegated, doc2md, ctx, period, `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
|
|
323
384
|
* @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
|
|
324
385
|
*/
|
|
325
386
|
/**
|
|
@@ -341,7 +402,7 @@ export const DEFAULT_SEGMENT_ORDER = [
|
|
|
341
402
|
'cap-warn', 'spike',
|
|
342
403
|
'usage', 'ctx',
|
|
343
404
|
'ttl',
|
|
344
|
-
'model',
|
|
405
|
+
'model', 'effort',
|
|
345
406
|
// Grouped by the timeframe each figure covers, because mixing them was the
|
|
346
407
|
// problem: a lifetime total, a windowed rate and a calendar-month estimate sat
|
|
347
408
|
// interleaved, with one window label at the end of the line that appeared to
|
|
@@ -359,7 +420,7 @@ export const DEFAULT_SEGMENT_ORDER = [
|
|
|
359
420
|
];
|
|
360
421
|
|
|
361
422
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
362
|
-
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
|
|
423
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model, effort } = data;
|
|
363
424
|
const { hitRate } = summary;
|
|
364
425
|
|
|
365
426
|
// Hit rate → color signal
|
|
@@ -711,6 +772,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
711
772
|
}
|
|
712
773
|
}
|
|
713
774
|
|
|
775
|
+
// Effort chip — how hard the session is being worked (`/effort`). It follows
|
|
776
|
+
// the model chip because it qualifies it: the same model answers differently
|
|
777
|
+
// at `low` and at `max`, and the pair is one glance.
|
|
778
|
+
const effortSeg = buildEffortSeg(effort, c, isIcon, g);
|
|
779
|
+
|
|
714
780
|
// Always-on usage segments — what /usage shows in Claude Code, mirrored
|
|
715
781
|
// to the statusline so the user doesn't have to slash-command for it.
|
|
716
782
|
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
@@ -820,6 +886,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
820
886
|
case 'harness': return [harnessSeg];
|
|
821
887
|
case 'korean': return [koreanSeg];
|
|
822
888
|
case 'model': return [modelSeg];
|
|
889
|
+
case 'effort': return [effortSeg];
|
|
823
890
|
// Dropped when the same story already owns a headline line above, which
|
|
824
891
|
// would otherwise repeat it on line 2.
|
|
825
892
|
case 'delegated': return totalsLine ? [] : [delegateSeg];
|
package/src/glyphs.js
CHANGED
|
@@ -39,6 +39,13 @@ const ICON = {
|
|
|
39
39
|
harness: '🅷',
|
|
40
40
|
korean: '✍️',
|
|
41
41
|
model: '🤖',
|
|
42
|
+
// Effort: a closer look at the same thing, which is what raising it buys.
|
|
43
|
+
// Width decided this as much as meaning. The obvious picks for "a level" are
|
|
44
|
+
// 🎚 and 🎛 (U+1F39A/1F39B), and both are East Asian Width N while every other
|
|
45
|
+
// glyph in this set is W — drawn from the color-emoji font they occupy two
|
|
46
|
+
// cells while being counted as one, and the line shifts on every repaint. 🔬
|
|
47
|
+
// is W, so it measures the same as its neighbours.
|
|
48
|
+
effort: '🔬',
|
|
42
49
|
routing: '🔀',
|
|
43
50
|
doc: '📄',
|
|
44
51
|
hit: '🧠',
|
|
@@ -67,6 +74,7 @@ const NARROW = {
|
|
|
67
74
|
harness: '⍟', // circled star: a score out of five
|
|
68
75
|
korean: '⌨', // an input/typing convention rather than a hand
|
|
69
76
|
model: '◈',
|
|
77
|
+
effort: '▲', // a magnitude: how hard the turn is being worked
|
|
70
78
|
routing: '⇉', // sent down a different path
|
|
71
79
|
doc: '≣', // stacked lines: a page of text
|
|
72
80
|
hit: '◉', // bullseye: a cache hit
|
package/src/stdin-payload.js
CHANGED
|
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
|
|
|
14
14
|
* The blob shape (subset we consume):
|
|
15
15
|
* {
|
|
16
16
|
* "transcript_path": "...",
|
|
17
|
+
* "effort": { "level": "high" },
|
|
17
18
|
* "rate_limits": {
|
|
18
19
|
* "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
|
|
19
20
|
* "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
|
|
@@ -120,3 +121,42 @@ export function extractModel(stdinJson) {
|
|
|
120
121
|
if (typeof m.id === 'string' && m.id) return m.id;
|
|
121
122
|
return null;
|
|
122
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The effort level the session is running at — what `/effort` sets.
|
|
127
|
+
*
|
|
128
|
+
* Claude Code 2.1.276 assembles the statusline payload with
|
|
129
|
+
* `...modelHasEffort(model) && { effort: { level: resolved } }`, so two facts
|
|
130
|
+
* follow. The level arrives already resolved against the session setting (the
|
|
131
|
+
* builder defaults it to `high` when nothing is set), which is why no default
|
|
132
|
+
* is applied here. And the key is dropped entirely for models that carry no
|
|
133
|
+
* effort setting at all (the claude-3 era, opus-4.0, opus-4.1), as it is on
|
|
134
|
+
* Claude Code builds older than the field itself — so a null means "nothing to
|
|
135
|
+
* show", never "something went wrong", and the chip simply does not render.
|
|
136
|
+
*
|
|
137
|
+
* A per-turn override (the transcript's `perTurnEffort`) is not visible here:
|
|
138
|
+
* the builder resolves the level without passing the per-turn argument, so what
|
|
139
|
+
* this returns is the session level even on a turn that ran at another one.
|
|
140
|
+
*
|
|
141
|
+
* Lowercased so the renderer can look a tone up by level; an unrecognised level
|
|
142
|
+
* still renders, just in the default tone.
|
|
143
|
+
*/
|
|
144
|
+
export function extractEffort(stdinJson) {
|
|
145
|
+
const level = stdinJson?.effort?.level;
|
|
146
|
+
if (typeof level !== 'string') return null;
|
|
147
|
+
const trimmed = level.trim();
|
|
148
|
+
return trimmed === '' ? null : trimmed.toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The current session's transcript path, as Claude Code reports it.
|
|
153
|
+
*
|
|
154
|
+
* Read for one thing only: the effort chip's ultracode check, which the payload
|
|
155
|
+
* cannot answer on its own (see src/ultracode.js). Everything else about the
|
|
156
|
+
* current session comes from the analysis window, so a missing or non-string
|
|
157
|
+
* path is not worth a fallback — the caller simply skips the check.
|
|
158
|
+
*/
|
|
159
|
+
export function extractTranscriptPath(stdinJson) {
|
|
160
|
+
const path = stdinJson?.transcript_path;
|
|
161
|
+
return typeof path === 'string' && path !== '' ? path : null;
|
|
162
|
+
}
|
package/src/ultracode.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultracode detection — the one effort level Claude Code does not put in the
|
|
3
|
+
* statusline payload.
|
|
4
|
+
*
|
|
5
|
+
* `/effort ultracode` is "xhigh + dynamic workflow orchestration, this session
|
|
6
|
+
* only". Claude Code stores it as two things: the effort level, which it raises
|
|
7
|
+
* to `xhigh`, and a session flag for the orchestration half. Only the level
|
|
8
|
+
* reaches the statusline contract (2.1.276 builds the payload as
|
|
9
|
+
* `...modelHasEffort(model) && { effort: { level: resolved } }`, where `resolved`
|
|
10
|
+
* is validated against the level list and so can never read "ultracode"), so a
|
|
11
|
+
* session on ultracode and a session on plain xhigh arrive here identical.
|
|
12
|
+
*
|
|
13
|
+
* What does distinguish them is in the transcript. Claude Code injects a
|
|
14
|
+
* system-reminder when ultracode turns on and another when it turns off, and
|
|
15
|
+
* both are recorded as attachment entries:
|
|
16
|
+
*
|
|
17
|
+
* {"type":"attachment","attachment":{"type":"ultra_effort_enter","reminderType":"full"}}
|
|
18
|
+
* {"type":"attachment","attachment":{"type":"ultra_effort_exit"}}
|
|
19
|
+
*
|
|
20
|
+
* The `enter` attachment repeats while ultracode stays on — once on the turn it
|
|
21
|
+
* is switched on ("full"), then every tenth user turn as a reminder ("sparse",
|
|
22
|
+
* TURNS_BETWEEN_MAINTENANCE = 10) — so the most recent of the two markers is
|
|
23
|
+
* the current state, and an active session keeps refreshing it near the tail.
|
|
24
|
+
*
|
|
25
|
+
* A marker is only written when the NEXT turn is assembled, though, so on its own
|
|
26
|
+
* it leaves the chip a turn behind: you run `/effort ultracode`, nothing has been
|
|
27
|
+
* recorded yet, and the chip still says xhigh until you send something. The
|
|
28
|
+
* command's own output closes that gap, because Claude Code writes it to the
|
|
29
|
+
* transcript the moment the command returns:
|
|
30
|
+
*
|
|
31
|
+
* {"type":"user","message":{"content":"<local-command-stdout>Set effort level to
|
|
32
|
+
* ultracode (this session only): xhigh + dynamic workflow orchestration</local-command-stdout>"}}
|
|
33
|
+
*
|
|
34
|
+
* measured 7s, 34s and 72s ahead of the matching attachment in one session. Both
|
|
35
|
+
* directions are legible there: the ultracode row is the only one that names the
|
|
36
|
+
* level, and every other `/effort <level>` clears the flag as it sets the level
|
|
37
|
+
* (`effortUpdate:{value:e,ultracode:!1}`), so those rows mean "off". The bare
|
|
38
|
+
* query prints `Current effort level: …`, which reads the same way.
|
|
39
|
+
*
|
|
40
|
+
* Hence the read below: walk backwards from the end of the transcript and take
|
|
41
|
+
* whichever of the two signals comes last. The scan is capped (see SCAN_BYTES)
|
|
42
|
+
* because the statusline re-renders every few seconds and most sessions carry
|
|
43
|
+
* neither signal — an uncapped backwards scan would read the whole transcript,
|
|
44
|
+
* every render, to answer "no". The cap makes the failure one-directional: past
|
|
45
|
+
* it we report "not ultracode", which is what the chip showed before this
|
|
46
|
+
* existed.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How far back to look for a marker. Measured against real transcripts: the
|
|
53
|
+
* gap between two consecutive markers of an ultracode session ran to 2.2MB
|
|
54
|
+
* (ten heavy turns), and the tail after the last one to 0.9MB. 4MB clears both
|
|
55
|
+
* with room, and costs a single bounded read of a file the renderer's own
|
|
56
|
+
* session parse has already pulled into the page cache.
|
|
57
|
+
*/
|
|
58
|
+
const SCAN_BYTES = 4 * 1024 * 1024;
|
|
59
|
+
/**
|
|
60
|
+
* Substrings worth parsing a line for. The first is the attachment; the other
|
|
61
|
+
* two are the shapes `/effort` prints. Cheap prefilters only — every hit is
|
|
62
|
+
* parsed and checked before it counts.
|
|
63
|
+
*/
|
|
64
|
+
const MARKERS = ['ultra_effort_', 'Set effort level to', 'Current effort level:'];
|
|
65
|
+
const NEWLINE = 0x0a;
|
|
66
|
+
/** The levels Claude Code accepts; any of them being SET means ultracode is off. */
|
|
67
|
+
const PLAIN_LEVELS = /Set effort level to (?:low|medium|high|xhigh|max)\b/;
|
|
68
|
+
/** The bare query's answer, which names ultracode when it is on. */
|
|
69
|
+
const QUERY_LEVEL = /Current effort level: (\w+)/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The state one transcript line carries, or null when it carries none.
|
|
73
|
+
*
|
|
74
|
+
* The line has to be parsed, not just matched: a transcript records tool output
|
|
75
|
+
* too, so a session that greps for these very strings (this feature was built
|
|
76
|
+
* in one) has lines that contain `ultra_effort_enter` as quoted text. Only an
|
|
77
|
+
* attachment entry of that type is a marker.
|
|
78
|
+
*
|
|
79
|
+
* Sidechain entries are skipped. A subagent runs its own effort resolution, so
|
|
80
|
+
* its reminder describes the subagent, not the session the chip is about.
|
|
81
|
+
*/
|
|
82
|
+
function markerState(line) {
|
|
83
|
+
let entry;
|
|
84
|
+
try {
|
|
85
|
+
entry = JSON.parse(line);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
90
|
+
if (entry.isSidechain === true) return null;
|
|
91
|
+
const type = entry.attachment && entry.attachment.type;
|
|
92
|
+
if (type === 'ultra_effort_enter') return true;
|
|
93
|
+
if (type === 'ultra_effort_exit') return false;
|
|
94
|
+
return commandState(entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The state one `/effort` command output reports, or null when the entry is not
|
|
99
|
+
* one.
|
|
100
|
+
*
|
|
101
|
+
* The filter is narrow on purpose, because these phrases travel. In the session
|
|
102
|
+
* this was built in, "Set effort level to …" appears six times outside a real
|
|
103
|
+
* command output: three times in assistant text that quoted it and three in tool
|
|
104
|
+
* results that printed it. Requiring a user entry whose content is a plain
|
|
105
|
+
* string carrying the `<local-command-stdout>` wrapper excluded all six and kept
|
|
106
|
+
* all twelve real ones — a tool result's content is an array of blocks, and
|
|
107
|
+
* quoted prose is not a user entry at all.
|
|
108
|
+
*
|
|
109
|
+
* A cancelled picker writes `<local-command-stdout>Cancelled</local-command-stdout>`,
|
|
110
|
+
* which matches nothing here and correctly changes no state.
|
|
111
|
+
*/
|
|
112
|
+
function commandState(entry) {
|
|
113
|
+
if (entry.type !== 'user') return null;
|
|
114
|
+
const content = entry.message && entry.message.content;
|
|
115
|
+
if (typeof content !== 'string' || !content.includes('<local-command-stdout>')) return null;
|
|
116
|
+
if (content.includes('Set effort level to ultracode')) return true;
|
|
117
|
+
const query = content.match(QUERY_LEVEL);
|
|
118
|
+
if (query) return query[1] === 'ultracode';
|
|
119
|
+
if (PLAIN_LEVELS.test(content)) return false;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Is ultracode on, according to this transcript?
|
|
125
|
+
*
|
|
126
|
+
* @param {string|null|undefined} transcriptPath - `transcript_path` from the statusline payload.
|
|
127
|
+
* @param {object} [opts]
|
|
128
|
+
* @param {number} [opts.scanBytes=SCAN_BYTES] - how many trailing bytes to search.
|
|
129
|
+
* @returns {boolean|null} true/false from the newest marker, null when there is
|
|
130
|
+
* none in range or the file cannot be read. Callers treat null as "not
|
|
131
|
+
* ultracode"; it is kept distinct from false so a caller can tell "the
|
|
132
|
+
* session turned it off" from "nothing said".
|
|
133
|
+
*/
|
|
134
|
+
export function ultracodeFromTranscript(transcriptPath, { scanBytes = SCAN_BYTES } = {}) {
|
|
135
|
+
if (typeof transcriptPath !== 'string' || transcriptPath === '') return null;
|
|
136
|
+
let size;
|
|
137
|
+
try {
|
|
138
|
+
size = statSync(transcriptPath).size;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (size === 0) return null;
|
|
143
|
+
|
|
144
|
+
const start = Math.max(0, size - scanBytes);
|
|
145
|
+
const buf = Buffer.alloc(size - start);
|
|
146
|
+
let fd;
|
|
147
|
+
try {
|
|
148
|
+
fd = openSync(transcriptPath, 'r');
|
|
149
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
} finally {
|
|
153
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Backwards by candidate rather than line by line: lastIndexOf is a memchr
|
|
157
|
+
// over the window, while splitting 4MB of JSONL into lines to walk the tail
|
|
158
|
+
// would allocate the whole transcript tail as strings on every render.
|
|
159
|
+
let from = buf.length;
|
|
160
|
+
while (from > 0) {
|
|
161
|
+
// The newest hit of any marker, so the attachment and the command output
|
|
162
|
+
// compete on position and the later one wins.
|
|
163
|
+
let hit = -1;
|
|
164
|
+
for (const marker of MARKERS) {
|
|
165
|
+
const at = buf.lastIndexOf(marker, from - 1, 'utf8');
|
|
166
|
+
if (at > hit) hit = at;
|
|
167
|
+
}
|
|
168
|
+
if (hit === -1) return null;
|
|
169
|
+
const lineStart = buf.lastIndexOf(NEWLINE, hit) + 1;
|
|
170
|
+
const nlAfter = buf.indexOf(NEWLINE, hit);
|
|
171
|
+
const lineEnd = nlAfter === -1 ? buf.length : nlAfter;
|
|
172
|
+
// A window that starts mid-line leaves its first line truncated; that line
|
|
173
|
+
// fails to parse and is skipped like any other non-marker.
|
|
174
|
+
const state = markerState(buf.toString('utf8', lineStart, lineEnd));
|
|
175
|
+
if (state !== null) return state;
|
|
176
|
+
if (lineStart === 0) return null;
|
|
177
|
+
from = lineStart;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The level the effort chip should name.
|
|
184
|
+
*
|
|
185
|
+
* Reads the transcript only when the payload says `xhigh`, since that is the
|
|
186
|
+
* only level ultracode can present as — which keeps every other session, and
|
|
187
|
+
* every non-statusline caller, at zero extra I/O.
|
|
188
|
+
*
|
|
189
|
+
* The `xhigh` guard is also what keeps a stale marker honest. Dropping effort
|
|
190
|
+
* (say `/effort medium`) turns ultracode off, but the `exit` marker is only
|
|
191
|
+
* written when the next turn is assembled, so between those two moments the
|
|
192
|
+
* newest marker still says `enter` while the payload already says `medium`.
|
|
193
|
+
* Trusting the level here means the chip follows the payload, not the lag.
|
|
194
|
+
*
|
|
195
|
+
* @param {string|null} level - the level from the payload (see extractEffort).
|
|
196
|
+
* @param {string|null|undefined} transcriptPath
|
|
197
|
+
* @returns {string|null} `'ultracode'`, or `level` unchanged.
|
|
198
|
+
*/
|
|
199
|
+
export function effortChipLevel(level, transcriptPath) {
|
|
200
|
+
if (level !== 'xhigh') return level;
|
|
201
|
+
return ultracodeFromTranscript(transcriptPath) === true ? 'ultracode' : level;
|
|
202
|
+
}
|