bareloop 0.31.0 → 0.32.0

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.
@@ -330,12 +330,9 @@
330
330
  .expand-field:focus{height:52px;white-space:normal;}
331
331
  .radio-row{display:flex;gap:14px;align-items:center;margin-bottom:10px;}
332
332
  .radio-row label{display:flex;align-items:center;gap:5px;font-size:13px;color:var(--text);margin:0;text-transform:none;}
333
- .cap-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-bottom:6px;}
333
+ .cap-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:6px;margin-bottom:6px;}
334
334
  .cap-field label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:1px;}
335
335
  .cap-field input{width:100%;padding:5px 6px;box-sizing:border-box;}
336
- @media (max-width: 899px){
337
- .cap-row{grid-template-columns:1fr;}
338
- }
339
336
  .btn.primary{background:var(--primary-bg);color:var(--primary-text);border-color:var(--primary-border);font-weight:700;}
340
337
  .btn.primary::before,.btn.primary::after{color:var(--primary-text);opacity:0.6;}
341
338
  .btn:disabled{opacity:0.5;cursor:not-allowed;}
@@ -377,6 +374,32 @@
377
374
  .actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
378
375
  .actions-row > .btn{flex:1 1 auto;}
379
376
 
377
+ /* Settings view (P4a) — same tokens as the rest of the page; the mockup's settings styles */
378
+ .settings-view{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
379
+ .settings-header{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);}
380
+ .settings-header h2{margin:0;font-size:15px;display:inline;}
381
+ .settings-header h2::before{content:"┤ ";color:var(--text-faint);}
382
+ .settings-header h2::after{content:" ├";color:var(--text-faint);}
383
+ .money-strip{display:grid;grid-template-columns:repeat(3, 1fr);gap:12px;align-items:end;padding:10px;border:1px solid var(--border-strong);background:var(--panel2);}
384
+ .money-item{min-width:0;}
385
+ .money-item label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;}
386
+ .money-value{font-size:16px;font-weight:700;padding:6px 0;overflow-wrap:anywhere;}
387
+ .money-item input{width:100%;height:44px;box-sizing:border-box;}
388
+ .keyfile-strip{position:relative;border:1px solid var(--border-strong);padding:12px 12px 8px;background:var(--panel2);margin:14px 0;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
389
+ .keyfile-strip::before{content:"┤ KEYS ├";position:absolute;top:-9px;left:10px;background:var(--panel2);padding:0 4px;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;}
390
+ .keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:220px;}
391
+ .keyfile-strip-actions{display:flex;gap:6px;flex:none;}
392
+ .keyfile-strip code{background:var(--bg);border:1px solid var(--border-strong);padding:1px 5px;font-size:11px;color:var(--amber);overflow-wrap:anywhere;}
393
+ .pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
394
+ .pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
395
+ .pv-table .hint{display:block;margin-top:3px;}
396
+ /* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
397
+ .pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
398
+ .pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
399
+ .table-wrap{overflow-x:auto;border:1px solid var(--border-strong);background:var(--panel2);max-width:100%;}
400
+ @media (max-width: 899px){
401
+ .money-strip{grid-template-columns:1fr 1fr;}
402
+ }
380
403
  @media (max-width: 899px){
381
404
  .main{flex-direction:column;}
382
405
  .left-pane{width:100%;border-right:none;border-bottom:1px solid var(--border-strong);}
@@ -402,6 +425,7 @@
402
425
  </div>
403
426
  <div class="topbar-right">
404
427
  <span class="hint" id="host-line"></span>
428
+ <button class="btn small" type="button" id="btn-settings" data-testid="btn-settings">&#9881; Settings</button>
405
429
  <button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light/dark theme">&#9789;</button>
406
430
  </div>
407
431
  </header>
@@ -431,10 +455,7 @@
431
455
  </div>
432
456
  <div class="field">
433
457
  <label for="jf-model">Model</label>
434
- <select id="jf-model">
435
- <option value="claude-sonnet-5">claude-sonnet-5 · Anthropic</option>
436
- <option value="deepseek-flash">deepseek-flash · DeepSeek</option>
437
- </select>
458
+ <select id="jf-model" data-testid="jf-model"></select>
438
459
  <!-- build item 5 (2026-09-28) — ONE line under Model, not two:
439
460
  key + reachability collapsed into a single readiness read. -->
440
461
  <div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
@@ -472,15 +493,12 @@
472
493
  <div class="cap-field">
473
494
  <label for="jf-cap-money">Cap $</label>
474
495
  <input id="jf-cap-money" type="text">
496
+ <div class="hint" id="jf-cap-note" data-testid="cap-note"></div>
475
497
  </div>
476
498
  <div class="cap-field">
477
499
  <label for="jf-cap-time">Time cap (min)</label>
478
500
  <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
479
501
  </div>
480
- <div class="cap-field">
481
- <label for="jf-price">Token price $</label>
482
- <input id="jf-price" type="text" placeholder="est." disabled>
483
- </div>
484
502
  </div>
485
503
  <button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
486
504
  <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
@@ -620,6 +638,74 @@
620
638
 
621
639
  </div>
622
640
 
641
+ <!-- SETTINGS VIEW (P4a) — reads/writes ~/.config/bareloop/config.json through
642
+ /api/settings/*; every call carries the per-start token. -->
643
+ <section class="settings-view" id="settings-view" aria-label="Settings" hidden>
644
+ <div class="settings-header">
645
+ <button class="btn small" type="button" id="settings-back" data-testid="settings-back">&larr; Back</button>
646
+ <h2>Settings</h2>
647
+ </div>
648
+
649
+ <div class="tabrow" role="tablist" aria-label="Settings views">
650
+ <button role="tab" id="tab-providers" aria-controls="panel-providers" aria-selected="true" data-testid="settings-tab-providers">Providers</button>
651
+ <button role="tab" id="tab-money" aria-controls="panel-money" aria-selected="false" data-testid="settings-tab-money">Money &amp; limits</button>
652
+ </div>
653
+
654
+ <div class="pane-body">
655
+ <!-- PROVIDERS TAB (P4b: one row per key in the keys file that has a value) -->
656
+ <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
657
+ <div class="keyfile-strip" data-testid="keyfile-box">
658
+ <div class="keyfile-strip-main">
659
+ <span>Put <code>NAME=key</code> lines in <code id="pv-keyfile-path">~/.config/bareloop/.env</code>, then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
660
+ <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
661
+ </div>
662
+ <div class="keyfile-strip-actions">
663
+ <button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
664
+ </div>
665
+ </div>
666
+ <div class="table-wrap" data-testid="provider-table-wrap">
667
+ <table class="pv-table" data-testid="provider-table">
668
+ <thead>
669
+ <tr><th>Key</th><th>Name</th><th>API shape</th><th>Base URL</th><th>Test</th><th>Tokens used</th><th>Balance</th></tr>
670
+ </thead>
671
+ <tbody id="pv-rows"></tbody>
672
+ </table>
673
+ </div>
674
+ <div id="pv-shell" data-testid="pv-shell"></div>
675
+ <div class="hint" id="pv-note" data-testid="pv-note" style="margin-top:6px;">Name is the model id Chat uses. Base URL blank = the API shape's own host. Test asks the provider for its model list — it costs nothing.</div>
676
+ </section>
677
+
678
+ <!-- MONEY & LIMITS TAB -->
679
+ <section id="panel-money" class="tabpanel" role="tabpanel" aria-labelledby="tab-money" hidden>
680
+ <div class="money-strip" data-testid="money-strip">
681
+ <div class="money-item">
682
+ <label>$ month / to date</label>
683
+ <div class="money-value" id="ml-usd" data-testid="ml-usd">…</div>
684
+ </div>
685
+ <div class="money-item">
686
+ <label>Tokens month / to date</label>
687
+ <div class="money-value" id="ml-tokens" data-testid="ml-tokens">…</div>
688
+ </div>
689
+ <div class="money-item">
690
+ <label for="ml-money">Monthly money limit ($)</label>
691
+ <input id="ml-money" type="text" placeholder="blank = no limit" data-testid="ml-money">
692
+ </div>
693
+ </div>
694
+ <div class="hint" id="ml-note" data-testid="ml-note" style="margin-top:6px;">Limits are yours. The agent can never change them. A run whose cap is more than what is left this month does not start.</div>
695
+
696
+ <h4 style="margin-top:20px;font-size:13px;">Per-provider breakdown</h4>
697
+ <div class="table-wrap">
698
+ <table data-testid="provider-breakdown-table">
699
+ <thead>
700
+ <tr><th>Provider</th><th>$ month / to date</th><th>minutes month / to date</th><th>tokens</th></tr>
701
+ </thead>
702
+ <tbody id="ml-breakdown"></tbody>
703
+ </table>
704
+ </div>
705
+ </section>
706
+ </div>
707
+ </section>
708
+
623
709
  </div>
624
710
 
625
711
  <script>
@@ -2672,6 +2758,156 @@
2672
2758
  });
2673
2759
  })();
2674
2760
 
2761
+ // ---- Settings view (PANEL-BUILD.md P4a) ----
2762
+ (function(){
2763
+ var mainView = document.getElementById("main-view");
2764
+ var settingsView = document.getElementById("settings-view");
2765
+ var limitInput = document.getElementById("ml-money");
2766
+ var noteEl = document.getElementById("ml-note");
2767
+ var NOTE_DEFAULT = noteEl.textContent;
2768
+ // table cells: an "at least" figure keeps its marker as a "≥" prefix on that number — never dropped
2769
+ function moneyCell(n, atLeast){ return (atLeast ? "≥" : "") + panelMoney(n); }
2770
+ // wall minutes: "unknown" when no run had a known wall (never a clean 0); 1 decimal under 100, else a whole number with a thousands separator
2771
+ function minutesCell(ms, atLeast){
2772
+ if(ms === null || ms === undefined) return "unknown";
2773
+ var m = ms / 60000;
2774
+ var txt = m < 100 ? m.toFixed(1) : Math.round(m).toLocaleString("en-US");
2775
+ return (atLeast ? "≥" : "") + txt + " min";
2776
+ }
2777
+ var keepNote = false;
2778
+ function loadMoney(){
2779
+ authorGet("/api/settings/money").then(function(r){
2780
+ if(!r || !r.ok){ noteEl.textContent = "could not read your spend"; return; }
2781
+ document.getElementById("ml-usd").textContent = moneyCell(r.monthUsd, r.monthAtLeast) + " / " + moneyCell(r.totalUsd, r.totalAtLeast);
2782
+ document.getElementById("ml-tokens").textContent = tokensText(r.monthTokens) + " / " + tokensText(r.totalTokens);
2783
+ if(document.activeElement !== limitInput){
2784
+ limitInput.value = r.monthlyLimitUsd === null ? "" : r.monthlyLimitUsd.toFixed(2);
2785
+ }
2786
+ var rows = r.byProvider.map(function(p){
2787
+ return "<tr><td>" + escapeXml(p.label)
2788
+ + "</td><td>" + escapeXml(moneyCell(p.monthUsd, p.monthAtLeast) + " / " + moneyCell(p.totalUsd, p.totalAtLeast))
2789
+ + "</td><td>" + escapeXml(minutesCell(p.monthWallMs, p.monthWallAtLeast) + " / " + minutesCell(p.totalWallMs, p.totalWallAtLeast))
2790
+ + "</td><td>" + escapeXml(tokensText(p.tokens)) + "</td></tr>";
2791
+ }).join("");
2792
+ document.getElementById("ml-breakdown").innerHTML = rows || '<tr><td colspan="4" class="hint">no runs yet</td></tr>';
2793
+ if(keepNote){ keepNote = false; return; }
2794
+ noteEl.textContent = r.configProblem ? r.configProblem : NOTE_DEFAULT;
2795
+ }).catch(function(){ noteEl.textContent = "could not read your spend"; });
2796
+ }
2797
+ document.getElementById("btn-settings").addEventListener("click", function(){
2798
+ mainView.hidden = true;
2799
+ settingsView.hidden = false;
2800
+ loadProviders();
2801
+ loadMoney();
2802
+ });
2803
+ document.getElementById("settings-back").addEventListener("click", function(){
2804
+ settingsView.hidden = true;
2805
+ mainView.hidden = false;
2806
+ });
2807
+ document.getElementById("tab-money").addEventListener("click", loadMoney);
2808
+
2809
+ // ---- Providers tab (P4b): one row per key in the keys file that has a value ----
2810
+ var pvRows = document.getElementById("pv-rows");
2811
+ var pvNote = document.getElementById("pv-note");
2812
+ var PV_NOTE_DEFAULT = pvNote.textContent;
2813
+ function tokensText(n){
2814
+ if(!n) return "0";
2815
+ if(n >= 1e6) return (n / 1e6).toFixed(1) + "M";
2816
+ if(n >= 1e3) return (n / 1e3).toFixed(1) + "k";
2817
+ return String(n);
2818
+ }
2819
+ function loadProviders(){
2820
+ authorGet("/api/settings/providers").then(function(r){
2821
+ if(!r || !r.ok){ pvNote.textContent = "could not read providers"; return; }
2822
+ var kf = r.keysFile;
2823
+ document.getElementById("pv-keyfile-path").textContent = kf.path;
2824
+ document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
2825
+ pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
2826
+ var e = escapeXml(row.envName);
2827
+ var shapeOpts = r.shapes.map(function(s){
2828
+ return '<option value="' + escapeXml(s.id) + '"' + (s.id === row.shape ? " selected" : "") + ">" + escapeXml(s.label) + "</option>";
2829
+ }).join("");
2830
+ var bal = row.balance.kind === "note"
2831
+ ? '<input type="text" class="pv-balance" placeholder="type by hand" value="' + (row.balance.usd === null ? "" : escapeXml(row.balance.usd.toFixed(2))) + '"><div class="hint">a note, never checked</div>'
2832
+ : (row.balance.kind === "fetch" ? '<span class="pv-balance-fetch" data-env="' + e + '">…</span>' : "—");
2833
+ return '<tr data-testid="provider-row-' + e + '" data-env="' + e + '">'
2834
+ + "<td><code>" + e + "</code>"
2835
+ + '<div class="hint" data-testid="pv-status-' + e + '">' + escapeXml(row.keyStatus) + "</div></td>"
2836
+ + '<td><input type="text" class="pv-name" data-testid="pv-name-' + e + '" placeholder="model id" value="' + escapeXml(row.name) + '"></td>'
2837
+ + '<td><select class="pv-shape" data-testid="pv-shape-' + e + '">' + shapeOpts + "</select></td>"
2838
+ + '<td><input type="text" class="pv-url" data-testid="pv-url-' + e + '" placeholder="' + escapeXml(row.placeholder || "the shape\'s own host") + '" value="' + escapeXml(row.baseUrl) + '"></td>'
2839
+ + '<td class="pv-test-cell">' + (row.canTest
2840
+ ? '<button class="btn small pv-test" type="button" data-testid="test-connection-' + e + '">Test</button><div class="hint pv-test-result" data-testid="test-result-' + e + '"></div>'
2841
+ : '<span class="hint">not available</span>') + "</td>"
2842
+ + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
2843
+ + "<td>" + bal + "</td></tr>";
2844
+ }).join("") : '<tr><td colspan="7" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
2845
+ pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
2846
+ authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
2847
+ fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
2848
+ }).catch(function(){ fetchEl.textContent = "— unavailable"; });
2849
+ });
2850
+ // a key exported only in the shell has no row: say so, by NAME, once per name
2851
+ document.getElementById("pv-shell").innerHTML = (r.shellOnly || []).map(function(n){
2852
+ return '<div class="hint" data-testid="pv-shell-' + escapeXml(n) + '">' + escapeXml(n) + " is set in your shell but not in ~/.config/bareloop/.env — add it there to use it here and in Chat.</div>";
2853
+ }).join("");
2854
+ pvNote.textContent = r.configProblem ? r.configProblem : PV_NOTE_DEFAULT;
2855
+ document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
2856
+ }).catch(function(){ pvNote.textContent = "could not read providers"; });
2857
+ }
2858
+ pvRows.addEventListener("change", function(e){
2859
+ var t = e.target;
2860
+ var tr = t.closest("tr");
2861
+ if(!tr) return;
2862
+ if(t.classList.contains("pv-balance")){
2863
+ authorPost("/api/settings/providers/balance-note", { usd: t.value.trim() === "" ? null : t.value.trim() }).then(function(r){
2864
+ pvNote.textContent = r.body && r.body.ok ? "Saved — balance note." : ((r.body && r.body.error) || "could not save");
2865
+ }).catch(function(){ pvNote.textContent = "could not save"; });
2866
+ return;
2867
+ }
2868
+ if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url"))) return;
2869
+ var env = tr.getAttribute("data-env");
2870
+ authorPost("/api/settings/providers/row", {
2871
+ envName: env,
2872
+ name: tr.querySelector(".pv-name").value,
2873
+ shape: tr.querySelector(".pv-shape").value,
2874
+ baseUrl: tr.querySelector(".pv-url").value,
2875
+ }).then(function(r){
2876
+ pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
2877
+ // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
2878
+ document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
2879
+ }).catch(function(){ pvNote.textContent = "could not save"; });
2880
+ });
2881
+ pvRows.addEventListener("click", function(e){
2882
+ var t = e.target;
2883
+ if(!t.classList || !t.classList.contains("pv-test")) return;
2884
+ var out = t.parentNode.querySelector(".pv-test-result");
2885
+ out.textContent = "testing…"; t.disabled = true;
2886
+ authorPost("/api/settings/providers/test", { envName: t.closest("tr").getAttribute("data-env") }).then(function(r){
2887
+ var b = r.body || {};
2888
+ out.textContent = b.reachable ? ("OK" + (b.ms !== null && b.ms !== undefined ? " · " + b.ms + " ms" : "")) : ("failed — " + (b.note || b.status || b.error || "no answer"));
2889
+ }).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
2890
+ });
2891
+ document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
2892
+ wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
2893
+ document.getElementById("tab-providers").addEventListener("click", loadProviders);
2894
+ // Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
2895
+ limitInput.addEventListener("change", function(){
2896
+ var text = limitInput.value.trim();
2897
+ authorPost("/api/settings/money", { monthlyLimitUsd: text === "" ? null : text }).then(function(r){
2898
+ var msg = r.body && r.body.ok
2899
+ ? (r.body.monthlyLimitUsd === null ? "Saved — no monthly limit." : "Saved — monthly limit " + panelMoney(r.body.monthlyLimitUsd) + ".")
2900
+ : ((r.body && r.body.error) || "could not save");
2901
+ keepNote = true;
2902
+ loadMoney();
2903
+ noteEl.textContent = msg;
2904
+ document.dispatchEvent(new CustomEvent("bareloop-limit-changed"));
2905
+ }).catch(function(){ noteEl.textContent = "could not save"; });
2906
+ });
2907
+ // verification convenience, like the mockup: ?settings=1 opens Settings on load
2908
+ if(location.search.indexOf("settings=1") !== -1){ document.getElementById("btn-settings").click(); }
2909
+ })();
2910
+
2675
2911
  // ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
2676
2912
  (function(){
2677
2913
  var newBtn = document.getElementById("chat-new-btn");
@@ -2756,6 +2992,23 @@
2756
2992
  startBtn.disabled = !capOk || sessionLive || !keyOk;
2757
2993
  }
2758
2994
  moneyCapInput.addEventListener("input", refreshStartEnabled);
2995
+ // P4a — the monthly-limit note sits right under the cap, as the person types.
2996
+ // Plain text, no button; the wording comes from the server (one library check).
2997
+ var capNoteEl = document.getElementById("jf-cap-note");
2998
+ var capNoteTimer = null;
2999
+ function refreshCapNote(){
3000
+ var v = Number(moneyCapInput.value);
3001
+ if(moneyCapInput.value.trim() === "" || !Number.isFinite(v) || v <= 0){ capNoteEl.textContent = ""; return; }
3002
+ authorGet("/api/author/monthly-check?cap=" + encodeURIComponent(String(v))).then(function(r){
3003
+ capNoteEl.textContent = (r && r.refusal) ? r.refusal : "";
3004
+ }).catch(function(){ capNoteEl.textContent = ""; });
3005
+ }
3006
+ moneyCapInput.addEventListener("input", function(){
3007
+ if(capNoteTimer){ clearTimeout(capNoteTimer); }
3008
+ capNoteTimer = setTimeout(refreshCapNote, 250);
3009
+ });
3010
+ // the limit changed in Settings: re-read the note for whatever cap is typed
3011
+ document.addEventListener("bareloop-limit-changed", refreshCapNote);
2759
3012
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
2760
3013
  r.addEventListener("change", function(){
2761
3014
  var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
@@ -2806,6 +3059,13 @@
2806
3059
  }
2807
3060
  function refreshModelStatus(){
2808
3061
  var modelId = modelSelect.value;
3062
+ if(modelId === ""){
3063
+ keyOk = false;
3064
+ modelStatusEl.className = "jf-readiness bad";
3065
+ modelStatusEl.textContent = "[✗] no model — give a key a Name in Settings > Providers";
3066
+ refreshStartEnabled();
3067
+ return;
3068
+ }
2809
3069
  authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
2810
3070
  var line = modelStatusLine(r, modelId);
2811
3071
  keyOk = line.keyOk;
@@ -2820,7 +3080,21 @@
2820
3080
  });
2821
3081
  }
2822
3082
  modelSelect.addEventListener("change", refreshModelStatus);
2823
- refreshModelStatus();
3083
+ // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
3084
+ // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
3085
+ function loadModels(){
3086
+ authorGet("/api/author/models").then(function(r){
3087
+ var models = r && r.ok ? r.models : [];
3088
+ var was = modelSelect.value;
3089
+ modelSelect.innerHTML = models.length ? models.map(function(m){
3090
+ return '<option value="' + escapeXml(m.id) + '">' + escapeXml(m.id) + " · " + escapeXml(m.envName) + "</option>";
3091
+ }).join("") : '<option value="">no model — add a Name in Settings</option>';
3092
+ if(models.some(function(m){ return m.id === was; })) modelSelect.value = was;
3093
+ refreshModelStatus();
3094
+ }).catch(function(){ refreshModelStatus(); });
3095
+ }
3096
+ document.addEventListener("bareloop-providers-changed", loadModels);
3097
+ loadModels();
2824
3098
 
2825
3099
  // build item 2 — one plain line per event, no "(progress)" jargon
2826
3100
  // suffix: `onPhase` no longer posts its own bubble (see
@@ -5,8 +5,10 @@
5
5
  // `src/cli.js` calls — `src/replayio.js`'s read side and `src/runlist.js`'s
6
6
  // run list — never a re-implementation of either.
7
7
  //
8
- // READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405); no
9
- // endpoint runs a job, spends money, signs, or reads a key/.env. Nothing
8
+ // READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405) EXCEPT the two
9
+ // human-click-guarded route families in their own modules (`/api/author/*`,
10
+ // `/api/settings/*` — P3/P4a); no endpoint here runs a job, spends money, signs, or
11
+ // returns a key/.env value. Nothing
10
12
  // here imports `src/providers.js` or touches `process.env` for a secret —
11
13
  // grepped before writing this file, and the same discipline is kept here.
12
14
  //
@@ -28,19 +30,23 @@ import {
28
30
  dirname, join, basename, relative, isAbsolute, sep,
29
31
  } from 'node:path';
30
32
  import { fileURLToPath } from 'node:url';
31
- import { readRunList } from '../runlist.js';
33
+ import { readRunList, DIED_MTIME_MS } from '../runlist.js';
32
34
  import {
33
35
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
34
36
  } from '../replayio.js';
35
37
  import { summarizeForAllLine, auditWindow } from '../replay.js';
36
38
  import { runBehaviour } from '../behaviour.js';
37
- import { SPEND_RECORD_TYPES } from '../ledger.js';
39
+ import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
38
40
  import { jobSpecHash } from '../job.js';
39
41
  import { confirmProtections } from '../authorflow.js';
40
42
  import { createAuthorRoutes, mintToken } from './authorroutes.js';
43
+ import { createSettingsRoutes } from './settingsroutes.js';
41
44
 
42
45
  const HERE = dirname(fileURLToPath(import.meta.url));
43
46
 
47
+ /** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
48
+ const MAX_BODY_BYTES = 1024 * 1024;
49
+
44
50
  /**
45
51
  * A runid, as it appears in a URL path segment — never a raw filesystem
46
52
  * path. `~` is included so a backfill-disambiguated runid (F197:
@@ -131,17 +137,10 @@ export function checkTypeTitle(verdictType, atIso) {
131
137
  return null;
132
138
  }
133
139
 
134
- // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never shares
135
- // the failed glyph — [✗] stays reserved for a run whose close/arbiter
136
- // actually rendered a "no" (a real result). A spine that just stops, with
137
- // no ending ever recorded, is a DIFFERENT fact (killed, crashed, or the
138
- // machine slept) and gets its own [?] glyph. The distinguishing signal is
139
- // the spine FILE's own mtime, never wall-clock "now minus job-start" (a
140
- // resumed/paused run can legitimately sit quiet for a long time without
141
- // having died): still fresh (written to within this window) reads as the
142
- // existing `running` [▶] state; older than this reads as died. Tighten-only,
143
- // named so a future change is a deliberate, visible edit.
144
- export const DIED_MTIME_MS = 10 * 60 * 1000;
140
+ // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once its
141
+ // spine file's mtime is older than DIED_MTIME_MS — the constant and its full comment live in
142
+ // src/runlist.js (the monthly limit reads the same rule); re-exported here for the panel's callers.
143
+ export { DIED_MTIME_MS };
145
144
 
146
145
  /**
147
146
  * Plain-words description of ONE spine record, for the died "why" sentence's
@@ -237,32 +236,6 @@ export function formatTimestamp(ts) {
237
236
  * @param {string|null} outcome `replayRun`'s own `summary.outcome`
238
237
  * @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
239
238
  */
240
- /**
241
- * The priced-rounds spend floor + first-record→last-record wall floor,
242
- * derived straight off the spine's raw records — the same derivation a
243
- * died run's "at least …" figures use, factored out so build item 6
244
- * (2026-09-28) can hand a genuinely-still-running spine the identical
245
- * floor instead of "unknown" on the Run tab, without a second copy of this
246
- * math.
247
- * @param {any[]} records raw parsed spine records
248
- * @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
249
- */
250
- function floorsFromRecords(records) {
251
- let spendSum = 0;
252
- let pricedCount = 0;
253
- for (const r of records) {
254
- if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
255
- if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
256
- }
257
- const spendFloorUsd = pricedCount > 0 ? spendSum : null;
258
-
259
- const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
260
- const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
261
- const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
262
- const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
263
- return { spendFloorUsd, wallFloorMs };
264
- }
265
-
266
239
  function deriveDeath(spinePath, records, outcome) {
267
240
  const notDied = {
268
241
  died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
@@ -1751,9 +1724,17 @@ function sendText(res, code, text) {
1751
1724
  * only `createPanelServer` below always supplies one.
1752
1725
  * @param {import('node:http').IncomingMessage} req
1753
1726
  * @param {import('node:http').ServerResponse} res
1754
- * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes> }} opts
1727
+ * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
1755
1728
  */
1756
1729
  export function handleRequest(req, res, opts) {
1730
+ /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
1731
+ * `/api/settings/*` (Settings, P4a): separate modules, each behind the human-click guard.
1732
+ * @param {any} o @param {string} p */
1733
+ function routesFor(o, p) {
1734
+ if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
1735
+ if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
1736
+ return null;
1737
+ }
1757
1738
  const method = req.method ?? 'GET';
1758
1739
 
1759
1740
  let url;
@@ -1766,22 +1747,39 @@ export function handleRequest(req, res, opts) {
1766
1747
  const { pathname } = url;
1767
1748
 
1768
1749
  if (method !== 'GET' && method !== 'HEAD') {
1769
- if (opts.authorRoutes && pathname.startsWith('/api/author')) {
1750
+ const routes = routesFor(opts, pathname);
1751
+ if (routes) {
1770
1752
  let raw = '';
1771
- req.on('data', (c) => { raw += c; });
1753
+ let size = 0;
1754
+ let refused = false;
1755
+ req.on('data', (c) => {
1756
+ if (refused) return;
1757
+ size += c.length;
1758
+ if (size > MAX_BODY_BYTES) {
1759
+ refused = true;
1760
+ raw = '';
1761
+ res.once('finish', () => { req.destroy(); });
1762
+ res.setHeader('connection', 'close');
1763
+ sendText(res, 413, `request body over ${MAX_BODY_BYTES} bytes`);
1764
+ return;
1765
+ }
1766
+ raw += c;
1767
+ });
1772
1768
  req.on('end', () => {
1769
+ if (refused) return;
1773
1770
  /** @type {any} */
1774
1771
  let body = null;
1775
1772
  if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
1776
- opts.authorRoutes?.handle(req, res, pathname, body);
1773
+ routes.handle(req, res, pathname, body);
1777
1774
  });
1778
1775
  return;
1779
1776
  }
1780
- sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author (GET/HEAD only)');
1777
+ sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
1781
1778
  return;
1782
1779
  }
1783
- if (opts.authorRoutes && pathname.startsWith('/api/author')) {
1784
- opts.authorRoutes.handle(req, res, pathname, null);
1780
+ const getRoutes = routesFor(opts, pathname);
1781
+ if (getRoutes) {
1782
+ getRoutes.handle(req, res, pathname, null);
1785
1783
  return;
1786
1784
  }
1787
1785
 
@@ -1901,10 +1899,12 @@ export function createPanelServer(opts = {}) {
1901
1899
  let boundPort = requestedPort;
1902
1900
  /** @type {ReturnType<typeof createAuthorRoutes>|undefined} */
1903
1901
  let authorRoutes;
1902
+ /** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
1903
+ let settingsRoutes;
1904
1904
  const server = createServer((req, res) => {
1905
1905
  try {
1906
1906
  handleRequest(req, res, {
1907
- home, port: boundPort, token, authorRoutes,
1907
+ home, port: boundPort, token, authorRoutes, settingsRoutes,
1908
1908
  });
1909
1909
  } catch (e) {
1910
1910
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
@@ -1926,6 +1926,10 @@ export function createPanelServer(opts = {}) {
1926
1926
  spawnFn: opts.spawnFn,
1927
1927
  bareloopBin: opts.bareloopBin,
1928
1928
  fetchImpl: opts.fetchImpl,
1929
+ home,
1930
+ });
1931
+ settingsRoutes = createSettingsRoutes({
1932
+ port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
1929
1933
  });
1930
1934
  resolve({
1931
1935
  server,
@@ -1942,7 +1946,10 @@ export function createPanelServer(opts = {}) {
1942
1946
  * Never picks a different port on collision (see file header): prints a
1943
1947
  * loud, named error to stderr and returns 1.
1944
1948
  * @param {string[]} argv
1945
- * @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string }} ctx
1949
+ * @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string, env?: Record<string,string|undefined> }} ctx
1950
+ * `env` is the RAW shell env (never the start-time merge with the keys file) — the routes
1951
+ * re-merge `~/.config/bareloop/.env` onto it per request so Reload keys is real;
1952
+ * undefined = the process env.
1946
1953
  * @returns {Promise<number>}
1947
1954
  */
1948
1955
  export async function panelMain(argv, ctx) {
@@ -1956,7 +1963,7 @@ export async function panelMain(argv, ctx) {
1956
1963
  }
1957
1964
  }
1958
1965
  try {
1959
- const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome });
1966
+ const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome, env: ctx.env });
1960
1967
  ctx.out(`bareloop panel — read-only, http://127.0.0.1:${boundPort} (Ctrl-C to stop)`);
1961
1968
  // never resolves on its own — the process stays up until killed, same
1962
1969
  // shape any other long-running dev server takes.