@henryz2004/agency 1.0.0 → 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/README.md CHANGED
@@ -12,7 +12,7 @@ the payroll you'd be paying humans to match it.
12
12
 
13
13
  Nothing leaves your machine — unless you explicitly opt into the [leaderboard](#leaderboard-opt-in). No dependencies.
14
14
 
15
- ![Agency](docs/screenshot.png)
15
+ ![Agency — a living pixel-art office of your AI agents](https://cdn.jsdelivr.net/npm/@henryz2004/agency/docs/agency.gif)
16
16
 
17
17
  ## Run it
18
18
 
Binary file
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
- // projects dir) and return its meta, cached by mtime.
173
- function sessionMetaFor(sessionId, cwd) {
174
- // Prefer the project dir derived from cwd (Claude encodes path with dashes).
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 encoded = cwd.replace(/[/.]/g, '-');
178
- candidates.push(path.join(PROJECTS_DIR, encoded, `${sessionId}.jsonl`));
176
+ const f = path.join(PROJECTS_DIR, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`);
177
+ if (fs.existsSync(f)) return f;
179
178
  }
180
-
181
- let file = candidates.find((f) => fs.existsSync(f));
182
- if (!file) {
183
- try {
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
- return r.pid && isAlive(r.pid);
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.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",
@@ -12,11 +12,12 @@
12
12
  "lib/",
13
13
  "public/",
14
14
  "scripts/",
15
- "README.md"
15
+ "README.md",
16
+ "docs/agency.gif"
16
17
  ],
17
18
  "scripts": {
18
19
  "start": "node server.js",
19
- "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",
20
21
  "install-hook": "node scripts/install-hook.mjs",
21
22
  "charsheet": "node scripts/charsheet.mjs",
22
23
  "animsheet": "node scripts/animsheet.mjs"
@@ -345,31 +345,49 @@
345
345
  opacity: 0.6;
346
346
  cursor: default;
347
347
  }
348
- /* hide: a small icon toggle (👁 / 🙈) quiet, square-ish, sits beside Save */
349
- .cp-cust-hide {
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-size: 12px;
352
- line-height: 1;
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: 0 8px;
358
+ padding: 4px 9px;
358
359
  cursor: pointer;
359
360
  }
360
- .cp-cust-hide:hover {
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-hide.ok {
366
+ .cp-cust-act.ok {
366
367
  color: var(--green);
367
368
  border-color: var(--green);
368
369
  }
369
- .cp-cust-hide:disabled {
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
@@ -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
- // --- rename: input pre-filled with the current name + a Save button. Saving
320
- // an empty input clears the custom name (server resets to the minted name). ---
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-hide';
346
- const labelFor = (hidden) => (hidden ? '🙈' : '👁');
347
- const titleFor = (hidden) => (hidden ? 'Unhide — show on the floor again' : 'Hide this agent from the office floor');
348
- hide.textContent = labelFor(a.hidden);
349
- hide.title = titleFor(a.hidden);
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
- const prev = labelFor(next);
354
- hide.title = titleFor(next);
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>
@@ -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
- const LEADERBOARD_API = 'https://agency-leaderboard.henryz2004.workers.dev';
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
- if (!res.ok) return { _error: `HTTP ${res.status}` };
54
- return await res.json();
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 out = (state && state.usage && state.usage.lifetime && state.usage.lifetime.out) || 0;
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} · ${fmtEY(r.engYears)} eng-yrs`;
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> on a public leaderboard.
110
- Only a display name + that one number are sent <b>never</b> your code, transcripts, or repo names.</p>
111
- <p class="lb-preview">Your standardized score right now: <b></b> eng-yrs</p>
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 = fmtEY(standardEngYears(stats.out));
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"></div>`;
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 = `${fmtEY(row.engYears)} eng-yrs`;
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
- // A small floor control showing how many desks the user has hidden, toggling
576
- // them back into view. The hidden STATE is owned by app.js (agent.hidden); proc
577
- // just collapses them by default and offers a local reveal (no poll needed —
578
- // rebuild() re-filters lastAll). Anchored to the floor-frame, not the scaled
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
- const host = world && world.parentElement;
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
- // visible with no chip to re-collapse it (the chip is gone at count 0).
598
- if (!hiddenCount) { showHidden = false; hiddenChip.style.display = 'none'; return; }
599
- hiddenChip.style.display = 'inline-block';
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
- ? `▾ hide ${hiddenCount} hidden`
602
- : `▸ ${hiddenCount} away · show`;
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' : 'rgba(255,255,255,0.14)';
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 small walk affordances (a toggle button + a hint) over the floor
1080
- // frame, styled inline (index.html / style.css aren't in this lane).
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 || walkBtn) return;
1084
- walkBtn = document.createElement('button');
1085
- walkBtn.type = 'button';
1086
- walkBtn.id = 'walkToggle';
1087
- Object.assign(walkBtn.style, {
1088
- position: 'absolute', left: '12px', bottom: '12px', zIndex: '6',
1089
- font: '11px ui-monospace, monospace', background: 'rgba(16,20,28,0.82)',
1090
- border: '1px solid rgba(255,255,255,0.14)', borderRadius: '6px',
1091
- padding: '5px 9px', cursor: 'pointer',
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
- walkBtn.addEventListener('click', () => toggleWalk());
1094
- host.appendChild(walkBtn);
1095
- // nametag-visibility toggle (top-left, clear of the bottom controls)
1096
- nameBtn = document.createElement('button');
1097
- nameBtn.type = 'button';
1098
- nameBtn.id = 'nameToggle';
1099
- nameBtn.title = 'Show / hide agent name tags (N)';
1100
- Object.assign(nameBtn.style, {
1101
- // sits just below the top-left "N away · show" hidden-agents chip (which also
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
- position: 'absolute', left: '12px', bottom: '40px', zIndex: '6',
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) });