claude-rpc 0.3.8
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/LICENSE +21 -0
- package/README.md +300 -0
- package/bin/claude-rpc.js +2 -0
- package/config.example.json +67 -0
- package/package.json +53 -0
- package/src/badge.js +144 -0
- package/src/cli.js +765 -0
- package/src/daemon.js +324 -0
- package/src/default-config.js +91 -0
- package/src/format.js +657 -0
- package/src/git.js +74 -0
- package/src/hook.js +169 -0
- package/src/insights.js +138 -0
- package/src/install.js +280 -0
- package/src/languages.js +114 -0
- package/src/paths.js +59 -0
- package/src/pricing.js +73 -0
- package/src/scanner.js +721 -0
- package/src/server.js +1584 -0
- package/src/state.js +73 -0
- package/src/tui.js +420 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { Client } from '@xhayper/discord-rpc';
|
|
4
|
+
import { readState } from './state.js';
|
|
5
|
+
import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
|
|
6
|
+
import { scan, readAggregate, findLiveSessions } from './scanner.js';
|
|
7
|
+
import { detectGithubUrl } from './git.js';
|
|
8
|
+
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
|
|
9
|
+
|
|
10
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
11
|
+
|
|
12
|
+
function log(...args) {
|
|
13
|
+
const line = `[${new Date().toISOString()}] ${args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
|
|
14
|
+
try { appendFileSync(LOG_PATH, line); } catch {}
|
|
15
|
+
process.stdout.write(line);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadConfig() {
|
|
19
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
|
|
20
|
+
catch (e) { log('Failed to read config.json:', e.message); process.exit(1); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let config = loadConfig();
|
|
24
|
+
let aggregate = readAggregate() || null;
|
|
25
|
+
let liveSessions = [];
|
|
26
|
+
let client = null;
|
|
27
|
+
let connected = false;
|
|
28
|
+
let lastPayloadHash = '';
|
|
29
|
+
let reconnectTimer = null;
|
|
30
|
+
let rotationIndex = 0;
|
|
31
|
+
let lastRotationAt = 0;
|
|
32
|
+
// Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
|
|
33
|
+
// from a moving transcript mtime, and missing-hook scenarios leave it null —
|
|
34
|
+
// either case would make startTimestamp jump on every rotation.
|
|
35
|
+
let effectiveSessionStart = null;
|
|
36
|
+
// Track which status the rotation cursor belongs to so we can reset it cleanly
|
|
37
|
+
// on a status transition — otherwise the cursor carries over from idle's
|
|
38
|
+
// 7-frame rotation into a single-frame working state and back, producing a
|
|
39
|
+
// jarring "blank tick" until modulo aligns.
|
|
40
|
+
let rotationStatus = null;
|
|
41
|
+
|
|
42
|
+
writeFileSync(PID_PATH, String(process.pid));
|
|
43
|
+
|
|
44
|
+
function maybeAdvanceRotation(rotation, intervalMs) {
|
|
45
|
+
if (!Array.isArray(rotation) || rotation.length === 0) return undefined;
|
|
46
|
+
if (rotation.length === 1) return rotation[0];
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (now - lastRotationAt >= intervalMs) {
|
|
49
|
+
rotationIndex = (rotationIndex + 1) % rotation.length;
|
|
50
|
+
lastRotationAt = now;
|
|
51
|
+
}
|
|
52
|
+
return rotation[rotationIndex % rotation.length];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Choose frames + a status-level largeImageText override based on the new
|
|
56
|
+
// `presence.byStatus` block when present. Falls back to legacy `p.rotation`
|
|
57
|
+
// (which still works for any existing user config that doesn't use byStatus).
|
|
58
|
+
//
|
|
59
|
+
// Returns { frames, largeImageTextTpl }. A byStatus entry can be:
|
|
60
|
+
// { details, state, largeImageText, rotation? }
|
|
61
|
+
// If `rotation` is present, the base { details, state } is rendered first
|
|
62
|
+
// and the rotation array cycles after it. Otherwise the entry is a single
|
|
63
|
+
// fixed frame.
|
|
64
|
+
function pickFrames(p, status) {
|
|
65
|
+
const sb = p.byStatus?.[status];
|
|
66
|
+
if (sb) {
|
|
67
|
+
const base = { details: sb.details, state: sb.state, largeImageText: sb.largeImageText };
|
|
68
|
+
const frames = Array.isArray(sb.rotation) && sb.rotation.length
|
|
69
|
+
? [base, ...sb.rotation]
|
|
70
|
+
: [base];
|
|
71
|
+
return { frames, largeImageTextTpl: sb.largeImageText || null };
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(p.rotation) && p.rotation.length) {
|
|
74
|
+
return { frames: p.rotation, largeImageTextTpl: null };
|
|
75
|
+
}
|
|
76
|
+
return { frames: [{ details: p.details, state: p.state }], largeImageTextTpl: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildActivity(opts = {}) {
|
|
80
|
+
let state = opts.state || readState();
|
|
81
|
+
// Attach live sessions BEFORE applyIdle so the stale/idle decision can
|
|
82
|
+
// see ongoing transcript activity, not just this daemon's hook state.
|
|
83
|
+
state.liveSessions = opts.liveSessions || liveSessions;
|
|
84
|
+
state = applyIdle(state, config);
|
|
85
|
+
const vars = buildVars(state, config, opts.aggregate || aggregate);
|
|
86
|
+
const p = config.presence || {};
|
|
87
|
+
|
|
88
|
+
// Pick the active set of frames + any status-level largeImageText override.
|
|
89
|
+
// Reset the rotation cursor when status changes so a 7-frame idle rotation
|
|
90
|
+
// doesn't bleed its index into a 1-frame working state.
|
|
91
|
+
const { frames: rawFrames, largeImageTextTpl: statusLIT } = pickFrames(p, state.status);
|
|
92
|
+
if (state.status !== rotationStatus) {
|
|
93
|
+
rotationIndex = 0;
|
|
94
|
+
lastRotationAt = 0;
|
|
95
|
+
rotationStatus = state.status;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Drop frames whose `requires` vars are empty/zero. Keeps presence tight.
|
|
99
|
+
const frames = rawFrames.filter((f) => framePasses(f, vars));
|
|
100
|
+
const safeFrames = frames.length ? frames : rawFrames.slice(0, 1);
|
|
101
|
+
|
|
102
|
+
const intervalMs = Math.max(5000, config.rotationIntervalMs || 12000);
|
|
103
|
+
const frame = maybeAdvanceRotation(safeFrames, intervalMs) || {};
|
|
104
|
+
|
|
105
|
+
const activity = {};
|
|
106
|
+
// Forcing `name` overrides whatever Discord has cached for the app's
|
|
107
|
+
// display name, so every user sees the same "Playing <appName>" header
|
|
108
|
+
// regardless of their client's stale application cache.
|
|
109
|
+
activity.name = config.appName || 'Claude Code';
|
|
110
|
+
if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
|
|
111
|
+
if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
|
|
112
|
+
|
|
113
|
+
// Image precedence: statusAssets[status] → modelAssets[modelMatch] → presence.largeImageKey.
|
|
114
|
+
// statusAssets lets the user swap the big image based on what Claude is doing
|
|
115
|
+
// (working/thinking/idle/stale/notification).
|
|
116
|
+
let largeKeyTpl = p.largeImageKey;
|
|
117
|
+
if (config.statusAssets && config.statusAssets[state.status]) {
|
|
118
|
+
largeKeyTpl = config.statusAssets[state.status];
|
|
119
|
+
} else if (config.modelAssets && state.model && state.status !== 'stale') {
|
|
120
|
+
const m = String(state.model).toLowerCase();
|
|
121
|
+
let pick = null;
|
|
122
|
+
if (m.includes('opus')) pick = config.modelAssets.opus;
|
|
123
|
+
else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
|
|
124
|
+
else if (m.includes('haiku')) pick = config.modelAssets.haiku;
|
|
125
|
+
if (!pick) pick = config.modelAssets.default;
|
|
126
|
+
if (pick) largeKeyTpl = pick;
|
|
127
|
+
}
|
|
128
|
+
if (largeKeyTpl) activity.largeImageKey = fillTemplate(largeKeyTpl, vars);
|
|
129
|
+
// largeImageText precedence: per-frame override > byStatus entry > global default.
|
|
130
|
+
const largeTextTpl = frame.largeImageText || statusLIT || p.largeImageText;
|
|
131
|
+
if (largeTextTpl) activity.largeImageText = fillTemplate(largeTextTpl, vars).slice(0, 128);
|
|
132
|
+
|
|
133
|
+
// Small image: pick the status icon directly from vars (set via config.statusIcons).
|
|
134
|
+
// Skips when the icon is empty (e.g. 'stale' has no asset).
|
|
135
|
+
const smallKey = p.smallImageKey ? fillTemplate(p.smallImageKey, vars) : vars.statusIcon;
|
|
136
|
+
if (smallKey && smallKey !== 'stale') {
|
|
137
|
+
activity.smallImageKey = smallKey;
|
|
138
|
+
activity.smallImageText = fillTemplate(p.smallImageText || '{statusVerbose}', vars).slice(0, 128);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (state.status === 'stale') {
|
|
142
|
+
effectiveSessionStart = null;
|
|
143
|
+
} else if (state.sessionStart) {
|
|
144
|
+
effectiveSessionStart = state.sessionStart;
|
|
145
|
+
} else if (!effectiveSessionStart) {
|
|
146
|
+
effectiveSessionStart = state.lastActivity || Date.now();
|
|
147
|
+
}
|
|
148
|
+
if (config.showElapsed && effectiveSessionStart && state.status !== 'stale') {
|
|
149
|
+
// Discord IPC + @xhayper/discord-rpc expect milliseconds (not seconds).
|
|
150
|
+
activity.startTimestamp = effectiveSessionStart;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Activity type — 0=Playing, 1=Streaming, 2=Listening, 3=Watching, 5=Competing.
|
|
154
|
+
// Default to Playing for backwards-compat; config can override.
|
|
155
|
+
if (typeof config.activityType === 'number') activity.type = config.activityType;
|
|
156
|
+
|
|
157
|
+
// Buttons: static configured set, optionally augmented with a per-project
|
|
158
|
+
// GitHub button when the current cwd has a github origin.
|
|
159
|
+
const buttons = Array.isArray(p.buttons) ? p.buttons.slice() : [];
|
|
160
|
+
const gh = state.status !== 'stale' ? detectGithubUrl(state.cwd) : null;
|
|
161
|
+
if (gh && !buttons.some((b) => /github\.com/i.test(b.url || ''))) {
|
|
162
|
+
buttons.unshift({ label: 'View on GitHub →', url: gh });
|
|
163
|
+
}
|
|
164
|
+
if (buttons.length) {
|
|
165
|
+
activity.buttons = buttons.slice(0, 2).map((b) => ({
|
|
166
|
+
label: fillTemplate(b.label, vars).slice(0, 32),
|
|
167
|
+
url: fillTemplate(b.url, vars),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
return activity;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function pushPresence() {
|
|
174
|
+
if (!connected || !client?.user) return;
|
|
175
|
+
try {
|
|
176
|
+
// Resolve state once so we can decide whether to push or clear.
|
|
177
|
+
// Mirrors buildActivity's first two lines — kept here so we don't
|
|
178
|
+
// have to round-trip through buildActivity just to learn the status.
|
|
179
|
+
let resolved = readState();
|
|
180
|
+
resolved.liveSessions = liveSessions;
|
|
181
|
+
resolved = applyIdle(resolved, config);
|
|
182
|
+
|
|
183
|
+
const hideWhenStale = config.hideWhenStale !== false;
|
|
184
|
+
if (resolved.status === 'stale' && hideWhenStale) {
|
|
185
|
+
const stamp = 'cleared';
|
|
186
|
+
if (lastPayloadHash === stamp) return;
|
|
187
|
+
lastPayloadHash = stamp;
|
|
188
|
+
// Wipe effectiveSessionStart so the next active push gets a fresh
|
|
189
|
+
// elapsed timer rather than counting from a previous session.
|
|
190
|
+
effectiveSessionStart = null;
|
|
191
|
+
await client.user.clearActivity();
|
|
192
|
+
log('Presence cleared (stale — Claude Code not running)');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const activity = buildActivity({ state: resolved });
|
|
197
|
+
const hash = JSON.stringify(activity);
|
|
198
|
+
if (hash === lastPayloadHash) return;
|
|
199
|
+
lastPayloadHash = hash;
|
|
200
|
+
await client.user.setActivity(activity);
|
|
201
|
+
log('Presence updated:', activity.details || '-', '|', activity.state || '-');
|
|
202
|
+
} catch (e) {
|
|
203
|
+
log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function connect() {
|
|
208
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
209
|
+
client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
|
|
210
|
+
|
|
211
|
+
client.on('ready', () => {
|
|
212
|
+
connected = true;
|
|
213
|
+
log('Discord RPC connected as', client.user?.username);
|
|
214
|
+
lastPayloadHash = '';
|
|
215
|
+
pushPresence();
|
|
216
|
+
});
|
|
217
|
+
client.on('disconnected', () => {
|
|
218
|
+
connected = false;
|
|
219
|
+
log('Discord disconnected — retrying in 10s');
|
|
220
|
+
scheduleReconnect();
|
|
221
|
+
});
|
|
222
|
+
try { await client.login(); }
|
|
223
|
+
catch (e) {
|
|
224
|
+
log('Discord login failed:', e.message, '— retrying in 10s. Is Discord desktop running?');
|
|
225
|
+
scheduleReconnect();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function scheduleReconnect() {
|
|
230
|
+
connected = false;
|
|
231
|
+
if (reconnectTimer) return;
|
|
232
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 10000);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function watchFiles() {
|
|
236
|
+
let stateTimer = null;
|
|
237
|
+
if (existsSync(STATE_PATH)) {
|
|
238
|
+
watch(STATE_PATH, () => {
|
|
239
|
+
clearTimeout(stateTimer);
|
|
240
|
+
stateTimer = setTimeout(pushPresence, 250);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
watch(CONFIG_PATH, () => {
|
|
244
|
+
log('Config changed — reloading');
|
|
245
|
+
try { config = loadConfig(); lastPayloadHash = ''; pushPresence(); }
|
|
246
|
+
catch (e) { log('Reload failed:', e.message); }
|
|
247
|
+
});
|
|
248
|
+
if (existsSync(AGGREGATE_PATH)) {
|
|
249
|
+
let aggTimer = null;
|
|
250
|
+
watch(AGGREGATE_PATH, () => {
|
|
251
|
+
clearTimeout(aggTimer);
|
|
252
|
+
aggTimer = setTimeout(() => {
|
|
253
|
+
aggregate = readAggregate() || aggregate;
|
|
254
|
+
lastPayloadHash = '';
|
|
255
|
+
pushPresence();
|
|
256
|
+
}, 250);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runBackgroundScan({ force = false } = {}) {
|
|
262
|
+
try {
|
|
263
|
+
const t0 = Date.now();
|
|
264
|
+
const { aggregate: agg, scanned, skipped, removed, total } = scan({ force });
|
|
265
|
+
aggregate = agg;
|
|
266
|
+
log(`Scan complete: ${scanned} parsed / ${skipped} cached / ${removed} removed / ${total} total in ${Date.now() - t0}ms — allHours=${(agg.activeMs / 3_600_000).toFixed(1)}, sessions=${agg.sessions}, tokens=${(agg.inputTokens + agg.outputTokens)}`);
|
|
267
|
+
lastPayloadHash = '';
|
|
268
|
+
pushPresence();
|
|
269
|
+
} catch (e) {
|
|
270
|
+
log('Scan failed:', e.message);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function shutdown() {
|
|
275
|
+
log('Shutting down…');
|
|
276
|
+
try { client?.destroy(); } catch {}
|
|
277
|
+
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
process.on('SIGINT', shutdown);
|
|
282
|
+
process.on('SIGTERM', shutdown);
|
|
283
|
+
process.on('SIGHUP', shutdown);
|
|
284
|
+
|
|
285
|
+
log('Claude RPC daemon starting. clientId=', config.clientId);
|
|
286
|
+
if (!config.clientId || config.clientId === '1234567890123456789') {
|
|
287
|
+
log('WARNING: config.json contains the placeholder clientId.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
connect();
|
|
291
|
+
watchFiles();
|
|
292
|
+
|
|
293
|
+
// Push presence on a tick — also drives rotation.
|
|
294
|
+
const pushIntervalMs = Math.max(2000, config.updateIntervalMs || 4000);
|
|
295
|
+
setInterval(pushPresence, pushIntervalMs);
|
|
296
|
+
|
|
297
|
+
// Initial scan + periodic rescan.
|
|
298
|
+
runBackgroundScan({ force: false });
|
|
299
|
+
const rescanMs = Math.max(60_000, (config.rescanIntervalSec || 300) * 1000);
|
|
300
|
+
setInterval(() => runBackgroundScan({ force: false }), rescanMs);
|
|
301
|
+
|
|
302
|
+
// Live session polling — cheap mtime walk. With the SessionEnd hook now
|
|
303
|
+
// flipping claudeClosed=true authoritatively (see format.applyIdle), this
|
|
304
|
+
// poll is no longer the primary "is Claude open?" signal — it's a hard-kill
|
|
305
|
+
// backstop and the source for concurrent-session detection. 30s cadence
|
|
306
|
+
// keeps the disk work minimal while still surfacing a new sibling session
|
|
307
|
+
// within a rotation cycle or two.
|
|
308
|
+
function refreshLiveSessions() {
|
|
309
|
+
try {
|
|
310
|
+
const thresholdMs = (config.liveSessionThresholdSec || 90) * 1000;
|
|
311
|
+
const next = findLiveSessions({ thresholdMs });
|
|
312
|
+
const prevCount = liveSessions.length;
|
|
313
|
+
liveSessions = next;
|
|
314
|
+
if (next.length !== prevCount) {
|
|
315
|
+
log('Concurrent sessions:', next.length, next.map((s) => `${s.project}(${s.ageSec}s)`).join(', '));
|
|
316
|
+
lastPayloadHash = '';
|
|
317
|
+
pushPresence();
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {
|
|
320
|
+
log('live-session poll failed:', e.message);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
refreshLiveSessions();
|
|
324
|
+
setInterval(refreshLiveSessions, 30_000);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Inlined default config used to seed <USER_CONFIG_DIR>/config.json on first
|
|
2
|
+
// install. Kept in sync with config.example.json. Inlining (rather than
|
|
3
|
+
// bundling the JSON as an asset) keeps the build pipeline simple and works
|
|
4
|
+
// with both pkg and Node SEA without asset APIs.
|
|
5
|
+
//
|
|
6
|
+
// v0.3.6 shape:
|
|
7
|
+
// presence.byStatus.<status> — fixed template per status (the "VSCode-RPC
|
|
8
|
+
// style" main view). Each entry may optionally include a `rotation` array
|
|
9
|
+
// that cycles AFTER the base frame. Backwards-compat: a config without
|
|
10
|
+
// byStatus still works — the legacy top-level `presence.rotation` is used.
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_CONFIG = {
|
|
13
|
+
clientId: "1506443909406920948",
|
|
14
|
+
appName: "Claude Code",
|
|
15
|
+
updateIntervalMs: 4000,
|
|
16
|
+
rotationIntervalMs: 12000,
|
|
17
|
+
rescanIntervalSec: 300,
|
|
18
|
+
idleThresholdSec: 60,
|
|
19
|
+
// Time (minutes) of no hook activity AND no live transcripts on disk before
|
|
20
|
+
// the daemon treats Claude Code as "not running". Kept short on purpose:
|
|
21
|
+
// when Claude isn't open, the Discord presence should disappear quickly.
|
|
22
|
+
// The SessionEnd hook short-circuits this — see hook.js + format.applyIdle.
|
|
23
|
+
staleSessionMin: 5,
|
|
24
|
+
// When true, the daemon CLEARS Discord activity entirely once the state
|
|
25
|
+
// goes stale — your profile shows nothing instead of an "Away" frame.
|
|
26
|
+
hideWhenStale: true,
|
|
27
|
+
notificationWindowSec: 8,
|
|
28
|
+
showElapsed: true,
|
|
29
|
+
activityType: 0,
|
|
30
|
+
statusAssets: {
|
|
31
|
+
working: "https://cdn.qualit.ly/clawd-working-building.gif",
|
|
32
|
+
thinking: "https://cdn.qualit.ly/clawd-working-typing.gif",
|
|
33
|
+
idle: "https://cdn.qualit.ly/clawd-sleeping.gif",
|
|
34
|
+
stale: "https://cdn.qualit.ly/clawd-sleeping.gif",
|
|
35
|
+
notification: "https://cdn.qualit.ly/clawd-notification.gif",
|
|
36
|
+
},
|
|
37
|
+
presence: {
|
|
38
|
+
largeImageKey: "https://cdn.qualit.ly/clawd-sleeping.gif",
|
|
39
|
+
// Tooltip used when a status doesn't supply its own largeImageText.
|
|
40
|
+
// The lifetime "credentials" line that travels with every status.
|
|
41
|
+
largeImageText: "{modelPretty} · {allHours} on Claude · {streakLabel}",
|
|
42
|
+
smallImageKey: "{statusIcon}",
|
|
43
|
+
smallImageText: "{statusVerbose}",
|
|
44
|
+
|
|
45
|
+
// Status-driven templates. Each status renders a fixed "what's happening
|
|
46
|
+
// right now" frame. Only `idle` carries an inner rotation — that's where
|
|
47
|
+
// the lifetime stats cycle through.
|
|
48
|
+
byStatus: {
|
|
49
|
+
working: {
|
|
50
|
+
details: "Working in {project}",
|
|
51
|
+
state: "{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens",
|
|
52
|
+
largeImageText: "Working on a {fileLang} file",
|
|
53
|
+
},
|
|
54
|
+
thinking: {
|
|
55
|
+
details: "Thinking in {project}",
|
|
56
|
+
state: "{modelPretty} · {messagesLabel} · {tokensFmt} tokens",
|
|
57
|
+
largeImageText: "Reasoning with {modelPretty}",
|
|
58
|
+
},
|
|
59
|
+
notification: {
|
|
60
|
+
details: "Waiting on you · {project}",
|
|
61
|
+
state: "{modelPretty} · {messagesLabel}",
|
|
62
|
+
largeImageText: "Permission needed",
|
|
63
|
+
},
|
|
64
|
+
idle: {
|
|
65
|
+
details: "Idle in {project}",
|
|
66
|
+
state: "{modelPretty} · {todayHours} today",
|
|
67
|
+
largeImageText: "Idle · {modelPretty}",
|
|
68
|
+
rotation: [
|
|
69
|
+
{ details: "This week · {weekHours}", state: "{weekPromptsLabel} · {weekTokensFmt} tokens", requires: ["weekActiveMs"] },
|
|
70
|
+
{ details: "{streakLabel}", state: "{daysSinceFirstLabel} · {allSessionsLabel}", requires: ["streakIsMilestone"] },
|
|
71
|
+
{ details: "Hotspot · {topEditedFile}", state: "{topEditedCountLabel} all-time", requires: ["topEditedCount"] },
|
|
72
|
+
{ details: "{allHours} on Claude all-time", state: "{allSessionsLabel} · {allMessagesFmt} prompts", requires: ["allSessions"] },
|
|
73
|
+
{ details: "Lifetime · {allTokensFmt} tokens", state: "{allToolsFmt} tool calls · {allFilesFmt} files", requires: ["allTools"] },
|
|
74
|
+
{ details: "Code churn · {linesAddedFmt} added", state: "{linesNetFmt} net · {topLanguage}", requires: ["topLanguage"] },
|
|
75
|
+
{ details: "Cost · {todayCostFmt} today", state: "{allCostFmt} all-time", requires: ["allCost"] },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
buttons: [
|
|
81
|
+
{ label: "Claude Code", url: "https://claude.com/claude-code" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
statusIcons: {
|
|
85
|
+
working: "working",
|
|
86
|
+
thinking: "thinking",
|
|
87
|
+
idle: "idle",
|
|
88
|
+
notification: "",
|
|
89
|
+
stale: "",
|
|
90
|
+
},
|
|
91
|
+
};
|