bareloop 0.35.0 → 0.37.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 +50 -0
- package/bareloop.context.md +69 -22
- package/package.json +1 -1
- package/src/authorrun.js +5 -5
- package/src/bundlerun.js +4 -8
- package/src/draftspend.js +114 -1
- package/src/exits.js +3 -0
- package/src/index.js +3 -3
- package/src/legs.js +1 -1
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +12 -1
- package/src/panel/authorsession.js +95 -28
- package/src/panel/index.html +317 -76
- package/src/panel/server.js +176 -16
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +22 -0
- package/src/planrun.js +10 -7
- package/src/ralph.js +1 -1
- package/src/source.js +55 -4
- package/src/u-patient.js +3 -2
- package/src/userrun.js +56 -10
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +88 -0
- package/types/legs.d.ts +1 -1
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +4 -1
- package/types/panel/authorsession.d.ts +4 -8
- package/types/panel/server.d.ts +30 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +20 -0
- package/types/planrun.d.ts +3 -3
- package/types/ralph.d.ts +19 -0
- package/types/source.d.ts +20 -5
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
package/src/panel/index.html
CHANGED
|
@@ -180,7 +180,7 @@
|
|
|
180
180
|
.st-word{font-weight:700;color:var(--text);}
|
|
181
181
|
.wf-ended-inline{flex:1 1 auto;white-space:normal;}
|
|
182
182
|
.wf-compact{flex-wrap:wrap;}
|
|
183
|
-
.wf-
|
|
183
|
+
.wf-runno{color:var(--accent);flex:none;}
|
|
184
184
|
.step-state{display:inline-flex;align-items:center;gap:2px;font-size:12px;}
|
|
185
185
|
.badge{display:inline-block;font-size:11px;padding:1px 4px;color:var(--text-dim);background:none;font-weight:700;}
|
|
186
186
|
.badge::before{content:"[";color:var(--text-faint);}
|
|
@@ -272,10 +272,21 @@
|
|
|
272
272
|
.import-view .imp-row{display:flex;gap:10px;flex-wrap:wrap;align-items:baseline;}
|
|
273
273
|
.import-view .imp-label{color:var(--text-faint);font-size:11px;letter-spacing:0.08em;min-width:84px;}
|
|
274
274
|
.import-view .imp-text{flex:1 1 220px;min-width:0;overflow-wrap:anywhere;}
|
|
275
|
-
.startfrom-line{margin:
|
|
275
|
+
.startfrom-line{margin:4px 0 0;font-size:11px;color:var(--text-dim);overflow-wrap:anywhere;}
|
|
276
276
|
.job-card .locked{opacity:.55;cursor:not-allowed;}
|
|
277
|
-
|
|
278
|
-
.
|
|
277
|
+
/* Reuse workflow picker (hamr 2026-10-06): the third Check type radio; search box + list, then the picked box */
|
|
278
|
+
.radio-row label{white-space:nowrap;}
|
|
279
|
+
.rw-box{position:relative;margin-top:6px;}
|
|
280
|
+
.rw-box input{width:100%;box-sizing:border-box;}
|
|
281
|
+
.rw-list{list-style:none;margin:0;padding:0;border:1px solid var(--border-strong);border-top:none;background:var(--bg);max-height:220px;overflow:auto;}
|
|
282
|
+
.rw-list li{padding:7px 8px;border-bottom:1px dotted var(--border);cursor:pointer;}
|
|
283
|
+
.rw-list li:hover,.rw-list li.hl{background:var(--panel2);}
|
|
284
|
+
.rw-name{font-weight:700;overflow-wrap:anywhere;} .rw-name mark{background:none;color:var(--accent);text-decoration:underline;}
|
|
285
|
+
.rw-meta{font-size:11px;color:var(--text-dim);margin-top:2px;overflow-wrap:anywhere;}
|
|
286
|
+
.rw-empty{padding:7px 8px;font-size:12px;color:var(--text-dim);}
|
|
287
|
+
.rw-picked{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:6px;padding:6px 8px;border:1px solid var(--accent);}
|
|
288
|
+
.rw-picked span{overflow-wrap:anywhere;}
|
|
289
|
+
.rw-picked a{font-size:12px;cursor:pointer;flex:none;}
|
|
279
290
|
.run-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
|
|
280
291
|
.run-actions[hidden]{display:none;}
|
|
281
292
|
.run-actions .stopping{color:var(--amber);font-size:13px;overflow-wrap:anywhere;}
|
|
@@ -467,6 +478,7 @@
|
|
|
467
478
|
.pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
|
|
468
479
|
.pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
|
|
469
480
|
.pv-table .hint{display:block;margin-top:3px;}
|
|
481
|
+
.pv-table td.pv-price-cell input{width:90px;}
|
|
470
482
|
/* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
|
|
471
483
|
.pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
|
|
472
484
|
.pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
|
|
@@ -518,10 +530,8 @@
|
|
|
518
530
|
<!-- hamr 2026-10-05: the empty card is ALWAYS there when Chat opens (no + New). The top-right button reads
|
|
519
531
|
"Abandon" while a session is live and unsigned, else "Clear"; both leave an empty card. -->
|
|
520
532
|
<div class="job-card compact" id="job-card" data-testid="job-card">
|
|
521
|
-
<div class="startfrom-origin" id="chat-startfrom-origin" data-testid="startfrom-origin" hidden></div>
|
|
522
533
|
<h4 id="job-card-title">Job card — draft</h4>
|
|
523
534
|
<button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
|
|
524
|
-
<div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
|
|
525
535
|
<div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
|
|
526
536
|
|
|
527
537
|
<div class="field">
|
|
@@ -529,6 +539,15 @@
|
|
|
529
539
|
<div class="radio-row" role="radiogroup" aria-labelledby="jf-verdict-label">
|
|
530
540
|
<label><input type="radio" name="jf-verdict" value="deterministic" checked> Deterministic</label>
|
|
531
541
|
<label><input type="radio" name="jf-verdict" value="rubric"> Rubric</label>
|
|
542
|
+
<label><input type="radio" name="jf-verdict" value="reuse" data-testid="jf-verdict-reuse"> Reuse workflow</label>
|
|
543
|
+
</div>
|
|
544
|
+
<div id="rw" data-testid="rw" hidden>
|
|
545
|
+
<div class="rw-box" id="rw-search" data-testid="rw-search">
|
|
546
|
+
<input id="rw-q" data-testid="rw-q" type="text" placeholder="type a job name…" autocomplete="off">
|
|
547
|
+
<ul class="rw-list" id="rw-list" data-testid="rw-list"></ul>
|
|
548
|
+
</div>
|
|
549
|
+
<div class="rw-picked" id="rw-picked" data-testid="rw-picked" hidden><span id="rw-picked-txt" data-testid="rw-picked-txt"></span><a id="rw-change" data-testid="rw-change" role="button" tabindex="0">change</a></div>
|
|
550
|
+
<div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
|
|
532
551
|
</div>
|
|
533
552
|
</div>
|
|
534
553
|
<div class="field">
|
|
@@ -742,21 +761,24 @@
|
|
|
742
761
|
<section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
|
|
743
762
|
<div class="keyfile-strip" data-testid="keyfile-box">
|
|
744
763
|
<div class="keyfile-strip-main">
|
|
745
|
-
<span>Put <code>NAME=key</code> lines in
|
|
764
|
+
<span>Put <code>NAME=key</code> lines in your keys file (Open keys folder), then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
|
|
765
|
+
<span class="hint" id="pv-keyfile-path" data-testid="keyfile-path"></span>
|
|
746
766
|
<span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
|
|
747
767
|
</div>
|
|
748
768
|
<div class="keyfile-strip-actions">
|
|
769
|
+
<button class="btn small" type="button" id="btn-open-keys-folder" data-testid="btn-open-keys-folder">Open keys folder</button>
|
|
749
770
|
<button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
|
|
750
771
|
</div>
|
|
751
772
|
</div>
|
|
752
773
|
<div class="table-wrap" data-testid="provider-table-wrap">
|
|
753
774
|
<table class="pv-table" data-testid="provider-table">
|
|
754
775
|
<thead>
|
|
755
|
-
<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>
|
|
776
|
+
<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><th>In $/1M</th><th>Out $/1M</th></tr>
|
|
756
777
|
</thead>
|
|
757
778
|
<tbody id="pv-rows"></tbody>
|
|
758
779
|
</table>
|
|
759
780
|
</div>
|
|
781
|
+
<div class="hint" id="pv-price-hint" data-testid="pv-price-hint">If your vendor lists two prices, enter the higher one.</div>
|
|
760
782
|
<div id="pv-shell" data-testid="pv-shell"></div>
|
|
761
783
|
<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>
|
|
762
784
|
</section>
|
|
@@ -946,9 +968,12 @@
|
|
|
946
968
|
// jump straight to that SAME part's Audit-tab row (build item B's
|
|
947
969
|
// click-through), with no re-lookup needed.
|
|
948
970
|
function partBoxState(part, isLastLivePart){
|
|
971
|
+
// the drafting part happened before the run existed: it is always done
|
|
972
|
+
if(part.kind === "drafting") return "done";
|
|
949
973
|
if(part.outcome === "green" || part.outcome === "satisfied" || part.outcome === "already-green") return "done";
|
|
950
974
|
if(typeof part.outcome === "string") return "stopped";
|
|
951
975
|
// P5-R: its leg's halt cut this part off — the map's existing "stopped" state, never "done"
|
|
976
|
+
if(part.status && part.status.key === "died") return "died";
|
|
952
977
|
if(part.stopReason) return "stopped";
|
|
953
978
|
if(isLastLivePart) return "running";
|
|
954
979
|
return "done"; // scout/plan/replan/judge: no per-part verdict, but the part itself already happened
|
|
@@ -968,6 +993,7 @@
|
|
|
968
993
|
// (a single-attempt step, or the whole "run" box on an old-shape spine)
|
|
969
994
|
// reads the part's own outcome.
|
|
970
995
|
function partResultGlyph(part, box){
|
|
996
|
+
if(part.kind === "drafting") return glyphSpan(attemptGlyph("green", false)); // [✓]: drafting is done once the run exists
|
|
971
997
|
if(partHasNoVerdict(part)) return null;
|
|
972
998
|
var boxIsLive = box.state === "running";
|
|
973
999
|
if(box.attempts.length > 1) return box.attempts.map(function(a){ return glyphSpan(attemptGlyph(a.outcome, boxIsLive)); }).join("");
|
|
@@ -986,6 +1012,8 @@
|
|
|
986
1012
|
partIndex: idx,
|
|
987
1013
|
title: part.label + occSuffix,
|
|
988
1014
|
state: partBoxState(part, isLastLivePart),
|
|
1015
|
+
// the word this box wears: the server's table entry for the part (src/panel/status.js partStatusFor), except the live part, which is running
|
|
1016
|
+
status: isLastLivePart && partBoxState(part, true) === "running" ? {sign:"\u25B6", word:"running"} : (part.status || null),
|
|
989
1017
|
noNumber: part.kind !== "step",
|
|
990
1018
|
// display-only attempts (map inline text + card/audit result glyph
|
|
991
1019
|
// fallback) — [] for a no-verdict part kind even though `part.attempts`
|
|
@@ -1000,6 +1028,14 @@
|
|
|
1000
1028
|
};
|
|
1001
1029
|
});
|
|
1002
1030
|
}
|
|
1031
|
+
// ONE spelling of a part's shown state: `[sign] word` from the server's table; the raw state only when no table entry came
|
|
1032
|
+
function boxStatusWord(box){
|
|
1033
|
+
return box.status && box.status.word ? box.status.word : String(box.state);
|
|
1034
|
+
}
|
|
1035
|
+
// the map box adds the sign; step cards and Audit rows show the word only (an attempt glyph already sits beside them)
|
|
1036
|
+
function boxStatusText(box){
|
|
1037
|
+
return box.status && box.status.word ? "[" + box.status.sign + "] " + boxStatusWord(box) : boxStatusWord(box);
|
|
1038
|
+
}
|
|
1003
1039
|
function naturalBoxWidth(steps){
|
|
1004
1040
|
var numIdx = stepNumberIndices(steps);
|
|
1005
1041
|
var maxChars = 0;
|
|
@@ -1100,7 +1136,7 @@
|
|
|
1100
1136
|
'" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[1]) + '</text>';
|
|
1101
1137
|
}
|
|
1102
1138
|
body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
|
|
1103
|
-
'" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(s
|
|
1139
|
+
'" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(boxStatusText(s)) + '</text>';
|
|
1104
1140
|
// P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
|
|
1105
1141
|
// amber dot (design/panel-mockup.html ~line 1648) — same fixed color
|
|
1106
1142
|
// and animation, never re-derived from the state's own stroke color.
|
|
@@ -1247,8 +1283,12 @@
|
|
|
1247
1283
|
if(!d || isNaN(d.getTime())) return "unknown";
|
|
1248
1284
|
return d.toLocaleString("en-US", {year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit"});
|
|
1249
1285
|
}
|
|
1250
|
-
// the right-side header: `[sign] job (
|
|
1286
|
+
// the right-side header: `[sign] job (run-N) │ **word** — reason · ended 10/4/2026, 6:01 PM` (no capitals: the job
|
|
1251
1287
|
// name is shown as written). `bodyHtml` is already escaped.
|
|
1288
|
+
// a run's NAME for a person (P6 item 7): `job (run-N)`, N from the server (`runNo`, one owner) — the page never counts.
|
|
1289
|
+
// A run with no number (an imported bundle's run is not in the run list) falls back to its id.
|
|
1290
|
+
function runLabel(runNo, runid){ return typeof runNo === "number" ? "run-" + runNo : String(runid || ""); }
|
|
1291
|
+
function runName(job, runNo, runid){ return job + " (" + runLabel(runNo, runid) + ")"; }
|
|
1252
1292
|
function setRunHeader(sign, nameText, bodyHtml){
|
|
1253
1293
|
document.getElementById("active-wf-name").textContent = nameText;
|
|
1254
1294
|
document.getElementById("active-wf-dot").className = "dot " + glyphClass(sign);
|
|
@@ -1294,7 +1334,7 @@
|
|
|
1294
1334
|
row.setAttribute("tabindex", "0");
|
|
1295
1335
|
row.setAttribute("role", "button");
|
|
1296
1336
|
row.setAttribute("aria-pressed", selected ? "true" : "false");
|
|
1297
|
-
var histFullName = r.job
|
|
1337
|
+
var histFullName = runName(r.job, r.runNo, r.runid);
|
|
1298
1338
|
if(r.starting){
|
|
1299
1339
|
row.innerHTML =
|
|
1300
1340
|
'<span class="wf-line1"><span class="dot amber"></span>' +
|
|
@@ -1312,14 +1352,20 @@
|
|
|
1312
1352
|
'<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
|
|
1313
1353
|
'</span>';
|
|
1314
1354
|
} else if(compact){
|
|
1315
|
-
// an EXPANDED per-run row under a workflow
|
|
1355
|
+
// an EXPANDED per-run row under a workflow (hamr 2026-10-06), 2 lines, left-aligned:
|
|
1356
|
+
// `[sign] (run-N) **word** — reason` / `$ ($drafting) · wall · date`
|
|
1316
1357
|
row.innerHTML =
|
|
1317
1358
|
'<span class="wf-line1 wf-compact"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
|
|
1318
|
-
'<span class="wf-
|
|
1319
|
-
'<span class="wf-
|
|
1320
|
-
|
|
1359
|
+
'<span class="wf-name wf-runno" title="' + escapeXml(histFullName) + '" data-testid="hist-runno-' + escapeXml(r.runid) + '">(' + escapeXml(runLabel(r.runNo, r.runid)) + ')</span>' +
|
|
1360
|
+
'<span class="wf-ended wf-ended-inline" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span></span>' +
|
|
1361
|
+
'<span class="wf-meta-line">' +
|
|
1362
|
+
'<span class="wf-meta">' + escapeXml(rowSpendText(r, true)) + '</span>' +
|
|
1363
|
+
'<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
|
|
1364
|
+
'<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
|
|
1365
|
+
resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
|
|
1366
|
+
'</span>';
|
|
1321
1367
|
} else {
|
|
1322
|
-
// History card: line 1 sign + job (
|
|
1368
|
+
// History card: line 1 sign + job (run-N); line 2 **word** — reason; line 3 the facts
|
|
1323
1369
|
row.innerHTML =
|
|
1324
1370
|
'<span class="wf-line1"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
|
|
1325
1371
|
'<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
|
|
@@ -1370,6 +1416,7 @@
|
|
|
1370
1416
|
runs: jobRuns,
|
|
1371
1417
|
runCount: jobRuns.length,
|
|
1372
1418
|
lastRunid: last.runid,
|
|
1419
|
+
lastRunNo: last.runNo,
|
|
1373
1420
|
lastAt: last.at,
|
|
1374
1421
|
lastGlyph: last.glyph || "▶",
|
|
1375
1422
|
lastStatus: last.status || null,
|
|
@@ -1377,7 +1424,7 @@
|
|
|
1377
1424
|
lastResumedCount: last.resumedCount || 0,
|
|
1378
1425
|
lastCheckType: last.checkType || "unknown",
|
|
1379
1426
|
lastModel: last.model || null,
|
|
1380
|
-
lastSpend: rowSpendText(last),
|
|
1427
|
+
lastSpend: rowSpendText(last, true),
|
|
1381
1428
|
lastWall: rowWallText(last) || "unknown",
|
|
1382
1429
|
lastDate: last.date,
|
|
1383
1430
|
};
|
|
@@ -1503,18 +1550,16 @@
|
|
|
1503
1550
|
row.setAttribute("role", "button");
|
|
1504
1551
|
row.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
1505
1552
|
row.setAttribute("aria-pressed", isSelected ? "true" : "false");
|
|
1506
|
-
var wfFullName = g.job
|
|
1553
|
+
var wfFullName = runName(g.job, g.lastRunNo, g.lastRunid);
|
|
1507
1554
|
row.innerHTML =
|
|
1508
|
-
'<span class="wf-line1"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
|
|
1509
|
-
'<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span
|
|
1510
|
-
(g.lastStatus ? '<span class="wf-ended" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') +
|
|
1555
|
+
'<span class="wf-line1 wf-compact"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
|
|
1556
|
+
'<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span>' +
|
|
1557
|
+
(g.lastStatus ? '<span class="wf-ended wf-ended-inline" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') + '</span>' +
|
|
1511
1558
|
'<span class="wf-meta-line">' +
|
|
1512
1559
|
'<span class="wf-meta">' + escapeXml(g.lastCheckType) + '</span>' +
|
|
1513
1560
|
'<span class="wf-meta">' + escapeXml(g.lastSpend) + '</span>' +
|
|
1514
1561
|
'<span class="wf-meta">' + escapeXml(g.lastWall) + '</span>' +
|
|
1515
1562
|
'<span class="wf-meta">' + escapeXml(g.lastDate || "unknown") + '</span>' +
|
|
1516
|
-
resumedTagHtml(g.lastResumedCount, "wf-resumed-" + g.job) +
|
|
1517
|
-
'<span class="wf-meta">' + g.runCount + ' run' + (g.runCount === 1 ? '' : 's') + '</span>' +
|
|
1518
1563
|
'</span>';
|
|
1519
1564
|
row.addEventListener("click", function(){
|
|
1520
1565
|
// a single-run job has nothing to expand — the click still opens
|
|
@@ -1559,7 +1604,7 @@
|
|
|
1559
1604
|
function filterBarHTML(scope){
|
|
1560
1605
|
return (
|
|
1561
1606
|
'<div class="filterbar" data-scope="' + scope + '" data-testid="' + scope + '-filters">' +
|
|
1562
|
-
'<input type="text" class="search-input" placeholder="search job, run id or model…" ' +
|
|
1607
|
+
'<input type="text" class="search-input" placeholder="search job, run-N, run id or model…" ' +
|
|
1563
1608
|
'id="' + scope + '-filter-search" data-testid="' + scope + '-filter-search" autocomplete="off">' +
|
|
1564
1609
|
'<div class="chips" role="group" aria-label="Filter">' +
|
|
1565
1610
|
'<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="checkType" data-filter-value="deterministic" title="deterministic" data-testid="chip-' + scope + '-ct-deterministic">D</button>' +
|
|
@@ -1588,15 +1633,18 @@
|
|
|
1588
1633
|
// means "no search filter" — everything passes it, same posture as an
|
|
1589
1634
|
// empty chip group). item C (2026-09-26): also matches the run's model
|
|
1590
1635
|
// name (e.g. "deepseek", "sonnet") — `model` is `null`/absent on an older
|
|
1591
|
-
// archived run, same posture as a missing job/runid.
|
|
1592
|
-
|
|
1636
|
+
// archived run, same posture as a missing job/runid. Also the run's shown
|
|
1637
|
+
// name: `run-N` and `job (run-N)` (N is the server's `runNo`, never counted here).
|
|
1638
|
+
function matchesSearch(job, runid, model, query, runNo){
|
|
1593
1639
|
if(!query) return true;
|
|
1594
1640
|
var q = String(query).trim().toLowerCase();
|
|
1595
1641
|
if(!q) return true;
|
|
1596
1642
|
var j = String(job || "").toLowerCase();
|
|
1597
1643
|
var r = String(runid || "").toLowerCase();
|
|
1598
1644
|
var m = String(model || "").toLowerCase();
|
|
1599
|
-
|
|
1645
|
+
var label = typeof runNo === "number" ? "run-" + runNo : "";
|
|
1646
|
+
var shown = label ? j + " (" + label + ")" : "";
|
|
1647
|
+
return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1 || (label !== "" && (label.indexOf(q) !== -1 || shown.indexOf(q) !== -1));
|
|
1600
1648
|
}
|
|
1601
1649
|
function filterRuns(runs, filters, nowMs){
|
|
1602
1650
|
nowMs = (typeof nowMs === "number") ? nowMs : Date.now();
|
|
@@ -1607,7 +1655,7 @@
|
|
|
1607
1655
|
return (runs || []).filter(function(r){
|
|
1608
1656
|
if(ct.length && ct.indexOf(r.checkType) === -1) return false;
|
|
1609
1657
|
if(rs.length && rs.indexOf(r.glyph) === -1) return false;
|
|
1610
|
-
if(!matchesSearch(r.job, r.runid, r.model, q)) return false;
|
|
1658
|
+
if(!matchesSearch(r.job, r.runid, r.model, q, r.runNo)) return false;
|
|
1611
1659
|
if(time !== "all"){
|
|
1612
1660
|
var days = time === "7d" ? 7 : 30;
|
|
1613
1661
|
var t = typeof r.at === "string" ? Date.parse(r.at) : NaN;
|
|
@@ -2101,15 +2149,16 @@
|
|
|
2101
2149
|
* complete run whose drafting share is itself incomplete — hamr's ruling:
|
|
2102
2150
|
* a reader summing the two must never read the leading number as exact
|
|
2103
2151
|
* when the bracket next to it isn't). `draftSpendComplete === false` marks
|
|
2104
|
-
* the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX".
|
|
2152
|
+
* the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX". `shortForm` drops the word
|
|
2153
|
+
* "drafting" (the Runs list cards only — "$5.00 ($0.08)").
|
|
2105
2154
|
*/
|
|
2106
|
-
function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd){
|
|
2155
|
+
function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd, shortForm){
|
|
2107
2156
|
var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
|
|
2108
2157
|
var draftFloor = hasDraft && draftSpendComplete === false;
|
|
2109
2158
|
var core = (spendCoreVal === null || spendCoreVal === undefined)
|
|
2110
2159
|
? "unknown"
|
|
2111
2160
|
: ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
|
|
2112
|
-
var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
|
|
2161
|
+
var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
|
|
2113
2162
|
var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
|
|
2114
2163
|
return core + draftPart + capPart;
|
|
2115
2164
|
}
|
|
@@ -2134,13 +2183,13 @@
|
|
|
2134
2183
|
* is a floor only when its own drafting share is incomplete.
|
|
2135
2184
|
* @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
|
|
2136
2185
|
*/
|
|
2137
|
-
function rowSpendText(r){
|
|
2186
|
+
function rowSpendText(r, shortForm){
|
|
2138
2187
|
if(r.starting) return "starting…";
|
|
2139
|
-
if(rowIsLive(r)) return liveSpendText(r.spendFloorUsd, r.draftSpentUsd, r.draftSpendComplete);
|
|
2188
|
+
if(rowIsLive(r)) return liveSpendText(r.spendFloorUsd, r.draftSpentUsd, r.draftSpendComplete, shortForm);
|
|
2140
2189
|
var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
|
|
2141
2190
|
var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
|
|
2142
2191
|
var coreFloor = !!r.died || draftIncomplete;
|
|
2143
|
-
return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete);
|
|
2192
|
+
return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete, undefined, shortForm);
|
|
2144
2193
|
}
|
|
2145
2194
|
function duration(ms){
|
|
2146
2195
|
if(ms === null || ms === undefined) return "unknown";
|
|
@@ -2163,11 +2212,11 @@
|
|
|
2163
2212
|
* @param {number|null} spendFloorUsd @param {number|null} draftSpentUsd
|
|
2164
2213
|
* @param {boolean|null} draftSpendComplete @returns {string}
|
|
2165
2214
|
*/
|
|
2166
|
-
function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete){
|
|
2215
|
+
function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete, shortForm){
|
|
2167
2216
|
if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
|
|
2168
2217
|
var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
|
|
2169
2218
|
var draftFloor = hasDraft && draftSpendComplete === false;
|
|
2170
|
-
var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
|
|
2219
|
+
var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
|
|
2171
2220
|
return panelMoney(spendFloorUsd) + " so far" + draftPart;
|
|
2172
2221
|
}
|
|
2173
2222
|
/** build item 6 — the matching wall-clock phrase: "running <duration>",
|
|
@@ -2340,8 +2389,9 @@
|
|
|
2340
2389
|
var stopAsked = {};
|
|
2341
2390
|
// Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
|
|
2342
2391
|
// hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
|
|
2343
|
-
|
|
2344
|
-
|
|
2392
|
+
// `model` (optional) = the Chat card's Model box, when the picker asks: the estimate line then counts only that worker's runs.
|
|
2393
|
+
function reuseWorkflow(runid, model){
|
|
2394
|
+
authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&model=" + encodeURIComponent(model) : "")).then(function(r){
|
|
2345
2395
|
if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
|
|
2346
2396
|
document.getElementById("tab-chat").click();
|
|
2347
2397
|
document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
|
|
@@ -2474,7 +2524,7 @@
|
|
|
2474
2524
|
|
|
2475
2525
|
function renderRun(detail){
|
|
2476
2526
|
if(detail.fileMissing){
|
|
2477
|
-
setRunHeader("\u00B7", detail.job
|
|
2527
|
+
setRunHeader("\u00B7", runName(detail.job, detail.runNo, detail.runid), "file missing");
|
|
2478
2528
|
document.getElementById("run-empty").hidden = false;
|
|
2479
2529
|
document.getElementById("run-empty").textContent = "spine file no longer on disk";
|
|
2480
2530
|
document.getElementById("run-content").hidden = true;
|
|
@@ -2485,7 +2535,7 @@
|
|
|
2485
2535
|
}
|
|
2486
2536
|
|
|
2487
2537
|
if(detail.starting){
|
|
2488
|
-
setRunHeader("\u25B6", detail.job
|
|
2538
|
+
setRunHeader("\u25B6", runName(detail.job, detail.runNo, detail.runid), statusWordHtml(detail.status, "starting\u2026") + " \u00B7 " + escapeXml("started " + fmtLocalDateTime(detail.at)));
|
|
2489
2539
|
document.getElementById("run-empty").hidden = false;
|
|
2490
2540
|
document.getElementById("run-empty").textContent = "starting… the run has not written its log yet";
|
|
2491
2541
|
document.getElementById("run-content").hidden = true;
|
|
@@ -2498,7 +2548,7 @@
|
|
|
2498
2548
|
document.getElementById("audit-body").hidden = false;
|
|
2499
2549
|
renderEnded(detail);
|
|
2500
2550
|
|
|
2501
|
-
setRunHeader(detail.glyph, detail.job
|
|
2551
|
+
setRunHeader(detail.glyph, runName(detail.job, detail.runNo, detail.runid), runHeaderBody(detail));
|
|
2502
2552
|
|
|
2503
2553
|
document.getElementById("run-empty").hidden = true;
|
|
2504
2554
|
document.getElementById("run-content").hidden = false;
|
|
@@ -2608,7 +2658,7 @@
|
|
|
2608
2658
|
(box.blocked > 0 ? '<span class="badge red" data-testid="part-blocked-badge">' + box.blocked + ' blocked</span>' : '') +
|
|
2609
2659
|
'</div>' +
|
|
2610
2660
|
'<div class="step-meta" title="a part is one piece of the run (scout, plan, a step, replan, fix, judge); a call is one model round">' + line1Bits.join(" · ") + '</div>' +
|
|
2611
|
-
'<div class="step-meta">' + escapeXml(
|
|
2661
|
+
'<div class="step-meta">' + escapeXml(partLine2Text(box.part)) + '</div>';
|
|
2612
2662
|
listEl.appendChild(card);
|
|
2613
2663
|
var jumpToAudit = function(){
|
|
2614
2664
|
document.getElementById("tab-audit").click();
|
|
@@ -2630,7 +2680,7 @@
|
|
|
2630
2680
|
// ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
|
|
2631
2681
|
function stepStateHTML(box, testid){
|
|
2632
2682
|
var cls = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
|
|
2633
|
-
return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(box
|
|
2683
|
+
return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(boxStatusWord(box)) + '</b></span>';
|
|
2634
2684
|
}
|
|
2635
2685
|
// build item C: Audit tab attempt row -> its rounds (server-built,
|
|
2636
2686
|
// lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
|
|
@@ -2769,6 +2819,46 @@
|
|
|
2769
2819
|
// shows — expand -> attempts (parts with >1, e.g. a multi-attempt step or
|
|
2770
2820
|
// the fix loop) or straight to a rounds table (every other part kind,
|
|
2771
2821
|
// which carries exactly one synthetic whole-part attempt).
|
|
2822
|
+
// The DRAFTING part (a panel-authored run's first part; src/draftspend.js `draftingPartFor`): ONE owner of its
|
|
2823
|
+
// money wording, line 1, line 2 and its Audit table. Money is the spine's own drafting figure ("at least" when it
|
|
2824
|
+
// is a floor); a call with no price reads "unknown", never $0.
|
|
2825
|
+
function draftingMoneyText(part){
|
|
2826
|
+
return (part.spendComplete === false ? "at least " : "") + panelMoney(part.spentUsd);
|
|
2827
|
+
}
|
|
2828
|
+
function draftingLine1Text(part){
|
|
2829
|
+
var n = typeof part.rounds === "number" ? part.rounds : 0;
|
|
2830
|
+
return (part.wallMs === null || part.wallMs === undefined ? "–" : duration(part.wallMs)) + " · " + draftingMoneyText(part) + " · " + n + " call" + (n === 1 ? "" : "s");
|
|
2831
|
+
}
|
|
2832
|
+
// line 2 of a part's card / Audit body: the drafting step names, or a worker part's tool breakdown
|
|
2833
|
+
function partLine2Text(part){
|
|
2834
|
+
if(part && part.kind === "drafting") return (part.draftSteps || []).map(function(x){ return x.label; }).join(" · ");
|
|
2835
|
+
return toolBreakdownLine(part.byTool);
|
|
2836
|
+
}
|
|
2837
|
+
function draftingCallCost(c){
|
|
2838
|
+
if(c.costUsd === null || c.costUsd === undefined) return "unknown";
|
|
2839
|
+
if(c.unpricedRounds > 0) return "unknown (" + c.unpricedRounds + " unpriced round" + (c.unpricedRounds === 1 ? "" : "s") + ")";
|
|
2840
|
+
return panelMoney(c.costUsd);
|
|
2841
|
+
}
|
|
2842
|
+
// one row per drafting step ("–" where it made no call); a step's further calls get their own row named by the
|
|
2843
|
+
// call's own label (confirm#2, revise-1…); the last row is the total, equal to the header's money
|
|
2844
|
+
function draftingTableHtml(part){
|
|
2845
|
+
var rows = "";
|
|
2846
|
+
(part.draftSteps || []).forEach(function(st){
|
|
2847
|
+
var wall = st.wallMs === null || st.wallMs === undefined ? "–" : duration(st.wallMs);
|
|
2848
|
+
var calls = Array.isArray(st.calls) ? st.calls : [];
|
|
2849
|
+
var first = calls[0];
|
|
2850
|
+
rows += "<tr><td>" + escapeXml(st.label) + "</td><td>" + wall + "</td><td>" + (first ? "1" : "–") + "</td><td>" +
|
|
2851
|
+
(first && first.model ? escapeXml(first.model) : "–") + "</td><td>" + (first ? draftingCallCost(first) : "–") + "</td></tr>";
|
|
2852
|
+
calls.slice(1).forEach(function(c){
|
|
2853
|
+
rows += "<tr><td>" + escapeXml(st.label) + " (" + escapeXml(c.label) + ")</td><td>–</td><td>1</td><td>" +
|
|
2854
|
+
(c.model ? escapeXml(c.model) : "–") + "</td><td>" + draftingCallCost(c) + "</td></tr>";
|
|
2855
|
+
});
|
|
2856
|
+
});
|
|
2857
|
+
rows += '<tr class="drafting-total"><td><b>total</b></td><td>' + (part.wallMs === null || part.wallMs === undefined ? "–" : duration(part.wallMs)) +
|
|
2858
|
+
"</td><td>" + (typeof part.rounds === "number" ? part.rounds : 0) + "</td><td>–</td><td>" + draftingMoneyText(part) + "</td></tr>";
|
|
2859
|
+
return '<div class="audit-table-scroll"><table class="rounds-table drafting-table" data-testid="drafting-table">' +
|
|
2860
|
+
"<thead><tr><th>Step</th><th>Time</th><th>Calls</th><th>Model</th><th>Cost</th></tr></thead><tbody>" + rows + "</tbody></table></div>";
|
|
2861
|
+
}
|
|
2772
2862
|
function partHasWrites(part){
|
|
2773
2863
|
var bt = part.byTool;
|
|
2774
2864
|
if(!bt) return false;
|
|
@@ -2798,6 +2888,7 @@
|
|
|
2798
2888
|
* @returns {string}
|
|
2799
2889
|
*/
|
|
2800
2890
|
function partLine1Text(part, detail){
|
|
2891
|
+
if(part && part.kind === "drafting") return draftingLine1Text(part);
|
|
2801
2892
|
var isLiveRunPart = part && part.kind === "run" && detail && !detail.died && detail.spentUsd === null;
|
|
2802
2893
|
var wallText = (isLiveRunPart && part.wallMs === null) ? liveWallPhrase(detail.wallFloorMs) : duration(part.wallMs);
|
|
2803
2894
|
var spendText = (isLiveRunPart && part.spentUsd === null)
|
|
@@ -3016,9 +3107,15 @@
|
|
|
3016
3107
|
built = true;
|
|
3017
3108
|
var toolsHeader = document.createElement("div");
|
|
3018
3109
|
toolsHeader.className = "hint";
|
|
3019
|
-
toolsHeader.textContent =
|
|
3110
|
+
toolsHeader.textContent = partLine2Text(part);
|
|
3020
3111
|
body.appendChild(toolsHeader);
|
|
3021
|
-
if(
|
|
3112
|
+
if(part.kind === "drafting"){
|
|
3113
|
+
// no tool calls and no rounds: one row per drafting step instead (the drafting part is index 0 of the
|
|
3114
|
+
// shown list; the rounds endpoint has nothing to serve for it)
|
|
3115
|
+
var draftingEl = document.createElement("div");
|
|
3116
|
+
draftingEl.innerHTML = draftingTableHtml(part);
|
|
3117
|
+
body.appendChild(draftingEl);
|
|
3118
|
+
} else if(attempts.length > 1){
|
|
3022
3119
|
// parts WITH an attempt sub-loop (a multi-attempt step, or the fix
|
|
3023
3120
|
// loop) expand to attempt rows first — each attempt expands to its
|
|
3024
3121
|
// own rounds table on its own click, same as the Run tab used to.
|
|
@@ -3480,7 +3577,7 @@
|
|
|
3480
3577
|
authorGet("/api/settings/providers").then(function(r){
|
|
3481
3578
|
if(!r || !r.ok){ pvNote.textContent = "could not read providers"; document.getElementById("pv-keyfile-path").textContent = "the keys file (path unknown — could not read providers)"; return; }
|
|
3482
3579
|
var kf = r.keysFile;
|
|
3483
|
-
document.getElementById("pv-keyfile-path").textContent =
|
|
3580
|
+
document.getElementById("pv-keyfile-path").textContent = "";
|
|
3484
3581
|
document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
|
|
3485
3582
|
pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
|
|
3486
3583
|
var e = escapeXml(row.envName);
|
|
@@ -3500,8 +3597,10 @@
|
|
|
3500
3597
|
? '<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>'
|
|
3501
3598
|
: '<span class="hint">not available</span>') + "</td>"
|
|
3502
3599
|
+ '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
|
|
3503
|
-
+ "<td>" + bal + "</td
|
|
3504
|
-
|
|
3600
|
+
+ "<td>" + bal + "</td>"
|
|
3601
|
+
+ '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-in" data-testid="pv-price-in-' + e + '" placeholder="not set" value="' + (row.priceInPerM === null ? "" : escapeXml(String(row.priceInPerM))) + '"></td>'
|
|
3602
|
+
+ '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-out" data-testid="pv-price-out-' + e + '" placeholder="not set" value="' + (row.priceOutPerM === null ? "" : escapeXml(String(row.priceOutPerM))) + '"></td></tr>';
|
|
3603
|
+
}).join("") : '<tr><td colspan="9" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
|
|
3505
3604
|
pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
|
|
3506
3605
|
authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
|
|
3507
3606
|
fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
|
|
@@ -3515,6 +3614,16 @@
|
|
|
3515
3614
|
document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
|
|
3516
3615
|
}).catch(function(){ pvNote.textContent = "could not read providers"; });
|
|
3517
3616
|
}
|
|
3617
|
+
// The row's POST body, or null when exactly ONE price box is filled: a price is both boxes or neither (the server
|
|
3618
|
+
// refuses a half-set one), so nothing in that row saves. This runs only when a save would happen (the row's
|
|
3619
|
+
// change event), so typing the first box never flashes the message; it shows when the person leaves that box.
|
|
3620
|
+
var PV_HALF_PRICE = "Fill both price boxes or clear both.";
|
|
3621
|
+
function pvRowBody(env, v){
|
|
3622
|
+
var pin = String(v.priceIn).trim();
|
|
3623
|
+
var pout = String(v.priceOut).trim();
|
|
3624
|
+
if((pin === "") !== (pout === "")) return null;
|
|
3625
|
+
return { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl, priceInPerM: pin, priceOutPerM: pout };
|
|
3626
|
+
}
|
|
3518
3627
|
pvRows.addEventListener("change", function(e){
|
|
3519
3628
|
var t = e.target;
|
|
3520
3629
|
var tr = t.closest("tr");
|
|
@@ -3525,14 +3634,17 @@
|
|
|
3525
3634
|
}).catch(function(){ pvNote.textContent = "could not save"; });
|
|
3526
3635
|
return;
|
|
3527
3636
|
}
|
|
3528
|
-
if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url"))) return;
|
|
3637
|
+
if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url") || t.classList.contains("pv-price-in") || t.classList.contains("pv-price-out"))) return;
|
|
3529
3638
|
var env = tr.getAttribute("data-env");
|
|
3530
|
-
|
|
3531
|
-
envName: env,
|
|
3639
|
+
var rowBody = pvRowBody(env, {
|
|
3532
3640
|
name: tr.querySelector(".pv-name").value,
|
|
3533
3641
|
shape: tr.querySelector(".pv-shape").value,
|
|
3534
3642
|
baseUrl: tr.querySelector(".pv-url").value,
|
|
3535
|
-
|
|
3643
|
+
priceIn: tr.querySelector(".pv-price-in").value,
|
|
3644
|
+
priceOut: tr.querySelector(".pv-price-out").value,
|
|
3645
|
+
});
|
|
3646
|
+
if(!rowBody){ pvNote.textContent = PV_HALF_PRICE; return; }
|
|
3647
|
+
authorPost("/api/settings/providers/row", rowBody).then(function(r){
|
|
3536
3648
|
pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
|
|
3537
3649
|
// no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
|
|
3538
3650
|
document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
|
|
@@ -3549,6 +3661,14 @@
|
|
|
3549
3661
|
}).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
|
|
3550
3662
|
});
|
|
3551
3663
|
document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
|
|
3664
|
+
// Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
|
|
3665
|
+
document.getElementById("btn-open-keys-folder").addEventListener("click", function(){
|
|
3666
|
+
var out = document.getElementById("pv-keyfile-path");
|
|
3667
|
+
authorPost("/api/settings/open-keys-folder", {}).then(function(r){
|
|
3668
|
+
var b = r.body || {};
|
|
3669
|
+
out.textContent = b.ok && b.opened ? "" : ("Could not open it here. Your keys folder: " + (b.path || "(unknown)"));
|
|
3670
|
+
}).catch(function(){ out.textContent = "Could not open it here — the keys folder is ~/.config/bareloop"; });
|
|
3671
|
+
});
|
|
3552
3672
|
wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
|
|
3553
3673
|
document.getElementById("tab-providers").addEventListener("click", loadProviders);
|
|
3554
3674
|
// Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
|
|
@@ -3604,9 +3724,11 @@
|
|
|
3604
3724
|
// Reuse workflow — set when the card was opened by [Reuse workflow]: the run (or imported job) it came from.
|
|
3605
3725
|
// Only Source, Destination and the two caps are open on that card; the SERVER refuses any other change.
|
|
3606
3726
|
var startFrom = null;
|
|
3727
|
+
// the picked job's OWN check type: with the Check type radio on "Reuse workflow" the card's checkType comes from here, never from the radio
|
|
3728
|
+
var reuseCheckType = "deterministic";
|
|
3729
|
+
var reusePickedName = "";
|
|
3607
3730
|
var reuseSession = false;
|
|
3608
3731
|
var autoSigned = false;
|
|
3609
|
-
var sfOrigin = document.getElementById("chat-startfrom-origin");
|
|
3610
3732
|
var sfLine = document.getElementById("chat-startfrom-line");
|
|
3611
3733
|
var sfNote = document.getElementById("chat-startfrom-note");
|
|
3612
3734
|
// the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
|
|
@@ -3621,7 +3743,8 @@
|
|
|
3621
3743
|
LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live); });
|
|
3622
3744
|
OPEN_IDS.forEach(function(id){ setLock(id, live); });
|
|
3623
3745
|
document.getElementById("jf-model").disabled = live;
|
|
3624
|
-
|
|
3746
|
+
// the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); only a live session freezes them
|
|
3747
|
+
document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live; });
|
|
3625
3748
|
document.getElementById("job-card").classList.toggle("reuse", reuseOn);
|
|
3626
3749
|
if(!reuseOn && !live){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
|
|
3627
3750
|
}
|
|
@@ -3632,11 +3755,105 @@
|
|
|
3632
3755
|
["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"].forEach(function(id){ document.getElementById(id).value = ""; });
|
|
3633
3756
|
document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
|
|
3634
3757
|
}
|
|
3635
|
-
startFrom = null; reuseSession = false; autoSigned = false;
|
|
3636
|
-
|
|
3758
|
+
startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
|
|
3759
|
+
sfLine.hidden = true; sfNote.hidden = true;
|
|
3637
3760
|
setReuseLocked(false);
|
|
3761
|
+
renderPicker();
|
|
3638
3762
|
renderMain();
|
|
3639
3763
|
}
|
|
3764
|
+
// ---- Reuse workflow picker (hamr 2026-10-06): Check type is ONE radio group of three; "Reuse workflow" shows a search
|
|
3765
|
+
// box over the jobs that have a green local run (GET /api/author/reuse-jobs), a pick fills the card through the SAME
|
|
3766
|
+
// path the Run tab's button uses (reuseWorkflow -> bareloop-reuse -> applyReuse), then a picked box with `change`.
|
|
3767
|
+
var CARD_BOX_IDS = ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"];
|
|
3768
|
+
var rwBox = document.getElementById("rw");
|
|
3769
|
+
var rwSearch = document.getElementById("rw-search");
|
|
3770
|
+
var rwQ = document.getElementById("rw-q");
|
|
3771
|
+
var rwList = document.getElementById("rw-list");
|
|
3772
|
+
var rwPicked = document.getElementById("rw-picked");
|
|
3773
|
+
var rwPickedTxt = document.getElementById("rw-picked-txt");
|
|
3774
|
+
var rwJobs = null;
|
|
3775
|
+
var rwFailed = false;
|
|
3776
|
+
var rwShown = [];
|
|
3777
|
+
var rwHl = 0;
|
|
3778
|
+
function verdictValue(){
|
|
3779
|
+
var ck = document.querySelector('input[name="jf-verdict"]:checked');
|
|
3780
|
+
return ck ? ck.value : "deterministic";
|
|
3781
|
+
}
|
|
3782
|
+
function setVerdict(v){
|
|
3783
|
+
document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === v); });
|
|
3784
|
+
}
|
|
3785
|
+
function clearCardBoxes(){
|
|
3786
|
+
CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
|
|
3787
|
+
document.getElementById("jf-cap-note").textContent = "";
|
|
3788
|
+
}
|
|
3789
|
+
// ONE function shows/hides the picker: visible only while the radio is on Reuse workflow; the search box before a
|
|
3790
|
+
// pick, the picked box (+ the estimate line under it) after. A re-attached reuse session shows as picked too.
|
|
3791
|
+
function renderPicker(){
|
|
3792
|
+
var on = verdictValue() === "reuse";
|
|
3793
|
+
var picked = on && (startFrom !== null || reuseSession);
|
|
3794
|
+
rwBox.hidden = !on;
|
|
3795
|
+
rwSearch.hidden = picked;
|
|
3796
|
+
rwPicked.hidden = !picked;
|
|
3797
|
+
rwPickedTxt.innerHTML = picked ? "<b>" + escapeXml(reusePickedName) + "</b>" : "";
|
|
3798
|
+
if(!picked) sfLine.hidden = true;
|
|
3799
|
+
}
|
|
3800
|
+
function rwMark(name, q){
|
|
3801
|
+
var i = q ? name.toLowerCase().indexOf(q.toLowerCase()) : -1;
|
|
3802
|
+
if(i < 0) return escapeXml(name);
|
|
3803
|
+
return escapeXml(name.slice(0, i)) + "<mark>" + escapeXml(name.slice(i, i + q.length)) + "</mark>" + escapeXml(name.slice(i + q.length));
|
|
3804
|
+
}
|
|
3805
|
+
// the server's estimate line, prefixed with the job's check type (the line itself does not carry it)
|
|
3806
|
+
function rwMeta(j){ return j.checkType + " · " + String(j.line || "").replace(/^Same job — /, ""); }
|
|
3807
|
+
function renderReuseList(){
|
|
3808
|
+
var q = rwQ.value.trim();
|
|
3809
|
+
var all = rwJobs || [];
|
|
3810
|
+
rwShown = all.filter(function(j){ return j.job.toLowerCase().indexOf(q.toLowerCase()) !== -1; });
|
|
3811
|
+
if(rwHl >= rwShown.length) rwHl = 0;
|
|
3812
|
+
if(rwShown.length){
|
|
3813
|
+
rwList.innerHTML = rwShown.map(function(j, i){
|
|
3814
|
+
return '<li data-i="' + i + '" data-testid="rw-item" class="' + (i === rwHl ? "hl" : "") + '"><div class="rw-name">' + rwMark(j.job, q) + '</div><div class="rw-meta">' + escapeXml(rwMeta(j)) + "</div></li>";
|
|
3815
|
+
}).join("");
|
|
3816
|
+
} else {
|
|
3817
|
+
rwList.innerHTML = '<div class="rw-empty" data-testid="rw-empty">' + (rwFailed ? "Could not load the job list."
|
|
3818
|
+
: (all.length === 0 ? "No green jobs yet." : 'No green job matches "' + escapeXml(q) + '".')) + "</div>";
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
function loadReuseJobs(){
|
|
3822
|
+
authorGet("/api/author/reuse-jobs" + (modelSelect.value ? "?model=" + encodeURIComponent(modelSelect.value) : "")).then(function(r){
|
|
3823
|
+
rwFailed = !(r && r.ok);
|
|
3824
|
+
rwJobs = rwFailed ? null : r.jobs;
|
|
3825
|
+
renderReuseList();
|
|
3826
|
+
}).catch(function(){ rwFailed = true; rwJobs = null; renderReuseList(); });
|
|
3827
|
+
}
|
|
3828
|
+
// the empty search state: card emptied, nothing picked, Sign & run disabled until a job is picked
|
|
3829
|
+
function openPicker(){
|
|
3830
|
+
clearCardBoxes();
|
|
3831
|
+
rwQ.value = ""; rwHl = 0;
|
|
3832
|
+
document.getElementById("jf-judge").disabled = true;
|
|
3833
|
+
renderPicker();
|
|
3834
|
+
renderReuseList();
|
|
3835
|
+
loadReuseJobs();
|
|
3836
|
+
refreshStartEnabled();
|
|
3837
|
+
rwQ.focus();
|
|
3838
|
+
}
|
|
3839
|
+
function pickReuseJob(j){ if(j) reuseWorkflow(j.runid, modelSelect.value); }
|
|
3840
|
+
rwQ.addEventListener("input", function(){ rwHl = 0; renderReuseList(); });
|
|
3841
|
+
rwQ.addEventListener("keydown", function(e){
|
|
3842
|
+
if(e.key === "ArrowDown"){ rwHl = Math.min(rwHl + 1, rwShown.length - 1); renderReuseList(); e.preventDefault(); }
|
|
3843
|
+
else if(e.key === "ArrowUp"){ rwHl = Math.max(rwHl - 1, 0); renderReuseList(); e.preventDefault(); }
|
|
3844
|
+
else if(e.key === "Enter" && rwShown[rwHl]){ e.preventDefault(); pickReuseJob(rwShown[rwHl]); }
|
|
3845
|
+
});
|
|
3846
|
+
rwList.addEventListener("click", function(e){
|
|
3847
|
+
var li = e.target.closest ? e.target.closest("li") : null;
|
|
3848
|
+
if(li) pickReuseJob(rwShown[Number(li.getAttribute("data-i"))]);
|
|
3849
|
+
});
|
|
3850
|
+
// `change` = back to the empty search state; the radio stays on Reuse workflow
|
|
3851
|
+
function unpickReuse(){
|
|
3852
|
+
clearStartFrom();
|
|
3853
|
+
setVerdict("reuse");
|
|
3854
|
+
openPicker();
|
|
3855
|
+
}
|
|
3856
|
+
document.getElementById("rw-change").addEventListener("click", unpickReuse);
|
|
3640
3857
|
function openNewCard(){
|
|
3641
3858
|
card.hidden = false;
|
|
3642
3859
|
thread.innerHTML = "";
|
|
@@ -3667,12 +3884,12 @@
|
|
|
3667
3884
|
// back to a fresh empty card: every box, check type deterministic, the default (first) Model, origin/estimate/notes
|
|
3668
3885
|
// hidden, progress list and thread cleared, reuse lock lifted
|
|
3669
3886
|
function resetCard(){
|
|
3670
|
-
|
|
3671
|
-
|
|
3887
|
+
clearCardBoxes();
|
|
3888
|
+
setVerdict("deterministic");
|
|
3672
3889
|
document.getElementById("jf-judge").disabled = true;
|
|
3673
3890
|
modelSelect.selectedIndex = 0;
|
|
3674
3891
|
msgInput.value = "";
|
|
3675
|
-
|
|
3892
|
+
rwQ.value = "";
|
|
3676
3893
|
lastPhase = null;
|
|
3677
3894
|
clearStartFrom();
|
|
3678
3895
|
openNewCard();
|
|
@@ -3694,7 +3911,8 @@
|
|
|
3694
3911
|
var moneyCap = Number(document.getElementById("jf-cap-money").value);
|
|
3695
3912
|
var timeCapMin = document.getElementById("jf-cap-time").value.trim();
|
|
3696
3913
|
return {
|
|
3697
|
-
|
|
3914
|
+
// on "Reuse workflow" the type is the picked job's own, never the radio's
|
|
3915
|
+
checkType: (verdictValue() === "reuse" ? reuseCheckType : verdictValue()) === "rubric" ? "rubric" : "deterministic",
|
|
3698
3916
|
model: document.getElementById("jf-model").value,
|
|
3699
3917
|
jobName: document.getElementById("jf-name").value.trim(),
|
|
3700
3918
|
goal: document.getElementById("jf-goal").value.trim(),
|
|
@@ -3718,7 +3936,8 @@
|
|
|
3718
3936
|
function refreshStartEnabled(){
|
|
3719
3937
|
var v = Number(moneyCapInput.value);
|
|
3720
3938
|
var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
|
|
3721
|
-
|
|
3939
|
+
// on Reuse workflow nothing starts before a job is picked
|
|
3940
|
+
startOk = capOk && !sessionLive && keyOk && (verdictValue() !== "reuse" || startFrom !== null);
|
|
3722
3941
|
syncCardLock();
|
|
3723
3942
|
renderMain();
|
|
3724
3943
|
refreshClearBtn();
|
|
@@ -3741,11 +3960,21 @@
|
|
|
3741
3960
|
});
|
|
3742
3961
|
// the limit changed in Settings: re-read the note for whatever cap is typed
|
|
3743
3962
|
document.addEventListener("bareloop-limit-changed", refreshCapNote);
|
|
3963
|
+
// Check type: Deterministic / Rubric / Reuse workflow. Reuse opens the picker on an empty card; Deterministic or
|
|
3964
|
+
// Rubric while the picker is showing (picked or not) LEAVES reuse — the empty card on that type (clearStartFrom).
|
|
3965
|
+
function onVerdictChange(v){
|
|
3966
|
+
if(v === "reuse"){ if(rwBox.hidden) openPicker(); return; }
|
|
3967
|
+
if(!rwBox.hidden){
|
|
3968
|
+
clearStartFrom();
|
|
3969
|
+
clearCardBoxes();
|
|
3970
|
+
setVerdict(v);
|
|
3971
|
+
renderPicker();
|
|
3972
|
+
}
|
|
3973
|
+
document.getElementById("jf-judge").disabled = v !== "rubric";
|
|
3974
|
+
refreshStartEnabled();
|
|
3975
|
+
}
|
|
3744
3976
|
document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
|
|
3745
|
-
r.addEventListener("change", function(){
|
|
3746
|
-
var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
|
|
3747
|
-
document.getElementById("jf-judge").disabled = !isRubric;
|
|
3748
|
-
});
|
|
3977
|
+
r.addEventListener("change", function(){ onVerdictChange(r.value); });
|
|
3749
3978
|
});
|
|
3750
3979
|
|
|
3751
3980
|
// build item 7 / build item 5 (2026-09-28) — key + reachability, $0,
|
|
@@ -3818,10 +4047,11 @@
|
|
|
3818
4047
|
var q = (startFrom.importId ? "import=" + encodeURIComponent(startFrom.importId) : "runid=" + encodeURIComponent(startFrom.runid)) +
|
|
3819
4048
|
"&model=" + encodeURIComponent(modelSelect.value);
|
|
3820
4049
|
authorGet("/api/author/start-from?" + q).then(function(r){
|
|
3821
|
-
if(startFrom && r && r.ok){ sfLine.textContent = r.line
|
|
4050
|
+
if(startFrom && r && r.ok){ sfLine.textContent = r.line ? rwMeta({checkType: reuseCheckType, line: r.line}) : ""; }
|
|
3822
4051
|
}).catch(function(){ /* the line keeps what it had */ });
|
|
3823
4052
|
}
|
|
3824
4053
|
modelSelect.addEventListener("change", refreshReuseLine);
|
|
4054
|
+
modelSelect.addEventListener("change", function(){ if(!rwBox.hidden && startFrom === null && !reuseSession) loadReuseJobs(); });
|
|
3825
4055
|
// P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
|
|
3826
4056
|
// is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
|
|
3827
4057
|
function loadModels(){
|
|
@@ -3940,7 +4170,7 @@
|
|
|
3940
4170
|
var open = askBoxOpenFor(st);
|
|
3941
4171
|
msgInput.disabled = !open;
|
|
3942
4172
|
if(!open) msgInput.value = "";
|
|
3943
|
-
var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom);
|
|
4173
|
+
var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom || verdictValue() === "reuse");
|
|
3944
4174
|
mainBtn.textContent = b.text;
|
|
3945
4175
|
mainBtn.disabled = b.disabled;
|
|
3946
4176
|
mainAction = b.action;
|
|
@@ -3960,6 +4190,11 @@
|
|
|
3960
4190
|
function fitCardBoxes(){
|
|
3961
4191
|
["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){ fitBox(document.getElementById(id)); });
|
|
3962
4192
|
}
|
|
4193
|
+
// live growth: each card box re-fits on its own input (typing and Enter), through the same fitBox
|
|
4194
|
+
["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){
|
|
4195
|
+
var box = document.getElementById(id);
|
|
4196
|
+
box.addEventListener("input", function(){ fitBox(box); });
|
|
4197
|
+
});
|
|
3963
4198
|
window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
|
|
3964
4199
|
document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
|
|
3965
4200
|
// Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
|
|
@@ -4055,7 +4290,9 @@
|
|
|
4055
4290
|
reuseSession = st.reuse === true;
|
|
4056
4291
|
autoSigned = false;
|
|
4057
4292
|
signClickedOnce = reuseSession;
|
|
4058
|
-
|
|
4293
|
+
reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
|
|
4294
|
+
reusePickedName = c.jobName || "job";
|
|
4295
|
+
setVerdict(reuseSession ? "reuse" : reuseCheckType);
|
|
4059
4296
|
if(c.model){
|
|
4060
4297
|
if(!Array.prototype.some.call(modelSelect.options, function(o){ return o.value === c.model; })){
|
|
4061
4298
|
modelSelect.insertAdjacentHTML("beforeend", '<option value="' + escapeXml(c.model) + '">' + escapeXml(c.model) + "</option>");
|
|
@@ -4068,6 +4305,8 @@
|
|
|
4068
4305
|
setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
|
|
4069
4306
|
document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
|
|
4070
4307
|
if(reuseSession) setReuseLocked(true);
|
|
4308
|
+
renderPicker();
|
|
4309
|
+
fitCardBoxes();
|
|
4071
4310
|
renderMessages(st);
|
|
4072
4311
|
renderActions(st);
|
|
4073
4312
|
if(pollTimer) clearInterval(pollTimer);
|
|
@@ -4086,15 +4325,17 @@
|
|
|
4086
4325
|
|
|
4087
4326
|
// ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
|
|
4088
4327
|
function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
|
|
4089
|
-
|
|
4090
|
-
|
|
4328
|
+
// ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
|
|
4329
|
+
// `bareloop-reuse` event), so the card ends up in the same picked state whichever door was used.
|
|
4330
|
+
function applyReuse(d){
|
|
4091
4331
|
var pre = d.prefill || {};
|
|
4092
4332
|
var c = pre.card || {};
|
|
4093
4333
|
clearStartFrom();
|
|
4094
4334
|
openNewCard();
|
|
4095
4335
|
startFrom = d.importId ? {importId: d.importId} : {runid: d.runid};
|
|
4096
|
-
|
|
4097
|
-
|
|
4336
|
+
reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
|
|
4337
|
+
reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
|
|
4338
|
+
setVerdict("reuse");
|
|
4098
4339
|
if(c.model){ modelSelect.value = c.model; }
|
|
4099
4340
|
setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
|
|
4100
4341
|
setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
|
|
@@ -4102,10 +4343,9 @@
|
|
|
4102
4343
|
setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
|
|
4103
4344
|
document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
|
|
4104
4345
|
setReuseLocked(true);
|
|
4105
|
-
|
|
4106
|
-
sfOrigin.textContent = "Reuse: " + ((pre.origin && pre.origin.job) || "job") + (d.runid ? " (run " + d.runid + ")" : " (imported)");
|
|
4346
|
+
renderPicker();
|
|
4107
4347
|
sfLine.hidden = false;
|
|
4108
|
-
sfLine.textContent = pre.line
|
|
4348
|
+
sfLine.textContent = pre.line ? rwMeta({checkType: reuseCheckType, line: pre.line}) : "";
|
|
4109
4349
|
sfLine.className = "startfrom-line";
|
|
4110
4350
|
sfNote.hidden = !pre.note;
|
|
4111
4351
|
sfNote.textContent = pre.note || "";
|
|
@@ -4113,7 +4353,8 @@
|
|
|
4113
4353
|
refreshReuseLine();
|
|
4114
4354
|
refreshCapNote();
|
|
4115
4355
|
refreshStartEnabled();
|
|
4116
|
-
}
|
|
4356
|
+
}
|
|
4357
|
+
document.addEventListener("bareloop-reuse", function(e){ applyReuse(e.detail || {}); });
|
|
4117
4358
|
|
|
4118
4359
|
// build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
|
|
4119
4360
|
// (fed by the every-2s `poll()`) unconditionally overwrites
|