bareloop 0.35.0 → 0.36.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.
@@ -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-rowmeta{margin-left:auto;}
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);}
@@ -467,6 +467,7 @@
467
467
  .pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
468
468
  .pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
469
469
  .pv-table .hint{display:block;margin-top:3px;}
470
+ .pv-table td.pv-price-cell input{width:90px;}
470
471
  /* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
471
472
  .pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
472
473
  .pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
@@ -742,21 +743,24 @@
742
743
  <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
743
744
  <div class="keyfile-strip" data-testid="keyfile-box">
744
745
  <div class="keyfile-strip-main">
745
- <span>Put <code>NAME=key</code> lines in <code id="pv-keyfile-path" data-testid="keyfile-path">the keys file (path loading…)</code>, then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
746
+ <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>
747
+ <span class="hint" id="pv-keyfile-path" data-testid="keyfile-path"></span>
746
748
  <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
747
749
  </div>
748
750
  <div class="keyfile-strip-actions">
751
+ <button class="btn small" type="button" id="btn-open-keys-folder" data-testid="btn-open-keys-folder">Open keys folder</button>
749
752
  <button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
750
753
  </div>
751
754
  </div>
752
755
  <div class="table-wrap" data-testid="provider-table-wrap">
753
756
  <table class="pv-table" data-testid="provider-table">
754
757
  <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>
758
+ <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
759
  </thead>
757
760
  <tbody id="pv-rows"></tbody>
758
761
  </table>
759
762
  </div>
763
+ <div class="hint" id="pv-price-hint" data-testid="pv-price-hint">If your vendor lists two prices, enter the higher one.</div>
760
764
  <div id="pv-shell" data-testid="pv-shell"></div>
761
765
  <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
766
  </section>
@@ -946,6 +950,8 @@
946
950
  // jump straight to that SAME part's Audit-tab row (build item B's
947
951
  // click-through), with no re-lookup needed.
948
952
  function partBoxState(part, isLastLivePart){
953
+ // the drafting part happened before the run existed: it is always done
954
+ if(part.kind === "drafting") return "done";
949
955
  if(part.outcome === "green" || part.outcome === "satisfied" || part.outcome === "already-green") return "done";
950
956
  if(typeof part.outcome === "string") return "stopped";
951
957
  // P5-R: its leg's halt cut this part off — the map's existing "stopped" state, never "done"
@@ -968,6 +974,7 @@
968
974
  // (a single-attempt step, or the whole "run" box on an old-shape spine)
969
975
  // reads the part's own outcome.
970
976
  function partResultGlyph(part, box){
977
+ if(part.kind === "drafting") return glyphSpan(attemptGlyph("green", false)); // [✓]: drafting is done once the run exists
971
978
  if(partHasNoVerdict(part)) return null;
972
979
  var boxIsLive = box.state === "running";
973
980
  if(box.attempts.length > 1) return box.attempts.map(function(a){ return glyphSpan(attemptGlyph(a.outcome, boxIsLive)); }).join("");
@@ -1247,8 +1254,12 @@
1247
1254
  if(!d || isNaN(d.getTime())) return "unknown";
1248
1255
  return d.toLocaleString("en-US", {year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit"});
1249
1256
  }
1250
- // the right-side header: `[sign] job (runid) │ **word** — reason · ended 10/4/2026, 6:01 PM` (no capitals: the job
1257
+ // the right-side header: `[sign] job (run-N) │ **word** — reason · ended 10/4/2026, 6:01 PM` (no capitals: the job
1251
1258
  // name is shown as written). `bodyHtml` is already escaped.
1259
+ // a run's NAME for a person (P6 item 7): `job (run-N)`, N from the server (`runNo`, one owner) — the page never counts.
1260
+ // A run with no number (an imported bundle's run is not in the run list) falls back to its id.
1261
+ function runLabel(runNo, runid){ return typeof runNo === "number" ? "run-" + runNo : String(runid || ""); }
1262
+ function runName(job, runNo, runid){ return job + " (" + runLabel(runNo, runid) + ")"; }
1252
1263
  function setRunHeader(sign, nameText, bodyHtml){
1253
1264
  document.getElementById("active-wf-name").textContent = nameText;
1254
1265
  document.getElementById("active-wf-dot").className = "dot " + glyphClass(sign);
@@ -1294,7 +1305,7 @@
1294
1305
  row.setAttribute("tabindex", "0");
1295
1306
  row.setAttribute("role", "button");
1296
1307
  row.setAttribute("aria-pressed", selected ? "true" : "false");
1297
- var histFullName = r.job + ' (' + r.runid + ')';
1308
+ var histFullName = runName(r.job, r.runNo, r.runid);
1298
1309
  if(r.starting){
1299
1310
  row.innerHTML =
1300
1311
  '<span class="wf-line1"><span class="dot amber"></span>' +
@@ -1312,14 +1323,20 @@
1312
1323
  '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1313
1324
  '</span>';
1314
1325
  } else if(compact){
1315
- // an EXPANDED per-run row under a workflow: `[sign] **word** — reason runid · $ · date`
1326
+ // an EXPANDED per-run row under a workflow (hamr 2026-10-06), 2 lines, left-aligned:
1327
+ // `[sign] (run-N) **word** — reason` / `$ ($drafting) · wall · date`
1316
1328
  row.innerHTML =
1317
1329
  '<span class="wf-line1 wf-compact"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1318
- '<span class="wf-ended wf-ended-inline" title="' + escapeXml(histFullName) + '" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span>' +
1319
- '<span class="wf-meta wf-rowmeta">' + escapeXml(r.runid) + ' \u00B7 ' + escapeXml(rowSpendText(r)) + ' \u00B7 ' + escapeXml(r.date || "unknown") + '</span>' +
1320
- resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) + '</span>';
1330
+ '<span class="wf-name wf-runno" title="' + escapeXml(histFullName) + '" data-testid="hist-runno-' + escapeXml(r.runid) + '">(' + escapeXml(runLabel(r.runNo, r.runid)) + ')</span>' +
1331
+ '<span class="wf-ended wf-ended-inline" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span></span>' +
1332
+ '<span class="wf-meta-line">' +
1333
+ '<span class="wf-meta">' + escapeXml(rowSpendText(r, true)) + '</span>' +
1334
+ '<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
1335
+ '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
1336
+ resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
1337
+ '</span>';
1321
1338
  } else {
1322
- // History card: line 1 sign + job (runid); line 2 **word** — reason; line 3 the facts
1339
+ // History card: line 1 sign + job (run-N); line 2 **word** — reason; line 3 the facts
1323
1340
  row.innerHTML =
1324
1341
  '<span class="wf-line1"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1325
1342
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
@@ -1370,6 +1387,7 @@
1370
1387
  runs: jobRuns,
1371
1388
  runCount: jobRuns.length,
1372
1389
  lastRunid: last.runid,
1390
+ lastRunNo: last.runNo,
1373
1391
  lastAt: last.at,
1374
1392
  lastGlyph: last.glyph || "▶",
1375
1393
  lastStatus: last.status || null,
@@ -1377,7 +1395,7 @@
1377
1395
  lastResumedCount: last.resumedCount || 0,
1378
1396
  lastCheckType: last.checkType || "unknown",
1379
1397
  lastModel: last.model || null,
1380
- lastSpend: rowSpendText(last),
1398
+ lastSpend: rowSpendText(last, true),
1381
1399
  lastWall: rowWallText(last) || "unknown",
1382
1400
  lastDate: last.date,
1383
1401
  };
@@ -1503,18 +1521,16 @@
1503
1521
  row.setAttribute("role", "button");
1504
1522
  row.setAttribute("aria-expanded", expanded ? "true" : "false");
1505
1523
  row.setAttribute("aria-pressed", isSelected ? "true" : "false");
1506
- var wfFullName = g.job + ' (' + g.lastRunid + ')';
1524
+ var wfFullName = runName(g.job, g.lastRunNo, g.lastRunid);
1507
1525
  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></span>' +
1510
- (g.lastStatus ? '<span class="wf-ended" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') +
1526
+ '<span class="wf-line1 wf-compact"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
1527
+ '<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span>' +
1528
+ (g.lastStatus ? '<span class="wf-ended wf-ended-inline" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') + '</span>' +
1511
1529
  '<span class="wf-meta-line">' +
1512
1530
  '<span class="wf-meta">' + escapeXml(g.lastCheckType) + '</span>' +
1513
1531
  '<span class="wf-meta">' + escapeXml(g.lastSpend) + '</span>' +
1514
1532
  '<span class="wf-meta">' + escapeXml(g.lastWall) + '</span>' +
1515
1533
  '<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
1534
  '</span>';
1519
1535
  row.addEventListener("click", function(){
1520
1536
  // a single-run job has nothing to expand — the click still opens
@@ -1559,7 +1575,7 @@
1559
1575
  function filterBarHTML(scope){
1560
1576
  return (
1561
1577
  '<div class="filterbar" data-scope="' + scope + '" data-testid="' + scope + '-filters">' +
1562
- '<input type="text" class="search-input" placeholder="search job, run id or model…" ' +
1578
+ '<input type="text" class="search-input" placeholder="search job, run-N, run id or model…" ' +
1563
1579
  'id="' + scope + '-filter-search" data-testid="' + scope + '-filter-search" autocomplete="off">' +
1564
1580
  '<div class="chips" role="group" aria-label="Filter">' +
1565
1581
  '<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 +1604,18 @@
1588
1604
  // means "no search filter" — everything passes it, same posture as an
1589
1605
  // empty chip group). item C (2026-09-26): also matches the run's model
1590
1606
  // name (e.g. "deepseek", "sonnet") — `model` is `null`/absent on an older
1591
- // archived run, same posture as a missing job/runid.
1592
- function matchesSearch(job, runid, model, query){
1607
+ // archived run, same posture as a missing job/runid. Also the run's shown
1608
+ // name: `run-N` and `job (run-N)` (N is the server's `runNo`, never counted here).
1609
+ function matchesSearch(job, runid, model, query, runNo){
1593
1610
  if(!query) return true;
1594
1611
  var q = String(query).trim().toLowerCase();
1595
1612
  if(!q) return true;
1596
1613
  var j = String(job || "").toLowerCase();
1597
1614
  var r = String(runid || "").toLowerCase();
1598
1615
  var m = String(model || "").toLowerCase();
1599
- return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1;
1616
+ var label = typeof runNo === "number" ? "run-" + runNo : "";
1617
+ var shown = label ? j + " (" + label + ")" : "";
1618
+ return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1 || (label !== "" && (label.indexOf(q) !== -1 || shown.indexOf(q) !== -1));
1600
1619
  }
1601
1620
  function filterRuns(runs, filters, nowMs){
1602
1621
  nowMs = (typeof nowMs === "number") ? nowMs : Date.now();
@@ -1607,7 +1626,7 @@
1607
1626
  return (runs || []).filter(function(r){
1608
1627
  if(ct.length && ct.indexOf(r.checkType) === -1) return false;
1609
1628
  if(rs.length && rs.indexOf(r.glyph) === -1) return false;
1610
- if(!matchesSearch(r.job, r.runid, r.model, q)) return false;
1629
+ if(!matchesSearch(r.job, r.runid, r.model, q, r.runNo)) return false;
1611
1630
  if(time !== "all"){
1612
1631
  var days = time === "7d" ? 7 : 30;
1613
1632
  var t = typeof r.at === "string" ? Date.parse(r.at) : NaN;
@@ -2101,15 +2120,16 @@
2101
2120
  * complete run whose drafting share is itself incomplete — hamr's ruling:
2102
2121
  * a reader summing the two must never read the leading number as exact
2103
2122
  * when the bracket next to it isn't). `draftSpendComplete === false` marks
2104
- * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX".
2123
+ * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX". `shortForm` drops the word
2124
+ * "drafting" (the Runs list cards only — "$5.00 ($0.08)").
2105
2125
  */
2106
- function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd){
2126
+ function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd, shortForm){
2107
2127
  var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
2108
2128
  var draftFloor = hasDraft && draftSpendComplete === false;
2109
2129
  var core = (spendCoreVal === null || spendCoreVal === undefined)
2110
2130
  ? "unknown"
2111
2131
  : ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
2112
- var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
2132
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
2113
2133
  var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
2114
2134
  return core + draftPart + capPart;
2115
2135
  }
@@ -2134,13 +2154,13 @@
2134
2154
  * is a floor only when its own drafting share is incomplete.
2135
2155
  * @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
2136
2156
  */
2137
- function rowSpendText(r){
2157
+ function rowSpendText(r, shortForm){
2138
2158
  if(r.starting) return "starting…";
2139
- if(rowIsLive(r)) return liveSpendText(r.spendFloorUsd, r.draftSpentUsd, r.draftSpendComplete);
2159
+ if(rowIsLive(r)) return liveSpendText(r.spendFloorUsd, r.draftSpentUsd, r.draftSpendComplete, shortForm);
2140
2160
  var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
2141
2161
  var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
2142
2162
  var coreFloor = !!r.died || draftIncomplete;
2143
- return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete);
2163
+ return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete, undefined, shortForm);
2144
2164
  }
2145
2165
  function duration(ms){
2146
2166
  if(ms === null || ms === undefined) return "unknown";
@@ -2163,11 +2183,11 @@
2163
2183
  * @param {number|null} spendFloorUsd @param {number|null} draftSpentUsd
2164
2184
  * @param {boolean|null} draftSpendComplete @returns {string}
2165
2185
  */
2166
- function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete){
2186
+ function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete, shortForm){
2167
2187
  if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
2168
2188
  var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
2169
2189
  var draftFloor = hasDraft && draftSpendComplete === false;
2170
- var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
2190
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
2171
2191
  return panelMoney(spendFloorUsd) + " so far" + draftPart;
2172
2192
  }
2173
2193
  /** build item 6 — the matching wall-clock phrase: "running <duration>",
@@ -2474,7 +2494,7 @@
2474
2494
 
2475
2495
  function renderRun(detail){
2476
2496
  if(detail.fileMissing){
2477
- setRunHeader("\u00B7", detail.job + " (" + detail.runid + ")", "file missing");
2497
+ setRunHeader("\u00B7", runName(detail.job, detail.runNo, detail.runid), "file missing");
2478
2498
  document.getElementById("run-empty").hidden = false;
2479
2499
  document.getElementById("run-empty").textContent = "spine file no longer on disk";
2480
2500
  document.getElementById("run-content").hidden = true;
@@ -2485,7 +2505,7 @@
2485
2505
  }
2486
2506
 
2487
2507
  if(detail.starting){
2488
- setRunHeader("\u25B6", detail.job + " (" + detail.runid + ")", statusWordHtml(detail.status, "starting\u2026") + " \u00B7 " + escapeXml("started " + fmtLocalDateTime(detail.at)));
2508
+ setRunHeader("\u25B6", runName(detail.job, detail.runNo, detail.runid), statusWordHtml(detail.status, "starting\u2026") + " \u00B7 " + escapeXml("started " + fmtLocalDateTime(detail.at)));
2489
2509
  document.getElementById("run-empty").hidden = false;
2490
2510
  document.getElementById("run-empty").textContent = "starting… the run has not written its log yet";
2491
2511
  document.getElementById("run-content").hidden = true;
@@ -2498,7 +2518,7 @@
2498
2518
  document.getElementById("audit-body").hidden = false;
2499
2519
  renderEnded(detail);
2500
2520
 
2501
- setRunHeader(detail.glyph, detail.job + " (" + detail.runid + ")", runHeaderBody(detail));
2521
+ setRunHeader(detail.glyph, runName(detail.job, detail.runNo, detail.runid), runHeaderBody(detail));
2502
2522
 
2503
2523
  document.getElementById("run-empty").hidden = true;
2504
2524
  document.getElementById("run-content").hidden = false;
@@ -2608,7 +2628,7 @@
2608
2628
  (box.blocked > 0 ? '<span class="badge red" data-testid="part-blocked-badge">' + box.blocked + ' blocked</span>' : '') +
2609
2629
  '</div>' +
2610
2630
  '<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(" &middot; ") + '</div>' +
2611
- '<div class="step-meta">' + escapeXml(toolBreakdownLine(box.part.byTool)) + '</div>';
2631
+ '<div class="step-meta">' + escapeXml(partLine2Text(box.part)) + '</div>';
2612
2632
  listEl.appendChild(card);
2613
2633
  var jumpToAudit = function(){
2614
2634
  document.getElementById("tab-audit").click();
@@ -2769,6 +2789,46 @@
2769
2789
  // shows — expand -> attempts (parts with >1, e.g. a multi-attempt step or
2770
2790
  // the fix loop) or straight to a rounds table (every other part kind,
2771
2791
  // which carries exactly one synthetic whole-part attempt).
2792
+ // The DRAFTING part (a panel-authored run's first part; src/draftspend.js `draftingPartFor`): ONE owner of its
2793
+ // money wording, line 1, line 2 and its Audit table. Money is the spine's own drafting figure ("at least" when it
2794
+ // is a floor); a call with no price reads "unknown", never $0.
2795
+ function draftingMoneyText(part){
2796
+ return (part.spendComplete === false ? "at least " : "") + panelMoney(part.spentUsd);
2797
+ }
2798
+ function draftingLine1Text(part){
2799
+ var n = typeof part.rounds === "number" ? part.rounds : 0;
2800
+ return (part.wallMs === null || part.wallMs === undefined ? "–" : duration(part.wallMs)) + " &middot; " + draftingMoneyText(part) + " &middot; " + n + " call" + (n === 1 ? "" : "s");
2801
+ }
2802
+ // line 2 of a part's card / Audit body: the drafting step names, or a worker part's tool breakdown
2803
+ function partLine2Text(part){
2804
+ if(part && part.kind === "drafting") return (part.draftSteps || []).map(function(x){ return x.label; }).join(" · ");
2805
+ return toolBreakdownLine(part.byTool);
2806
+ }
2807
+ function draftingCallCost(c){
2808
+ if(c.costUsd === null || c.costUsd === undefined) return "unknown";
2809
+ if(c.unpricedRounds > 0) return "unknown (" + c.unpricedRounds + " unpriced round" + (c.unpricedRounds === 1 ? "" : "s") + ")";
2810
+ return panelMoney(c.costUsd);
2811
+ }
2812
+ // one row per drafting step ("–" where it made no call); a step's further calls get their own row named by the
2813
+ // call's own label (confirm#2, revise-1…); the last row is the total, equal to the header's money
2814
+ function draftingTableHtml(part){
2815
+ var rows = "";
2816
+ (part.draftSteps || []).forEach(function(st){
2817
+ var wall = st.wallMs === null || st.wallMs === undefined ? "–" : duration(st.wallMs);
2818
+ var calls = Array.isArray(st.calls) ? st.calls : [];
2819
+ var first = calls[0];
2820
+ rows += "<tr><td>" + escapeXml(st.label) + "</td><td>" + wall + "</td><td>" + (first ? "1" : "–") + "</td><td>" +
2821
+ (first && first.model ? escapeXml(first.model) : "–") + "</td><td>" + (first ? draftingCallCost(first) : "–") + "</td></tr>";
2822
+ calls.slice(1).forEach(function(c){
2823
+ rows += "<tr><td>" + escapeXml(st.label) + " (" + escapeXml(c.label) + ")</td><td>–</td><td>1</td><td>" +
2824
+ (c.model ? escapeXml(c.model) : "–") + "</td><td>" + draftingCallCost(c) + "</td></tr>";
2825
+ });
2826
+ });
2827
+ rows += '<tr class="drafting-total"><td><b>total</b></td><td>' + (part.wallMs === null || part.wallMs === undefined ? "–" : duration(part.wallMs)) +
2828
+ "</td><td>" + (typeof part.rounds === "number" ? part.rounds : 0) + "</td><td>–</td><td>" + draftingMoneyText(part) + "</td></tr>";
2829
+ return '<div class="audit-table-scroll"><table class="rounds-table drafting-table" data-testid="drafting-table">' +
2830
+ "<thead><tr><th>Step</th><th>Time</th><th>Calls</th><th>Model</th><th>Cost</th></tr></thead><tbody>" + rows + "</tbody></table></div>";
2831
+ }
2772
2832
  function partHasWrites(part){
2773
2833
  var bt = part.byTool;
2774
2834
  if(!bt) return false;
@@ -2798,6 +2858,7 @@
2798
2858
  * @returns {string}
2799
2859
  */
2800
2860
  function partLine1Text(part, detail){
2861
+ if(part && part.kind === "drafting") return draftingLine1Text(part);
2801
2862
  var isLiveRunPart = part && part.kind === "run" && detail && !detail.died && detail.spentUsd === null;
2802
2863
  var wallText = (isLiveRunPart && part.wallMs === null) ? liveWallPhrase(detail.wallFloorMs) : duration(part.wallMs);
2803
2864
  var spendText = (isLiveRunPart && part.spentUsd === null)
@@ -3016,9 +3077,15 @@
3016
3077
  built = true;
3017
3078
  var toolsHeader = document.createElement("div");
3018
3079
  toolsHeader.className = "hint";
3019
- toolsHeader.textContent = toolBreakdownLine(part.byTool);
3080
+ toolsHeader.textContent = partLine2Text(part);
3020
3081
  body.appendChild(toolsHeader);
3021
- if(attempts.length > 1){
3082
+ if(part.kind === "drafting"){
3083
+ // no tool calls and no rounds: one row per drafting step instead (the drafting part is index 0 of the
3084
+ // shown list; the rounds endpoint has nothing to serve for it)
3085
+ var draftingEl = document.createElement("div");
3086
+ draftingEl.innerHTML = draftingTableHtml(part);
3087
+ body.appendChild(draftingEl);
3088
+ } else if(attempts.length > 1){
3022
3089
  // parts WITH an attempt sub-loop (a multi-attempt step, or the fix
3023
3090
  // loop) expand to attempt rows first — each attempt expands to its
3024
3091
  // own rounds table on its own click, same as the Run tab used to.
@@ -3480,7 +3547,7 @@
3480
3547
  authorGet("/api/settings/providers").then(function(r){
3481
3548
  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
3549
  var kf = r.keysFile;
3483
- document.getElementById("pv-keyfile-path").textContent = kf.path;
3550
+ document.getElementById("pv-keyfile-path").textContent = "";
3484
3551
  document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
3485
3552
  pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
3486
3553
  var e = escapeXml(row.envName);
@@ -3500,8 +3567,10 @@
3500
3567
  ? '<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
3568
  : '<span class="hint">not available</span>') + "</td>"
3502
3569
  + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
3503
- + "<td>" + bal + "</td></tr>";
3504
- }).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>';
3570
+ + "<td>" + bal + "</td>"
3571
+ + '<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>'
3572
+ + '<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>';
3573
+ }).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
3574
  pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
3506
3575
  authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
3507
3576
  fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
@@ -3515,6 +3584,16 @@
3515
3584
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
3516
3585
  }).catch(function(){ pvNote.textContent = "could not read providers"; });
3517
3586
  }
3587
+ // The row's POST body. A price is both boxes or neither (the server refuses a half-set one), so while exactly one
3588
+ // is filled — the normal middle of typing a price — the price fields are LEFT OUT (the server keeps what is saved)
3589
+ // and the other boxes still save; the price posts once both are filled, or both are cleared.
3590
+ function pvRowBody(env, v){
3591
+ var pin = String(v.priceIn).trim();
3592
+ var pout = String(v.priceOut).trim();
3593
+ var body = { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl };
3594
+ if((pin === "") === (pout === "")){ body.priceInPerM = pin; body.priceOutPerM = pout; }
3595
+ return body;
3596
+ }
3518
3597
  pvRows.addEventListener("change", function(e){
3519
3598
  var t = e.target;
3520
3599
  var tr = t.closest("tr");
@@ -3525,14 +3604,15 @@
3525
3604
  }).catch(function(){ pvNote.textContent = "could not save"; });
3526
3605
  return;
3527
3606
  }
3528
- if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url"))) return;
3607
+ 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
3608
  var env = tr.getAttribute("data-env");
3530
- authorPost("/api/settings/providers/row", {
3531
- envName: env,
3609
+ authorPost("/api/settings/providers/row", pvRowBody(env, {
3532
3610
  name: tr.querySelector(".pv-name").value,
3533
3611
  shape: tr.querySelector(".pv-shape").value,
3534
3612
  baseUrl: tr.querySelector(".pv-url").value,
3535
- }).then(function(r){
3613
+ priceIn: tr.querySelector(".pv-price-in").value,
3614
+ priceOut: tr.querySelector(".pv-price-out").value,
3615
+ })).then(function(r){
3536
3616
  pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
3537
3617
  // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
3538
3618
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
@@ -3549,6 +3629,14 @@
3549
3629
  }).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
3550
3630
  });
3551
3631
  document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
3632
+ // Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
3633
+ document.getElementById("btn-open-keys-folder").addEventListener("click", function(){
3634
+ var out = document.getElementById("pv-keyfile-path");
3635
+ authorPost("/api/settings/open-keys-folder", {}).then(function(r){
3636
+ var b = r.body || {};
3637
+ out.textContent = b.ok && b.opened ? "" : ("Could not open it here. Your keys folder: " + (b.path || "(unknown)"));
3638
+ }).catch(function(){ out.textContent = "Could not open it here — the keys folder is ~/.config/bareloop"; });
3639
+ });
3552
3640
  wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
3553
3641
  document.getElementById("tab-providers").addEventListener("click", loadProviders);
3554
3642
  // Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
@@ -3960,6 +4048,11 @@
3960
4048
  function fitCardBoxes(){
3961
4049
  ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){ fitBox(document.getElementById(id)); });
3962
4050
  }
4051
+ // live growth: each card box re-fits on its own input (typing and Enter), through the same fitBox
4052
+ ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){
4053
+ var box = document.getElementById(id);
4054
+ box.addEventListener("input", function(){ fitBox(box); });
4055
+ });
3963
4056
  window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
3964
4057
  document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
3965
4058
  // Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
@@ -4068,6 +4161,7 @@
4068
4161
  setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4069
4162
  document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4070
4163
  if(reuseSession) setReuseLocked(true);
4164
+ fitCardBoxes();
4071
4165
  renderMessages(st);
4072
4166
  renderActions(st);
4073
4167
  if(pollTimer) clearInterval(pollTimer);