sessionboard 0.0.0-stage → 0.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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +7 -0
- package/LICENSE +9 -0
- package/README.md +66 -2
- package/bin/sessionboard.mjs +1126 -0
- package/commands/dashboard.md +9 -0
- package/commands/setup.md +32 -0
- package/commands/share.md +10 -0
- package/commands/top.md +7 -0
- package/hooks/hooks.json +11 -0
- package/lib/dashboard.html +313 -0
- package/package.json +34 -4
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Open the sessionboard dashboard in your browser
|
|
3
|
+
allowed-tools: Bash(node:*)
|
|
4
|
+
---
|
|
5
|
+
Start the dashboard in the background with the Bash tool (`run_in_background: true`):
|
|
6
|
+
|
|
7
|
+
`node "${CLAUDE_PLUGIN_ROOT}/bin/sessionboard.mjs" dashboard`
|
|
8
|
+
|
|
9
|
+
It opens http://localhost:4317 (or the next free port) in the browser. Tell the user the URL from its output, and that it stops when this session ends.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Set up sessionboard - choose the judge model and settings, then rate your sessions
|
|
3
|
+
allowed-tools: Bash(node:*), AskUserQuestion
|
|
4
|
+
---
|
|
5
|
+
Current state:
|
|
6
|
+
!`node "${CLAUDE_PLUGIN_ROOT}/bin/sessionboard.mjs" setup-info`
|
|
7
|
+
|
|
8
|
+
Walk the user through setup. Briefly explain first, in 2-3 lines: when a session ends, a background hook gives it one AI rating (0-10, with a summary and the reason), the best sessions make their board, and any session can be exported as a redacted page to share.
|
|
9
|
+
|
|
10
|
+
Then ask with AskUserQuestion, preselecting the user's current values where the state above shows them (otherwise put the recommended option first):
|
|
11
|
+
|
|
12
|
+
Call 1 (four questions):
|
|
13
|
+
1. "Which model should judge your sessions?" - Haiku (Recommended: fast, cheapest) / Sonnet (sharper, ~3x cost) / Opus (best, slowest) / Off (free heuristic only)
|
|
14
|
+
2. "When should a resumed session be re-rated?" - When it grows 25% (Recommended) / When it doubles / Never
|
|
15
|
+
3. "How big is your board?" - Top 3 (Recommended) / Top 5 / Top 10
|
|
16
|
+
4. "Which sessions count?" - Standard: 2+ prompts and 5+ tool calls (Recommended) / Strict: 5+ prompts and 20+ tool calls / Everything
|
|
17
|
+
|
|
18
|
+
Call 2 (ask only the questions that apply):
|
|
19
|
+
5. Only if cleanupPeriodDays is under 365: "Claude Code deletes transcripts older than N days, so older sessions drop off your board. Keep them longer? This edits ~/.claude/settings.json." - Keep for 10 years (Recommended) / Keep for 1 year / Leave as is
|
|
20
|
+
6. Only if hookInstalled is false: "Rate sessions automatically when they end? This adds one hook to ~/.claude/settings.json." - Yes (Recommended) / No, I'll refresh myself
|
|
21
|
+
7. "Words to hide in exports, like company, product, client or internal host names?" - None / Type them (via Other, comma-separated)
|
|
22
|
+
8. "Redact email addresses in exports?" - Yes (Recommended) / No
|
|
23
|
+
|
|
24
|
+
Then apply exactly what they chose. Only pass --retention and --hook=yes if they explicitly picked those options:
|
|
25
|
+
|
|
26
|
+
`node "${CLAUDE_PLUGIN_ROOT}/bin/sessionboard.mjs" setup-apply --judgeModel=<haiku|sonnet|opus|off> --rejudgeGrowth=<1.25|2|0> --topN=<3|5|10> --minPrompts=<2|5|1> --minToolCalls=<5|20|1> --redactEmails=<true|false> --redactTerms="<comma,separated or empty>" --retention=<3650|365|keep> --hook=<yes|no>`
|
|
27
|
+
|
|
28
|
+
If the judge is on and there are unrated sessions, rate them (one call per session, roughly 8 seconds each; tell the user it's running):
|
|
29
|
+
|
|
30
|
+
`node "${CLAUDE_PLUGIN_ROOT}/bin/sessionboard.mjs" update`
|
|
31
|
+
|
|
32
|
+
Finish by running `node "${CLAUDE_PLUGIN_ROOT}/bin/sessionboard.mjs" top` and showing the board as-is, then mention /sessionboard:dashboard and /sessionboard:share 1.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Export one of your top sessions as a redacted, shareable page (default rank 1)
|
|
3
|
+
argument-hint: "[rank|session-id] [--html] [--copy] [--gist]"
|
|
4
|
+
allowed-tools: Bash(node:*)
|
|
5
|
+
---
|
|
6
|
+
Run this and report the output path to the user:
|
|
7
|
+
|
|
8
|
+
`node "${CLAUDE_PLUGIN_ROOT}/bin/sessionboard.mjs" share $ARGUMENTS`
|
|
9
|
+
|
|
10
|
+
If no format flag was given, add `--html`. Only pass `--gist` if the user explicitly asked for a gist, since it uploads to GitHub. Remind them to skim the export before sending it; redaction catches common secrets, not everything.
|
package/commands/top.md
ADDED
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
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">
|
|
6
|
+
<title>sessionboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg:#f7f6f3;--fg:#1d1c1a;--mute:#6f6b64;--faint:#a29d94;--card:#fff;--line:#e5e1da;--hover:#f1eee8;
|
|
10
|
+
--accent:#c4643f;--accent-soft:#f6e6de;--gold:#b8860b;--silver:#7d8590;--bronze:#a0522d;--err:#b3261e;--ok:#2f7d4f;
|
|
11
|
+
--shadow:0 1px 2px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.04);
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme:dark){:root{
|
|
14
|
+
--bg:#171614;--fg:#ecebe7;--mute:#a19c93;--faint:#6d6961;--card:#211f1d;--line:#34312d;--hover:#2a2825;
|
|
15
|
+
--accent:#e08560;--accent-soft:#3a2a22;--gold:#e0b440;--silver:#aab2bd;--bronze:#d08a5c;--err:#f2b8b5;--ok:#7fd1a0;
|
|
16
|
+
--shadow:none;
|
|
17
|
+
}}
|
|
18
|
+
*{box-sizing:border-box}
|
|
19
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
|
|
20
|
+
button{font:inherit;color:inherit;cursor:pointer}
|
|
21
|
+
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
22
|
+
.wrap{max-width:1120px;margin:0 auto;padding:32px 16px 80px}
|
|
23
|
+
header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:28px}
|
|
24
|
+
.brand{display:flex;align-items:baseline;gap:10px}
|
|
25
|
+
.brand h1{font-size:22px;margin:0;letter-spacing:-.01em}
|
|
26
|
+
.brand .sub{color:var(--mute);font-size:13px}
|
|
27
|
+
.btn{border:1px solid var(--line);background:var(--card);border-radius:8px;padding:6px 12px;font-size:13px;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
|
|
28
|
+
.btn:hover{background:var(--hover)}
|
|
29
|
+
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
30
|
+
.btn.primary:hover{filter:brightness(1.05)}
|
|
31
|
+
.btn.ghost{border-color:transparent;background:transparent;padding:4px 8px;color:var(--mute)}
|
|
32
|
+
.btn.ghost:hover{background:var(--hover);color:var(--fg)}
|
|
33
|
+
.btn[disabled]{opacity:.5;cursor:default}
|
|
34
|
+
.status{color:var(--mute);font-size:12px}
|
|
35
|
+
.notice{background:var(--accent-soft);border:1px solid var(--line);border-radius:10px;padding:10px 14px;margin-bottom:20px;font-size:13px}
|
|
36
|
+
|
|
37
|
+
h2{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--mute);margin:0 0 12px}
|
|
38
|
+
.podium{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:40px}
|
|
39
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;box-shadow:var(--shadow);display:flex;flex-direction:column;gap:10px;min-width:0}
|
|
40
|
+
.card .top{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
41
|
+
.rank{font-weight:700;font-size:13px;display:inline-flex;align-items:center;gap:6px}
|
|
42
|
+
.rank .medal{width:22px;height:22px;border-radius:50%;display:inline-grid;place-items:center;color:#fff;font-size:12px}
|
|
43
|
+
.m1{background:var(--gold)}.m2{background:var(--silver)}.m3{background:var(--bronze)}.mx{background:var(--faint)}
|
|
44
|
+
.score{font-weight:700;font-size:22px;letter-spacing:-.02em}
|
|
45
|
+
.score small{font-size:13px;color:var(--mute);font-weight:500}
|
|
46
|
+
.card h3{margin:0;font-size:16px;line-height:1.35;overflow-wrap:anywhere}
|
|
47
|
+
.card .summary{margin:0;color:var(--fg)}
|
|
48
|
+
.card .why{margin:0;color:var(--mute);font-size:13px}
|
|
49
|
+
.meta{color:var(--mute);font-size:12px;display:flex;flex-wrap:wrap;gap:4px 10px}
|
|
50
|
+
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
|
51
|
+
.chip{font-size:12px;background:var(--hover);border-radius:999px;padding:2px 9px;color:var(--mute)}
|
|
52
|
+
.card .actions{display:flex;gap:6px;margin-top:auto;padding-top:6px;flex-wrap:wrap}
|
|
53
|
+
.tag{font-size:11px;border-radius:999px;padding:1px 8px;border:1px solid var(--line);color:var(--mute)}
|
|
54
|
+
.tag.warn{color:var(--err);border-color:currentColor}
|
|
55
|
+
|
|
56
|
+
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
|
|
57
|
+
.toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
58
|
+
.toolbar input{border:1px solid var(--line);background:var(--bg);color:var(--fg);border-radius:8px;padding:6px 10px;font:inherit;font-size:13px;min-width:0;flex:1;max-width:280px}
|
|
59
|
+
.toolbar label{font-size:13px;color:var(--mute);display:flex;align-items:center;gap:6px}
|
|
60
|
+
.tscroll{overflow-x:auto}
|
|
61
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
62
|
+
th{text-align:left;font-weight:600;color:var(--mute);font-size:12px;padding:10px 14px;border-bottom:1px solid var(--line);white-space:nowrap;cursor:pointer;user-select:none}
|
|
63
|
+
th:hover{color:var(--fg)}
|
|
64
|
+
th.num,td.num{text-align:right}
|
|
65
|
+
td{padding:10px 14px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
66
|
+
tr:last-child td{border-bottom:0}
|
|
67
|
+
tbody tr{cursor:pointer}
|
|
68
|
+
tbody tr:hover{background:var(--hover)}
|
|
69
|
+
tr.dim td{color:var(--faint)}
|
|
70
|
+
td .t{font-weight:500;color:var(--fg);overflow-wrap:anywhere}
|
|
71
|
+
tr.dim td .t{color:var(--faint)}
|
|
72
|
+
td .s{color:var(--mute);font-size:12px;margin-top:2px;max-width:520px}
|
|
73
|
+
.bar{display:inline-block;width:44px;height:6px;border-radius:3px;background:var(--hover);vertical-align:middle;margin-left:8px;overflow:hidden}
|
|
74
|
+
.bar i{display:block;height:100%;background:var(--accent)}
|
|
75
|
+
|
|
76
|
+
/* drawer */
|
|
77
|
+
.scrim{position:fixed;inset:0;background:rgba(0,0,0,.35);opacity:0;pointer-events:none;transition:opacity .15s}
|
|
78
|
+
.scrim.open{opacity:1;pointer-events:auto}
|
|
79
|
+
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(760px,100%);background:var(--bg);border-left:1px solid var(--line);transform:translateX(100%);transition:transform .2s ease;display:flex;flex-direction:column;z-index:2}
|
|
80
|
+
.drawer.open{transform:none}
|
|
81
|
+
.dhead{padding:18px 20px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px;background:var(--card)}
|
|
82
|
+
.dhead .row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
83
|
+
.dhead h3{margin:0;font-size:18px;line-height:1.3;overflow-wrap:anywhere}
|
|
84
|
+
.dbody{overflow-y:auto;padding:16px 20px 60px;flex:1}
|
|
85
|
+
.turn{margin:12px 0;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--card)}
|
|
86
|
+
.turn.user{background:var(--accent-soft)}
|
|
87
|
+
.who{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);margin-bottom:4px}
|
|
88
|
+
.txt{white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0}
|
|
89
|
+
.tool{font-size:12px;color:var(--mute);margin:3px 0;overflow-wrap:anywhere}
|
|
90
|
+
.tool b{font-weight:600;margin-right:6px}
|
|
91
|
+
.tool.err b{color:var(--err)}
|
|
92
|
+
.menu{position:relative;display:inline-block}
|
|
93
|
+
.menu .pop{position:absolute;right:0;top:calc(100% + 4px);background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.12);padding:4px;min-width:210px;display:none;z-index:5}
|
|
94
|
+
.menu.open .pop{display:block}
|
|
95
|
+
.pop button{display:block;width:100%;text-align:left;border:0;background:transparent;padding:7px 10px;border-radius:6px;font-size:13px}
|
|
96
|
+
.pop button:hover{background:var(--hover)}
|
|
97
|
+
.pop .hint{font-size:11px;color:var(--mute);padding:6px 10px 4px}
|
|
98
|
+
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--fg);color:var(--bg);padding:8px 14px;border-radius:8px;font-size:13px;opacity:0;transition:all .2s;pointer-events:none;z-index:10}
|
|
99
|
+
.toast.show{opacity:1;transform:translateX(-50%)}
|
|
100
|
+
.empty{padding:40px 16px;text-align:center;color:var(--mute)}
|
|
101
|
+
@media (max-width:640px){.hide-sm{display:none}}
|
|
102
|
+
</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<div class="wrap">
|
|
106
|
+
<header>
|
|
107
|
+
<div class="brand"><h1>sessionboard</h1><span class="sub" id="sub">your best agent sessions</span></div>
|
|
108
|
+
<div style="display:flex;gap:10px;align-items:center">
|
|
109
|
+
<span class="status" id="status"></span>
|
|
110
|
+
<button class="btn" id="refresh">↻ Refresh</button>
|
|
111
|
+
</div>
|
|
112
|
+
</header>
|
|
113
|
+
<div id="notice"></div>
|
|
114
|
+
|
|
115
|
+
<h2 id="podiumTitle">Top sessions</h2>
|
|
116
|
+
<div class="podium" id="podium"></div>
|
|
117
|
+
|
|
118
|
+
<h2>All sessions</h2>
|
|
119
|
+
<div class="tablecard">
|
|
120
|
+
<div class="toolbar">
|
|
121
|
+
<input id="q" placeholder="Search title, summary, project…" type="search">
|
|
122
|
+
<label><input type="checkbox" id="showAll"> show trivial & hidden</label>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="tscroll"><table>
|
|
125
|
+
<thead><tr>
|
|
126
|
+
<th data-k="rank" class="num">#</th>
|
|
127
|
+
<th data-k="title">Session</th>
|
|
128
|
+
<th data-k="score" class="num">Score</th>
|
|
129
|
+
<th data-k="date" class="hide-sm">Date</th>
|
|
130
|
+
<th data-k="tools" class="num hide-sm">Tools</th>
|
|
131
|
+
<th data-k="files" class="num hide-sm">Files</th>
|
|
132
|
+
<th data-k="active" class="num hide-sm">Active</th>
|
|
133
|
+
</tr></thead>
|
|
134
|
+
<tbody id="rows"></tbody>
|
|
135
|
+
</table></div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div class="scrim" id="scrim"></div>
|
|
140
|
+
<aside class="drawer" id="drawer" aria-hidden="true">
|
|
141
|
+
<div class="dhead" id="dhead"></div>
|
|
142
|
+
<div class="dbody" id="dbody"></div>
|
|
143
|
+
</aside>
|
|
144
|
+
<div class="toast" id="toast"></div>
|
|
145
|
+
|
|
146
|
+
<script>
|
|
147
|
+
const $ = (s) => document.querySelector(s);
|
|
148
|
+
const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
149
|
+
const api = (p, opts = {}) => fetch(p, { ...opts, headers: { 'x-sessionboard': '1', ...(opts.headers || {}) } });
|
|
150
|
+
let state = { sessions: [], topN: 3 }, sortKey = 'rank', sortDir = 1, current = null, poll = null;
|
|
151
|
+
|
|
152
|
+
function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('show'), 2200); }
|
|
153
|
+
|
|
154
|
+
async function load() {
|
|
155
|
+
const r = await api('/api/sessions');
|
|
156
|
+
state = await r.json();
|
|
157
|
+
render();
|
|
158
|
+
// keep polling while a judge run is in progress
|
|
159
|
+
clearTimeout(poll);
|
|
160
|
+
if (state.judging) poll = setTimeout(load, 2500);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const scoreOf = (s) => s.judge ? s.judge.score : null;
|
|
164
|
+
const medal = (r) => `<span class="medal ${r <= 3 ? 'm' + r : 'mx'}">${r}</span>`;
|
|
165
|
+
const chips = (s) => {
|
|
166
|
+
const c = [`${s.stats.toolCalls} tool calls`];
|
|
167
|
+
if (s.stats.files) c.push(`${s.stats.files} files`);
|
|
168
|
+
if (s.stats.tests) c.push(`${s.stats.tests} test/build runs`);
|
|
169
|
+
if (s.stats.commits) c.push(`${s.stats.commits} commits`);
|
|
170
|
+
if (s.stats.prs) c.push(`${s.stats.prs} PRs`);
|
|
171
|
+
c.push(`${s.stats.prompts} prompts`, `${s.active} active`);
|
|
172
|
+
return c.map((x) => `<span class="chip">${esc(x)}</span>`).join('');
|
|
173
|
+
};
|
|
174
|
+
const tags = (s) => [
|
|
175
|
+
s.pinned && '<span class="tag">📌 pinned</span>',
|
|
176
|
+
s.hidden && '<span class="tag">hidden</span>',
|
|
177
|
+
s.missing && '<span class="tag warn">transcript deleted</span>',
|
|
178
|
+
state.judging === s.id && '<span class="tag">judging…</span>',
|
|
179
|
+
].filter(Boolean).join(' ');
|
|
180
|
+
|
|
181
|
+
function shareMenu(s) {
|
|
182
|
+
if (s.missing) return '';
|
|
183
|
+
return `<div class="menu" data-menu="${s.id}">
|
|
184
|
+
<button class="btn primary" data-act="menu">Share ▾</button>
|
|
185
|
+
<div class="pop">
|
|
186
|
+
<div class="hint">Redacted: keys, tokens, secrets, emails, home paths</div>
|
|
187
|
+
<button data-act="dl-html">Download HTML page</button>
|
|
188
|
+
<button data-act="dl-md">Download Markdown</button>
|
|
189
|
+
<button data-act="copy-md">Copy Markdown</button>
|
|
190
|
+
<button data-act="open-html">Open shareable page</button>
|
|
191
|
+
</div></div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function render() {
|
|
195
|
+
const ranked = state.sessions.filter((s) => s.rank).sort((a, b) => a.rank - b.rank);
|
|
196
|
+
const top = ranked.slice(0, state.topN);
|
|
197
|
+
$('#podiumTitle').textContent = `Top ${state.topN} sessions`;
|
|
198
|
+
$('#sub').textContent = `${state.sessions.length} sessions · ${ranked.filter((s) => s.judge).length} rated`;
|
|
199
|
+
$('#status').textContent = state.judging === 'all' ? 'Rating new sessions…' : state.judging ? 'Rating a session…' : state.updatedAt ? `updated ${new Date(state.updatedAt).toLocaleTimeString()}` : '';
|
|
200
|
+
$('#notice').innerHTML = !state.judgeEnabled ? '<div class="notice">AI rating is off. Run <code>sessionboard config judge=true</code> to enable it.</div>'
|
|
201
|
+
: ranked.length && !ranked.some((s) => s.judge) ? '<div class="notice">Sessions aren\'t rated yet. Run <code>sessionboard init</code> in your terminal to set up rating.</div>' : '';
|
|
202
|
+
|
|
203
|
+
$('#podium').innerHTML = top.length ? top.map((s) => `
|
|
204
|
+
<article class="card" data-id="${s.id}">
|
|
205
|
+
<div class="top">
|
|
206
|
+
<span class="rank">${medal(s.rank)} ${tags(s)}</span>
|
|
207
|
+
<span class="score">${scoreOf(s) ?? '–'}<small>/10</small></span>
|
|
208
|
+
</div>
|
|
209
|
+
<h3>${esc(s.title)}</h3>
|
|
210
|
+
${s.judge?.summary ? `<p class="summary">${esc(s.judge.summary)}</p>` : ''}
|
|
211
|
+
${s.judge?.reason ? `<p class="why">${esc(s.judge.reason)}</p>` : ''}
|
|
212
|
+
<div class="meta"><span>${esc(s.date)}</span><span class="mono">${esc(s.project || '')}</span></div>
|
|
213
|
+
<div class="chips">${chips(s)}</div>
|
|
214
|
+
<div class="actions">
|
|
215
|
+
${shareMenu(s)}
|
|
216
|
+
<button class="btn" data-act="view">Preview</button>
|
|
217
|
+
<button class="btn ghost" data-act="${s.pinned ? 'unpin' : 'pin'}">${s.pinned ? 'Unpin' : 'Pin'}</button>
|
|
218
|
+
<button class="btn ghost" data-act="hide">Hide</button>
|
|
219
|
+
</div>
|
|
220
|
+
</article>`).join('') : '<div class="empty">No eligible sessions yet. Do some real work with Claude Code, then refresh.</div>';
|
|
221
|
+
|
|
222
|
+
const q = $('#q').value.trim().toLowerCase();
|
|
223
|
+
const showAll = $('#showAll').checked;
|
|
224
|
+
let rows = state.sessions.filter((s) => showAll || s.rank);
|
|
225
|
+
if (q) rows = rows.filter((s) => [s.title, s.project, s.judge?.summary, s.judge?.reason].join(' ').toLowerCase().includes(q));
|
|
226
|
+
const key = {
|
|
227
|
+
rank: (s) => s.rank ?? 1e9, title: (s) => s.title.toLowerCase(), score: (s) => -(scoreOf(s) ?? -1) * 1000 - s.heuristic,
|
|
228
|
+
date: (s) => -(Date.parse(s.start) || 0), tools: (s) => -s.stats.toolCalls, files: (s) => -s.stats.files, active: (s) => -s.activeMinutes,
|
|
229
|
+
}[sortKey];
|
|
230
|
+
rows.sort((a, b) => (key(a) > key(b) ? 1 : key(a) < key(b) ? -1 : 0) * sortDir);
|
|
231
|
+
$('#rows').innerHTML = rows.length ? rows.map((s) => {
|
|
232
|
+
const sc = scoreOf(s);
|
|
233
|
+
return `<tr data-id="${s.id}" class="${s.rank ? '' : 'dim'}">
|
|
234
|
+
<td class="num">${s.rank ?? '–'}</td>
|
|
235
|
+
<td><div class="t">${esc(s.title)} ${tags(s)}</div><div class="s">${esc(s.judge?.summary || s.project || '')}</div></td>
|
|
236
|
+
<td class="num" style="white-space:nowrap">${sc ?? '–'}${sc != null ? `<span class="bar"><i style="width:${sc * 10}%"></i></span>` : ''}</td>
|
|
237
|
+
<td class="hide-sm" style="white-space:nowrap">${esc(s.date)}</td>
|
|
238
|
+
<td class="num hide-sm">${s.stats.toolCalls}</td>
|
|
239
|
+
<td class="num hide-sm">${s.stats.files}</td>
|
|
240
|
+
<td class="num hide-sm" style="white-space:nowrap">${esc(s.active)}</td>
|
|
241
|
+
</tr>`;
|
|
242
|
+
}).join('') : '<tr><td colspan="7" class="empty">Nothing matches.</td></tr>';
|
|
243
|
+
|
|
244
|
+
if (current) renderDrawerHead(state.sessions.find((s) => s.id === current));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderDrawerHead(s) {
|
|
248
|
+
if (!s) return;
|
|
249
|
+
$('#dhead').innerHTML = `
|
|
250
|
+
<div class="row"><h3>${esc(s.title)}</h3><button class="btn ghost" data-act="close" aria-label="Close">✕</button></div>
|
|
251
|
+
<div class="meta"><span>${s.rank ? '#' + s.rank : 'unranked'}</span><span><b>${scoreOf(s) ?? '–'}</b>/10</span><span>${esc(s.date)}</span><span class="mono">${esc(s.project || '')}</span>${s.model ? `<span>${esc(s.model)}</span>` : ''}</div>
|
|
252
|
+
${s.judge?.summary ? `<div>${esc(s.judge.summary)}</div>` : ''}
|
|
253
|
+
${s.judge?.reason ? `<div class="why" style="color:var(--mute);font-size:13px">why: ${esc(s.judge.reason)}</div>` : ''}
|
|
254
|
+
<div class="chips">${chips(s)} ${tags(s)}</div>
|
|
255
|
+
<div class="actions" style="display:flex;gap:6px;flex-wrap:wrap" data-id="${s.id}">
|
|
256
|
+
${shareMenu(s)}
|
|
257
|
+
<button class="btn" data-act="rejudge" ${state.judging || s.missing ? 'disabled' : ''}>Re-rate</button>
|
|
258
|
+
<button class="btn ghost" data-act="${s.pinned ? 'unpin' : 'pin'}">${s.pinned ? 'Unpin' : 'Pin'}</button>
|
|
259
|
+
<button class="btn ghost" data-act="${s.hidden ? 'unhide' : 'hide'}">${s.hidden ? 'Unhide' : 'Hide'}</button>
|
|
260
|
+
</div>`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function openDrawer(id) {
|
|
264
|
+
current = id;
|
|
265
|
+
renderDrawerHead(state.sessions.find((s) => s.id === id));
|
|
266
|
+
$('#dbody').innerHTML = '<div class="empty">Loading transcript…</div>';
|
|
267
|
+
$('#drawer').classList.add('open'); $('#scrim').classList.add('open'); $('#drawer').setAttribute('aria-hidden', 'false');
|
|
268
|
+
const r = await api(`/api/sessions/${id}/transcript`);
|
|
269
|
+
const data = await r.json();
|
|
270
|
+
if (current !== id) return;
|
|
271
|
+
if (!r.ok) { $('#dbody').innerHTML = `<div class="empty">${esc(data.error)}</div>`; return; }
|
|
272
|
+
$('#dbody').innerHTML = data.turns.map((t) => `<div class="turn ${t.role}"><div class="who">${t.role === 'user' ? 'You' : 'Claude'}</div>${
|
|
273
|
+
t.parts.map((p) => p.kind === 'tool'
|
|
274
|
+
? `<div class="tool ${p.error ? 'err' : ''}"><b>${esc(p.name)}</b><code>${esc(p.text.slice(0, 180))}</code></div>`
|
|
275
|
+
: `<div class="txt">${esc(p.text)}</div>`).join('')}</div>`).join('');
|
|
276
|
+
}
|
|
277
|
+
function closeDrawer() { current = null; $('#drawer').classList.remove('open'); $('#scrim').classList.remove('open'); $('#drawer').setAttribute('aria-hidden', 'true'); }
|
|
278
|
+
|
|
279
|
+
async function act(action, id, el) {
|
|
280
|
+
const base = `/api/sessions/${id}`;
|
|
281
|
+
if (action === 'menu') { const m = el.closest('.menu'); const open = m.classList.contains('open'); document.querySelectorAll('.menu.open').forEach((x) => x.classList.remove('open')); if (!open) m.classList.add('open'); return; }
|
|
282
|
+
document.querySelectorAll('.menu.open').forEach((x) => x.classList.remove('open'));
|
|
283
|
+
if (action === 'view') return openDrawer(id);
|
|
284
|
+
if (action === 'close') return closeDrawer();
|
|
285
|
+
if (action === 'dl-html' || action === 'dl-md') { location.href = `${base}/export?format=${action === 'dl-html' ? 'html' : 'md'}&download=1`; return toast('Downloading…'); }
|
|
286
|
+
if (action === 'open-html') return window.open(`${base}/export?format=html`, '_blank');
|
|
287
|
+
if (action === 'copy-md') {
|
|
288
|
+
const r = await api(`${base}/export?format=md`);
|
|
289
|
+
if (!r.ok) return toast((await r.json()).error);
|
|
290
|
+
await navigator.clipboard.writeText(await r.text());
|
|
291
|
+
return toast('Markdown copied. Skim it before sharing.');
|
|
292
|
+
}
|
|
293
|
+
if (['pin', 'unpin', 'hide', 'unhide'].includes(action)) { await api(`${base}/${action}`, { method: 'POST' }); toast(`${action[0].toUpperCase() + action.slice(1)}ned`.replace('Hidened', 'Hidden').replace('Unhidened', 'Unhidden')); return load(); }
|
|
294
|
+
if (action === 'rejudge') { const r = await api(`${base}/rejudge`, { method: 'POST' }); if (!r.ok) return toast((await r.json()).error); toast('Re-rating… (takes ~10s)'); return load(); }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
document.addEventListener('click', (e) => {
|
|
298
|
+
const btn = e.target.closest('[data-act]');
|
|
299
|
+
if (btn) { e.stopPropagation(); const id = btn.closest('[data-menu]')?.dataset.menu || btn.closest('[data-id]')?.dataset.id || current; return act(btn.dataset.act, id, btn); }
|
|
300
|
+
if (!e.target.closest('.menu')) document.querySelectorAll('.menu.open').forEach((x) => x.classList.remove('open'));
|
|
301
|
+
const row = e.target.closest('tbody tr[data-id], .card[data-id]');
|
|
302
|
+
if (row && !e.target.closest('.menu')) openDrawer(row.dataset.id);
|
|
303
|
+
});
|
|
304
|
+
$('#scrim').addEventListener('click', closeDrawer);
|
|
305
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDrawer(); });
|
|
306
|
+
$('#q').addEventListener('input', render);
|
|
307
|
+
$('#showAll').addEventListener('change', render);
|
|
308
|
+
document.querySelectorAll('th[data-k]').forEach((th) => th.addEventListener('click', () => { sortDir = sortKey === th.dataset.k ? -sortDir : 1; sortKey = th.dataset.k; render(); }));
|
|
309
|
+
$('#refresh').addEventListener('click', async () => { $('#refresh').disabled = true; await api('/api/refresh', { method: 'POST' }); await load(); $('#refresh').disabled = false; toast(state.judging ? 'Rescanned; rating new or grown sessions…' : 'Rescanned; everything is up to date'); });
|
|
310
|
+
load();
|
|
311
|
+
</script>
|
|
312
|
+
</body>
|
|
313
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sessionboard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "An AI-judged leaderboard of your Claude Code sessions. Keeps your top 3 current and exports them, redacted, for sharing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sessionboard": "bin/sessionboard.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"commands",
|
|
13
|
+
"hooks",
|
|
14
|
+
".claude-plugin",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"claude-code",
|
|
22
|
+
"claude",
|
|
23
|
+
"agent",
|
|
24
|
+
"ai",
|
|
25
|
+
"session",
|
|
26
|
+
"portfolio",
|
|
27
|
+
"leaderboard"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/kbiam/sessionboard.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/kbiam/sessionboard",
|
|
35
|
+
"author": "Kush Bang"
|
|
36
|
+
}
|