@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/live.js ADDED
@@ -0,0 +1,493 @@
1
+ // live.js — discover currently-running Claude Code sessions ("agents on the
2
+ // floor"). Claude Code writes a heartbeat file per process at
3
+ // ~/.claude/sessions/<pid>.json containing startedAt, status (busy/idle), cwd,
4
+ // and kind. We validate each against a live PID, derive uptime, and look up the
5
+ // session's current model from the tail of its transcript.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { getOpenCodeLive } from './opencode.js';
12
+ import { getCodexLive } from './codex.js';
13
+ import { identityFor } from './roster.js';
14
+
15
+ const HOME = os.homedir();
16
+ const SESSIONS_DIR = path.join(HOME, '.claude', 'sessions');
17
+ const PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
18
+ const TEAMS_DIR = path.join(HOME, '.claude', 'teams');
19
+
20
+ // Codex has no live IPC ground truth — its liveness is inferred from a rolling
21
+ // 30-min DB window (codex.js), so agents flicker on/off and report conversation
22
+ // age as "uptime". It can't hit the accuracy bar, so it's benched from the LIVE
23
+ // floor. Historical token usage stays accurate via getCodexUsage()/mergeUsage in
24
+ // usage.js — only the live floor is gated here. Flip to true to restore codex
25
+ // agents on the floor (one edit, no other change needed).
26
+ const CODEX_LIVE = false;
27
+
28
+ // Terminal background-agent states. `claude agents --json` keeps a FINISHED
29
+ // background agent in its listing with state ∈ done|failed|stopped; such an
30
+ // agent must NOT render as a live worker, so the getLive() filter drops these.
31
+ // A 'blocked' (waiting-on-user) background agent is NOT terminal — it's kept even
32
+ // when pid-less and flagged needsYou:true (see the filter below + buildAgent).
33
+ // Interactive rows carry state:null and are never matched here.
34
+ const DONE_STATES = new Set(['done', 'failed', 'stopped']);
35
+ const isDoneState = (s) => !!s && DONE_STATES.has(String(s).toLowerCase());
36
+
37
+ // Is a PID currently alive? Signal 0 performs error checking without sending.
38
+ function isAlive(pid) {
39
+ if (!pid || pid <= 0) return false;
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch (e) {
44
+ return e.code === 'EPERM'; // exists but not ours
45
+ }
46
+ }
47
+
48
+ // session meta cache: sessionId -> { file, mtime, meta }
49
+ const metaCache = new Map();
50
+
51
+ // Factory (not a shared constant) so no two agents alias the same subagents array.
52
+ // inFlight is a TRI-STATE: true = a foreground tool is genuinely in flight, false
53
+ // = transcript parsed and nothing in flight, null = couldn't read/parse it (so
54
+ // activityFor falls back conservatively rather than mis-reading "parked").
55
+ const emptyMeta = () => ({ model: null, aiTitle: null, lastPrompt: null, subagents: [], teammates: [], inFlight: null });
56
+
57
+ function truncate(s, n) {
58
+ if (!s) return null;
59
+ const clean = String(s).replace(/\s+/g, ' ').trim();
60
+ return clean.length > n ? clean.slice(0, n - 1) + '…' : clean;
61
+ }
62
+
63
+ // Scan the trailing chunk of a transcript (cheap; avoids loading huge files) for
64
+ // the session's current model, AI-generated chat title, last user prompt, plus
65
+ // two kinds of spawned agent:
66
+ // - foreground subagents — `Task`/`Agent` tool_use WITHOUT run_in_background.
67
+ // Still-running ones (no matching tool_result) cluster as minions.
68
+ // - in-process teammates — `Agent` tool_use WITH run_in_background:true. These
69
+ // have no pid/heartbeat/transcript of their own; they live in the team
70
+ // config. We record their name/type here and surface them as INDIVIDUAL
71
+ // workers (liveness comes from the team config + live lead, NOT the
72
+ // unmatched-id test — a background spawn returns its tool_result immediately
73
+ // while the teammate keeps working).
74
+ function readSessionMeta(file) {
75
+ const meta = { model: null, aiTitle: null, lastPrompt: null, subagents: [], teammates: [], inFlight: null };
76
+ let fd;
77
+ try {
78
+ const st = fs.statSync(file);
79
+ const len = Math.min(st.size, 256 * 1024);
80
+ const buf = Buffer.alloc(len);
81
+ fd = fs.openSync(file, 'r');
82
+ fs.readSync(fd, buf, 0, len, st.size - len);
83
+ const lines = buf.toString('utf8').split('\n');
84
+
85
+ const spawned = new Map(); // tool_use id -> subagent type (foreground only)
86
+ const finished = new Set(); // tool_use ids that have returned
87
+ const teammates = new Map(); // teammate name -> subagent type (background)
88
+ const fgTools = new Set(); // ALL foreground tool_use ids (any tool) — in-flight signal
89
+
90
+ for (const line of lines) {
91
+ if (!line) continue;
92
+ // cheap prefilter — skip the overwhelming majority of lines
93
+ if (
94
+ line.indexOf('"model"') === -1 &&
95
+ line.indexOf('"aiTitle"') === -1 &&
96
+ line.indexOf('"lastPrompt"') === -1 &&
97
+ line.indexOf('"tool_use"') === -1 &&
98
+ line.indexOf('"tool_result"') === -1
99
+ )
100
+ continue;
101
+ let o;
102
+ try {
103
+ o = JSON.parse(line);
104
+ } catch {
105
+ continue; // partial line at the head of the tail window
106
+ }
107
+
108
+ if (o.type === 'ai-title' && o.aiTitle) {
109
+ meta.aiTitle = o.aiTitle;
110
+ continue;
111
+ }
112
+ if (o.type === 'last-prompt' && o.lastPrompt) {
113
+ meta.lastPrompt = o.lastPrompt;
114
+ continue;
115
+ }
116
+
117
+ const content = o.message && Array.isArray(o.message.content) ? o.message.content : null;
118
+ if (!content) continue;
119
+
120
+ if (o.type === 'assistant') {
121
+ if (o.message.model) meta.model = o.message.model;
122
+ for (const b of content) {
123
+ if (!b || b.type !== 'tool_use' || !b.id) continue;
124
+ const inp = b.input || {};
125
+ // Any FOREGROUND tool (run_in_background not set) is an in-flight
126
+ // candidate until its tool_result returns. Background launches get
127
+ // their result immediately, so they never count as in-flight.
128
+ if (inp.run_in_background !== true) fgTools.add(b.id);
129
+ if (b.name === 'Agent' || b.name === 'Task') {
130
+ const type = inp.subagent_type || inp.agentType || 'agent';
131
+ if (inp.run_in_background === true) {
132
+ // background teammate — keyed by the launch name so the team
133
+ // config can enrich it (color/model) downstream.
134
+ if (inp.name) teammates.set(inp.name, type);
135
+ } else {
136
+ spawned.set(b.id, type);
137
+ }
138
+ }
139
+ }
140
+ } else if (o.type === 'user') {
141
+ for (const b of content) {
142
+ if (b && b.type === 'tool_result' && b.tool_use_id) finished.add(b.tool_use_id);
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const [id, type] of spawned) {
148
+ if (!finished.has(id)) meta.subagents.push({ type });
149
+ }
150
+ for (const [name, type] of teammates) {
151
+ meta.teammates.push({ name, type });
152
+ }
153
+ // In-flight foreground tool? (set to a real boolean only on a clean parse, so
154
+ // a read error leaves inFlight:null and activityFor falls back conservatively.)
155
+ meta.inFlight = false;
156
+ for (const id of fgTools) {
157
+ if (!finished.has(id)) {
158
+ meta.inFlight = true;
159
+ break;
160
+ }
161
+ }
162
+ } catch {
163
+ /* ignore */
164
+ } finally {
165
+ if (fd !== undefined) fs.closeSync(fd);
166
+ }
167
+ meta.lastPrompt = truncate(meta.lastPrompt, 80);
168
+ return meta;
169
+ }
170
+
171
+ // Resolve a session's transcript file (named <sessionId>.jsonl under the
172
+ // projects dir) and return its meta, cached by mtime.
173
+ function sessionMetaFor(sessionId, cwd) {
174
+ // Prefer the project dir derived from cwd (Claude encodes path with dashes).
175
+ const candidates = [];
176
+ if (cwd) {
177
+ const encoded = cwd.replace(/[/.]/g, '-');
178
+ candidates.push(path.join(PROJECTS_DIR, encoded, `${sessionId}.jsonl`));
179
+ }
180
+
181
+ let file = candidates.find((f) => fs.existsSync(f));
182
+ if (!file) {
183
+ try {
184
+ for (const dir of fs.readdirSync(PROJECTS_DIR)) {
185
+ const f = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
186
+ if (fs.existsSync(f)) {
187
+ file = f;
188
+ break;
189
+ }
190
+ }
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ }
195
+ if (!file) return emptyMeta();
196
+
197
+ let mtime = 0;
198
+ try {
199
+ mtime = fs.statSync(file).mtimeMs;
200
+ } catch {
201
+ return emptyMeta();
202
+ }
203
+
204
+ const cached = metaCache.get(sessionId);
205
+ if (cached && cached.file === file && cached.mtime === mtime) return cached.meta;
206
+
207
+ const meta = readSessionMeta(file);
208
+ metaCache.set(sessionId, { file, mtime, meta });
209
+ return meta;
210
+ }
211
+
212
+ // Read the configured teams (orchestrations). Each team's config.json is the
213
+ // orchestration record: a lead (agentType:"team-lead") plus in-process member
214
+ // teammates. We keep the per-member enrichment (color/model/agentType/prompt)
215
+ // so getLive() can render background teammates as individual, well-dressed
216
+ // workers and mark the lead distinctly. Fail soft — a missing dir/file yields
217
+ // no teams, never throws.
218
+ function readTeams() {
219
+ const teams = [];
220
+ let dirs;
221
+ try {
222
+ dirs = fs.readdirSync(TEAMS_DIR);
223
+ } catch {
224
+ return teams;
225
+ }
226
+ for (const d of dirs) {
227
+ const cfg = path.join(TEAMS_DIR, d, 'config.json');
228
+ try {
229
+ const o = JSON.parse(fs.readFileSync(cfg, 'utf8'));
230
+ const members = Array.isArray(o.members) ? o.members : [];
231
+ teams.push({
232
+ name: o.name || d,
233
+ createdAt: o.createdAt || null,
234
+ leadAgentId: o.leadAgentId || null,
235
+ leadSessionId: o.leadSessionId || null,
236
+ members: members.map((m) => ({
237
+ agentId: m.agentId || null,
238
+ name: m.name,
239
+ color: m.color || null,
240
+ model: m.model || null,
241
+ agentType: m.agentType,
242
+ prompt: m.prompt || null,
243
+ backendType: m.backendType || null,
244
+ cwd: m.cwd,
245
+ joinedAt: m.joinedAt,
246
+ isLead: m.agentType === 'team-lead',
247
+ })),
248
+ });
249
+ } catch {
250
+ /* skip */
251
+ }
252
+ }
253
+ return teams;
254
+ }
255
+
256
+ // Granular activity from the per-pid heartbeat file: busy | shell | idle.
257
+ // `claude agents --json` collapses busy+shell into "busy", so we read the file
258
+ // to tell "the model is generating" (busy) from "a command is running" (shell).
259
+ function fileStatusFor(pid) {
260
+ try {
261
+ const o = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, `${pid}.json`), 'utf8'));
262
+ return o.status || null;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ // Resolve the three-way activity from the coarse CLI status + granular file
269
+ // status + the transcript in-flight signal.
270
+ function activityFor(rawStatus, fileStatus, inFlight) {
271
+ // CLI treats anything not explicitly "idle" as busy (a turn is in progress).
272
+ const inTurn = rawStatus != null && rawStatus !== 'idle';
273
+ if (!inTurn) return 'idle';
274
+ if (fileStatus === 'shell') {
275
+ // Heartbeat 'shell' can be STALE: statusUpdatedAt only marks the last status
276
+ // CHANGE — it's set when a background command launches (e.g. a
277
+ // run_in_background `bus listen`) and never cleared, while the CLI reports
278
+ // 'busy' merely because that child process is alive. Only call it 'shell' if
279
+ // a foreground tool is genuinely in flight; if the transcript says nothing is
280
+ // (inFlight === false) the agent is parked → idle. inFlight null (unreadable)
281
+ // falls back to 'shell' conservatively.
282
+ return inFlight === false ? 'idle' : 'shell';
283
+ }
284
+ if (fileStatus === 'idle') return 'idle'; // file is more granular than the CLI here
285
+ return 'working'; // file "busy" or missing → model generating
286
+ }
287
+
288
+ // Normalize one raw session record into a dashboard agent.
289
+ function buildAgent(r, now) {
290
+ const startedAt = r.startedAt || null;
291
+ const meta = r.sessionId ? sessionMetaFor(r.sessionId, r.cwd) : emptyMeta();
292
+ const fileStatus = fileStatusFor(r.pid);
293
+ const activity = activityFor(r.status, fileStatus, meta.inFlight);
294
+ return {
295
+ pid: r.pid,
296
+ sessionId: r.sessionId || null,
297
+ source: 'claude',
298
+ cwd: r.cwd || null,
299
+ project: r.cwd ? path.basename(r.cwd) : 'unknown',
300
+ kind: r.kind || 'interactive',
301
+ activity, // 'working' | 'shell' | 'idle'
302
+ status: activity === 'working' ? 'busy' : 'idle', // coarse, kept for reference
303
+ state: r.state || null, // e.g. "done" for finished background agents
304
+ needsYou: r.state === 'blocked', // background agent waiting on the USER
305
+ task: r.task || null, // CLI-provided task name (background agents)
306
+ chatName: meta.aiTitle || null, // the session's AI-generated chat title
307
+ lastPrompt: meta.lastPrompt || null, // fallback label: last user prompt
308
+ subagents: meta.subagents || [], // currently-running subagents
309
+ startedAt,
310
+ uptimeMs: startedAt ? Math.max(0, now - startedAt) : null,
311
+ model: meta.model || null,
312
+ role: null, // 'lead' once we match this session to a team's lead (below)
313
+ teamColor: null, // member.color word, for background teammates only
314
+ };
315
+ }
316
+
317
+ // Synthesize an INDIVIDUAL worker agent for an in-process teammate (a member
318
+ // launched via the Agent tool with run_in_background:true). It has no pid /
319
+ // heartbeat / transcript, so we mint a stable synthetic identity from its
320
+ // agentId and take its liveness from the live lead it belongs to. Shape matches
321
+ // buildAgent() so the merge + frontend treat it like any other worker.
322
+ function buildTeammate(member, lead, type, now) {
323
+ return {
324
+ pid: null,
325
+ sessionId: member.agentId || `${member.name}@${lead.sessionId}`, // stable key
326
+ source: 'claude',
327
+ cwd: member.cwd || lead.cwd || null,
328
+ project: member.cwd ? path.basename(member.cwd) : lead.project,
329
+ kind: 'teammate',
330
+ activity: lead.activity === 'working' ? 'working' : 'idle', // no per-agent signal; mirror the lead's pulse
331
+ status: 'idle',
332
+ state: null,
333
+ needsYou: false, // teammates never carry the user-blocked flag
334
+ task: null,
335
+ chatName: null,
336
+ lastPrompt: truncate(member.prompt, 80), // its launch brief is the best label we have
337
+ subagents: [],
338
+ startedAt: member.joinedAt || lead.startedAt || null,
339
+ uptimeMs: member.joinedAt ? Math.max(0, now - member.joinedAt) : lead.uptimeMs,
340
+ model: member.model || null,
341
+ role: 'teammate',
342
+ teamColor: member.color || null,
343
+ teammateName: member.name || null, // config label ("cc-internals"), preserved over the roster name
344
+ teammateType: type || member.agentType || null, // subagent_type ("general-purpose")
345
+ leadSessionId: lead.sessionId || null, // ties the teammate to its lead for grouping
346
+ leadName: identityFor(lead.sessionId, lead.model, lead.startedAt).name, // lead's display name (override-aware)
347
+ };
348
+ }
349
+
350
+ // Source of truth: `claude agents --json` reads live IPC state, so its
351
+ // busy/idle status is accurate even for long-running turns (unlike the session
352
+ // heartbeat files, whose statusUpdatedAt only marks the last status *change*).
353
+ function agentsFromCli() {
354
+ try {
355
+ const out = execFileSync('claude', ['agents', '--json'], {
356
+ timeout: 4000,
357
+ encoding: 'utf8',
358
+ stdio: ['ignore', 'pipe', 'ignore'],
359
+ });
360
+ const arr = JSON.parse(out);
361
+ if (!Array.isArray(arr)) return null;
362
+ return arr.map((a) => ({
363
+ pid: a.pid,
364
+ sessionId: a.sessionId,
365
+ cwd: a.cwd,
366
+ kind: a.kind,
367
+ startedAt: a.startedAt,
368
+ status: a.status,
369
+ state: a.state,
370
+ task: a.name,
371
+ }));
372
+ } catch {
373
+ return null; // fall back to session files
374
+ }
375
+ }
376
+
377
+ // Fallback: read ~/.claude/sessions/<pid>.json and validate liveness. We trust
378
+ // the file's reported status directly (no staleness override).
379
+ function agentsFromSessionFiles() {
380
+ const list = [];
381
+ let files;
382
+ try {
383
+ files = fs.readdirSync(SESSIONS_DIR);
384
+ } catch {
385
+ return list;
386
+ }
387
+ for (const f of files) {
388
+ if (!f.endsWith('.json')) continue;
389
+ let o;
390
+ try {
391
+ o = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
392
+ } catch {
393
+ continue;
394
+ }
395
+ if (!o.pid || !isAlive(o.pid)) continue;
396
+ list.push({
397
+ pid: o.pid,
398
+ sessionId: o.sessionId,
399
+ cwd: o.cwd,
400
+ kind: o.kind,
401
+ startedAt: o.startedAt,
402
+ status: o.status,
403
+ state: o.state, // session files don't carry these today, but keep the
404
+ task: o.name, // shape consistent with the CLI path (defaults to null)
405
+ });
406
+ }
407
+ return list;
408
+ }
409
+
410
+ // Expand teams into individual agents: mark any live session that is a team's
411
+ // lead with role:'lead', and synthesize one individual worker per in-process
412
+ // teammate it launched.
413
+ //
414
+ // LIVENESS: a teammate surfaces only when BOTH the team config lists it AND the
415
+ // lead's recent transcript shows a run_in_background launch of that name. The
416
+ // lead being in claudeAgents already proves the lead is alive; the launch's
417
+ // presence in the transcript tail is our "still active" proxy. We need the
418
+ // transcript signal because the team config is APPEND-ONLY — members are never
419
+ // removed when they finish, so config membership alone would resurface every
420
+ // teammate the lead ever spawned as a permanent ghost worker. Because
421
+ // readSessionMeta only scans the trailing 256 KB, a completed teammate's launch
422
+ // naturally ages out of the window and it drops off the floor; a long-idle but
423
+ // not-yet-finished teammate could age out too (see report — heuristic fork).
424
+ // This deliberately does NOT use the unmatched tool_use→tool_result test (a
425
+ // background spawn returns its tool_result immediately while the teammate keeps
426
+ // working). Fail soft: no teams / no match → leads & teammates just don't appear.
427
+ function expandTeams(claudeAgents, teams, now) {
428
+ const byLeadSession = new Map();
429
+ for (const t of teams) {
430
+ if (t.leadSessionId) byLeadSession.set(t.leadSessionId, t);
431
+ }
432
+ if (byLeadSession.size === 0) return [];
433
+
434
+ const extra = [];
435
+ for (const lead of claudeAgents) {
436
+ const team = lead.sessionId ? byLeadSession.get(lead.sessionId) : null;
437
+ if (!team) continue;
438
+ const workers = team.members.filter((m) => !m.isLead);
439
+ if (workers.length === 0) continue;
440
+
441
+ // Which teammates the lead recently launched in the background (by name).
442
+ const meta = sessionMetaFor(lead.sessionId, lead.cwd);
443
+ const launched = new Map((meta.teammates || []).map((tm) => [tm.name, tm.type]));
444
+ const seen = new Set(); // a name appears once even if the config lists it twice
445
+ const liveMates = []; // teammates actually surfaced THIS tick (launch still in the tail)
446
+ for (const m of workers) {
447
+ if (m.backendType !== 'in-process') continue; // real bg jobs already appear via the CLI
448
+ if (!launched.has(m.name) || seen.has(m.name)) continue; // config-only / aged out / dup
449
+ seen.add(m.name);
450
+ liveMates.push(buildTeammate(m, lead, launched.get(m.name), now));
451
+ }
452
+
453
+ // The crown means something: a lead wears role:'lead' ONLY while it currently
454
+ // leads >=1 LIVE teammate. A lead whose teammates have all finished / aged out
455
+ // of the transcript window keeps role:null (no crown). A teammate only ever
456
+ // appears here when its lead is live (lead ∈ claudeAgents) AND its launch is
457
+ // still in the lead's recent tail, so a teammate whose lead is gone, or that
458
+ // has aged out, is dropped — never lingers as an idle ghost worker.
459
+ if (liveMates.length > 0) {
460
+ lead.role = 'lead'; // the orchestrator/PM — rendered distinctly
461
+ extra.push(...liveMates);
462
+ }
463
+ }
464
+ return extra;
465
+ }
466
+
467
+ export function getLive() {
468
+ const now = Date.now();
469
+ const raw = agentsFromCli() || agentsFromSessionFiles();
470
+ // "Live" = not a finished background agent (done/failed/stopped), AND EITHER a
471
+ // session whose pid the OS still confirms (isAlive guards both the CLI and the
472
+ // session-file path against a dead pid) OR a 'blocked' background agent waiting
473
+ // on the user — those are often pid-less but ARE live and need attention, so
474
+ // they're kept (and flagged needsYou in buildAgent).
475
+ const claudeAgents = raw
476
+ .filter((r) => {
477
+ if (!r) return false;
478
+ if (isDoneState(r.state)) return false; // done/failed/stopped → off the floor
479
+ if (r.state === 'blocked') return true; // needs-you bg agent — keep even pid-less
480
+ return r.pid && isAlive(r.pid);
481
+ })
482
+ .map((r) => buildAgent(r, now));
483
+
484
+ const teams = readTeams();
485
+ const teammates = expandTeams(claudeAgents, teams, now);
486
+
487
+ const oc = getOpenCodeLive();
488
+ const codex = CODEX_LIVE ? getCodexLive() : { agents: [] };
489
+ const allAgents = [...claudeAgents, ...teammates, ...oc.agents, ...codex.agents]
490
+ .sort((a, b) => (b.uptimeMs || 0) - (a.uptimeMs || 0));
491
+
492
+ return { agents: allAgents, teams, now };
493
+ }