claude-token-saver 2.1.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -23
- package/bin/cli.js +285 -3
- package/package.json +1 -1
- package/src/advice.js +120 -0
- package/src/caps-cache.js +84 -0
- package/src/demo.js +73 -1
- package/src/format-time.js +44 -0
- package/src/formatters/statusline.js +214 -14
- package/src/formatters/table.js +36 -2
- package/src/handoff.js +161 -0
- package/src/history.js +208 -8
- package/src/installer.js +55 -30
- package/src/window-labels.js +61 -0
- package/examples/statusline-with-rz1989s.sh +0 -52
|
@@ -17,12 +17,34 @@
|
|
|
17
17
|
* }
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { formatResetClock } from '../format-time.js';
|
|
21
|
+
import { labelForKey } from '../window-labels.js';
|
|
22
|
+
|
|
23
|
+
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
24
|
+
// next to each other — terminal palettes set them with unbalanced perceptual
|
|
25
|
+
// brightness, so the line ends up feeling loud. We emit a Tailwind-inspired
|
|
26
|
+
// muted palette via 24-bit truecolor when the terminal advertises support
|
|
27
|
+
// (`COLORTERM=truecolor`/`24bit`), and gracefully fall back to the legacy
|
|
28
|
+
// 8-color codes on terminals that don't.
|
|
29
|
+
//
|
|
30
|
+
// GREEN → emerald-400 #34D399 (calm, balanced with the others)
|
|
31
|
+
// YELLOW → amber-400 #FBBF24 (warm, not screamy)
|
|
32
|
+
// RED → rose-400 #FB7185 (alarm without the eye-burn of pure red)
|
|
33
|
+
// CYAN → cyan-400 #22D3EE
|
|
34
|
+
// MAGENTA → violet-400 #A78BFA (model identity tone)
|
|
35
|
+
// GRAY → slate-500 #64748B (recedes for the gauge track / period footer)
|
|
36
|
+
const TRUECOLOR =
|
|
37
|
+
process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit';
|
|
38
|
+
const fg = (r, g, b, fallback) =>
|
|
39
|
+
TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : fallback;
|
|
40
|
+
|
|
20
41
|
const RESET = '\x1b[0m';
|
|
21
|
-
const RED = '\x1b[31m';
|
|
22
|
-
const GREEN = '\x1b[32m';
|
|
23
|
-
const YELLOW = '\x1b[33m';
|
|
24
|
-
const CYAN = '\x1b[36m';
|
|
25
|
-
const
|
|
42
|
+
const RED = fg(251, 113, 133, '\x1b[31m');
|
|
43
|
+
const GREEN = fg(52, 211, 153, '\x1b[32m');
|
|
44
|
+
const YELLOW = fg(251, 191, 36, '\x1b[33m');
|
|
45
|
+
const CYAN = fg(34, 211, 238, '\x1b[36m');
|
|
46
|
+
const MAGENTA = fg(167, 139, 250, '\x1b[35m');
|
|
47
|
+
const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
26
48
|
const BOLD = '\x1b[1m';
|
|
27
49
|
|
|
28
50
|
function formatMoney(usd) {
|
|
@@ -36,6 +58,44 @@ function formatPct(v) {
|
|
|
36
58
|
return `${(v * 100).toFixed(1)}%`;
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Render a 6-cell density-gradient gauge for `pct` (0..100). All cells share
|
|
63
|
+
* the same Unicode "Block Elements" density family — `█` (100%) → `▓` (75%)
|
|
64
|
+
* → `▒` (50%) → `░` (25%) — so the fill→empty boundary reads as one smooth
|
|
65
|
+
* gradient instead of an awkward step.
|
|
66
|
+
*
|
|
67
|
+
* Earlier we used partial-fill glyphs (`▏▎▍▌▋▊▉`) for sub-cell precision, but
|
|
68
|
+
* those have transparent halves that clash visually with the `░` track next
|
|
69
|
+
* to them (the eye sees "solid edge | gap | dotted track" — three zones).
|
|
70
|
+
* Density chars are the same shape, just darker/lighter, so the boundary
|
|
71
|
+
* cell reads as a single smooth fade.
|
|
72
|
+
*
|
|
73
|
+
* Each cell is ~17% wide; the boundary cell uses 3 intermediate density steps
|
|
74
|
+
* for ~4% effective precision around the fill edge. Stable monospace width
|
|
75
|
+
* across all terminal fonts that ship Block Elements (U+2580–U+259F).
|
|
76
|
+
*/
|
|
77
|
+
function gaugeBar(pct) {
|
|
78
|
+
const cells = 6;
|
|
79
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
80
|
+
const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
|
|
81
|
+
const fullCells = Math.floor(filled);
|
|
82
|
+
const remainder = filled - fullCells; // 0..1 — fill fraction of the boundary cell
|
|
83
|
+
// Boundary cell: ░ (empty), ▒ (1/3), ▓ (2/3), or roll over to a full █.
|
|
84
|
+
let partial = '';
|
|
85
|
+
let extra = 0;
|
|
86
|
+
if (remainder >= 0.83) {
|
|
87
|
+
extra = 1; // round up — fill the boundary cell completely
|
|
88
|
+
} else if (remainder >= 0.5) {
|
|
89
|
+
partial = '▓';
|
|
90
|
+
} else if (remainder >= 0.16) {
|
|
91
|
+
partial = '▒';
|
|
92
|
+
} // else: remainder is too small to show — leave the cell empty
|
|
93
|
+
const totalFull = Math.min(cells, fullCells + extra);
|
|
94
|
+
const usedCells = totalFull + (partial ? 1 : 0);
|
|
95
|
+
const empty = '░'.repeat(Math.max(0, cells - usedCells));
|
|
96
|
+
return '█'.repeat(totalFull) + partial + empty;
|
|
97
|
+
}
|
|
98
|
+
|
|
39
99
|
/**
|
|
40
100
|
* Format a remaining-seconds countdown as MM:SS (or H:MM when ≥ 1h).
|
|
41
101
|
*/
|
|
@@ -49,6 +109,25 @@ function formatTimer(remainingSec) {
|
|
|
49
109
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
50
110
|
}
|
|
51
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Pick the cap-warn chip that should surface from any of the rate-limit
|
|
114
|
+
* windows, or null when none are at 90%+. When multiple windows are warning,
|
|
115
|
+
* the one that resets sooner wins (it's the more imminent block).
|
|
116
|
+
*/
|
|
117
|
+
export function pickCapWarn(caps) {
|
|
118
|
+
if (!caps || !Array.isArray(caps.windows)) return null;
|
|
119
|
+
const candidates = caps.windows
|
|
120
|
+
.filter((w) => Number.isFinite(w.usedPct) && w.usedPct >= 90)
|
|
121
|
+
.map((w) => ({ ...w, label: labelForKey(w.key).short }));
|
|
122
|
+
if (candidates.length === 0) return null;
|
|
123
|
+
candidates.sort((a, b) => {
|
|
124
|
+
const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
|
|
125
|
+
const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
|
|
126
|
+
return ar - br;
|
|
127
|
+
});
|
|
128
|
+
return candidates[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
52
131
|
/**
|
|
53
132
|
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
54
133
|
* @param {object} [opts]
|
|
@@ -56,9 +135,10 @@ function formatTimer(remainingSec) {
|
|
|
56
135
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
57
136
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
58
137
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
138
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
59
139
|
*/
|
|
60
|
-
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
|
|
61
|
-
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
|
|
140
|
+
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
|
|
141
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps, model } = data;
|
|
62
142
|
const { hitRate } = summary;
|
|
63
143
|
|
|
64
144
|
// Hit rate → color signal
|
|
@@ -156,12 +236,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
156
236
|
if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
157
237
|
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
158
238
|
const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
|
|
239
|
+
// `Ctx` (not the full word `Context`) across every mode — the icon already
|
|
240
|
+
// tells the eye what the chip is, and the short form fits the same cadence
|
|
241
|
+
// as `Hit`/`Saved` peers when we eventually shorten those too.
|
|
159
242
|
if (isIcon && verbose) {
|
|
160
|
-
ctxSeg = `${c(ctxColor)}📦
|
|
243
|
+
ctxSeg = `${c(ctxColor)}📦 Ctx ${label}${c(RESET)}`;
|
|
161
244
|
} else if (isIcon) {
|
|
162
245
|
ctxSeg = `${c(ctxColor)}📦 ${label}${c(RESET)}`;
|
|
163
|
-
} else if (verbose) {
|
|
164
|
-
ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
|
|
165
246
|
} else {
|
|
166
247
|
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
167
248
|
}
|
|
@@ -170,12 +251,131 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
170
251
|
// Spike chip — one word only, keeps the statusline single-line.
|
|
171
252
|
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
172
253
|
|
|
254
|
+
// Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
|
|
255
|
+
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
256
|
+
// mid-session and wants to confirm at a glance which one is answering.
|
|
257
|
+
let modelSeg = null;
|
|
258
|
+
if (typeof model === 'string' && model.length > 0) {
|
|
259
|
+
// 🤖 + name is enough — the emoji disambiguates so the literal word "Model"
|
|
260
|
+
// is dead weight in icon mode. Text modes keep the bare name; the magenta
|
|
261
|
+
// tone marks it as identity context.
|
|
262
|
+
if (isIcon) {
|
|
263
|
+
modelSeg = `${c(MAGENTA)}🤖 ${model}${c(RESET)}`;
|
|
264
|
+
} else {
|
|
265
|
+
modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Always-on usage segments — what /usage shows in Claude Code, mirrored
|
|
270
|
+
// to the statusline so the user doesn't have to slash-command for it.
|
|
271
|
+
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
272
|
+
// rolling ("Current week") windows; if Anthropic ships more (e.g. a
|
|
273
|
+
// Sonnet-only weekly), they render automatically with derived labels.
|
|
274
|
+
// Each renders as `{label} {pct}% · {countdown}`. When a window is at >=90%
|
|
275
|
+
// the cap-warn chip already shouts about it, so we suppress the always-on
|
|
276
|
+
// segment to avoid duplicate noise.
|
|
277
|
+
function buildUsageSeg({ labels, info, color: tone }) {
|
|
278
|
+
if (!info || !Number.isFinite(info.usedPct)) return null;
|
|
279
|
+
if (info.usedPct >= 90) return null; // cap-warn chip handles this case
|
|
280
|
+
const pct = Math.round(info.usedPct);
|
|
281
|
+
// Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
|
|
282
|
+
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
283
|
+
// 🔄 icon itself separates the percent from the clock — no extra `·` needed.
|
|
284
|
+
const clock = formatResetClock(info.resetsAt);
|
|
285
|
+
const tail = clock ? ` 🔄 ${clock}` : '';
|
|
286
|
+
// `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
|
|
287
|
+
// leans on the icon to identify the window (✦ = session/now, 📅 = week),
|
|
288
|
+
// so the 5H label is empty while the 7D label spells out "weekly". Text and
|
|
289
|
+
// verbose modes keep the `5H`/`7D` short label since they have no icon.
|
|
290
|
+
// Icon mode renders an inline ▰▱ gauge instead of the literal "cap used" —
|
|
291
|
+
// a glance at the bar conveys urgency faster than parsing a percent number,
|
|
292
|
+
// and the gauge stays the same width as the percent climbs.
|
|
293
|
+
if (isIcon) {
|
|
294
|
+
const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
|
|
295
|
+
const bar = gaugeBar(pct);
|
|
296
|
+
return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${tail}${c(RESET)}`;
|
|
297
|
+
}
|
|
298
|
+
if (verbose) {
|
|
299
|
+
return `${c(tone)}${labels.short} cap ${pct}% used${tail}${c(RESET)}`;
|
|
300
|
+
}
|
|
301
|
+
return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
|
|
302
|
+
}
|
|
303
|
+
// Color tone: green when <70%, yellow 70-89% (the segment is suppressed at
|
|
304
|
+
// 90+% in favor of cap-warn). Lets the user spot "I'm getting close" without
|
|
305
|
+
// waiting for the alarm chip.
|
|
306
|
+
function usageTone(info) {
|
|
307
|
+
if (!info || !Number.isFinite(info.usedPct)) return GRAY;
|
|
308
|
+
if (info.usedPct >= 70) return YELLOW;
|
|
309
|
+
return GREEN;
|
|
310
|
+
}
|
|
311
|
+
const usageSegs = [];
|
|
312
|
+
if (caps && Array.isArray(caps.windows)) {
|
|
313
|
+
for (const win of caps.windows) {
|
|
314
|
+
const labels = labelForKey(win.key);
|
|
315
|
+
const seg = buildUsageSeg({
|
|
316
|
+
labels,
|
|
317
|
+
info: win,
|
|
318
|
+
color: usageTone(win),
|
|
319
|
+
});
|
|
320
|
+
if (seg) usageSegs.push({ key: win.key, seg });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
|
|
325
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
326
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
327
|
+
// English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
|
|
328
|
+
const capWarn = pickCapWarn(caps);
|
|
329
|
+
let capWarnSeg = null;
|
|
330
|
+
if (capWarn) {
|
|
331
|
+
const pct = Math.round(capWarn.usedPct);
|
|
332
|
+
// At 90%+ the user wants to know "when can I send again" — wall-clock is
|
|
333
|
+
// the actionable bit. Same `🔄 HH:MM` shape as the always-on segments so
|
|
334
|
+
// the icon's meaning carries over to the alarm chip.
|
|
335
|
+
const clock = formatResetClock(capWarn.resetsAt);
|
|
336
|
+
const clockTail = clock ? ` 🔄 ${clock}` : '';
|
|
337
|
+
if (isIcon) {
|
|
338
|
+
// Gauge keeps shape parity with the always-on usage segment — the
|
|
339
|
+
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
340
|
+
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
341
|
+
const bar = gaugeBar(pct);
|
|
342
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
343
|
+
} else {
|
|
344
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
173
348
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
174
349
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
350
|
+
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
351
|
+
// a single spiking session.
|
|
352
|
+
const allow = segments && segments.length
|
|
353
|
+
? new Set(segments.map((s) => s.toLowerCase()))
|
|
354
|
+
: null;
|
|
355
|
+
const want = (name) => !allow || allow.has(name);
|
|
356
|
+
// Legacy whitelist aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`. So
|
|
357
|
+
// existing `--segments` configs keep working after the generic refactor.
|
|
358
|
+
const usageWant = (key) => {
|
|
359
|
+
if (!allow) return true;
|
|
360
|
+
if (allow.has(key.toLowerCase())) return true;
|
|
361
|
+
if (key === 'five_hour' && allow.has('5h')) return true;
|
|
362
|
+
if (key === 'seven_day' && allow.has('7d')) return true;
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
175
365
|
const segs = [];
|
|
176
|
-
if (
|
|
177
|
-
segs.push(
|
|
178
|
-
if (
|
|
179
|
-
segs.push(
|
|
366
|
+
if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
|
|
367
|
+
if (spikeSeg && want('spike')) segs.push(spikeSeg);
|
|
368
|
+
if (modelSeg && want('model')) segs.push(modelSeg);
|
|
369
|
+
if (want('hit')) segs.push(hitSeg);
|
|
370
|
+
if (want('ttl')) segs.push(ttlSeg);
|
|
371
|
+
for (const { key, seg } of usageSegs) {
|
|
372
|
+
if (usageWant(key)) segs.push(seg);
|
|
373
|
+
}
|
|
374
|
+
if (ctxSeg && want('ctx')) segs.push(ctxSeg);
|
|
375
|
+
// Cache saved is the "lifetime brag" stat — useful but not actionable, so
|
|
376
|
+
// it sits near the tail. The period label closes the line as a quiet
|
|
377
|
+
// timeframe footer.
|
|
378
|
+
if (want('saved')) segs.push(saveSeg);
|
|
379
|
+
if (want('period')) segs.push(periodSeg);
|
|
180
380
|
return segs.join(' · ');
|
|
181
381
|
}
|
package/src/formatters/table.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Terminal table formatter — zero dependencies.
|
|
3
3
|
*/
|
|
4
4
|
import { ISSUE_MESSAGES } from '../advice.js';
|
|
5
|
+
import { formatResetIn, formatResetClock } from '../format-time.js';
|
|
6
|
+
import { labelForKey } from '../window-labels.js';
|
|
5
7
|
|
|
6
8
|
function pad(str, len, align = 'left') {
|
|
7
9
|
const s = String(str);
|
|
@@ -123,7 +125,34 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
123
125
|
return lines;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
function renderCapWarnSection(caps) {
|
|
129
|
+
if (!caps || !Array.isArray(caps.windows)) return [];
|
|
130
|
+
const warning = caps.windows.filter(
|
|
131
|
+
(w) => Number.isFinite(w.usedPct) && w.usedPct >= 90,
|
|
132
|
+
);
|
|
133
|
+
if (warning.length === 0) return [];
|
|
134
|
+
const lines = [];
|
|
135
|
+
lines.push(' 🚨 Rate-limit cap is closing in');
|
|
136
|
+
lines.push(` ${'─'.repeat(50)}`);
|
|
137
|
+
for (const win of warning) {
|
|
138
|
+
const label = labelForKey(win.key).long;
|
|
139
|
+
const reset = formatResetIn(win.resetsAt);
|
|
140
|
+
const clock = formatResetClock(win.resetsAt);
|
|
141
|
+
let tail = '';
|
|
142
|
+
if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
|
|
143
|
+
else if (reset) tail = `, resets in ${reset}`;
|
|
144
|
+
else if (clock) tail = `, resets at ${clock}`;
|
|
145
|
+
lines.push(` • ${label}: ${Math.round(win.usedPct)}% used${tail}`);
|
|
146
|
+
}
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push(' Back up work before the cap hits:');
|
|
149
|
+
lines.push(' claude-token-saver handoff');
|
|
150
|
+
lines.push(' (writes a HANDOFF-*.md so a fresh session can pick up.)');
|
|
151
|
+
lines.push('');
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
|
|
127
156
|
const lines = [];
|
|
128
157
|
|
|
129
158
|
// Header
|
|
@@ -133,7 +162,12 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
133
162
|
lines.push(` ${'═'.repeat(50)}`);
|
|
134
163
|
lines.push('');
|
|
135
164
|
|
|
136
|
-
//
|
|
165
|
+
// Cap warning leads — it's the most time-sensitive signal we can show.
|
|
166
|
+
if (caps) {
|
|
167
|
+
lines.push(...renderCapWarnSection(caps));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Spike section goes next — it's what the user acts on.
|
|
137
171
|
if (spikeReport && spikeReport.spikes.length > 0) {
|
|
138
172
|
lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
|
|
139
173
|
}
|
package/src/handoff.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff template — captures enough state at session-cap time that a fresh
|
|
3
|
+
* Claude Code session can pick up the work without a long prelude.
|
|
4
|
+
*
|
|
5
|
+
* Output: `./HANDOFF-YYYY-MM-DD-HHMM.md` in the caller's cwd. We never
|
|
6
|
+
* overwrite — if the path is taken we add a `-N` suffix.
|
|
7
|
+
*
|
|
8
|
+
* What goes in:
|
|
9
|
+
* - Header: timestamp, cwd, git branch / HEAD / dirty file list
|
|
10
|
+
* - Cap snapshot: 5h/7d % and resets-in (when known)
|
|
11
|
+
* - Empty fillable sections the user pastes context into
|
|
12
|
+
* - A one-line resume prompt for the next session
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { formatResetIn, formatResetClock } from './format-time.js';
|
|
20
|
+
import { labelForKey } from './window-labels.js';
|
|
21
|
+
|
|
22
|
+
function pad(n) {
|
|
23
|
+
return String(n).padStart(2, '0');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ymd(d = new Date()) {
|
|
27
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hhmm(d = new Date()) {
|
|
31
|
+
return `${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeGit(cmd, cwd) {
|
|
35
|
+
try {
|
|
36
|
+
return execSync(`git ${cmd}`, {
|
|
37
|
+
cwd,
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
40
|
+
}).trim();
|
|
41
|
+
} catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function gitSnapshot(cwd) {
|
|
47
|
+
// `rev-parse --git-dir` succeeds in any repo, including a freshly-init'd one
|
|
48
|
+
// with no commits yet (where `rev-parse HEAD` would fail). We use it as the
|
|
49
|
+
// "is this a repo?" probe.
|
|
50
|
+
const gitDir = safeGit('rev-parse --git-dir', cwd);
|
|
51
|
+
if (!gitDir) return null;
|
|
52
|
+
const branch = safeGit('rev-parse --abbrev-ref HEAD', cwd) || '(no commits)';
|
|
53
|
+
const head = safeGit('rev-parse --short HEAD', cwd);
|
|
54
|
+
const status = safeGit('status --short', cwd);
|
|
55
|
+
return { branch, head, status };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pickPath(cwd, now) {
|
|
59
|
+
const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
|
|
60
|
+
const direct = join(cwd, `${stem}.md`);
|
|
61
|
+
if (!existsSync(direct)) return direct;
|
|
62
|
+
for (let i = 2; i < 100; i++) {
|
|
63
|
+
const candidate = join(cwd, `${stem}-${i}.md`);
|
|
64
|
+
if (!existsSync(candidate)) return candidate;
|
|
65
|
+
}
|
|
66
|
+
return join(cwd, `${stem}-${Date.now()}.md`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderTemplate({ now, cwd, git, caps }) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
lines.push(`# Handoff — ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push(`Generated by \`claude-token-saver handoff\`.`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push('## Context');
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(`- cwd: \`${cwd}\``);
|
|
78
|
+
if (git) {
|
|
79
|
+
lines.push(`- git branch: \`${git.branch}\`${git.head ? ` @ \`${git.head}\`` : ''}`);
|
|
80
|
+
if (git.status) {
|
|
81
|
+
lines.push('- dirty files:');
|
|
82
|
+
lines.push(' ```');
|
|
83
|
+
for (const line of git.status.split('\n')) lines.push(` ${line}`);
|
|
84
|
+
lines.push(' ```');
|
|
85
|
+
} else {
|
|
86
|
+
lines.push('- working tree: clean');
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
lines.push('- git: (not a repo)');
|
|
90
|
+
}
|
|
91
|
+
lines.push('');
|
|
92
|
+
|
|
93
|
+
lines.push('## Cap snapshot');
|
|
94
|
+
lines.push('');
|
|
95
|
+
if (caps && Array.isArray(caps.windows) && caps.windows.length > 0) {
|
|
96
|
+
for (const win of caps.windows) {
|
|
97
|
+
const label = labelForKey(win.key).long;
|
|
98
|
+
if (!Number.isFinite(win.usedPct)) {
|
|
99
|
+
lines.push(`- ${label}: (unknown)`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const reset = formatResetIn(win.resetsAt, now);
|
|
103
|
+
const clock = formatResetClock(win.resetsAt, now);
|
|
104
|
+
let tail = '';
|
|
105
|
+
if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
|
|
106
|
+
else if (reset) tail = `, resets in ${reset}`;
|
|
107
|
+
else if (clock) tail = `, resets at ${clock}`;
|
|
108
|
+
lines.push(`- ${label}: ${Math.round(win.usedPct)}%${tail}`);
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
lines.push('- (no cap data — run `handoff` from a Claude Code session for live numbers)');
|
|
112
|
+
}
|
|
113
|
+
lines.push('');
|
|
114
|
+
|
|
115
|
+
lines.push('## What I just did');
|
|
116
|
+
lines.push('');
|
|
117
|
+
lines.push('- _(fill in: 1–3 bullets describing the most recent work)_');
|
|
118
|
+
lines.push('');
|
|
119
|
+
|
|
120
|
+
lines.push('## What\'s left (TODO)');
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('- [ ] _(fill in)_');
|
|
123
|
+
lines.push('');
|
|
124
|
+
|
|
125
|
+
lines.push('## Where to pick up next');
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('- _(file paths, function names, the exact next step)_');
|
|
128
|
+
lines.push('');
|
|
129
|
+
|
|
130
|
+
lines.push('## Watch out for');
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
|
|
133
|
+
lines.push('');
|
|
134
|
+
|
|
135
|
+
lines.push('## Resume prompt for the next Claude Code session');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('```');
|
|
138
|
+
lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
|
|
139
|
+
lines.push('```');
|
|
140
|
+
lines.push('');
|
|
141
|
+
|
|
142
|
+
return lines.join('\n') + '\n';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Write a handoff file in the given cwd.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} [opts]
|
|
149
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
150
|
+
* @param {object|null} [opts.caps] - { windows: [...] } from extractCaps
|
|
151
|
+
* @param {Date} [opts.now=new Date()]
|
|
152
|
+
* @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
|
|
153
|
+
*/
|
|
154
|
+
export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
|
|
155
|
+
const absCwd = resolve(cwd);
|
|
156
|
+
const git = gitSnapshot(absCwd);
|
|
157
|
+
const path = pickPath(absCwd, now);
|
|
158
|
+
const body = renderTemplate({ now, cwd: absCwd, git, caps });
|
|
159
|
+
writeFileSync(path, body);
|
|
160
|
+
return { path, git };
|
|
161
|
+
}
|