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.
- package/CHANGELOG.md +72 -0
- package/bareloop.context.md +87 -13
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorrun.js +14 -4
- package/src/cli.js +12 -6
- package/src/config.js +135 -0
- package/src/interviewrun.js +10 -2
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +352 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +23 -25
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +3 -1
- package/src/runlist.js +13 -0
- package/src/userrun.js +59 -5
- package/types/authorrun.d.ts +5 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +192 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +5 -0
package/src/panel/index.html
CHANGED
|
@@ -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
|
|
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">⚙ Settings</button>
|
|
405
429
|
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light/dark theme">☽</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">← 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 & 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
|
-
|
|
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
|
package/src/panel/server.js
CHANGED
|
@@ -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)
|
|
9
|
-
//
|
|
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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
-
|
|
1750
|
+
const routes = routesFor(opts, pathname);
|
|
1751
|
+
if (routes) {
|
|
1770
1752
|
let raw = '';
|
|
1771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1784
|
-
|
|
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.
|