@enderfga/claw-orchestrator 3.7.1 → 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.
Files changed (115) hide show
  1. package/README.md +45 -3
  2. package/configs/autoloop-coder-prompt.md +65 -28
  3. package/configs/autoloop-planner-prompt.md +103 -42
  4. package/configs/autoloop-reviewer-prompt.md +58 -30
  5. package/dist/bin/cli.js +30 -7
  6. package/dist/bin/cli.js.map +1 -1
  7. package/dist/src/autoloop/dispatcher.js +16 -2
  8. package/dist/src/autoloop/dispatcher.js.map +1 -1
  9. package/dist/src/autoloop/messages.d.ts +0 -2
  10. package/dist/src/autoloop/messages.js +0 -2
  11. package/dist/src/autoloop/messages.js.map +1 -1
  12. package/dist/src/autoloop/planner-tools.d.ts +11 -6
  13. package/dist/src/autoloop/planner-tools.js +24 -7
  14. package/dist/src/autoloop/planner-tools.js.map +1 -1
  15. package/dist/src/autoloop/runner.d.ts +1 -2
  16. package/dist/src/autoloop/runner.js +1 -2
  17. package/dist/src/autoloop/runner.js.map +1 -1
  18. package/dist/src/autoloop/types.d.ts +0 -2
  19. package/dist/src/autoloop/types.js +0 -2
  20. package/dist/src/autoloop/types.js.map +1 -1
  21. package/dist/src/council.js +1 -0
  22. package/dist/src/council.js.map +1 -1
  23. package/dist/src/dashboard/index.html +1066 -12
  24. package/dist/src/embedded-server.d.ts +7 -0
  25. package/dist/src/embedded-server.js +359 -17
  26. package/dist/src/embedded-server.js.map +1 -1
  27. package/dist/src/index.js +222 -1
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/session-manager.d.ts +98 -1
  30. package/dist/src/session-manager.js +340 -5
  31. package/dist/src/session-manager.js.map +1 -1
  32. package/dist/src/ultraapp/build-events.d.ts +46 -0
  33. package/dist/src/ultraapp/build-events.js +6 -0
  34. package/dist/src/ultraapp/build-events.js.map +1 -0
  35. package/dist/src/ultraapp/build.d.ts +39 -0
  36. package/dist/src/ultraapp/build.js +111 -0
  37. package/dist/src/ultraapp/build.js.map +1 -0
  38. package/dist/src/ultraapp/conventions.d.ts +8 -0
  39. package/dist/src/ultraapp/conventions.js +248 -0
  40. package/dist/src/ultraapp/conventions.js.map +1 -0
  41. package/dist/src/ultraapp/council-adapter.d.ts +49 -0
  42. package/dist/src/ultraapp/council-adapter.js +152 -0
  43. package/dist/src/ultraapp/council-adapter.js.map +1 -0
  44. package/dist/src/ultraapp/deploy.d.ts +45 -0
  45. package/dist/src/ultraapp/deploy.js +82 -0
  46. package/dist/src/ultraapp/deploy.js.map +1 -0
  47. package/dist/src/ultraapp/diff-apply.d.ts +15 -0
  48. package/dist/src/ultraapp/diff-apply.js +48 -0
  49. package/dist/src/ultraapp/diff-apply.js.map +1 -0
  50. package/dist/src/ultraapp/docker.d.ts +57 -0
  51. package/dist/src/ultraapp/docker.js +83 -0
  52. package/dist/src/ultraapp/docker.js.map +1 -0
  53. package/dist/src/ultraapp/feedback-classifier.d.ts +24 -0
  54. package/dist/src/ultraapp/feedback-classifier.js +85 -0
  55. package/dist/src/ultraapp/feedback-classifier.js.map +1 -0
  56. package/dist/src/ultraapp/files.d.ts +24 -0
  57. package/dist/src/ultraapp/files.js +79 -0
  58. package/dist/src/ultraapp/files.js.map +1 -0
  59. package/dist/src/ultraapp/fix-on-failure-session.d.ts +23 -0
  60. package/dist/src/ultraapp/fix-on-failure-session.js +48 -0
  61. package/dist/src/ultraapp/fix-on-failure-session.js.map +1 -0
  62. package/dist/src/ultraapp/fix-on-failure.d.ts +39 -0
  63. package/dist/src/ultraapp/fix-on-failure.js +69 -0
  64. package/dist/src/ultraapp/fix-on-failure.js.map +1 -0
  65. package/dist/src/ultraapp/host-strategy.d.ts +57 -0
  66. package/dist/src/ultraapp/host-strategy.js +205 -0
  67. package/dist/src/ultraapp/host-strategy.js.map +1 -0
  68. package/dist/src/ultraapp/interview-parser.d.ts +41 -0
  69. package/dist/src/ultraapp/interview-parser.js +83 -0
  70. package/dist/src/ultraapp/interview-parser.js.map +1 -0
  71. package/dist/src/ultraapp/interview-tools.d.ts +16 -0
  72. package/dist/src/ultraapp/interview-tools.js +36 -0
  73. package/dist/src/ultraapp/interview-tools.js.map +1 -0
  74. package/dist/src/ultraapp/json-patch.d.ts +6 -0
  75. package/dist/src/ultraapp/json-patch.js +78 -0
  76. package/dist/src/ultraapp/json-patch.js.map +1 -0
  77. package/dist/src/ultraapp/lifecycle.d.ts +38 -0
  78. package/dist/src/ultraapp/lifecycle.js +31 -0
  79. package/dist/src/ultraapp/lifecycle.js.map +1 -0
  80. package/dist/src/ultraapp/manager.d.ts +150 -0
  81. package/dist/src/ultraapp/manager.js +684 -0
  82. package/dist/src/ultraapp/manager.js.map +1 -0
  83. package/dist/src/ultraapp/narrator-prompt.d.ts +10 -0
  84. package/dist/src/ultraapp/narrator-prompt.js +54 -0
  85. package/dist/src/ultraapp/narrator-prompt.js.map +1 -0
  86. package/dist/src/ultraapp/narrator.d.ts +49 -0
  87. package/dist/src/ultraapp/narrator.js +83 -0
  88. package/dist/src/ultraapp/narrator.js.map +1 -0
  89. package/dist/src/ultraapp/patcher.d.ts +35 -0
  90. package/dist/src/ultraapp/patcher.js +159 -0
  91. package/dist/src/ultraapp/patcher.js.map +1 -0
  92. package/dist/src/ultraapp/router.d.ts +39 -0
  93. package/dist/src/ultraapp/router.js +139 -0
  94. package/dist/src/ultraapp/router.js.map +1 -0
  95. package/dist/src/ultraapp/spec-delta.d.ts +18 -0
  96. package/dist/src/ultraapp/spec-delta.js +35 -0
  97. package/dist/src/ultraapp/spec-delta.js.map +1 -0
  98. package/dist/src/ultraapp/spec.d.ts +82 -0
  99. package/dist/src/ultraapp/spec.js +125 -0
  100. package/dist/src/ultraapp/spec.js.map +1 -0
  101. package/dist/src/ultraapp/store.d.ts +67 -0
  102. package/dist/src/ultraapp/store.js +177 -0
  103. package/dist/src/ultraapp/store.js.map +1 -0
  104. package/dist/src/ultraapp/versions.d.ts +50 -0
  105. package/dist/src/ultraapp/versions.js +65 -0
  106. package/dist/src/ultraapp/versions.js.map +1 -0
  107. package/openclaw.plugin.json +15 -1
  108. package/package.json +3 -1
  109. package/skills/SKILL.md +2 -2
  110. package/skills/references/autoloop.md +7 -4
  111. package/skills/references/dashboard.md +153 -0
  112. package/skills/references/mcp.md +2 -2
  113. package/skills/references/tools.md +183 -0
  114. package/skills/references/ultraapp.md +203 -0
  115. package/skills/ultraapp/SKILL.md +112 -0
@@ -353,6 +353,342 @@
353
353
  border-top: 1px solid var(--border);
354
354
  background: var(--surface);
355
355
  }
356
+ /* ─── Forge tab ──────────────────────────────────────────── */
357
+ .forge-layout {
358
+ display: grid;
359
+ grid-template-columns: 1fr 380px;
360
+ height: 100%;
361
+ gap: 0;
362
+ }
363
+ .forge-chat {
364
+ display: flex;
365
+ flex-direction: column;
366
+ padding: 16px;
367
+ overflow: hidden;
368
+ height: 100%;
369
+ }
370
+ .forge-mode {
371
+ display: inline-block;
372
+ padding: 4px 10px;
373
+ border-radius: 12px;
374
+ font-size: 11px;
375
+ background: var(--surface-2);
376
+ color: var(--muted);
377
+ margin-bottom: 8px;
378
+ text-transform: uppercase;
379
+ align-self: flex-start;
380
+ }
381
+ .forge-mode[data-mode='build'],
382
+ .forge-mode[data-mode='building'] { background: var(--info); color: white; }
383
+ .forge-mode[data-mode='done'],
384
+ .forge-mode[data-mode='build-complete'] { background: var(--accent); color: white; }
385
+ .forge-mode[data-mode='failed'] { background: var(--error); color: white; }
386
+ .forge-mode[data-mode='queued'] { background: var(--warn); color: white; }
387
+ .forge-empty {
388
+ margin: auto;
389
+ max-width: 440px;
390
+ text-align: center;
391
+ color: var(--muted);
392
+ }
393
+ .forge-messages {
394
+ flex: 1;
395
+ overflow-y: auto;
396
+ padding-right: 8px;
397
+ margin-bottom: 8px;
398
+ }
399
+ .forge-msg {
400
+ padding: 6px 10px;
401
+ margin: 4px 0;
402
+ border-radius: 4px;
403
+ font-size: 13px;
404
+ }
405
+ .forge-msg.user { background: #e0eaff; align-self: flex-end; }
406
+ .forge-msg.assistant { background: var(--surface); border: 1px solid var(--border); }
407
+ .forge-msg.system { background: var(--surface-2); color: var(--muted); font-size: 12px; }
408
+ .forge-share {
409
+ background: var(--surface);
410
+ border: 1px solid var(--accent);
411
+ border-radius: 6px;
412
+ padding: 12px;
413
+ margin: 8px 0;
414
+ }
415
+ .forge-share strong { color: var(--accent); display: block; margin-bottom: 6px; }
416
+ .forge-share a { color: var(--accent); word-break: break-all; }
417
+ .forge-share-actions { display: flex; gap: 8px; margin-top: 10px; }
418
+ .forge-share-actions button {
419
+ padding: 4px 12px;
420
+ font-size: 12px;
421
+ border: 1px solid var(--border);
422
+ background: var(--surface);
423
+ border-radius: 4px;
424
+ cursor: pointer;
425
+ }
426
+ .forge-public-modal {
427
+ max-width: 600px;
428
+ padding: 20px;
429
+ border: 1px solid var(--border);
430
+ border-radius: 8px;
431
+ }
432
+ .forge-public-modal pre {
433
+ background: var(--surface-2);
434
+ padding: 8px;
435
+ border-radius: 4px;
436
+ font-size: 12px;
437
+ overflow-x: auto;
438
+ }
439
+ .forge-public-modal h4 { margin-bottom: 4px; }
440
+ .runItem .lifecycle-actions {
441
+ display: none;
442
+ gap: 4px;
443
+ margin-top: 4px;
444
+ }
445
+ .runItem:hover .lifecycle-actions { display: flex; }
446
+ .lifecycle-actions button {
447
+ font-size: 10px;
448
+ padding: 2px 6px;
449
+ border: 1px solid var(--border);
450
+ background: var(--surface);
451
+ border-radius: 3px;
452
+ cursor: pointer;
453
+ }
454
+ .lifecycle-actions button:hover { background: var(--surface-2); }
455
+ .forge-question {
456
+ background: var(--surface);
457
+ border: 1px solid var(--border);
458
+ border-radius: 6px;
459
+ padding: 12px;
460
+ margin: 8px 0;
461
+ }
462
+ .forge-question .ctx { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
463
+ .forge-question .q { font-weight: 600; margin-bottom: 8px; }
464
+ .forge-question .opts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
465
+ .forge-question .opt {
466
+ padding: 4px 10px;
467
+ border: 1px solid var(--border);
468
+ border-radius: 16px;
469
+ cursor: pointer;
470
+ font-size: 12px;
471
+ background: var(--surface);
472
+ }
473
+ .forge-question .opt.recommended { border-color: var(--accent); }
474
+ .forge-question .opt.selected { background: var(--accent); color: white; border-color: var(--accent); }
475
+ .forge-input {
476
+ display: grid;
477
+ grid-template-columns: 1fr auto;
478
+ gap: 8px;
479
+ padding-top: 8px;
480
+ border-top: 1px solid var(--border);
481
+ }
482
+ .forge-input textarea {
483
+ resize: vertical;
484
+ font-family: inherit;
485
+ padding: 6px;
486
+ border: 1px solid var(--border);
487
+ border-radius: 4px;
488
+ }
489
+ .forge-input-row {
490
+ display: grid;
491
+ grid-template-columns: 1fr auto;
492
+ gap: 8px;
493
+ margin-top: 4px;
494
+ }
495
+ .forge-input button {
496
+ padding: 6px 14px;
497
+ border-radius: 4px;
498
+ border: 1px solid var(--accent);
499
+ background: var(--accent);
500
+ color: white;
501
+ cursor: pointer;
502
+ }
503
+ .forge-input button:disabled { opacity: 0.4; cursor: not-allowed; }
504
+ .forge-spec {
505
+ border-left: 1px solid var(--border);
506
+ padding: 16px;
507
+ overflow-y: auto;
508
+ background: var(--surface-2);
509
+ display: flex;
510
+ flex-direction: column;
511
+ }
512
+ .forge-spec h3 { margin: 0 0 8px 0; }
513
+ .forge-spec textarea {
514
+ width: 100%;
515
+ font-family: ui-monospace, SFMono-Regular, monospace;
516
+ font-size: 11px;
517
+ padding: 6px;
518
+ border: 1px solid var(--border);
519
+ border-radius: 4px;
520
+ flex: 1;
521
+ }
522
+ .forge-spec-actions {
523
+ display: flex;
524
+ gap: 8px;
525
+ margin-top: 8px;
526
+ align-items: center;
527
+ }
528
+ .forge-spec-actions button {
529
+ padding: 4px 10px;
530
+ border-radius: 4px;
531
+ border: 1px solid var(--border);
532
+ background: var(--surface);
533
+ cursor: pointer;
534
+ }
535
+ .forge-spec-msg { font-size: 11px; color: var(--muted); }
536
+ #forgeNew,
537
+ #councilNew,
538
+ #autoloopNew {
539
+ margin: 8px 12px;
540
+ padding: 6px 12px;
541
+ border-radius: 4px;
542
+ border: 1px solid var(--accent);
543
+ background: var(--accent);
544
+ color: white;
545
+ cursor: pointer;
546
+ width: calc(100% - 24px);
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
+ }
651
+ #forgeStartBuild {
652
+ margin-top: 8px;
653
+ padding: 8px 14px;
654
+ border-radius: 4px;
655
+ border: 1px solid var(--accent);
656
+ background: var(--accent);
657
+ color: white;
658
+ cursor: pointer;
659
+ font-weight: 600;
660
+ }
661
+ #forgeStartBuild:disabled { opacity: 0.4; cursor: not-allowed; }
662
+ .forge-versions { margin-top: 12px; }
663
+ .forge-versions h4 { margin: 0 0 6px 0; font-size: 12px; color: var(--muted); text-transform: uppercase; }
664
+ .version-list { list-style: none; padding: 0; margin: 0; }
665
+ .version-row {
666
+ display: flex;
667
+ gap: 8px;
668
+ align-items: center;
669
+ padding: 4px 0;
670
+ font-size: 12px;
671
+ border-top: 1px solid var(--border);
672
+ }
673
+ .version-tag {
674
+ background: var(--surface-2);
675
+ padding: 1px 6px;
676
+ border-radius: 3px;
677
+ font-family: monospace;
678
+ font-weight: 600;
679
+ }
680
+ .version-meta { color: var(--muted); }
681
+ .version-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
682
+ .version-row button {
683
+ margin-left: auto;
684
+ padding: 2px 8px;
685
+ font-size: 11px;
686
+ border: 1px solid var(--border);
687
+ background: var(--surface);
688
+ border-radius: 3px;
689
+ cursor: pointer;
690
+ }
691
+ .version-row button:disabled { opacity: 0.4; cursor: not-allowed; }
356
692
  </style>
357
693
  </head>
358
694
  <body>
@@ -362,6 +698,7 @@
362
698
  <nav class="tabs" role="tablist">
363
699
  <button class="tab" role="tab" aria-selected="true" data-tab="autoloop">Autoloop</button>
364
700
  <button class="tab" role="tab" aria-selected="false" data-tab="council">Council</button>
701
+ <button class="tab" role="tab" aria-selected="false" data-tab="forge">Forge</button>
365
702
  </nav>
366
703
  <div class="grow"></div>
367
704
  <small id="status">connected</small>
@@ -370,6 +707,9 @@
370
707
  <div class="body">
371
708
  <aside class="sidebar">
372
709
  <h2 id="sidebarTitle">Runs</h2>
710
+ <button id="autoloopNew" hidden>+ New autoloop</button>
711
+ <button id="councilNew" hidden>+ New council</button>
712
+ <button id="forgeNew" hidden>+ New forge</button>
373
713
  <ul class="runlist" id="runlist"></ul>
374
714
  </aside>
375
715
 
@@ -381,6 +721,60 @@
381
721
  <div class="footer">SSE-driven dashboard • served by claw-orchestrator embedded server</div>
382
722
  </div>
383
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
+
384
778
  <script>
385
779
  // ─── State ────────────────────────────────────────────────────
386
780
  const state = {
@@ -402,6 +796,14 @@
402
796
  events: [],
403
797
  eventSource: null,
404
798
  },
799
+ // forge run detail
800
+ forge: {
801
+ runId: null,
802
+ spec: null,
803
+ mode: 'interview',
804
+ currentQuestion: null,
805
+ eventSource: null,
806
+ },
405
807
  };
406
808
 
407
809
  const $ = (id) => document.getElementById(id);
@@ -423,7 +825,15 @@
423
825
  document.querySelectorAll('.tab').forEach((b) => {
424
826
  b.setAttribute('aria-selected', String(b.dataset.tab === tab));
425
827
  });
426
- $('sidebarTitle').textContent = tab === 'autoloop' ? 'Autoloop runs' : 'Council sessions';
828
+ $('sidebarTitle').textContent =
829
+ tab === 'autoloop'
830
+ ? 'Autoloop runs'
831
+ : tab === 'council'
832
+ ? 'Council sessions'
833
+ : 'Forge apps';
834
+ $('forgeNew').hidden = tab !== 'forge';
835
+ $('councilNew').hidden = tab !== 'council';
836
+ $('autoloopNew').hidden = tab !== 'autoloop';
427
837
  state.selectedId = null;
428
838
  closeStreams();
429
839
  renderEmptyMain();
@@ -439,6 +849,10 @@
439
849
  state.council.eventSource.close();
440
850
  state.council.eventSource = null;
441
851
  }
852
+ if (state.forge.eventSource) {
853
+ state.forge.eventSource.close();
854
+ state.forge.eventSource = null;
855
+ }
442
856
  // Reset caches.
443
857
  state.autoloop.state = null;
444
858
  state.autoloop.plannerLog = [];
@@ -447,20 +861,64 @@
447
861
  state.autoloop.pushLog = [];
448
862
  state.council.session = null;
449
863
  state.council.events = [];
864
+ state.forge.runId = null;
865
+ state.forge.spec = null;
866
+ state.forge.currentQuestion = null;
867
+ state.forge.mode = 'interview';
450
868
  }
451
869
 
452
870
  function renderEmptyMain() {
453
- $('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
+ }
454
898
  }
455
899
 
456
900
  // ─── List polling ─────────────────────────────────────────────
457
901
  async function refreshList() {
458
902
  try {
459
- const url = state.tab === 'autoloop' ? '/autoloop/list' : '/council/list';
460
- const r = await fetch(url);
903
+ const url =
904
+ state.tab === 'autoloop'
905
+ ? '/autoloop/list'
906
+ : state.tab === 'council'
907
+ ? '/council/list'
908
+ : '/ultraapp/list';
909
+ const opts = state.tab === 'forge' ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' } : {};
910
+ const r = await fetch(url, opts);
461
911
  const j = await r.json();
462
- state.runs = state.tab === 'autoloop' ? j.runs || [] : j.councils || [];
912
+ state.runs =
913
+ state.tab === 'autoloop'
914
+ ? j.runs || []
915
+ : state.tab === 'council'
916
+ ? j.councils || []
917
+ : j.runs || [];
463
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();
464
922
  $('status').textContent = 'connected';
465
923
  } catch (err) {
466
924
  $('status').textContent = 'disconnected';
@@ -471,9 +929,11 @@
471
929
  function renderList() {
472
930
  const ul = $('runlist');
473
931
  if (state.runs.length === 0) {
932
+ const noun =
933
+ state.tab === 'autoloop' ? 'runs' : state.tab === 'council' ? 'councils' : 'apps';
474
934
  ul.innerHTML =
475
935
  '<li class="empty" style="padding:20px;list-style:none">No active ' +
476
- (state.tab === 'autoloop' ? 'runs' : 'councils') +
936
+ noun +
477
937
  '.</li>';
478
938
  return;
479
939
  }
@@ -488,8 +948,11 @@
488
948
  <span>iter ${r.iter}</span>
489
949
  <span>↑${r.push_log_count}</span>
490
950
  </div>
951
+ <div class="lifecycle-actions">
952
+ <button data-act="delete">Delete</button>
953
+ </div>
491
954
  </li>`;
492
- } else {
955
+ } else if (state.tab === 'council') {
493
956
  return `
494
957
  <li class="runItem" data-id="${escapeHtml(r.id)}" aria-selected="${r.id === state.selectedId}">
495
958
  <div class="id">${escapeHtml(r.id)}</div>
@@ -498,16 +961,70 @@
498
961
  <span>${r.responses?.length ?? 0} resp</span>
499
962
  </div>
500
963
  </li>`;
964
+ } else {
965
+ const showLifecycle = r.mode === 'done' || r.mode === 'failed' || r.mode === 'build-complete';
966
+ const lifecycleHtml = showLifecycle
967
+ ? `<div class="lifecycle-actions">
968
+ ${r.mode === 'done' ? `<button data-act="stop">Stop</button>` : ''}
969
+ ${r.mode !== 'done' ? `<button data-act="start">Start</button>` : ''}
970
+ <button data-act="delete">Delete</button>
971
+ </div>`
972
+ : '';
973
+ return `
974
+ <li class="runItem" data-id="${escapeHtml(r.runId)}" aria-selected="${r.runId === state.selectedId}">
975
+ <div class="id">${escapeHtml(r.title || r.runId)}</div>
976
+ <div class="meta">
977
+ <span class="badge ${r.mode}">${r.mode}</span>
978
+ </div>
979
+ ${lifecycleHtml}
980
+ </li>`;
501
981
  }
502
982
  })
503
983
  .join('');
504
984
  ul.innerHTML = html;
505
985
  ul.querySelectorAll('.runItem').forEach((el) => {
506
- el.addEventListener('click', () => {
986
+ // Click on item itself (not action button) opens it
987
+ el.addEventListener('click', (ev) => {
988
+ const t = ev.target;
989
+ if (t && t.closest('.lifecycle-actions')) return;
507
990
  state.selectedId = el.dataset.id;
508
991
  renderList();
509
992
  openDetail(el.dataset.id);
510
993
  });
994
+ el.querySelectorAll('.lifecycle-actions button').forEach((btn) => {
995
+ btn.addEventListener('click', async (ev) => {
996
+ ev.stopPropagation();
997
+ const action = btn.dataset.act;
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
+ }
1023
+ if (action === 'delete' && !confirm('Delete this run? Container, image, and on-disk artifacts are removed.')) return;
1024
+ await forgeLifecycleAction(id, action);
1025
+ refreshList();
1026
+ });
1027
+ });
511
1028
  });
512
1029
  }
513
1030
 
@@ -515,7 +1032,410 @@
515
1032
  function openDetail(id) {
516
1033
  closeStreams();
517
1034
  if (state.tab === 'autoloop') openAutoloop(id);
518
- else openCouncil(id);
1035
+ else if (state.tab === 'council') openCouncil(id);
1036
+ else openForgeRun(id);
1037
+ }
1038
+
1039
+ // ─── Forge ────────────────────────────────────────────────────
1040
+ $('forgeNew').addEventListener('click', async () => {
1041
+ const r = await fetch('/ultraapp/new', {
1042
+ method: 'POST',
1043
+ headers: { 'Content-Type': 'application/json' },
1044
+ body: '{}',
1045
+ });
1046
+ const j = await r.json();
1047
+ if (!j.ok) {
1048
+ alert('Failed to create run: ' + (j.error || 'unknown'));
1049
+ return;
1050
+ }
1051
+ state.selectedId = j.runId;
1052
+ await refreshList();
1053
+ openForgeRun(j.runId);
1054
+ });
1055
+
1056
+ function renderForgeShell() {
1057
+ $('main').innerHTML = `
1058
+ <div class="forge-layout">
1059
+ <div class="forge-chat">
1060
+ <div class="forge-mode" id="forgeMode" data-mode="interview">interview</div>
1061
+ <div class="forge-messages" id="forgeMessages"></div>
1062
+ <div class="forge-input">
1063
+ <textarea id="forgeFreeform" placeholder="Free-form input (or click an option above and Submit)" rows="2"></textarea>
1064
+ <button id="forgeSubmit">Submit</button>
1065
+ </div>
1066
+ <div class="forge-input-row">
1067
+ <input type="file" id="forgeFile" multiple />
1068
+ <input type="text" id="forgePath" placeholder="…or paste an absolute path" />
1069
+ </div>
1070
+ </div>
1071
+ <div class="forge-spec">
1072
+ <h3>AppSpec</h3>
1073
+ <textarea id="forgeSpecJson" rows="20" spellcheck="false"></textarea>
1074
+ <div class="forge-spec-actions">
1075
+ <button id="forgeSpecApply">Apply edit</button>
1076
+ <span class="forge-spec-msg" id="forgeSpecMsg"></span>
1077
+ </div>
1078
+ <button id="forgeStartBuild" disabled>Start Build</button>
1079
+ <div id="forgeVersions" class="forge-versions"></div>
1080
+ </div>
1081
+ </div>
1082
+ `;
1083
+ $('forgeSubmit').addEventListener('click', forgeOnSubmit);
1084
+ $('forgeSpecApply').addEventListener('click', forgeOnSpecApply);
1085
+ $('forgeStartBuild').addEventListener('click', forgeOnStartBuild);
1086
+ }
1087
+
1088
+ async function forgeOnStartBuild() {
1089
+ const runId = state.forge.runId;
1090
+ if (!runId) return;
1091
+ const btn = $('forgeStartBuild');
1092
+ if (btn) btn.disabled = true;
1093
+ try {
1094
+ const r = await fetch('/ultraapp/' + encodeURIComponent(runId) + '/build', {
1095
+ method: 'POST',
1096
+ headers: { 'Content-Type': 'application/json' },
1097
+ body: '{}',
1098
+ });
1099
+ const j = await r.json();
1100
+ if (!j.ok) {
1101
+ renderChatEntry({ role: 'system', kind: 'error', text: '[error] start build: ' + (j.error || 'unknown') });
1102
+ if (btn) btn.disabled = false;
1103
+ }
1104
+ } catch (err) {
1105
+ renderChatEntry({ role: 'system', kind: 'error', text: '[error] start build: ' + err.message });
1106
+ if (btn) btn.disabled = false;
1107
+ }
1108
+ }
1109
+
1110
+ async function openForgeRun(runId) {
1111
+ state.forge.runId = runId;
1112
+ renderForgeShell();
1113
+ try {
1114
+ const r = await fetch('/ultraapp/' + encodeURIComponent(runId), {
1115
+ method: 'POST',
1116
+ headers: { 'Content-Type': 'application/json' },
1117
+ body: '{}',
1118
+ });
1119
+ const j = await r.json();
1120
+ if (!j.ok) {
1121
+ $('forgeMessages').textContent = 'error: ' + (j.error || 'unknown');
1122
+ return;
1123
+ }
1124
+ state.forge.spec = j.spec;
1125
+ state.forge.mode = j.state.mode;
1126
+ renderSpec(j.spec);
1127
+ setForgeMode(j.state.mode);
1128
+ for (const e of j.chat || []) renderChatEntry(e);
1129
+ } catch (err) {
1130
+ $('forgeMessages').textContent = 'fetch failed: ' + err.message;
1131
+ return;
1132
+ }
1133
+ const es = new EventSource('/ultraapp/' + encodeURIComponent(runId) + '/events');
1134
+ state.forge.eventSource = es;
1135
+ es.addEventListener('ultraapp', (ev) => {
1136
+ const data = JSON.parse(ev.data);
1137
+ if (data.type === 'question') renderQuestion(data.question);
1138
+ else if (data.type === 'spec-updated') {
1139
+ state.forge.spec = data.spec;
1140
+ renderSpec(data.spec);
1141
+ } else if (data.type === 'chat') renderChatEntry(data.entry);
1142
+ else if (data.type === 'interview-complete') {
1143
+ renderChatEntry({ role: 'system', kind: 'narrator', text: 'Interview complete: ' + data.summary });
1144
+ $('forgeStartBuild').disabled = false;
1145
+ } else if (data.type === 'build-event') {
1146
+ const ev = data.event;
1147
+ if (ev.type === 'queued') setForgeMode('queued');
1148
+ else if (ev.type === 'build-start') setForgeMode('building');
1149
+ else if (ev.type === 'build-complete') setForgeMode('build-complete');
1150
+ else if (ev.type === 'build-failed') {
1151
+ setForgeMode('failed');
1152
+ const btn = $('forgeStartBuild');
1153
+ if (btn) btn.disabled = false;
1154
+ } else if (ev.type === 'build-cancelled') {
1155
+ setForgeMode('failed');
1156
+ const btn = $('forgeStartBuild');
1157
+ if (btn) btn.disabled = false;
1158
+ }
1159
+ } else if (data.type === 'app-url') {
1160
+ setForgeMode('done');
1161
+ renderShareCard(data.url, data.version);
1162
+ } else if (data.type === 'error') {
1163
+ renderChatEntry({ role: 'system', kind: 'error', text: '[error] ' + data.message });
1164
+ }
1165
+ });
1166
+ es.onerror = () => {
1167
+ /* allow auto-reconnect */
1168
+ };
1169
+ }
1170
+
1171
+ function setForgeMode(m) {
1172
+ state.forge.mode = m;
1173
+ const el = $('forgeMode');
1174
+ if (el) {
1175
+ el.dataset.mode = m;
1176
+ el.textContent = m;
1177
+ }
1178
+ const btn = $('forgeStartBuild');
1179
+ if (btn) {
1180
+ // Disabled while interview, queued, or building. Enabled at any
1181
+ // resting state from which a (re)build is sensible.
1182
+ const lockedModes = new Set(['interview', 'queued', 'building']);
1183
+ btn.disabled = lockedModes.has(m);
1184
+ }
1185
+ // Hint the input's role per mode (interview = answer; done = feedback).
1186
+ const ff = $('forgeFreeform');
1187
+ if (ff) {
1188
+ ff.placeholder =
1189
+ m === 'done'
1190
+ ? 'Done — type feedback (e.g. "make button green" / "add a thumbnail step")'
1191
+ : 'Free-form input (or click an option above and Submit)';
1192
+ }
1193
+ // Refresh versions panel when entering a state that may have artifacts.
1194
+ if (m === 'done' || m === 'build-complete') {
1195
+ void forgeRefreshVersions();
1196
+ }
1197
+ }
1198
+
1199
+ function renderSpec(spec) {
1200
+ const ta = $('forgeSpecJson');
1201
+ if (!ta) return;
1202
+ if (document.activeElement !== ta) {
1203
+ ta.value = JSON.stringify(spec, null, 2);
1204
+ }
1205
+ }
1206
+
1207
+ function renderChatEntry(entry) {
1208
+ const m = $('forgeMessages');
1209
+ if (!m) return;
1210
+ const div = document.createElement('div');
1211
+ div.className = 'forge-msg ' + entry.role;
1212
+ div.textContent = entry.text;
1213
+ m.appendChild(div);
1214
+ m.scrollTop = m.scrollHeight;
1215
+ }
1216
+
1217
+ function renderShareCard(url, version) {
1218
+ const m = $('forgeMessages');
1219
+ if (!m) return;
1220
+ const card = document.createElement('div');
1221
+ card.className = 'forge-share';
1222
+ card.innerHTML =
1223
+ `<strong>App deployed (${escapeHtml(version)})</strong>` +
1224
+ `<div><a href="${escapeHtml(url)}" target="_blank">${escapeHtml(url)}</a></div>` +
1225
+ `<div class="forge-share-actions">` +
1226
+ `<button data-act="copy">Copy</button>` +
1227
+ `<button data-act="open">Open</button>` +
1228
+ `<button data-act="public">Make Public…</button>` +
1229
+ `</div>`;
1230
+ card.querySelector('[data-act="copy"]').addEventListener('click', () => {
1231
+ navigator.clipboard?.writeText(url);
1232
+ });
1233
+ card.querySelector('[data-act="open"]').addEventListener('click', () => {
1234
+ window.open(url, '_blank');
1235
+ });
1236
+ card.querySelector('[data-act="public"]').addEventListener('click', () => openPublicModal(url));
1237
+ m.appendChild(card);
1238
+ m.scrollTop = m.scrollHeight;
1239
+ }
1240
+
1241
+ function openPublicModal(localUrl) {
1242
+ const dlg = document.createElement('dialog');
1243
+ dlg.className = 'forge-public-modal';
1244
+ const u = new URL(localUrl);
1245
+ dlg.innerHTML =
1246
+ `<h3>Make this app public</h3>` +
1247
+ `<p>This app currently only runs on your machine. To share it, expose port ${escapeHtml(String(u.port || '19000'))} with one of these tools:</p>` +
1248
+ `<h4>Cloudflare Tunnel</h4>` +
1249
+ `<pre>cloudflared tunnel --url ${escapeHtml(u.origin)}</pre>` +
1250
+ `<h4>ngrok</h4>` +
1251
+ `<pre>ngrok http ${escapeHtml(u.port || '19000')}</pre>` +
1252
+ `<h4>Tailscale Funnel</h4>` +
1253
+ `<pre>tailscale serve --bg ${escapeHtml(u.origin)}</pre>` +
1254
+ `<h4>Self-hosted (Caddy)</h4>` +
1255
+ `<pre>example.com {\n reverse_proxy ${escapeHtml(u.host)}\n}</pre>` +
1256
+ `<p>Once exposed, your app will be reachable at <code>{your-public-host}${escapeHtml(u.pathname)}</code>.</p>` +
1257
+ `<button data-act="close">Close</button>`;
1258
+ document.body.appendChild(dlg);
1259
+ dlg.showModal();
1260
+ dlg.querySelector('[data-act="close"]').addEventListener('click', () => {
1261
+ dlg.close();
1262
+ dlg.remove();
1263
+ });
1264
+ }
1265
+
1266
+ async function forgeLifecycleAction(runId, action) {
1267
+ try {
1268
+ const r = await fetch('/ultraapp/' + encodeURIComponent(runId) + '/' + action, {
1269
+ method: 'POST',
1270
+ headers: { 'Content-Type': 'application/json' },
1271
+ body: '{}',
1272
+ });
1273
+ const j = await r.json();
1274
+ if (!j.ok) {
1275
+ renderChatEntry({ role: 'system', kind: 'error', text: '[error] ' + action + ': ' + (j.error || 'unknown') });
1276
+ }
1277
+ if (action === 'delete' && j.ok && state.forge.runId === runId) {
1278
+ // Run is gone — close it and refresh sidebar
1279
+ state.forge.runId = null;
1280
+ $('forgeMessages').innerHTML = '';
1281
+ }
1282
+ } catch (err) {
1283
+ renderChatEntry({ role: 'system', kind: 'error', text: '[error] ' + action + ': ' + err.message });
1284
+ }
1285
+ }
1286
+
1287
+ function renderQuestion(q) {
1288
+ state.forge.currentQuestion = q;
1289
+ const m = $('forgeMessages');
1290
+ if (!m) return;
1291
+ const card = document.createElement('div');
1292
+ card.className = 'forge-question';
1293
+ card.innerHTML =
1294
+ (q.context ? `<div class="ctx">${escapeHtml(q.context)}</div>` : '') +
1295
+ `<div class="q">${escapeHtml(q.question)}</div>` +
1296
+ `<div class="opts">` +
1297
+ q.options
1298
+ .map(
1299
+ (o) =>
1300
+ `<span class="opt ${o.value === q.recommended ? 'recommended' : ''}" data-val="${escapeHtml(o.value)}">${escapeHtml(o.label)}</span>`,
1301
+ )
1302
+ .join('') +
1303
+ `</div>`;
1304
+ card.querySelectorAll('.opt').forEach((el) => {
1305
+ el.addEventListener('click', () => {
1306
+ card.querySelectorAll('.opt').forEach((x) => x.classList.remove('selected'));
1307
+ el.classList.add('selected');
1308
+ state.forge.currentQuestion._selected = el.dataset.val;
1309
+ });
1310
+ });
1311
+ m.appendChild(card);
1312
+ m.scrollTop = m.scrollHeight;
1313
+ }
1314
+
1315
+ async function forgeOnSubmit() {
1316
+ const runId = state.forge.runId;
1317
+ if (!runId) return;
1318
+ const ff = $('forgeFreeform');
1319
+ const path = $('forgePath');
1320
+ const fileInput = $('forgeFile');
1321
+
1322
+ for (const f of fileInput.files) {
1323
+ const buf = await f.arrayBuffer();
1324
+ const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
1325
+ await fetch('/ultraapp/' + encodeURIComponent(runId) + '/files', {
1326
+ method: 'POST',
1327
+ headers: { 'Content-Type': 'application/json' },
1328
+ body: JSON.stringify({ filename: f.name, dataB64: b64 }),
1329
+ });
1330
+ }
1331
+ fileInput.value = '';
1332
+
1333
+ if (path.value.trim()) {
1334
+ await fetch('/ultraapp/' + encodeURIComponent(runId) + '/files', {
1335
+ method: 'POST',
1336
+ headers: { 'Content-Type': 'application/json' },
1337
+ body: JSON.stringify({ absolutePath: path.value.trim() }),
1338
+ });
1339
+ path.value = '';
1340
+ }
1341
+
1342
+ const freeform = ff.value.trim();
1343
+ const selected = state.forge.currentQuestion?._selected;
1344
+ // Mode-aware routing: in done mode, free-form text is feedback,
1345
+ // not an interview answer. /feedback runs through the classifier
1346
+ // and routes to patcher / spec-delta / structural-suggest.
1347
+ if (state.forge.mode === 'done' && freeform) {
1348
+ await fetch('/ultraapp/' + encodeURIComponent(runId) + '/feedback', {
1349
+ method: 'POST',
1350
+ headers: { 'Content-Type': 'application/json' },
1351
+ body: JSON.stringify({ text: freeform }),
1352
+ });
1353
+ ff.value = '';
1354
+ return;
1355
+ }
1356
+ if (freeform || selected) {
1357
+ const body = freeform ? { value: selected ?? '', freeform } : { value: selected };
1358
+ await fetch('/ultraapp/' + encodeURIComponent(runId) + '/answer', {
1359
+ method: 'POST',
1360
+ headers: { 'Content-Type': 'application/json' },
1361
+ body: JSON.stringify(body),
1362
+ });
1363
+ ff.value = '';
1364
+ state.forge.currentQuestion = null;
1365
+ }
1366
+ }
1367
+
1368
+ async function forgeRefreshVersions() {
1369
+ const runId = state.forge.runId;
1370
+ if (!runId) return;
1371
+ const panel = $('forgeVersions');
1372
+ if (!panel) return;
1373
+ try {
1374
+ const r = await fetch('/ultraapp/' + encodeURIComponent(runId) + '/artifacts', {
1375
+ method: 'POST',
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ body: '{}',
1378
+ });
1379
+ const j = await r.json();
1380
+ if (!j.ok || !j.artifacts || j.artifacts.length === 0) {
1381
+ panel.innerHTML = '';
1382
+ return;
1383
+ }
1384
+ const html = j.artifacts
1385
+ .map((a) => {
1386
+ const isDeployed = !!a.deploy;
1387
+ const url = a.deploy?.url || '';
1388
+ return `<li class="version-row">
1389
+ <span class="version-tag">${escapeHtml(a.version)}</span>
1390
+ <span class="version-meta">${a.builtAt ? new Date(a.builtAt).toLocaleString() : ''}</span>
1391
+ ${isDeployed ? `<a href="${escapeHtml(url)}" target="_blank">${escapeHtml(url)}</a>` : '<span class="version-meta">(not deployed)</span>'}
1392
+ ${isDeployed ? `<button data-promote="${escapeHtml(a.version)}">Promote</button>` : ''}
1393
+ </li>`;
1394
+ })
1395
+ .join('');
1396
+ panel.innerHTML = '<h4>Versions</h4><ul class="version-list">' + html + '</ul>';
1397
+ panel.querySelectorAll('button[data-promote]').forEach((btn) => {
1398
+ btn.addEventListener('click', async () => {
1399
+ const v = btn.dataset.promote;
1400
+ if (!confirm(`Promote ${v} as the deployed version?`)) return;
1401
+ btn.disabled = true;
1402
+ const r = await fetch('/ultraapp/' + encodeURIComponent(runId) + '/promote-version', {
1403
+ method: 'POST',
1404
+ headers: { 'Content-Type': 'application/json' },
1405
+ body: JSON.stringify({ version: v }),
1406
+ });
1407
+ const j = await r.json();
1408
+ if (!j.ok) {
1409
+ renderChatEntry({ role: 'system', kind: 'error', text: '[error] promote: ' + (j.error || 'unknown') });
1410
+ }
1411
+ btn.disabled = false;
1412
+ await forgeRefreshVersions();
1413
+ });
1414
+ });
1415
+ } catch (err) {
1416
+ /* best-effort */
1417
+ }
1418
+ }
1419
+
1420
+ async function forgeOnSpecApply() {
1421
+ const runId = state.forge.runId;
1422
+ if (!runId) return;
1423
+ const msg = $('forgeSpecMsg');
1424
+ msg.textContent = '';
1425
+ let next;
1426
+ try {
1427
+ next = JSON.parse($('forgeSpecJson').value);
1428
+ } catch (e) {
1429
+ msg.textContent = 'JSON parse error: ' + e.message;
1430
+ return;
1431
+ }
1432
+ const r = await fetch('/ultraapp/' + encodeURIComponent(runId) + '/spec-edit', {
1433
+ method: 'POST',
1434
+ headers: { 'Content-Type': 'application/json' },
1435
+ body: JSON.stringify({ patch: [{ op: 'replace', path: '', value: next }] }),
1436
+ });
1437
+ const j = await r.json();
1438
+ msg.textContent = j.ok ? 'Saved.' : 'Error: ' + (j.error ?? 'unknown');
519
1439
  }
520
1440
 
521
1441
  // ─── Autoloop detail ──────────────────────────────────────────
@@ -590,9 +1510,21 @@
590
1510
  });
591
1511
  es.addEventListener('planner_reply', (e) => {
592
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');
593
1515
  state.autoloop.plannerLog.push({ who: 'planner', text, ts: new Date().toISOString() });
594
1516
  paintPlanner();
595
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
+ });
596
1528
  es.addEventListener('coder_reply', (e) => {
597
1529
  const { text } = JSON.parse(e.data);
598
1530
  state.autoloop.coderLog.push({ who: 'coder', text, ts: new Date().toISOString() });
@@ -741,16 +1673,47 @@
741
1673
  const btn = $('auto-send');
742
1674
  btn.disabled = true;
743
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 = '';
744
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
+ });
745
1687
  paintPlanner();
746
1688
  try {
747
- await fetch('/v1/openclaw/tools/autoloop_chat', {
1689
+ const r = await fetch(`/autoloop/${encodeURIComponent(id)}/chat`, {
748
1690
  method: 'POST',
749
1691
  headers: { 'content-type': 'application/json' },
750
- body: JSON.stringify({ run_id: id, text }),
1692
+ body: JSON.stringify({ text }),
751
1693
  });
752
- 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).
753
1715
  } catch (err) {
1716
+ state.autoloop.plannerLog = state.autoloop.plannerLog.filter((m) => m.who !== 'pending');
754
1717
  state.autoloop.plannerLog.push({
755
1718
  who: 'system',
756
1719
  text: `[error] ${(err && err.message) || 'send failed'}`,
@@ -903,7 +1866,98 @@
903
1866
  el.scrollTop = el.scrollHeight;
904
1867
  }
905
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
+
906
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';
907
1961
  refreshList();
908
1962
  </script>
909
1963
  </body>