cc-session-length 1.0.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 +76 -0
- package/cli.mjs +226 -0
- package/index.html +268 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# cc-session-length
|
|
2
|
+
|
|
3
|
+
**How long are your Claude Code sessions?**
|
|
4
|
+
|
|
5
|
+
Reads your `~/.claude` session files and shows the duration distribution — from 30-second context-compaction restarts to multi-hour marathon runs.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx cc-session-length
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or use the browser version (no install):
|
|
14
|
+
|
|
15
|
+
→ **[yurukusa.github.io/cc-session-length](https://yurukusa.github.io/cc-session-length/)**
|
|
16
|
+
|
|
17
|
+
Drag in your `~/.claude` folder. Everything runs locally — nothing is uploaded.
|
|
18
|
+
|
|
19
|
+
## Sample output
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
cc-session-length — How long are your Claude Code sessions? (Asia/Tokyo)
|
|
23
|
+
|
|
24
|
+
457 sessions · All time
|
|
25
|
+
|
|
26
|
+
< 1 min ████████████████████████░░░░ 88 ( 19%)
|
|
27
|
+
1–5 min ████████████████████████████ 109 ( 24%)
|
|
28
|
+
5–15 min ██████████████░░░░░░░░░░░░░░ 54 ( 12%)
|
|
29
|
+
15–30 min ██████████░░░░░░░░░░░░░░░░░░ 38 ( 8%)
|
|
30
|
+
30–60 min ███████████░░░░░░░░░░░░░░░░░ 43 ( 9%)
|
|
31
|
+
1–2 hr ███████░░░░░░░░░░░░░░░░░░░░░ 27 ( 6%)
|
|
32
|
+
2–4 hr ██████░░░░░░░░░░░░░░░░░░░░░░ 25 ( 5%)
|
|
33
|
+
4–8 hr ████░░░░░░░░░░░░░░░░░░░░░░░░ 17 ( 4%)
|
|
34
|
+
8+ hr ██████████████░░░░░░░░░░░░░░ 56 ( 12%)
|
|
35
|
+
|
|
36
|
+
───────────────────────────────────────────────────────
|
|
37
|
+
Session type: ⚖️ Balanced Worker
|
|
38
|
+
Mix of short and medium sessions. Steady rhythm.
|
|
39
|
+
|
|
40
|
+
Average: 4h 13m
|
|
41
|
+
Median: 11m
|
|
42
|
+
P90: 10h 42m
|
|
43
|
+
Longest: 101h 30m
|
|
44
|
+
< 1 min (compaction): 19.3% of all sessions
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Session types
|
|
48
|
+
|
|
49
|
+
| Type | Description |
|
|
50
|
+
|------|-------------|
|
|
51
|
+
| ⚡ Rapid Cycler | 60%+ of sessions < 1 min — lots of context compactions |
|
|
52
|
+
| 🏃 Quick Iterator | Median < 5 min — short, fast feedback cycles |
|
|
53
|
+
| ⚖️ Balanced Worker | Median 5–30 min — mix of short and medium |
|
|
54
|
+
| 🔬 Deep Worker | Median 30–120 min — long, focused sessions |
|
|
55
|
+
| 🦁 Marathon Coder | Median > 2 hr — multi-hour deep work |
|
|
56
|
+
|
|
57
|
+
## Options
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx cc-session-length # All-time breakdown
|
|
61
|
+
npx cc-session-length --days=30 # Last 30 days
|
|
62
|
+
npx cc-session-length --json # JSON output for dashboards
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
For each session file in `~/.claude/projects/`, the tool reads the first and last timestamp entry and computes the duration. Sessions in `subagents/` directories are excluded (those are subagent runs, not your main sessions).
|
|
68
|
+
|
|
69
|
+
## Part of cc-toolkit
|
|
70
|
+
|
|
71
|
+
cc-session-length is tool #46 in [cc-toolkit](https://yurukusa.github.io/cc-toolkit/) — 48 free tools for Claude Code users.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
**GitHub**: [yurukusa/cc-session-length](https://github.com/yurukusa/cc-session-length)
|
|
76
|
+
**Try it**: `npx cc-session-length`
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readdir, open } from 'fs/promises';
|
|
3
|
+
import { join, basename } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
const VERSION = '1.0.0';
|
|
7
|
+
const MAX_CHUNK = 4096;
|
|
8
|
+
const TAIL_CHUNK = 8192;
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = { days: 0, json: false, utc: false };
|
|
12
|
+
for (const a of argv.slice(2)) {
|
|
13
|
+
if (a.startsWith('--days=')) args.days = parseInt(a.slice(7)) || 0;
|
|
14
|
+
else if (a === '--json') args.json = true;
|
|
15
|
+
else if (a === '--utc') args.utc = true;
|
|
16
|
+
else if (a === '--help' || a === '-h') {
|
|
17
|
+
console.log([
|
|
18
|
+
`cc-session-length v${VERSION}`,
|
|
19
|
+
'',
|
|
20
|
+
'Usage: cc-session-length [options]',
|
|
21
|
+
'',
|
|
22
|
+
'Options:',
|
|
23
|
+
' --days=N Only analyze sessions from the last N days',
|
|
24
|
+
' --utc Use UTC instead of local time',
|
|
25
|
+
' --json Output JSON for piping',
|
|
26
|
+
' --help Show this help',
|
|
27
|
+
].join('\n'));
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return args;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readChunk(path, fromEnd = false) {
|
|
35
|
+
let fh;
|
|
36
|
+
try {
|
|
37
|
+
fh = await open(path, 'r');
|
|
38
|
+
const buf = Buffer.alloc(fromEnd ? TAIL_CHUNK : MAX_CHUNK);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
if (fromEnd) {
|
|
41
|
+
const stat = await fh.stat();
|
|
42
|
+
offset = Math.max(0, stat.size - TAIL_CHUNK);
|
|
43
|
+
}
|
|
44
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
|
|
45
|
+
return buf.subarray(0, bytesRead).toString('utf8');
|
|
46
|
+
} catch { return ''; }
|
|
47
|
+
finally { await fh?.close(); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractFirstTimestamp(text) {
|
|
51
|
+
for (const line of text.split('\n')) {
|
|
52
|
+
if (!line.trim()) continue;
|
|
53
|
+
try {
|
|
54
|
+
const d = JSON.parse(line);
|
|
55
|
+
if (d.timestamp) return new Date(d.timestamp);
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractLastTimestamp(text) {
|
|
62
|
+
const lines = text.split('\n').reverse();
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (!line.trim()) continue;
|
|
65
|
+
try {
|
|
66
|
+
const d = JSON.parse(line);
|
|
67
|
+
if (d.timestamp) return new Date(d.timestamp);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function collectSessions(dir, cutoff) {
|
|
74
|
+
const sessions = [];
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = await readdir(dir); } catch { return sessions; }
|
|
77
|
+
for (const name of entries) {
|
|
78
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
79
|
+
const path = join(dir, name);
|
|
80
|
+
const head = await readChunk(path, false);
|
|
81
|
+
const firstTs = extractFirstTimestamp(head);
|
|
82
|
+
if (!firstTs) continue;
|
|
83
|
+
if (cutoff && firstTs < cutoff) continue;
|
|
84
|
+
const tail = await readChunk(path, true);
|
|
85
|
+
const lastTs = extractLastTimestamp(tail);
|
|
86
|
+
if (!lastTs) continue;
|
|
87
|
+
const durationMin = (lastTs - firstTs) / 60000;
|
|
88
|
+
if (durationMin < 0) continue;
|
|
89
|
+
sessions.push({ file: name, firstTs, lastTs, durationMin });
|
|
90
|
+
}
|
|
91
|
+
return sessions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const BUCKETS = [
|
|
95
|
+
{ label: '< 1 min', min: 0, max: 1 },
|
|
96
|
+
{ label: '1–5 min', min: 1, max: 5 },
|
|
97
|
+
{ label: '5–15 min', min: 5, max: 15 },
|
|
98
|
+
{ label: '15–30 min', min: 15, max: 30 },
|
|
99
|
+
{ label: '30–60 min', min: 30, max: 60 },
|
|
100
|
+
{ label: '1–2 hr', min: 60, max: 120 },
|
|
101
|
+
{ label: '2–4 hr', min: 120, max: 240 },
|
|
102
|
+
{ label: '4–8 hr', min: 240, max: 480 },
|
|
103
|
+
{ label: '8+ hr', min: 480, max: Infinity },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
function median(sorted) {
|
|
107
|
+
if (!sorted.length) return 0;
|
|
108
|
+
const m = Math.floor(sorted.length / 2);
|
|
109
|
+
return sorted.length % 2 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function mean(arr) {
|
|
113
|
+
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function classify(medianMin, compactionRate) {
|
|
117
|
+
if (compactionRate >= 0.6) return { label: '⚡ Rapid Cycler', desc: 'Mostly short context-compaction restarts. Your AI works in tight loops.' };
|
|
118
|
+
if (medianMin < 5) return { label: '🏃 Quick Iterator', desc: 'Short, frequent sessions. Fast feedback cycles.' };
|
|
119
|
+
if (medianMin < 30) return { label: '⚖️ Balanced Worker', desc: 'Mix of short and medium sessions. Steady rhythm.' };
|
|
120
|
+
if (medianMin < 120) return { label: '🔬 Deep Worker', desc: 'Long, focused sessions. Sustained concentration.' };
|
|
121
|
+
return { label: '🦁 Marathon Coder', desc: 'Multi-hour sessions. Maximum deep work.' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function bar(pct, width = 28) {
|
|
125
|
+
const filled = Math.round(pct / 100 * width);
|
|
126
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fmtDuration(min) {
|
|
130
|
+
if (min < 1) return `${Math.round(min * 60)}s`;
|
|
131
|
+
if (min < 60) return `${Math.round(min)}m`;
|
|
132
|
+
const h = Math.floor(min / 60);
|
|
133
|
+
const m = Math.round(min % 60);
|
|
134
|
+
return m ? `${h}h ${m}m` : `${h}h`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function main() {
|
|
138
|
+
const args = parseArgs(process.argv);
|
|
139
|
+
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
140
|
+
const cutoff = args.days ? new Date(Date.now() - args.days * 86400000) : null;
|
|
141
|
+
|
|
142
|
+
let all = [];
|
|
143
|
+
let dirs;
|
|
144
|
+
try { dirs = await readdir(claudeDir); } catch {
|
|
145
|
+
console.error('~/.claude/projects not found');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const d of dirs) {
|
|
150
|
+
const sub = join(claudeDir, d);
|
|
151
|
+
const sessions = await collectSessions(sub, cutoff);
|
|
152
|
+
all.push(...sessions);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!all.length) {
|
|
156
|
+
console.log('No sessions found.');
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const durations = all.map(s => s.durationMin).sort((a, b) => a - b);
|
|
161
|
+
const total = durations.length;
|
|
162
|
+
const avg = mean(durations);
|
|
163
|
+
const med = median(durations);
|
|
164
|
+
const max = durations[total - 1];
|
|
165
|
+
const p90 = durations[Math.floor(total * 0.9)];
|
|
166
|
+
const compactionCount = durations.filter(d => d < 1).length;
|
|
167
|
+
const compactionRate = compactionCount / total;
|
|
168
|
+
|
|
169
|
+
const bucketCounts = BUCKETS.map(b => ({
|
|
170
|
+
...b,
|
|
171
|
+
count: durations.filter(d => d >= b.min && d < b.max).length,
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
const cls = classify(med, compactionRate);
|
|
175
|
+
|
|
176
|
+
if (args.json) {
|
|
177
|
+
console.log(JSON.stringify({
|
|
178
|
+
total, avg, median: med, max, p90, compactionRate,
|
|
179
|
+
classification: cls.label,
|
|
180
|
+
buckets: bucketCounts.map(b => ({ label: b.label, count: b.count, pct: b.count / total * 100 })),
|
|
181
|
+
}, null, 2));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const tz = args.utc ? 'UTC' : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
186
|
+
const scope = args.days ? `Last ${args.days} days` : 'All time';
|
|
187
|
+
|
|
188
|
+
const C = {
|
|
189
|
+
reset: '\x1b[0m', bold: '\x1b[1m',
|
|
190
|
+
purple: '\x1b[35m', cyan: '\x1b[36m', yellow: '\x1b[33m',
|
|
191
|
+
orange: '\x1b[38;5;214m', muted: '\x1b[90m',
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const maxBucketCount = Math.max(...bucketCounts.map(b => b.count), 1);
|
|
195
|
+
|
|
196
|
+
console.log(`\n${C.purple}${C.bold}cc-session-length${C.reset} — How long are your Claude Code sessions? (${tz})\n`);
|
|
197
|
+
console.log(`${C.muted}${total} sessions · ${scope}${C.reset}\n`);
|
|
198
|
+
|
|
199
|
+
for (const b of bucketCounts) {
|
|
200
|
+
const pct = (b.count / total * 100).toFixed(0);
|
|
201
|
+
const barWidth = Math.round(b.count / maxBucketCount * 28);
|
|
202
|
+
const isShort = b.max <= 1;
|
|
203
|
+
const color = isShort ? C.purple : b.max <= 15 ? C.cyan : b.max <= 60 ? C.yellow : C.orange;
|
|
204
|
+
const filled = '█'.repeat(barWidth);
|
|
205
|
+
const empty = '░'.repeat(28 - barWidth);
|
|
206
|
+
console.log(
|
|
207
|
+
` ${C.muted}${b.label.padEnd(10)}${C.reset} ` +
|
|
208
|
+
`${color}${filled}${C.muted}${empty}${C.reset} ` +
|
|
209
|
+
`${C.muted}${String(b.count).padStart(5)} (${String(pct).padStart(4)}%)${C.reset}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
console.log(`\n${'─'.repeat(55)}`);
|
|
214
|
+
console.log(` ${C.cyan}Session type:${C.reset} ${C.bold}${cls.label}${C.reset}`);
|
|
215
|
+
console.log(` ${C.muted}${cls.desc}${C.reset}`);
|
|
216
|
+
|
|
217
|
+
console.log(`\n ${C.muted}Average:${C.reset} ${C.bold}${fmtDuration(avg)}${C.reset}`);
|
|
218
|
+
console.log(` ${C.muted}Median:${C.reset} ${C.bold}${fmtDuration(med)}${C.reset}`);
|
|
219
|
+
console.log(` ${C.muted}P90:${C.reset} ${C.bold}${fmtDuration(p90)}${C.reset}`);
|
|
220
|
+
console.log(` ${C.muted}Longest:${C.reset} ${C.purple}${C.bold}${fmtDuration(max)}${C.reset}`);
|
|
221
|
+
console.log(` ${C.muted}< 1 min (compaction):${C.reset} ${(compactionRate * 100).toFixed(1)}% of all sessions`);
|
|
222
|
+
|
|
223
|
+
console.log();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|
package/index.html
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>cc-session-length — How long are your Claude Code sessions?</title>
|
|
7
|
+
<meta name="description" content="See the duration distribution of your Claude Code sessions. Drop your ~/.claude folder to find out if you're a Quick Iterator, Deep Worker, or Marathon Coder.">
|
|
8
|
+
<meta property="og:title" content="cc-session-length — Session duration analyzer for Claude Code">
|
|
9
|
+
<meta property="og:description" content="How long are your Claude Code sessions? From 30-second compaction restarts to 100-hour marathon runs.">
|
|
10
|
+
<meta property="og:url" content="https://yurukusa.github.io/cc-session-length/">
|
|
11
|
+
<meta name="twitter:card" content="summary">
|
|
12
|
+
<meta name="twitter:site" content="@yurukusa_dev">
|
|
13
|
+
<style>
|
|
14
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
15
|
+
:root {
|
|
16
|
+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
|
17
|
+
--text: #e6edf3; --muted: #8b949e;
|
|
18
|
+
--purple: #bc8cff; --cyan: #58d8f0; --yellow: #f7c948; --orange: #ffa657;
|
|
19
|
+
--blue: #58a6ff; --green: #56d364;
|
|
20
|
+
}
|
|
21
|
+
body {
|
|
22
|
+
background: var(--bg); color: var(--text);
|
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Consolas, monospace;
|
|
24
|
+
min-height: 100vh; display: flex; flex-direction: column;
|
|
25
|
+
align-items: center; padding: 2rem 1rem;
|
|
26
|
+
}
|
|
27
|
+
.header { text-align: center; margin-bottom: 2rem; }
|
|
28
|
+
.header h1 { font-size: 1.8rem; font-weight: 700; color: var(--purple); margin-bottom: 0.5rem; }
|
|
29
|
+
.header p { color: var(--muted); font-size: 0.9rem; }
|
|
30
|
+
.drop-zone {
|
|
31
|
+
width: 100%; max-width: 600px; border: 2px dashed var(--border);
|
|
32
|
+
border-radius: 12px; padding: 3rem 2rem; text-align: center;
|
|
33
|
+
cursor: pointer; transition: border-color 0.2s, background 0.2s;
|
|
34
|
+
margin-bottom: 2rem; background: var(--surface);
|
|
35
|
+
}
|
|
36
|
+
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--purple); background: #1a0f2e; }
|
|
37
|
+
.drop-zone h2 { font-size: 1.1rem; margin-bottom: 0.75rem; }
|
|
38
|
+
.drop-zone p { color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
|
39
|
+
.drop-zone .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
40
|
+
.drop-zone input[type="file"] { display: none; }
|
|
41
|
+
#results { width: 100%; max-width: 700px; display: none; }
|
|
42
|
+
.meta { font-size: 0.8rem; color: var(--muted); margin-bottom: 1.5rem; text-align: center; }
|
|
43
|
+
.card {
|
|
44
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
45
|
+
border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem;
|
|
46
|
+
}
|
|
47
|
+
.card-title { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1rem; }
|
|
48
|
+
.hour-row {
|
|
49
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
50
|
+
margin-bottom: 0.3rem; font-size: 0.78rem;
|
|
51
|
+
}
|
|
52
|
+
.row-label { color: var(--muted); min-width: 5.5rem; font-family: Consolas, monospace; }
|
|
53
|
+
.bar-wrap { flex: 1; height: 14px; background: #21262d; border-radius: 3px; overflow: hidden; }
|
|
54
|
+
.bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; }
|
|
55
|
+
.bar-fill.short { background: var(--purple); }
|
|
56
|
+
.bar-fill.quick { background: var(--cyan); }
|
|
57
|
+
.bar-fill.medium { background: var(--yellow); }
|
|
58
|
+
.bar-fill.long { background: var(--orange); }
|
|
59
|
+
.row-count { color: var(--muted); min-width: 4.5rem; text-align: right; font-family: Consolas, monospace; font-size: 0.75rem; }
|
|
60
|
+
.score-card { text-align: center; }
|
|
61
|
+
.cls-label { font-size: 1.5rem; font-weight: 700; color: var(--purple); margin-bottom: 0.5rem; }
|
|
62
|
+
.cls-desc { font-size: 0.85rem; color: var(--muted); margin-bottom: 1rem; }
|
|
63
|
+
.stats-grid {
|
|
64
|
+
display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin-top: 1rem;
|
|
65
|
+
}
|
|
66
|
+
@media (min-width: 480px) { .stats-grid { grid-template-columns: repeat(4, 1fr); } }
|
|
67
|
+
.stat { text-align: center; }
|
|
68
|
+
.stat .num { font-size: 1.2rem; font-weight: 700; display: block; }
|
|
69
|
+
.stat .lbl { font-size: 0.7rem; color: var(--muted); }
|
|
70
|
+
.footer { margin-top: 2rem; text-align: center; font-size: 0.8rem; color: var(--muted); }
|
|
71
|
+
.footer a { color: var(--blue); text-decoration: none; margin: 0 0.5rem; }
|
|
72
|
+
.btn {
|
|
73
|
+
background: var(--purple); color: #0d1117; border: none;
|
|
74
|
+
border-radius: 6px; padding: 0.6rem 1.5rem; font-size: 0.9rem;
|
|
75
|
+
font-weight: 600; cursor: pointer; margin-top: 1rem; font-family: inherit;
|
|
76
|
+
}
|
|
77
|
+
.btn:hover { opacity: 0.85; }
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<div class="header">
|
|
82
|
+
<h1>cc-session-length</h1>
|
|
83
|
+
<p>How long are your Claude Code sessions?</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fi').click()">
|
|
86
|
+
<div class="icon">⏱️</div>
|
|
87
|
+
<h2>Drop your .claude folder here</h2>
|
|
88
|
+
<p>Or click to select it.<br>Your data stays local — nothing is uploaded.</p>
|
|
89
|
+
<input type="file" id="fi" webkitdirectory multiple>
|
|
90
|
+
<button class="btn" onclick="event.stopPropagation(); document.getElementById('fi').click()">
|
|
91
|
+
Select ~/.claude folder
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
<div id="results">
|
|
95
|
+
<div class="meta" id="metaLine"></div>
|
|
96
|
+
<div class="card score-card" id="scoreCard"></div>
|
|
97
|
+
<div class="card">
|
|
98
|
+
<div class="card-title">Session duration distribution</div>
|
|
99
|
+
<div id="chart"></div>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="footer">
|
|
102
|
+
<a href="https://github.com/yurukusa/cc-session-length" target="_blank">GitHub</a> ·
|
|
103
|
+
<a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit (106 free tools)</a> ·
|
|
104
|
+
<span>Also: <code>npx cc-session-length</code></span>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="footer" id="footerMain">
|
|
108
|
+
<a href="https://github.com/yurukusa/cc-session-length" target="_blank">GitHub</a> ·
|
|
109
|
+
<a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit</a> ·
|
|
110
|
+
<span>Part of 106 free tools for Claude Code</span>
|
|
111
|
+
</div>
|
|
112
|
+
<script>
|
|
113
|
+
const dropZone = document.getElementById('dropZone');
|
|
114
|
+
const fi = document.getElementById('fi');
|
|
115
|
+
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
|
116
|
+
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
|
117
|
+
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); processFiles(Array.from(e.dataTransfer.files)); });
|
|
118
|
+
fi.addEventListener('change', e => processFiles(Array.from(e.target.files)));
|
|
119
|
+
|
|
120
|
+
const BUCKETS = [
|
|
121
|
+
{ label: '< 1 min', min: 0, max: 1, cls: 'short' },
|
|
122
|
+
{ label: '1–5 min', min: 1, max: 5, cls: 'quick' },
|
|
123
|
+
{ label: '5–15 min', min: 5, max: 15, cls: 'quick' },
|
|
124
|
+
{ label: '15–30 min', min: 15, max: 30, cls: 'medium' },
|
|
125
|
+
{ label: '30–60 min', min: 30, max: 60, cls: 'medium' },
|
|
126
|
+
{ label: '1–2 hr', min: 60, max: 120, cls: 'long' },
|
|
127
|
+
{ label: '2–4 hr', min: 120, max: 240, cls: 'long' },
|
|
128
|
+
{ label: '4–8 hr', min: 240, max: 480, cls: 'long' },
|
|
129
|
+
{ label: '8+ hr', min: 480, max: Infinity, cls: 'long' },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
function fmtDuration(min) {
|
|
133
|
+
if (min < 1) return `${Math.round(min * 60)}s`;
|
|
134
|
+
if (min < 60) return `${Math.round(min)}m`;
|
|
135
|
+
const h = Math.floor(min / 60);
|
|
136
|
+
const m = Math.round(min % 60);
|
|
137
|
+
return m ? `${h}h ${m}m` : `${h}h`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function classify(med, compactionRate) {
|
|
141
|
+
if (compactionRate >= 0.6) return { label: '⚡ Rapid Cycler', desc: 'Mostly short context-compaction restarts. Your AI works in tight loops.' };
|
|
142
|
+
if (med < 5) return { label: '🏃 Quick Iterator', desc: 'Short, frequent sessions. Fast feedback cycles.' };
|
|
143
|
+
if (med < 30) return { label: '⚖️ Balanced Worker', desc: 'Mix of short and medium sessions. Steady rhythm.' };
|
|
144
|
+
if (med < 120) return { label: '🔬 Deep Worker', desc: 'Long, focused sessions. Sustained concentration.' };
|
|
145
|
+
return { label: '🦁 Marathon Coder', desc: 'Multi-hour sessions. Maximum deep work.' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function median(arr) {
|
|
149
|
+
if (!arr.length) return 0;
|
|
150
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
151
|
+
const m = Math.floor(s.length / 2);
|
|
152
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function processFiles(files) {
|
|
156
|
+
// Only top-level session files (not in subagents/ subdirs), under .claude/projects/
|
|
157
|
+
const sessionFiles = files.filter(f => {
|
|
158
|
+
const p = f.webkitRelativePath;
|
|
159
|
+
return p.endsWith('.jsonl') &&
|
|
160
|
+
p.includes('projects') &&
|
|
161
|
+
!p.includes('/subagents/');
|
|
162
|
+
});
|
|
163
|
+
if (!sessionFiles.length) {
|
|
164
|
+
alert('No session files found.\nSelect your .claude folder (not a subfolder).');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const durations = [];
|
|
169
|
+
|
|
170
|
+
for (const f of sessionFiles) {
|
|
171
|
+
try {
|
|
172
|
+
// Read head (4KB) for first timestamp
|
|
173
|
+
const headText = await f.slice(0, 4096).text();
|
|
174
|
+
let firstTs = null;
|
|
175
|
+
for (const line of headText.split('\n')) {
|
|
176
|
+
if (!line.trim()) continue;
|
|
177
|
+
try {
|
|
178
|
+
const d = JSON.parse(line);
|
|
179
|
+
if (d.timestamp) { firstTs = new Date(d.timestamp); break; }
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
if (!firstTs) continue;
|
|
183
|
+
|
|
184
|
+
// Read tail (8KB) for last timestamp
|
|
185
|
+
const tailText = await f.slice(Math.max(0, f.size - 8192)).text();
|
|
186
|
+
let lastTs = null;
|
|
187
|
+
const tailLines = tailText.split('\n').reverse();
|
|
188
|
+
for (const line of tailLines) {
|
|
189
|
+
if (!line.trim()) continue;
|
|
190
|
+
try {
|
|
191
|
+
const d = JSON.parse(line);
|
|
192
|
+
if (d.timestamp) { lastTs = new Date(d.timestamp); break; }
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
if (!lastTs) continue;
|
|
196
|
+
|
|
197
|
+
const durationMin = (lastTs - firstTs) / 60000;
|
|
198
|
+
if (durationMin < 0) continue;
|
|
199
|
+
durations.push(durationMin);
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!durations.length) {
|
|
204
|
+
alert('Could not extract session durations.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
209
|
+
const total = sorted.length;
|
|
210
|
+
const avg = durations.reduce((s, v) => s + v, 0) / total;
|
|
211
|
+
const med = median(sorted);
|
|
212
|
+
const maxVal = sorted[total - 1];
|
|
213
|
+
const p90 = sorted[Math.floor(total * 0.9)];
|
|
214
|
+
const compactionCount = sorted.filter(d => d < 1).length;
|
|
215
|
+
const compactionRate = compactionCount / total;
|
|
216
|
+
|
|
217
|
+
document.getElementById('metaLine').textContent = `${total} sessions analyzed`;
|
|
218
|
+
dropZone.style.display = 'none';
|
|
219
|
+
document.getElementById('footerMain').style.display = 'none';
|
|
220
|
+
document.getElementById('results').style.display = 'block';
|
|
221
|
+
|
|
222
|
+
renderChart(sorted, total);
|
|
223
|
+
renderScore(med, avg, maxVal, p90, compactionRate, total, compactionCount);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderChart(sorted, total) {
|
|
227
|
+
const bucketCounts = BUCKETS.map(b => ({
|
|
228
|
+
...b,
|
|
229
|
+
count: sorted.filter(d => d >= b.min && d < b.max).length,
|
|
230
|
+
}));
|
|
231
|
+
const maxCount = Math.max(...bucketCounts.map(b => b.count), 1);
|
|
232
|
+
|
|
233
|
+
document.getElementById('chart').innerHTML = bucketCounts.map(b => {
|
|
234
|
+
const pct = (b.count / total * 100).toFixed(1);
|
|
235
|
+
const barPct = (b.count / maxCount * 100).toFixed(0);
|
|
236
|
+
return `<div class="hour-row">
|
|
237
|
+
<span class="row-label">${b.label}</span>
|
|
238
|
+
<div class="bar-wrap"><div class="bar-fill ${b.cls}" style="width:${barPct}%"></div></div>
|
|
239
|
+
<span class="row-count">${b.count} (${pct}%)</span>
|
|
240
|
+
</div>`;
|
|
241
|
+
}).join('');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderScore(med, avg, maxVal, p90, compactionRate, total, compactionCount) {
|
|
245
|
+
const cls = classify(med, compactionRate);
|
|
246
|
+
const colors = {
|
|
247
|
+
'⚡ Rapid Cycler': 'var(--purple)',
|
|
248
|
+
'🏃 Quick Iterator': 'var(--cyan)',
|
|
249
|
+
'⚖️ Balanced Worker': 'var(--yellow)',
|
|
250
|
+
'🔬 Deep Worker': 'var(--orange)',
|
|
251
|
+
'🦁 Marathon Coder': 'var(--orange)',
|
|
252
|
+
};
|
|
253
|
+
const color = colors[cls.label] || 'var(--purple)';
|
|
254
|
+
|
|
255
|
+
document.getElementById('scoreCard').innerHTML = `
|
|
256
|
+
<div class="card-title">Session length profile</div>
|
|
257
|
+
<div class="cls-label" style="color:${color}">${cls.label}</div>
|
|
258
|
+
<div class="cls-desc">${cls.desc}</div>
|
|
259
|
+
<div class="stats-grid">
|
|
260
|
+
<div class="stat"><span class="num" style="color:${color}">${fmtDuration(med)}</span><span class="lbl">median</span></div>
|
|
261
|
+
<div class="stat"><span class="num">${fmtDuration(avg)}</span><span class="lbl">average</span></div>
|
|
262
|
+
<div class="stat"><span class="num" style="color:var(--purple)">${fmtDuration(maxVal)}</span><span class="lbl">longest</span></div>
|
|
263
|
+
<div class="stat"><span class="num">${(compactionRate * 100).toFixed(1)}%</span><span class="lbl">< 1 min (compact)</span></div>
|
|
264
|
+
</div>`;
|
|
265
|
+
}
|
|
266
|
+
</script>
|
|
267
|
+
</body>
|
|
268
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-session-length",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "How long are your Claude Code sessions? Duration distribution and analysis.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-session-length": "./cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node cli.mjs"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude-code",
|
|
14
|
+
"claude",
|
|
15
|
+
"ai",
|
|
16
|
+
"session",
|
|
17
|
+
"productivity",
|
|
18
|
+
"analytics",
|
|
19
|
+
"cli"
|
|
20
|
+
],
|
|
21
|
+
"author": "yurukusa",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
}
|
|
26
|
+
}
|