@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
@@ -0,0 +1,361 @@
1
+ // transcript.js — read-only "peek" into a Claude Code session's transcript so
2
+ // the UI can show the last few turns of a chat. Like the rest of Agency this
3
+ // NEVER writes to or executes against the session: getTranscript only reads the
4
+ // trailing slice of the on-disk JSONL transcript and returns a `resumeCmd`
5
+ // STRING for the UI to display/copy (it is never run here).
6
+ //
7
+ // Transcript path mapping mirrors lib/live.js: Claude encodes a session's cwd
8
+ // into the projects dir name by replacing every '/' and '.' with '-', and names
9
+ // the transcript <sessionId>.jsonl inside it. We duplicate that tiny mapping
10
+ // here rather than reach into live.js internals.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+
16
+ const HOME = os.homedir();
17
+ const PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
18
+ const TAIL_BYTES = 256 * 1024; // messages + lastAction read this much from the file end
19
+ // Metrics scan a LARGER window than the message/lastAction tail: on a hyper-active
20
+ // session a few giant entries (big tool_results) can fill 256KB and crowd recent
21
+ // small assistant entries out of it, intermittently yielding 0 tool calls / 0
22
+ // tokens. Reading ~1MB keeps the 30-min counts stable while staying BOUNDED (never
23
+ // the whole file). Past this bound we conservatively undercount, never overcount.
24
+ const METRICS_BYTES = 1024 * 1024;
25
+
26
+ // Resolve a session's transcript path the way live.js does: prefer the project
27
+ // dir derived from cwd; otherwise scan the projects dir for <sessionId>.jsonl.
28
+ function resolvePath(sessionId, cwd) {
29
+ if (cwd) {
30
+ const encoded = cwd.replace(/[/.]/g, '-');
31
+ const f = path.join(PROJECTS_DIR, encoded, `${sessionId}.jsonl`);
32
+ if (fs.existsSync(f)) return f;
33
+ }
34
+ try {
35
+ for (const dir of fs.readdirSync(PROJECTS_DIR)) {
36
+ const f = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
37
+ if (fs.existsSync(f)) return f;
38
+ }
39
+ } catch {
40
+ /* ignore — missing projects dir */
41
+ }
42
+ return null;
43
+ }
44
+
45
+ // Pull the displayable text out of one transcript line's message.content, which
46
+ // is either a plain string or an array of blocks; we join the text of the
47
+ // type:"text" blocks (skipping tool_use / tool_result / thinking blocks).
48
+ function extractText(content) {
49
+ if (typeof content === 'string') return content.trim();
50
+ if (!Array.isArray(content)) return '';
51
+ const parts = [];
52
+ for (const b of content) {
53
+ if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
54
+ }
55
+ return parts.join('\n').trim();
56
+ }
57
+
58
+ // Read the trailing `bytes` of `file` (cheap; avoids loading huge transcripts),
59
+ // mirroring live.js's readSessionMeta windowing. Defaults to TAIL_BYTES; metrics
60
+ // pass METRICS_BYTES for a wider, stable window. The first line of the slice is
61
+ // likely a partial JSON line and is skipped by the parse try/catch below.
62
+ function readTail(file, bytes = TAIL_BYTES) {
63
+ let fd;
64
+ try {
65
+ const st = fs.fstatSync((fd = fs.openSync(file, 'r')));
66
+ const len = Math.min(st.size, bytes);
67
+ const buf = Buffer.alloc(len);
68
+ fs.readSync(fd, buf, 0, len, st.size - len);
69
+ return buf.toString('utf8');
70
+ } finally {
71
+ if (fd !== undefined) fs.closeSync(fd);
72
+ }
73
+ }
74
+
75
+ // Human-readable summary of one tool_use block — "what the agent is doing right
76
+ // now". Picks the most informative argument (file path, command, query, url, …)
77
+ // as the target. Returns { tool, target } or null for an unrecognized block.
78
+ function describeTool(b) {
79
+ if (!b || b.type !== 'tool_use' || !b.name) return null;
80
+ const inp = b.input || {};
81
+ const base = (p) => (typeof p === 'string' ? p.replace(/\/+$/, '').split('/').pop() || p : null);
82
+ const clip = (s, n = 60) => {
83
+ if (typeof s !== 'string') return null;
84
+ const c = s.replace(/\s+/g, ' ').trim();
85
+ return c.length > n ? c.slice(0, n - 1) + '…' : c;
86
+ };
87
+ const name = b.name;
88
+ let verb = name;
89
+ let target = null;
90
+ switch (name) {
91
+ case 'Edit':
92
+ case 'Write':
93
+ case 'NotebookEdit':
94
+ verb = name === 'Write' ? 'Writing' : 'Editing';
95
+ target = base(inp.file_path || inp.notebook_path);
96
+ break;
97
+ case 'Read':
98
+ verb = 'Reading';
99
+ target = base(inp.file_path);
100
+ break;
101
+ case 'Bash':
102
+ verb = 'Running';
103
+ target = clip(inp.command, 70);
104
+ break;
105
+ case 'Grep':
106
+ verb = 'Searching';
107
+ target = clip(inp.pattern, 40);
108
+ break;
109
+ case 'Glob':
110
+ verb = 'Globbing';
111
+ target = clip(inp.pattern, 40);
112
+ break;
113
+ case 'Task':
114
+ case 'Agent':
115
+ verb = 'Dispatching';
116
+ target = clip(inp.description || inp.subagent_type || inp.agentType, 50);
117
+ break;
118
+ case 'WebFetch':
119
+ verb = 'Fetching';
120
+ target = clip(inp.url, 50);
121
+ break;
122
+ case 'WebSearch':
123
+ verb = 'Searching the web';
124
+ target = clip(inp.query, 50);
125
+ break;
126
+ case 'TodoWrite':
127
+ verb = 'Updating the plan';
128
+ break;
129
+ default:
130
+ // MCP tools read as "mcp__server__tool" — surface a tidy name.
131
+ verb = String(name).replace(/^mcp__/, '').replace(/__/g, ' · ');
132
+ target = clip(inp.path || inp.file_path || inp.query || inp.command, 50);
133
+ }
134
+ return { tool: name, verb, target };
135
+ }
136
+
137
+ // Scan JSONL text for the agent's CURRENT action: the most recent assistant
138
+ // tool_use whose tool_result hasn't come back yet (i.e. the one genuinely in
139
+ // flight). If every tool has already returned, there is no current action and we
140
+ // return null — we never report a historical "last did X", because an idle agent
141
+ // must not look like it's still running its last-ever command.
142
+ // Caveat: this only sees the trailing ~256KB (readTail). A tool_use whose
143
+ // matching tool_result scrolled out of that window looks in-flight and may be
144
+ // reported even though it has returned; that's an accepted limitation.
145
+ // Returns { tool, verb, target } or null. Exported for unit-testing without the fs.
146
+ export function parseLastAction(text) {
147
+ const lines = text.split('\n');
148
+ const finished = new Set(); // tool_use ids that have a matching tool_result
149
+ const uses = []; // { id, desc } in file order (oldest → newest)
150
+ for (const line of lines) {
151
+ if (!line) continue;
152
+ let o;
153
+ try {
154
+ o = JSON.parse(line);
155
+ } catch {
156
+ continue;
157
+ }
158
+ const content = o.message && Array.isArray(o.message.content) ? o.message.content : null;
159
+ if (!content) continue;
160
+ if (o.type === 'assistant') {
161
+ for (const b of content) {
162
+ if (b && b.type === 'tool_use') {
163
+ const desc = describeTool(b);
164
+ if (desc) uses.push({ id: b.id || null, desc });
165
+ }
166
+ }
167
+ } else if (o.type === 'user') {
168
+ for (const b of content) {
169
+ if (b && b.type === 'tool_result' && b.tool_use_id) finished.add(b.tool_use_id);
170
+ }
171
+ }
172
+ }
173
+ if (!uses.length) return null;
174
+ // Return the newest still-in-flight tool_use (its result hasn't arrived).
175
+ for (let i = uses.length - 1; i >= 0; i--) {
176
+ if (!uses[i].id || !finished.has(uses[i].id)) return uses[i].desc;
177
+ }
178
+ // Everything has returned — the agent is idle, so there is no current action.
179
+ return null;
180
+ }
181
+
182
+ // Scan JSONL text once for recent-activity metrics over the last `windowMs`:
183
+ // `toolCalls30m` (tool_use blocks in assistant messages timestamped within the
184
+ // window) and `tokensOut30m` (their summed message.usage.output_tokens). Token
185
+ // shape mirrors usage.js: assistant lines carry o.message.usage.output_tokens
186
+ // and an o.message.content array whose type:"tool_use" blocks are the calls.
187
+ // Fail-soft: a line with a bad/absent timestamp or missing usage contributes 0,
188
+ // never throws. Window is named "30m" in the field names but honors windowMs.
189
+ // Exported so the metrics are unit-testable without the fs.
190
+ export function parseRecentMetrics(text, windowMs = 30 * 60 * 1000) {
191
+ const cutoff = Date.now() - windowMs;
192
+ let toolCalls30m = 0;
193
+ let tokensOut30m = 0;
194
+ for (const line of text.split('\n')) {
195
+ if (!line) continue;
196
+ let o;
197
+ try {
198
+ o = JSON.parse(line);
199
+ } catch {
200
+ continue; // partial line at the head of the tail window, or non-JSON
201
+ }
202
+ if (o.type !== 'assistant') continue;
203
+ const ts = o.timestamp ? new Date(o.timestamp).getTime() : NaN;
204
+ if (!Number.isFinite(ts) || ts < cutoff) continue; // bad/absent/old timestamp
205
+ const msg = o.message || {};
206
+ const out = msg.usage && msg.usage.output_tokens;
207
+ if (Number.isFinite(out)) tokensOut30m += out;
208
+ if (Array.isArray(msg.content)) {
209
+ for (const b of msg.content) {
210
+ if (b && b.type === 'tool_use') toolCalls30m++;
211
+ }
212
+ }
213
+ }
214
+ return { toolCalls30m, tokensOut30m, windowMin: Math.round(windowMs / 60000) };
215
+ }
216
+
217
+ // Parse JSONL text → the last `limit` user/assistant turns as
218
+ // [{ role, text, ts }]. Exported so the parser is unit-testable without the fs.
219
+ export function parseTranscript(text, limit = 25) {
220
+ const out = [];
221
+ for (const line of text.split('\n')) {
222
+ if (!line) continue;
223
+ let o;
224
+ try {
225
+ o = JSON.parse(line);
226
+ } catch {
227
+ continue; // partial line at the head of the tail window, or non-JSON
228
+ }
229
+ if (o.type !== 'user' && o.type !== 'assistant') continue;
230
+ const content = o.message ? o.message.content : undefined;
231
+ const txt = extractText(content);
232
+ if (!txt) continue; // e.g. a turn that was only a tool_use / tool_result
233
+ out.push({ role: o.type, text: txt, ts: o.timestamp || null });
234
+ }
235
+ return out.slice(-limit);
236
+ }
237
+
238
+ // Public API: a read-only peek at the tail of a session's transcript. Returns
239
+ // { messages, lastAction, metrics, resumeCmd, path }. `lastAction` is what the
240
+ // agent is doing right now (its in-flight tool_use) for the agent-detail
241
+ // "current task" view, or null if it's idle. `metrics` is the last-30-minutes
242
+ // activity summary ({ toolCalls30m, tokensOut30m, windowMin }). Fails soft —
243
+ // any error yields no messages / null lastAction / zeroed metrics and path:null,
244
+ // never throws.
245
+ export function getTranscript(sessionId, cwd, { limit = 25 } = {}) {
246
+ // resumeCmd is a STRING for the UI to show/copy; it is never executed here.
247
+ const resumeCmd = `cd ${cwd || '.'} && claude --resume ${sessionId}`;
248
+ const emptyMetrics = { toolCalls30m: 0, tokensOut30m: 0, windowMin: 30 };
249
+ try {
250
+ const file = resolvePath(sessionId, cwd);
251
+ if (!file) return { messages: [], lastAction: null, metrics: emptyMetrics, resumeCmd, path: null };
252
+ // Read the wider metrics window once, then derive the cheaper message/lastAction
253
+ // tail as its trailing slice — one file read, metrics get the full window.
254
+ const metricsText = readTail(file, METRICS_BYTES);
255
+ const text =
256
+ metricsText.length > TAIL_BYTES ? metricsText.slice(metricsText.length - TAIL_BYTES) : metricsText;
257
+ const messages = parseTranscript(text, limit);
258
+ const lastAction = parseLastAction(text);
259
+ const metrics = parseRecentMetrics(metricsText);
260
+ return { messages, lastAction, metrics, resumeCmd, path: file };
261
+ } catch {
262
+ return { messages: [], lastAction: null, metrics: emptyMetrics, resumeCmd, path: null };
263
+ }
264
+ }
265
+
266
+ // Tiny self-check (no test framework in this repo):
267
+ // node lib/transcript.js → run the synthetic parser assert
268
+ // node lib/transcript.js <sessionId> <cwd> → peek a real transcript
269
+ if (import.meta.url === `file://${process.argv[1]}`) {
270
+ const [sessionId, cwd] = process.argv.slice(2);
271
+ if (sessionId) {
272
+ console.log(JSON.stringify(getTranscript(sessionId, cwd), null, 2));
273
+ } else {
274
+ const assert = (await import('node:assert')).default;
275
+ const jsonl = [
276
+ JSON.stringify({ type: 'summary', summary: 'ignored' }),
277
+ JSON.stringify({ type: 'user', timestamp: 't1', message: { content: 'hi there' } }),
278
+ JSON.stringify({
279
+ type: 'assistant',
280
+ timestamp: 't2',
281
+ message: {
282
+ content: [
283
+ { type: 'text', text: 'hello' },
284
+ { type: 'tool_use', name: 'Bash', input: {} },
285
+ ],
286
+ },
287
+ }),
288
+ JSON.stringify({
289
+ type: 'user',
290
+ message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] },
291
+ }), // tool-only turn → no text → dropped
292
+ '{ this is a partial/garbage line', // unparseable → skipped
293
+ ].join('\n');
294
+
295
+ const msgs = parseTranscript(jsonl);
296
+ assert.strictEqual(msgs.length, 2, 'two text-bearing turns survive');
297
+ assert.deepStrictEqual(msgs[0], { role: 'user', text: 'hi there', ts: 't1' });
298
+ assert.deepStrictEqual(msgs[1], { role: 'assistant', text: 'hello', ts: 't2' });
299
+ assert.strictEqual(parseTranscript(jsonl, 1).length, 1, 'limit slices from the end');
300
+ assert.deepStrictEqual(parseTranscript(jsonl, 1)[0].role, 'assistant', 'keeps the LAST turn');
301
+
302
+ // parseLastAction: an in-flight Edit (no matching tool_result) wins over an
303
+ // earlier, already-returned Bash.
304
+ const actJsonl = [
305
+ JSON.stringify({
306
+ type: 'assistant',
307
+ message: { content: [{ type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'npm test' } }] },
308
+ }),
309
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'b1' }] } }),
310
+ JSON.stringify({
311
+ type: 'assistant',
312
+ message: { content: [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: '/a/b/render.js' } }] },
313
+ }),
314
+ ].join('\n');
315
+ const act = parseLastAction(actJsonl);
316
+ assert.strictEqual(act.verb, 'Editing', 'in-flight Edit is the current action');
317
+ assert.strictEqual(act.target, 'render.js', 'Edit target is the basename');
318
+ assert.strictEqual(parseLastAction('') , null, 'no actions → null');
319
+ // When everything has returned, there is no current action → null (no stale
320
+ // "last did X" fallback): an idle agent must not look like it's still running.
321
+ const doneJsonl = [
322
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: '/x/y/usage.js' } }] } }),
323
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'r1' }] } }),
324
+ ].join('\n');
325
+ assert.strictEqual(parseLastAction(doneJsonl), null, 'everything returned → no current action');
326
+
327
+ // parseRecentMetrics: an assistant entry timestamped "now" carrying usage and
328
+ // two tool_use blocks is counted; an entry from 2 hours ago is excluded.
329
+ const metricsJsonl = [
330
+ JSON.stringify({
331
+ type: 'assistant',
332
+ timestamp: new Date(Date.now()).toISOString(),
333
+ message: {
334
+ usage: { output_tokens: 123 },
335
+ content: [
336
+ { type: 'text', text: 'working' },
337
+ { type: 'tool_use', id: 'm1', name: 'Read', input: { file_path: '/a/b.js' } },
338
+ { type: 'tool_use', id: 'm2', name: 'Bash', input: { command: 'ls' } },
339
+ ],
340
+ },
341
+ }),
342
+ JSON.stringify({
343
+ type: 'assistant',
344
+ timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
345
+ message: {
346
+ usage: { output_tokens: 999 },
347
+ content: [{ type: 'tool_use', id: 'old1', name: 'Edit', input: { file_path: '/c/d.js' } }],
348
+ },
349
+ }),
350
+ ].join('\n');
351
+ const m = parseRecentMetrics(metricsJsonl);
352
+ assert.strictEqual(m.toolCalls30m, 2, 'only the recent entry\'s tool_use blocks are counted');
353
+ assert.strictEqual(m.tokensOut30m, 123, 'only the recent entry\'s output_tokens are summed');
354
+ assert.strictEqual(m.windowMin, 30, 'window reported in minutes');
355
+ // Fail-soft: a bad timestamp and missing usage contribute nothing, no throw.
356
+ const badJsonl = JSON.stringify({ type: 'assistant', timestamp: 'not-a-date', message: { content: [{ type: 'tool_use', id: 'z' }] } });
357
+ assert.deepStrictEqual(parseRecentMetrics(badJsonl), { toolCalls30m: 0, tokensOut30m: 0, windowMin: 30 }, 'bad timestamp skipped');
358
+
359
+ console.log('transcript.js self-check OK');
360
+ }
361
+ }