@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.
Files changed (41) hide show
  1. package/README.md +106 -0
  2. package/lib/codex.js +211 -0
  3. package/lib/control.js +168 -0
  4. package/lib/live.js +493 -0
  5. package/lib/opencode.js +447 -0
  6. package/lib/paths.js +12 -0
  7. package/lib/roster.js +204 -0
  8. package/lib/transcript.js +361 -0
  9. package/lib/usage.js +346 -0
  10. package/package.json +27 -0
  11. package/public/app.js +1021 -0
  12. package/public/audio-controls.js +165 -0
  13. package/public/avatar.js +467 -0
  14. package/public/characters/dev-auburn.json +32 -0
  15. package/public/characters/dev-auburn.png +0 -0
  16. package/public/characters/dev-beanie.json +32 -0
  17. package/public/characters/dev-beanie.png +0 -0
  18. package/public/characters/dev-glasses.json +32 -0
  19. package/public/characters/dev-glasses.png +0 -0
  20. package/public/chat-panel.css +514 -0
  21. package/public/chat-panel.js +815 -0
  22. package/public/index.html +190 -0
  23. package/public/lab.html +129 -0
  24. package/public/leaderboard.js +222 -0
  25. package/public/metric.js +34 -0
  26. package/public/mock-agents.js +70 -0
  27. package/public/mock.js +277 -0
  28. package/public/music/Console_Morning.mp3 +0 -0
  29. package/public/music/Midnight_Desk.mp3 +0 -0
  30. package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
  31. package/public/music/Three_AM_Window.mp3 +0 -0
  32. package/public/office.js +1484 -0
  33. package/public/sound.js +382 -0
  34. package/public/sprites.js +983 -0
  35. package/public/style.css +506 -0
  36. package/public/ui.js +50 -0
  37. package/scripts/_pixpng.mjs +104 -0
  38. package/scripts/animsheet.mjs +60 -0
  39. package/scripts/charsheet.mjs +61 -0
  40. package/scripts/install-hook.mjs +120 -0
  41. package/server.js +370 -0
package/lib/usage.js ADDED
@@ -0,0 +1,346 @@
1
+ // usage.js — parse Claude Code transcripts into workforce usage stats.
2
+ // Aggregates token throughput, tool actions, and subagent spawns by day,
3
+ // project, and model. Caches per-file aggregates keyed by mtime+size so a
4
+ // refresh only re-reads transcripts that actually changed.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { getOpenCodeUsage } from './opencode.js';
10
+ import { getCodexUsage } from './codex.js';
11
+ import { DATA_DIR } from './paths.js';
12
+
13
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
14
+ const CACHE_PATH = path.join(DATA_DIR, 'usage-cache.json');
15
+
16
+ // ---- per-file cache -------------------------------------------------------
17
+
18
+ let cache = { version: 3, files: {} }; // path -> { mtime, size, agg }
19
+
20
+ function loadCache() {
21
+ try {
22
+ const raw = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
23
+ if (raw && raw.version === cache.version) cache = raw;
24
+ } catch {
25
+ /* no cache yet */
26
+ }
27
+ }
28
+
29
+ function saveCache() {
30
+ try {
31
+ fs.mkdirSync(DATA_DIR, { recursive: true });
32
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(cache));
33
+ } catch {
34
+ /* best effort */
35
+ }
36
+ }
37
+
38
+ // Convert an ISO timestamp into a local YYYY-MM-DD bucket.
39
+ function localDay(ts) {
40
+ const d = new Date(ts);
41
+ if (Number.isNaN(d.getTime())) return null;
42
+ return d.toLocaleDateString('en-CA'); // YYYY-MM-DD in local tz
43
+ }
44
+
45
+ function emptyDay() {
46
+ return { out: 0, in: 0, cr: 0, cc: 0, tools: 0, msgs: 0, agents: 0 };
47
+ }
48
+
49
+ // How many days back still counts as a "currently active" workspace. The
50
+ // departments panel scopes to this so it reflects what you're working in now,
51
+ // not every repo you've ever opened. Exported so the frontend can label it.
52
+ export const RECENT_DAYS = 7;
53
+ const RECENT_MS = RECENT_DAYS * 86400e3;
54
+
55
+ // Parse a single transcript file into an aggregate record.
56
+ function parseFile(file) {
57
+ const agg = {
58
+ days: {},
59
+ models: {},
60
+ project: null,
61
+ cwd: null,
62
+ sessionId: path.basename(file, '.jsonl'),
63
+ firstTs: null,
64
+ lastTs: null,
65
+ out: 0,
66
+ in: 0,
67
+ cr: 0,
68
+ cc: 0,
69
+ tools: 0,
70
+ msgs: 0,
71
+ agents: 0,
72
+ };
73
+
74
+ let content;
75
+ try {
76
+ content = fs.readFileSync(file, 'utf8');
77
+ } catch {
78
+ return agg;
79
+ }
80
+
81
+ for (const line of content.split('\n')) {
82
+ if (!line) continue;
83
+ let o;
84
+ try {
85
+ o = JSON.parse(line);
86
+ } catch {
87
+ continue;
88
+ }
89
+
90
+ if (!agg.cwd && typeof o.cwd === 'string') {
91
+ agg.cwd = o.cwd;
92
+ agg.project = path.basename(o.cwd) || o.cwd;
93
+ }
94
+
95
+ const ts = o.timestamp;
96
+ if (ts) {
97
+ if (!agg.firstTs || ts < agg.firstTs) agg.firstTs = ts;
98
+ if (!agg.lastTs || ts > agg.lastTs) agg.lastTs = ts;
99
+ }
100
+
101
+ if (o.type !== 'assistant') continue;
102
+ const msg = o.message || {};
103
+ const u = msg.usage || {};
104
+ const model = msg.model || 'unknown';
105
+ const day = ts ? localDay(ts) : null;
106
+
107
+ const out = u.output_tokens || 0;
108
+ const inp = u.input_tokens || 0;
109
+ const cr = u.cache_read_input_tokens || 0;
110
+ const cc = u.cache_creation_input_tokens || 0;
111
+
112
+ agg.out += out;
113
+ agg.in += inp;
114
+ agg.cr += cr;
115
+ agg.cc += cc;
116
+ agg.msgs += 1;
117
+
118
+ // count tool actions + subagent spawns
119
+ let tools = 0;
120
+ let agents = 0;
121
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
122
+ for (const b of blocks) {
123
+ if (b && b.type === 'tool_use') {
124
+ tools += 1;
125
+ if (b.name === 'Task' || b.name === 'Agent') agents += 1;
126
+ }
127
+ }
128
+ agg.tools += tools;
129
+ agg.agents += agents;
130
+
131
+ if (day) {
132
+ const d = (agg.days[day] ||= emptyDay());
133
+ d.out += out;
134
+ d.in += inp;
135
+ d.cr += cr;
136
+ d.cc += cc;
137
+ d.tools += tools;
138
+ d.agents += agents;
139
+ d.msgs += 1;
140
+ }
141
+
142
+ if (!agg.models[model]) agg.models[model] = { out: 0, in: 0, msgs: 0 };
143
+ agg.models[model].out += out;
144
+ agg.models[model].in += inp;
145
+ agg.models[model].msgs += 1;
146
+ }
147
+
148
+ return agg;
149
+ }
150
+
151
+ // Walk the projects dir and return every transcript path with its stat.
152
+ function listTranscripts() {
153
+ const out = [];
154
+ let projectDirs;
155
+ try {
156
+ projectDirs = fs.readdirSync(PROJECTS_DIR);
157
+ } catch {
158
+ return out;
159
+ }
160
+ for (const dir of projectDirs) {
161
+ const full = path.join(PROJECTS_DIR, dir);
162
+ let entries;
163
+ try {
164
+ entries = fs.readdirSync(full);
165
+ } catch {
166
+ continue;
167
+ }
168
+ for (const name of entries) {
169
+ if (!name.endsWith('.jsonl')) continue;
170
+ const file = path.join(full, name);
171
+ try {
172
+ const st = fs.statSync(file);
173
+ out.push({ file, mtime: st.mtimeMs, size: st.size });
174
+ } catch {
175
+ /* skip */
176
+ }
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // ---- public API -----------------------------------------------------------
183
+
184
+ let loaded = false;
185
+
186
+ // Refresh the cache (re-parse only changed files) and return combined stats.
187
+ export function getUsage() {
188
+ if (!loaded) {
189
+ loadCache();
190
+ loaded = true;
191
+ }
192
+
193
+ const transcripts = listTranscripts();
194
+ const seen = new Set();
195
+ let dirty = false;
196
+
197
+ for (const t of transcripts) {
198
+ seen.add(t.file);
199
+ const prev = cache.files[t.file];
200
+ if (prev && prev.mtime === t.mtime && prev.size === t.size) continue;
201
+ cache.files[t.file] = { mtime: t.mtime, size: t.size, agg: parseFile(t.file) };
202
+ dirty = true;
203
+ }
204
+
205
+ // drop deleted transcripts
206
+ for (const f of Object.keys(cache.files)) {
207
+ if (!seen.has(f)) {
208
+ delete cache.files[f];
209
+ dirty = true;
210
+ }
211
+ }
212
+
213
+ if (dirty) saveCache();
214
+
215
+ return combine();
216
+ }
217
+
218
+ function combine() {
219
+ const lifetime = { out: 0, in: 0, cr: 0, cc: 0, tools: 0, msgs: 0, agents: 0, sessions: 0 };
220
+ const dayMap = {};
221
+ const byModel = {};
222
+ const byProject = {};
223
+ // The oldest local-day bucket that still counts as "recent" for the
224
+ // departments scope (inclusive). Compared as a YYYY-MM-DD string.
225
+ const recentCutoffDay = new Date(Date.now() - RECENT_MS).toLocaleDateString('en-CA');
226
+
227
+ for (const { agg } of Object.values(cache.files)) {
228
+ if (!agg) continue;
229
+ lifetime.out += agg.out;
230
+ lifetime.in += agg.in;
231
+ lifetime.cr += agg.cr;
232
+ lifetime.cc += agg.cc;
233
+ lifetime.tools += agg.tools;
234
+ lifetime.msgs += agg.msgs;
235
+ lifetime.agents += agg.agents;
236
+ lifetime.sessions += 1;
237
+
238
+ for (const [day, d] of Object.entries(agg.days)) {
239
+ const t = (dayMap[day] ||= emptyDay());
240
+ t.out += d.out;
241
+ t.in += d.in;
242
+ t.cr += d.cr;
243
+ t.cc += d.cc;
244
+ t.tools += d.tools;
245
+ t.agents += d.agents;
246
+ t.msgs += d.msgs;
247
+ }
248
+
249
+ for (const [m, v] of Object.entries(agg.models)) {
250
+ const t = (byModel[m] ||= { out: 0, in: 0, msgs: 0 });
251
+ t.out += v.out;
252
+ t.in += v.in;
253
+ t.msgs += v.msgs;
254
+ }
255
+
256
+ const proj = agg.project || 'unknown';
257
+ const p = (byProject[proj] ||= { out: 0, msgs: 0, tools: 0, agents: 0, sessions: 0, lastTs: null, recentOut: 0, recentDays: 0 });
258
+ p.out += agg.out;
259
+ p.msgs += agg.msgs;
260
+ p.tools += agg.tools;
261
+ p.agents += agg.agents;
262
+ p.sessions += 1;
263
+ if (agg.lastTs && (!p.lastTs || agg.lastTs > p.lastTs)) p.lastTs = agg.lastTs;
264
+ // recentOut: output tokens this project shipped within the recency window —
265
+ // the truthful "currently active" signal the departments panel scopes to.
266
+ for (const [day, d] of Object.entries(agg.days)) {
267
+ if (day >= recentCutoffDay) {
268
+ p.recentOut += d.out;
269
+ p.recentDays += 1;
270
+ }
271
+ }
272
+ }
273
+
274
+ const oc = getOpenCodeUsage();
275
+ if (oc) mergeUsage(lifetime, dayMap, byModel, byProject, oc, recentCutoffDay);
276
+ const codex = getCodexUsage();
277
+ if (codex) mergeUsage(lifetime, dayMap, byModel, byProject, codex, recentCutoffDay);
278
+
279
+ const daily = Object.entries(dayMap)
280
+ .map(([date, v]) => ({ date, ...v }))
281
+ .sort((a, b) => (a.date < b.date ? -1 : 1));
282
+
283
+ const today = new Date().toLocaleDateString('en-CA');
284
+ const todayStats = dayMap[today] ? { date: today, ...dayMap[today] } : { date: today, ...emptyDay() };
285
+
286
+ return {
287
+ lifetime,
288
+ today: todayStats,
289
+ daily,
290
+ byModel,
291
+ byProject,
292
+ firstDay: daily.length ? daily[0].date : null,
293
+ activeDays: daily.length,
294
+ recentWindowDays: RECENT_DAYS, // window the departments panel scopes "active" to
295
+ };
296
+ }
297
+
298
+ function mergeUsage(lifetime, dayMap, byModel, byProject, oc, recentCutoffDay) {
299
+ lifetime.out += oc.lifetime.out;
300
+ lifetime.in += oc.lifetime.in;
301
+ lifetime.cr += oc.lifetime.cr;
302
+ lifetime.cc += oc.lifetime.cc;
303
+ lifetime.tools += oc.lifetime.tools;
304
+ lifetime.msgs += oc.lifetime.msgs;
305
+ lifetime.agents += oc.lifetime.agents;
306
+ lifetime.sessions += oc.lifetime.sessions;
307
+
308
+ for (const d of oc.daily || []) {
309
+ const t = (dayMap[d.date] ||= emptyDay());
310
+ t.out += d.out;
311
+ t.in += d.in;
312
+ t.cr += d.cr;
313
+ t.cc += d.cc;
314
+ t.tools += d.tools;
315
+ t.agents += d.agents;
316
+ t.msgs += d.msgs;
317
+ }
318
+
319
+ for (const [m, v] of Object.entries(oc.byModel || {})) {
320
+ const t = (byModel[m] ||= { out: 0, in: 0, msgs: 0 });
321
+ t.out += v.out;
322
+ t.in += v.in;
323
+ t.msgs += v.msgs;
324
+ }
325
+
326
+ for (const [proj, v] of Object.entries(oc.byProject || {})) {
327
+ const p = (byProject[proj] ||= { out: 0, msgs: 0, tools: 0, agents: 0, sessions: 0, lastTs: null, recentOut: 0, recentDays: 0 });
328
+ p.out += v.out;
329
+ p.msgs += v.msgs;
330
+ p.tools += v.tools;
331
+ p.agents += v.agents;
332
+ p.sessions += v.sessions;
333
+ if (v.lastTs && (!p.lastTs || v.lastTs > p.lastTs)) p.lastTs = v.lastTs;
334
+ // opencode/codex adapters don't bucket recent output; approximate "active"
335
+ // from lastTs so a recently-used non-Claude workspace still shows up. Their
336
+ // lastTs is epoch-ms (codex: updated_at_ms, opencode: time_updated), NOT an
337
+ // ISO string, so normalize to a local-day key before comparing to the
338
+ // YYYY-MM-DD cutoff (a raw string-prefix compare would always be false).
339
+ if (v.recentOut) p.recentOut += v.recentOut;
340
+ else if (recentCutoffDay && v.lastTs) {
341
+ const day = new Date(v.lastTs).toLocaleDateString('en-CA');
342
+ if (day >= recentCutoffDay) p.recentOut += v.out;
343
+ }
344
+ if (v.recentDays) p.recentDays += v.recentDays;
345
+ }
346
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@henryz2004/agency",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "A live pixel-art office sim of your Claude Code, Codex, and opencode workforce.",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "agency": "server.js"
9
+ },
10
+ "files": [
11
+ "server.js",
12
+ "lib/",
13
+ "public/",
14
+ "scripts/",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "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
+ "install-hook": "node scripts/install-hook.mjs",
21
+ "charsheet": "node scripts/charsheet.mjs",
22
+ "animsheet": "node scripts/animsheet.mjs"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ }
27
+ }