@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,815 @@
1
+ // chat-panel.js — read-only "open this agent's status" side panel.
2
+ //
3
+ // Listens for an `agency:select` CustomEvent (dispatched by office.js when a
4
+ // desk is clicked) and slides in a panel that summarizes the selected agent's
5
+ // truthful status. For a real Claude session it leads with a STATUS + METRICS
6
+ // card (honest state from the agent itself + a 30-min activity readout fetched
7
+ // from GET /api/transcript) — NOT a dump of the conversation. Reading is
8
+ // READ-ONLY. The one action it offers is "Open in Terminal", which POSTs
9
+ // /api/resume to jump back into the session via `claude --resume`.
10
+ //
11
+ // Agent kinds it handles:
12
+ // - Claude sessions (role null/lead, source 'claude', has sessionId+cwd) →
13
+ // status + metrics card (idle or working), plus the resume footer.
14
+ // - In-process teammates (role 'teammate', kind 'teammate') → have no
15
+ // transcript of their own; show their launch brief from the team config
16
+ // instead, plus the lead's resume hint.
17
+ // - opencode / codex agents → no Claude transcript on disk; show a short note.
18
+ //
19
+ // This module owns no render/camera state; it is a pure consumer of the
20
+ // selection event, so it stays decoupled from office.js.
21
+
22
+ const PANEL_ID = 'chatPanel';
23
+
24
+ let panelEl = null;
25
+ let bodyEl = null;
26
+ let titleEl = null;
27
+ let subEl = null;
28
+ let editEl = null; // header-level rename + hide strip (rebuilt per selection)
29
+ let currentKey = null; // selection key of the agent currently shown
30
+ let reqToken = 0; // guards against out-of-order fetch responses
31
+ let refreshTimer = null; // periodic /api/transcript re-fetch while the panel is open
32
+ const REFRESH_MS = 5000; // how often to refresh the live metrics/status readout
33
+
34
+ function esc(s) {
35
+ return String(s == null ? '' : s).replace(/[&<>"]/g, (c) =>
36
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])
37
+ );
38
+ }
39
+
40
+ // Stable per-agent key, mirroring office.js's keyOf so selection lines up.
41
+ function keyOf(a) {
42
+ if (!a) return null;
43
+ return a.sessionId || (a.pid != null ? `pid:${a.pid}` : null);
44
+ }
45
+
46
+ function shortModel(m) {
47
+ if (!m) return '?';
48
+ return m
49
+ .replace(/^.+\//, '')
50
+ .replace('claude-', '')
51
+ .replace(/-\d{8}$/, '')
52
+ .replace(/\[1m\]/, ' 1M');
53
+ }
54
+
55
+ // ---- DOM scaffold ---------------------------------------------------------
56
+
57
+ function build() {
58
+ panelEl = document.createElement('aside');
59
+ panelEl.id = PANEL_ID;
60
+ panelEl.className = 'chat-panel';
61
+ panelEl.setAttribute('aria-hidden', 'true');
62
+ panelEl.innerHTML = `
63
+ <div class="cp-head">
64
+ <div class="cp-headtop">
65
+ <div class="cp-id">
66
+ <div class="cp-title" id="cpTitle">—</div>
67
+ <div class="cp-sub" id="cpSub"></div>
68
+ </div>
69
+ <button type="button" class="cp-close" id="cpClose" title="Close (Esc)" aria-label="Close">✕</button>
70
+ </div>
71
+ <div class="cp-edit" id="cpEdit"></div>
72
+ </div>
73
+ <div class="cp-body" id="cpBody"></div>`;
74
+ document.body.appendChild(panelEl);
75
+
76
+ bodyEl = panelEl.querySelector('#cpBody');
77
+ titleEl = panelEl.querySelector('#cpTitle');
78
+ subEl = panelEl.querySelector('#cpSub');
79
+ editEl = panelEl.querySelector('#cpEdit');
80
+
81
+ panelEl.querySelector('#cpClose').addEventListener('click', () => close());
82
+ // Esc closes when the panel is open.
83
+ document.addEventListener('keydown', (e) => {
84
+ if (e.key === 'Escape' && panelEl.classList.contains('open')) close();
85
+ });
86
+ }
87
+
88
+ function open() {
89
+ panelEl.classList.add('open');
90
+ panelEl.setAttribute('aria-hidden', 'false');
91
+ }
92
+
93
+ function close() {
94
+ panelEl.classList.remove('open');
95
+ panelEl.setAttribute('aria-hidden', 'true');
96
+ currentKey = null;
97
+ reqToken++; // abandon any in-flight fetch's render
98
+ stopRefresh();
99
+ }
100
+
101
+ // Stop the periodic metrics refresh (on close or before a new selection).
102
+ function stopRefresh() {
103
+ if (refreshTimer) {
104
+ clearInterval(refreshTimer);
105
+ refreshTimer = null;
106
+ }
107
+ }
108
+
109
+ // Re-fetch /api/transcript and patch the activity card's metrics + "doing now"
110
+ // line IN PLACE (no body rebuild, so a half-typed rename is undisturbed). Used
111
+ // on an interval while the panel is open so a transient 0 (e.g. a stale entry
112
+ // crowding the metrics window for one tick) can't stick until the user
113
+ // re-selects. On a transient miss we keep the last good values — never blank.
114
+ function refreshMetrics(agent, token, card) {
115
+ if (token !== reqToken) return; // selection changed — this card is stale
116
+ const url = `/api/transcript?sessionId=${encodeURIComponent(agent.sessionId)}&cwd=${encodeURIComponent(agent.cwd)}`;
117
+ fetch(url, { cache: 'no-store' })
118
+ .then((r) => r.json())
119
+ .then((data) => {
120
+ if (token !== reqToken) return;
121
+ fillActivityCard(card, agent, data && data.lastAction, data && data.metrics);
122
+ })
123
+ .catch(() => {
124
+ /* transient miss — leave the last good readout in place */
125
+ });
126
+ }
127
+
128
+ // ---- copy affordance ------------------------------------------------------
129
+
130
+ // Copy `text` to the clipboard and flash the button label. Falls back to a
131
+ // hidden textarea + execCommand when the async clipboard API is unavailable
132
+ // (e.g. non-secure contexts), so the affordance still works on 127.0.0.1.
133
+ function copyToClipboard(text, btn) {
134
+ const done = (ok) => {
135
+ if (!btn) return;
136
+ const prev = btn.dataset.label || btn.textContent;
137
+ btn.dataset.label = prev;
138
+ btn.textContent = ok ? '✓ copied' : '⚠ copy failed';
139
+ btn.classList.toggle('ok', ok);
140
+ setTimeout(() => {
141
+ btn.textContent = btn.dataset.label;
142
+ btn.classList.remove('ok');
143
+ }, 1400);
144
+ };
145
+ if (navigator.clipboard && navigator.clipboard.writeText) {
146
+ navigator.clipboard.writeText(text).then(() => done(true), () => fallbackCopy(text, done));
147
+ } else {
148
+ fallbackCopy(text, done);
149
+ }
150
+ }
151
+
152
+ function fallbackCopy(text, done) {
153
+ try {
154
+ const ta = document.createElement('textarea');
155
+ ta.value = text;
156
+ ta.style.position = 'fixed';
157
+ ta.style.opacity = '0';
158
+ document.body.appendChild(ta);
159
+ ta.focus();
160
+ ta.select();
161
+ const ok = document.execCommand('copy');
162
+ document.body.removeChild(ta);
163
+ done(ok);
164
+ } catch {
165
+ done(false);
166
+ }
167
+ }
168
+
169
+ // One copy button. Returns the element; wires the click to copy `value`.
170
+ function copyButton(label, value, title) {
171
+ const b = document.createElement('button');
172
+ b.type = 'button';
173
+ b.className = 'cp-copy';
174
+ b.textContent = label;
175
+ if (title) b.title = title;
176
+ b.addEventListener('click', () => copyToClipboard(value, b));
177
+ return b;
178
+ }
179
+
180
+ // ---- rendering ------------------------------------------------------------
181
+
182
+ function setHeader(a) {
183
+ const roleTag =
184
+ a.role === 'lead'
185
+ ? '<span class="cp-role lead">PM</span>'
186
+ : a.role === 'teammate'
187
+ ? '<span class="cp-role teammate">teammate</span>'
188
+ : '';
189
+ titleEl.innerHTML = `${esc(a.name || '—')} ${roleTag}`;
190
+ // Sub-line is project · model (the agent title — "Distinguished Eng" — was
191
+ // dropped as noise; project + model are what identify the desk).
192
+ const bits = [];
193
+ if (a.project) bits.push(`<span class="cp-dept">${esc(a.project)}</span>`);
194
+ if (a.model) bits.push(esc(shortModel(a.model)));
195
+ subEl.innerHTML = bits.join(' · ');
196
+ // The rename + hide strip lives in the header now (not the body), so the body
197
+ // is just status/transcript. Rebuilt per selection; self-gates to null.
198
+ editEl.innerHTML = '';
199
+ const cust = customizeControls(a);
200
+ if (cust) editEl.appendChild(cust);
201
+ }
202
+
203
+ // A small "footer" of actions shown beneath any content. The primary action is
204
+ // "Open in Terminal" — it jumps you back into the agent via `claude --resume`
205
+ // (the one non-read-only call; see openTerminalButton). A "copy" button copies
206
+ // the full resume command for fallback; the command itself is NOT shown (too
207
+ // small to read, and you can't act on the raw text anyway). One clean row: a
208
+ // short note, then the terminal + copy buttons.
209
+ function actionsFor(a, transcript) {
210
+ const wrap = document.createElement('div');
211
+ wrap.className = 'cp-actions';
212
+
213
+ // Resume — only for a real Claude session with an id+cwd. A running BACKGROUND
214
+ // agent can't be --resume'd; you attach to it via `claude agents` instead.
215
+ const bg = a.kind === 'background' || a.kind === 'bg';
216
+ const resumeCmd = !(a.sessionId && a.cwd)
217
+ ? null
218
+ : bg
219
+ ? `cd ${a.cwd} && claude agents --cwd ${a.cwd}`
220
+ : (transcript && transcript.resumeCmd) || `cd ${a.cwd} && claude --resume ${a.sessionId}`;
221
+ if (a.role !== 'teammate' && a.source === 'claude' && a.sessionId && a.cwd && resumeCmd) {
222
+ // "Background agent" isn't a meaningful category — it's just an agent in agent
223
+ // mode, and it IS reachable. Frame by the SPAWN relationship (was it spawned by
224
+ // someone?), never as inaccessible. The terminal button below is the jump-in.
225
+ const row = document.createElement('div');
226
+ row.className = 'cp-action-row';
227
+ const note = document.createElement('div');
228
+ note.className = 'cp-action-note';
229
+ note.textContent = bg
230
+ ? a.leadName
231
+ ? `Spawned by ${a.leadName}.`
232
+ : 'Running in agent mode.'
233
+ : 'Resume to read or message it.';
234
+ row.appendChild(note);
235
+ row.appendChild(openTerminalButton(a));
236
+ // copy still copies the FULL resume command, even though it isn't shown.
237
+ row.appendChild(copyButton('copy', resumeCmd, 'Copy the resume command'));
238
+ wrap.appendChild(row);
239
+ }
240
+
241
+ return wrap.children.length ? wrap : null;
242
+ }
243
+
244
+ // Open a terminal running `claude --resume` for this agent. This POSTs the one
245
+ // server ACTION endpoint (/api/resume) — the only non-read-only call the panel
246
+ // makes. The server validates id+cwd and spawns osascript→Terminal.
247
+ function openTerminalButton(a) {
248
+ const bg = a.kind === 'background' || a.kind === 'bg';
249
+ const b = document.createElement('button');
250
+ b.type = 'button';
251
+ b.className = 'cp-open';
252
+ b.textContent = bg ? 'Attach in Terminal ▶' : 'Open in Terminal ▶';
253
+ b.title = bg
254
+ ? 'Open a terminal and attach to this running agent (claude agents)'
255
+ : 'Open a terminal running claude --resume for this agent';
256
+ b.addEventListener('click', () => {
257
+ const prev = b.textContent;
258
+ b.disabled = true;
259
+ b.textContent = 'opening…';
260
+ fetch('/api/resume', {
261
+ method: 'POST',
262
+ headers: { 'content-type': 'application/json' },
263
+ body: JSON.stringify({ sessionId: a.sessionId, cwd: a.cwd, kind: a.kind }),
264
+ })
265
+ .then((r) => r.json().catch(() => ({})))
266
+ .then((d) => { b.textContent = d && d.ok ? '✓ opened' : '⚠ failed'; })
267
+ .catch(() => { b.textContent = '⚠ failed'; })
268
+ .finally(() => {
269
+ setTimeout(() => { b.textContent = prev; b.disabled = false; }, 1600);
270
+ });
271
+ });
272
+ return b;
273
+ }
274
+
275
+ function renderNote(html) {
276
+ const n = document.createElement('div');
277
+ n.className = 'cp-note';
278
+ n.innerHTML = html;
279
+ return n;
280
+ }
281
+
282
+ // ---- customize controls (rename + hide) -----------------------------------
283
+ // A compact "edit this agent" strip rendered IN THE HEADER (see setHeader) for
284
+ // any agent with a sessionId (claude sessions, teammates, opencode, codex): a
285
+ // slim rename field + Save and a small hide toggle on one row, so the body stays
286
+ // pure status/transcript. Both controls persist server-side via POST
287
+ // /api/agent-override and only WRITE the field they touch:
288
+ // • RENAME → { sessionId, name } (name:"" resets to the minted roster name)
289
+ // • HIDE → { sessionId, hidden } (toggles agent.hidden; office.js owns state)
290
+ // Each control flashes a transient confirmation on its own button, mirroring the
291
+ // copyButton / openTerminalButton pattern, and never throws on a failed/non-JSON
292
+ // response (we guard the .json() like openTerminalButton does).
293
+
294
+ // POST a single override field and flash `btn` with the result. `prevText` is
295
+ // restored after the flash; `body` carries only the field being changed.
296
+ function postOverride(btn, body, prevText) {
297
+ btn.disabled = true;
298
+ fetch('/api/agent-override', {
299
+ method: 'POST',
300
+ headers: { 'content-type': 'application/json' },
301
+ body: JSON.stringify(body),
302
+ })
303
+ .then((r) => r.json().catch(() => ({})))
304
+ .then((d) => { btn.textContent = d && d.ok ? '✓ saved' : '⚠ failed'; btn.classList.toggle('ok', !!(d && d.ok)); })
305
+ .catch(() => { btn.textContent = '⚠ failed'; btn.classList.remove('ok'); })
306
+ .finally(() => {
307
+ setTimeout(() => { btn.textContent = prevText; btn.classList.remove('ok'); btn.disabled = false; }, 1400);
308
+ });
309
+ }
310
+
311
+ function customizeControls(a) {
312
+ // Gate: only render for a selectable real agent carrying a sessionId.
313
+ if (!a || !a.sessionId) return null;
314
+
315
+ // One compact row: a slim rename field + Save, then a small hide toggle.
316
+ const wrap = document.createElement('div');
317
+ wrap.className = 'cp-customize cp-cust-row';
318
+
319
+ // --- rename: input pre-filled with the current name + a Save button. Saving
320
+ // an empty input clears the custom name (server resets to the minted name). ---
321
+ const input = document.createElement('input');
322
+ input.type = 'text';
323
+ input.className = 'cp-cust-input';
324
+ input.value = a.name || '';
325
+ input.placeholder = 'Rename…';
326
+ input.title = 'Rename this agent (clear to reset to its minted name)';
327
+ const save = document.createElement('button');
328
+ save.type = 'button';
329
+ save.className = 'cp-cust-save';
330
+ save.textContent = 'Save';
331
+ save.title = 'Save the new name';
332
+ const submitName = () => postOverride(save, { sessionId: a.sessionId, name: input.value.trim() }, 'Save');
333
+ save.addEventListener('click', submitName);
334
+ // Enter in the field saves too (blur is left alone so tabbing away is quiet).
335
+ input.addEventListener('keydown', (e) => {
336
+ if (e.key === 'Enter') { e.preventDefault(); submitName(); }
337
+ });
338
+ wrap.appendChild(input);
339
+ wrap.appendChild(save);
340
+
341
+ // --- hide: a small icon toggle reflecting agent.hidden. Optimistically flips
342
+ // its own glyph/label; the next /api/state poll reflects the real state. ---
343
+ const hide = document.createElement('button');
344
+ hide.type = 'button';
345
+ hide.className = 'cp-cust-hide';
346
+ const labelFor = (hidden) => (hidden ? '🙈' : '👁');
347
+ const titleFor = (hidden) => (hidden ? 'Unhide — show on the floor again' : 'Hide this agent from the office floor');
348
+ hide.textContent = labelFor(a.hidden);
349
+ hide.title = titleFor(a.hidden);
350
+ hide.addEventListener('click', () => {
351
+ const next = !a.hidden;
352
+ a.hidden = next; // optimistic; office.js reconciles on the next poll
353
+ const prev = labelFor(next);
354
+ hide.title = titleFor(next);
355
+ postOverride(hide, { sessionId: a.sessionId, hidden: next }, prev);
356
+ });
357
+ wrap.appendChild(hide);
358
+
359
+ return wrap;
360
+ }
361
+
362
+ // ---- status + metrics card ------------------------------------------------
363
+ // For EVERY real Claude session (idle OR working) the panel leads with this: a
364
+ // truthful status badge from the agent's OWN state, the task it's on, a 30-min
365
+ // activity readout (tool calls + tokens), and — ONLY when a tool is genuinely
366
+ // in flight — a live "doing now" line. We deliberately do NOT dump the
367
+ // conversation; the user wants honest status, not a transcript to read.
368
+
369
+ // The task/topic the agent is working toward — its AI chat title, the CLI task
370
+ // name, or the last user prompt, in that order of usefulness.
371
+ function taskLabel(a) {
372
+ return a.chatName || a.task || a.lastPrompt || null;
373
+ }
374
+
375
+ // Compact a count for the metrics line: <1000 as-is, else "12.3k", "1.2M".
376
+ function fmtCount(n) {
377
+ if (n == null || !isFinite(n)) return '—';
378
+ const abs = Math.abs(n);
379
+ if (abs < 1000) return String(n);
380
+ if (abs < 1e6) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
381
+ return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
382
+ }
383
+
384
+ // One-word status + matching css class for the activity dot/headline.
385
+ function statusBits(a) {
386
+ if (a.activity === 'working') return { cls: 'working', icon: '🟢', text: 'Shipping now' };
387
+ if (a.activity === 'shell') return { cls: 'shell', icon: '⚙', text: 'Running a command' };
388
+ if (a.state === 'done') return { cls: 'done', icon: '✅', text: 'Finished' };
389
+ return { cls: 'idle', icon: '💤', text: 'Idle' };
390
+ }
391
+
392
+ // Render the live "current action" line from a transcript lastAction object,
393
+ // e.g. "Editing app.js" / "Running npm test". Returns '' when we have none.
394
+ function actionLine(lastAction) {
395
+ if (!lastAction || !lastAction.verb) return '';
396
+ const verb = esc(lastAction.verb);
397
+ const target = lastAction.target ? `<span class="cp-act-target">${esc(lastAction.target)}</span>` : '';
398
+ return `${verb}${target ? ' ' + target : ''}`;
399
+ }
400
+
401
+ // The status + metrics header card. `lastAction` + `metrics` arrive with the
402
+ // transcript fetch; we render the card immediately (with both null) and patch
403
+ // them in once the fetch resolves, so the card never blocks on the network.
404
+ function renderActivityCard(a, lastAction, metrics) {
405
+ const s = statusBits(a);
406
+ const card = document.createElement('div');
407
+ card.className = `cp-activity ${s.cls}`;
408
+
409
+ const head = document.createElement('div');
410
+ head.className = 'cp-act-head';
411
+ head.innerHTML = `<span class="cp-act-dot ${s.cls}"></span><span class="cp-act-status">${s.icon} ${esc(s.text)}</span>`;
412
+ card.appendChild(head);
413
+
414
+ const task = taskLabel(a);
415
+ if (task) {
416
+ const t = document.createElement('div');
417
+ t.className = 'cp-act-task';
418
+ t.textContent = task;
419
+ card.appendChild(t);
420
+ }
421
+
422
+ // The headline 30-min activity readout; renders em-dashes until the fetch
423
+ // resolves, then patches in real counts (see show()).
424
+ const stat = document.createElement('div');
425
+ stat.className = 'cp-act-metrics';
426
+ setMetricsLine(stat, metrics);
427
+ card.appendChild(stat);
428
+
429
+ // The live "doing now" line — populated ONLY when a tool is genuinely in
430
+ // flight. Patched async; starts empty (no false "running" text on idle).
431
+ const act = document.createElement('div');
432
+ act.className = 'cp-act-now';
433
+ setActionLine(act, a, lastAction);
434
+ card.appendChild(act);
435
+
436
+ const sub = (a.subagents || []).length;
437
+ if (sub) {
438
+ const m = document.createElement('div');
439
+ m.className = 'cp-act-sub';
440
+ m.textContent = `🤖 ${sub} subagent${sub > 1 ? 's' : ''} running`;
441
+ card.appendChild(m);
442
+ }
443
+ return card;
444
+ }
445
+
446
+ // Fill the metrics readout element from a `metrics` object. Missing metrics
447
+ // degrade to em-dashes rather than vanishing, so the headline stat stays put.
448
+ function setMetricsLine(el, metrics) {
449
+ const win = (metrics && metrics.windowMin) || 30;
450
+ const calls = metrics ? fmtCount(metrics.toolCalls30m) : '—';
451
+ const toks = metrics ? fmtCount(metrics.tokensOut30m) : '—';
452
+ el.innerHTML =
453
+ `<span class="cp-act-bolt">⚡</span> ${esc(calls)} tool calls · ` +
454
+ `${esc(toks)} tokens <span class="cp-act-win">· last ${esc(win)} min</span>`;
455
+ }
456
+
457
+ // Fill the "doing now" line. CRUCIAL honesty rule: when `lastAction` is null we
458
+ // print NOTHING (the status badge already says idle/running) — UNLESS the agent
459
+ // is genuinely mid-generation (activity 'working' with no tool in flight), in
460
+ // which case a subtle "thinking…" is fair. A null action on an idle/shell agent
461
+ // must never read as "doing now …".
462
+ function setActionLine(el, a, lastAction) {
463
+ const line = actionLine(lastAction);
464
+ if (line) {
465
+ el.innerHTML = `<span class="cp-act-label">doing now</span> ${line}`;
466
+ el.classList.remove('cp-act-empty');
467
+ } else if (a.activity === 'working') {
468
+ el.innerHTML = '<span class="cp-act-label">doing now</span> <span class="cp-dim">thinking…</span>';
469
+ el.classList.remove('cp-act-empty');
470
+ } else {
471
+ // Idle / shell with no in-flight tool — say nothing here.
472
+ el.textContent = '';
473
+ el.classList.add('cp-act-empty');
474
+ }
475
+ }
476
+
477
+ // Patch an already-rendered card's metrics + action lines in place (called after
478
+ // the transcript fetch resolves). Mirrors the pre-render-then-patch pattern.
479
+ function fillActivityCard(card, a, lastAction, metrics) {
480
+ if (!card) return;
481
+ const stat = card.querySelector('.cp-act-metrics');
482
+ if (stat) setMetricsLine(stat, metrics);
483
+ const act = card.querySelector('.cp-act-now');
484
+ if (act) setActionLine(act, a, lastAction);
485
+ }
486
+
487
+ // ---- blocked-background banner --------------------------------------------
488
+ // A needsYou agent is a BACKGROUND agent blocked on the user (state:'blocked').
489
+ // Unlike awaitingReply it holds NO Stop-hook connection, so a reply box here
490
+ // couldn't reach it — the only way in is its own terminal. So instead of a reply
491
+ // box we show WHY it's blocked + point at the attach footer, reusing the amber
492
+ // reply styling so it reads as the same "waiting on you" class of signal.
493
+ function renderBlockedBanner(agent, hasAttach) {
494
+ const wrap = document.createElement('div');
495
+ wrap.className = 'cp-reply cp-blocked';
496
+ const head = document.createElement('div');
497
+ head.className = 'cp-reply-head';
498
+ head.innerHTML = '<span class="cp-reply-dot"></span> Blocked — waiting on you in its terminal.';
499
+ wrap.appendChild(head);
500
+ // Its parting question, when we have it (same source the reply box uses).
501
+ if (agent.pendingQuestion) {
502
+ const q = document.createElement('div');
503
+ q.className = 'cp-reply-q';
504
+ q.textContent = agent.pendingQuestion;
505
+ wrap.appendChild(q);
506
+ }
507
+ const hint = document.createElement('div');
508
+ hint.className = 'cp-reply-q cp-dim';
509
+ hint.textContent = hasAttach
510
+ ? 'No live chat hold here — attach in its terminal below to respond and unblock it.'
511
+ : 'No live chat hold here — reach it in the terminal where it is running to respond.';
512
+ wrap.appendChild(hint);
513
+ return wrap;
514
+ }
515
+
516
+ // ---- reply box (Control Phase-1) ------------------------------------------
517
+ // When the selected agent is paused on a Stop hook (awaitingReply), render a
518
+ // reply box ABOVE the transcript: a textarea + Send that POSTs /api/reply
519
+ // { sessionId, text }. On success the agent is resumed with the typed text and
520
+ // the box collapses to a "sent" confirmation. This is the one place the panel
521
+ // writes anything (the authorized control surface, alongside /api/resume) — and
522
+ // it only resolves an already-held hook connection, never the ~/.claude data.
523
+ function renderReplyBox(agent) {
524
+ const wrap = document.createElement('div');
525
+ wrap.className = 'cp-reply';
526
+
527
+ const head = document.createElement('div');
528
+ head.className = 'cp-reply-head';
529
+ head.innerHTML =
530
+ '<span class="cp-reply-dot"></span> Waiting on your input. Reply to resume it.';
531
+ wrap.appendChild(head);
532
+
533
+ // Show the agent's parting question only when we actually have it
534
+ // (pendingQuestion). We deliberately DON'T fall back to lastPrompt/chatName —
535
+ // those are the last *user* prompt / chat title, not what the agent is asking,
536
+ // so labelling them as its question would mislead.
537
+ if (agent.pendingQuestion) {
538
+ const ctx = document.createElement('div');
539
+ ctx.className = 'cp-reply-q';
540
+ ctx.textContent = agent.pendingQuestion;
541
+ wrap.appendChild(ctx);
542
+ }
543
+
544
+ const ta = document.createElement('textarea');
545
+ ta.className = 'cp-reply-input';
546
+ ta.rows = 3;
547
+ ta.placeholder = 'Type your reply… (⌘/Ctrl+Enter to send)';
548
+ wrap.appendChild(ta);
549
+
550
+ const row = document.createElement('div');
551
+ row.className = 'cp-reply-row';
552
+ const status = document.createElement('span');
553
+ status.className = 'cp-reply-status';
554
+ const send = document.createElement('button');
555
+ send.type = 'button';
556
+ send.className = 'cp-reply-send';
557
+ send.textContent = 'Send ▶';
558
+ row.appendChild(status);
559
+ row.appendChild(send);
560
+ wrap.appendChild(row);
561
+
562
+ const doSend = () => {
563
+ const text = ta.value.trim();
564
+ if (!text) {
565
+ ta.focus();
566
+ return;
567
+ }
568
+ send.disabled = true;
569
+ ta.disabled = true;
570
+ status.textContent = 'sending…';
571
+ status.className = 'cp-reply-status';
572
+ fetch('/api/reply', {
573
+ method: 'POST',
574
+ headers: { 'content-type': 'application/json' },
575
+ body: JSON.stringify({ sessionId: agent.sessionId, text }),
576
+ })
577
+ .then((r) => r.json().then((d) => ({ ok: r.ok, d })).catch(() => ({ ok: r.ok, d: {} })))
578
+ .then(({ ok, d }) => {
579
+ if (ok && d && d.ok) {
580
+ // Collapse to a confirmation; the next poll will drop awaitingReply.
581
+ wrap.innerHTML =
582
+ '<div class="cp-reply-sent">✓ sent — resuming the agent</div>';
583
+ } else {
584
+ status.textContent =
585
+ d && d.error === 'no agent waiting for this session'
586
+ ? 'agent already resumed or timed out'
587
+ : '⚠ failed to send';
588
+ status.className = 'cp-reply-status err';
589
+ send.disabled = false;
590
+ ta.disabled = false;
591
+ }
592
+ })
593
+ .catch(() => {
594
+ status.textContent = '⚠ failed to send';
595
+ status.className = 'cp-reply-status err';
596
+ send.disabled = false;
597
+ ta.disabled = false;
598
+ });
599
+ };
600
+
601
+ send.addEventListener('click', doSend);
602
+ ta.addEventListener('keydown', (e) => {
603
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
604
+ e.preventDefault();
605
+ doSend();
606
+ }
607
+ });
608
+ // Autofocus so the reply box is immediately typeable on selection.
609
+ setTimeout(() => ta.focus(), 0);
610
+ return wrap;
611
+ }
612
+
613
+ // The agent's most recent turn(s), so you can tell at a glance which real
614
+ // session a desk maps to (the rest of the panel is honest-status only). Renders
615
+ // the last assistant + user turn from /api/transcript's `messages`, each clipped.
616
+ function renderLastMessages(messages) {
617
+ if (!Array.isArray(messages) || !messages.length) return null;
618
+ const recent = messages.slice(-2); // last turn + the prior one for context
619
+ const card = document.createElement('div');
620
+ card.className = 'cp-lastmsg';
621
+ const head = document.createElement('div');
622
+ head.className = 'cp-lastmsg-head';
623
+ head.textContent = recent.length > 1 ? 'Last messages' : 'Last message';
624
+ card.appendChild(head);
625
+ for (const m of recent) {
626
+ const assistant = m && m.role === 'assistant';
627
+ const row = document.createElement('div');
628
+ row.className = 'cp-msg ' + (assistant ? 'cp-msg-agent' : 'cp-msg-you');
629
+ const who = document.createElement('span');
630
+ who.className = 'cp-msg-who';
631
+ who.textContent = assistant ? 'agent' : 'you';
632
+ const txt = document.createElement('div');
633
+ txt.className = 'cp-msg-text';
634
+ const t = String((m && m.text) || '').replace(/\s+/g, ' ').trim();
635
+ txt.textContent = t.length > 300 ? t.slice(0, 299) + '…' : t;
636
+ row.appendChild(who);
637
+ row.appendChild(txt);
638
+ card.appendChild(row);
639
+ }
640
+ return card;
641
+ }
642
+
643
+ // ---- selection flow -------------------------------------------------------
644
+
645
+ function show(agent) {
646
+ if (!agent) {
647
+ close();
648
+ return;
649
+ }
650
+ const key = keyOf(agent);
651
+ // Clicking the already-open agent toggles the panel shut (matches the
652
+ // renderer's tap-to-deselect feel).
653
+ if (key && key === currentKey && panelEl.classList.contains('open')) {
654
+ close();
655
+ return;
656
+ }
657
+ currentKey = key;
658
+ // Bump the request token on EVERY selection (not just the fetch path) so a
659
+ // still-in-flight Claude transcript response can't render into a later
660
+ // selection's panel (e.g. clicking a Claude agent then a teammate).
661
+ reqToken++;
662
+ stopRefresh(); // a prior agent's refresh loop must not patch this selection
663
+ setHeader(agent);
664
+ open();
665
+
666
+ // ---- in-process teammate: no transcript, show its launch brief ----------
667
+ if (agent.role === 'teammate' || agent.kind === 'teammate') {
668
+ bodyEl.innerHTML = '';
669
+ const lead = agent.leadName ? esc(agent.leadName) : 'its lead';
670
+ bodyEl.appendChild(
671
+ renderNote(`Spawned by ${lead} — part of their run. Its launch brief:`)
672
+ );
673
+ const brief = agent.lastPrompt || agent.prompt;
674
+ if (brief) {
675
+ const b = document.createElement('div');
676
+ b.className = 'cp-brief';
677
+ b.textContent = brief;
678
+ bodyEl.appendChild(b);
679
+ } else {
680
+ bodyEl.appendChild(renderNote('<span class="cp-dim">No launch brief on record.</span>'));
681
+ }
682
+ // (Rename/hide for this teammate, when it carries a sessionId-ish key, lives
683
+ // in the header strip now — see setHeader → customizeControls.)
684
+ // Reach path: a teammate runs INSIDE its lead's session, so jumping in =
685
+ // opening the lead. Reuse actionsFor with a lead-targeted pseudo-agent.
686
+ if (agent.leadSessionId && agent.cwd) {
687
+ const leadAgent = { ...agent, sessionId: agent.leadSessionId, kind: 'interactive', role: null, source: 'claude' };
688
+ const acts = actionsFor(leadAgent, null);
689
+ if (acts) bodyEl.appendChild(acts);
690
+ }
691
+ return;
692
+ }
693
+
694
+ // ---- opencode / codex: no Claude transcript, but INLINE metrics ----------
695
+ if (agent.source && agent.source !== 'claude') {
696
+ // Non-Claude agents carry their 30-min metrics INLINE on the agent object
697
+ // (their adapter computes them), so render the same status + metrics card
698
+ // straight from `agent.metrics` — no /api/transcript fetch (that path is
699
+ // Claude-only). The Claude-vs-non-Claude shape asymmetry is INTENTIONAL: we
700
+ // don't recompute Claude metrics every poll, so only non-Claude is inline.
701
+ bodyEl.innerHTML = '';
702
+ bodyEl.appendChild(renderActivityCard(agent, null, agent.metrics || null));
703
+ const acts = actionsFor(agent, null);
704
+ if (acts) bodyEl.appendChild(acts);
705
+ return;
706
+ }
707
+
708
+ // ---- needsYou: a BACKGROUND agent blocked on the USER (state:'blocked') -----
709
+ // It holds NO Stop-hook connection, so the reply box can't resume it — the only
710
+ // way in is its own terminal. Surface that ACTIONABLE path. This sits ABOVE the
711
+ // cwd guard below so a cwd-less blocked agent still gets the attach note instead
712
+ // of dead-ending at "No transcript available". (awaitingReply — interactive,
713
+ // hook-held — keeps its reply box in the card path.) Net: every amber "!" on
714
+ // the floor leads to a real next step.
715
+ if (agent.needsYou && !agent.awaitingReply) {
716
+ bodyEl.innerHTML = '';
717
+ const acts = actionsFor(agent, null); // the attach-in-terminal footer = the unblock path
718
+ const banner = renderBlockedBanner(agent, !!acts);
719
+ bodyEl.appendChild(banner);
720
+ if (acts) bodyEl.appendChild(acts);
721
+ // A blocked BACKGROUND agent still has a transcript on disk — surface its last
722
+ // message(s) too (it used to show only the attach banner), so you can see what
723
+ // it's blocked on without attaching. Fetched async, inserted just below the banner.
724
+ if (agent.source === 'claude' && agent.sessionId && agent.cwd) {
725
+ const token = reqToken; // bumped at the top of show(); a newer selection bails this
726
+ const url = `/api/transcript?sessionId=${encodeURIComponent(agent.sessionId)}&cwd=${encodeURIComponent(agent.cwd)}`;
727
+ fetch(url, { cache: 'no-store' })
728
+ .then((r) => r.json())
729
+ .then((data) => {
730
+ if (token !== reqToken || !banner.parentNode) return; // superseded / detached
731
+ const lastMsgs = renderLastMessages(data && data.messages);
732
+ if (lastMsgs) banner.insertAdjacentElement('afterend', lastMsgs);
733
+ })
734
+ .catch(() => { /* fail-soft — keep the banner */ });
735
+ }
736
+ return;
737
+ }
738
+
739
+ // ---- real Claude session: status + metrics card --------------------------
740
+ if (!agent.sessionId || !agent.cwd) {
741
+ bodyEl.innerHTML = '';
742
+ bodyEl.appendChild(renderNote('<span class="cp-dim">No transcript available for this agent.</span>'));
743
+ return;
744
+ }
745
+
746
+ // CONTEXTUAL MODE — the panel adapts to the agent's state:
747
+ // • WAITING ON YOU (awaitingReply): reply box pinned on top, then the
748
+ // status + metrics card below it so you can answer in context.
749
+ // • IDLE / WORKING / done (and NOT waiting): just the status + metrics card.
750
+ // The status + metrics card builds for EVERY real Claude session (idle
751
+ // included) — its badge is the agent's OWN honest state, not derived from the
752
+ // fetch. The reply box + card render IMMEDIATELY (before the fetch) so the
753
+ // panel is useful the instant a desk is clicked; the live metrics + "doing
754
+ // now" line patch in once the transcript fetch resolves.
755
+ const waiting = !!agent.awaitingReply;
756
+
757
+ const replyBox = waiting ? renderReplyBox(agent) : null;
758
+ // The card is pre-built (metrics + action null) so we can patch them in async.
759
+ const activityCard = renderActivityCard(agent, null, null);
760
+
761
+ bodyEl.innerHTML = '';
762
+ if (replyBox) bodyEl.appendChild(replyBox);
763
+ bodyEl.appendChild(activityCard);
764
+ const acts = actionsFor(agent, null);
765
+ if (acts) bodyEl.appendChild(acts);
766
+
767
+ const token = reqToken; // already bumped at the top of show(); guards this fetch
768
+ const url = `/api/transcript?sessionId=${encodeURIComponent(agent.sessionId)}&cwd=${encodeURIComponent(agent.cwd)}`;
769
+ fetch(url, { cache: 'no-store' })
770
+ .then((r) => r.json())
771
+ .then((data) => {
772
+ if (token !== reqToken) return; // a newer selection superseded this one
773
+ // Patch the live metrics + "doing now" line into the card. lastAction is
774
+ // null whenever no tool is in flight; setActionLine handles that honestly.
775
+ fillActivityCard(activityCard, agent, data && data.lastAction, data && data.metrics);
776
+ // Rebuild the resume footer with the transcript's resumeCmd if present.
777
+ const newActs = actionsFor(agent, data);
778
+ bodyEl.innerHTML = '';
779
+ if (replyBox) bodyEl.appendChild(replyBox);
780
+ bodyEl.appendChild(activityCard);
781
+ // The last message(s) — surfaced so a desk is identifiable as a real session.
782
+ const lastMsgs = renderLastMessages(data && data.messages);
783
+ if (lastMsgs) bodyEl.appendChild(lastMsgs);
784
+ if (newActs) bodyEl.appendChild(newActs);
785
+ // The rename/hide strip lives in the header (editEl), which this rebuild
786
+ // leaves untouched — so a half-typed rename survives the metrics patch.
787
+ })
788
+ .catch(() => {
789
+ if (token !== reqToken) return;
790
+ // Couldn't reach /api/transcript — leave the honest status badge alone and
791
+ // just clear the pending metrics to em-dashes.
792
+ fillActivityCard(activityCard, agent, null, null);
793
+ });
794
+
795
+ // Keep the readout fresh while the panel stays open: re-fetch metrics + status
796
+ // on an interval so a momentary 0 can't stick until the user re-selects. Patches
797
+ // the card in place (see refreshMetrics) without rebuilding the body.
798
+ refreshTimer = setInterval(() => refreshMetrics(agent, token, activityCard), REFRESH_MS);
799
+ }
800
+
801
+ // Normalize the event detail: accept either { agent } or the agent directly,
802
+ // and treat a null/absent agent as a deselect.
803
+ function agentFromEvent(e) {
804
+ const d = e && e.detail;
805
+ if (!d) return null;
806
+ if ('agent' in d) return d.agent;
807
+ // detail is the agent object itself (be liberal in what we accept).
808
+ return d.sessionId !== undefined || d.pid !== undefined ? d : null;
809
+ }
810
+
811
+ export function initChatPanel() {
812
+ if (panelEl) return; // idempotent
813
+ build();
814
+ window.addEventListener('agency:select', (e) => show(agentFromEvent(e)));
815
+ }