@henryz2004/agency 1.0.1 → 1.0.2
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/lib/install.js +48 -0
- package/lib/live.js +27 -21
- package/package.json +2 -2
- package/public/chat-panel.css +26 -8
- package/public/chat-panel.js +54 -34
- package/public/index.html +3 -0
- package/public/leaderboard.js +53 -29
- package/public/metric.js +33 -0
- package/public/office.js +91 -52
- package/public/style.css +2 -0
- package/server.js +30 -1
package/lib/install.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// install.js — when this machine first ran Agency.
|
|
2
|
+
//
|
|
3
|
+
// The leaderboard ranks engineer-years shipped SINCE you joined Agency, not your
|
|
4
|
+
// whole Claude history, so everyone starts from zero on install. We persist the
|
|
5
|
+
// timestamp once, on the first run that records it — for an `npx` user that's
|
|
6
|
+
// the download. We deliberately do NOT backfill from the data dir's birthtime:
|
|
7
|
+
// an existing ~/.agency (from an earlier, pre-leaderboard build) would otherwise
|
|
8
|
+
// sweep months of pre-join usage into the "since install" slice.
|
|
9
|
+
//
|
|
10
|
+
// Fail-soft like every adapter: a read/write error never throws — worst case we
|
|
11
|
+
// recompute the anchor next process start.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { DATA_DIR } from './paths.js';
|
|
16
|
+
|
|
17
|
+
const FILE = path.join(DATA_DIR, 'install.json');
|
|
18
|
+
|
|
19
|
+
let cached = null;
|
|
20
|
+
|
|
21
|
+
// { at: epoch-ms, day: 'YYYY-MM-DD' (local, matches usage.js day buckets) }
|
|
22
|
+
export function installInfo() {
|
|
23
|
+
if (cached) return cached;
|
|
24
|
+
|
|
25
|
+
// Already recorded? Trust it.
|
|
26
|
+
try {
|
|
27
|
+
const j = JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
|
28
|
+
if (j && typeof j.installedAt === 'number' && j.installedAt > 0) {
|
|
29
|
+
return (cached = { at: j.installedAt, day: dayOf(j.installedAt) });
|
|
30
|
+
}
|
|
31
|
+
} catch { /* not written yet */ }
|
|
32
|
+
|
|
33
|
+
// First run: anchor at now (see header — no birthtime backfill).
|
|
34
|
+
const at = Date.now();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
38
|
+
fs.writeFileSync(FILE, JSON.stringify({ installedAt: at }, null, 2));
|
|
39
|
+
} catch { /* read-only data dir — keep the in-memory anchor for this process */ }
|
|
40
|
+
|
|
41
|
+
return (cached = { at, day: dayOf(at) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Local-tz YYYY-MM-DD — usage.js keys day buckets with the same en-CA local date,
|
|
45
|
+
// so the leaderboard slice can compare bucket.date >= installedDay lexically.
|
|
46
|
+
function dayOf(ms) {
|
|
47
|
+
return new Date(ms).toLocaleDateString('en-CA');
|
|
48
|
+
}
|
package/lib/live.js
CHANGED
|
@@ -168,30 +168,28 @@ function readSessionMeta(file) {
|
|
|
168
168
|
return meta;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// Resolve a session's transcript file (named <sessionId>.jsonl under the
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const candidates = [];
|
|
171
|
+
// Resolve a session's transcript file (named <sessionId>.jsonl under the projects
|
|
172
|
+
// dir), or null if it has none yet. Prefer the project dir derived from cwd
|
|
173
|
+
// (Claude encodes the path with dashes); else scan the projects dirs.
|
|
174
|
+
function transcriptFile(sessionId, cwd) {
|
|
176
175
|
if (cwd) {
|
|
177
|
-
const
|
|
178
|
-
|
|
176
|
+
const f = path.join(PROJECTS_DIR, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`);
|
|
177
|
+
if (fs.existsSync(f)) return f;
|
|
179
178
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
for (const dir of fs.readdirSync(PROJECTS_DIR)) {
|
|
185
|
-
const f = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
|
|
186
|
-
if (fs.existsSync(f)) {
|
|
187
|
-
file = f;
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
/* ignore */
|
|
179
|
+
try {
|
|
180
|
+
for (const dir of fs.readdirSync(PROJECTS_DIR)) {
|
|
181
|
+
const f = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
|
|
182
|
+
if (fs.existsSync(f)) return f;
|
|
193
183
|
}
|
|
184
|
+
} catch {
|
|
185
|
+
/* ignore */
|
|
194
186
|
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A session's transcript meta, cached by mtime. No transcript yet → empty meta.
|
|
191
|
+
function sessionMetaFor(sessionId, cwd) {
|
|
192
|
+
const file = transcriptFile(sessionId, cwd);
|
|
195
193
|
if (!file) return emptyMeta();
|
|
196
194
|
|
|
197
195
|
let mtime = 0;
|
|
@@ -477,7 +475,15 @@ export function getLive() {
|
|
|
477
475
|
if (!r) return false;
|
|
478
476
|
if (isDoneState(r.state)) return false; // done/failed/stopped → off the floor
|
|
479
477
|
if (r.state === 'blocked') return true; // needs-you bg agent — keep even pid-less
|
|
480
|
-
|
|
478
|
+
if (!(r.pid && isAlive(r.pid))) return false; // dead pid → not live
|
|
479
|
+
// Drop interactive sessions that never wrote a transcript: a `claude` opened
|
|
480
|
+
// at a prompt and abandoned is a live process with zero conversation, so it
|
|
481
|
+
// would otherwise linger as an empty named worker. The first message creates
|
|
482
|
+
// the transcript and it reappears on the next poll. Background agents carry a
|
|
483
|
+
// task and always have a transcript, so this gates interactive only.
|
|
484
|
+
const interactive = r.kind !== 'background' && r.kind !== 'bg';
|
|
485
|
+
if (interactive && r.sessionId && !transcriptFile(r.sessionId, r.cwd)) return false;
|
|
486
|
+
return true;
|
|
481
487
|
})
|
|
482
488
|
.map((r) => buildAgent(r, now));
|
|
483
489
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryz2004/agency",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A live pixel-art office sim of your Claude Code, Codex, and opencode workforce.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "node server.js",
|
|
20
|
-
"check": "node --check server.js && node --check lib/live.js && node --check lib/usage.js && node --check lib/opencode.js && node --check lib/codex.js && node --check lib/roster.js && node --check lib/transcript.js && node --check lib/control.js && node --check lib/paths.js && node --check public/app.js && node --check public/chat-panel.js && node --check public/office.js && node --check public/sprites.js && node --check public/avatar.js && node --check public/ui.js && node --check public/sound.js && node --check public/audio-controls.js && node --check public/mock.js && node --check public/mock-agents.js && node --check public/metric.js && node --check public/leaderboard.js && node --check worker/index.js && node --check scripts/install-hook.mjs && node --check scripts/_pixpng.mjs && node --check scripts/charsheet.mjs && node --check scripts/animsheet.mjs",
|
|
20
|
+
"check": "node --check server.js && node --check lib/live.js && node --check lib/usage.js && node --check lib/opencode.js && node --check lib/codex.js && node --check lib/roster.js && node --check lib/transcript.js && node --check lib/control.js && node --check lib/paths.js && node --check lib/install.js && node --check public/app.js && node --check public/chat-panel.js && node --check public/office.js && node --check public/sprites.js && node --check public/avatar.js && node --check public/ui.js && node --check public/sound.js && node --check public/audio-controls.js && node --check public/mock.js && node --check public/mock-agents.js && node --check public/metric.js && node --check public/leaderboard.js && node --check worker/index.js && node --check scripts/install-hook.mjs && node --check scripts/_pixpng.mjs && node --check scripts/charsheet.mjs && node --check scripts/animsheet.mjs",
|
|
21
21
|
"install-hook": "node scripts/install-hook.mjs",
|
|
22
22
|
"charsheet": "node scripts/charsheet.mjs",
|
|
23
23
|
"animsheet": "node scripts/animsheet.mjs"
|
package/public/chat-panel.css
CHANGED
|
@@ -345,31 +345,49 @@
|
|
|
345
345
|
opacity: 0.6;
|
|
346
346
|
cursor: default;
|
|
347
347
|
}
|
|
348
|
-
/*
|
|
349
|
-
.
|
|
348
|
+
/* rename + hide actions: quiet ghost-text buttons in the collapsed strip. Rename
|
|
349
|
+
expands to the input + Save in place; Hide toggles here. */
|
|
350
|
+
.cp-cust-act {
|
|
350
351
|
flex: 0 0 auto;
|
|
351
|
-
font-
|
|
352
|
-
|
|
352
|
+
font-family: var(--term);
|
|
353
|
+
font-size: var(--t-sub);
|
|
353
354
|
color: var(--muted);
|
|
354
355
|
background: transparent;
|
|
355
356
|
border: 1px solid var(--line2);
|
|
356
357
|
border-radius: 4px;
|
|
357
|
-
padding:
|
|
358
|
+
padding: 4px 9px;
|
|
358
359
|
cursor: pointer;
|
|
359
360
|
}
|
|
360
|
-
.cp-cust-
|
|
361
|
+
.cp-cust-act:hover {
|
|
361
362
|
color: var(--ink);
|
|
362
363
|
border-color: var(--line);
|
|
363
364
|
background: rgba(255, 255, 255, 0.04);
|
|
364
365
|
}
|
|
365
|
-
.cp-cust-
|
|
366
|
+
.cp-cust-act.ok {
|
|
366
367
|
color: var(--green);
|
|
367
368
|
border-color: var(--green);
|
|
368
369
|
}
|
|
369
|
-
.cp-cust-
|
|
370
|
+
.cp-cust-act:disabled {
|
|
370
371
|
opacity: 0.6;
|
|
371
372
|
cursor: default;
|
|
372
373
|
}
|
|
374
|
+
/* cancel (✕) on the expanded rename strip — dismiss back to the collapsed row */
|
|
375
|
+
.cp-cust-cancel {
|
|
376
|
+
flex: 0 0 auto;
|
|
377
|
+
font-size: var(--t-sub);
|
|
378
|
+
line-height: 1;
|
|
379
|
+
color: var(--muted);
|
|
380
|
+
background: transparent;
|
|
381
|
+
border: 1px solid var(--line2);
|
|
382
|
+
border-radius: 4px;
|
|
383
|
+
padding: 0 8px;
|
|
384
|
+
cursor: pointer;
|
|
385
|
+
}
|
|
386
|
+
.cp-cust-cancel:hover {
|
|
387
|
+
color: var(--ink);
|
|
388
|
+
border-color: var(--line);
|
|
389
|
+
background: rgba(255, 255, 255, 0.04);
|
|
390
|
+
}
|
|
373
391
|
|
|
374
392
|
/* "needs you" HUD pill (Control Phase-1): a non-intrusive topbar chip counting
|
|
375
393
|
agents paused on a Stop hook. Lives here (not style.css) to keep the control
|
package/public/chat-panel.js
CHANGED
|
@@ -312,50 +312,70 @@ function customizeControls(a) {
|
|
|
312
312
|
// Gate: only render for a selectable real agent carrying a sessionId.
|
|
313
313
|
if (!a || !a.sessionId) return null;
|
|
314
314
|
|
|
315
|
-
// One compact row: a slim rename field + Save, then a small hide toggle.
|
|
316
315
|
const wrap = document.createElement('div');
|
|
317
316
|
wrap.className = 'cp-customize cp-cust-row';
|
|
318
317
|
|
|
319
|
-
// ---
|
|
320
|
-
//
|
|
321
|
-
const input = document.createElement('input');
|
|
322
|
-
input.type = 'text';
|
|
323
|
-
input.className = 'cp-cust-input';
|
|
324
|
-
input.value = a.name || '';
|
|
325
|
-
input.placeholder = 'Rename…';
|
|
326
|
-
input.title = 'Rename this agent (clear to reset to its minted name)';
|
|
327
|
-
const save = document.createElement('button');
|
|
328
|
-
save.type = 'button';
|
|
329
|
-
save.className = 'cp-cust-save';
|
|
330
|
-
save.textContent = 'Save';
|
|
331
|
-
save.title = 'Save the new name';
|
|
332
|
-
const submitName = () => postOverride(save, { sessionId: a.sessionId, name: input.value.trim() }, 'Save');
|
|
333
|
-
save.addEventListener('click', submitName);
|
|
334
|
-
// Enter in the field saves too (blur is left alone so tabbing away is quiet).
|
|
335
|
-
input.addEventListener('keydown', (e) => {
|
|
336
|
-
if (e.key === 'Enter') { e.preventDefault(); submitName(); }
|
|
337
|
-
});
|
|
338
|
-
wrap.appendChild(input);
|
|
339
|
-
wrap.appendChild(save);
|
|
340
|
-
|
|
341
|
-
// --- hide: a small icon toggle reflecting agent.hidden. Optimistically flips
|
|
342
|
-
// its own glyph/label; the next /api/state poll reflects the real state. ---
|
|
318
|
+
// --- hide: a quiet text toggle reflecting agent.hidden (no uncanny eyeball).
|
|
319
|
+
// Optimistically flips its own label; the next /api/state poll reconciles. ---
|
|
343
320
|
const hide = document.createElement('button');
|
|
344
321
|
hide.type = 'button';
|
|
345
|
-
hide.className = 'cp-cust-
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
hide.textContent =
|
|
349
|
-
hide.title =
|
|
322
|
+
hide.className = 'cp-cust-act';
|
|
323
|
+
const hideLabel = (hidden) => (hidden ? 'Unhide' : 'Hide');
|
|
324
|
+
const hideTitle = (hidden) => (hidden ? 'Show on the floor again' : 'Hide this agent from the office floor');
|
|
325
|
+
hide.textContent = hideLabel(a.hidden);
|
|
326
|
+
hide.title = hideTitle(a.hidden);
|
|
350
327
|
hide.addEventListener('click', () => {
|
|
351
328
|
const next = !a.hidden;
|
|
352
329
|
a.hidden = next; // optimistic; office.js reconciles on the next poll
|
|
353
|
-
|
|
354
|
-
hide.
|
|
355
|
-
postOverride(hide, { sessionId: a.sessionId, hidden: next }, prev);
|
|
330
|
+
hide.title = hideTitle(next);
|
|
331
|
+
postOverride(hide, { sessionId: a.sessionId, hidden: next }, hideLabel(next));
|
|
356
332
|
});
|
|
357
|
-
wrap.appendChild(hide);
|
|
358
333
|
|
|
334
|
+
// --- rename: collapsed to a quiet button so the field isn't always live.
|
|
335
|
+
// Clicking expands the strip in place to an input + Save + cancel. ---
|
|
336
|
+
const rename = document.createElement('button');
|
|
337
|
+
rename.type = 'button';
|
|
338
|
+
rename.className = 'cp-cust-act';
|
|
339
|
+
rename.textContent = '✎ Rename';
|
|
340
|
+
rename.title = 'Rename this agent';
|
|
341
|
+
rename.addEventListener('click', openRename);
|
|
342
|
+
|
|
343
|
+
const collapse = () => { wrap.innerHTML = ''; wrap.append(rename, hide); };
|
|
344
|
+
|
|
345
|
+
function openRename() {
|
|
346
|
+
wrap.innerHTML = '';
|
|
347
|
+
// Input pre-filled with the current name; saving an empty value clears the
|
|
348
|
+
// custom name (server resets to the minted roster name).
|
|
349
|
+
const input = document.createElement('input');
|
|
350
|
+
input.type = 'text';
|
|
351
|
+
input.className = 'cp-cust-input';
|
|
352
|
+
input.value = a.name || '';
|
|
353
|
+
input.placeholder = 'Rename…';
|
|
354
|
+
input.title = 'Rename this agent (clear to reset to its minted name)';
|
|
355
|
+
const save = document.createElement('button');
|
|
356
|
+
save.type = 'button';
|
|
357
|
+
save.className = 'cp-cust-save';
|
|
358
|
+
save.textContent = 'Save';
|
|
359
|
+
save.title = 'Save the new name';
|
|
360
|
+
const cancel = document.createElement('button');
|
|
361
|
+
cancel.type = 'button';
|
|
362
|
+
cancel.className = 'cp-cust-cancel';
|
|
363
|
+
cancel.textContent = '✕';
|
|
364
|
+
cancel.title = 'Cancel rename';
|
|
365
|
+
const submitName = () => postOverride(save, { sessionId: a.sessionId, name: input.value.trim() }, 'Save');
|
|
366
|
+
save.addEventListener('click', submitName);
|
|
367
|
+
cancel.addEventListener('click', collapse);
|
|
368
|
+
input.addEventListener('keydown', (e) => {
|
|
369
|
+
if (e.key === 'Enter') { e.preventDefault(); submitName(); }
|
|
370
|
+
// Esc collapses the field; stopPropagation so it doesn't also close the panel.
|
|
371
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); collapse(); }
|
|
372
|
+
});
|
|
373
|
+
wrap.append(input, save, cancel);
|
|
374
|
+
input.focus();
|
|
375
|
+
input.select();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
collapse();
|
|
359
379
|
return wrap;
|
|
360
380
|
}
|
|
361
381
|
|
package/public/index.html
CHANGED
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
<div class="topctrls">
|
|
44
44
|
<div class="live-pill"><span class="rec"></span> LIVE</div>
|
|
45
45
|
<button id="leaderboardBtn" class="panel-toggle" type="button" title="Leaderboard" hidden>🏆</button>
|
|
46
|
+
<a id="feedbackBtn" class="panel-toggle" title="Send feedback (opens a GitHub issue)" target="_blank" rel="noopener"
|
|
47
|
+
href="https://github.com/henryz2004/agency/issues/new?title=Feedback%3A%20&body=**What%20were%20you%20doing%20in%20Agency%3F**%0A%0A%0A**What%20happened%2C%20or%20what%20would%20you%20change%3F**%0A%0A%0A---%0A_Agency%20version%20%2F%20OS%20%2F%20browser%20(helps%20a%20lot)%3A_%0A">💬</a>
|
|
46
48
|
<button id="panelToggle" class="panel-toggle" type="button" title="Toggle stats panel">📊</button>
|
|
47
49
|
</div>
|
|
48
50
|
</div>
|
|
@@ -184,6 +186,7 @@
|
|
|
184
186
|
</div>
|
|
185
187
|
</div>
|
|
186
188
|
|
|
189
|
+
<script src="/env.js"></script><!-- runtime config (LEADERBOARD_API) before the deferred modules -->
|
|
187
190
|
<script type="module" src="/app.js"></script>
|
|
188
191
|
<script type="module" src="/leaderboard.js"></script>
|
|
189
192
|
</body>
|
package/public/leaderboard.js
CHANGED
|
@@ -8,13 +8,22 @@
|
|
|
8
8
|
// Set LEADERBOARD_API to your deployed Worker URL (see worker/README.md). While
|
|
9
9
|
// it's empty, the whole feature stays hidden and the dashboard is unchanged.
|
|
10
10
|
|
|
11
|
-
import { standardEngYears } from './metric.js';
|
|
11
|
+
import { standardEngYears, fmtEngTime } from './metric.js';
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Backend URL is injected by the server via /env.js (window.AGENCY_LEADERBOARD_API),
|
|
14
|
+
// driven by the LEADERBOARD_API env var — `LEADERBOARD_API=http://localhost:8787 npm
|
|
15
|
+
// start` for local/staging, else the production board. The hardcoded fallback only
|
|
16
|
+
// applies if /env.js didn't load. (We no longer read a sticky localStorage override:
|
|
17
|
+
// a stale value silently broke the prod board with a confusing "Failed to fetch".)
|
|
18
|
+
const LEADERBOARD_API =
|
|
19
|
+
(typeof window !== 'undefined' && window.AGENCY_LEADERBOARD_API) ||
|
|
20
|
+
'https://agency-leaderboard.henryz2004.workers.dev';
|
|
14
21
|
|
|
15
22
|
const KNOWN_SOURCES = ['claude', 'codex', 'opencode'];
|
|
16
23
|
const LS = { id: 'agency.lb.installId', handle: 'agency.lb.handle', opted: 'agency.lb.optedIn' };
|
|
17
24
|
const $ = (id) => document.getElementById(id);
|
|
25
|
+
const RESYNC_MS = 5 * 60 * 1000; // leaderboard auto-resync cadence (declared up here:
|
|
26
|
+
// init() runs at load and reaches startAutoSync synchronously).
|
|
18
27
|
|
|
19
28
|
if (LEADERBOARD_API) init();
|
|
20
29
|
|
|
@@ -30,6 +39,27 @@ function init() {
|
|
|
30
39
|
document.addEventListener('keydown', (e) => {
|
|
31
40
|
if (e.key === 'Escape' && $('lbOverlay') && !$('lbOverlay').classList.contains('hidden')) close();
|
|
32
41
|
});
|
|
42
|
+
startAutoSync();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Keep your score current without a manual "update" — re-submit on load and on a
|
|
46
|
+
// slow interval while Agency is open. Silent: a failed sync (offline, or a legacy
|
|
47
|
+
// duplicate name) just leaves the last score standing. ponytail: fixed 5-min
|
|
48
|
+
// cadence; tie it to token deltas only if D1 writes ever become a concern.
|
|
49
|
+
function startAutoSync() {
|
|
50
|
+
const tick = async () => {
|
|
51
|
+
if (localStorage.getItem(LS.opted) !== '1') return;
|
|
52
|
+
const handle = localStorage.getItem(LS.handle);
|
|
53
|
+
if (!handle) return;
|
|
54
|
+
const res = await submitScore(handle, await localStats());
|
|
55
|
+
if (res && res.ok && $('lbYouRank')) { // modal open → refresh what's visible
|
|
56
|
+
refreshRank();
|
|
57
|
+
const list = document.querySelector('.lb-list');
|
|
58
|
+
if (list) loadList(list);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
tick();
|
|
62
|
+
setInterval(tick, RESYNC_MS);
|
|
33
63
|
}
|
|
34
64
|
|
|
35
65
|
function open() { $('lbOverlay').classList.remove('hidden'); render(); }
|
|
@@ -50,8 +80,11 @@ function installId() {
|
|
|
50
80
|
async function fetchJSON(url, opts) {
|
|
51
81
|
try {
|
|
52
82
|
const res = await fetch(url, opts);
|
|
53
|
-
|
|
54
|
-
|
|
83
|
+
const data = await res.json().catch(() => ({}));
|
|
84
|
+
// On non-2xx, surface the server's { error } (e.g. "handle taken") so callers
|
|
85
|
+
// can react to it, not just a bare status code.
|
|
86
|
+
if (!res.ok) return { _error: data.error || `HTTP ${res.status}`, ...data };
|
|
87
|
+
return data;
|
|
55
88
|
} catch (e) {
|
|
56
89
|
return { _error: String((e && e.message) || e) };
|
|
57
90
|
}
|
|
@@ -61,13 +94,18 @@ async function fetchJSON(url, opts) {
|
|
|
61
94
|
// app's own read-only endpoint. Used only at submit time.
|
|
62
95
|
async function localStats() {
|
|
63
96
|
const state = await fetchJSON('/api/state');
|
|
64
|
-
const
|
|
97
|
+
const u = (state && state.usage) || {};
|
|
98
|
+
// Rank by output SINCE this machine installed Agency (a level field for all),
|
|
99
|
+
// not lifetime Claude history. Presence-check sinceInstall so a legit 0 (brand
|
|
100
|
+
// new install) submits as 0; only fall back to lifetime on an older server.
|
|
101
|
+
const out = (u.sinceInstall && typeof u.sinceInstall.out === 'number')
|
|
102
|
+
? u.sinceInstall.out
|
|
103
|
+
: ((u.lifetime && u.lifetime.out) || 0);
|
|
65
104
|
const agents = (state && state.live && state.live.agents) || [];
|
|
66
105
|
const sources = [...new Set(agents.map((a) => a && a.source).filter((s) => KNOWN_SOURCES.includes(s)))];
|
|
67
106
|
return { out, sources };
|
|
68
107
|
}
|
|
69
108
|
|
|
70
|
-
const fmtEY = (n) => (n >= 10 ? Math.round(n).toString() : n.toFixed(n >= 1 ? 1 : 2));
|
|
71
109
|
const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n));
|
|
72
110
|
|
|
73
111
|
async function render() {
|
|
@@ -97,7 +135,7 @@ function refreshRank() {
|
|
|
97
135
|
fetchJSON(LEADERBOARD_API + '/api/rank?installId=' + encodeURIComponent(installId())).then((r) => {
|
|
98
136
|
const el = $('lbYouRank');
|
|
99
137
|
if (!el) return;
|
|
100
|
-
if (r && typeof r.rank === 'number') el.textContent = `#${r.rank} of ${r.total} · ${
|
|
138
|
+
if (r && typeof r.rank === 'number') el.textContent = `#${r.rank} of ${r.total} · ${fmtEngTime(r.engYears)}`;
|
|
101
139
|
else el.textContent = 'not ranked yet';
|
|
102
140
|
});
|
|
103
141
|
}
|
|
@@ -106,15 +144,15 @@ function optInBlock(stats) {
|
|
|
106
144
|
const wrap = document.createElement('div');
|
|
107
145
|
wrap.className = 'lb-optin';
|
|
108
146
|
wrap.innerHTML = `
|
|
109
|
-
<p class="lb-note">Share your standardized <b>engineer-years</b>
|
|
110
|
-
Only a display name + that one number are sent
|
|
111
|
-
<p class="lb-preview">Your standardized score
|
|
147
|
+
<p class="lb-note">Share your standardized <b>engineer-years</b> — counted <b>since you installed Agency</b> —
|
|
148
|
+
on a public leaderboard. Only a display name + that one number are sent; <b>never</b> your code, transcripts, or repo names.</p>
|
|
149
|
+
<p class="lb-preview">Your standardized score since install: <b></b></p>
|
|
112
150
|
<div class="lb-form">
|
|
113
151
|
<input id="lbHandle" type="text" maxlength="32" placeholder="display name" autocomplete="off" />
|
|
114
152
|
<button id="lbJoin" type="button">Join</button>
|
|
115
153
|
</div>
|
|
116
154
|
<div id="lbMsg" class="lb-msg"></div>`;
|
|
117
|
-
wrap.querySelector('.lb-preview b').textContent =
|
|
155
|
+
wrap.querySelector('.lb-preview b').textContent = fmtEngTime(standardEngYears(stats.out));
|
|
118
156
|
const input = wrap.querySelector('#lbHandle');
|
|
119
157
|
input.value = localStorage.getItem(LS.handle) || '';
|
|
120
158
|
const join = wrap.querySelector('#lbJoin');
|
|
@@ -129,6 +167,8 @@ function optInBlock(stats) {
|
|
|
129
167
|
localStorage.setItem(LS.handle, handle);
|
|
130
168
|
localStorage.setItem(LS.opted, '1');
|
|
131
169
|
render();
|
|
170
|
+
} else if (res && res.error === 'handle taken') {
|
|
171
|
+
msg.textContent = 'That name is taken — try another.';
|
|
132
172
|
} else {
|
|
133
173
|
msg.textContent = 'Could not submit' + (res && res._error ? ` (${res._error})` : '') + '.';
|
|
134
174
|
}
|
|
@@ -150,30 +190,14 @@ function statusBlock(stats) {
|
|
|
150
190
|
<div class="lb-you-rank" id="lbYouRank">—</div>
|
|
151
191
|
</div>
|
|
152
192
|
<div class="lb-actions">
|
|
153
|
-
<button id="lbUpdate" type="button">Update my score</button>
|
|
154
193
|
<button id="lbForget" type="button" class="lb-danger">Stop sharing</button>
|
|
155
194
|
</div>
|
|
156
|
-
<div id="lbMsg" class="lb-msg"
|
|
195
|
+
<div id="lbMsg" class="lb-msg">Your score syncs automatically while Agency is open.</div>`;
|
|
157
196
|
wrap.querySelector('.lb-you-handle').textContent = handle || 'you'; // display only; textContent = no injection
|
|
158
197
|
const msg = wrap.querySelector('#lbMsg');
|
|
159
198
|
|
|
160
199
|
refreshRank();
|
|
161
200
|
|
|
162
|
-
wrap.querySelector('#lbUpdate').addEventListener('click', async (e) => {
|
|
163
|
-
if (!handle) { msg.textContent = 'Re-join to set a display name first.'; return; }
|
|
164
|
-
e.target.disabled = true; msg.textContent = 'Updating…';
|
|
165
|
-
const res = await submitScore(handle, await localStats()); // re-fetch for freshness
|
|
166
|
-
e.target.disabled = false;
|
|
167
|
-
if (res && res.ok) {
|
|
168
|
-
msg.textContent = 'Updated.';
|
|
169
|
-
refreshRank();
|
|
170
|
-
const list = document.querySelector('.lb-list');
|
|
171
|
-
if (list) loadList(list);
|
|
172
|
-
} else {
|
|
173
|
-
msg.textContent = 'Update failed' + (res && res._error ? ` (${res._error})` : '') + '.';
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
|
|
177
201
|
wrap.querySelector('#lbForget').addEventListener('click', async (e) => {
|
|
178
202
|
e.target.disabled = true; msg.textContent = 'Removing…';
|
|
179
203
|
const res = await fetchJSON(LEADERBOARD_API + '/api/forget', {
|
|
@@ -214,7 +238,7 @@ async function loadList(list) {
|
|
|
214
238
|
const rank = document.createElement('span'); rank.className = 'lb-rank'; rank.textContent = `#${row.rank}`;
|
|
215
239
|
const name = document.createElement('span'); name.className = 'lb-name'; name.textContent = row.handle;
|
|
216
240
|
const val = document.createElement('span'); val.className = 'lb-val';
|
|
217
|
-
val.textContent =
|
|
241
|
+
val.textContent = fmtEngTime(row.engYears);
|
|
218
242
|
val.title = `${fmtTok(row.outputTokens)} output tokens` + (row.sources && row.sources.length ? ` · ${row.sources.join(', ')}` : '');
|
|
219
243
|
r.append(rank, name, val);
|
|
220
244
|
list.appendChild(r);
|
package/public/metric.js
CHANGED
|
@@ -22,6 +22,30 @@ export function standardEngYears(lifetimeOutputTokens) {
|
|
|
22
22
|
return (Number(lifetimeOutputTokens) || 0) / TOKENS_PER_ENG_YEAR;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Engineer-years expressed in eng-years per unit, from the SAME locked constants:
|
|
26
|
+
// 1 eng-yr = 230 eng-days, 1 eng-day = 8 eng-hrs; an eng-month is 1/12 eng-yr.
|
|
27
|
+
const EY_PER = {
|
|
28
|
+
yr: 1,
|
|
29
|
+
mo: 1 / 12,
|
|
30
|
+
day: 1 / STANDARD.daysPerYear,
|
|
31
|
+
hr: 1 / (STANDARD.daysPerYear * STANDARD.hrsPerDay),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Format an engineer-years value in the largest unit where it reads naturally,
|
|
35
|
+
// so a tiny score shows as "6.3 eng-hrs" not "0.003 eng-yrs". Returns a display
|
|
36
|
+
// string: "8.7 eng-yrs" · "4.8 eng-mos" · "12 eng-days" · "6.3 eng-hrs" (singular
|
|
37
|
+
// at exactly 1: "1 eng-yr"). Ranking stays in eng-years; only display adapts.
|
|
38
|
+
export function fmtEngTime(engYears) {
|
|
39
|
+
const ey = Number(engYears) || 0;
|
|
40
|
+
let value, unit;
|
|
41
|
+
if (ey >= EY_PER.yr) { value = ey; unit = 'yr'; }
|
|
42
|
+
else if (ey >= EY_PER.mo) { value = ey / EY_PER.mo; unit = 'mo'; }
|
|
43
|
+
else if (ey >= EY_PER.day) { value = ey / EY_PER.day; unit = 'day'; }
|
|
44
|
+
else { value = ey / EY_PER.hr; unit = 'hr'; }
|
|
45
|
+
const v = value >= 10 ? Math.round(value).toString() : (Math.round(value * 10) / 10).toString();
|
|
46
|
+
return `${v} eng-${unit}${v === '1' ? '' : 's'}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
// --- self-check: `node public/metric.js` -----------------------------------
|
|
26
50
|
if (typeof process !== 'undefined' && process.argv[1] && process.argv[1].endsWith('metric.js')) {
|
|
27
51
|
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1); } };
|
|
@@ -30,5 +54,14 @@ if (typeof process !== 'undefined' && process.argv[1] && process.argv[1].endsWit
|
|
|
30
54
|
assert(standardEngYears(0) === 0, 'zero tokens != 0');
|
|
31
55
|
assert(standardEngYears(null) === 0 && standardEngYears(undefined) === 0, 'nullish != 0');
|
|
32
56
|
assert(standardEngYears('11040000') === 2, 'string coercion / 2 eng-years failed');
|
|
57
|
+
// dynamic unit picker
|
|
58
|
+
assert(fmtEngTime(8.7) === '8.7 eng-yrs', `years fmt: ${fmtEngTime(8.7)}`);
|
|
59
|
+
assert(fmtEngTime(1) === '1 eng-yr', `singular year: ${fmtEngTime(1)}`);
|
|
60
|
+
assert(fmtEngTime(12) === '12 eng-yrs', `int years: ${fmtEngTime(12)}`);
|
|
61
|
+
assert(fmtEngTime(0.4).endsWith(' eng-mos'), `months unit: ${fmtEngTime(0.4)}`);
|
|
62
|
+
assert(fmtEngTime(0.01).endsWith(' eng-days'), `days unit: ${fmtEngTime(0.01)}`);
|
|
63
|
+
assert(fmtEngTime(1 / 230) === '1 eng-day', `singular day: ${fmtEngTime(1 / 230)}`);
|
|
64
|
+
assert(fmtEngTime(0.001).endsWith(' eng-hrs'), `hours unit: ${fmtEngTime(0.001)}`);
|
|
65
|
+
assert(fmtEngTime(0) === '0 eng-hrs', `zero: ${fmtEngTime(0)}`);
|
|
33
66
|
console.log('metric.js self-check OK');
|
|
34
67
|
}
|
package/public/office.js
CHANGED
|
@@ -31,6 +31,7 @@ let avatar = null; // the player (created in initOffice, off by
|
|
|
31
31
|
let lastT = 0; // last loop timestamp → real dt for motion
|
|
32
32
|
let walkBtn = null, walkHint = null; // on-screen walk-mode affordances (built in JS)
|
|
33
33
|
let nameBtn = null; // on-screen nametag-visibility toggle
|
|
34
|
+
let menuBtn = null, menuPanel = null; // ☰ floor-controls menu (holds the toggles)
|
|
34
35
|
let labelsHidden = false; // hide all agent name chips when true ('n' / button)
|
|
35
36
|
// roaming pet: dir +1 faces right; sit→sleep when it rests a while; `petted` is
|
|
36
37
|
// set while the walking avatar is right next to it (→ wakes + hearts in drawCat).
|
|
@@ -572,34 +573,24 @@ function syncLabels() {
|
|
|
572
573
|
syncHiddenChip();
|
|
573
574
|
}
|
|
574
575
|
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
// rebuild() re-filters lastAll).
|
|
579
|
-
// label layer, so it stays a crisp, fixed-size control.
|
|
576
|
+
// The "show hidden desks" control is a row INSIDE the ☰ floor menu (built in
|
|
577
|
+
// ensureWalkUI). The hidden STATE is owned by app.js (agent.hidden); proc just
|
|
578
|
+
// collapses them by default and offers a local reveal (no poll needed —
|
|
579
|
+
// rebuild() re-filters lastAll). This just shows/labels the row by hiddenCount.
|
|
580
580
|
let hiddenChip = null;
|
|
581
581
|
function syncHiddenChip() {
|
|
582
|
-
|
|
583
|
-
if (!host) return;
|
|
584
|
-
if (!hiddenChip) {
|
|
585
|
-
hiddenChip = document.createElement('button');
|
|
586
|
-
hiddenChip.type = 'button';
|
|
587
|
-
hiddenChip.style.cssText =
|
|
588
|
-
'position:absolute;left:12px;top:12px;z-index:8;cursor:pointer;' +
|
|
589
|
-
'appearance:none;-webkit-appearance:none;outline:none;' +
|
|
590
|
-
'padding:3px 9px;border-radius:11px;white-space:nowrap;' +
|
|
591
|
-
'background:rgba(14,20,32,0.9);border:1px solid rgba(255,255,255,0.22);' +
|
|
592
|
-
"font:11px 'IBM Plex Mono', ui-monospace, monospace;color:#cdd8ea;letter-spacing:.3px;";
|
|
593
|
-
hiddenChip.addEventListener('click', () => { showHidden = !showHidden; rebuild(); });
|
|
594
|
-
host.appendChild(hiddenChip);
|
|
595
|
-
}
|
|
582
|
+
if (!hiddenChip) return; // built lazily in ensureWalkUI
|
|
596
583
|
// count emptied → also drop the reveal flag, else a future hide would stay
|
|
597
|
-
//
|
|
598
|
-
if (!hiddenCount) {
|
|
599
|
-
|
|
584
|
+
// revealed with no way to re-collapse it; hide the row when nothing's hidden.
|
|
585
|
+
if (!hiddenCount) {
|
|
586
|
+
showHidden = false;
|
|
587
|
+
hiddenChip.style.display = 'none';
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
hiddenChip.style.display = 'block';
|
|
600
591
|
hiddenChip.textContent = showHidden
|
|
601
|
-
? `▾
|
|
602
|
-
: `▸ ${hiddenCount}
|
|
592
|
+
? `▾ Hide ${hiddenCount} hidden`
|
|
593
|
+
: `▸ Show ${hiddenCount} hidden`;
|
|
603
594
|
}
|
|
604
595
|
|
|
605
596
|
// ---- camera: pan / zoom / click-to-select -----------------------------------
|
|
@@ -1057,7 +1048,7 @@ function updateWalkUI() {
|
|
|
1057
1048
|
const on = !!(avatar && avatar.enabled);
|
|
1058
1049
|
if (walkBtn) {
|
|
1059
1050
|
walkBtn.textContent = on ? '🚶 walking · G to exit' : '🚶 walk (G)';
|
|
1060
|
-
walkBtn.style.borderColor = on ? '#ff2e88' : '
|
|
1051
|
+
walkBtn.style.borderColor = on ? '#ff2e88' : 'transparent'; // a menu row: framed only when active
|
|
1061
1052
|
walkBtn.style.color = on ? '#ff8fc4' : '#cdd6e6';
|
|
1062
1053
|
}
|
|
1063
1054
|
if (walkHint) walkHint.style.display = on ? 'block' : 'none';
|
|
@@ -1076,41 +1067,89 @@ function updateLabelBtn() {
|
|
|
1076
1067
|
nameBtn.style.opacity = labelsHidden ? '0.6' : '1';
|
|
1077
1068
|
}
|
|
1078
1069
|
|
|
1079
|
-
// Build the
|
|
1080
|
-
//
|
|
1070
|
+
// Build the floor controls over the floor frame, styled inline (index.html /
|
|
1071
|
+
// style.css aren't in this lane). A ☰ button top-left opens a small dropdown
|
|
1072
|
+
// holding the toggles (name tags, walk mode, show-hidden) so they don't clutter
|
|
1073
|
+
// the floor corners. The walk HINT stays a free-floating strip while walking.
|
|
1081
1074
|
function ensureWalkUI() {
|
|
1082
1075
|
const host = world && world.parentElement; // .floor-frame
|
|
1083
|
-
if (!host ||
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1076
|
+
if (!host || menuBtn) return;
|
|
1077
|
+
|
|
1078
|
+
// ☰ hamburger — toggles the dropdown.
|
|
1079
|
+
menuBtn = document.createElement('button');
|
|
1080
|
+
menuBtn.type = 'button';
|
|
1081
|
+
menuBtn.id = 'floorMenu';
|
|
1082
|
+
menuBtn.textContent = '☰';
|
|
1083
|
+
menuBtn.title = 'Floor controls';
|
|
1084
|
+
menuBtn.setAttribute('aria-label', 'Floor controls');
|
|
1085
|
+
Object.assign(menuBtn.style, {
|
|
1086
|
+
position: 'absolute', left: '12px', top: '12px', zIndex: '8',
|
|
1087
|
+
font: '13px ui-monospace, monospace', lineHeight: '1', color: '#cdd6e6',
|
|
1088
|
+
background: 'rgba(16,20,28,0.82)', border: '1px solid rgba(255,255,255,0.14)',
|
|
1089
|
+
borderRadius: '6px', padding: '6px 9px', cursor: 'pointer',
|
|
1092
1090
|
});
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
// anchors top-left), so the two stack instead of overlapping.
|
|
1103
|
-
position: 'absolute', left: '12px', top: '40px', zIndex: '6',
|
|
1104
|
-
font: '11px ui-monospace, monospace', background: 'rgba(16,20,28,0.82)',
|
|
1105
|
-
border: '1px solid rgba(255,255,255,0.14)', borderRadius: '6px',
|
|
1106
|
-
padding: '5px 9px', cursor: 'pointer', color: '#cdd6e6',
|
|
1091
|
+
host.appendChild(menuBtn);
|
|
1092
|
+
|
|
1093
|
+
// Dropdown panel (hidden until ☰ clicked).
|
|
1094
|
+
menuPanel = document.createElement('div');
|
|
1095
|
+
Object.assign(menuPanel.style, {
|
|
1096
|
+
position: 'absolute', left: '12px', top: '44px', zIndex: '8',
|
|
1097
|
+
display: 'none', flexDirection: 'column', gap: '2px', minWidth: '150px',
|
|
1098
|
+
background: 'rgba(14,20,32,0.96)', border: '1px solid rgba(255,255,255,0.16)',
|
|
1099
|
+
borderRadius: '8px', padding: '5px', boxShadow: '0 8px 24px rgba(0,0,0,0.45)',
|
|
1107
1100
|
});
|
|
1101
|
+
host.appendChild(menuPanel);
|
|
1102
|
+
|
|
1103
|
+
const isOpen = () => menuPanel.style.display !== 'none';
|
|
1104
|
+
const setOpen = (on) => {
|
|
1105
|
+
menuPanel.style.display = on ? 'flex' : 'none';
|
|
1106
|
+
menuBtn.style.borderColor = on ? 'rgba(255,255,255,0.34)' : 'rgba(255,255,255,0.14)';
|
|
1107
|
+
};
|
|
1108
|
+
menuBtn.addEventListener('click', (e) => { e.stopPropagation(); setOpen(!isOpen()); });
|
|
1109
|
+
// Click-outside / Esc dismisses the menu (added once; ensureWalkUI is idempotent).
|
|
1110
|
+
document.addEventListener('click', (e) => {
|
|
1111
|
+
if (isOpen() && e.target !== menuBtn && !menuPanel.contains(e.target)) setOpen(false);
|
|
1112
|
+
});
|
|
1113
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) setOpen(false); });
|
|
1114
|
+
|
|
1115
|
+
// A menu row button: full-width, left-aligned, subtle hover. The per-control
|
|
1116
|
+
// update fns (updateLabelBtn / updateWalkUI / syncHiddenChip) own text + active
|
|
1117
|
+
// color; hover only touches background, so they don't fight.
|
|
1118
|
+
const makeRow = (id) => {
|
|
1119
|
+
const b = document.createElement('button');
|
|
1120
|
+
b.type = 'button';
|
|
1121
|
+
if (id) b.id = id;
|
|
1122
|
+
Object.assign(b.style, {
|
|
1123
|
+
font: '11px ui-monospace, monospace', textAlign: 'left', width: '100%',
|
|
1124
|
+
color: '#cdd6e6', background: 'transparent', border: '1px solid transparent',
|
|
1125
|
+
borderRadius: '5px', padding: '6px 8px', cursor: 'pointer', whiteSpace: 'nowrap',
|
|
1126
|
+
});
|
|
1127
|
+
b.addEventListener('mouseenter', () => { b.style.background = 'rgba(255,255,255,0.06)'; });
|
|
1128
|
+
b.addEventListener('mouseleave', () => { b.style.background = 'transparent'; });
|
|
1129
|
+
menuPanel.appendChild(b);
|
|
1130
|
+
return b;
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
// Name-tags toggle.
|
|
1134
|
+
nameBtn = makeRow('nameToggle');
|
|
1135
|
+
nameBtn.title = 'Show / hide agent name tags (N)';
|
|
1108
1136
|
nameBtn.addEventListener('click', () => toggleLabels());
|
|
1109
|
-
host.appendChild(nameBtn);
|
|
1110
1137
|
updateLabelBtn();
|
|
1138
|
+
|
|
1139
|
+
// Walk-mode toggle.
|
|
1140
|
+
walkBtn = makeRow('walkToggle');
|
|
1141
|
+
walkBtn.addEventListener('click', () => toggleWalk());
|
|
1142
|
+
|
|
1143
|
+
// Show-hidden toggle — syncHiddenChip shows/labels it only when desks are hidden.
|
|
1144
|
+
hiddenChip = makeRow();
|
|
1145
|
+
hiddenChip.addEventListener('click', () => { showHidden = !showHidden; rebuild(); });
|
|
1146
|
+
syncHiddenChip();
|
|
1147
|
+
|
|
1148
|
+
// Walk hint — a free-floating strip bottom-left, shown only while walking.
|
|
1111
1149
|
walkHint = document.createElement('div');
|
|
1112
1150
|
Object.assign(walkHint.style, {
|
|
1113
|
-
|
|
1151
|
+
// bottom:48 clears the #recenter button (bottom:12) when both show while walking.
|
|
1152
|
+
position: 'absolute', left: '12px', bottom: '48px', zIndex: '6',
|
|
1114
1153
|
font: '11px ui-monospace, monospace', color: '#9aa6ba',
|
|
1115
1154
|
background: 'rgba(16,20,28,0.82)', border: '1px solid rgba(255,255,255,0.10)',
|
|
1116
1155
|
borderRadius: '6px', padding: '4px 8px', display: 'none', maxWidth: '250px',
|
package/public/style.css
CHANGED
|
@@ -260,11 +260,13 @@ html, body {
|
|
|
260
260
|
/* top-bar panel toggle + edge reopen handle */
|
|
261
261
|
.panel-toggle {
|
|
262
262
|
font-size: 16px; line-height: 1; cursor: pointer;
|
|
263
|
+
display: inline-flex; align-items: center; justify-content: center; text-decoration: none;
|
|
263
264
|
width: 38px; height: 34px; padding: 0; margin-left: 4px;
|
|
264
265
|
border: 1px solid var(--line); border-radius: 4px;
|
|
265
266
|
background: #0c111b; color: var(--ink);
|
|
266
267
|
transition: border-color .15s, box-shadow .15s, transform .05s;
|
|
267
268
|
}
|
|
269
|
+
.panel-toggle[hidden] { display: none; }
|
|
268
270
|
.panel-toggle:hover { border-color: var(--cyan); }
|
|
269
271
|
.panel-toggle:active { transform: translateY(1px); }
|
|
270
272
|
.panel-toggle.active { border-color: var(--cyan); box-shadow: 0 0 10px rgba(92,208,255,.3); }
|
package/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { execFile } from 'node:child_process';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { getUsage } from './lib/usage.js';
|
|
12
12
|
import { getLive } from './lib/live.js';
|
|
13
|
+
import { installInfo } from './lib/install.js';
|
|
13
14
|
import { identityFor, overrideFor, setOverride } from './lib/roster.js';
|
|
14
15
|
import { getTranscript } from './lib/transcript.js';
|
|
15
16
|
import * as control from './lib/control.js';
|
|
@@ -18,6 +19,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
18
19
|
const PUBLIC = path.join(__dirname, 'public');
|
|
19
20
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 4313;
|
|
20
21
|
const HOST = process.env.HOST || '127.0.0.1';
|
|
22
|
+
// Which leaderboard backend the dashboard talks to. Override per-run for
|
|
23
|
+
// local/staging (LEADERBOARD_API=http://localhost:8787 npm start); defaults to
|
|
24
|
+
// the production Worker. Injected into the page via /env.js — replaces the old
|
|
25
|
+
// sticky localStorage override (a stale value silently broke the prod board).
|
|
26
|
+
const LEADERBOARD_API = process.env.LEADERBOARD_API || 'https://agency-leaderboard.henryz2004.workers.dev';
|
|
21
27
|
|
|
22
28
|
const MIME = {
|
|
23
29
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -65,13 +71,29 @@ function buildState() {
|
|
|
65
71
|
return { ...a, ...ident, ...ctrl, name: ov.name || ident.name, hidden: ov.hidden };
|
|
66
72
|
});
|
|
67
73
|
|
|
74
|
+
// Leaderboard ranks output SINCE you joined Agency, not your whole Claude
|
|
75
|
+
// history — so surface a per-install slice alongside the lifetime totals.
|
|
76
|
+
const { day: installedDay } = installInfo();
|
|
77
|
+
const sinceInstall = sumSince(usage.daily, installedDay);
|
|
78
|
+
|
|
68
79
|
return {
|
|
69
80
|
generatedAt: Date.now(),
|
|
70
81
|
live: { agents, teams: live.teams, now: live.now },
|
|
71
|
-
usage,
|
|
82
|
+
usage: { ...usage, installedDay, sinceInstall },
|
|
72
83
|
};
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
// Sum output tokens (+ active days) on or after the install day. Daily bucket
|
|
87
|
+
// dates are local YYYY-MM-DD (en-CA), so a lexical >= compares correctly.
|
|
88
|
+
function sumSince(daily, day) {
|
|
89
|
+
let out = 0;
|
|
90
|
+
let days = 0;
|
|
91
|
+
for (const d of daily || []) {
|
|
92
|
+
if (d.date >= day) { out += d.out || 0; days += 1; }
|
|
93
|
+
}
|
|
94
|
+
return { out, days };
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
function sendJSON(res, code, obj) {
|
|
76
98
|
const body = JSON.stringify(obj);
|
|
77
99
|
res.writeHead(code, {
|
|
@@ -336,6 +358,13 @@ const server = http.createServer((req, res) => {
|
|
|
336
358
|
});
|
|
337
359
|
return;
|
|
338
360
|
}
|
|
361
|
+
// Runtime config for the frontend (classic script → runs before the deferred
|
|
362
|
+
// leaderboard module). Lets server-side env reach the browser with no build step.
|
|
363
|
+
if (pathname === '/env.js') {
|
|
364
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
365
|
+
res.end(`window.AGENCY_LEADERBOARD_API=${JSON.stringify(LEADERBOARD_API)};`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
339
368
|
serveStatic(req, res);
|
|
340
369
|
} catch (err) {
|
|
341
370
|
sendJSON(res, 500, { error: String(err && err.message ? err.message : err) });
|