@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
package/public/app.js
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
// app.js — fetches /api/state, drives the office renderer, and computes the
|
|
2
|
+
// "manpower" translation + comparison panels. All manpower math is client-side
|
|
3
|
+
// so the assumption sliders update everything instantly.
|
|
4
|
+
|
|
5
|
+
import { initOffice, setAgents } from './office.js';
|
|
6
|
+
import { drawHead, colorFor } from './sprites.js';
|
|
7
|
+
import { updateSound } from './sound.js';
|
|
8
|
+
import { initAudioControls } from './audio-controls.js';
|
|
9
|
+
import { initUI } from './ui.js';
|
|
10
|
+
import { mockEnabled, getMockState } from './mock.js';
|
|
11
|
+
import { initChatPanel } from './chat-panel.js';
|
|
12
|
+
|
|
13
|
+
const $ = (id) => document.getElementById(id);
|
|
14
|
+
|
|
15
|
+
const office = $('office');
|
|
16
|
+
const labels = $('labels');
|
|
17
|
+
initOffice(office, labels);
|
|
18
|
+
initUI();
|
|
19
|
+
initChatPanel(); // read-only "open agent's chat" peek panel (listens for agency:select)
|
|
20
|
+
|
|
21
|
+
// ---- assumptions (persisted) ---------------------------------------------
|
|
22
|
+
|
|
23
|
+
const DEFAULTS = { tok: 3000, hrs: 8, days: 230, sal: 150000 };
|
|
24
|
+
let assume = loadAssumptions();
|
|
25
|
+
|
|
26
|
+
function loadAssumptions() {
|
|
27
|
+
try {
|
|
28
|
+
return { ...DEFAULTS, ...JSON.parse(localStorage.getItem('agency.assume') || '{}') };
|
|
29
|
+
} catch {
|
|
30
|
+
return { ...DEFAULTS };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function saveAssumptions() {
|
|
34
|
+
try {
|
|
35
|
+
localStorage.setItem('agency.assume', JSON.stringify(assume));
|
|
36
|
+
} catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bindSlider(id, key, fmt) {
|
|
42
|
+
const el = $(id);
|
|
43
|
+
const out = $('v' + key[0].toUpperCase() + key.slice(1));
|
|
44
|
+
el.value = assume[key];
|
|
45
|
+
out.textContent = fmt(assume[key]);
|
|
46
|
+
el.addEventListener('input', () => {
|
|
47
|
+
assume[key] = Number(el.value);
|
|
48
|
+
out.textContent = fmt(assume[key]);
|
|
49
|
+
saveAssumptions();
|
|
50
|
+
renderManpower();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- formatting -----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function fmt(n) {
|
|
57
|
+
n = n || 0;
|
|
58
|
+
if (n >= 1e9) return (n / 1e9).toFixed(n >= 1e10 ? 0 : 1) + 'B';
|
|
59
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M';
|
|
60
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'K';
|
|
61
|
+
return String(Math.round(n));
|
|
62
|
+
}
|
|
63
|
+
function money(n) {
|
|
64
|
+
if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M';
|
|
65
|
+
if (n >= 1e3) return '$' + Math.round(n / 1e3) + 'k';
|
|
66
|
+
return '$' + Math.round(n);
|
|
67
|
+
}
|
|
68
|
+
function comma(n) {
|
|
69
|
+
return Math.round(n).toLocaleString('en-US');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- state ----------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
let STATE = null;
|
|
75
|
+
|
|
76
|
+
async function poll() {
|
|
77
|
+
if (mockEnabled) {
|
|
78
|
+
STATE = getMockState(); // synthetic data — iterate on the UI with no agents
|
|
79
|
+
onState();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch('/api/state', { cache: 'no-store' });
|
|
84
|
+
STATE = await res.json();
|
|
85
|
+
onState();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// server probably restarting; keep last frame
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onState() {
|
|
92
|
+
// Bail if the server returned an error body instead of state.
|
|
93
|
+
if (!STATE || !STATE.live || !Array.isArray(STATE.live.agents) || !STATE.usage) return;
|
|
94
|
+
const { live, usage } = STATE;
|
|
95
|
+
// Drive the canvas office, but never let a renderer throw take down the data
|
|
96
|
+
// panels — they read the same STATE independently. (Fail-soft charter: a
|
|
97
|
+
// broken render path shouldn't blank the whole dashboard.)
|
|
98
|
+
try {
|
|
99
|
+
setAgents(live.agents);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.error('office render failed:', e);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- topbar headline metrics (truthful definitions) --------------------
|
|
105
|
+
// ponytail: "on the floor" = count of live agents (anything getLive() returns:
|
|
106
|
+
// running interactive sessions + active/blocked background agents + teammates).
|
|
107
|
+
const onFloor = live.agents.length;
|
|
108
|
+
// ponytail: "shipping now" = agents whose activity is 'working' (the model is
|
|
109
|
+
// actively generating). 'shell' (running a command) and 'idle' don't count.
|
|
110
|
+
// This is an instantaneous snapshot, so on a 3s poll it's frequently 0 even
|
|
111
|
+
// with agents present — true, but it reads dead. We keep it honest by RELABELING
|
|
112
|
+
// it as a live $/hr burn rate (below) when we can derive one, and otherwise show
|
|
113
|
+
// the working count under a clearer "generating now" frame.
|
|
114
|
+
const workingCount = live.agents.filter((a) => a.activity === 'working').length;
|
|
115
|
+
const shellCount = live.agents.filter((a) => a.activity === 'shell').length;
|
|
116
|
+
const idleCount = onFloor - workingCount - shellCount;
|
|
117
|
+
// ponytail: "teams" = distinct projects with a live agent (project = basename of
|
|
118
|
+
// the agent's cwd). This counts the workspaces currently staffed, not the
|
|
119
|
+
// collaborative-team records from readTeams() (which were almost always 0).
|
|
120
|
+
const liveProjects = new Set(live.agents.map((a) => a && a.project).filter(Boolean));
|
|
121
|
+
$('tsLive').textContent = onFloor;
|
|
122
|
+
$('tsTeams').textContent = liveProjects.size;
|
|
123
|
+
// Task 2: honest "generating now" count (model actively generating this instant).
|
|
124
|
+
$('tsGenerating').textContent = workingCount;
|
|
125
|
+
// Task 1: live token-burn from poll-to-poll deltas of lifetime.out.
|
|
126
|
+
sampleBurn(usage);
|
|
127
|
+
updateTopbarBurn();
|
|
128
|
+
// Task 3 "Now" view panels (live, lightweight).
|
|
129
|
+
renderNow({ onFloor, workingCount, shellCount, idleCount }, usage);
|
|
130
|
+
// ponytail: track per-agent activity → drives the "just finished / unread" set.
|
|
131
|
+
updateUnread(live.agents);
|
|
132
|
+
updateSound(workingCount);
|
|
133
|
+
$('emptyBanner').classList.toggle('hidden', live.agents.length > 0);
|
|
134
|
+
|
|
135
|
+
// "Waiting on you" HUD: agents paused on a Stop hook (awaitingReply) OR a
|
|
136
|
+
// background agent blocked waiting on the user (needsYou) — both need a look.
|
|
137
|
+
renderWaiting(live.agents.filter((a) => a.awaitingReply || a.needsYou));
|
|
138
|
+
|
|
139
|
+
renderManpower();
|
|
140
|
+
renderModels(usage);
|
|
141
|
+
renderDepts(usage);
|
|
142
|
+
renderDaily(usage);
|
|
143
|
+
renderLedger(usage);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- topbar live token-burn ----------------------------------------------
|
|
147
|
+
// The honest "what's happening NOW": a LIVE output-token rate derived from
|
|
148
|
+
// poll-to-poll deltas of usage.lifetime.out (which is MONOTONIC — it never
|
|
149
|
+
// resets, unlike today.out which zeroes at midnight). We keep a trailing
|
|
150
|
+
// window of {t, out} samples and compute rate over the elapsed minutes, so it
|
|
151
|
+
// reads ~0 when idle and spikes on a burst — the genuine bursty signal. NO
|
|
152
|
+
// averages, no $-framing here (that moved to the Analytics tab). Guards: a
|
|
153
|
+
// missing/backwards lifetime.out never produces a rate; the FIRST sample (no
|
|
154
|
+
// prior) shows 0, never a phantom spike.
|
|
155
|
+
//
|
|
156
|
+
// Two windows over the SAME sample buffer:
|
|
157
|
+
// - HEADLINE (~60s): drives both the topbar `tsBurn` and the panel `#nowBurn`.
|
|
158
|
+
// Short so it reacts fast to bursts and settles to ~0 when idle, but uses
|
|
159
|
+
// samples (≈20s apart on a 3s poll → a couple of samples) so it doesn't
|
|
160
|
+
// flicker wildly poll-to-poll.
|
|
161
|
+
// - HISTORY (~30 min): the full sample buffer we retain to chart the rate
|
|
162
|
+
// over time (see burnRateHistory / renderBurnSpark).
|
|
163
|
+
const BURN_HEADLINE_MS = 60 * 1000; // ~60s trailing window for the headline figure
|
|
164
|
+
const BURN_HISTORY_MS = 30 * 60 * 1000; // ~30-min sample history kept for the chart
|
|
165
|
+
const BURN_WINDOW_MS = BURN_HISTORY_MS; // (back-compat alias: the retained span)
|
|
166
|
+
const burnSamples = []; // [{ t: ms, out: lifetime.out }], oldest→newest
|
|
167
|
+
let liveBurnPerMin = 0; // tokens/min over the ~60s headline window (0 = idle/unknown)
|
|
168
|
+
// Rate history for the sparkline: one {t, rate} point per poll, derived from the
|
|
169
|
+
// instantaneous poll-to-poll delta. Spans ~30 min; oldest→newest.
|
|
170
|
+
const burnRateHistory = []; // [{ t: ms, rate: tok/min }]
|
|
171
|
+
|
|
172
|
+
function sampleBurn(usage) {
|
|
173
|
+
const out = usage && usage.lifetime ? usage.lifetime.out : null;
|
|
174
|
+
// Guard: missing or non-finite lifetime.out — don't record, don't fabricate.
|
|
175
|
+
if (typeof out !== 'number' || !Number.isFinite(out)) {
|
|
176
|
+
liveBurnPerMin = 0;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const prev = burnSamples.length ? burnSamples[burnSamples.length - 1] : null;
|
|
181
|
+
// Guard: lifetime.out should be monotonic. If it goes BACKWARDS (e.g. a server
|
|
182
|
+
// restart re-derived a smaller total, or a transient bad read), the window is
|
|
183
|
+
// no longer a valid baseline — reset to this point and report 0 until we have a
|
|
184
|
+
// fresh forward delta. This prevents a negative/garbage spike.
|
|
185
|
+
if (prev && out < prev.out) {
|
|
186
|
+
burnSamples.length = 0;
|
|
187
|
+
burnSamples.push({ t: now, out });
|
|
188
|
+
liveBurnPerMin = 0;
|
|
189
|
+
pushBurnRate(now, 0);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
burnSamples.push({ t: now, out });
|
|
193
|
+
// Drop samples older than the HISTORY span (always keep ≥1 so we have a
|
|
194
|
+
// baseline, and so the chart retains ~30 min of rate context).
|
|
195
|
+
while (burnSamples.length > 1 && now - burnSamples[0].t > BURN_HISTORY_MS) {
|
|
196
|
+
burnSamples.shift();
|
|
197
|
+
}
|
|
198
|
+
// First sample ever (no prior) → no delta → 0, never a huge spike.
|
|
199
|
+
if (!prev) {
|
|
200
|
+
liveBurnPerMin = 0;
|
|
201
|
+
pushBurnRate(now, 0);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Headline figure: rate over the trailing ~60s window. Find the oldest sample
|
|
206
|
+
// still within BURN_HEADLINE_MS of now (fall back to the second-newest so we
|
|
207
|
+
// always have a baseline once ≥2 samples exist) and measure across it.
|
|
208
|
+
let base = null;
|
|
209
|
+
for (let i = burnSamples.length - 1; i >= 0; i--) {
|
|
210
|
+
if (now - burnSamples[i].t <= BURN_HEADLINE_MS) base = burnSamples[i];
|
|
211
|
+
else break;
|
|
212
|
+
}
|
|
213
|
+
if (!base || base === burnSamples[burnSamples.length - 1]) {
|
|
214
|
+
base = burnSamples[burnSamples.length - 2] || burnSamples[0];
|
|
215
|
+
}
|
|
216
|
+
const newest = burnSamples[burnSamples.length - 1];
|
|
217
|
+
const minutes = (newest.t - base.t) / 60000;
|
|
218
|
+
liveBurnPerMin = minutes > 0 ? Math.max(0, (newest.out - base.out) / minutes) : 0;
|
|
219
|
+
|
|
220
|
+
// History point for the chart: the instantaneous rate since the previous poll.
|
|
221
|
+
const dtMin = (now - prev.t) / 60000;
|
|
222
|
+
const instRate = dtMin > 0 ? Math.max(0, (out - prev.out) / dtMin) : 0;
|
|
223
|
+
pushBurnRate(now, instRate);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Append a rate point and trim the history to the ~30-min span.
|
|
227
|
+
function pushBurnRate(t, rate) {
|
|
228
|
+
burnRateHistory.push({ t, rate: Number.isFinite(rate) ? Math.max(0, rate) : 0 });
|
|
229
|
+
while (burnRateHistory.length > 1 && t - burnRateHistory[0].t > BURN_HISTORY_MS) {
|
|
230
|
+
burnRateHistory.shift();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function updateTopbarBurn() {
|
|
235
|
+
const valEl = $('tsBurn');
|
|
236
|
+
const lblEl = $('tsBurnLbl');
|
|
237
|
+
if (!valEl) return;
|
|
238
|
+
const active = liveBurnPerMin > 0;
|
|
239
|
+
valEl.innerHTML = `${fmtBurn(liveBurnPerMin)} <span class="tstat-unit">tok/min</span>`;
|
|
240
|
+
if (lblEl) lblEl.textContent = active ? 'burning now' : 'idle';
|
|
241
|
+
valEl.title = 'live output-token rate over the last ~60s (0 when idle)';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Burn-specific number format: keep one decimal in the k range so "12.4k" reads,
|
|
245
|
+
// but show whole tokens below 1k and never the trailing ".0".
|
|
246
|
+
function fmtBurn(n) {
|
|
247
|
+
n = n || 0;
|
|
248
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
249
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
250
|
+
return String(Math.round(n));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- "just finished / unread" tracking ------------------------------------
|
|
254
|
+
// When an agent finishes a turn (active → idle) and the user hasn't opened it,
|
|
255
|
+
// it's "unread". We watch per-sessionId activity across polls and only mark
|
|
256
|
+
// unread on an OBSERVED active→idle transition — an agent already idle on first
|
|
257
|
+
// load is NOT unread (no false positives). The set is surfaced as a topbar count
|
|
258
|
+
// and exposed for an on-floor per-desk badge built in office.js:
|
|
259
|
+
// - window.agencyUnread : live Set<sessionId>, refreshed each poll
|
|
260
|
+
// - 'agency:unread' CustomEvent: { detail: { ids: [...] } }, fired on change
|
|
261
|
+
// Viewing an agent (the existing agency:select event) clears its unread.
|
|
262
|
+
// ponytail: in-memory only for v1; localStorage persistence (so unread survives
|
|
263
|
+
// a refresh) is the upgrade.
|
|
264
|
+
const lastActivity = new Map(); // sessionId -> last seen activity
|
|
265
|
+
const unreadIds = new Set(); // sessionIds that just finished and aren't viewed yet
|
|
266
|
+
window.agencyUnread = unreadIds; // expose for office.js per-desk badge
|
|
267
|
+
|
|
268
|
+
function isActive(activity) {
|
|
269
|
+
return activity === 'working' || activity === 'shell';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function emitUnread() {
|
|
273
|
+
window.dispatchEvent(new CustomEvent('agency:unread', { detail: { ids: [...unreadIds] } }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function updateUnread(agents) {
|
|
277
|
+
const present = new Set();
|
|
278
|
+
let changed = false;
|
|
279
|
+
for (const a of agents) {
|
|
280
|
+
const id = a && a.sessionId;
|
|
281
|
+
if (!id) continue;
|
|
282
|
+
present.add(id);
|
|
283
|
+
const prev = lastActivity.get(id);
|
|
284
|
+
const now = a.activity;
|
|
285
|
+
// Only an OBSERVED transition counts. `prev === undefined` (first sighting)
|
|
286
|
+
// never marks unread, so an agent already idle at page open stays read.
|
|
287
|
+
if (prev !== undefined && isActive(prev) && !isActive(now)) {
|
|
288
|
+
if (!unreadIds.has(id)) {
|
|
289
|
+
unreadIds.add(id);
|
|
290
|
+
changed = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
lastActivity.set(id, now);
|
|
294
|
+
}
|
|
295
|
+
// Forget agents that left the floor (and drop their stale unread).
|
|
296
|
+
for (const id of lastActivity.keys()) {
|
|
297
|
+
if (!present.has(id)) {
|
|
298
|
+
lastActivity.delete(id);
|
|
299
|
+
if (unreadIds.delete(id)) changed = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
renderUnreadPill();
|
|
303
|
+
if (changed) emitUnread();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clearUnread(sessionId) {
|
|
307
|
+
if (sessionId && unreadIds.delete(sessionId)) {
|
|
308
|
+
renderUnreadPill();
|
|
309
|
+
emitUnread();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let unreadPill = null;
|
|
314
|
+
function ensureUnreadPill() {
|
|
315
|
+
if (unreadPill) return unreadPill;
|
|
316
|
+
const stats = document.querySelector('.topctrls') || document.querySelector('.topstats');
|
|
317
|
+
if (!stats) return null;
|
|
318
|
+
unreadPill = document.createElement('button');
|
|
319
|
+
unreadPill.type = 'button';
|
|
320
|
+
unreadPill.id = 'unreadPill';
|
|
321
|
+
unreadPill.className = 'unread-pill hidden';
|
|
322
|
+
unreadPill.title = 'Agents that just finished a turn you haven’t opened — click to view';
|
|
323
|
+
const live = stats.querySelector('.live-pill');
|
|
324
|
+
if (live) stats.insertBefore(unreadPill, live);
|
|
325
|
+
else stats.appendChild(unreadPill);
|
|
326
|
+
return unreadPill;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderUnreadPill() {
|
|
330
|
+
const pill = ensureUnreadPill();
|
|
331
|
+
if (!pill) return;
|
|
332
|
+
const n = unreadIds.size;
|
|
333
|
+
if (!n) {
|
|
334
|
+
pill.classList.add('hidden');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
pill.classList.remove('hidden');
|
|
338
|
+
pill.textContent = `✉ ${n} just finished`;
|
|
339
|
+
pill.onclick = () => {
|
|
340
|
+
// Open the first unread agent (clears its unread via the agency:select path).
|
|
341
|
+
const first = unreadIds.values().next().value;
|
|
342
|
+
const agent = STATE && STATE.live && STATE.live.agents
|
|
343
|
+
? STATE.live.agents.find((a) => a && a.sessionId === first)
|
|
344
|
+
: null;
|
|
345
|
+
if (agent) window.dispatchEvent(new CustomEvent('agency:select', { detail: { agent } }));
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Viewing an agent (click on the floor, walk into its desk, or the waiting/unread
|
|
350
|
+
// pill) clears that agent's unread — same event every selection path already fires.
|
|
351
|
+
window.addEventListener('agency:select', (e) => {
|
|
352
|
+
const agent = e && e.detail && e.detail.agent;
|
|
353
|
+
if (agent && agent.sessionId) clearUnread(agent.sessionId);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ---- "needs you" HUD (Control Phase-1) ------------------------------------
|
|
357
|
+
// A non-intrusive pill in the topbar showing how many agents are paused on a
|
|
358
|
+
// Stop hook waiting for a reply. Clicking it selects the first waiter, which
|
|
359
|
+
// opens the chat panel with the reply box (via the same agency:select event the
|
|
360
|
+
// renderer uses). The on-canvas per-agent indicator lives in office.js; this is
|
|
361
|
+
// just the at-a-glance count.
|
|
362
|
+
let waitingPill = null;
|
|
363
|
+
|
|
364
|
+
function ensureWaitingPill() {
|
|
365
|
+
if (waitingPill) return waitingPill;
|
|
366
|
+
const stats = document.querySelector('.topctrls') || document.querySelector('.topstats');
|
|
367
|
+
if (!stats) return null;
|
|
368
|
+
// come-help-me pulse keyframe, injected once (we own app.js, not style.css)
|
|
369
|
+
if (!document.getElementById('waiting-kf')) {
|
|
370
|
+
const s = document.createElement('style');
|
|
371
|
+
s.id = 'waiting-kf';
|
|
372
|
+
s.textContent =
|
|
373
|
+
'@keyframes waitingPulse{0%,100%{box-shadow:0 0 0 0 rgba(255,170,60,0.55),0 0 6px rgba(255,170,60,0.55)}' +
|
|
374
|
+
'50%{box-shadow:0 0 0 5px rgba(255,170,60,0),0 0 13px rgba(255,170,60,0.95)}}';
|
|
375
|
+
document.head.appendChild(s);
|
|
376
|
+
}
|
|
377
|
+
waitingPill = document.createElement('button');
|
|
378
|
+
waitingPill.type = 'button';
|
|
379
|
+
waitingPill.id = 'waitingPill';
|
|
380
|
+
waitingPill.className = 'waiting-pill hidden';
|
|
381
|
+
waitingPill.title = 'Agents paused, waiting for your reply — click to answer';
|
|
382
|
+
// Strong, unmissable amber treatment (inline visual styles; `display` is left
|
|
383
|
+
// OUT so the .hidden class still controls show/hide). The amber + bell + pulse
|
|
384
|
+
// read "come help me", distinct from every other status color.
|
|
385
|
+
// Pixel font comes from the .waiting-pill class; sized to the topbar chip scale
|
|
386
|
+
// (8px) so it matches the LIVE pill instead of towering over the stats.
|
|
387
|
+
waitingPill.style.cssText =
|
|
388
|
+
'cursor:pointer;border:1px solid #d98a2a;border-radius:999px;padding:5px 11px;' +
|
|
389
|
+
'font-size:8px;letter-spacing:1px;color:#3a2606;' +
|
|
390
|
+
'background:linear-gradient(180deg,#ffd27a,#ffae3d);' +
|
|
391
|
+
'animation:waitingPulse 1.25s ease-in-out infinite;white-space:nowrap;';
|
|
392
|
+
// Insert before the LIVE pill so it reads alongside the status chips.
|
|
393
|
+
const live = stats.querySelector('.live-pill');
|
|
394
|
+
if (live) stats.insertBefore(waitingPill, live);
|
|
395
|
+
else stats.appendChild(waitingPill);
|
|
396
|
+
return waitingPill;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function renderWaiting(waiters) {
|
|
400
|
+
const pill = ensureWaitingPill();
|
|
401
|
+
if (!pill) return;
|
|
402
|
+
const n = waiters.length;
|
|
403
|
+
if (!n) {
|
|
404
|
+
pill.classList.add('hidden');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
pill.classList.remove('hidden');
|
|
408
|
+
pill.textContent = `🔔 ${n} waiting on you`;
|
|
409
|
+
pill.onclick = () => {
|
|
410
|
+
// Jump straight into answering the first (oldest) waiter.
|
|
411
|
+
const first = waiters.slice().sort((a, b) => (a.pendingSince || 0) - (b.pendingSince || 0))[0];
|
|
412
|
+
if (first) window.dispatchEvent(new CustomEvent('agency:select', { detail: { agent: first } }));
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---- "Now" view (default) -------------------------------------------------
|
|
417
|
+
// The current-activity panels: live token-burn (mirrors the topbar), a
|
|
418
|
+
// working/shell/idle breakdown of the live floor, and recent tool-call activity.
|
|
419
|
+
// Everything here is instantaneous/live — no averages, no $-framing.
|
|
420
|
+
|
|
421
|
+
function renderNow(counts, usage) {
|
|
422
|
+
const { onFloor, workingCount, shellCount, idleCount } = counts;
|
|
423
|
+
|
|
424
|
+
// Live burn (same figure as the topbar, larger).
|
|
425
|
+
const burnEl = $('nowBurn');
|
|
426
|
+
if (burnEl) burnEl.textContent = fmtBurn(liveBurnPerMin);
|
|
427
|
+
const burnNote = $('nowBurnNote');
|
|
428
|
+
if (burnNote) {
|
|
429
|
+
burnNote.textContent = liveBurnPerMin > 0
|
|
430
|
+
? 'output tokens / min over the last ~60s'
|
|
431
|
+
: 'no output flowing right now · 0 when idle';
|
|
432
|
+
}
|
|
433
|
+
// Live rate sparkline (last ~30 min of output-token rate).
|
|
434
|
+
renderBurnSpark();
|
|
435
|
+
|
|
436
|
+
// Working / shell / idle breakdown of the floor.
|
|
437
|
+
setText('nowGenerating', workingCount);
|
|
438
|
+
setText('nowShell', shellCount);
|
|
439
|
+
setText('nowIdle', Math.max(0, idleCount));
|
|
440
|
+
|
|
441
|
+
// Proportional bar (working = green, shell = amber, idle = grey).
|
|
442
|
+
const bar = $('nowBar');
|
|
443
|
+
if (bar) {
|
|
444
|
+
bar.innerHTML = '';
|
|
445
|
+
const total = onFloor;
|
|
446
|
+
if (total > 0) {
|
|
447
|
+
const segs = [
|
|
448
|
+
['now-seg-working', workingCount],
|
|
449
|
+
['now-seg-shell', shellCount],
|
|
450
|
+
['now-seg-idle', Math.max(0, idleCount)],
|
|
451
|
+
];
|
|
452
|
+
for (const [cls, n] of segs) {
|
|
453
|
+
if (n <= 0) continue;
|
|
454
|
+
const seg = document.createElement('div');
|
|
455
|
+
seg.className = 'now-seg ' + cls;
|
|
456
|
+
seg.style.width = (100 * n) / total + '%';
|
|
457
|
+
bar.appendChild(seg);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const foot = $('nowFoot');
|
|
462
|
+
if (foot) {
|
|
463
|
+
foot.textContent = onFloor === 0
|
|
464
|
+
? 'No agents on the floor.'
|
|
465
|
+
: `${onFloor} on the floor · ${workingCount} generating`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
renderNowTools(usage);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Recent tool-call activity: which currently-active workspaces have logged the
|
|
472
|
+
// most tool actions. /api/state carries no per-window tool count, only all-time
|
|
473
|
+
// `tools` and `recentOut` (output within the recency window), so we SCOPE to
|
|
474
|
+
// projects that are live now or shipped recently (honest "currently active") and
|
|
475
|
+
// rank those by their all-time tool actions. Fall back to all-time if nothing is
|
|
476
|
+
// active so the panel never goes blank.
|
|
477
|
+
function renderNowTools(usage) {
|
|
478
|
+
const list = $('nowToolList');
|
|
479
|
+
if (!list) return;
|
|
480
|
+
const liveProjects = new Set(
|
|
481
|
+
(STATE && STATE.live && STATE.live.agents ? STATE.live.agents : [])
|
|
482
|
+
.map((a) => a && a.project)
|
|
483
|
+
.filter(Boolean)
|
|
484
|
+
);
|
|
485
|
+
const all = Object.entries(usage.byProject || {}).map(([p, v]) => ({
|
|
486
|
+
project: p,
|
|
487
|
+
tools: v.tools || 0,
|
|
488
|
+
recentOut: v.recentOut || 0,
|
|
489
|
+
}));
|
|
490
|
+
// Active = a live agent here now, or output shipped within the recency window.
|
|
491
|
+
let entries = all.filter((e) => e.recentOut > 0 || liveProjects.has(e.project));
|
|
492
|
+
let fallback = false;
|
|
493
|
+
if (!entries.some((e) => e.tools > 0)) {
|
|
494
|
+
entries = all.filter((e) => e.tools > 0);
|
|
495
|
+
fallback = true;
|
|
496
|
+
}
|
|
497
|
+
entries.sort((a, b) => (b.tools || 0) - (a.tools || 0));
|
|
498
|
+
entries = entries.slice(0, 5);
|
|
499
|
+
const max = entries.length ? entries[0].tools || 1 : 1;
|
|
500
|
+
|
|
501
|
+
const hint = $('nowToolsHint');
|
|
502
|
+
if (hint) hint.textContent = fallback ? 'all-time' : `active · last ${usage.recentWindowDays || 7}d`;
|
|
503
|
+
|
|
504
|
+
list.innerHTML = '';
|
|
505
|
+
if (!entries.length) {
|
|
506
|
+
list.innerHTML = '<div class="dept-empty">No tool activity yet.</div>';
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
for (const e of entries) {
|
|
510
|
+
const live = liveProjects.has(e.project);
|
|
511
|
+
const liveTag = live ? '<span class="dept-live" title="an agent is here now">●</span>' : '';
|
|
512
|
+
const val = e.tools || 0;
|
|
513
|
+
const barW = max ? (100 * val) / max : 0;
|
|
514
|
+
const row = document.createElement('div');
|
|
515
|
+
row.className = 'nowtool-row';
|
|
516
|
+
row.innerHTML = `
|
|
517
|
+
<div class="nowtool-head"><span class="nowtool-name">${liveTag}${escapeHtml(e.project)}</span>
|
|
518
|
+
<span class="nowtool-val">${fmt(val)} actions</span></div>
|
|
519
|
+
<div class="nowtool-bar"><div class="nowtool-fill" style="width:${barW}%"></div></div>`;
|
|
520
|
+
list.appendChild(row);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function setText(id, v) {
|
|
525
|
+
const el = $(id);
|
|
526
|
+
if (el) el.textContent = String(v);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---- live burn sparkline --------------------------------------------------
|
|
530
|
+
// A small, clean line/area chart of the output-token RATE over the last ~30 min,
|
|
531
|
+
// drawn in the "Burning now" card below the headline number. Mirrors
|
|
532
|
+
// renderDaily's canvas pattern: size to clientWidth × a fixed height, scale by
|
|
533
|
+
// devicePixelRatio for crisp pixels, clear+redraw each poll. Green line + soft
|
|
534
|
+
// fill, retro-appropriate. Fail-soft: never throws — an empty/just-started
|
|
535
|
+
// history draws a flat baseline at 0.
|
|
536
|
+
const GREEN = '#39d98a';
|
|
537
|
+
|
|
538
|
+
function renderBurnSpark() {
|
|
539
|
+
const cv = $('burnSpark');
|
|
540
|
+
if (!cv || typeof cv.getContext !== 'function') return;
|
|
541
|
+
try {
|
|
542
|
+
const cssW = cv.clientWidth || 300;
|
|
543
|
+
const cssH = 46;
|
|
544
|
+
const dpr = window.devicePixelRatio || 1;
|
|
545
|
+
cv.width = Math.max(1, Math.round(cssW * dpr));
|
|
546
|
+
cv.height = Math.round(cssH * dpr);
|
|
547
|
+
cv.style.height = cssH + 'px';
|
|
548
|
+
const ctx = cv.getContext('2d');
|
|
549
|
+
if (!ctx) return;
|
|
550
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
551
|
+
ctx.clearRect(0, 0, cssW, cssH);
|
|
552
|
+
|
|
553
|
+
const padT = 4;
|
|
554
|
+
const padB = 3;
|
|
555
|
+
const baseY = cssH - padB; // y of the 0 line
|
|
556
|
+
const plotH = baseY - padT;
|
|
557
|
+
|
|
558
|
+
// Baseline (the 0 floor) — always drawn so an empty/idle chart reads as flat 0.
|
|
559
|
+
ctx.strokeStyle = 'rgba(57,217,138,0.18)';
|
|
560
|
+
ctx.lineWidth = 1;
|
|
561
|
+
ctx.beginPath();
|
|
562
|
+
ctx.moveTo(0, baseY + 0.5);
|
|
563
|
+
ctx.lineTo(cssW, baseY + 0.5);
|
|
564
|
+
ctx.stroke();
|
|
565
|
+
|
|
566
|
+
const pts = burnRateHistory;
|
|
567
|
+
if (!pts || pts.length === 0) return;
|
|
568
|
+
|
|
569
|
+
// X maps over a fixed ~30-min span so the line "scrolls" as time passes and
|
|
570
|
+
// doesn't rescale jarringly when only a few samples exist. Anchor the right
|
|
571
|
+
// edge at the newest sample's time (or now), and map older points left.
|
|
572
|
+
const now = pts[pts.length - 1].t;
|
|
573
|
+
const span = BURN_HISTORY_MS;
|
|
574
|
+
const xFor = (t) => {
|
|
575
|
+
const frac = 1 - (now - t) / span; // 1 = right edge (now), 0 = span ago
|
|
576
|
+
return Math.max(0, Math.min(1, frac)) * cssW;
|
|
577
|
+
};
|
|
578
|
+
const max = Math.max(1, ...pts.map((p) => p.rate || 0)); // 1 floor → no /0
|
|
579
|
+
const yFor = (rate) => baseY - (Math.max(0, rate) / max) * plotH;
|
|
580
|
+
|
|
581
|
+
// Single point (just started): show a flat line at its level, not a dot only.
|
|
582
|
+
const line = [];
|
|
583
|
+
for (const p of pts) line.push([xFor(p.t), yFor(p.rate || 0)]);
|
|
584
|
+
if (line.length === 1) line.unshift([0, line[0][1]]);
|
|
585
|
+
|
|
586
|
+
// Soft fill under the line.
|
|
587
|
+
const grad = ctx.createLinearGradient(0, padT, 0, baseY);
|
|
588
|
+
grad.addColorStop(0, 'rgba(57,217,138,0.28)');
|
|
589
|
+
grad.addColorStop(1, 'rgba(57,217,138,0.02)');
|
|
590
|
+
ctx.fillStyle = grad;
|
|
591
|
+
ctx.beginPath();
|
|
592
|
+
ctx.moveTo(line[0][0], baseY);
|
|
593
|
+
for (const [x, y] of line) ctx.lineTo(x, y);
|
|
594
|
+
ctx.lineTo(line[line.length - 1][0], baseY);
|
|
595
|
+
ctx.closePath();
|
|
596
|
+
ctx.fill();
|
|
597
|
+
|
|
598
|
+
// The rate line on top.
|
|
599
|
+
ctx.strokeStyle = GREEN;
|
|
600
|
+
ctx.lineWidth = 1.5;
|
|
601
|
+
ctx.lineJoin = 'round';
|
|
602
|
+
ctx.lineCap = 'round';
|
|
603
|
+
ctx.beginPath();
|
|
604
|
+
line.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
|
|
605
|
+
ctx.stroke();
|
|
606
|
+
|
|
607
|
+
// A subtle dot at the newest point so "now" reads as live.
|
|
608
|
+
const head = line[line.length - 1];
|
|
609
|
+
ctx.fillStyle = GREEN;
|
|
610
|
+
ctx.beginPath();
|
|
611
|
+
ctx.arc(head[0], head[1], 1.8, 0, Math.PI * 2);
|
|
612
|
+
ctx.fill();
|
|
613
|
+
} catch (e) {
|
|
614
|
+
// Fail-soft: a render hiccup must never blank the dashboard.
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- manpower -------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
function recentDailyAvgOut(usage) {
|
|
621
|
+
const d = usage.daily || [];
|
|
622
|
+
if (!d.length) return 0;
|
|
623
|
+
const last = d.slice(-7);
|
|
624
|
+
const sum = last.reduce((s, x) => s + (x.out || 0), 0);
|
|
625
|
+
return sum / Math.max(1, last.length);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function renderManpower() {
|
|
629
|
+
if (!STATE) return;
|
|
630
|
+
const usage = STATE.usage;
|
|
631
|
+
const perEngDay = assume.tok * assume.hrs; // output tokens one engineer ships/day
|
|
632
|
+
const perEngYear = perEngDay * assume.days;
|
|
633
|
+
|
|
634
|
+
const avgDailyOut = recentDailyAvgOut(usage);
|
|
635
|
+
const teamSize = avgDailyOut / perEngDay; // effective FTEs at recent pace
|
|
636
|
+
const engYears = (usage.lifetime.out || 0) / perEngYear;
|
|
637
|
+
const payroll = engYears * assume.sal;
|
|
638
|
+
const todayOut = usage.today ? usage.today.out || 0 : 0;
|
|
639
|
+
const engDaysToday = todayOut / perEngDay; // engineer-days produced today
|
|
640
|
+
|
|
641
|
+
$('hcNum').textContent = teamSize.toFixed(1);
|
|
642
|
+
$('mEngYears').textContent = engYears >= 1 ? engYears.toFixed(1) : engYears.toFixed(2);
|
|
643
|
+
$('mPayroll').textContent = money(payroll);
|
|
644
|
+
$('mToday').textContent = engDaysToday.toFixed(engDaysToday >= 10 ? 0 : 1);
|
|
645
|
+
|
|
646
|
+
// TODAY headline: "shipped like a team of N engineers today". N = today's
|
|
647
|
+
// engineer-days (one engineer's full workday = 1), so it reads as a team size.
|
|
648
|
+
// Today's dollars = engineer-days today * per-workday pay. All recomputed here
|
|
649
|
+
// so the assumption sliders update it live alongside the rest of the card.
|
|
650
|
+
const tdTeamEl = $('tdTeam');
|
|
651
|
+
if (tdTeamEl) tdTeamEl.textContent = Math.max(0, Math.round(engDaysToday));
|
|
652
|
+
const tdTokensEl = $('tdTokens');
|
|
653
|
+
if (tdTokensEl) tdTokensEl.textContent = fmt(todayOut);
|
|
654
|
+
const tdPayEl = $('tdPay');
|
|
655
|
+
if (tdPayEl) {
|
|
656
|
+
const dollarsPerWorkday = assume.days > 0 ? assume.sal / assume.days : 0;
|
|
657
|
+
tdPayEl.textContent = money(engDaysToday * dollarsPerWorkday);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
updatePayrollRate(avgDailyOut, perEngDay);
|
|
661
|
+
|
|
662
|
+
const team = Math.max(1, Math.round(teamSize));
|
|
663
|
+
const shown = Math.min(team, 60);
|
|
664
|
+
const cap = team > shown ? ` <span class="hc-cap">(showing ${shown})</span>` : '';
|
|
665
|
+
$('hcCompare').innerHTML = `1 human operating like a team of <b>${team}</b>${cap}`;
|
|
666
|
+
drawHeadcount(team);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function drawHeadcount(team) {
|
|
670
|
+
const cv = $('headsCanvas');
|
|
671
|
+
const cssW = cv.clientWidth || 300;
|
|
672
|
+
const HEAD_W = 12, HEAD_H = 13, GAP = 3;
|
|
673
|
+
const perRow = Math.max(6, Math.floor((cssW * 0.9) / ((HEAD_W + GAP))));
|
|
674
|
+
const show = Math.min(team, 60);
|
|
675
|
+
const rows = Math.ceil(show / perRow);
|
|
676
|
+
|
|
677
|
+
const scale = 3;
|
|
678
|
+
cv.width = perRow * (HEAD_W + GAP) * scale;
|
|
679
|
+
cv.height = Math.max(1, rows) * (HEAD_H + GAP) * scale;
|
|
680
|
+
const ctx = cv.getContext('2d');
|
|
681
|
+
ctx.imageSmoothingEnabled = false;
|
|
682
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
683
|
+
ctx.clearRect(0, 0, cv.width, cv.height);
|
|
684
|
+
|
|
685
|
+
for (let i = 0; i < show; i++) {
|
|
686
|
+
const r = Math.floor(i / perRow);
|
|
687
|
+
const c = i % perRow;
|
|
688
|
+
const x = c * (HEAD_W + GAP);
|
|
689
|
+
const y = r * (HEAD_H + GAP);
|
|
690
|
+
if (i === 0) {
|
|
691
|
+
// "you" — gold to stand out
|
|
692
|
+
drawHead(ctx, x, y, { skin: '#ffe0b0', hair: '#3a2a18', shirt: '#ffd166' });
|
|
693
|
+
} else {
|
|
694
|
+
const t = (i * 73) % 360;
|
|
695
|
+
drawHead(ctx, x, y, {
|
|
696
|
+
skin: ['#f0c8a0', '#d8a070', '#a87048'][i % 3],
|
|
697
|
+
hair: ['#2b2233', '#5a3a26', '#26333a'][(i >> 1) % 3],
|
|
698
|
+
shirt: `hsl(${t} 55% 60%)`,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---- live payroll-equivalent meter ---------------------------------------
|
|
705
|
+
// A money meter that ticks UP in real time. We derive a $/sec accrual rate from
|
|
706
|
+
// recent throughput and accumulate it via requestAnimationFrame so the figure
|
|
707
|
+
// keeps climbing while the page is open. The rate is recomputed on every slider
|
|
708
|
+
// change / new STATE without resetting the running total (no jarring jumps).
|
|
709
|
+
|
|
710
|
+
let payrollRate = 0; // dollars per second
|
|
711
|
+
let payrollTotal = 0; // accumulated dollars shown on the meter
|
|
712
|
+
let payrollLastTs = 0; // timestamp of last rAF frame (ms)
|
|
713
|
+
let payrollStarted = false;
|
|
714
|
+
|
|
715
|
+
function updatePayrollRate(avgDailyOut, perEngDay) {
|
|
716
|
+
// engineer-days produced per real day -> $/day -> $/sec.
|
|
717
|
+
// perEngDay = tok/hr * hrs = output one engineer ships per workday.
|
|
718
|
+
const engDaysPerDay = perEngDay > 0 ? avgDailyOut / perEngDay : 0;
|
|
719
|
+
const dollarsPerWorkday = assume.days > 0 ? assume.sal / assume.days : 0;
|
|
720
|
+
const dollarsPerDay = engDaysPerDay * dollarsPerWorkday;
|
|
721
|
+
payrollRate = Math.max(0, dollarsPerDay / 86400);
|
|
722
|
+
|
|
723
|
+
const rateEl = $('pmRate');
|
|
724
|
+
if (rateEl) rateEl.textContent = payrollRate > 0 ? `+${money(payrollRate * 3600)}/hr` : '—';
|
|
725
|
+
|
|
726
|
+
// Seed the starting total once with the lifetime payroll equivalent so the
|
|
727
|
+
// meter shows a meaningful figure immediately, then climbs from there.
|
|
728
|
+
if (!payrollStarted) {
|
|
729
|
+
const usage = STATE && STATE.usage;
|
|
730
|
+
if (usage && usage.lifetime) {
|
|
731
|
+
const perEngYear = perEngDay * assume.days;
|
|
732
|
+
const engYears = perEngYear > 0 ? (usage.lifetime.out || 0) / perEngYear : 0;
|
|
733
|
+
payrollTotal = engYears * assume.sal;
|
|
734
|
+
payrollStarted = true;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function renderPayrollValue() {
|
|
740
|
+
const el = $('pmValue');
|
|
741
|
+
if (!el) return;
|
|
742
|
+
const whole = Math.floor(payrollTotal);
|
|
743
|
+
const cents = Math.floor((payrollTotal - whole) * 100);
|
|
744
|
+
el.innerHTML = `$${comma(whole)}<span class="pm-cents">.${String(cents).padStart(2, '0')}</span>`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function payrollTick(ts) {
|
|
748
|
+
if (payrollLastTs) {
|
|
749
|
+
// Clamp dt so a backgrounded/throttled tab doesn't dump a huge lump sum on
|
|
750
|
+
// refocus (rAF pauses in hidden tabs). Smooth climbing only.
|
|
751
|
+
const dt = Math.min((ts - payrollLastTs) / 1000, 1);
|
|
752
|
+
payrollTotal += payrollRate * dt;
|
|
753
|
+
}
|
|
754
|
+
payrollLastTs = ts;
|
|
755
|
+
renderPayrollValue();
|
|
756
|
+
requestAnimationFrame(payrollTick);
|
|
757
|
+
}
|
|
758
|
+
requestAnimationFrame(payrollTick);
|
|
759
|
+
|
|
760
|
+
// ---- model mix ------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
function renderModels(usage) {
|
|
763
|
+
const entries = Object.entries(usage.byModel || {})
|
|
764
|
+
.map(([m, v]) => ({ model: m, out: v.out }))
|
|
765
|
+
.filter((e) => e.out > 0)
|
|
766
|
+
.sort((a, b) => b.out - a.out);
|
|
767
|
+
const total = entries.reduce((s, e) => s + e.out, 0) || 1;
|
|
768
|
+
|
|
769
|
+
const bar = $('modelBar');
|
|
770
|
+
bar.innerHTML = '';
|
|
771
|
+
entries.forEach((e) => {
|
|
772
|
+
const seg = document.createElement('div');
|
|
773
|
+
seg.className = 'seg';
|
|
774
|
+
seg.style.width = (100 * e.out) / total + '%';
|
|
775
|
+
seg.style.background = colorFor(e.model).screen;
|
|
776
|
+
seg.title = `${e.model}: ${fmt(e.out)}`;
|
|
777
|
+
bar.appendChild(seg);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const legend = $('modelLegend');
|
|
781
|
+
legend.innerHTML = '';
|
|
782
|
+
entries.slice(0, 5).forEach((e) => {
|
|
783
|
+
const item = document.createElement('div');
|
|
784
|
+
item.className = 'legend-item';
|
|
785
|
+
const pct = ((100 * e.out) / total).toFixed(0);
|
|
786
|
+
item.innerHTML = `<span class="swatch" style="background:${colorFor(e.model).screen}"></span>
|
|
787
|
+
<span class="lg-name">${shortModel(e.model)}</span><span class="lg-val">${pct}% · ${fmt(e.out)}</span>`;
|
|
788
|
+
legend.appendChild(item);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function shortModel(m) {
|
|
793
|
+
return m
|
|
794
|
+
.replace('claude-', '')
|
|
795
|
+
.replace(/-\d{8}$/, '')
|
|
796
|
+
.replace(/\[1m\]/, ' 1M')
|
|
797
|
+
.replace(/^.+\//, '');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ---- departments ----------------------------------------------------------
|
|
801
|
+
// The user's main data complaint was that this panel listed every workspace
|
|
802
|
+
// they'd ever opened (derived from all-time transcript history), drowning the
|
|
803
|
+
// few they're actually working in now. We scope it to CURRENTLY-ACTIVE
|
|
804
|
+
// workspaces: a project counts if it shipped output within the recency window
|
|
805
|
+
// (usage.recentWindowDays, default 7) OR has a live agent on the floor right
|
|
806
|
+
// now. Bars are sized by recent output so the busiest *current* work reads
|
|
807
|
+
// loudest. Fail soft: if nothing is recent we fall back to all-time so the
|
|
808
|
+
// panel never goes mysteriously empty.
|
|
809
|
+
|
|
810
|
+
function renderDepts(usage) {
|
|
811
|
+
const liveProjects = new Set(
|
|
812
|
+
(STATE && STATE.live && STATE.live.agents ? STATE.live.agents : [])
|
|
813
|
+
.map((a) => a && a.project)
|
|
814
|
+
.filter(Boolean)
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
const all = Object.entries(usage.byProject || {})
|
|
818
|
+
.map(([p, v]) => ({ project: p, ...v, recentOut: v.recentOut || 0 }));
|
|
819
|
+
|
|
820
|
+
// Active = recent output, or a live agent sitting in that project right now.
|
|
821
|
+
let entries = all.filter((e) => e.recentOut > 0 || liveProjects.has(e.project));
|
|
822
|
+
let metric = 'recentOut';
|
|
823
|
+
let fallback = false;
|
|
824
|
+
if (!entries.length) {
|
|
825
|
+
// Nothing recent (e.g. a fresh boot before today's work) — show all-time so
|
|
826
|
+
// the panel still says something useful rather than going blank.
|
|
827
|
+
entries = all.filter((e) => e.out > 0);
|
|
828
|
+
metric = 'out';
|
|
829
|
+
fallback = true;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
entries.sort((a, b) => b[metric] - a[metric]);
|
|
833
|
+
const total = entries.length;
|
|
834
|
+
entries = entries.slice(0, 7);
|
|
835
|
+
const max = entries.length ? entries[0][metric] || 1 : 1;
|
|
836
|
+
|
|
837
|
+
const win = usage.recentWindowDays || 7;
|
|
838
|
+
const hintEl = $('deptHint');
|
|
839
|
+
if (hintEl) {
|
|
840
|
+
hintEl.textContent = fallback
|
|
841
|
+
? 'all-time (nothing active yet)'
|
|
842
|
+
: `active · last ${win}d`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const list = $('deptList');
|
|
846
|
+
list.innerHTML = '';
|
|
847
|
+
if (!entries.length) {
|
|
848
|
+
list.innerHTML = '<div class="dept-empty">No active workspaces yet.</div>';
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
entries.forEach((e) => {
|
|
852
|
+
const live = liveProjects.has(e.project);
|
|
853
|
+
const liveTag = live ? '<span class="dept-live" title="an agent is here now">●</span>' : '';
|
|
854
|
+
// Sort/scale by the chosen metric, but never show a bare "0" for a project
|
|
855
|
+
// that's only here because an agent is sitting in it right now (recentOut 0
|
|
856
|
+
// but live) — fall back to its all-time output, marked so it doesn't read as
|
|
857
|
+
// "shipped this week".
|
|
858
|
+
let val = e[metric];
|
|
859
|
+
let valNote = '';
|
|
860
|
+
if (metric === 'recentOut' && val === 0 && live) {
|
|
861
|
+
val = e.out;
|
|
862
|
+
valNote = '<span class="dept-alltime" title="no output yet this week — showing all-time"> all-time</span>';
|
|
863
|
+
}
|
|
864
|
+
const barW = max ? (100 * (metric === 'recentOut' ? e.recentOut : val)) / max : 0;
|
|
865
|
+
const row = document.createElement('div');
|
|
866
|
+
row.className = 'dept-row';
|
|
867
|
+
// Lead with the token number so the value reads as TOKEN OUTPUT (the thing
|
|
868
|
+
// sizing the bar). Meta line trimmed to sessions · actions for density.
|
|
869
|
+
row.innerHTML = `
|
|
870
|
+
<div class="dept-head"><span class="dept-name">${liveTag}${escapeHtml(e.project)}</span>
|
|
871
|
+
<span class="dept-val">${fmt(val)}<span class="dept-unit"> tok</span>${valNote}</span></div>
|
|
872
|
+
<div class="dept-bar"><div class="dept-fill" style="width:${barW}%"></div></div>
|
|
873
|
+
<div class="dept-meta">${e.sessions} sessions · ${fmt(e.tools)} actions</div>`;
|
|
874
|
+
list.appendChild(row);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// If we trimmed the list, note how many active workspaces exist in total.
|
|
878
|
+
if (total > entries.length) {
|
|
879
|
+
const more = document.createElement('div');
|
|
880
|
+
more.className = 'dept-more';
|
|
881
|
+
more.textContent = `+${total - entries.length} more active`;
|
|
882
|
+
list.appendChild(more);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ---- daily chart ----------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
function renderDaily(usage) {
|
|
889
|
+
const cv = $('dailyCanvas');
|
|
890
|
+
const days = (usage.daily || []).slice(-30);
|
|
891
|
+
const cssW = cv.clientWidth || 320;
|
|
892
|
+
const cssH = 90;
|
|
893
|
+
const dpr = window.devicePixelRatio || 1;
|
|
894
|
+
cv.width = cssW * dpr;
|
|
895
|
+
cv.height = cssH * dpr;
|
|
896
|
+
cv.style.height = cssH + 'px';
|
|
897
|
+
const ctx = cv.getContext('2d');
|
|
898
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
899
|
+
ctx.clearRect(0, 0, cssW, cssH);
|
|
900
|
+
if (!days.length) return;
|
|
901
|
+
|
|
902
|
+
const max = Math.max(...days.map((d) => d.out)) || 1;
|
|
903
|
+
const today = new Date().toLocaleDateString('en-CA');
|
|
904
|
+
const gap = 2;
|
|
905
|
+
const bw = Math.max(2, (cssW - gap * (days.length - 1)) / days.length);
|
|
906
|
+
days.forEach((d, i) => {
|
|
907
|
+
const h = Math.max(1, (d.out / max) * (cssH - 14));
|
|
908
|
+
const x = i * (bw + gap);
|
|
909
|
+
const y = cssH - h - 12;
|
|
910
|
+
ctx.fillStyle = d.date === today ? '#39d98a' : '#3a6ea5';
|
|
911
|
+
ctx.fillRect(x, y, bw, h);
|
|
912
|
+
});
|
|
913
|
+
// baseline + max label
|
|
914
|
+
ctx.fillStyle = '#5b6478';
|
|
915
|
+
ctx.font = '11px "IBM Plex Mono", monospace';
|
|
916
|
+
ctx.fillText(`peak ${fmt(max)} tok`, 2, cssH - 1);
|
|
917
|
+
ctx.textAlign = 'right';
|
|
918
|
+
ctx.fillText(`${days.length}d`, cssW - 2, cssH - 1);
|
|
919
|
+
ctx.textAlign = 'left';
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ---- ledger ---------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
function renderLedger(usage) {
|
|
925
|
+
const L = usage.lifetime;
|
|
926
|
+
const rows = [
|
|
927
|
+
['Output tokens', fmt(L.out)],
|
|
928
|
+
['Input tokens', fmt(L.in)],
|
|
929
|
+
['Cache reads', fmt(L.cr)],
|
|
930
|
+
['Assistant turns', comma(L.msgs)],
|
|
931
|
+
['Tool actions', comma(L.tools)],
|
|
932
|
+
['Subagents hired', comma(L.agents)],
|
|
933
|
+
['Sessions worked', comma(L.sessions)],
|
|
934
|
+
['Active days', `${usage.activeDays}`],
|
|
935
|
+
['First day on the job', usage.firstDay || '—'],
|
|
936
|
+
];
|
|
937
|
+
const el = $('ledger');
|
|
938
|
+
el.innerHTML = rows
|
|
939
|
+
.map(([k, v]) => `<div class="ledger-row"><span>${k}</span><b>${v}</b></div>`)
|
|
940
|
+
.join('');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---- utils ----------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
function escapeHtml(s) {
|
|
946
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ---- boot -----------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
bindSlider('sTok', 'tok', (v) => comma(v));
|
|
952
|
+
bindSlider('sHrs', 'hrs', (v) => String(v));
|
|
953
|
+
bindSlider('sDays', 'days', (v) => String(v));
|
|
954
|
+
bindSlider('sSal', 'sal', (v) => '$' + Math.round(v / 1000) + 'k');
|
|
955
|
+
|
|
956
|
+
// ---- sidebar view toggle: Now (default) | Analytics ----------------------
|
|
957
|
+
// "Now" = current activity (live burn, generating count, working/idle split,
|
|
958
|
+
// recent tool calls). "Analytics" = the relocated windowed/historical panels
|
|
959
|
+
// (effective team size, eng-years, payroll-equiv + meter, model mix, depts,
|
|
960
|
+
// daily). Default is Now. ponytail: persisted to localStorage; falls back to
|
|
961
|
+
// in-memory if storage is unavailable (private mode / quota).
|
|
962
|
+
(function wireViewToggle() {
|
|
963
|
+
const nowBtn = $('vtNow');
|
|
964
|
+
const anaBtn = $('vtAnalytics');
|
|
965
|
+
const nowView = $('viewNow');
|
|
966
|
+
const anaView = $('viewAnalytics');
|
|
967
|
+
if (!nowBtn || !anaBtn || !nowView || !anaView) return;
|
|
968
|
+
|
|
969
|
+
function readPref() {
|
|
970
|
+
try {
|
|
971
|
+
return localStorage.getItem('agency.view') === 'analytics' ? 'analytics' : 'now';
|
|
972
|
+
} catch {
|
|
973
|
+
return 'now';
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
function setView(view) {
|
|
977
|
+
const analytics = view === 'analytics';
|
|
978
|
+
nowView.classList.toggle('hidden', analytics);
|
|
979
|
+
anaView.classList.toggle('hidden', !analytics);
|
|
980
|
+
nowBtn.classList.toggle('active', !analytics);
|
|
981
|
+
anaBtn.classList.toggle('active', analytics);
|
|
982
|
+
nowBtn.setAttribute('aria-selected', String(!analytics));
|
|
983
|
+
anaBtn.setAttribute('aria-selected', String(analytics));
|
|
984
|
+
try {
|
|
985
|
+
localStorage.setItem('agency.view', view);
|
|
986
|
+
} catch {
|
|
987
|
+
/* in-memory only */
|
|
988
|
+
}
|
|
989
|
+
// Analytics has canvases (heads, daily) sized to clientWidth — they render to
|
|
990
|
+
// 0px while hidden, so re-render on reveal to size them correctly.
|
|
991
|
+
if (analytics && STATE) {
|
|
992
|
+
renderManpower();
|
|
993
|
+
renderDaily(STATE.usage);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
nowBtn.addEventListener('click', () => setView('now'));
|
|
997
|
+
anaBtn.addEventListener('click', () => setView('analytics'));
|
|
998
|
+
setView(readPref());
|
|
999
|
+
})();
|
|
1000
|
+
|
|
1001
|
+
// ---- audio controls -------------------------------------------------------
|
|
1002
|
+
// Two independent channels (music + SFX) with a now-playing label, mounted into
|
|
1003
|
+
// the topbar. Self-contained in audio-controls.js; AudioContext is created/resumed
|
|
1004
|
+
// only on a user gesture (a toggle click), per browser autoplay rules.
|
|
1005
|
+
initAudioControls();
|
|
1006
|
+
|
|
1007
|
+
window.addEventListener('resize', () => {
|
|
1008
|
+
if (STATE) {
|
|
1009
|
+
renderManpower();
|
|
1010
|
+
renderDaily(STATE.usage);
|
|
1011
|
+
renderBurnSpark();
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
if (mockEnabled) {
|
|
1016
|
+
const sub = document.querySelector('.brand-sub');
|
|
1017
|
+
if (sub) sub.textContent = 'MOCK MODE — synthetic data';
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
poll();
|
|
1021
|
+
setInterval(poll, 3000);
|