cc-agent-ui 0.2.1 → 0.2.3

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.1",
3
+ "version": "0.2.3",
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
@@ -240,13 +240,48 @@ body {
240
240
  position: absolute;
241
241
  top: 0; left: 0;
242
242
  transform-origin: 0 0;
243
- display: grid;
244
- grid-template-columns: repeat(var(--cols, 3), 460px);
245
- gap: 14px;
243
+ display: flex;
244
+ flex-direction: row;
245
+ align-items: flex-start;
246
+ gap: 18px;
246
247
  padding: 24px;
247
248
  width: max-content;
248
249
  }
249
250
 
251
+ /* ── Repo Column ──────────────────────────────────────────────────────── */
252
+ .repo-col {
253
+ display: flex;
254
+ flex-direction: column;
255
+ gap: 10px;
256
+ width: 460px;
257
+ flex-shrink: 0;
258
+ }
259
+
260
+ .col-header {
261
+ padding: 8px 12px;
262
+ background: rgba(0,0,0,0.35);
263
+ border: 1px solid var(--border);
264
+ border-radius: 5px;
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 8px;
268
+ flex-shrink: 0;
269
+ }
270
+ .col-repo-icon { color: var(--dimmer); font-size: 12px; }
271
+ .col-repo-name {
272
+ font-size: 11px; font-weight: 600; color: var(--blue);
273
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
274
+ flex: 1;
275
+ }
276
+ .col-counts { font-size: 9px; color: var(--dim); flex-shrink: 0; }
277
+ .col-counts.live { color: var(--cyan); }
278
+
279
+ .col-cards {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 10px;
283
+ }
284
+
250
285
  /* ── Terminal Card ────────────────────────────────────────────────────── */
251
286
  .tcard {
252
287
  width: 460px;
@@ -293,7 +328,9 @@ body {
293
328
  border-bottom: 1px solid var(--border);
294
329
  background: rgba(0,0,0,0.2);
295
330
  flex-shrink: 0;
331
+ cursor: pointer;
296
332
  }
333
+ .card-hdr:hover { background: rgba(255,255,255,0.04); }
297
334
 
298
335
  .card-num {
299
336
  font-size: 10px;
@@ -506,6 +543,99 @@ body {
506
543
  border-radius: 4px;
507
544
  }
508
545
 
546
+ /* ── Job Detail Panel ────────────────────────────────────────────────────── */
547
+ #jobpanel {
548
+ position: fixed;
549
+ top: 40px; right: 0; bottom: 0;
550
+ width: 600px;
551
+ background: var(--sidebar-bg);
552
+ border-left: 1px solid var(--border-hi);
553
+ display: flex;
554
+ flex-direction: column;
555
+ z-index: 600;
556
+ transform: translateX(100%);
557
+ transition: transform 0.22s cubic-bezier(.4,0,.2,1);
558
+ }
559
+ #jobpanel.open { transform: translateX(0); }
560
+ #jobpanel.open ~ #filebrowser { right: 600px; }
561
+
562
+ #jp-topbar {
563
+ display: flex; align-items: center;
564
+ padding: 0 14px; height: 44px;
565
+ border-bottom: 1px solid var(--border);
566
+ gap: 10px; flex-shrink: 0;
567
+ }
568
+ #jp-title {
569
+ flex: 1; font-size: 11px; font-weight: 600;
570
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
571
+ }
572
+ #jp-close {
573
+ background: transparent; border: none;
574
+ color: var(--dim); font-size: 16px; cursor: pointer;
575
+ padding: 0 4px; line-height: 1; font-family: var(--font);
576
+ }
577
+ #jp-close:hover { color: var(--text); }
578
+
579
+ #jp-meta {
580
+ padding: 10px 14px;
581
+ border-bottom: 1px solid var(--border);
582
+ display: flex; flex-wrap: wrap; gap: 8px;
583
+ flex-shrink: 0;
584
+ }
585
+ .jp-badge {
586
+ font-size: 9px; padding: 3px 8px; border-radius: 3px;
587
+ text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
588
+ }
589
+ .jp-meta-row {
590
+ width: 100%; font-size: 10px; color: var(--dim);
591
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
592
+ }
593
+ .jp-meta-row span { color: var(--text); }
594
+
595
+ #jp-actions {
596
+ padding: 10px 14px;
597
+ border-bottom: 1px solid var(--border);
598
+ display: flex; gap: 8px; flex-shrink: 0;
599
+ flex-wrap: wrap;
600
+ }
601
+ .jp-btn {
602
+ font-family: var(--font); font-size: 10px;
603
+ padding: 5px 14px; border-radius: 4px; cursor: pointer;
604
+ border: 1px solid var(--border); background: transparent;
605
+ color: var(--text); transition: all 0.15s;
606
+ font-weight: 600; letter-spacing: 0.04em;
607
+ }
608
+ .jp-btn:hover { border-color: var(--border-hi); background: rgba(255,255,255,0.04); }
609
+ .jp-btn.approve { border-color: rgba(152,195,121,0.5); color: var(--green); }
610
+ .jp-btn.approve:hover { background: rgba(152,195,121,0.12); }
611
+ .jp-btn.cancel { border-color: rgba(224,108,117,0.5); color: var(--red); }
612
+ .jp-btn.cancel:hover { background: rgba(224,108,117,0.12); }
613
+ .jp-btn.wake { border-color: rgba(229,192,123,0.5); color: var(--yellow); }
614
+ .jp-btn.wake:hover { background: rgba(229,192,123,0.12); }
615
+ .jp-btn:disabled { opacity: 0.35; cursor: not-allowed; }
616
+
617
+ #jp-log {
618
+ flex: 1; overflow-y: auto;
619
+ padding: 10px 14px;
620
+ font-size: 11px; line-height: 1.65;
621
+ scrollbar-width: thin;
622
+ scrollbar-color: var(--border) transparent;
623
+ background: var(--card-body);
624
+ }
625
+ #jp-log::-webkit-scrollbar { width: 4px; }
626
+ #jp-log::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 2px; }
627
+
628
+ #jp-task {
629
+ padding: 10px 14px;
630
+ border-bottom: 1px solid var(--border);
631
+ font-size: 10px; color: var(--dim);
632
+ max-height: 100px; overflow-y: auto;
633
+ flex-shrink: 0;
634
+ white-space: pre-wrap; word-break: break-word;
635
+ line-height: 1.6;
636
+ }
637
+ #jp-task strong { color: var(--text); display: block; margin-bottom: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
638
+
509
639
  /* Clickable file paths in terminal */
510
640
  .fp-link {
511
641
  color: var(--orange);
@@ -583,6 +713,18 @@ body {
583
713
 
584
714
  </div>
585
715
 
716
+ <!-- Job Detail Panel -->
717
+ <div id="jobpanel">
718
+ <div id="jp-topbar">
719
+ <span id="jp-title">—</span>
720
+ <button id="jp-close" onclick="jpClose()">✕</button>
721
+ </div>
722
+ <div id="jp-meta"></div>
723
+ <div id="jp-task"></div>
724
+ <div id="jp-actions"></div>
725
+ <div id="jp-log"></div>
726
+ </div>
727
+
586
728
  <!-- File Browser Panel -->
587
729
  <div id="filebrowser">
588
730
  <div id="fb-topbar">
@@ -595,7 +737,8 @@ body {
595
737
 
596
738
  <script>
597
739
  // ── State ──────────────────────────────────────────────────────────────────
598
- const jobs = {}; // id → { job, card, logEl, lineCount }
740
+ const jobs = {}; // id → { job, card, logEl, n }
741
+ const cols = {}; // repoKey → { el, cardsEl }
599
742
  let filterMode = 'all';
600
743
  let jobCounter = 0;
601
744
  let scale = 1, ox = 40, oy = 40;
@@ -816,14 +959,14 @@ function focusCard(id) {
816
959
  applyFilter();
817
960
  }
818
961
 
819
- // Use actual DOM layout positions (accurate regardless of grid index math)
820
- const cardX = card.offsetLeft;
821
- const cardY = card.offsetTop;
962
+ // Walk offsetParent chain up to inner for accurate canvas coordinates
963
+ let cx = 0, cy = 0, el = card;
964
+ while (el && el !== inner) { cx += el.offsetLeft; cy += el.offsetTop; el = el.offsetParent; }
822
965
  const cardW = card.offsetWidth || 460;
823
966
  const cardH = card.offsetHeight || 370;
824
967
  const vw = viewport.clientWidth, vh = viewport.clientHeight;
825
- ox = vw/2 - (cardX + cardW/2) * scale;
826
- oy = vh/2 - (cardY + cardH/2) * scale;
968
+ ox = vw/2 - (cx + cardW/2) * scale;
969
+ oy = vh/2 - (cy + cardH/2) * scale;
827
970
  applyTransform();
828
971
 
829
972
  // Scroll terminal to bottom
@@ -856,6 +999,11 @@ function applyFilter() {
856
999
  if (item) item.style.display = show ? '' : 'none';
857
1000
  if (card) card.style.display = show ? '' : 'none';
858
1001
  }
1002
+ // Hide columns with no visible cards
1003
+ for (const [key, col] of Object.entries(cols)) {
1004
+ const hasVisible = [...col.cardsEl.children].some(c => c.style.display !== 'none');
1005
+ col.el.style.display = hasVisible ? '' : 'none';
1006
+ }
859
1007
  }
860
1008
 
861
1009
  document.querySelectorAll('.fbtn').forEach(btn => {
@@ -867,34 +1015,95 @@ document.querySelectorAll('.fbtn').forEach(btn => {
867
1015
  });
868
1016
  });
869
1017
 
870
- // ── Handle snapshot (initial load) ────────────────────────────────────────
871
- function handleSnapshot(data) {
872
- // Sort: running first, then pending, then recent done/failed
873
- const sorted = [...data.jobs].sort((a, b) => {
874
- const order = { running:0, cloning:1, pending_approval:2, failed:3, done:4, cancelled:5 };
875
- return (order[a.status]??9) - (order[b.status]??9) ||
876
- new Date(b.startedAt||0) - new Date(a.startedAt||0);
1018
+ // ── Column helpers ─────────────────────────────────────────────────────────
1019
+ function repoKey(job) {
1020
+ return shortRepo(job.repoUrl) || 'local';
1021
+ }
1022
+
1023
+ function getOrCreateCol(key) {
1024
+ if (cols[key]) return cols[key];
1025
+ const el = document.createElement('div');
1026
+ el.className = 'repo-col';
1027
+ el.id = `col-${key.replace(/[^a-z0-9]/gi, '-')}`;
1028
+ el.innerHTML = `
1029
+ <div class="col-header">
1030
+ <span class="col-repo-icon">⎇</span>
1031
+ <span class="col-repo-name" title="${escHtml(key)}">${escHtml(key)}</span>
1032
+ <span class="col-counts"></span>
1033
+ </div>
1034
+ <div class="col-cards"></div>
1035
+ `;
1036
+ cols[key] = { el, cardsEl: el.querySelector('.col-cards') };
1037
+ inner.appendChild(el);
1038
+ return cols[key];
1039
+ }
1040
+
1041
+ function insertCardInColumn(key, card, job) {
1042
+ const { cardsEl } = cols[key];
1043
+ const t = new Date(job.startedAt || 0).getTime();
1044
+ for (const sib of cardsEl.children) {
1045
+ const sibJob = jobs[sib.id.replace('card-', '')]?.job;
1046
+ if (t >= new Date(sibJob?.startedAt || 0).getTime()) {
1047
+ cardsEl.insertBefore(card, sib);
1048
+ return;
1049
+ }
1050
+ }
1051
+ cardsEl.appendChild(card);
1052
+ }
1053
+
1054
+ function updateColHeader(key) {
1055
+ const col = cols[key];
1056
+ if (!col) return;
1057
+ const colJobs = Object.values(jobs).filter(e => repoKey(e.job) === key);
1058
+ const running = colJobs.filter(e => ['running','cloning','pending_approval'].includes(e.job.status)).length;
1059
+ const el = col.el.querySelector('.col-counts');
1060
+ if (!el) return;
1061
+ if (running > 0) {
1062
+ el.textContent = `${running} live · ${colJobs.length}`;
1063
+ el.className = 'col-counts live';
1064
+ } else {
1065
+ el.textContent = `${colJobs.length}`;
1066
+ el.className = 'col-counts';
1067
+ }
1068
+ }
1069
+
1070
+ function sortColumns() {
1071
+ // Order columns by their most recent job's startedAt, descending
1072
+ const keys = Object.keys(cols).sort((a, b) => {
1073
+ const latest = key => Math.max(0, ...Object.values(jobs)
1074
+ .filter(e => repoKey(e.job) === key)
1075
+ .map(e => new Date(e.job.startedAt || 0).getTime()));
1076
+ return latest(b) - latest(a);
877
1077
  });
1078
+ for (const key of keys) inner.appendChild(cols[key].el);
1079
+ }
878
1080
 
879
- for (const job of sorted) {
880
- addJob(job, job.lines || []);
881
- }
1081
+ // ── Handle snapshot (initial load) ────────────────────────────────────────
1082
+ function handleSnapshot(data) {
1083
+ // Sort purely by time descending — columns handle repo grouping visually
1084
+ const sorted = [...data.jobs].sort((a, b) =>
1085
+ new Date(b.startedAt||0) - new Date(a.startedAt||0)
1086
+ );
1087
+ for (const job of sorted) addJob(job, job.lines || []);
1088
+ sortColumns();
882
1089
  updateCounts();
883
1090
  applyFilter();
884
- if (Object.keys(jobs).length > 0) {
885
- emptyState.style.display = 'none';
886
- }
1091
+ if (Object.keys(jobs).length > 0) emptyState.style.display = 'none';
887
1092
  }
888
1093
 
889
1094
  function addJob(job, lines) {
890
- if (jobs[job.id]) return; // already exists
1095
+ if (jobs[job.id]) return;
891
1096
  jobCounter++;
892
1097
  const { card, logEl } = makeCard(job, jobCounter);
893
- inner.appendChild(card);
1098
+ const key = repoKey(job);
1099
+ getOrCreateCol(key);
1100
+ insertCardInColumn(key, card, job);
894
1101
  const sidebarItem = makeSidebarItem(job);
895
1102
  jobList.prepend(sidebarItem);
896
1103
  jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
897
1104
  if (lines.length) appendLines(logEl, lines, false);
1105
+ updateColHeader(key);
1106
+ wireCardClick(card, job.id);
898
1107
  }
899
1108
 
900
1109
  // ── Handle job update ──────────────────────────────────────────────────────
@@ -940,6 +1149,14 @@ function handleJobUpdate(data) {
940
1149
  const dot = $(`si-${job.id}`)?.querySelector('.ji-status');
941
1150
  if (dot) { dot.className = `ji-status ${status}`; }
942
1151
 
1152
+ // Refresh job panel if this job is open
1153
+ if (jpCurrentId === job.id) {
1154
+ jpMeta.querySelector('.jp-badge').className = `jp-badge badge-${status}`;
1155
+ jpMeta.querySelector('.jp-badge').textContent = status.replace('_', ' ');
1156
+ renderActions(status);
1157
+ }
1158
+
1159
+ updateColHeader(repoKey(job));
943
1160
  updateCounts();
944
1161
  applyFilter();
945
1162
  }
@@ -947,6 +1164,7 @@ function handleJobUpdate(data) {
947
1164
  // ── Handle job_new ─────────────────────────────────────────────────────────
948
1165
  function handleJobNew(data) {
949
1166
  addJob(data.job, data.job.lines || []);
1167
+ sortColumns();
950
1168
  emptyState.style.display = 'none';
951
1169
  updateCounts();
952
1170
  applyFilter();
@@ -957,6 +1175,10 @@ function handleOutput(data) {
957
1175
  const entry = jobs[data.id];
958
1176
  if (!entry) return;
959
1177
  appendLines(entry.logEl, data.lines, true);
1178
+ // Mirror to job panel if open
1179
+ if (jpCurrentId === data.id) {
1180
+ appendLines(jpLog, data.lines, true);
1181
+ }
960
1182
  }
961
1183
 
962
1184
  // ── WebSocket ──────────────────────────────────────────────────────────────
@@ -980,6 +1202,114 @@ function connect() {
980
1202
  };
981
1203
  }
982
1204
 
1205
+ // ── Job Detail Panel ───────────────────────────────────────────────────────
1206
+ const jp = $('jobpanel');
1207
+ const jpLog = $('jp-log');
1208
+ const jpTitle = $('jp-title');
1209
+ const jpMeta = $('jp-meta');
1210
+ const jpTask = $('jp-task');
1211
+ const jpActions = $('jp-actions');
1212
+ let jpCurrentId = null;
1213
+
1214
+ function jpClose() {
1215
+ jp.classList.remove('open');
1216
+ jpCurrentId = null;
1217
+ }
1218
+
1219
+ async function jpOpen(id) {
1220
+ const entry = jobs[id];
1221
+ if (!entry) return;
1222
+ jpCurrentId = id;
1223
+ jp.classList.add('open');
1224
+
1225
+ const job = entry.job;
1226
+ const status = job.status || 'unknown';
1227
+
1228
+ jpTitle.textContent = shortTask(job.task);
1229
+ jpTask.innerHTML = `<strong>Task</strong>${escHtml(job.task || '(no task)')}`;
1230
+
1231
+ // Meta
1232
+ jpMeta.innerHTML = `
1233
+ <span class="jp-badge badge-${status}">${status.replace('_',' ')}</span>
1234
+ <span class="jp-meta-row">repo <span>${escHtml(shortRepo(job.repoUrl) || 'local')}</span>
1235
+ ${job.branch ? ` · branch <span>${escHtml(job.branch)}</span>` : ''}
1236
+ </span>
1237
+ <span class="jp-meta-row">id <span>${escHtml(job.id || id)}</span></span>
1238
+ <span class="jp-meta-row">started <span>${job.startedAt ? new Date(job.startedAt).toLocaleString() : '—'}</span></span>
1239
+ `;
1240
+
1241
+ // Actions
1242
+ renderActions(status);
1243
+
1244
+ // Load full log
1245
+ jpLog.innerHTML = '<div style="color:var(--dim);padding:4px">loading…</div>';
1246
+ try {
1247
+ const r = await fetch(`/api/job/output?id=${encodeURIComponent(id)}`);
1248
+ const data = await r.json();
1249
+ jpLog.innerHTML = '';
1250
+ appendLines(jpLog, data.lines || [], false);
1251
+ jpLog.scrollTop = jpLog.scrollHeight;
1252
+ } catch (e) {
1253
+ jpLog.innerHTML = `<div style="color:var(--red)">${escHtml(e.message)}</div>`;
1254
+ }
1255
+ }
1256
+
1257
+ function renderActions(status) {
1258
+ jpActions.innerHTML = '';
1259
+ const add = (label, cls, action, extra = {}) => {
1260
+ const b = document.createElement('button');
1261
+ b.className = `jp-btn ${cls}`;
1262
+ b.textContent = label;
1263
+ if (extra.disabled) b.disabled = true;
1264
+ b.addEventListener('click', () => jobAction(jpCurrentId, action));
1265
+ jpActions.appendChild(b);
1266
+ };
1267
+
1268
+ if (status === 'pending_approval') add('✓ Approve', 'approve', 'approve');
1269
+ if (['running','cloning','pending_approval'].includes(status)) add('✕ Cancel', 'cancel', 'cancel');
1270
+ if (status === 'failed' || status === 'cancelled') add('⟳ Wake', 'wake', 'wake');
1271
+ if (jpActions.children.length === 0) {
1272
+ jpActions.innerHTML = `<span style="font-size:10px;color:var(--dim)">no actions available for ${status} jobs</span>`;
1273
+ }
1274
+ }
1275
+
1276
+ async function jobAction(id, action) {
1277
+ if (!id) return;
1278
+ const btns = jpActions.querySelectorAll('.jp-btn');
1279
+ btns.forEach(b => b.disabled = true);
1280
+ try {
1281
+ const r = await fetch('/api/job/action', {
1282
+ method: 'POST',
1283
+ headers: { 'Content-Type': 'application/json' },
1284
+ body: JSON.stringify({ id, action }),
1285
+ });
1286
+ const data = await r.json();
1287
+ if (data.ok) {
1288
+ // Append confirmation to panel log
1289
+ const d = document.createElement('div');
1290
+ d.className = 'tl tl-ok';
1291
+ d.textContent = `[ui] Action "${action}" sent`;
1292
+ jpLog.appendChild(d);
1293
+ jpLog.scrollTop = jpLog.scrollHeight;
1294
+ }
1295
+ } catch (e) {
1296
+ const d = document.createElement('div');
1297
+ d.className = 'tl tl-err';
1298
+ d.textContent = `[ui] Error: ${e.message}`;
1299
+ jpLog.appendChild(d);
1300
+ } finally {
1301
+ btns.forEach(b => b.disabled = false);
1302
+ }
1303
+ }
1304
+
1305
+ // Wire card clicks to open job panel
1306
+ function wireCardClick(card, id) {
1307
+ card.querySelector('.card-hdr').addEventListener('click', (e) => {
1308
+ if (e.target.closest('.fp-link')) return; // don't interfere with file links
1309
+ jpOpen(id);
1310
+ });
1311
+ }
1312
+
983
1313
  // ── File Browser ───────────────────────────────────────────────────────────
984
1314
  const fb = $('filebrowser');
985
1315
  const fbBody = $('fb-body');
@@ -1117,15 +1447,6 @@ setInterval(() => {
1117
1447
  }
1118
1448
  }, 30000);
1119
1449
 
1120
- // Auto-layout columns based on viewport width
1121
- function updateCols() {
1122
- const vw = viewport.clientWidth;
1123
- const cols = Math.max(1, Math.floor((vw - 40) / 474));
1124
- inner.style.setProperty('--cols', cols);
1125
- }
1126
- updateCols();
1127
- new ResizeObserver(updateCols).observe(viewport);
1128
-
1129
1450
  connect();
1130
1451
  </script>
1131
1452
  </body>
package/server.js CHANGED
@@ -195,6 +195,56 @@ const server = http.createServer((req, res) => {
195
195
  res.writeHead(404); res.end(e.message);
196
196
  }
197
197
 
198
+ } else if (url.pathname === '/api/job/output') {
199
+ // Full output for a job
200
+ const id = url.searchParams.get('id');
201
+ if (!id) { res.writeHead(400); res.end('missing id'); return; }
202
+ (async () => {
203
+ try {
204
+ const lines = await getOutputTail(id, 5000);
205
+ res.writeHead(200, { 'Content-Type': 'application/json' });
206
+ res.end(JSON.stringify({ lines }));
207
+ } catch (e) { res.writeHead(500); res.end(e.message); }
208
+ })();
209
+
210
+ } else if (url.pathname === '/api/job/action' && req.method === 'POST') {
211
+ // Job actions: approve, cancel, wake
212
+ let body = '';
213
+ req.on('data', d => body += d);
214
+ req.on('end', async () => {
215
+ try {
216
+ const { id, action, message } = JSON.parse(body);
217
+ if (!id || !action) { res.writeHead(400); res.end('missing id/action'); return; }
218
+ const jobRaw = await redis.get(`cca:job:${id}`);
219
+ const job = parseJob(jobRaw);
220
+ if (!job) { res.writeHead(404); res.end('job not found'); return; }
221
+
222
+ if (action === 'approve') {
223
+ // Set approval flag — cc-agent polls for this
224
+ const updated = { ...job, approvedAt: new Date().toISOString(), approved: true };
225
+ 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');
228
+ } else if (action === 'cancel') {
229
+ const updated = { ...job, status: 'cancelled', cancelledAt: new Date().toISOString() };
230
+ await redis.set(`cca:job:${id}`, JSON.stringify(updated));
231
+ broadcast({ type: 'job_update', job: updated });
232
+ } else if (action === 'wake') {
233
+ const updated = { ...job, status: 'running', wakedAt: new Date().toISOString() };
234
+ await redis.set(`cca:job:${id}`, JSON.stringify(updated));
235
+ broadcast({ type: 'job_update', job: updated });
236
+ } 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);
239
+ }
240
+
241
+ res.writeHead(200, { 'Content-Type': 'application/json' });
242
+ res.end(JSON.stringify({ ok: true, action, id }));
243
+ } catch (e) {
244
+ res.writeHead(500); res.end(e.message);
245
+ }
246
+ });
247
+
198
248
  } else {
199
249
  res.writeHead(404); res.end();
200
250
  }