@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/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
|
+
}
|