cc-agent-ui 0.2.4 → 0.2.6

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.4",
3
+ "version": "0.2.6",
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
@@ -440,38 +440,50 @@ body {
440
440
  .flash-ok { animation: flash-g 0.8s ease-out; }
441
441
  .flash-err { animation: flash-r 0.8s ease-out; }
442
442
 
443
- /* ── File Browser Panel ──────────────────────────────────────────────────── */
444
- #filebrowser {
443
+ /* ── File Browser Modal ──────────────────────────────────────────────────── */
444
+ .fb-backdrop {
445
445
  position: fixed;
446
- top: 40px; right: 0; bottom: 0;
447
- width: 520px;
448
- background: var(--sidebar-bg);
449
- border-left: 1px solid var(--border-hi);
446
+ inset: 0;
447
+ background: rgba(0,0,0,0.7);
448
+ z-index: 1000;
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ }
453
+ .fb-backdrop.hidden { display: none; }
454
+
455
+ #fb-modal {
456
+ background: #0d1117;
457
+ border: 1px solid #2a2d3a;
458
+ border-radius: 8px;
459
+ width: 80vw;
460
+ max-width: 900px;
461
+ height: 80vh;
450
462
  display: flex;
451
463
  flex-direction: column;
452
- z-index: 500;
453
- transform: translateX(100%);
454
- transition: transform 0.22s cubic-bezier(.4,0,.2,1);
464
+ overflow: hidden;
465
+ box-shadow: 0 24px 64px rgba(0,0,0,0.7);
455
466
  }
456
- #filebrowser.open { transform: translateX(0); }
457
467
 
458
- #fb-topbar {
468
+ #fb-modal-header {
459
469
  display: flex;
460
470
  align-items: center;
461
471
  padding: 0 14px;
462
- height: 38px;
472
+ height: 42px;
463
473
  border-bottom: 1px solid var(--border);
464
- gap: 8px;
474
+ gap: 10px;
465
475
  flex-shrink: 0;
476
+ background: rgba(0,0,0,0.3);
466
477
  }
467
478
  #fb-path {
468
479
  flex: 1;
469
- font-size: 10px;
480
+ font-size: 11px;
470
481
  color: var(--cyan);
471
482
  overflow: hidden;
472
483
  text-overflow: ellipsis;
473
484
  white-space: nowrap;
474
485
  cursor: default;
486
+ font-family: var(--font);
475
487
  }
476
488
  #fb-close {
477
489
  background: transparent;
@@ -555,7 +567,6 @@ body {
555
567
  transition: transform 0.22s cubic-bezier(.4,0,.2,1);
556
568
  }
557
569
  #jobpanel.open { transform: translateX(0); }
558
- #jobpanel.open ~ #filebrowser { right: 600px; }
559
570
 
560
571
  #jp-topbar {
561
572
  display: flex; align-items: center;
@@ -635,14 +646,14 @@ body {
635
646
  #jp-task strong { color: var(--text); display: block; margin-bottom: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
636
647
 
637
648
  /* Clickable file paths in terminal */
638
- .fp-link {
649
+ .file-link {
639
650
  color: var(--orange);
640
651
  text-decoration: underline;
641
652
  text-decoration-color: rgba(209,154,102,0.4);
642
653
  cursor: pointer;
643
654
  border-radius: 2px;
644
655
  }
645
- .fp-link:hover {
656
+ .file-link:hover {
646
657
  background: rgba(209,154,102,0.1);
647
658
  text-decoration-color: var(--orange);
648
659
  }
@@ -801,14 +812,19 @@ body {
801
812
  </div>
802
813
  </div>
803
814
 
804
- <!-- File Browser Panel -->
805
- <div id="filebrowser">
806
- <div id="fb-topbar">
807
- <button id="fb-back" onclick="fbGoBack()">← back</button>
808
- <span id="fb-path">/</span>
809
- <button id="fb-close" onclick="fbClose()">✕</button>
815
+ <!-- File Browser Modal -->
816
+ <div id="fb-backdrop" class="fb-backdrop hidden">
817
+ <div id="fb-modal">
818
+ <div id="fb-modal-header">
819
+ <span style="color:var(--dim);font-size:12px;flex-shrink:0">📁</span>
820
+ <span id="fb-path">—</span>
821
+ <div style="display:flex;gap:6px;flex-shrink:0">
822
+ <button id="fb-back" onclick="fbGoBack()">← back</button>
823
+ <button id="fb-close" onclick="fbClose()">✕</button>
824
+ </div>
825
+ </div>
826
+ <div id="fb-body"></div>
810
827
  </div>
811
- <div id="fb-body"></div>
812
828
  </div>
813
829
 
814
830
  <script>
@@ -1355,15 +1371,7 @@ async function jpSendMessage() {
1355
1371
  headers: { 'Content-Type': 'application/json' },
1356
1372
  body: JSON.stringify({ id: jpCurrentId, action: 'message', message: text }),
1357
1373
  });
1358
- const data = await r.json();
1359
- const d = document.createElement('div');
1360
- d.className = 'tl tl-head';
1361
- d.textContent = `[you] ${text}`;
1362
- jpLog.appendChild(d);
1363
- jpLog.scrollTop = jpLog.scrollHeight;
1364
- // Also echo to card terminal
1365
- const entry = jobs[jpCurrentId];
1366
- if (entry?.logEl) appendLines(entry.logEl, [`[you] ${text}`], true);
1374
+ await r.json();
1367
1375
  } catch (e) {
1368
1376
  const d = document.createElement('div');
1369
1377
  d.className = 'tl tl-err';
@@ -1481,24 +1489,25 @@ async function jobAction(id, action) {
1481
1489
  // Wire card clicks to open job panel
1482
1490
  function wireCardClick(card, id) {
1483
1491
  card.querySelector('.card-hdr').addEventListener('click', (e) => {
1484
- if (e.target.closest('.fp-link')) return; // don't interfere with file links
1492
+ if (e.target.closest('.file-link')) return; // don't interfere with file links
1485
1493
  jpOpen(id);
1486
1494
  });
1487
1495
  }
1488
1496
 
1489
- // ── File Browser ───────────────────────────────────────────────────────────
1490
- const fb = $('filebrowser');
1497
+ // ── File Browser Modal ─────────────────────────────────────────────────────
1498
+ const fbBackdrop = $('fb-backdrop');
1491
1499
  const fbBody = $('fb-body');
1492
1500
  const fbPathEl = $('fb-path');
1493
1501
  let fbHistory = [];
1494
1502
 
1495
1503
  function fbOpen(p) {
1496
- fb.classList.add('open');
1504
+ fbBackdrop.classList.remove('hidden');
1497
1505
  fbNavigate(p);
1498
1506
  }
1499
1507
 
1500
1508
  function fbClose() {
1501
- fb.classList.remove('open');
1509
+ fbBackdrop.classList.add('hidden');
1510
+ fbHistory = [];
1502
1511
  }
1503
1512
 
1504
1513
  function fbGoBack() {
@@ -1508,19 +1517,30 @@ function fbGoBack() {
1508
1517
  }
1509
1518
  }
1510
1519
 
1520
+ function fbParentDir(p) {
1521
+ const trimmed = p.replace(/\/+$/, '');
1522
+ if (!trimmed || trimmed === '/') return null;
1523
+ const parent = trimmed.replace(/\/[^/]+$/, '') || '/';
1524
+ return parent;
1525
+ }
1526
+
1511
1527
  async function fbNavigate(p, fromBack) {
1512
1528
  if (!fromBack) fbHistory.push(p);
1513
1529
  fbPathEl.textContent = p;
1514
1530
  fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">loading…</div>';
1515
1531
  try {
1516
- const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`);
1517
- if (!res.ok) throw new Error(await res.text());
1518
- const ct = res.headers.get('content-type') || '';
1519
- if (ct.includes('application/json')) {
1520
- const data = await res.json();
1521
- if (data.type === 'dir') renderDir(data);
1532
+ const statRes = await fetch(`/api/fs/stat?path=${encodeURIComponent(p)}`);
1533
+ if (!statRes.ok) throw new Error(await statRes.text());
1534
+ const stat = await statRes.json();
1535
+ if (!stat.exists) throw new Error('not found: ' + p);
1536
+
1537
+ if (stat.type === 'dir') {
1538
+ const lsRes = await fetch(`/api/fs/ls?path=${encodeURIComponent(p)}`);
1539
+ if (!lsRes.ok) throw new Error(await lsRes.text());
1540
+ const data = await lsRes.json();
1541
+ renderDir(p, data.entries);
1522
1542
  } else {
1523
- renderFile(p, ct, res);
1543
+ renderFile(p);
1524
1544
  }
1525
1545
  } catch (e) {
1526
1546
  fbBody.innerHTML = `<div style="padding:14px;color:var(--red)">${escHtml(e.message)}</div>`;
@@ -1537,73 +1557,128 @@ function fmtSize(n) {
1537
1557
  function fileIcon(name, type) {
1538
1558
  if (type === 'dir') return '📁';
1539
1559
  const ext = name.split('.').pop().toLowerCase();
1540
- if (['js','ts','tsx','jsx','py','go','rs','sh'].includes(ext)) return '📄';
1560
+ if (['js','ts','tsx','jsx','py','go','rs','sh','clj','cljs','sql'].includes(ext)) return '📄';
1541
1561
  if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return '🖼';
1542
1562
  if (['mp4','webm','mov'].includes(ext)) return '🎬';
1543
1563
  if (['mp3','wav','ogg'].includes(ext)) return '🎵';
1544
1564
  if (ext === 'pdf') return '📋';
1545
1565
  if (['md','txt'].includes(ext)) return '📝';
1546
1566
  if (ext === 'json') return '{}';
1567
+ if (['yaml','yml'].includes(ext)) return '⚙';
1547
1568
  return '📄';
1548
1569
  }
1549
1570
 
1550
- function renderDir(data) {
1571
+ function renderDir(basePath, entries) {
1551
1572
  const frag = document.createDocumentFragment();
1552
- for (const e of data.entries) {
1573
+
1574
+ // Parent dir entry
1575
+ const parent = fbParentDir(basePath);
1576
+ if (parent) {
1577
+ const up = document.createElement('div');
1578
+ up.className = 'fb-entry';
1579
+ up.innerHTML = `<span class="fb-icon">↑</span><span class="fb-name" style="color:var(--dim)">parent directory</span><span class="fb-size"></span>`;
1580
+ up.addEventListener('click', () => fbNavigate(parent));
1581
+ frag.appendChild(up);
1582
+ }
1583
+
1584
+ for (const e of entries) {
1585
+ const entryPath = basePath.replace(/\/$/, '') + '/' + e.name;
1553
1586
  const row = document.createElement('div');
1554
1587
  row.className = `fb-entry${e.type === 'dir' ? ' is-dir' : ''}`;
1555
- row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name)}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
1556
- row.addEventListener('click', () => fbNavigate(e.path));
1588
+ row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name + (e.type === 'dir' ? '/' : ''))}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
1589
+ row.addEventListener('click', () => fbNavigate(entryPath));
1557
1590
  frag.appendChild(row);
1558
1591
  }
1592
+
1559
1593
  fbBody.innerHTML = '';
1560
- if (data.entries.length === 0) {
1594
+ if (entries.length === 0 && !parent) {
1561
1595
  fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">empty directory</div>';
1562
1596
  } else {
1563
1597
  fbBody.appendChild(frag);
1564
1598
  }
1565
1599
  }
1566
1600
 
1567
- async function renderFile(p, ct, res) {
1568
- const isText = ct.startsWith('text/') || ct.includes('json') || ct.includes('javascript') || ct.includes('xml');
1569
- const isImage = ct.startsWith('image/');
1570
- const isVideo = ct.startsWith('video/');
1571
- const isAudio = ct.startsWith('audio/');
1572
- const url = `/api/browse?path=${encodeURIComponent(p)}`;
1601
+ async function renderFile(p) {
1602
+ const ext = p.split('.').pop().toLowerCase();
1603
+ const rawUrl = `/api/fs/raw?path=${encodeURIComponent(p)}`;
1573
1604
  const div = document.createElement('div');
1574
1605
  div.id = 'fb-file-view';
1575
- if (isImage) {
1606
+
1607
+ const imageExts = ['png','jpg','jpeg','gif','svg','webp'];
1608
+ const videoExts = ['mp4','webm','mov'];
1609
+ const audioExts = ['mp3','wav','ogg'];
1610
+ const textExts = ['js','ts','tsx','jsx','py','go','rs','md','json','yaml','yml',
1611
+ 'sh','bash','html','css','txt','log','clj','cljs','sql','toml',
1612
+ 'env','gitignore','dockerfile','makefile','lock'];
1613
+
1614
+ if (imageExts.includes(ext)) {
1576
1615
  div.className = 'media-view';
1577
- div.innerHTML = `<img src="${url}" alt="${escHtml(p)}">`;
1578
- } else if (isVideo) {
1616
+ div.innerHTML = `<img src="${rawUrl}" alt="${escHtml(p)}">`;
1617
+ fbBody.innerHTML = '';
1618
+ fbBody.appendChild(div);
1619
+ } else if (videoExts.includes(ext)) {
1579
1620
  div.className = 'media-view';
1580
- div.innerHTML = `<video controls src="${url}"></video>`;
1581
- } else if (isAudio) {
1621
+ div.innerHTML = `<video controls src="${rawUrl}"></video>`;
1622
+ fbBody.innerHTML = '';
1623
+ fbBody.appendChild(div);
1624
+ } else if (audioExts.includes(ext)) {
1582
1625
  div.className = 'media-view';
1583
- div.innerHTML = `<audio controls src="${url}"></audio>`;
1584
- } else if (isText) {
1585
- const text = await res.text();
1586
- div.textContent = text;
1626
+ div.innerHTML = `<audio controls src="${rawUrl}"></audio>`;
1627
+ fbBody.innerHTML = '';
1628
+ fbBody.appendChild(div);
1629
+ } else if (textExts.includes(ext) || !ext || p.split('/').pop().indexOf('.') === -1) {
1630
+ div.style.cssText = 'padding:12px 14px;font-size:11px;line-height:1.65;white-space:pre-wrap;word-break:break-all;color:var(--text);overflow:auto;flex:1';
1631
+ div.textContent = 'loading…';
1632
+ fbBody.innerHTML = '';
1633
+ fbBody.appendChild(div);
1634
+ try {
1635
+ const r = await fetch(`/api/fs/cat?path=${encodeURIComponent(p)}`);
1636
+ if (!r.ok) { div.style.color = 'var(--red)'; div.textContent = await r.text(); return; }
1637
+ const data = await r.json();
1638
+ div.textContent = data.content;
1639
+ } catch (e) {
1640
+ div.style.color = 'var(--red)';
1641
+ div.textContent = e.message;
1642
+ }
1587
1643
  } else {
1588
- div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${url}" download style="color:var(--cyan)">download</a></div>`;
1644
+ div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${rawUrl}" download style="color:var(--cyan)">download</a></div>`;
1645
+ fbBody.innerHTML = '';
1646
+ fbBody.appendChild(div);
1589
1647
  }
1590
- fbBody.innerHTML = '';
1591
- fbBody.appendChild(div);
1592
1648
  }
1593
1649
 
1650
+ // Close modal on backdrop click or ESC
1651
+ document.addEventListener('keydown', e => {
1652
+ if (e.key === 'Escape' && !fbBackdrop.classList.contains('hidden')) fbClose();
1653
+ });
1654
+ $('fb-backdrop').addEventListener('click', e => {
1655
+ if (e.target === $('fb-backdrop')) fbClose();
1656
+ });
1657
+
1594
1658
  // ── File path detection in terminal lines ─────────────────────────────────
1595
- const PATH_RE = /((?:\/[^\s:'"<>()[\]{}\\|]+)+(?:\.[a-zA-Z0-9]+)?|~(?:\/[^\s:'"<>()[\]{}\\|]+)*)/g;
1659
+ function escAttr(s) {
1660
+ return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1661
+ }
1662
+
1663
+ // Delegated click handler for .file-link spans
1664
+ document.addEventListener('click', e => {
1665
+ const link = e.target.closest('.file-link');
1666
+ if (link) {
1667
+ e.stopPropagation();
1668
+ fbOpen(link.dataset.path);
1669
+ }
1670
+ });
1596
1671
 
1597
1672
  function linkifyPaths(text) {
1598
- // Escape then re-insert clickable spans for file paths
1599
- // Split on path patterns, escape non-path segments, wrap paths in spans
1673
+ const PATH_RE = /(\/(?:Users|home|tmp|workspace|var\/folders)[^\s"'`,;()[\]<>]{3,}|~\/[^\s"'`,;()[\]<>]+)/g;
1600
1674
  const parts = [];
1601
1675
  let last = 0;
1602
1676
  let m;
1603
1677
  PATH_RE.lastIndex = 0;
1604
1678
  while ((m = PATH_RE.exec(text)) !== null) {
1605
1679
  if (m.index > last) parts.push(escHtml(text.slice(last, m.index)));
1606
- parts.push(`<span class="fp-link" onclick="fbOpen(${JSON.stringify(m[0])})">${escHtml(m[0])}</span>`);
1680
+ const p = m[0];
1681
+ parts.push(`<span class="file-link" data-path="${escAttr(p)}">${escHtml(p)}</span>`);
1607
1682
  last = m.index + m[0].length;
1608
1683
  }
1609
1684
  if (last < text.length) parts.push(escHtml(text.slice(last)));
package/server.js CHANGED
@@ -154,10 +154,24 @@ function mimeFor(ext) {
154
154
  mp4:'video/mp4', webm:'video/webm', mov:'video/quicktime',
155
155
  mp3:'audio/mpeg', wav:'audio/wav', ogg:'audio/ogg',
156
156
  pdf:'application/pdf',
157
+ clj:'text/x-clojure', cljs:'text/x-clojure', sql:'text/x-sql',
158
+ log:'text/plain', env:'text/plain', toml:'text/x-toml',
157
159
  };
158
160
  return map[ext] || 'application/octet-stream';
159
161
  }
160
162
 
163
+ // Security: only allow paths under approved roots
164
+ const ALLOWED_ROOTS = [os.homedir(), '/tmp', '/workspace'];
165
+
166
+ function isAllowed(p) {
167
+ const resolved = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
168
+ return ALLOWED_ROOTS.some(root => resolved === root || resolved.startsWith(root + '/'));
169
+ }
170
+
171
+ function resolvePath(p) {
172
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
173
+ }
174
+
161
175
  // ── HTTP server ────────────────────────────────────────────────────────────
162
176
  const server = http.createServer((req, res) => {
163
177
  const url = new URL(req.url, `http://localhost`);
@@ -172,8 +186,8 @@ const server = http.createServer((req, res) => {
172
186
  // List directory or read file
173
187
  const p = url.searchParams.get('path');
174
188
  if (!p) { res.writeHead(400); res.end('missing path'); return; }
175
- // Resolve ~ and normalize
176
- const resolved = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
189
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
190
+ const resolved = resolvePath(p);
177
191
  try {
178
192
  const stat = fs.statSync(resolved);
179
193
  if (stat.isDirectory()) {
@@ -195,6 +209,67 @@ const server = http.createServer((req, res) => {
195
209
  res.writeHead(404); res.end(e.message);
196
210
  }
197
211
 
212
+ } else if (url.pathname === '/api/fs/stat') {
213
+ const p = url.searchParams.get('path');
214
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
215
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
216
+ const resolved = resolvePath(p);
217
+ try {
218
+ const stat = fs.statSync(resolved);
219
+ res.writeHead(200, { 'Content-Type': 'application/json' });
220
+ res.end(JSON.stringify({ exists: true, type: stat.isDirectory() ? 'dir' : 'file', size: stat.size }));
221
+ } catch {
222
+ res.writeHead(200, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ exists: false }));
224
+ }
225
+
226
+ } else if (url.pathname === '/api/fs/ls') {
227
+ const p = url.searchParams.get('path');
228
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
229
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
230
+ const resolved = resolvePath(p);
231
+ try {
232
+ const entries = fs.readdirSync(resolved, { withFileTypes: true }).map(e => ({
233
+ name: e.name,
234
+ type: e.isDirectory() ? 'dir' : 'file',
235
+ size: e.isFile() ? (() => { try { return fs.statSync(path.join(resolved, e.name)).size; } catch { return 0; } })() : null,
236
+ ext: path.extname(e.name).slice(1).toLowerCase(),
237
+ })).sort((a,b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
238
+ res.writeHead(200, { 'Content-Type': 'application/json' });
239
+ res.end(JSON.stringify({ entries }));
240
+ } catch (e) {
241
+ res.writeHead(404); res.end(e.message);
242
+ }
243
+
244
+ } else if (url.pathname === '/api/fs/cat') {
245
+ const p = url.searchParams.get('path');
246
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
247
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
248
+ const resolved = resolvePath(p);
249
+ try {
250
+ const stat = fs.statSync(resolved);
251
+ if (stat.size > 1048576) { res.writeHead(400); res.end('file too large (>1MB)'); return; }
252
+ const content = fs.readFileSync(resolved, 'utf8');
253
+ res.writeHead(200, { 'Content-Type': 'application/json' });
254
+ res.end(JSON.stringify({ content }));
255
+ } catch (e) {
256
+ res.writeHead(404); res.end(e.message);
257
+ }
258
+
259
+ } else if (url.pathname === '/api/fs/raw') {
260
+ const p = url.searchParams.get('path');
261
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
262
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
263
+ const resolved = resolvePath(p);
264
+ try {
265
+ const ext = path.extname(resolved).slice(1).toLowerCase();
266
+ const mime = mimeFor(ext);
267
+ res.writeHead(200, { 'Content-Type': mime });
268
+ fs.createReadStream(resolved).pipe(res);
269
+ } catch (e) {
270
+ res.writeHead(404); res.end(e.message);
271
+ }
272
+
198
273
  } else if (url.pathname === '/api/job/output') {
199
274
  // Full output for a job
200
275
  const id = url.searchParams.get('id');
@@ -240,7 +315,9 @@ const server = http.createServer((req, res) => {
240
315
  await redis.rPush(`cca:job:${id}:input`, message);
241
316
  // Echo to output so it's visible in terminal immediately
242
317
  const line = `[you] ${message}`;
243
- await redis.rPush(`cca:job:${id}:output`, line);
318
+ const newLen = await redis.rPush(`cca:job:${id}:output`, line);
319
+ // Advance the output length tracker so the poller doesn't re-broadcast this line
320
+ outputLengths[id] = newLen;
244
321
  broadcast({ type: 'job_output', id, lines: [line] });
245
322
  }
246
323
  }