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.
@@ -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);}
@@ -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:2px 0 8px;font-size:12px;color:var(--accent);overflow-wrap:anywhere;}
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
- .job-card.reuse .radio-row label{opacity:.55;cursor:not-allowed;}
278
- .startfrom-origin{font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 4px;padding-right:112px;overflow-wrap:anywhere;}
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 <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>
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.state) + '</text>';
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 (runid) │ **word** — reason · ended 10/4/2026, 6:01 PM` (no capitals: the 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 + ' (' + r.runid + ')';
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: `[sign] **word** — reason runid · $ · date`
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-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>';
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 (runid); line 2 **word** — reason; line 3 the facts
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 + ' (' + g.lastRunid + ')';
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></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
- function matchesSearch(job, runid, model, query){
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
- return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1;
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
- function reuseWorkflow(runid){
2344
- authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid)).then(function(r){
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 + " (" + detail.runid + ")", "file missing");
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 + " (" + detail.runid + ")", statusWordHtml(detail.status, "starting\u2026") + " \u00B7 " + escapeXml("started " + fmtLocalDateTime(detail.at)));
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 + " (" + detail.runid + ")", runHeaderBody(detail));
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(" &middot; ") + '</div>' +
2611
- '<div class="step-meta">' + escapeXml(toolBreakdownLine(box.part.byTool)) + '</div>';
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.state) + '</b></span>';
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)) + " &middot; " + draftingMoneyText(part) + " &middot; " + 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 = toolBreakdownLine(part.byTool);
3110
+ toolsHeader.textContent = partLine2Text(part);
3020
3111
  body.appendChild(toolsHeader);
3021
- if(attempts.length > 1){
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 = kf.path;
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></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>';
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
- authorPost("/api/settings/providers/row", {
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
- }).then(function(r){
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
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = reuseOn || live; });
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
- sfOrigin.hidden = true; sfLine.hidden = true; sfNote.hidden = true;
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
- ["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 = ""; });
3671
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3887
+ clearCardBoxes();
3888
+ setVerdict("deterministic");
3672
3889
  document.getElementById("jf-judge").disabled = true;
3673
3890
  modelSelect.selectedIndex = 0;
3674
3891
  msgInput.value = "";
3675
- document.getElementById("jf-cap-note").textContent = "";
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
- checkType: document.querySelector('input[name="jf-verdict"]:checked').value === "rubric" ? "rubric" : "deterministic",
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
- startOk = capOk && !sessionLive && keyOk;
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
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
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
- document.addEventListener("bareloop-reuse", function(e){
4090
- var d = e.detail || {};
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
- var radios = document.querySelectorAll('input[name="jf-verdict"]');
4097
- radios.forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
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
- sfOrigin.hidden = false;
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