claude-token-saver 2.0.3 โ 2.2.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 +81 -23
- package/bin/cli.js +340 -11
- package/package.json +1 -1
- package/src/advice.js +48 -41
- package/src/caps-cache.js +51 -0
- package/src/config.js +151 -0
- package/src/demo.js +251 -0
- package/src/formatters/statusline.js +102 -23
- package/src/formatters/table.js +51 -11
- package/src/handoff.js +162 -0
- package/src/history.js +258 -0
- package/src/installer.js +150 -0
- package/src/paths.js +41 -0
- package/examples/statusline-with-rz1989s.sh +0 -52
|
@@ -49,6 +49,39 @@ function formatTimer(remainingSec) {
|
|
|
49
49
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Pick the cap-warn chip ({ kind, usedPct, resetsAt, label }) that should
|
|
54
|
+
* surface, or null if neither window is at 90%+. When both windows are warning,
|
|
55
|
+
* the one that resets sooner wins (it's the more imminent block).
|
|
56
|
+
*/
|
|
57
|
+
export function pickCapWarn(caps) {
|
|
58
|
+
if (!caps) return null;
|
|
59
|
+
const candidates = [];
|
|
60
|
+
if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
|
|
61
|
+
candidates.push({
|
|
62
|
+
kind: 'five_hour',
|
|
63
|
+
label: '5H',
|
|
64
|
+
usedPct: caps.fiveHour.usedPct,
|
|
65
|
+
resetsAt: caps.fiveHour.resetsAt,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
|
|
69
|
+
candidates.push({
|
|
70
|
+
kind: 'seven_day',
|
|
71
|
+
label: '7D',
|
|
72
|
+
usedPct: caps.sevenDay.usedPct,
|
|
73
|
+
resetsAt: caps.sevenDay.resetsAt,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (candidates.length === 0) return null;
|
|
77
|
+
candidates.sort((a, b) => {
|
|
78
|
+
const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
|
|
79
|
+
const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
|
|
80
|
+
return ar - br;
|
|
81
|
+
});
|
|
82
|
+
return candidates[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
/**
|
|
53
86
|
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
54
87
|
* @param {object} [opts]
|
|
@@ -56,9 +89,10 @@ function formatTimer(remainingSec) {
|
|
|
56
89
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
57
90
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
58
91
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐ง โณ ๐ฐ instead of word labels.
|
|
92
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, hit, ttl, saved, ctx, period. Null/undefined = all.
|
|
59
93
|
*/
|
|
60
|
-
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
|
|
61
|
-
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
|
|
94
|
+
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
|
|
95
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps } = data;
|
|
62
96
|
const { hitRate } = summary;
|
|
63
97
|
|
|
64
98
|
// Hit rate โ color signal
|
|
@@ -87,25 +121,31 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
87
121
|
: 'Cache hit';
|
|
88
122
|
const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
89
123
|
|
|
90
|
-
// text: "
|
|
91
|
-
// icon: "๐ฐ $1.5K" | verbose: "๐ฐ
|
|
124
|
+
// text: "Cache saved $1.5K" | same in verbose
|
|
125
|
+
// icon: "๐ฐ $1.5K" | verbose: "๐ฐ Cache saved $1.5K"
|
|
92
126
|
const saveLabel = isIcon
|
|
93
|
-
? (verbose ? '๐ฐ
|
|
94
|
-
: '
|
|
127
|
+
? (verbose ? '๐ฐ Cache saved' : '๐ฐ')
|
|
128
|
+
: 'Cache saved';
|
|
95
129
|
const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
96
130
|
|
|
131
|
+
// Period label honors hour-precision configs (`mode 6h` โ "6h", `mode 1d` โ "1d").
|
|
132
|
+
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
133
|
+
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
97
134
|
const periodSeg = verbose
|
|
98
|
-
? `${c(GRAY)}last ${
|
|
99
|
-
: `${c(GRAY)}${
|
|
135
|
+
? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
|
|
136
|
+
: `${c(GRAY)}${periodLabel}${c(RESET)}`;
|
|
100
137
|
|
|
101
138
|
// TTL countdown โ how much time is left on the last API call's cache entry.
|
|
102
139
|
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
103
140
|
// TTL window, and the next call (hit) within that window resets it. So the
|
|
104
141
|
// countdown visibly ticks down between prompts, and "resets" happens as a
|
|
105
142
|
// jump back toward the bucket max the moment you send another message.
|
|
106
|
-
//
|
|
143
|
+
// Compact modes drop the bucket label โ it's read as part of the clock
|
|
144
|
+
// ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
|
|
145
|
+
// is plan-determined and rarely changes, so verbose mode is where it belongs.
|
|
146
|
+
// text compact: "Expires 59:58"
|
|
107
147
|
// text verbose: "1h bucket ยท expires in 59:58"
|
|
108
|
-
// icon compact: "โณ
|
|
148
|
+
// icon compact: "โณ 59:58"
|
|
109
149
|
// icon verbose: "โณ Expires 1h 59:58"
|
|
110
150
|
let ttlSeg;
|
|
111
151
|
if (timer && lastActivity) {
|
|
@@ -119,22 +159,28 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
119
159
|
pct > 0.10 ? YELLOW :
|
|
120
160
|
RED;
|
|
121
161
|
|
|
122
|
-
if (isIcon) {
|
|
123
|
-
|
|
124
|
-
|
|
162
|
+
if (isIcon && verbose) {
|
|
163
|
+
// Drop bucket here too โ `โณ Expires 1h 57:20` reads as "1h 57m 20s left"
|
|
164
|
+
// for the same reason the compact form did. The bucket lives in the
|
|
165
|
+
// text-verbose layout where the "bucket" word + `ยท` separator make it
|
|
166
|
+
// unambiguous.
|
|
167
|
+
ttlSeg = `${c(timerColor)}โณ Cache expires ${text}${c(RESET)}`;
|
|
168
|
+
} else if (isIcon) {
|
|
169
|
+
ttlSeg = `${c(timerColor)}โณ ${text}${c(RESET)}`;
|
|
125
170
|
} else if (verbose) {
|
|
126
|
-
ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)} ยท ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
171
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} ยท ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
127
172
|
} else {
|
|
128
|
-
ttlSeg = `${c(
|
|
173
|
+
ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
|
|
129
174
|
}
|
|
130
175
|
} else {
|
|
176
|
+
// No-timer fallback: only the bucket is available, so we show just that.
|
|
131
177
|
if (isIcon) {
|
|
132
|
-
const prefix = verbose ? 'โณ
|
|
178
|
+
const prefix = verbose ? 'โณ Cache bucket ' : 'โณ ';
|
|
133
179
|
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
134
180
|
} else if (verbose) {
|
|
135
|
-
ttlSeg = `${c(bucketColor)}${bucketLabel} bucket${c(RESET)}`;
|
|
181
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
136
182
|
} else {
|
|
137
|
-
ttlSeg = `${c(bucketColor)}
|
|
183
|
+
ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
|
|
138
184
|
}
|
|
139
185
|
}
|
|
140
186
|
|
|
@@ -144,7 +190,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
144
190
|
if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
145
191
|
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
146
192
|
const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
|
|
147
|
-
if (isIcon) {
|
|
193
|
+
if (isIcon && verbose) {
|
|
194
|
+
ctxSeg = `${c(ctxColor)}๐ฆ Context ${label}${c(RESET)}`;
|
|
195
|
+
} else if (isIcon) {
|
|
148
196
|
ctxSeg = `${c(ctxColor)}๐ฆ ${label}${c(RESET)}`;
|
|
149
197
|
} else if (verbose) {
|
|
150
198
|
ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
|
|
@@ -156,9 +204,40 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
156
204
|
// Spike chip โ one word only, keeps the statusline single-line.
|
|
157
205
|
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
158
206
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if
|
|
162
|
-
|
|
207
|
+
// Cap-warn chip โ leads everything when ANY rate-limit window is at 90%+.
|
|
208
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
209
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
210
|
+
// English shape `๐จ 5H 94%` / `๐จ 7D 92%` so history parsers can dedupe on it.
|
|
211
|
+
const capWarn = pickCapWarn(caps);
|
|
212
|
+
let capWarnSeg = null;
|
|
213
|
+
if (capWarn) {
|
|
214
|
+
const pct = Math.round(capWarn.usedPct);
|
|
215
|
+
if (isIcon && verbose) {
|
|
216
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}๐จ ${capWarn.label} cap ${pct}%${c(RESET)}`;
|
|
217
|
+
} else if (isIcon) {
|
|
218
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}๐จ ${capWarn.label} ${pct}%${c(RESET)}`;
|
|
219
|
+
} else if (verbose) {
|
|
220
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${c(RESET)}`;
|
|
221
|
+
} else {
|
|
222
|
+
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} ${pct}%${c(RESET)}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Warning chip leads โ a glance at the statusline catches "something's wrong"
|
|
227
|
+
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
228
|
+
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
229
|
+
// a single spiking session.
|
|
230
|
+
const allow = segments && segments.length
|
|
231
|
+
? new Set(segments.map((s) => s.toLowerCase()))
|
|
232
|
+
: null;
|
|
233
|
+
const want = (name) => !allow || allow.has(name);
|
|
234
|
+
const segs = [];
|
|
235
|
+
if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
|
|
236
|
+
if (spikeSeg && want('spike')) segs.push(spikeSeg);
|
|
237
|
+
if (want('hit')) segs.push(hitSeg);
|
|
238
|
+
if (want('ttl')) segs.push(ttlSeg);
|
|
239
|
+
if (want('saved')) segs.push(saveSeg);
|
|
240
|
+
if (ctxSeg && want('ctx')) segs.push(ctxSeg);
|
|
241
|
+
if (want('period')) segs.push(periodSeg);
|
|
163
242
|
return segs.join(' ยท ');
|
|
164
243
|
}
|
package/src/formatters/table.js
CHANGED
|
@@ -64,11 +64,11 @@ function formatContextSize(n) {
|
|
|
64
64
|
|
|
65
65
|
function renderSpikeSection(spikes, contextWindow) {
|
|
66
66
|
const lines = [];
|
|
67
|
-
lines.push(' โ
|
|
67
|
+
lines.push(' โ Token spike detected');
|
|
68
68
|
lines.push(` ${'โ'.repeat(50)}`);
|
|
69
69
|
if (contextWindow && contextWindow.size === '1M') {
|
|
70
70
|
lines.push(
|
|
71
|
-
`
|
|
71
|
+
` Context mode: 1M (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`,
|
|
72
72
|
);
|
|
73
73
|
lines.push('');
|
|
74
74
|
}
|
|
@@ -77,11 +77,11 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
77
77
|
const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}ร p95` : 'single-request > 250k';
|
|
78
78
|
lines.push(
|
|
79
79
|
` โข ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] ` +
|
|
80
|
-
|
|
80
|
+
`total input ${formatContextSize(m.totalInput)} (${ratioLabel}, ${m.requestCount} requests)`,
|
|
81
81
|
);
|
|
82
82
|
if (m.maxContextPerRequest > 0) {
|
|
83
83
|
lines.push(
|
|
84
|
-
`
|
|
84
|
+
` max single-request context: ${formatContextSize(m.maxContextPerRequest)} tokens`,
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
for (const issue of spike.issues) {
|
|
@@ -104,7 +104,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
if (uniqueIssues.length > 0) {
|
|
107
|
-
lines.push('
|
|
107
|
+
lines.push(' Recommended actions');
|
|
108
108
|
lines.push(` ${'โ'.repeat(50)}`);
|
|
109
109
|
for (const issue of uniqueIssues) {
|
|
110
110
|
const info = ISSUE_MESSAGES[issue.code];
|
|
@@ -123,17 +123,57 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
123
123
|
return lines;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
function formatResetIn(resetsAt, now = new Date()) {
|
|
127
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
128
|
+
const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
|
|
129
|
+
if (remaining <= 0) return '0m';
|
|
130
|
+
const h = Math.floor(remaining / 3600);
|
|
131
|
+
const m = Math.floor((remaining % 3600) / 60);
|
|
132
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
133
|
+
return `${m}m`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderCapWarnSection(caps) {
|
|
137
|
+
const warning = [];
|
|
138
|
+
if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
|
|
139
|
+
warning.push({ label: '5-hour window', info: caps.fiveHour });
|
|
140
|
+
}
|
|
141
|
+
if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
|
|
142
|
+
warning.push({ label: '7-day window', info: caps.sevenDay });
|
|
143
|
+
}
|
|
144
|
+
if (warning.length === 0) return [];
|
|
145
|
+
const lines = [];
|
|
146
|
+
lines.push(' ๐จ Rate-limit cap is closing in');
|
|
147
|
+
lines.push(` ${'โ'.repeat(50)}`);
|
|
148
|
+
for (const { label, info } of warning) {
|
|
149
|
+
const reset = formatResetIn(info.resetsAt);
|
|
150
|
+
const tail = reset ? `, resets in ${reset}` : '';
|
|
151
|
+
lines.push(` โข ${label}: ${Math.round(info.usedPct)}% used${tail}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(' Back up work before the cap hits:');
|
|
155
|
+
lines.push(' claude-token-saver handoff');
|
|
156
|
+
lines.push(' (writes a HANDOFF-*.md so a fresh session can pick up.)');
|
|
157
|
+
lines.push('');
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
|
|
127
162
|
const lines = [];
|
|
128
163
|
|
|
129
164
|
// Header
|
|
130
165
|
lines.push('');
|
|
131
|
-
lines.push(` Claude
|
|
166
|
+
lines.push(` Claude Token Saver โ Last ${options.days} day${options.days === 1 ? '' : 's'}`);
|
|
132
167
|
lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
|
|
133
168
|
lines.push(` ${'โ'.repeat(50)}`);
|
|
134
169
|
lines.push('');
|
|
135
170
|
|
|
136
|
-
//
|
|
171
|
+
// Cap warning leads โ it's the most time-sensitive signal we can show.
|
|
172
|
+
if (caps) {
|
|
173
|
+
lines.push(...renderCapWarnSection(caps));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Spike section goes next โ it's what the user acts on.
|
|
137
177
|
if (spikeReport && spikeReport.spikes.length > 0) {
|
|
138
178
|
lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
|
|
139
179
|
}
|
|
@@ -142,10 +182,10 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
142
182
|
if (contextWindow && contextWindow.size !== 'unknown') {
|
|
143
183
|
const note =
|
|
144
184
|
contextWindow.size === '1M'
|
|
145
|
-
? 'โ 1M
|
|
146
|
-
: 'โ 200k
|
|
185
|
+
? 'โ 1M context active (Opus 4.7+ Max default). Disable with CLAUDE_CODE_DISABLE_1M_CONTEXT=1'
|
|
186
|
+
: 'โ 200k context (standard)';
|
|
147
187
|
lines.push(` Context window: ${contextWindow.size} ${note}`);
|
|
148
|
-
lines.push(` (
|
|
188
|
+
lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
|
|
149
189
|
lines.push('');
|
|
150
190
|
}
|
|
151
191
|
|
package/src/handoff.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
function pad(n) {
|
|
20
|
+
return String(n).padStart(2, '0');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ymd(d = new Date()) {
|
|
24
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hhmm(d = new Date()) {
|
|
28
|
+
return `${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeGit(cmd, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return execSync(`git ${cmd}`, {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
}).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gitSnapshot(cwd) {
|
|
44
|
+
// `rev-parse --git-dir` succeeds in any repo, including a freshly-init'd one
|
|
45
|
+
// with no commits yet (where `rev-parse HEAD` would fail). We use it as the
|
|
46
|
+
// "is this a repo?" probe.
|
|
47
|
+
const gitDir = safeGit('rev-parse --git-dir', cwd);
|
|
48
|
+
if (!gitDir) return null;
|
|
49
|
+
const branch = safeGit('rev-parse --abbrev-ref HEAD', cwd) || '(no commits)';
|
|
50
|
+
const head = safeGit('rev-parse --short HEAD', cwd);
|
|
51
|
+
const status = safeGit('status --short', cwd);
|
|
52
|
+
return { branch, head, status };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatResetIn(resetsAt, now = new Date()) {
|
|
56
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
57
|
+
const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
|
|
58
|
+
if (remaining <= 0) return '0m';
|
|
59
|
+
const h = Math.floor(remaining / 3600);
|
|
60
|
+
const m = Math.floor((remaining % 3600) / 60);
|
|
61
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
62
|
+
return `${m}m`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pickPath(cwd, now) {
|
|
66
|
+
const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
|
|
67
|
+
const direct = join(cwd, `${stem}.md`);
|
|
68
|
+
if (!existsSync(direct)) return direct;
|
|
69
|
+
for (let i = 2; i < 100; i++) {
|
|
70
|
+
const candidate = join(cwd, `${stem}-${i}.md`);
|
|
71
|
+
if (!existsSync(candidate)) return candidate;
|
|
72
|
+
}
|
|
73
|
+
return join(cwd, `${stem}-${Date.now()}.md`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderTemplate({ now, cwd, git, caps }) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
lines.push(`# Handoff โ ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push(`Generated by \`claude-token-saver handoff\`.`);
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push('## Context');
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(`- cwd: \`${cwd}\``);
|
|
85
|
+
if (git) {
|
|
86
|
+
lines.push(`- git branch: \`${git.branch}\`${git.head ? ` @ \`${git.head}\`` : ''}`);
|
|
87
|
+
if (git.status) {
|
|
88
|
+
lines.push('- dirty files:');
|
|
89
|
+
lines.push(' ```');
|
|
90
|
+
for (const line of git.status.split('\n')) lines.push(` ${line}`);
|
|
91
|
+
lines.push(' ```');
|
|
92
|
+
} else {
|
|
93
|
+
lines.push('- working tree: clean');
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
lines.push('- git: (not a repo)');
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
lines.push('## Cap snapshot');
|
|
101
|
+
lines.push('');
|
|
102
|
+
if (caps) {
|
|
103
|
+
const fmtRow = (label, info) => {
|
|
104
|
+
if (!info) return `- ${label}: (unknown โ stdin had no rate-limit info)`;
|
|
105
|
+
const reset = formatResetIn(info.resetsAt, now);
|
|
106
|
+
const tail = reset ? `, resets in ${reset}` : '';
|
|
107
|
+
return `- ${label}: ${Math.round(info.usedPct)}%${tail}`;
|
|
108
|
+
};
|
|
109
|
+
lines.push(fmtRow('5-hour window', caps.fiveHour));
|
|
110
|
+
lines.push(fmtRow('7-day window', caps.sevenDay));
|
|
111
|
+
} else {
|
|
112
|
+
lines.push('- (no cap data โ run `handoff` from a Claude Code session for live numbers)');
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
|
|
116
|
+
lines.push('## What I just did');
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push('- _(fill in: 1โ3 bullets describing the most recent work)_');
|
|
119
|
+
lines.push('');
|
|
120
|
+
|
|
121
|
+
lines.push('## What\'s left (TODO)');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('- [ ] _(fill in)_');
|
|
124
|
+
lines.push('');
|
|
125
|
+
|
|
126
|
+
lines.push('## Where to pick up next');
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('- _(file paths, function names, the exact next step)_');
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
lines.push('## Watch out for');
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
|
|
134
|
+
lines.push('');
|
|
135
|
+
|
|
136
|
+
lines.push('## Resume prompt for the next Claude Code session');
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push('```');
|
|
139
|
+
lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
|
|
140
|
+
lines.push('```');
|
|
141
|
+
lines.push('');
|
|
142
|
+
|
|
143
|
+
return lines.join('\n') + '\n';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Write a handoff file in the given cwd.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} [opts]
|
|
150
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
151
|
+
* @param {object|null} [opts.caps] - { fiveHour, sevenDay } from extractCaps
|
|
152
|
+
* @param {Date} [opts.now=new Date()]
|
|
153
|
+
* @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
|
|
154
|
+
*/
|
|
155
|
+
export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
|
|
156
|
+
const absCwd = resolve(cwd);
|
|
157
|
+
const git = gitSnapshot(absCwd);
|
|
158
|
+
const path = pickPath(absCwd, now);
|
|
159
|
+
const body = renderTemplate({ now, cwd: absCwd, git, caps });
|
|
160
|
+
writeFileSync(path, body);
|
|
161
|
+
return { path, git };
|
|
162
|
+
}
|