@enderfga/claw-orchestrator 4.0.0 → 4.0.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.
@@ -533,7 +533,9 @@
533
533
  cursor: pointer;
534
534
  }
535
535
  .forge-spec-msg { font-size: 11px; color: var(--muted); }
536
- #forgeNew {
536
+ #forgeNew,
537
+ #councilNew,
538
+ #autoloopNew {
537
539
  margin: 8px 12px;
538
540
  padding: 6px 12px;
539
541
  border-radius: 4px;
@@ -543,6 +545,109 @@
543
545
  cursor: pointer;
544
546
  width: calc(100% - 24px);
545
547
  }
548
+ #forgeNew:hover,
549
+ #councilNew:hover,
550
+ #autoloopNew:hover {
551
+ filter: brightness(0.95);
552
+ }
553
+
554
+ /* ─── Launcher modal (Council / Autoloop forms) ─────────────── */
555
+ .modal-root {
556
+ position: fixed;
557
+ inset: 0;
558
+ background: rgba(0, 0, 0, 0.45);
559
+ display: flex;
560
+ align-items: center;
561
+ justify-content: center;
562
+ z-index: 100;
563
+ }
564
+ .modal-root[hidden] { display: none; }
565
+ .modal {
566
+ background: var(--surface);
567
+ border-radius: 8px;
568
+ min-width: 440px;
569
+ max-width: 600px;
570
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
571
+ }
572
+ .modal-header {
573
+ display: flex;
574
+ align-items: center;
575
+ justify-content: space-between;
576
+ padding: 14px 20px;
577
+ border-bottom: 1px solid var(--border);
578
+ }
579
+ .modal-header h2 { margin: 0; font-size: 15px; }
580
+ .modal-header button {
581
+ background: transparent;
582
+ border: none;
583
+ font-size: 22px;
584
+ cursor: pointer;
585
+ color: var(--muted);
586
+ padding: 0 6px;
587
+ }
588
+ .modal-body {
589
+ padding: 16px 20px;
590
+ display: flex;
591
+ flex-direction: column;
592
+ gap: 12px;
593
+ }
594
+ .modal-body label {
595
+ display: flex;
596
+ flex-direction: column;
597
+ gap: 4px;
598
+ font-size: 12px;
599
+ color: var(--muted);
600
+ }
601
+ .modal-body input,
602
+ .modal-body textarea {
603
+ padding: 6px 8px;
604
+ border: 1px solid var(--border);
605
+ border-radius: 4px;
606
+ font: inherit;
607
+ color: var(--text);
608
+ background: var(--bg);
609
+ }
610
+ .modal-footer {
611
+ display: flex;
612
+ justify-content: flex-end;
613
+ gap: 8px;
614
+ padding: 12px 20px 18px;
615
+ }
616
+ .modal-footer button {
617
+ padding: 6px 14px;
618
+ border-radius: 4px;
619
+ border: 1px solid var(--border);
620
+ background: var(--surface);
621
+ cursor: pointer;
622
+ font: inherit;
623
+ }
624
+ .modal-footer button.primary {
625
+ background: var(--accent);
626
+ color: white;
627
+ border-color: var(--accent);
628
+ }
629
+ .modal-footer button:disabled {
630
+ opacity: 0.6;
631
+ cursor: not-allowed;
632
+ }
633
+
634
+ /* ─── Empty-state CTA ──────────────────────────────────────── */
635
+ .empty-cta {
636
+ text-align: center;
637
+ margin: 80px auto 0;
638
+ max-width: 420px;
639
+ }
640
+ .empty-cta h2 { margin: 0 0 8px; font-size: 16px; }
641
+ .empty-cta p { margin: 0 0 16px; color: var(--muted); }
642
+ .empty-cta button {
643
+ padding: 8px 16px;
644
+ border-radius: 4px;
645
+ border: 1px solid var(--accent);
646
+ background: var(--accent);
647
+ color: white;
648
+ cursor: pointer;
649
+ font: inherit;
650
+ }
546
651
  #forgeStartBuild {
547
652
  margin-top: 8px;
548
653
  padding: 8px 14px;
@@ -602,7 +707,9 @@
602
707
  <div class="body">
603
708
  <aside class="sidebar">
604
709
  <h2 id="sidebarTitle">Runs</h2>
605
- <button id="forgeNew" hidden>+ New</button>
710
+ <button id="autoloopNew" hidden>+ New autoloop</button>
711
+ <button id="councilNew" hidden>+ New council</button>
712
+ <button id="forgeNew" hidden>+ New forge</button>
606
713
  <ul class="runlist" id="runlist"></ul>
607
714
  </aside>
608
715
 
@@ -614,6 +721,60 @@
614
721
  <div class="footer">SSE-driven dashboard • served by claw-orchestrator embedded server</div>
615
722
  </div>
616
723
 
724
+ <!-- ─── Launcher modal (tab-aware) ───────────────────────────────── -->
725
+ <div id="modalRoot" class="modal-root" hidden>
726
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
727
+ <div class="modal-header">
728
+ <h2 id="modalTitle">New run</h2>
729
+ <button id="modalClose" type="button" aria-label="Close">×</button>
730
+ </div>
731
+ <form id="modalForm" class="modal-body" autocomplete="off">
732
+ <!-- Council fields -->
733
+ <div data-for-tab="council" style="display: contents">
734
+ <label>
735
+ Task
736
+ <textarea
737
+ name="task"
738
+ rows="3"
739
+ placeholder="What should the council achieve?"
740
+ ></textarea>
741
+ </label>
742
+ <label>
743
+ Project directory
744
+ <input
745
+ name="projectDir"
746
+ type="text"
747
+ placeholder="/abs/path/to/workspace"
748
+ />
749
+ </label>
750
+ <label>
751
+ Max rounds
752
+ <input name="maxRounds" type="number" min="1" max="50" value="15" />
753
+ </label>
754
+ </div>
755
+ <!-- Autoloop fields -->
756
+ <div data-for-tab="autoloop" style="display: contents">
757
+ <label>
758
+ Workspace
759
+ <input
760
+ name="workspace"
761
+ type="text"
762
+ placeholder="/abs/path/to/git/workspace"
763
+ />
764
+ </label>
765
+ <label>
766
+ Planner model (optional)
767
+ <input name="planner_model" type="text" placeholder="claude-opus-4-7" />
768
+ </label>
769
+ </div>
770
+ <div class="modal-footer">
771
+ <button type="button" id="modalCancel">Cancel</button>
772
+ <button type="submit" id="modalSubmit" class="primary">Start</button>
773
+ </div>
774
+ </form>
775
+ </div>
776
+ </div>
777
+
617
778
  <script>
618
779
  // ─── State ────────────────────────────────────────────────────
619
780
  const state = {
@@ -671,6 +832,8 @@
671
832
  ? 'Council sessions'
672
833
  : 'Forge apps';
673
834
  $('forgeNew').hidden = tab !== 'forge';
835
+ $('councilNew').hidden = tab !== 'council';
836
+ $('autoloopNew').hidden = tab !== 'autoloop';
674
837
  state.selectedId = null;
675
838
  closeStreams();
676
839
  renderEmptyMain();
@@ -705,7 +868,33 @@
705
868
  }
706
869
 
707
870
  function renderEmptyMain() {
708
- $('main').innerHTML = '<div class="empty" style="margin-top:80px">Select a run to view.</div>';
871
+ // When the sidebar list is empty, invite the user to start their
872
+ // first run. Otherwise just say "pick one". The button delegates
873
+ // to the same launcher trigger as the sidebar "+ New" button.
874
+ const labels = {
875
+ autoloop: 'autoloop run',
876
+ council: 'council session',
877
+ forge: 'forge app',
878
+ };
879
+ const noun = labels[state.tab] || 'run';
880
+ if (state.runs.length === 0) {
881
+ const triggerId =
882
+ state.tab === 'autoloop'
883
+ ? 'autoloopNew'
884
+ : state.tab === 'council'
885
+ ? 'councilNew'
886
+ : 'forgeNew';
887
+ $('main').innerHTML = `
888
+ <div class="empty empty-cta">
889
+ <h2>No ${escapeHtml(noun)}s yet</h2>
890
+ <p>Click <strong>+ New</strong> in the sidebar to start your first one.</p>
891
+ <button type="button" onclick="document.getElementById('${triggerId}').click()">+ New ${escapeHtml(noun)}</button>
892
+ </div>
893
+ `;
894
+ } else {
895
+ $('main').innerHTML =
896
+ '<div class="empty" style="margin-top:80px">Select a run to view.</div>';
897
+ }
709
898
  }
710
899
 
711
900
  // ─── List polling ─────────────────────────────────────────────
@@ -727,6 +916,9 @@
727
916
  ? j.councils || []
728
917
  : j.runs || [];
729
918
  renderList();
919
+ // Refresh the empty-state CTA — runs may have just appeared or
920
+ // disappeared, and renderEmptyMain's text depends on state.runs.length.
921
+ if (!state.selectedId) renderEmptyMain();
730
922
  $('status').textContent = 'connected';
731
923
  } catch (err) {
732
924
  $('status').textContent = 'disconnected';
@@ -756,6 +948,9 @@
756
948
  <span>iter ${r.iter}</span>
757
949
  <span>↑${r.push_log_count}</span>
758
950
  </div>
951
+ <div class="lifecycle-actions">
952
+ <button data-act="delete">Delete</button>
953
+ </div>
759
954
  </li>`;
760
955
  } else if (state.tab === 'council') {
761
956
  return `
@@ -801,6 +996,30 @@
801
996
  ev.stopPropagation();
802
997
  const action = btn.dataset.act;
803
998
  const id = el.dataset.id;
999
+ if (state.tab === 'autoloop') {
1000
+ if (action !== 'delete') return;
1001
+ if (!confirm(`Delete autoloop run "${id}"? The registry entry is removed; the ledger directory under tasks/${id}/ is kept on disk.`)) return;
1002
+ try {
1003
+ const r = await fetch(`/autoloop/${encodeURIComponent(id)}/delete`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
1004
+ if (!r.ok) {
1005
+ let detail = `HTTP ${r.status}`;
1006
+ try { const j = await r.json(); if (j && j.error) detail = j.error; } catch { /* ignore */ }
1007
+ alert(`Delete failed: ${detail}`);
1008
+ return;
1009
+ }
1010
+ // If we were viewing this run, close the detail pane and pick another.
1011
+ if (state.selectedId === id) {
1012
+ closeStreams();
1013
+ state.selectedId = null;
1014
+ $('main').innerHTML = '<div class="empty">Select a run from the left.</div>';
1015
+ }
1016
+ } catch (err) {
1017
+ alert(`Delete failed: ${(err && err.message) || err}`);
1018
+ return;
1019
+ }
1020
+ refreshList();
1021
+ return;
1022
+ }
804
1023
  if (action === 'delete' && !confirm('Delete this run? Container, image, and on-disk artifacts are removed.')) return;
805
1024
  await forgeLifecycleAction(id, action);
806
1025
  refreshList();
@@ -1291,9 +1510,21 @@
1291
1510
  });
1292
1511
  es.addEventListener('planner_reply', (e) => {
1293
1512
  const { text } = JSON.parse(e.data);
1513
+ // Clear the "waiting…" placeholder, if any, before appending the reply.
1514
+ state.autoloop.plannerLog = state.autoloop.plannerLog.filter((m) => m.who !== 'pending');
1294
1515
  state.autoloop.plannerLog.push({ who: 'planner', text, ts: new Date().toISOString() });
1295
1516
  paintPlanner();
1296
1517
  });
1518
+ es.addEventListener('planner_error', (e) => {
1519
+ const { message } = JSON.parse(e.data);
1520
+ state.autoloop.plannerLog = state.autoloop.plannerLog.filter((m) => m.who !== 'pending');
1521
+ state.autoloop.plannerLog.push({
1522
+ who: 'system',
1523
+ text: `[planner error] ${message}`,
1524
+ ts: new Date().toISOString(),
1525
+ });
1526
+ paintPlanner();
1527
+ });
1297
1528
  es.addEventListener('coder_reply', (e) => {
1298
1529
  const { text } = JSON.parse(e.data);
1299
1530
  state.autoloop.coderLog.push({ who: 'coder', text, ts: new Date().toISOString() });
@@ -1442,16 +1673,47 @@
1442
1673
  const btn = $('auto-send');
1443
1674
  btn.disabled = true;
1444
1675
  ta.disabled = true;
1676
+ // Clear input optimistically — the message is already echoed in the
1677
+ // planner log below, and the new server contract is fire-and-forget
1678
+ // (returns 202 immediately; reply streams via SSE). If the POST fails
1679
+ // synchronously we surface the error in the log.
1680
+ ta.value = '';
1445
1681
  state.autoloop.plannerLog.push({ who: 'user', text, ts: new Date().toISOString() });
1682
+ state.autoloop.plannerLog.push({
1683
+ who: 'pending',
1684
+ text: 'Planner is thinking…',
1685
+ ts: new Date().toISOString(),
1686
+ });
1446
1687
  paintPlanner();
1447
1688
  try {
1448
- await fetch('/v1/openclaw/tools/autoloop_chat', {
1689
+ const r = await fetch(`/autoloop/${encodeURIComponent(id)}/chat`, {
1449
1690
  method: 'POST',
1450
1691
  headers: { 'content-type': 'application/json' },
1451
- body: JSON.stringify({ run_id: id, text }),
1692
+ body: JSON.stringify({ text }),
1452
1693
  });
1453
- ta.value = '';
1694
+ // Server returns 202 on accepted (queued); 200 is also fine for
1695
+ // legacy clients. Anything else is a real failure.
1696
+ if (!r.ok) {
1697
+ let detail = `HTTP ${r.status}`;
1698
+ try {
1699
+ const j = await r.json();
1700
+ if (j && typeof j.error === 'string') detail = j.error;
1701
+ } catch {
1702
+ /* non-JSON body — keep the status code */
1703
+ }
1704
+ state.autoloop.plannerLog = state.autoloop.plannerLog.filter((m) => m.who !== 'pending');
1705
+ state.autoloop.plannerLog.push({
1706
+ who: 'system',
1707
+ text: `[error] ${detail}`,
1708
+ ts: new Date().toISOString(),
1709
+ });
1710
+ paintPlanner();
1711
+ }
1712
+ // On success, do nothing — the planner_reply / planner_error SSE
1713
+ // handler upstairs clears the "thinking…" placeholder when the
1714
+ // Planner actually replies (or errors).
1454
1715
  } catch (err) {
1716
+ state.autoloop.plannerLog = state.autoloop.plannerLog.filter((m) => m.who !== 'pending');
1455
1717
  state.autoloop.plannerLog.push({
1456
1718
  who: 'system',
1457
1719
  text: `[error] ${(err && err.message) || 'send failed'}`,
@@ -1604,7 +1866,98 @@
1604
1866
  el.scrollTop = el.scrollHeight;
1605
1867
  }
1606
1868
 
1869
+ // ─── Modal launcher (Council / Autoloop) ─────────────────────
1870
+ //
1871
+ // forgeNew uses a different shape (no form: ultraapp is interview-
1872
+ // driven, so POST /ultraapp/new with an empty body is enough). For
1873
+ // Council / Autoloop we need real form input; one modal handles
1874
+ // both, switching visible fields by state.tab.
1875
+ function openLauncherModal() {
1876
+ const root = $('modalRoot');
1877
+ $('modalTitle').textContent =
1878
+ state.tab === 'council' ? 'New Council session' : 'New Autoloop run';
1879
+ root.querySelectorAll('[data-for-tab]').forEach((el) => {
1880
+ const visible = el.dataset.forTab === state.tab;
1881
+ el.style.display = visible ? 'contents' : 'none';
1882
+ // Only the visible group's required fields actually count.
1883
+ el.querySelectorAll('input,textarea').forEach((f) => {
1884
+ if (visible && (f.name === 'task' || f.name === 'projectDir' || f.name === 'workspace')) {
1885
+ f.required = true;
1886
+ } else {
1887
+ f.required = false;
1888
+ }
1889
+ });
1890
+ });
1891
+ root.hidden = false;
1892
+ setTimeout(() => {
1893
+ const firstVisible = root.querySelector(
1894
+ `[data-for-tab="${state.tab}"] input, [data-for-tab="${state.tab}"] textarea`,
1895
+ );
1896
+ firstVisible?.focus();
1897
+ }, 0);
1898
+ }
1899
+ function closeLauncherModal() {
1900
+ $('modalRoot').hidden = true;
1901
+ $('modalForm').reset();
1902
+ }
1903
+ $('councilNew').addEventListener('click', openLauncherModal);
1904
+ $('autoloopNew').addEventListener('click', openLauncherModal);
1905
+ $('modalClose').addEventListener('click', closeLauncherModal);
1906
+ $('modalCancel').addEventListener('click', closeLauncherModal);
1907
+ $('modalRoot').addEventListener('click', (e) => {
1908
+ if (e.target.id === 'modalRoot') closeLauncherModal();
1909
+ });
1910
+ document.addEventListener('keydown', (e) => {
1911
+ if (e.key === 'Escape' && !$('modalRoot').hidden) closeLauncherModal();
1912
+ });
1913
+ $('modalForm').addEventListener('submit', async (e) => {
1914
+ e.preventDefault();
1915
+ const fd = new FormData(e.target);
1916
+ const body = {};
1917
+ if (state.tab === 'council') {
1918
+ body.task = (fd.get('task') || '').toString().trim();
1919
+ body.projectDir = (fd.get('projectDir') || '').toString().trim();
1920
+ const mr = (fd.get('maxRounds') || '').toString().trim();
1921
+ if (mr) body.maxRounds = Number(mr);
1922
+ } else if (state.tab === 'autoloop') {
1923
+ body.workspace = (fd.get('workspace') || '').toString().trim();
1924
+ const pm = (fd.get('planner_model') || '').toString().trim();
1925
+ if (pm) body.planner_model = pm;
1926
+ }
1927
+ const url = state.tab === 'council' ? '/council/new' : '/autoloop/new';
1928
+ const submitBtn = $('modalSubmit');
1929
+ submitBtn.disabled = true;
1930
+ submitBtn.textContent = 'Starting...';
1931
+ try {
1932
+ const r = await fetch(url, {
1933
+ method: 'POST',
1934
+ headers: { 'Content-Type': 'application/json' },
1935
+ body: JSON.stringify(body),
1936
+ });
1937
+ const j = await r.json();
1938
+ if (!j.ok) {
1939
+ alert('Failed: ' + (j.error || `HTTP ${r.status}`));
1940
+ return;
1941
+ }
1942
+ const id = j.id ?? j.run_id;
1943
+ closeLauncherModal();
1944
+ await refreshList();
1945
+ state.selectedId = id;
1946
+ renderList();
1947
+ openDetail(id);
1948
+ } catch (err) {
1949
+ alert('Failed: ' + err.message);
1950
+ } finally {
1951
+ submitBtn.disabled = false;
1952
+ submitBtn.textContent = 'Start';
1953
+ }
1954
+ });
1955
+
1607
1956
  // ─── Boot ────────────────────────────────────────────────────
1957
+ // Make the right "+ New" button visible for the default tab.
1958
+ $('autoloopNew').hidden = state.tab !== 'autoloop';
1959
+ $('councilNew').hidden = state.tab !== 'council';
1960
+ $('forgeNew').hidden = state.tab !== 'forge';
1608
1961
  refreshList();
1609
1962
  </script>
1610
1963
  </body>
@@ -18,6 +18,13 @@ export declare class EmbeddedServer {
18
18
  private host;
19
19
  constructor(manager: SessionManager, port?: number, host?: string);
20
20
  private _checkRateLimit;
21
+ /**
22
+ * Try to reuse the token persisted by a previous server. Returns null if
23
+ * the file is absent, unreadable, the wrong shape, or the wrong perms.
24
+ * We accept any 32+ hex-char string (the format crypto.randomBytes(32)
25
+ * produces) — anything else is treated as garbage and rotated out.
26
+ */
27
+ private _readPersistedToken;
21
28
  private _writeTokenFile;
22
29
  start(): Promise<number>;
23
30
  stop(): Promise<void>;