cc-agent-ui 0.2.3 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-agent-ui",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Live canvas UI for cc-agent jobs — infinite canvas, streaming output, file browser",
5
5
  "type": "module",
6
6
  "repository": {
package/public/index.html CHANGED
@@ -296,26 +296,23 @@ body {
296
296
  }
297
297
 
298
298
  /* status colors */
299
- .tcard.running { border-color: #2a3545; box-shadow: 0 0 24px var(--cyan-glow); }
299
+
300
300
  .tcard.done { border-color: #1e2d1e; box-shadow: 0 0 16px var(--green-glow); }
301
301
  .tcard.failed { border-color: #2d1e20; box-shadow: 0 0 16px var(--red-glow); }
302
302
  .tcard.pending_approval { border-color: #2d2a1e; box-shadow: 0 0 16px var(--yellow-glow); }
303
303
  .tcard.cloning { border-color: #1e2233; box-shadow: 0 0 16px rgba(97,175,239,0.1); }
304
304
 
305
- /* scan line on running */
306
- .tcard.running::before {
307
- content: '';
308
- position: absolute;
309
- left: 0; right: 0; height: 1px;
310
- background: linear-gradient(90deg, transparent 0%, var(--cyan) 50%, transparent 100%);
311
- opacity: 0.4;
312
- animation: scanline 3s linear infinite;
313
- pointer-events: none;
314
- z-index: 5;
305
+ @keyframes border-race {
306
+ 0% { box-shadow: 0 -1px 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
307
+ 25% { box-shadow: 2px 0 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
308
+ 50% { box-shadow: 0 2px 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
309
+ 75% { box-shadow: -2px 0 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
310
+ 100% { box-shadow: 0 -1px 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
315
311
  }
316
- @keyframes scanline {
317
- 0% { top: 0; }
318
- 100% { top: 100%; }
312
+
313
+ .tcard.running {
314
+ border-color: #1e3a8a;
315
+ animation: border-race 2s linear infinite;
319
316
  }
320
317
 
321
318
  /* ── Card Header ── */
@@ -390,6 +387,7 @@ body {
390
387
  }
391
388
  .card-repo-icon { color: var(--dimmer); }
392
389
  .card-branch { color: var(--purple); margin-left: auto; flex-shrink:0; }
390
+ .card-docker-badge { color: #38bdf8; font-size:9px; background: rgba(56,189,248,.12); border:1px solid rgba(56,189,248,.3); border-radius:3px; padding:1px 5px; flex-shrink:0; }
393
391
 
394
392
  /* ── Terminal body ── */
395
393
  .card-term {
@@ -409,11 +407,11 @@ body {
409
407
 
410
408
  /* Output line coloring */
411
409
  .tl { white-space: pre-wrap; word-break: break-all; }
412
- .tl-sys { color: var(--dim); } /* [cc-agent] meta lines */
410
+ .tl-sys { color: #7a8099; } /* [cc-agent] meta lines */
413
411
  .tl-ok { color: var(--green); } /* success */
414
412
  .tl-err { color: var(--red); } /* errors */
415
413
  .tl-warn { color: var(--yellow); } /* warnings */
416
- .tl-tool { color: var(--blue); } /* tool calls */
414
+ .tl-tool { color: var(--blue); opacity: 0.8; font-size: 10px; } /* tool calls */
417
415
  .tl-file { color: var(--orange); } /* file operations */
418
416
  .tl-head { color: var(--cyan); font-weight:600; } /* bold headings */
419
417
  .tl-dim { color: var(--dim); } /* faded */
@@ -649,6 +647,49 @@ body {
649
647
  text-decoration-color: var(--orange);
650
648
  }
651
649
 
650
+ /* ── Message bar (job panel) ── */
651
+ #jp-msg-bar {
652
+ border-top: 1px solid var(--border);
653
+ padding: 8px 14px;
654
+ display: flex;
655
+ gap: 8px;
656
+ flex-shrink: 0;
657
+ background: rgba(0,0,0,0.15);
658
+ }
659
+ #jp-msg-bar.hidden { display: none; }
660
+ #jp-msg-input {
661
+ flex: 1;
662
+ background: rgba(0,0,0,0.4);
663
+ border: 1px solid var(--border-hi);
664
+ border-radius: 4px;
665
+ color: var(--text);
666
+ font-family: var(--font);
667
+ font-size: 11px;
668
+ padding: 5px 10px;
669
+ outline: none;
670
+ transition: border-color 0.15s;
671
+ }
672
+ #jp-msg-input::placeholder { color: var(--dim); }
673
+ #jp-msg-input:focus { border-color: var(--cyan); }
674
+ #jp-msg-send {
675
+ font-family: var(--font); font-size: 10px;
676
+ padding: 5px 14px; border-radius: 4px; cursor: pointer;
677
+ border: 1px solid rgba(86,182,194,0.4);
678
+ background: transparent; color: var(--cyan); font-weight: 600;
679
+ transition: all 0.15s; white-space: nowrap;
680
+ }
681
+ #jp-msg-send:hover { background: rgba(86,182,194,0.12); border-color: var(--cyan); }
682
+ #jp-msg-send:disabled { opacity: 0.35; cursor: not-allowed; }
683
+
684
+ /* ── Working indicator on empty running terminal ── */
685
+ .term-working {
686
+ padding: 12px 0 4px;
687
+ display: flex; align-items: center; gap: 8px;
688
+ color: var(--dim); font-size: 10px;
689
+ }
690
+ .term-spinner { animation: spin 1.2s linear infinite; display: inline-block; }
691
+ @keyframes spin { to { transform: rotate(360deg); } }
692
+
652
693
  /* ── Empty state ── */
653
694
  #empty-state {
654
695
  position: absolute;
@@ -662,10 +703,41 @@ body {
662
703
  #empty-state .e-icon { font-size: 48px; color: var(--dimmer); margin-bottom: 4px; }
663
704
  #empty-state .e-title { font-size: 16px; font-weight:700; color: var(--dim); }
664
705
  #empty-state .e-sub { font-size: 11px; color: var(--dimmer); }
706
+ /* ── Loading overlay ──────────────────────────────────────────────────── */
707
+ #loading-overlay {
708
+ position: fixed;
709
+ inset: 0;
710
+ background: var(--bg);
711
+ display: flex;
712
+ flex-direction: column;
713
+ align-items: center;
714
+ justify-content: center;
715
+ gap: 16px;
716
+ z-index: 9999;
717
+ }
718
+ #loading-spinner {
719
+ font-size: 24px;
720
+ color: var(--cyan);
721
+ text-shadow: 0 0 12px var(--cyan);
722
+ font-family: var(--font);
723
+ line-height: 1;
724
+ }
725
+ #loading-text {
726
+ font-size: 12px;
727
+ color: var(--dim);
728
+ letter-spacing: 0.08em;
729
+ font-family: var(--font);
730
+ }
665
731
  </style>
666
732
  </head>
667
733
  <body>
668
734
 
735
+ <!-- Loading overlay -->
736
+ <div id="loading-overlay">
737
+ <div id="loading-spinner">⣾</div>
738
+ <div id="loading-text">Loading agents…</div>
739
+ </div>
740
+
669
741
  <!-- Top bar -->
670
742
  <div id="topbar">
671
743
  <div id="logo">
@@ -723,6 +795,10 @@ body {
723
795
  <div id="jp-task"></div>
724
796
  <div id="jp-actions"></div>
725
797
  <div id="jp-log"></div>
798
+ <div id="jp-msg-bar" class="hidden">
799
+ <input id="jp-msg-input" type="text" placeholder="Send message to Claude…" autocomplete="off">
800
+ <button id="jp-msg-send" onclick="jpSendMessage()">↵ Send</button>
801
+ </div>
726
802
  </div>
727
803
 
728
804
  <!-- File Browser Panel -->
@@ -820,29 +896,51 @@ viewport.addEventListener('touchmove', e => {
820
896
  }, { passive: false });
821
897
 
822
898
  // ── Log line colorizer ─────────────────────────────────────────────────────
899
+ const TOOL_ICONS = {
900
+ Read:'📖', Edit:'✏️', Write:'💾', Bash:'$', Glob:'🔍', Grep:'🔎',
901
+ WebFetch:'🌐', WebSearch:'🌐', TodoWrite:'✅', Agent:'🤖',
902
+ MultiEdit:'✏️', NotebookEdit:'📓', Dispatch:'⚡',
903
+ };
904
+
823
905
  function classifyLine(raw) {
824
906
  const s = raw.replace(/\x1b\[[0-9;]*m/g, '').trim();
825
907
  if (!s) return null;
908
+ if (s.startsWith('[tool] ')) return 'tl tl-tool';
909
+ if (s.startsWith('[you] ')) return 'tl tl-head';
826
910
  if (s.startsWith('[cc-agent]')) return 'tl tl-sys';
827
911
  if (/✓|✅|PASS|passed|merged|pushed|success/i.test(s)) return 'tl tl-ok';
828
912
  if (/✗|❌|error|Error|FAIL|failed|exception/i.test(s)) return 'tl tl-err';
829
913
  if (/⚠|warn|Warning/i.test(s)) return 'tl tl-warn';
830
- if (/^(Read|Edit|Write|Bash|Glob|Grep|WebFetch|TodoWrite|Agent)\b/.test(s)) return 'tl tl-tool';
914
+ if (/^(Read|Edit|Write|Bash|Glob|Grep|WebFetch|WebSearch|TodoWrite|Agent)\b/.test(s)) return 'tl tl-tool';
831
915
  if (/\.(ts|js|tsx|jsx|py|go|rs|md|json|yaml|sh)\b/.test(s)) return 'tl tl-file';
832
916
  if (/^#{1,3}\s|^\*\*/.test(s)) return 'tl tl-head';
833
917
  if (/^[\-–—>·]/.test(s)) return 'tl tl-dim';
834
918
  return 'tl';
835
919
  }
836
920
 
921
+ function formatLine(raw) {
922
+ const s = raw.replace(/\x1b\[[0-9;]*m/g, '');
923
+ // Enrich [tool] lines with icon
924
+ const toolMatch = s.match(/^\[tool\] (\w+)(.*)/);
925
+ if (toolMatch) {
926
+ const icon = TOOL_ICONS[toolMatch[1]] || '⚙';
927
+ return `${icon} ${toolMatch[1]}${toolMatch[2]}`;
928
+ }
929
+ return s;
930
+ }
931
+
837
932
  function appendLines(logEl, lines, isNew) {
933
+ // Remove working indicator if present
934
+ const working = logEl.querySelector('.term-working');
935
+ if (working && lines.some(l => l.trim())) working.remove();
936
+
838
937
  const frag = document.createDocumentFragment();
839
938
  for (const raw of lines) {
840
939
  const cls = classifyLine(raw);
841
940
  if (!cls) continue;
842
941
  const d = document.createElement('div');
843
942
  d.className = isNew ? cls + ' tl-new' : cls;
844
- const clean = raw.replace(/\x1b\[[0-9;]*m/g, '');
845
- // Linkify file paths
943
+ const clean = formatLine(raw);
846
944
  d.innerHTML = linkifyPaths(clean);
847
945
  if (isNew) setTimeout(() => d.classList.remove('tl-new'), 1500);
848
946
  frag.appendChild(d);
@@ -852,6 +950,18 @@ function appendLines(logEl, lines, isNew) {
852
950
  logEl.scrollTop = logEl.scrollHeight;
853
951
  }
854
952
 
953
+ function setWorkingIndicator(logEl, show) {
954
+ const existing = logEl.querySelector('.term-working');
955
+ if (show && !existing && logEl.children.length === 0) {
956
+ const d = document.createElement('div');
957
+ d.className = 'term-working';
958
+ d.innerHTML = '<span class="term-spinner">⟳</span> Claude is working…';
959
+ logEl.appendChild(d);
960
+ } else if (!show && existing) {
961
+ existing.remove();
962
+ }
963
+ }
964
+
855
965
  // ── Card factory ───────────────────────────────────────────────────────────
856
966
  function shortRepo(url) {
857
967
  if (!url) return 'local';
@@ -902,6 +1012,7 @@ function makeCard(job, n) {
902
1012
  <span class="card-repo-icon">⎇</span>
903
1013
  <span>${escHtml(repoStr)}</span>
904
1014
  ${job.branch ? `<span class="card-branch">${escHtml(job.branch)}</span>` : ''}
1015
+ ${job.dockerIsolation ? `<span class="card-docker-badge" title="Running in Docker container">🐳 isolated</span>` : ''}
905
1016
  </div>
906
1017
  <div class="card-term"></div>
907
1018
  <div class="card-foot">
@@ -926,6 +1037,7 @@ function makeSidebarItem(job) {
926
1037
  const item = document.createElement('div');
927
1038
  item.className = `job-item`;
928
1039
  item.id = `si-${job.id}`;
1040
+ item.dataset.startedAt = job.startedAt || '';
929
1041
  const status = job.status || 'unknown';
930
1042
 
931
1043
  item.innerHTML = `
@@ -1089,6 +1201,7 @@ function handleSnapshot(data) {
1089
1201
  updateCounts();
1090
1202
  applyFilter();
1091
1203
  if (Object.keys(jobs).length > 0) emptyState.style.display = 'none';
1204
+ if (typeof hideLoader === 'function') hideLoader();
1092
1205
  }
1093
1206
 
1094
1207
  function addJob(job, lines) {
@@ -1099,9 +1212,15 @@ function addJob(job, lines) {
1099
1212
  getOrCreateCol(key);
1100
1213
  insertCardInColumn(key, card, job);
1101
1214
  const sidebarItem = makeSidebarItem(job);
1102
- jobList.prepend(sidebarItem);
1215
+ const jobTime = new Date(job.startedAt || 0).getTime();
1216
+ const after = Array.from(jobList.children).find(el =>
1217
+ new Date(el.dataset.startedAt || 0).getTime() < jobTime
1218
+ );
1219
+ if (after) jobList.insertBefore(sidebarItem, after);
1220
+ else jobList.appendChild(sidebarItem);
1103
1221
  jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
1104
1222
  if (lines.length) appendLines(logEl, lines, false);
1223
+ else if (job.status === 'running' || job.status === 'cloning') setWorkingIndicator(logEl, true);
1105
1224
  updateColHeader(key);
1106
1225
  wireCardClick(card, job.id);
1107
1226
  }
@@ -1124,6 +1243,10 @@ function handleJobUpdate(data) {
1124
1243
  entry.job = { ...entry.job, ...job };
1125
1244
  const status = job.status;
1126
1245
 
1246
+ // Working indicator
1247
+ if (status === 'running' || status === 'cloning') setWorkingIndicator(entry.logEl, true);
1248
+ else setWorkingIndicator(entry.logEl, false);
1249
+
1127
1250
  // Update card class
1128
1251
  entry.card.className = `tcard ${status}`;
1129
1252
 
@@ -1154,6 +1277,9 @@ function handleJobUpdate(data) {
1154
1277
  jpMeta.querySelector('.jp-badge').className = `jp-badge badge-${status}`;
1155
1278
  jpMeta.querySelector('.jp-badge').textContent = status.replace('_', ' ');
1156
1279
  renderActions(status);
1280
+ const msgBar = $('jp-msg-bar');
1281
+ if (status === 'running') msgBar.classList.remove('hidden');
1282
+ else msgBar.classList.add('hidden');
1157
1283
  }
1158
1284
 
1159
1285
  updateColHeader(repoKey(job));
@@ -1216,6 +1342,38 @@ function jpClose() {
1216
1342
  jpCurrentId = null;
1217
1343
  }
1218
1344
 
1345
+ async function jpSendMessage() {
1346
+ const input = $('jp-msg-input');
1347
+ const btn = $('jp-msg-send');
1348
+ const text = input.value.trim();
1349
+ if (!text || !jpCurrentId) return;
1350
+ input.value = '';
1351
+ btn.disabled = true;
1352
+ try {
1353
+ const r = await fetch('/api/job/action', {
1354
+ method: 'POST',
1355
+ headers: { 'Content-Type': 'application/json' },
1356
+ body: JSON.stringify({ id: jpCurrentId, action: 'message', message: text }),
1357
+ });
1358
+ await r.json();
1359
+ } catch (e) {
1360
+ const d = document.createElement('div');
1361
+ d.className = 'tl tl-err';
1362
+ d.textContent = `[ui] Error: ${e.message}`;
1363
+ jpLog.appendChild(d);
1364
+ } finally {
1365
+ btn.disabled = false;
1366
+ input.focus();
1367
+ }
1368
+ }
1369
+
1370
+ // Enter key submits message
1371
+ document.addEventListener('DOMContentLoaded', () => {
1372
+ $('jp-msg-input').addEventListener('keydown', e => {
1373
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); jpSendMessage(); }
1374
+ });
1375
+ });
1376
+
1219
1377
  async function jpOpen(id) {
1220
1378
  const entry = jobs[id];
1221
1379
  if (!entry) return;
@@ -1233,6 +1391,7 @@ async function jpOpen(id) {
1233
1391
  <span class="jp-badge badge-${status}">${status.replace('_',' ')}</span>
1234
1392
  <span class="jp-meta-row">repo <span>${escHtml(shortRepo(job.repoUrl) || 'local')}</span>
1235
1393
  ${job.branch ? ` · branch <span>${escHtml(job.branch)}</span>` : ''}
1394
+ ${job.dockerIsolation ? ` · <span style="color:#38bdf8">🐳 docker isolated</span>` : ''}
1236
1395
  </span>
1237
1396
  <span class="jp-meta-row">id <span>${escHtml(job.id || id)}</span></span>
1238
1397
  <span class="jp-meta-row">started <span>${job.startedAt ? new Date(job.startedAt).toLocaleString() : '—'}</span></span>
@@ -1241,6 +1400,15 @@ async function jpOpen(id) {
1241
1400
  // Actions
1242
1401
  renderActions(status);
1243
1402
 
1403
+ // Show/hide message bar
1404
+ const msgBar = $('jp-msg-bar');
1405
+ if (status === 'running') {
1406
+ msgBar.classList.remove('hidden');
1407
+ setTimeout(() => $('jp-msg-input').focus(), 50);
1408
+ } else {
1409
+ msgBar.classList.add('hidden');
1410
+ }
1411
+
1244
1412
  // Load full log
1245
1413
  jpLog.innerHTML = '<div style="color:var(--dim);padding:4px">loading…</div>';
1246
1414
  try {
@@ -1447,6 +1615,32 @@ setInterval(() => {
1447
1615
  }
1448
1616
  }, 30000);
1449
1617
 
1618
+ // ── Loading overlay ────────────────────────────────────────────────────────
1619
+ (function() {
1620
+ const overlay = document.getElementById('loading-overlay');
1621
+ const spinnerEl = document.getElementById('loading-spinner');
1622
+ const textEl = document.getElementById('loading-text');
1623
+ const frames = ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'];
1624
+ let frame = 0;
1625
+ const spinInterval = setInterval(() => {
1626
+ spinnerEl.textContent = frames[frame++ % frames.length];
1627
+ }, 100);
1628
+
1629
+ let snapshotReceived = false;
1630
+ const fallbackTimer = setTimeout(() => {
1631
+ if (!snapshotReceived) {
1632
+ textEl.textContent = 'Reconnecting…';
1633
+ }
1634
+ }, 8000);
1635
+
1636
+ window.hideLoader = function() {
1637
+ snapshotReceived = true;
1638
+ clearInterval(spinInterval);
1639
+ clearTimeout(fallbackTimer);
1640
+ overlay.style.display = 'none';
1641
+ };
1642
+ })();
1643
+
1450
1644
  connect();
1451
1645
  </script>
1452
1646
  </body>
package/server.js CHANGED
@@ -220,11 +220,12 @@ const server = http.createServer((req, res) => {
220
220
  if (!job) { res.writeHead(404); res.end('job not found'); return; }
221
221
 
222
222
  if (action === 'approve') {
223
- // Set approval flag — cc-agent polls for this
223
+ // Mark approved in Redis — cc-agent MCP must be called separately to actually start it
224
+ // (cc-agent's approval is in-memory; this sets a flag for reference and for GitHub issue polling)
224
225
  const updated = { ...job, approvedAt: new Date().toISOString(), approved: true };
225
226
  await redis.set(`cca:job:${id}`, JSON.stringify(updated));
226
- // Also push approval to output list so agent sees it
227
- await redis.rPush(`cca:job:${id}:output`, '[cc-agent-ui] Job approved by user');
227
+ await redis.rPush(`cca:job:${id}:output`, '[cc-agent-ui] Approved by UI use MCP approve_job to start');
228
+ broadcast({ type: 'job_output', id, lines: ['[cc-agent-ui] Approved by UI — use MCP approve_job to start'] });
228
229
  } else if (action === 'cancel') {
229
230
  const updated = { ...job, status: 'cancelled', cancelledAt: new Date().toISOString() };
230
231
  await redis.set(`cca:job:${id}`, JSON.stringify(updated));
@@ -234,8 +235,16 @@ const server = http.createServer((req, res) => {
234
235
  await redis.set(`cca:job:${id}`, JSON.stringify(updated));
235
236
  broadcast({ type: 'job_update', job: updated });
236
237
  } else if (action === 'message') {
237
- // Send a message into the job's input channel
238
- if (message) await redis.rPush(`cca:job:${id}:input`, message);
238
+ if (message) {
239
+ // Queue for cc-agent to pick up (future: cc-agent polls cca:job:{id}:input)
240
+ await redis.rPush(`cca:job:${id}:input`, message);
241
+ // Echo to output so it's visible in terminal immediately
242
+ const line = `[you] ${message}`;
243
+ const newLen = await redis.rPush(`cca:job:${id}:output`, line);
244
+ // Advance the output length tracker so the poller doesn't re-broadcast this line
245
+ outputLengths[id] = newLen;
246
+ broadcast({ type: 'job_output', id, lines: [line] });
247
+ }
239
248
  }
240
249
 
241
250
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -264,6 +273,25 @@ wss.on('connection', async ws => {
264
273
  ws.on('close', () => { clients.delete(ws); });
265
274
  });
266
275
 
276
+ // ── Tool call synthesis from recentTools diff ──────────────────────────────
277
+ const toolTrack = {}; // id → last recentTools array
278
+
279
+ function diffTools(prevArr, currArr) {
280
+ if (!currArr?.length) return [];
281
+ if (!prevArr?.length) return currArr.slice(-3); // first snapshot: emit up to 3
282
+ if (JSON.stringify(prevArr) === JSON.stringify(currArr)) return [];
283
+ // Find how many NEW items appeared at the tail of currArr relative to prevArr.
284
+ // Strategy: find the longest suffix of prevArr that matches a prefix of the new tail.
285
+ for (let overlap = Math.min(prevArr.length, currArr.length); overlap >= 0; overlap--) {
286
+ const prevSuffix = prevArr.slice(prevArr.length - overlap);
287
+ const currPrefix = currArr.slice(0, overlap);
288
+ if (JSON.stringify(prevSuffix) === JSON.stringify(currPrefix)) {
289
+ return currArr.slice(overlap); // these are genuinely new
290
+ }
291
+ }
292
+ return currArr.slice(-Math.min(3, currArr.length)); // fallback: last 3
293
+ }
294
+
267
295
  // ── Polling: job status changes ────────────────────────────────────────────
268
296
  setInterval(async () => {
269
297
  try {
@@ -277,6 +305,7 @@ setInterval(async () => {
277
305
  if (!prev) {
278
306
  // New job
279
307
  jobCache[job.id] = job;
308
+ toolTrack[job.id] = job.recentTools || [];
280
309
  const lines = await getOutputTail(job.id, 50);
281
310
  broadcast({ type: 'job_new', job: { ...job, lines } });
282
311
  } else if (prev.status !== job.status) {
@@ -285,6 +314,22 @@ setInterval(async () => {
285
314
  } else {
286
315
  jobCache[job.id] = { ...prev, ...job };
287
316
  }
317
+ // Detect new tool calls via recentTools diff
318
+ const activeStatuses = new Set(['running', 'cloning']);
319
+ if (activeStatuses.has(job.status)) {
320
+ const prevTools = toolTrack[job.id] || [];
321
+ const currTools = job.recentTools || [];
322
+ const newTools = diffTools(prevTools, currTools);
323
+ toolTrack[job.id] = currTools;
324
+ if (newTools.length) {
325
+ const lines = newTools.map(t => `[tool] ${t}`);
326
+ broadcast({ type: 'job_output', id: job.id, lines });
327
+ // Also write to Redis output so it persists
328
+ const pipeline = redis.multi();
329
+ for (const l of lines) pipeline.rPush(`cca:job:${job.id}:output`, l);
330
+ pipeline.exec().catch(() => {});
331
+ }
332
+ }
288
333
  }
289
334
  }
290
335
  } catch (e) {
@@ -292,11 +337,21 @@ setInterval(async () => {
292
337
  }
293
338
  }, 2500);
294
339
 
295
- // ── Polling: output for active jobs ───────────────────────────────────────
340
+ // ── Polling: output for active + recently-finished jobs ───────────────────
341
+ const recentlyFinished = new Map(); // id → finishedTimestamp
296
342
  setInterval(async () => {
343
+ const now = Date.now();
297
344
  const activeStatuses = new Set(['running', 'cloning', 'pending_approval']);
298
- const active = Object.values(jobCache).filter(j => activeStatuses.has(j.status));
299
- for (const job of active) {
345
+ // Include recently finished jobs for 15s to catch tail output
346
+ const toPoll = Object.values(jobCache).filter(j =>
347
+ activeStatuses.has(j.status) ||
348
+ (recentlyFinished.has(j.id) && now - recentlyFinished.get(j.id) < 15000)
349
+ );
350
+ for (const job of toPoll) {
351
+ // Track when jobs finish
352
+ if (!activeStatuses.has(job.status) && !recentlyFinished.has(job.id)) {
353
+ recentlyFinished.set(job.id, now);
354
+ }
300
355
  try {
301
356
  const lines = await pollNewOutput(job.id);
302
357
  if (lines.length > 0) {
@@ -304,6 +359,10 @@ setInterval(async () => {
304
359
  }
305
360
  } catch {}
306
361
  }
362
+ // Clean up old entries
363
+ for (const [id, ts] of recentlyFinished) {
364
+ if (now - ts > 30000) recentlyFinished.delete(id);
365
+ }
307
366
  }, 900);
308
367
 
309
368
  // ── Start ──────────────────────────────────────────────────────────────────