@henryz2004/agency 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>AGENCY ยท your AI workforce</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
10
|
+
<link rel="stylesheet" href="/style.css" />
|
|
11
|
+
<link rel="stylesheet" href="/chat-panel.css" />
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="scanlines"></div>
|
|
15
|
+
|
|
16
|
+
<header class="topbar">
|
|
17
|
+
<div class="brand">
|
|
18
|
+
<span class="logo">๐ข</span>
|
|
19
|
+
<div class="brand-text">
|
|
20
|
+
<div class="brand-name" id="brandName">AGENCY</div>
|
|
21
|
+
<div class="brand-sub">your one-person AI workforce</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="topright">
|
|
25
|
+
<div class="topstats">
|
|
26
|
+
<div class="tstat">
|
|
27
|
+
<div class="tstat-val" id="tsLive">0</div>
|
|
28
|
+
<div class="tstat-lbl">on the floor</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="tstat">
|
|
31
|
+
<div class="tstat-val" id="tsGenerating">0</div>
|
|
32
|
+
<div class="tstat-lbl">generating now</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="tstat">
|
|
35
|
+
<div class="tstat-val" id="tsBurn">0 <span class="tstat-unit">tok/min</span></div>
|
|
36
|
+
<div class="tstat-lbl" id="tsBurnLbl">idle</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="tstat">
|
|
39
|
+
<div class="tstat-val" id="tsTeams">0</div>
|
|
40
|
+
<div class="tstat-lbl">teams</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="topctrls">
|
|
44
|
+
<div class="live-pill"><span class="rec"></span> LIVE</div>
|
|
45
|
+
<button id="leaderboardBtn" class="panel-toggle" type="button" title="Leaderboard" hidden>๐</button>
|
|
46
|
+
<button id="panelToggle" class="panel-toggle" type="button" title="Toggle stats panel">๐</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</header>
|
|
50
|
+
|
|
51
|
+
<main class="layout">
|
|
52
|
+
<section class="floor-col">
|
|
53
|
+
<div class="floor-frame">
|
|
54
|
+
<div class="world" id="world">
|
|
55
|
+
<canvas id="office"></canvas>
|
|
56
|
+
<div id="labels" class="label-layer"></div>
|
|
57
|
+
</div>
|
|
58
|
+
<div id="emptyBanner" class="empty-banner hidden">
|
|
59
|
+
No agents on the floor โ run <code>claude</code>, <code>codex</code>, or <code>opencode</code> in any project to hire one.
|
|
60
|
+
</div>
|
|
61
|
+
<button id="recenter" class="recenter" type="button" title="Recenter the floor">โ recenter</button>
|
|
62
|
+
<div class="floor-hint" id="floorHint">drag to pan ยท scroll to move ยท โ/pinch to zoom ยท click a desk for details</div>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<aside class="panel">
|
|
67
|
+
<div class="view-toggle" id="viewToggle" role="tablist" aria-label="Sidebar view">
|
|
68
|
+
<button type="button" class="vt-btn active" id="vtNow" role="tab" aria-selected="true">Now</button>
|
|
69
|
+
<button type="button" class="vt-btn" id="vtAnalytics" role="tab" aria-selected="false">Analytics</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<!-- ===== NOW: live, current-activity view (default) ===== -->
|
|
73
|
+
<div class="view-now" id="viewNow">
|
|
74
|
+
<div class="card now-card">
|
|
75
|
+
<div class="card-title">Burning now <span class="card-title-hint">live output</span></div>
|
|
76
|
+
<div class="bignum"><span id="nowBurn">0</span><span class="bignum-unit" id="nowBurnUnit">tok/min</span></div>
|
|
77
|
+
<div class="card-note" id="nowBurnNote">output tokens over the last ~60s ยท 0 when idle</div>
|
|
78
|
+
<canvas id="burnSpark" class="burnspark" aria-hidden="true"></canvas>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="card">
|
|
82
|
+
<div class="card-title">Live floor <span class="card-title-hint" id="nowFloorHint">right now</span></div>
|
|
83
|
+
<div class="now-stats">
|
|
84
|
+
<div class="now-stat"><div class="now-k" id="nowGenerating">0</div><div class="now-l">generating</div></div>
|
|
85
|
+
<div class="now-stat"><div class="now-k" id="nowShell">0</div><div class="now-l">running cmd</div></div>
|
|
86
|
+
<div class="now-stat"><div class="now-k" id="nowIdle">0</div><div class="now-l">idle</div></div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="now-bar" id="nowBar"></div>
|
|
89
|
+
<div class="now-foot" id="nowFoot">No agents on the floor.</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="card">
|
|
93
|
+
<div class="card-title">Recent tool calls <span class="card-title-hint" id="nowToolsHint">last 7d</span></div>
|
|
94
|
+
<div id="nowToolList" class="nowtoollist"></div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- ===== ANALYTICS: windowed / historical view ===== -->
|
|
99
|
+
<div class="view-analytics hidden" id="viewAnalytics">
|
|
100
|
+
<!-- TODAY: the prominent "shipped like a team of X today" framing, recomputed
|
|
101
|
+
live by renderManpower() so the assumption sliders update it instantly. -->
|
|
102
|
+
<div class="card today-card">
|
|
103
|
+
<div class="card-title">Today <span class="card-title-hint" id="todayHint">resets at midnight</span></div>
|
|
104
|
+
<div class="today-line">Today you shipped like a team of</div>
|
|
105
|
+
<div class="bignum"><span id="tdTeam">0</span><span class="bignum-unit">engineers</span></div>
|
|
106
|
+
<div class="today-grid">
|
|
107
|
+
<div><div class="td-k" id="tdTokens">0</div><div class="td-l">output tokens today</div></div>
|
|
108
|
+
<div><div class="td-k" id="tdPay">$0</div><div class="td-l">payroll-equivalent today</div></div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="card headcount-card">
|
|
113
|
+
<div class="card-title">Effective team size <span class="card-title-hint">recent pace</span></div>
|
|
114
|
+
<div class="bignum"><span id="hcNum">0.0</span><span class="bignum-unit">engineers</span></div>
|
|
115
|
+
<div class="card-note">your recent daily output vs one human's</div>
|
|
116
|
+
<div class="payroll-meter">
|
|
117
|
+
<div class="pm-label">Payroll-equivalent earned <span class="pm-rate" id="pmRate">โ</span></div>
|
|
118
|
+
<div class="pm-value" id="pmValue">$0<span class="pm-cents">.00</span></div>
|
|
119
|
+
</div>
|
|
120
|
+
<canvas id="headsCanvas" class="heads"></canvas>
|
|
121
|
+
<div class="hc-compare" id="hcCompare">1 human operating like a team of โ</div>
|
|
122
|
+
<div class="hc-grid">
|
|
123
|
+
<div><div class="hc-k" id="mEngYears">โ</div><div class="hc-l">eng-years shipped</div></div>
|
|
124
|
+
<div><div class="hc-k" id="mPayroll">โ</div><div class="hc-l">payroll equivalent</div></div>
|
|
125
|
+
<div><div class="hc-k" id="mToday">โ</div><div class="hc-l">eng-days today</div></div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<details class="card card-fold">
|
|
130
|
+
<summary class="card-title">Assumptions <span class="card-title-hint">drag to re-rate</span></summary>
|
|
131
|
+
<div class="slider">
|
|
132
|
+
<label>Output tokens / engineer-hour <b id="vTok">3,000</b></label>
|
|
133
|
+
<input type="range" id="sTok" min="500" max="12000" step="250" value="3000" />
|
|
134
|
+
</div>
|
|
135
|
+
<div class="slider">
|
|
136
|
+
<label>Work hours / day <b id="vHrs">8</b></label>
|
|
137
|
+
<input type="range" id="sHrs" min="4" max="16" step="1" value="8" />
|
|
138
|
+
</div>
|
|
139
|
+
<div class="slider">
|
|
140
|
+
<label>Work days / year <b id="vDays">230</b></label>
|
|
141
|
+
<input type="range" id="sDays" min="180" max="300" step="5" value="230" />
|
|
142
|
+
</div>
|
|
143
|
+
<div class="slider">
|
|
144
|
+
<label>Avg engineer salary <b id="vSal">$150k</b></label>
|
|
145
|
+
<input type="range" id="sSal" min="80000" max="400000" step="10000" value="150000" />
|
|
146
|
+
</div>
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
<div class="card">
|
|
150
|
+
<div class="card-title">Model mix <span class="card-title-hint">by output</span></div>
|
|
151
|
+
<div class="modelbar" id="modelBar"></div>
|
|
152
|
+
<div class="legend" id="modelLegend"></div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div class="card">
|
|
156
|
+
<div class="card-title">Output by project <span class="card-title-hint" id="deptHint">active ยท last 7d</span></div>
|
|
157
|
+
<div id="deptList" class="deptlist"></div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div class="card">
|
|
161
|
+
<div class="card-title">Daily output <span class="card-title-hint">last 30 days</span></div>
|
|
162
|
+
<canvas id="dailyCanvas" class="daily"></canvas>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<details class="card card-fold">
|
|
166
|
+
<summary class="card-title">All-time ledger <span class="card-title-hint">lifetime totals</span></summary>
|
|
167
|
+
<div class="ledger" id="ledger"></div>
|
|
168
|
+
</details>
|
|
169
|
+
</div><!-- /view-analytics -->
|
|
170
|
+
</aside>
|
|
171
|
+
|
|
172
|
+
<div id="panelHandle" class="panel-handle" title="Show stats">๐</div>
|
|
173
|
+
</main>
|
|
174
|
+
|
|
175
|
+
<!-- Leaderboard (opt-in social layer). Hidden unless LEADERBOARD_API is set in leaderboard.js. -->
|
|
176
|
+
<div id="lbOverlay" class="lb-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="lbTitle">
|
|
177
|
+
<div class="lb-modal">
|
|
178
|
+
<button id="lbClose" class="lb-close" type="button" aria-label="Close">โ</button>
|
|
179
|
+
<div class="lb-head">
|
|
180
|
+
<h2 id="lbTitle">๐ Leaderboard</h2>
|
|
181
|
+
<p class="lb-sub">Standardized engineer-years shipped โ same formula for everyone, no sliders.</p>
|
|
182
|
+
</div>
|
|
183
|
+
<div id="lbBody" class="lb-body"></div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<script type="module" src="/app.js"></script>
|
|
188
|
+
<script type="module" src="/leaderboard.js"></script>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
package/public/lab.html
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>AGENCY ยท layout lab</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
10
|
+
<link rel="stylesheet" href="/style.css" />
|
|
11
|
+
<link rel="stylesheet" href="/chat-panel.css" />
|
|
12
|
+
<style>
|
|
13
|
+
/* lab controls live in the topbar; reuse the app's dark pixel look */
|
|
14
|
+
.lab-ctrls { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
|
15
|
+
.lab-ctrl { display: flex; align-items: center; gap: 8px; font: 12px 'IBM Plex Mono', monospace; color: #cdd8ea; }
|
|
16
|
+
.lab-ctrl input[type=range] { width: 200px; accent-color: #5cd0ff; }
|
|
17
|
+
.lab-ctrl b { color: #fff; min-width: 2.2em; text-align: right; font-variant-numeric: tabular-nums; }
|
|
18
|
+
.lab-dims { font: 11px 'IBM Plex Mono', monospace; color: #6b7689; }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<div class="scanlines"></div>
|
|
23
|
+
|
|
24
|
+
<header class="topbar">
|
|
25
|
+
<div class="brand">
|
|
26
|
+
<span class="logo">๐งช</span>
|
|
27
|
+
<div class="brand-text">
|
|
28
|
+
<div class="brand-name" id="brandName">LAYOUT LAB</div>
|
|
29
|
+
<div class="brand-sub">drag the sliders โ the floor re-lays live</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="topright">
|
|
33
|
+
<div class="lab-ctrls">
|
|
34
|
+
<label class="lab-ctrl">agents <input type="range" id="sAgents" min="0" max="40" step="1" value="5" /> <b id="vAgents">5</b></label>
|
|
35
|
+
<label class="lab-ctrl">teams <input type="range" id="sTeams" min="1" max="10" step="1" value="3" /> <b id="vTeams">3</b></label>
|
|
36
|
+
<span class="lab-dims" id="dims">โ</span>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<main class="layout">
|
|
42
|
+
<section class="floor-col">
|
|
43
|
+
<div class="floor-frame">
|
|
44
|
+
<div class="world" id="world">
|
|
45
|
+
<canvas id="office"></canvas>
|
|
46
|
+
<div id="labels" class="label-layer"></div>
|
|
47
|
+
</div>
|
|
48
|
+
<button id="recenter" class="recenter" type="button" title="Recenter the floor">โ recenter</button>
|
|
49
|
+
<div class="floor-hint" id="floorHint">drag to pan ยท scroll to move ยท โ/pinch to zoom ยท g to walk</div>
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
</main>
|
|
53
|
+
|
|
54
|
+
<script type="module">
|
|
55
|
+
import { initOffice, setAgents } from '/office.js';
|
|
56
|
+
import { initChatPanel } from '/chat-panel.js';
|
|
57
|
+
|
|
58
|
+
// ---- a faithful-enough synthetic cast: the office groups by `project`, and
|
|
59
|
+
// the procedural sprites read model/activity/subagents + a seed (pid/sessionId).
|
|
60
|
+
// Everything else is cosmetic, so we keep the agent shape minimal. ----
|
|
61
|
+
const PROJECTS = ['startup-agency', 'browser-harness', 'auth-service', 'data-pipeline',
|
|
62
|
+
'mobile-app', 'ml-infra', 'billing', 'design-system', 'infra', 'payments'];
|
|
63
|
+
const FIRST = ['Ada', 'Ravi', 'Mona', 'Kenji', 'Lena', 'Otis', 'Priya', 'Theo', 'Yara', 'Cole',
|
|
64
|
+
'Nina', 'Dax', 'Ines', 'Bram', 'Suki', 'Ezra', 'Milo', 'Joon', 'Rae', 'Tess'];
|
|
65
|
+
const LAST = ['Vance', 'Okoro', 'Sato', 'Reyes', 'Novak', 'Kapoor', 'Holt', 'Ferro', 'Lund', 'Cruz'];
|
|
66
|
+
const MODELS = ['claude-opus-4-8[1m]', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001', 'gpt-5-codex'];
|
|
67
|
+
const ACTS = ['working', 'working', 'shell', 'idle'];
|
|
68
|
+
const TASKS = ['wire up the live endpoint', 'fix the flaky tests', 'refactor the auth flow',
|
|
69
|
+
'chase a memory leak', 'draft the release notes', 'tighten the camera clamps', 'migrate the schema'];
|
|
70
|
+
|
|
71
|
+
function makeAgents(nAgents, nTeams) {
|
|
72
|
+
nTeams = Math.max(1, Math.min(nTeams, nAgents, PROJECTS.length));
|
|
73
|
+
// distribute agents across teams as evenly as possible (round-robin)
|
|
74
|
+
const sizes = Array(nTeams).fill(0);
|
|
75
|
+
for (let i = 0; i < nAgents; i++) sizes[i % nTeams]++;
|
|
76
|
+
const agents = [];
|
|
77
|
+
let i = 0;
|
|
78
|
+
for (let t = 0; t < nTeams; t++) {
|
|
79
|
+
for (let k = 0; k < sizes[t]; k++, i++) {
|
|
80
|
+
agents.push({
|
|
81
|
+
sessionId: `lab-${i}`, pid: 5000 + i,
|
|
82
|
+
source: 'claude', kind: 'interactive',
|
|
83
|
+
project: PROJECTS[t],
|
|
84
|
+
name: `${FIRST[i % FIRST.length]} ${LAST[i % LAST.length]}`,
|
|
85
|
+
model: MODELS[i % MODELS.length],
|
|
86
|
+
activity: ACTS[i % ACTS.length],
|
|
87
|
+
role: (k === 0 && sizes[t] > 1) ? 'lead' : null,
|
|
88
|
+
subagents: (i % 5 === 0) ? [{ type: 'general-purpose' }] : [],
|
|
89
|
+
chatName: TASKS[i % TASKS.length],
|
|
90
|
+
task: null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return agents;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const canvas = document.getElementById('office');
|
|
98
|
+
initOffice(canvas, document.getElementById('labels'));
|
|
99
|
+
initChatPanel(); // so clicking / E still opens the floating detail card
|
|
100
|
+
|
|
101
|
+
const sA = document.getElementById('sAgents'), vA = document.getElementById('vAgents');
|
|
102
|
+
const sT = document.getElementById('sTeams'), vT = document.getElementById('vTeams');
|
|
103
|
+
const dims = document.getElementById('dims');
|
|
104
|
+
|
|
105
|
+
function render() {
|
|
106
|
+
const n = +sA.value;
|
|
107
|
+
let teams = Math.min(+sT.value, n);
|
|
108
|
+
vA.textContent = n;
|
|
109
|
+
vT.textContent = teams;
|
|
110
|
+
setAgents(makeAgents(n, teams));
|
|
111
|
+
// report the resulting buffer size + aspect (office exposes nothing, so peek
|
|
112
|
+
// at the canvas the renderer just sized)
|
|
113
|
+
requestAnimationFrame(() => {
|
|
114
|
+
const w = canvas.width, h = canvas.height;
|
|
115
|
+
dims.textContent = w && h ? `room ${w}ร${h} ยท aspect ${(w / h).toFixed(2)}` : '';
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// deep-link / headless-screenshot support: ?agents=5&teams=3 seeds the sliders
|
|
120
|
+
const q = new URLSearchParams(location.search);
|
|
121
|
+
if (q.has('agents')) sA.value = q.get('agents');
|
|
122
|
+
if (q.has('teams')) sT.value = q.get('teams');
|
|
123
|
+
|
|
124
|
+
sA.addEventListener('input', render);
|
|
125
|
+
sT.addEventListener('input', render);
|
|
126
|
+
render();
|
|
127
|
+
</script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// leaderboard.js โ the opt-in social layer (Phase 2a).
|
|
2
|
+
//
|
|
3
|
+
// Privacy contract: NOTHING leaves this machine unless you click "Join", and
|
|
4
|
+
// even then only your chosen handle + your standardized eng-years (one number,
|
|
5
|
+
// derived from total output tokens) are sent. Never code, transcripts, repo, or
|
|
6
|
+
// project names. "Stop sharing" deletes your row server-side.
|
|
7
|
+
//
|
|
8
|
+
// Set LEADERBOARD_API to your deployed Worker URL (see worker/README.md). While
|
|
9
|
+
// it's empty, the whole feature stays hidden and the dashboard is unchanged.
|
|
10
|
+
|
|
11
|
+
import { standardEngYears } from './metric.js';
|
|
12
|
+
|
|
13
|
+
const LEADERBOARD_API = 'https://agency-leaderboard.henryz2004.workers.dev';
|
|
14
|
+
|
|
15
|
+
const KNOWN_SOURCES = ['claude', 'codex', 'opencode'];
|
|
16
|
+
const LS = { id: 'agency.lb.installId', handle: 'agency.lb.handle', opted: 'agency.lb.optedIn' };
|
|
17
|
+
const $ = (id) => document.getElementById(id);
|
|
18
|
+
|
|
19
|
+
if (LEADERBOARD_API) init();
|
|
20
|
+
|
|
21
|
+
function init() {
|
|
22
|
+
const btn = $('leaderboardBtn');
|
|
23
|
+
if (!btn) return;
|
|
24
|
+
btn.hidden = false;
|
|
25
|
+
btn.addEventListener('click', open);
|
|
26
|
+
$('lbClose') && $('lbClose').addEventListener('click', close);
|
|
27
|
+
$('lbOverlay') && $('lbOverlay').addEventListener('click', (e) => {
|
|
28
|
+
if (e.target.id === 'lbOverlay') close();
|
|
29
|
+
});
|
|
30
|
+
document.addEventListener('keydown', (e) => {
|
|
31
|
+
if (e.key === 'Escape' && $('lbOverlay') && !$('lbOverlay').classList.contains('hidden')) close();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function open() { $('lbOverlay').classList.remove('hidden'); render(); }
|
|
36
|
+
function close() { $('lbOverlay').classList.add('hidden'); }
|
|
37
|
+
|
|
38
|
+
// Stable anonymous identity (so re-submits update your row, not duplicate it).
|
|
39
|
+
function installId() {
|
|
40
|
+
let id = localStorage.getItem(LS.id);
|
|
41
|
+
if (!id || !/^[A-Za-z0-9_-]{8,64}$/.test(id)) {
|
|
42
|
+
id = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
43
|
+
? crypto.randomUUID()
|
|
44
|
+
: 'id-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
45
|
+
localStorage.setItem(LS.id, id);
|
|
46
|
+
}
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchJSON(url, opts) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url, opts);
|
|
53
|
+
if (!res.ok) return { _error: `HTTP ${res.status}` };
|
|
54
|
+
return await res.json();
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { _error: String((e && e.message) || e) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pull this machine's lifetime output tokens + which tools are running, from the
|
|
61
|
+
// app's own read-only endpoint. Used only at submit time.
|
|
62
|
+
async function localStats() {
|
|
63
|
+
const state = await fetchJSON('/api/state');
|
|
64
|
+
const out = (state && state.usage && state.usage.lifetime && state.usage.lifetime.out) || 0;
|
|
65
|
+
const agents = (state && state.live && state.live.agents) || [];
|
|
66
|
+
const sources = [...new Set(agents.map((a) => a && a.source).filter((s) => KNOWN_SOURCES.includes(s)))];
|
|
67
|
+
return { out, sources };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fmtEY = (n) => (n >= 10 ? Math.round(n).toString() : n.toFixed(n >= 1 ? 1 : 2));
|
|
71
|
+
const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n));
|
|
72
|
+
|
|
73
|
+
async function render() {
|
|
74
|
+
const body = $('lbBody');
|
|
75
|
+
body.innerHTML = '';
|
|
76
|
+
const opted = localStorage.getItem(LS.opted) === '1';
|
|
77
|
+
const stats = await localStats(); // { out, sources } โ one local snapshot for preview + submit
|
|
78
|
+
body.appendChild(opted ? statusBlock(stats) : optInBlock(stats));
|
|
79
|
+
const list = document.createElement('div');
|
|
80
|
+
list.className = 'lb-list';
|
|
81
|
+
list.textContent = 'Loadingโฆ';
|
|
82
|
+
body.appendChild(list);
|
|
83
|
+
loadList(list);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Submit the standardized score. Server derives eng-years from outputTokens.
|
|
87
|
+
function submitScore(handle, stats) {
|
|
88
|
+
return fetchJSON(LEADERBOARD_API + '/api/submit', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ installId: installId(), handle, outputTokens: stats.out, sources: stats.sources }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Refresh the "#N of M" line in place (so messages on the card survive).
|
|
96
|
+
function refreshRank() {
|
|
97
|
+
fetchJSON(LEADERBOARD_API + '/api/rank?installId=' + encodeURIComponent(installId())).then((r) => {
|
|
98
|
+
const el = $('lbYouRank');
|
|
99
|
+
if (!el) return;
|
|
100
|
+
if (r && typeof r.rank === 'number') el.textContent = `#${r.rank} of ${r.total} ยท ${fmtEY(r.engYears)} eng-yrs`;
|
|
101
|
+
else el.textContent = 'not ranked yet';
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function optInBlock(stats) {
|
|
106
|
+
const wrap = document.createElement('div');
|
|
107
|
+
wrap.className = 'lb-optin';
|
|
108
|
+
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>
|
|
112
|
+
<div class="lb-form">
|
|
113
|
+
<input id="lbHandle" type="text" maxlength="32" placeholder="display name" autocomplete="off" />
|
|
114
|
+
<button id="lbJoin" type="button">Join</button>
|
|
115
|
+
</div>
|
|
116
|
+
<div id="lbMsg" class="lb-msg"></div>`;
|
|
117
|
+
wrap.querySelector('.lb-preview b').textContent = fmtEY(standardEngYears(stats.out));
|
|
118
|
+
const input = wrap.querySelector('#lbHandle');
|
|
119
|
+
input.value = localStorage.getItem(LS.handle) || '';
|
|
120
|
+
const join = wrap.querySelector('#lbJoin');
|
|
121
|
+
const msg = wrap.querySelector('#lbMsg');
|
|
122
|
+
const go = async () => {
|
|
123
|
+
const handle = input.value.trim();
|
|
124
|
+
if (!handle) { msg.textContent = 'Pick a display name first.'; return; }
|
|
125
|
+
join.disabled = true; msg.textContent = 'Submittingโฆ';
|
|
126
|
+
const res = await submitScore(handle, stats);
|
|
127
|
+
join.disabled = false;
|
|
128
|
+
if (res && res.ok) {
|
|
129
|
+
localStorage.setItem(LS.handle, handle);
|
|
130
|
+
localStorage.setItem(LS.opted, '1');
|
|
131
|
+
render();
|
|
132
|
+
} else {
|
|
133
|
+
msg.textContent = 'Could not submit' + (res && res._error ? ` (${res._error})` : '') + '.';
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
join.addEventListener('click', go);
|
|
137
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
|
|
138
|
+
return wrap;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function statusBlock(stats) {
|
|
142
|
+
const wrap = document.createElement('div');
|
|
143
|
+
wrap.className = 'lb-status';
|
|
144
|
+
// Real stored handle (no placeholder fallback) โ this is what we SUBMIT, so a
|
|
145
|
+
// missing handle must never become the literal "you" on the server.
|
|
146
|
+
const handle = localStorage.getItem(LS.handle) || '';
|
|
147
|
+
wrap.innerHTML = `
|
|
148
|
+
<div class="lb-you">
|
|
149
|
+
<div class="lb-you-handle"></div>
|
|
150
|
+
<div class="lb-you-rank" id="lbYouRank">โ</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="lb-actions">
|
|
153
|
+
<button id="lbUpdate" type="button">Update my score</button>
|
|
154
|
+
<button id="lbForget" type="button" class="lb-danger">Stop sharing</button>
|
|
155
|
+
</div>
|
|
156
|
+
<div id="lbMsg" class="lb-msg"></div>`;
|
|
157
|
+
wrap.querySelector('.lb-you-handle').textContent = handle || 'you'; // display only; textContent = no injection
|
|
158
|
+
const msg = wrap.querySelector('#lbMsg');
|
|
159
|
+
|
|
160
|
+
refreshRank();
|
|
161
|
+
|
|
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
|
+
wrap.querySelector('#lbForget').addEventListener('click', async (e) => {
|
|
178
|
+
e.target.disabled = true; msg.textContent = 'Removingโฆ';
|
|
179
|
+
const res = await fetchJSON(LEADERBOARD_API + '/api/forget', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({ installId: installId() }),
|
|
183
|
+
});
|
|
184
|
+
if (res && res.ok) {
|
|
185
|
+
localStorage.removeItem(LS.opted);
|
|
186
|
+
render();
|
|
187
|
+
} else {
|
|
188
|
+
e.target.disabled = false;
|
|
189
|
+
msg.textContent = 'Could not remove' + (res && res._error ? ` (${res._error})` : '') + '.';
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return wrap;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadList(list) {
|
|
196
|
+
// installId is sent ONLY when opted in (so the server can flag our own row);
|
|
197
|
+
// before opt-in nothing identifying leaves the machine.
|
|
198
|
+
const opted = localStorage.getItem(LS.opted) === '1';
|
|
199
|
+
const q = '/api/leaderboard?limit=100' + (opted ? '&installId=' + encodeURIComponent(installId()) : '');
|
|
200
|
+
const data = await fetchJSON(LEADERBOARD_API + q);
|
|
201
|
+
if (!data || data._error || !Array.isArray(data.top)) {
|
|
202
|
+
list.textContent = 'Leaderboard unavailable right now.';
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!data.top.length) {
|
|
206
|
+
list.textContent = 'No one has joined yet โ be the first.';
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
list.innerHTML = '';
|
|
210
|
+
for (const row of data.top) {
|
|
211
|
+
const r = document.createElement('div');
|
|
212
|
+
r.className = 'lb-row';
|
|
213
|
+
if (row.mine) r.classList.add('lb-mine'); // server-flagged by installId, not by handle
|
|
214
|
+
const rank = document.createElement('span'); rank.className = 'lb-rank'; rank.textContent = `#${row.rank}`;
|
|
215
|
+
const name = document.createElement('span'); name.className = 'lb-name'; name.textContent = row.handle;
|
|
216
|
+
const val = document.createElement('span'); val.className = 'lb-val';
|
|
217
|
+
val.textContent = `${fmtEY(row.engYears)} eng-yrs`;
|
|
218
|
+
val.title = `${fmtTok(row.outputTokens)} output tokens` + (row.sources && row.sources.length ? ` ยท ${row.sources.join(', ')}` : '');
|
|
219
|
+
r.append(rank, name, val);
|
|
220
|
+
list.appendChild(r);
|
|
221
|
+
}
|
|
222
|
+
}
|
package/public/metric.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// metric.js โ the STANDARDIZED leaderboard metric. Shared by the dashboard
|
|
2
|
+
// (display) and the Worker (authoritative ranking). Unlike the personal
|
|
3
|
+
// manpower card, these constants are LOCKED โ the leaderboard must be
|
|
4
|
+
// comparable across people, so it can't use anyone's tunable sliders.
|
|
5
|
+
//
|
|
6
|
+
// They are the personal-view DEFAULTS (app.js `DEFAULTS`) frozen in place:
|
|
7
|
+
// one "engineer" ships 3000 output tokens/hr ยท 8 hr/day ยท 230 days/yr.
|
|
8
|
+
// Keep these in sync with that default if it ever changes โ same formula,
|
|
9
|
+
// fixed inputs, so 1.0 eng-year here == 1.0 at the slider defaults there.
|
|
10
|
+
//
|
|
11
|
+
// ponytail: client-submitted output_tokens are forgeable in v1 (no proof).
|
|
12
|
+
// The ceiling is intentional โ server-side verification of real token counts
|
|
13
|
+
// is the upgrade path when leaderboard gaming actually shows up.
|
|
14
|
+
|
|
15
|
+
export const STANDARD = { tokPerHr: 3000, hrsPerDay: 8, daysPerYear: 230 };
|
|
16
|
+
export const TOKENS_PER_ENG_YEAR =
|
|
17
|
+
STANDARD.tokPerHr * STANDARD.hrsPerDay * STANDARD.daysPerYear; // 5_520_000
|
|
18
|
+
|
|
19
|
+
// Lifetime OUTPUT tokens โ standardized engineer-years. Output (not input)
|
|
20
|
+
// because that's what the personal card ranks on โ it's the "work shipped".
|
|
21
|
+
export function standardEngYears(lifetimeOutputTokens) {
|
|
22
|
+
return (Number(lifetimeOutputTokens) || 0) / TOKENS_PER_ENG_YEAR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- self-check: `node public/metric.js` -----------------------------------
|
|
26
|
+
if (typeof process !== 'undefined' && process.argv[1] && process.argv[1].endsWith('metric.js')) {
|
|
27
|
+
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1); } };
|
|
28
|
+
assert(TOKENS_PER_ENG_YEAR === 5_520_000, 'constant drifted from 5.52M');
|
|
29
|
+
assert(standardEngYears(TOKENS_PER_ENG_YEAR) === 1, 'one eng-year of tokens != 1.0');
|
|
30
|
+
assert(standardEngYears(0) === 0, 'zero tokens != 0');
|
|
31
|
+
assert(standardEngYears(null) === 0 && standardEngYears(undefined) === 0, 'nullish != 0');
|
|
32
|
+
assert(standardEngYears('11040000') === 2, 'string coercion / 2 eng-years failed');
|
|
33
|
+
console.log('metric.js self-check OK');
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// mock-agents.js โ synthetic agents in the SHAPE the real /api/state emits
|
|
2
|
+
// (source, activity, model, project/cwd, subagents, role). Used to drive the
|
|
3
|
+
// fully-procedural office prototype at 3 / 8 / 12 agents.
|
|
4
|
+
|
|
5
|
+
const MODELS = [
|
|
6
|
+
'claude-opus-4-8', // opus โ gold
|
|
7
|
+
'claude-sonnet-4-6', // sonnet โ cyan
|
|
8
|
+
'claude-haiku-4-5', // haiku โ green
|
|
9
|
+
'openai/codex-mini', // codex โ orange
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const PROJECTS = [
|
|
13
|
+
'startup-agency', 'browser-harness', 'sprite-lab',
|
|
14
|
+
'control-plane', 'pixel-pack', 'edge-router',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const TITLES = [
|
|
18
|
+
'Staff Engineer', 'Senior Engineer', 'Frontend Dev', 'Backend Dev',
|
|
19
|
+
'Infra Lead', 'Designer', 'QA Engineer', 'Researcher',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const NAMES = [
|
|
23
|
+
'Ada', 'Bjorn', 'Cleo', 'Dex', 'Esme', 'Finn', 'Goro', 'Hana',
|
|
24
|
+
'Iris', 'Juno', 'Kai', 'Lena', 'Milo', 'Nova', 'Otis', 'Priya',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const ACTIVITIES = ['working', 'working', 'shell', 'idle']; // weighted toward busy
|
|
28
|
+
|
|
29
|
+
// A deterministic mock generator so 3/8/12 are stable across reloads.
|
|
30
|
+
function mulberry(seed) {
|
|
31
|
+
let s = seed >>> 0;
|
|
32
|
+
return () => {
|
|
33
|
+
s |= 0; s = (s + 0x6d2b79f5) | 0;
|
|
34
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
35
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
36
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function makeAgents(n, seed = 7) {
|
|
41
|
+
const r = mulberry(seed * 100 + n);
|
|
42
|
+
const out = [];
|
|
43
|
+
// Decide a project layout: cluster agents into a few repos so the floor groups.
|
|
44
|
+
// Fewer projects than agents โ real desk neighbourhoods.
|
|
45
|
+
const nProj = Math.max(1, Math.min(PROJECTS.length, Math.round(n / 2.5)));
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
const proj = PROJECTS[Math.floor(r() * nProj)];
|
|
48
|
+
const model = MODELS[Math.floor(r() * MODELS.length)];
|
|
49
|
+
const activity = i === 0 ? 'working' : ACTIVITIES[Math.floor(r() * ACTIVITIES.length)];
|
|
50
|
+
const subN = activity === 'working' && r() < 0.4 ? 1 + Math.floor(r() * 3) : 0;
|
|
51
|
+
out.push({
|
|
52
|
+
sessionId: `mock-${seed}-${i}`,
|
|
53
|
+
pid: 1000 + i,
|
|
54
|
+
name: NAMES[i % NAMES.length] + (i >= NAMES.length ? ' ' + Math.floor(i / NAMES.length) : ''),
|
|
55
|
+
title: TITLES[Math.floor(r() * TITLES.length)],
|
|
56
|
+
project: proj,
|
|
57
|
+
cwd: `/Users/dev/code/${proj}`,
|
|
58
|
+
model,
|
|
59
|
+
source: model.includes('codex') ? 'codex' : 'claude',
|
|
60
|
+
activity,
|
|
61
|
+
role: i === 0 ? 'lead' : 'teammate',
|
|
62
|
+
subagents: Array.from({ length: subN }, (_, k) => ({ name: `sub-${k}` })),
|
|
63
|
+
uptimeMs: Math.floor(r() * 6 * 3600 * 1000),
|
|
64
|
+
startedAt: Date.now() - Math.floor(r() * 6 * 3600 * 1000),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Group by project so co-located agents are adjacent (mirrors office.js).
|
|
68
|
+
out.sort((a, b) => a.project.localeCompare(b.project));
|
|
69
|
+
return out;
|
|
70
|
+
}
|