bareloop 0.40.0 → 0.42.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
@@ -113,8 +113,9 @@
113
113
  .topbar > div:first-child{display:flex;align-items:baseline;gap:0;white-space:nowrap;overflow:hidden;}
114
114
  .topbar .brand{font-weight:700;font-size:17px;letter-spacing:0.06em;flex:none;}
115
115
  .topbar .brand::before{content:"■ ";}
116
- .topbar .tagline{font-size:12px;color:var(--text-dim);margin-left:8px;overflow:hidden;text-overflow:ellipsis;}
117
- .topbar .tagline::before{content:"· ";color:var(--text-faint);}
116
+ .topbar .tagline,.topbar .version{font-size:12px;color:var(--text-dim);margin-left:8px;overflow:hidden;text-overflow:ellipsis;font-weight:400;}
117
+ .topbar .tagline::before,.topbar .version::before{content:"· ";color:var(--text-faint);}
118
+ .topbar .version{flex:none;}
118
119
  @media (max-width: 600px){ .topbar .tagline{display:none;} }
119
120
  .topbar-right{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
120
121
 
@@ -530,19 +531,95 @@
530
531
  .money-item input{width:100%;height:44px;box-sizing:border-box;}
531
532
  .keyfile-strip{position:relative;border:1px solid var(--border-strong);padding:12px 12px 8px;background:var(--panel2);margin:14px 0;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
532
533
  .keyfile-strip::before{content:"┤ KEYS ├";position:absolute;top:-9px;left:10px;background:var(--panel2);padding:0 4px;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;}
533
- .keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:220px;}
534
- .keyfile-strip-actions{display:flex;gap:6px;flex:none;}
534
+ .keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:0;overflow-wrap:anywhere;}
535
+ .keyfile-strip-actions{display:flex;flex-wrap:wrap;gap:6px;flex:none;max-width:100%;}
535
536
  .keyfile-strip code{background:var(--bg);border:1px solid var(--border-strong);padding:1px 5px;font-size:11px;color:var(--amber);overflow-wrap:anywhere;}
536
- .pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
537
- .pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
538
- .pv-table .hint{display:block;margin-top:3px;}
539
- .pv-table td.pv-price-cell input{width:90px;}
540
- /* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
541
- .pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
542
- .pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
537
+ /* M4d Providers layout F (hamr approved 2026-10-11): ONE shared grid, so a key row, the model sub-head and
538
+ every model row sit on the same column edges. Key band: KEY | API SHAPE | BASE URL (two columns) | OLD MAX_TOKENS | TOKENS | refresh.
539
+ Model row: [USE 44px][MODEL] under KEY, IN + OUT side by side under BASE URL, TOKENS on the key band's TOKENS edge. A key row has a
540
+ solid 1px line, a model row a dotted 1px hairline. Below 1280px every row collapses to stacked label : value lines. */
541
+ .pv-grid{--pv-cols:minmax(0,2.8fr) minmax(0,1.5fr) minmax(0,.95fr) minmax(0,.95fr) minmax(0,.9fr) minmax(0,.6fr) 272px;font-size:13px;}
542
+ .pv-head,.pv-krow,.pv-mhead,.pv-mrow{display:grid;grid-template-columns:var(--pv-cols);column-gap:8px;align-items:center;padding:0 8px;min-width:0;}
543
+ .pv-head > *,.pv-krow > *,.pv-mhead > *,.pv-mrow > *{min-width:0;}
544
+ .pv-head,.pv-mhead{font-size:11px;font-weight:700;color:var(--text-dim);letter-spacing:0.04em;min-height:26px;}
545
+ .pv-head{background:var(--panel2);border-bottom:1px solid var(--border-strong);}
546
+ .pv-mhead{background:var(--bg);border-bottom:1px dotted var(--border-strong);}
547
+ .pv-key{border-top:1px solid var(--border-strong);}
548
+ .pv-krow{min-height:40px;padding-top:4px;padding-bottom:4px;}
549
+ .pv-c-url{grid-column:3 / span 2;}
550
+ .pv-lbl{display:none;}
551
+ .pv-name{display:flex;align-items:center;flex-wrap:wrap;gap:4px 6px;min-width:0;overflow-wrap:anywhere;}
552
+ .pv-name code{font-weight:700;}
553
+ .pv-ok{color:var(--green);font-weight:700;}
554
+ .pv-fold{border:0;background:none;color:var(--text);font:inherit;padding:0 4px 0 0;min-width:22px;min-height:24px;cursor:pointer;text-align:left;}
555
+ .pv-krow select,.pv-krow input[type=text]{width:100%;max-width:100%;min-height:28px;box-sizing:border-box;}
556
+ .pv-grid select,.pv-grid input[type=text]{font-size:12px;height:28px;padding:2px 6px;}
557
+ .pv-legacy{display:inline-flex;align-items:center;gap:6px;cursor:pointer;min-height:28px;text-transform:none;letter-spacing:0;font-weight:400;font-size:12px;}
558
+ .pv-legacy input,.pv-m-use input{width:16px;height:16px;margin:0;accent-color:var(--accent);cursor:pointer;}
559
+ .pv-tok{text-align:right;font-variant-numeric:tabular-nums;}
560
+ .pv-refcell{display:flex;justify-content:flex-end;}
561
+ .pv-refbox{display:flex;flex-direction:column;align-items:flex-start;max-width:100%;}
562
+ .pv-refbtns{display:flex;gap:6px;}
563
+ .pv-refbox .pv-msg{width:0;min-width:100%;overflow-wrap:anywhere;margin:2px 0 0;}
564
+ .pv-refbox .pv-msg:empty{display:none;}
565
+ .pv-refbtn{white-space:nowrap;flex:0 0 auto;}
566
+ .pv-refbtn:disabled{opacity:.5;cursor:not-allowed;}
567
+ .pv-sum{color:var(--text-faint);font-size:11px;font-weight:400;}
568
+ .pv-key[data-open="1"] .pv-sum{display:none;}
569
+ .pv-knote{padding:0 8px 4px 34px;margin:0;}
570
+ .pv-knote:empty{display:none;}
571
+ .pv-knote > .hint:empty{display:none;}
572
+ .pv-key[data-open="0"] .pv-models{display:none;}
573
+ .pv-mrow{border-top:1px dotted var(--border-strong);min-height:32px;}
574
+ .pv-mrow:hover,.pv-mrow:focus-within{background:var(--panel2);}
575
+ .pv-um{grid-column:1;display:flex;align-items:center;gap:0;min-width:0;}
576
+ .pv-uh,.pv-m-use{flex:0 0 44px;width:44px;display:flex;align-items:center;justify-content:center;}
577
+ .pv-m-use{min-height:32px;}
578
+ .pv-m-name{min-width:0;overflow-wrap:anywhere;font-size:12px;}
579
+ .pv-pair{grid-column:3 / span 2;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);column-gap:8px;align-items:center;min-width:0;}
580
+ .pv-pair > *{min-width:0;}
581
+ .pv-pair input[type=text]{width:100%;text-align:right;}
582
+ .pv-mhead .pv-tok{text-align:right;}
583
+ .pv-mhead .pv-tok,.pv-m-tok{grid-column:6;}
584
+ .pv-m-msg{grid-column:1 / -1;padding:2px 8px 2px 52px;margin:0;}
585
+ .pv-m-msg:empty{display:none;}
586
+ .pv-more{padding:3px 8px 3px 34px;border-top:1px dotted var(--border-strong);}
587
+ .pv-more:empty{display:none;}
588
+ .pv-more .pv-add{border:0;background:none;color:var(--accent);font:inherit;font-size:12px;padding:2px 0;min-height:24px;cursor:pointer;text-decoration:underline;}
589
+ .pv-more.pv-failed{color:var(--amber);background:var(--amber-bg);font-size:12px;}
590
+ .pv-typed{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin-top:4px;}
591
+ .pv-typed input{flex:1 1 160px;min-width:0;max-width:260px;min-height:28px;box-sizing:border-box;}
592
+ .pv-typed .hint{flex:1 1 100%;margin:0;}
593
+ .hint.bad{color:var(--red);}
594
+ /* a refused Start (a $0 refusal, e.g. an Inputs line) is read like the red failure line of the progress list, not like a faint hint */
595
+ .card-refusal:not(:empty){font-size:13px;margin-top:8px;line-height:1.4;}
596
+ .card-refusal:empty{display:none;}
597
+ .field label.jf-thinking{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;gap:6px;margin-top:6px;font-size:12px;text-transform:none;letter-spacing:0;}
598
+ .field label.jf-thinking input{width:auto;min-height:0;margin:0;}
543
599
  .table-wrap{overflow-x:auto;border:1px solid var(--border-strong);background:var(--panel2);max-width:100%;}
544
600
  @media (max-width: 899px){
545
601
  .money-strip{grid-template-columns:1fr 1fr;}
602
+ .keyfile-strip-main{flex:1 1 100%;}
603
+ }
604
+ @media (max-width: 1279px){
605
+ .pv-head,.pv-mhead{display:none;}
606
+ .pv-krow,.pv-mrow{display:block;padding:2px 8px;}
607
+ .pv-cell{display:grid;grid-template-columns:104px minmax(0,1fr);gap:8px;align-items:center;min-height:44px;border-top:1px dotted var(--border);}
608
+ .pv-cell:first-child{border-top:none;}
609
+ .pv-lbl{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);font-weight:700;}
610
+ .pv-cell.nolbl{display:flex;}
611
+ .pv-tok{text-align:left;}
612
+ .pv-grid select,.pv-grid input[type=text]{height:44px;font-size:14px;}
613
+ .pv-krow select,.pv-krow input[type=text]{min-height:44px;}
614
+ .pv-legacy{min-height:44px;gap:10px;}
615
+ .pv-legacy input,.pv-m-use input{width:24px;height:24px;}
616
+ .pv-um{min-height:44px;}
617
+ .pv-uh,.pv-m-use{min-height:44px;}
618
+ .pv-cell.pv-pair{display:grid;grid-template-columns:104px minmax(0,1fr) minmax(0,1fr);}
619
+ .pv-cell.pv-pair::before{content:"In / Out $/1M";font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);font-weight:700;}
620
+ .pv-fold,.pv-refbtn,.pv-more .pv-add{min-height:44px;min-width:44px;}
621
+ .pv-knote,.pv-more{padding-left:8px;}
622
+ .pv-m-msg{padding-left:8px;}
546
623
  }
547
624
  @media (max-width: 899px){
548
625
  .main{flex-direction:column;}
@@ -565,6 +642,7 @@
565
642
  <header class="topbar">
566
643
  <div>
567
644
  <span class="brand">bareloop</span>
645
+ <span class="version" id="panel-version">v__BARELOOP_PANEL_VERSION__</span>
568
646
  <span class="tagline">automate a job, verified</span>
569
647
  </div>
570
648
  <div class="topbar-right">
@@ -615,6 +693,9 @@
615
693
  <!-- build item 5 (2026-09-28) — ONE line under Model, not two:
616
694
  key + reachability collapsed into a single readiness read. -->
617
695
  <div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
696
+ <!-- P8: the job's "Thinking on" tick (drafting + the rubric judge; never the worker). The SERVER's per-line `thinking.canChange` decides. -->
697
+ <label class="jf-thinking" id="jf-thinking-label"><input type="checkbox" id="jf-thinking" data-testid="jf-thinking"> Thinking on</label>
698
+ <div class="hint" id="jf-thinking-note" data-testid="jf-thinking-note"></div>
618
699
  </div>
619
700
  <div class="field">
620
701
  <label for="jf-name">Job name</label>
@@ -650,7 +731,7 @@
650
731
  <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
651
732
  </div>
652
733
  </div>
653
- <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
734
+ <div class="hint bad card-refusal" id="chat-card-error" data-testid="chat-card-error" role="alert"></div>
654
735
  <div id="chat-resume-box" class="chat-resume-box" data-testid="resume-box" hidden>
655
736
  <div class="hint" id="chat-resume-spent" data-testid="resume-spent"></div>
656
737
  <div class="ended-actions"><button class="btn primary" type="button" id="chat-resume-sign" data-testid="btn-sign-resume">Sign &amp; resume</button> <button class="btn" type="button" id="chat-resume-cancel" data-testid="btn-resume-cancel">Cancel</button></div>
@@ -815,30 +896,26 @@
815
896
  </div>
816
897
 
817
898
  <div class="pane-body">
818
- <!-- PROVIDERS TAB (P4b: one row per key in the keys file that has a value) -->
899
+ <!-- PROVIDERS TAB (P8: one row per key in the keys file that has a value; layout rewritten from fwdloop src/panel/index.html@f9449e8) -->
819
900
  <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
820
- <div class="keyfile-strip" data-testid="keyfile-box">
901
+ <div class="keyfile-strip" data-testid="keys-strip">
821
902
  <div class="keyfile-strip-main">
822
- <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>
823
- <span class="hint" id="pv-keyfile-path" data-testid="keyfile-path"></span>
824
- <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
903
+ <span>Put <code>NAME=key</code> lines in <code id="keys-path" data-testid="keys-path">~/.config/bareloop/.env</code>, then Reload keys &mdash; each row below says whether its key is set. Only names are listed; keys never reach this page.</span>
904
+ <span class="hint bad" id="keys-refusal" data-testid="keys-refusal"></span>
905
+ <span class="hint" id="keys-open-note" data-testid="keys-open-note"></span>
825
906
  </div>
826
907
  <div class="keyfile-strip-actions">
827
- <button class="btn small" type="button" id="btn-open-keys-folder" data-testid="btn-open-keys-folder">Open keys folder</button>
828
- <button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
908
+ <button class="btn small" id="open-keys-folder" type="button" data-testid="open-keys-folder" aria-label="Open keys folder">Open keys folder</button>
909
+ <button class="btn small" id="reload-keys" type="button" data-testid="reload-keys">Reload keys</button>
829
910
  </div>
830
911
  </div>
831
912
  <div class="table-wrap" data-testid="provider-table-wrap">
832
- <table class="pv-table" data-testid="provider-table">
833
- <thead>
834
- <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>
835
- </thead>
836
- <tbody id="pv-rows"></tbody>
837
- </table>
913
+ <div class="pv-grid" data-testid="provider-table">
914
+ <div class="pv-head" aria-hidden="true"><span>KEY</span><span>API SHAPE</span><span class="pv-c-url">BASE URL</span><span>OLD MAX_TOKENS</span><span class="pv-tok">TOKENS</span><span></span></div>
915
+ <div id="pv-rows"></div>
916
+ </div>
838
917
  </div>
839
- <div class="hint" id="pv-price-hint" data-testid="pv-price-hint">If your vendor lists two prices, enter the higher one.</div>
840
- <div id="pv-shell" data-testid="pv-shell"></div>
841
- <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>
918
+ <div class="hint" id="pv-note" data-testid="pv-note" style="margin-top:6px;">&#10227; refresh models (free) &middot; old max_tokens: tick if the server wants the old field name</div>
842
919
  </section>
843
920
 
844
921
  <!-- MONEY & LIMITS TAB -->
@@ -2327,7 +2404,7 @@
2327
2404
  // hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
2328
2405
  // `model` (optional) = the Chat card's Model box, when the picker asks: the estimate line then counts only that worker's runs.
2329
2406
  function reuseWorkflow(runid, model){
2330
- authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&model=" + encodeURIComponent(model) : "")).then(function(r){
2407
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&" + model : "")).then(function(r){
2331
2408
  if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2332
2409
  document.getElementById("tab-chat").click();
2333
2410
  document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
@@ -2416,6 +2493,19 @@
2416
2493
  }, 1500);
2417
2494
  }
2418
2495
 
2496
+ // M3 (Part 1c full-run fixes): after Sign & run the new run's row is written a few seconds later (the runner starts, then
2497
+ // appends its row), and the Runs list only re-fetches every RUNS_LIST_POLL_MS — so a run just signed showed only after a page
2498
+ // refresh. Like a resume, a sign forces list refreshes until a run that was not listed before the click appears; bounded.
2499
+ function afterSignRefresh(knownIds, tries){
2500
+ refreshRunsList(true).then(function(runs){
2501
+ var fresh = Array.isArray(runs) && runs.some(function(r){ return knownIds.indexOf(r.runid) === -1; });
2502
+ if(!fresh && tries > 0) setTimeout(function(){ afterSignRefresh(knownIds, tries - 1); }, 1500);
2503
+ });
2504
+ }
2505
+ function currentRunIds(){
2506
+ try { return (JSON.parse(lastRunsSig || "[]") || []).map(function(r){ return r.runid; }); } catch(e){ return []; }
2507
+ }
2508
+
2419
2509
  function renderRun(detail){
2420
2510
  if(detail.fileMissing){
2421
2511
  setRunHeader("\u00B7", runName(detail.job, detail.runNo, detail.runid), "file missing");
@@ -3163,14 +3253,15 @@
3163
3253
  // (src/panel/jobplan.js): numbered lines with their rule, the checks under the line each serves (folded), the AI's own
3164
3254
  // "Not checked", the always-on guards. Only the marker is blue (--plan). Returns HTML (every text is escaped).
3165
3255
  function renderJobPlan(plan){
3166
- var h = '<div class="jp" data-testid="job-plan">' + (plan.older ? '<div class="older" data-testid="plan-older">older job \u2014 numbered lines were not saved</div>' : "") + '<div class="jp-legend"><b>\u203A</b> = the approved plan</div>' +
3256
+ var h = '<div class="jp" data-testid="job-plan">' + (plan.older ? '<div class="older" data-testid="plan-older">older job \u2014 numbered lines were not saved</div>' : "") + '<div class="jp-legend">Done when every <b>\u203A</b> check passes</div>' +
3167
3257
  (plan.goal && !plan.older ? '<div class="jp-sum" data-testid="plan-goal"><span class="lbl">AI\'s summary</span>' + escapeXml(plan.goal) + "</div>" : "");
3168
3258
  var checkLine = function(c){
3169
3259
  return '<div class="fold" data-testid="plan-check"><span class="mk">\u203A</span> check \u00B7 ' + escapeXml(c.name) + (c.cls ? " \u00B7 " + escapeXml(c.cls) : "") + "</div>";
3170
3260
  };
3171
3261
  (plan.lines || []).forEach(function(l){
3172
3262
  h += '<div class="jl" data-testid="plan-line"><span class="num">' + escapeXml(String(l.n)) + "</span>" + escapeXml(l.text);
3173
- if(l.rule) h += '<div class="sub">~ ' + escapeXml(l.rule) + "</div>";
3263
+ // one `~` row per rule (fwdloop's shape); an older plan that carries only the joined `rule` still shows it
3264
+ (l.rules && l.rules.length ? l.rules : (l.rule ? [l.rule] : [])).forEach(function(r){ h += '<div class="sub">~ ' + escapeXml(r) + "</div>"; });
3174
3265
  (l.checks || []).forEach(function(c){ h += checkLine(c); });
3175
3266
  (l.refused || []).forEach(function(r){ h += '<div class="fold" data-testid="plan-refused"><span class="mk">\u203A</span> not checked \u00B7 ' + escapeXml(r) + "</div>"; });
3176
3267
  h += "</div>";
@@ -3184,7 +3275,7 @@
3184
3275
  items.map(function(x){ return "<li>" + escapeXml(x) + "</li>"; }).join("") + "</ul></details>";
3185
3276
  };
3186
3277
  h += fold("Not checked (the AI's own reading)", plan.notChecked, "plan-notchecked");
3187
- h += fold("Always on", plan.alwaysOn, "plan-alwayson");
3278
+ h += fold("Always checked", plan.alwaysOn, "plan-alwayson");
3188
3279
  return h + "</div>";
3189
3280
  }
3190
3281
 
@@ -3330,7 +3421,7 @@
3330
3421
  pollFailStreak = 0;
3331
3422
  var runs = result.runs || [];
3332
3423
  var next = sig(runs);
3333
- if(next === lastRunsSig) return; // unchanged payload — never rebuild the DOM for nothing
3424
+ if(next === lastRunsSig) return runs; // unchanged payload — never rebuild the DOM for nothing
3334
3425
  lastRunsSig = next;
3335
3426
  withScrollPreserved(["left-pane-body"], function(){
3336
3427
  // setItems re-runs the SAME filter/search state and view mode
@@ -3338,6 +3429,7 @@
3338
3429
  // bar's own state) — a poll tick never resets either.
3339
3430
  runsFilterBar.setItems(runs);
3340
3431
  });
3432
+ return runs;
3341
3433
  }).catch(function(){
3342
3434
  pollFailStreak++;
3343
3435
  // Keep the last good render; a transient failure is invisible. Only a
@@ -3478,7 +3570,7 @@
3478
3570
  document.getElementById("btn-settings").addEventListener("click", function(){
3479
3571
  mainView.hidden = true;
3480
3572
  settingsView.hidden = false;
3481
- loadProviders();
3573
+ loadProviders(true);
3482
3574
  loadMoney();
3483
3575
  });
3484
3576
  document.getElementById("settings-back").addEventListener("click", function(){
@@ -3487,7 +3579,7 @@
3487
3579
  });
3488
3580
  document.getElementById("tab-money").addEventListener("click", loadMoney);
3489
3581
 
3490
- // ---- Providers tab (P4b): one row per key in the keys file that has a value ----
3582
+ // ---- Providers tab (P8): one block per key in the keys file that has a value; rewritten from fwdloop src/panel/index.html@f9449e8 ----
3491
3583
  var pvRows = document.getElementById("pv-rows");
3492
3584
  var pvNote = document.getElementById("pv-note");
3493
3585
  var PV_NOTE_DEFAULT = pvNote.textContent;
@@ -3497,104 +3589,316 @@
3497
3589
  if(n >= 1e3) return (n / 1e3).toFixed(1) + "k";
3498
3590
  return String(n);
3499
3591
  }
3500
- function loadProviders(){
3501
- authorGet("/api/settings/providers").then(function(r){
3502
- 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; }
3503
- var kf = r.keysFile;
3504
- document.getElementById("pv-keyfile-path").textContent = "";
3505
- document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
3506
- pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
3507
- var e = escapeXml(row.envName);
3508
- var shapeOpts = r.shapes.map(function(s){
3509
- return '<option value="' + escapeXml(s.id) + '"' + (s.id === row.shape ? " selected" : "") + ">" + escapeXml(s.label) + "</option>";
3510
- }).join("");
3511
- var bal = row.balance.kind === "note"
3512
- ? '<input type="text" class="pv-balance" placeholder="type by hand" value="' + (row.balance.usd === null ? "" : escapeXml(row.balance.usd.toFixed(2))) + '"><div class="hint">a note, never checked</div>'
3513
- : (row.balance.kind === "fetch" ? '<span class="pv-balance-fetch" data-env="' + e + '">…</span>' : "—");
3514
- return '<tr data-testid="provider-row-' + e + '" data-env="' + e + '">'
3515
- + "<td><code>" + e + "</code>"
3516
- + '<div class="hint" data-testid="pv-status-' + e + '">' + escapeXml(row.keyStatus) + "</div></td>"
3517
- + '<td><input type="text" class="pv-name" data-testid="pv-name-' + e + '" placeholder="model id" value="' + escapeXml(row.name) + '"></td>'
3518
- + '<td><select class="pv-shape" data-testid="pv-shape-' + e + '">' + shapeOpts + "</select></td>"
3519
- + '<td><input type="text" class="pv-url" data-testid="pv-url-' + e + '" placeholder="' + escapeXml(row.placeholder || "the shape\'s own host") + '" value="' + escapeXml(row.baseUrl) + '"></td>'
3520
- + '<td class="pv-test-cell">' + (row.canTest
3521
- ? '<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>'
3522
- : '<span class="hint">not available</span>') + "</td>"
3523
- + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
3524
- + "<td>" + bal + "</td>"
3525
- + '<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>'
3526
- + '<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>';
3527
- }).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>';
3528
- pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
3529
- authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
3530
- fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
3531
- }).catch(function(){ fetchEl.textContent = "— unavailable"; });
3532
- });
3533
- // a key exported only in the shell has no row: say so, by NAME, once per name
3534
- document.getElementById("pv-shell").innerHTML = (r.shellOnly || []).map(function(n){
3535
- return '<div class="hint" data-testid="pv-shell-' + escapeXml(n) + '">' + escapeXml(n) + " is set in your shell but not in ~/.config/bareloop/.env — add it there to use it here and in Chat.</div>";
3536
- }).join("");
3537
- pvNote.textContent = r.configProblem ? r.configProblem : PV_NOTE_DEFAULT;
3592
+ // A hint line: plain text, red when it is a refusal.
3593
+ function setNote(el, bad, text){
3594
+ el.className = "hint" + (bad ? " bad" : "");
3595
+ el.textContent = text || "";
3596
+ }
3597
+ // The server's own plain sentence (`say`); the page never shows a code or an HTTP status.
3598
+ function settingsRefusal(r){
3599
+ var b = r && r.body;
3600
+ if(b && typeof b.say === "string" && b.say) return b.say;
3601
+ return "Settings cannot do that. Keys are typed into your keys file by hand.";
3602
+ }
3603
+ function networkText(){ return "Could not reach the panel, so nothing is confirmed. Check it is still running."; }
3604
+ // `list` true = also ask each provider for its model list (free): on page open and on Reload keys. A save repaints without asking again.
3605
+ function loadProviders(list){
3606
+ return authorGet("/api/settings/providers").then(function(d){
3607
+ if(!d || !d.ok){ setNote(document.getElementById("keys-refusal"), true, "Could not read your providers. Reload the page."); return; }
3608
+ paintProviders(d, list === true);
3538
3609
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
3539
- }).catch(function(){ pvNote.textContent = "could not read providers"; });
3610
+ }, function(){ setNote(document.getElementById("keys-refusal"), true, networkText()); });
3611
+ }
3612
+ // Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
3613
+ document.getElementById("open-keys-folder").addEventListener("click", function(){
3614
+ var out = document.getElementById("keys-open-note");
3615
+ out.textContent = "";
3616
+ authorPost("/api/settings/open-keys-folder", {}).then(function(r){
3617
+ var b = r.body || {};
3618
+ if(b.ok === true) out.textContent = b.opened ? "" : "Could not open it here. Your keys folder: " + (b.path || "(unknown)");
3619
+ else out.textContent = settingsRefusal(r);
3620
+ }, function(){ out.textContent = networkText(); });
3621
+ });
3622
+ document.getElementById("reload-keys").addEventListener("click", function(){ loadProviders(true); });
3623
+
3624
+ // M4d Providers layout F. One parent block per key (.pv-key): its key band (name, API shape select, Base URL box, the legacy tick, tokens,
3625
+ // the Refresh button), a folded summary, and its model lines as child rows (.pv-mrow). The key row owns shape, Base URL and the legacy
3626
+ // tick, so a model line can never drift from them. Shape and Base URL are always editable; a change of either saves both (judged together).
3627
+ var folds = {}; // slot -> true (unfolded) / false (folded): set once, on first paint (a key with a ticked model starts unfolded), then only the person changes it
3628
+ var typedOpen = {}; // slot -> true while "+ type a model name" is open
3629
+ function buildProviderRow(row){
3630
+ var key = document.createElement("div");
3631
+ key.className = "pv-key";
3632
+ key.setAttribute("data-slot", row.slot);
3633
+ key.setAttribute("data-testid", "provider-row-" + row.slot);
3634
+ key.innerHTML =
3635
+ '<div class="pv-krow">' +
3636
+ '<div class="pv-cell pv-name nolbl"><button class="pv-fold" type="button" data-act="fold" data-testid="pv-fold"></button><code></code><span class="pv-status" data-f="status" data-testid="pv-status-' + escapeXml(row.slot) + '"></span><span class="pv-sum" data-f="sum" data-testid="pv-sum"></span></div>' +
3637
+ '<div class="pv-cell"><span class="pv-lbl">API shape</span><select data-prov="shape" data-testid="prov-shape" aria-label="' + escapeXml(row.label + " API shape") + '"></select></div>' +
3638
+ '<div class="pv-cell pv-c-url"><span class="pv-lbl">Base URL</span><input type="text" data-prov="baseUrl" data-testid="prov-baseurl" aria-label="' + escapeXml(row.label + " Base URL") + '" autocomplete="off" spellcheck="false" placeholder="type the Base URL"></div>' +
3639
+ '<div class="pv-cell"><span class="pv-lbl">Old max_tokens</span><label class="pv-legacy"><input type="checkbox" data-prov="legacyMaxTokens" data-testid="prov-legacy" aria-label="' + escapeXml(row.label + " legacy max_tokens") + '"><span data-f="legacy-word">off</span></label></div>' +
3640
+ '<div class="pv-cell pv-tok pv-ktok"><span class="pv-lbl">Tokens</span><span data-f="tokens" data-testid="pv-tokens-' + escapeXml(row.slot) + '"></span></div>' +
3641
+ '<div class="pv-cell pv-refcell nolbl"><div class="pv-refbox"><div class="pv-refbtns"><button class="btn small pv-refbtn" type="button" data-act="test-key" data-testid="prov-test" aria-label="' + escapeXml("Test " + row.envVar) + '">Test</button><button class="btn small pv-refbtn" type="button" data-act="refresh-models" data-testid="prov-refresh" aria-label="' + escapeXml("Refresh models for " + row.label) + '">&#10227; Refresh</button></div><div class="hint pv-msg" data-f="msg-test" data-testid="prov-msg-test" role="status"></div></div></div>' +
3642
+ '</div>' +
3643
+ '<div class="pv-knote"><div class="hint bad pv-msg" data-f="msg-edit" data-testid="prov-msg-edit"></div><div class="hint pv-msg" data-f="msg-legacyMaxTokens" data-testid="prov-msg-legacyMaxTokens"></div></div>' +
3644
+ '<div class="pv-models" data-models-for="' + escapeXml(row.slot) + '" data-testid="models-row-' + escapeXml(row.slot) + '">' +
3645
+ '<div class="pv-mhead" aria-hidden="true"><span class="pv-um"><span class="pv-uh">USE</span><span>MODEL</span></span><span class="pv-pair"><span>IN $/1M</span><span>OUT $/1M</span></span><span class="pv-tok">TOKENS</span></div>' +
3646
+ '<div data-f="models"></div>' +
3647
+ '<div class="pv-more"><button class="pv-add" type="button" data-act="typed-open" data-testid="model-typed-open">+ type a model name</button>' +
3648
+ '<div class="pv-typed" data-f="typed" hidden><input type="text" data-typed="1" data-testid="model-typed" autocomplete="off" spellcheck="false" aria-label="' + escapeXml(row.label + " model name") + '" placeholder="type a model name"> ' +
3649
+ '<button class="btn small" type="button" data-act="add-model" data-testid="model-add">Add</button><div class="hint pv-msg" data-f="typed-msg" data-testid="model-typed-msg"></div></div></div>' +
3650
+ '</div>';
3651
+ key.querySelector("code").textContent = row.envVar;
3652
+ key.querySelectorAll("input").forEach(function(inp){
3653
+ inp.addEventListener("input", function(){ inp.setAttribute("data-dirty", "1"); });
3654
+ });
3655
+ key.querySelector("select").addEventListener("change", function(ev){ ev.target.setAttribute("data-dirty", "1"); });
3656
+ return key;
3657
+ }
3658
+ // One child row per model: [Use][name], In + Out side by side, Tokens. The price pair is both-or-neither; the server's one rule says why not.
3659
+ function buildModelLine(slot, m){
3660
+ var d = document.createElement("div");
3661
+ d.className = "pv-mrow";
3662
+ d.setAttribute("data-model", m.model);
3663
+ d.setAttribute("data-testid", "model-line");
3664
+ d.innerHTML =
3665
+ '<div class="pv-cell pv-um nolbl"><span class="pv-m-use"><input type="checkbox" data-mf="use" data-testid="model-use"></span><code class="pv-m-name"></code></div>' +
3666
+ '<div class="pv-cell pv-pair"><label><input type="text" inputmode="decimal" data-mf="inPerM" data-testid="model-in" autocomplete="off"></label>' +
3667
+ '<label><input type="text" inputmode="decimal" data-mf="outPerM" data-testid="model-out" autocomplete="off"></label></div>' +
3668
+ '<div class="pv-cell pv-tok pv-m-tok"><span class="pv-lbl">Tokens</span><span data-f="model-tokens" data-testid="model-tokens"></span></div>' +
3669
+ '<div class="hint pv-msg pv-m-msg" data-f="msg-model" data-testid="model-msg"></div>';
3670
+ d.querySelector(".pv-m-name").textContent = m.model;
3671
+ d.querySelector("[data-mf=use]").setAttribute("aria-label", slot + " " + m.model + " use");
3672
+ d.querySelector("[data-mf=inPerM]").setAttribute("aria-label", m.model + " price in per 1M tokens");
3673
+ d.querySelector("[data-mf=outPerM]").setAttribute("aria-label", m.model + " price out per 1M tokens");
3674
+ d.querySelectorAll("input[type=text]").forEach(function(inp){
3675
+ inp.addEventListener("input", function(){ inp.setAttribute("data-dirty", "1"); });
3676
+ });
3677
+ return d;
3678
+ }
3679
+ // The provider's own model list per key (asked on page open, Reload keys, Refresh models): { models: [ids] } or { why }. Not saved: only ticked or typed lines are.
3680
+ var listed = {};
3681
+ var lastData = null;
3682
+ function linesFor(row){
3683
+ var l = listed[row.slot];
3684
+ var have = {};
3685
+ row.models.forEach(function(m){ have[m.model] = true; });
3686
+ var extra = (l && l.models ? l.models : []).filter(function(id){ return !have[id]; }).map(function(id){ return { model: id, use: false }; });
3687
+ return row.models.concat(extra);
3688
+ }
3689
+ // "3 models · none ticked", "2 models · 1 ticked"; a list that failed with nothing saved says so.
3690
+ function sumText(row){
3691
+ var l = listed[row.slot];
3692
+ var lines = linesFor(row);
3693
+ if(l && l.why && lines.length === 0) return "failed — type a name";
3694
+ var ticked = lines.filter(function(m){ return m.use === true; }).length;
3695
+ return lines.length + (lines.length === 1 ? " model" : " models") + " · " + (ticked === 0 ? "none ticked" : ticked + " ticked");
3696
+ }
3697
+ function paintModels(row, mr){
3698
+ var box = mr.querySelector("[data-f=models]");
3699
+ var lines = linesFor(row);
3700
+ var l = listed[row.slot];
3701
+ var typed = mr.querySelector("[data-f=typed]");
3702
+ typed.hidden = typedOpen[row.slot] !== true;
3703
+ var wanted = lines.map(function(m){ return m.model; });
3704
+ Array.prototype.slice.call(box.querySelectorAll(".pv-mrow")).forEach(function(el){
3705
+ if(wanted.indexOf(el.getAttribute("data-model")) < 0) box.removeChild(el);
3706
+ });
3707
+ var note = box.querySelector("[data-f=models-note]");
3708
+ lines.forEach(function(m){
3709
+ var line = Array.prototype.find.call(box.querySelectorAll(".pv-mrow"), function(el){ return el.getAttribute("data-model") === m.model; });
3710
+ if(!line){ line = buildModelLine(row.slot, m); box.insertBefore(line, note); }
3711
+ var use = line.querySelector("[data-mf=use]");
3712
+ use.checked = m.use === true;
3713
+ ["inPerM", "outPerM"].forEach(function(f){
3714
+ var inp = line.querySelector('[data-mf="' + f + '"]');
3715
+ inp.placeholder = "—";
3716
+ if(inp.getAttribute("data-dirty") !== "1") inp.value = typeof m[f] === "number" ? String(m[f]) : "";
3717
+ });
3718
+ line.querySelector("[data-f=model-tokens]").textContent = typeof m.tokens === "number" ? tokensText(m.tokens) : "—";
3719
+ });
3720
+ note = box.querySelector("[data-f=models-note]");
3721
+ if(!note){ note = document.createElement("div"); note.className = "hint pv-more"; note.setAttribute("data-f", "models-note"); note.setAttribute("data-testid", "models-note"); box.appendChild(note); }
3722
+ setNote(note, false, l && l.why ? l.why + " Type the model name below." : (lines.length === 0 ? "No models yet. Press ⟳ to refresh models, or type a name." : ""));
3723
+ note.className += " pv-more" + (l && l.why ? " pv-failed" : "");
3724
+ box.parentNode.querySelector(".pv-mhead").hidden = lines.length === 0;
3540
3725
  }
3541
- // The row's POST body, or null when exactly ONE price box is filled: a price is both boxes or neither (the server
3542
- // refuses a half-set one), so nothing in that row saves. This runs only when a save would happen (the row's
3543
- // change event), so typing the first box never flashes the message; it shows when the person leaves that box.
3544
- var PV_HALF_PRICE = "Fill both price boxes or clear both.";
3545
- function pvRowBody(env, v){
3546
- var pin = String(v.priceIn).trim();
3547
- var pout = String(v.priceOut).trim();
3548
- if((pin === "") !== (pout === "")) return null;
3549
- return { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl, priceInPerM: pin, priceOutPerM: pout };
3726
+ var STATUS_WORD = { "set in file": "set in file", "found in shell": "found in shell", "not set": "not set", "refused": "refused — see above" };
3727
+ // The Refresh button: "[ ⟳ Refresh ]", disabled "[ ⟳ refreshing… ]" while the free list call is out.
3728
+ function setRefreshBtn(key, pending){
3729
+ var b = key.querySelector("[data-act=refresh-models]");
3730
+ b.disabled = pending === true;
3731
+ b.innerHTML = pending === true ? "&#10227; refreshing&hellip;" : "&#10227; Refresh";
3550
3732
  }
3551
- pvRows.addEventListener("change", function(e){
3552
- var t = e.target;
3553
- var tr = t.closest("tr");
3554
- if(!tr) return;
3555
- if(t.classList.contains("pv-balance")){
3556
- authorPost("/api/settings/providers/balance-note", { usd: t.value.trim() === "" ? null : t.value.trim() }).then(function(r){
3557
- pvNote.textContent = r.body && r.body.ok ? "Saved — balance note." : ((r.body && r.body.error) || "could not save");
3558
- }).catch(function(){ pvNote.textContent = "could not save"; });
3733
+ // The Test button: "[ Test ]", disabled "[ testing… ]" while the free call is out. The route says ok + ms or a plain-words why; written on the key row's own note.
3734
+ function testKey(slot, key){
3735
+ var b = key.querySelector("[data-act=test-key]");
3736
+ var msg = key.querySelector("[data-f=msg-test]");
3737
+ var row = lastData && lastData.rows.filter(function(r){ return r.slot === slot; })[0];
3738
+ if(row && row.baseUrl === ""){
3739
+ setNote(msg, true, "Type a Base URL for " + row.envVar + " in Settings.");
3559
3740
  return;
3560
3741
  }
3561
- 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;
3562
- var env = tr.getAttribute("data-env");
3563
- var rowBody = pvRowBody(env, {
3564
- name: tr.querySelector(".pv-name").value,
3565
- shape: tr.querySelector(".pv-shape").value,
3566
- baseUrl: tr.querySelector(".pv-url").value,
3567
- priceIn: tr.querySelector(".pv-price-in").value,
3568
- priceOut: tr.querySelector(".pv-price-out").value,
3742
+ b.disabled = true;
3743
+ b.innerHTML = "testing&hellip;";
3744
+ setNote(msg, false, "");
3745
+ authorPost("/api/settings/test", { slot: slot }).then(function(r){
3746
+ var d = r.body;
3747
+ if(d && d.ok === true) setNote(msg, false, "OK \u00b7 " + d.ms + " ms");
3748
+ else setNote(msg, true, d && d.why ? d.why : settingsRefusal(r));
3749
+ }, function(){ setNote(msg, true, networkText()); }).then(function(){
3750
+ b.disabled = false;
3751
+ b.innerHTML = "Test";
3569
3752
  });
3570
- if(!rowBody){ pvNote.textContent = PV_HALF_PRICE; return; }
3571
- authorPost("/api/settings/providers/row", rowBody).then(function(r){
3572
- pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
3573
- // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
3574
- document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
3575
- }).catch(function(){ pvNote.textContent = "could not save"; });
3576
- });
3577
- pvRows.addEventListener("click", function(e){
3578
- var t = e.target;
3579
- if(!t.classList || !t.classList.contains("pv-test")) return;
3580
- var out = t.parentNode.querySelector(".pv-test-result");
3581
- out.textContent = "testing…"; t.disabled = true;
3582
- authorPost("/api/settings/providers/test", { envName: t.closest("tr").getAttribute("data-env") }).then(function(r){
3583
- var b = r.body || {};
3584
- out.textContent = b.reachable ? ("OK" + (b.ms !== null && b.ms !== undefined ? " · " + b.ms + " ms" : "")) : ("failed — " + (b.note || b.status || b.error || "no answer"));
3585
- }).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
3753
+ }
3754
+ function refreshModels(slot, tr){
3755
+ listed[slot] = { pending: true };
3756
+ setRefreshBtn(tr, true);
3757
+ return authorPost("/api/settings/models", { slot: slot }).then(function(r){
3758
+ var b = r.body;
3759
+ listed[slot] = b && b.ok ? { models: b.models } : { why: (b && b.why) ? b.why : settingsRefusal(r) };
3760
+ }, function(){ listed[slot] = { why: networkText() }; }).then(function(){
3761
+ if(lastData) paintProviders(lastData, false);
3762
+ });
3763
+ }
3764
+ function paintProviders(data, list){
3765
+ lastData = data;
3766
+ var path = data.keysFile && data.keysFile.path ? data.keysFile.path : "~/.config/bareloop/.env";
3767
+ document.getElementById("keys-path").textContent = path;
3768
+ setNote(document.getElementById("keys-refusal"), true, data.keysFile && data.keysFile.refusal);
3769
+ setNote(pvNote, !!data.configProblem, data.configProblem || PV_NOTE_DEFAULT);
3770
+ data.rows.forEach(function(row){
3771
+ var key = pvRows.querySelector('.pv-key[data-slot="' + row.slot + '"]');
3772
+ if(!key){ key = buildProviderRow(row); pvRows.appendChild(key); }
3773
+ if(folds[row.slot] === undefined) folds[row.slot] = row.models.some(function(m){ return m.use === true; });
3774
+ key.setAttribute("data-open", folds[row.slot] ? "1" : "0");
3775
+ var fold = key.querySelector("[data-act=fold]");
3776
+ fold.textContent = folds[row.slot] ? "▾" : "▸";
3777
+ fold.setAttribute("aria-expanded", folds[row.slot] ? "true" : "false");
3778
+ fold.setAttribute("aria-label", (folds[row.slot] ? "Fold " : "Unfold ") + row.label + " models");
3779
+ paintModels(row, key.querySelector(".pv-models"));
3780
+ setRefreshBtn(key, !!(listed[row.slot] && listed[row.slot].pending));
3781
+ key.querySelector("[data-f=sum]").textContent = sumText(row);
3782
+ var st = key.querySelector("[data-f=status]");
3783
+ var word = STATUS_WORD[row.keyStatus] || row.keyStatus;
3784
+ st.className = row.keyStatus === "set in file" || row.keyStatus === "found in shell" ? "pv-status pv-ok" : "pv-status hint";
3785
+ st.textContent = st.className.indexOf("pv-ok") >= 0 ? "✓" : word;
3786
+ st.setAttribute("title", word);
3787
+ if(st.className.indexOf("pv-ok") >= 0) st.setAttribute("aria-label", word); else st.removeAttribute("aria-label");
3788
+ var legacyIn = key.querySelector('[data-prov=legacyMaxTokens]');
3789
+ if(document.activeElement !== legacyIn) legacyIn.checked = row.legacyMaxTokens === true;
3790
+ key.querySelector("[data-f=legacy-word]").textContent = legacyIn.checked ? "on" : "off";
3791
+ // shape + Base URL: the key row's own boxes, written from the server's row unless the person has typed in them (a refused value stays typed, with its sentence)
3792
+ var sel = key.querySelector("[data-prov=shape]");
3793
+ if(sel.options.length !== data.shapes.length || data.shapes.some(function(sh, i){ return sel.options[i].value !== sh.id; })){
3794
+ sel.textContent = "";
3795
+ data.shapes.forEach(function(sh){ var o = document.createElement("option"); o.value = sh.id; o.textContent = sh.label; sel.appendChild(o); });
3796
+ }
3797
+ if(sel.getAttribute("data-dirty") !== "1") sel.value = row.shape;
3798
+ var urlIn = key.querySelector("[data-prov=baseUrl]");
3799
+ if(urlIn.getAttribute("data-dirty") !== "1" && document.activeElement !== urlIn) urlIn.value = row.baseUrl;
3800
+ key.querySelector("[data-f=tokens]").textContent = tokensText(row.tokens);
3801
+ });
3802
+ // rows that left the keys file leave the page
3803
+ Array.prototype.slice.call(pvRows.querySelectorAll(".pv-key")).forEach(function(key){
3804
+ if(!data.rows.some(function(r){ return r.slot === key.getAttribute("data-slot"); })) pvRows.removeChild(key);
3805
+ });
3806
+ if(list) data.rows.forEach(function(row){
3807
+ var key = pvRows.querySelector('.pv-key[data-slot="' + row.slot + '"]');
3808
+ if(row.baseUrl === ""){ listed[row.slot] = { why: "Type a Base URL for " + row.slot + " in Settings." }; paintProviders(lastData, false); }
3809
+ else refreshModels(row.slot, key);
3810
+ });
3811
+ }
3812
+ // Shape and Base URL save together, on change of either (blur / Enter / pick); the server judges them as a pair and says which sentence on
3813
+ // a refusal (nothing saved, the typed value stays so the person can fix it).
3814
+ function saveEndpoint(key){
3815
+ var msg = key.querySelector("[data-f=msg-edit]");
3816
+ var payload = { slot: key.getAttribute("data-slot"), shape: key.querySelector("[data-prov=shape]").value, baseUrl: key.querySelector("[data-prov=baseUrl]").value.trim() };
3817
+ authorPost("/api/settings/provider", payload).then(function(r){
3818
+ if(r.body && r.body.ok){
3819
+ key.querySelector("[data-prov=shape]").removeAttribute("data-dirty");
3820
+ key.querySelector("[data-prov=baseUrl]").removeAttribute("data-dirty");
3821
+ setNote(msg, true, "");
3822
+ loadProviders();
3823
+ } else setNote(msg, true, settingsRefusal(r));
3824
+ }, function(){ setNote(msg, true, networkText()); });
3825
+ }
3826
+ pvRows.addEventListener("click", function(ev){
3827
+ var b = ev.target.closest && ev.target.closest("button[data-act]");
3828
+ if(!b) return;
3829
+ var key = b.closest(".pv-key");
3830
+ var slot = key.getAttribute("data-slot");
3831
+ var act = b.getAttribute("data-act");
3832
+ if(act === "refresh-models") refreshModels(slot, key);
3833
+ else if(act === "test-key") testKey(slot, key);
3834
+ else if(act === "fold"){ folds[slot] = !folds[slot]; paintProviders(lastData, false); }
3835
+ else if(act === "typed-open"){ typedOpen[slot] = !typedOpen[slot]; paintProviders(lastData, false); }
3836
+ else if(act === "add-model") addTypedModel(slot, b.closest("[data-f=typed]"));
3586
3837
  });
3587
- document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
3588
- // Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
3589
- document.getElementById("btn-open-keys-folder").addEventListener("click", function(){
3590
- var out = document.getElementById("pv-keyfile-path");
3591
- authorPost("/api/settings/open-keys-folder", {}).then(function(r){
3592
- var b = r.body || {};
3593
- out.textContent = b.ok && b.opened ? "" : ("Could not open it here. Your keys folder: " + (b.path || "(unknown)"));
3594
- }).catch(function(){ out.textContent = "Could not open it here — the keys folder is ~/.config/bareloop"; });
3838
+ // Saved like bareloop saves a row field: on change (blur or Enter), no Save button. The legacy tick saves alone; shape and Base URL save as a pair.
3839
+ pvRows.addEventListener("change", function(ev){
3840
+ var inp = ev.target;
3841
+ if(!inp.getAttribute) return;
3842
+ if(inp.getAttribute("data-prov") === "legacyMaxTokens"){ saveProvider(inp.closest(".pv-key"), inp); return; }
3843
+ if(inp.getAttribute("data-prov") === "shape" || inp.getAttribute("data-prov") === "baseUrl"){ saveEndpoint(inp.closest(".pv-key")); return; }
3844
+ if(!inp.getAttribute("data-mf")) return;
3845
+ saveModel(inp.closest(".pv-models") ? inp.closest(".pv-models").getAttribute("data-models-for") : "", inp.closest(".pv-mrow"));
3595
3846
  });
3847
+ // A box the person typed: empty = back to the default (null); a number is sent as a number; anything else is
3848
+ // sent as the text typed, so the server refuses it by naming the box.
3849
+ function priceValue(text){
3850
+ var t = String(text).trim();
3851
+ if(t === "") return null;
3852
+ var n = Number(t);
3853
+ return isFinite(n) ? n : t;
3854
+ }
3855
+ // One model line per save: {slot, model, use, inPerM, outPerM}. A refusal is the line's hint (the server's one rule),
3856
+ // and nothing was saved: the repaint puts the tick back.
3857
+ function saveModel(slot, line){
3858
+ var msg = line.querySelector("[data-f=msg-model]");
3859
+ var payload = {
3860
+ slot: slot,
3861
+ model: line.getAttribute("data-model"),
3862
+ use: line.querySelector("[data-mf=use]").checked,
3863
+ inPerM: priceValue(line.querySelector("[data-mf=inPerM]").value),
3864
+ outPerM: priceValue(line.querySelector("[data-mf=outPerM]").value)
3865
+ };
3866
+ authorPost("/api/settings/model", payload).then(function(r){
3867
+ if(r.body && r.body.ok){
3868
+ line.querySelectorAll("input[type=text]").forEach(function(inp){ inp.removeAttribute("data-dirty"); });
3869
+ loadProviders();
3870
+ setNote(msg, false, "saved — applies from the next model call");
3871
+ } else { setNote(msg, true, settingsRefusal(r)); loadProviders(); }
3872
+ }, function(){ setNote(msg, true, networkText()); });
3873
+ }
3874
+
3875
+ // A model name typed because the list failed: saved as an unticked, empty line (typed lines are saved); the person then fills the prices and ticks it.
3876
+ function addTypedModel(slot, box){
3877
+ var inp = box.querySelector("input");
3878
+ var msg = box.querySelector("[data-f=typed-msg]");
3879
+ authorPost("/api/settings/model", { slot: slot, model: inp.value.trim(), use: false }).then(function(r){
3880
+ if(r.body && r.body.ok){ inp.value = ""; setNote(msg, false, "added"); loadProviders(); }
3881
+ else setNote(msg, true, settingsRefusal(r));
3882
+ }, function(){ setNote(msg, true, networkText()); });
3883
+ }
3884
+
3885
+ // The legacy tick saves alone: {slot, legacyMaxTokens}. A refusal is the cell's hint line.
3886
+ function saveProvider(key, inp){
3887
+ var field = inp.getAttribute("data-prov");
3888
+ var msg = key.querySelector("[data-f=msg-" + field + "]");
3889
+ var payload = { slot: key.getAttribute("data-slot") };
3890
+ payload[field] = inp.checked;
3891
+ authorPost("/api/settings/provider", payload).then(function(r){
3892
+ if(r.body && r.body.ok){
3893
+ inp.removeAttribute("data-dirty");
3894
+ loadProviders();
3895
+ setNote(msg, false, "saved — applies from the next model call");
3896
+ } else setNote(msg, true, settingsRefusal(r));
3897
+ }, function(){ setNote(msg, true, networkText()); });
3898
+ }
3899
+
3900
+ document.getElementById("tab-providers").addEventListener("click", function(){ loadProviders(); });
3596
3901
  wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
3597
- document.getElementById("tab-providers").addEventListener("click", loadProviders);
3598
3902
  // Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
3599
3903
  limitInput.addEventListener("change", function(){
3600
3904
  var text = limitInput.value.trim();
@@ -3622,12 +3926,16 @@
3622
3926
  var mainHint = document.getElementById("chat-main-hint");
3623
3927
  var reopenBtn = document.getElementById("chat-reopen-btn");
3624
3928
  var mainAction = "none";
3929
+ var knownRunIdsAtSign = null; // the run ids listed when Sign & run was pressed (M3: afterSignRefresh waits for a new one)
3625
3930
  var lastState = null;
3626
3931
  var startOk = false;
3627
3932
  var errEl = document.getElementById("chat-card-error");
3628
3933
  var actionErrEl = document.getElementById("chat-action-error");
3629
3934
  var moneyCapInput = document.getElementById("jf-cap-money");
3630
3935
  var modelSelect = document.getElementById("jf-model");
3936
+ var thinkingBox = document.getElementById("jf-thinking");
3937
+ var thinkingNote = document.getElementById("jf-thinking-note");
3938
+ var lineThinking = {}; // option value -> the server's {support, canChange, say} for that Model line
3631
3939
  var modelStatusEl = document.getElementById("jf-model-status");
3632
3940
  var progressRow = document.getElementById("chat-progress-row");
3633
3941
 
@@ -3672,11 +3980,14 @@
3672
3980
  var resuming = resumeRun !== null;
3673
3981
  var setLock = function(id, on){ var el = document.getElementById(id); el.readOnly = on; el.classList.toggle("locked", on); };
3674
3982
  LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live || resuming); });
3675
- OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1)); });
3676
- document.getElementById("jf-model").disabled = live || resuming;
3983
+ // the one exception on a resume card: an older spec that names no model leaves the Model box open (picked at resume)
3984
+ var modelOpen = resuming && resumeRun.pickModel === true;
3985
+ OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1 && !(modelOpen && id === "jf-model"))); });
3986
+ document.getElementById("jf-model").disabled = live || (resuming && !modelOpen);
3677
3987
  // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3678
3988
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3679
3989
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3990
+ syncThinking();
3680
3991
  }
3681
3992
  function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3682
3993
  function clearStartFrom(keepBoxes){
@@ -3754,7 +4065,7 @@
3754
4065
  }
3755
4066
  }
3756
4067
  function loadReuseJobs(){
3757
- authorGet("/api/author/reuse-jobs" + (modelSelect.value ? "?model=" + encodeURIComponent(modelSelect.value) : "")).then(function(r){
4068
+ authorGet("/api/author/reuse-jobs" + (modelSelect.value ? "?" + pickQuery(modelSelect.value) : "")).then(function(r){
3758
4069
  rwFailed = !(r && r.ok);
3759
4070
  rwJobs = rwFailed ? null : r.jobs;
3760
4071
  renderReuseList();
@@ -3771,7 +4082,7 @@
3771
4082
  refreshStartEnabled();
3772
4083
  rwQ.focus();
3773
4084
  }
3774
- function pickReuseJob(j){ if(j) reuseWorkflow(j.runid, modelSelect.value); }
4085
+ function pickReuseJob(j){ if(j) reuseWorkflow(j.runid, pickQuery(modelSelect.value)); }
3775
4086
  rwQ.addEventListener("input", function(){ rwHl = 0; renderReuseList(); });
3776
4087
  rwQ.addEventListener("keydown", function(e){
3777
4088
  if(e.key === "ArrowDown"){ rwHl = Math.min(rwHl + 1, rwShown.length - 1); renderReuseList(); e.preventDefault(); }
@@ -3791,7 +4102,9 @@
3791
4102
  document.getElementById("rw-change").addEventListener("click", unpickReuse);
3792
4103
  // leaving resume mode: the title, the hidden drafting chrome, the time box and the resume box all come back
3793
4104
  function exitResumeMode(){
4105
+ var filtered = resumeRun !== null && !!resumeRun.worker;
3794
4106
  resumeRun = null;
4107
+ if(filtered){ renderModelOptions(); }
3795
4108
  originLine.classList.remove("res");
3796
4109
  document.getElementById("job-card-title").textContent = "Job card \u2014 draft";
3797
4110
  document.getElementById("chat-resume-box").hidden = true;
@@ -3836,6 +4149,7 @@
3836
4149
  clearCardBoxes();
3837
4150
  setVerdict("deterministic");
3838
4151
  modelSelect.selectedIndex = 0;
4152
+ setThinkingTick(false);
3839
4153
  msgInput.value = "";
3840
4154
  rwQ.value = "";
3841
4155
  lastPhase = null;
@@ -3861,11 +4175,14 @@
3861
4175
  return {
3862
4176
  // on "Reuse workflow" the type is the picked job's own, never the radio's
3863
4177
  checkType: (verdictValue() === "reuse" ? reuseCheckType : verdictValue()) === "rubric" ? "rubric" : "deterministic",
3864
- model: document.getElementById("jf-model").value,
4178
+ provider: pickParts(document.getElementById("jf-model").value).provider,
4179
+ model: pickParts(document.getElementById("jf-model").value).model,
3865
4180
  jobName: document.getElementById("jf-name").value.trim(),
3866
4181
  jobText: document.getElementById("jf-job").value.trim(),
3867
4182
  inputs: document.getElementById("jf-inputs").value.trim(),
3868
4183
  destination: document.getElementById("jf-dest").value.trim(),
4184
+ // the "Thinking on" tick: sent as a boolean; the server judges it per line and signs it only when ticked
4185
+ thinking: thinkingBox.checked === true,
3869
4186
  capUsd: moneyCap,
3870
4187
  maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
3871
4188
  };
@@ -3883,6 +4200,7 @@
3883
4200
  var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
3884
4201
  // on Reuse workflow nothing starts before a job is picked
3885
4202
  startOk = capOk && !sessionLive && keyOk && (verdictValue() !== "reuse" || startFrom !== null);
4203
+ if(typeof syncResumeSign === "function"){ syncResumeSign(); }
3886
4204
  syncCardLock();
3887
4205
  renderMain();
3888
4206
  refreshClearBtn();
@@ -3963,21 +4281,30 @@
3963
4281
  return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
3964
4282
  }
3965
4283
  function refreshModelStatus(){
3966
- var modelId = modelSelect.value;
3967
- if(modelId === ""){
4284
+ var modelId = pickParts(modelSelect.value).model;
4285
+ if(modelSelect.value === ""){
3968
4286
  keyOk = false;
3969
4287
  modelStatusEl.className = "jf-readiness bad";
3970
- modelStatusEl.textContent = "[✗] no model — give a key a Name in Settings > Providers";
4288
+ modelStatusEl.textContent = "[✗] no model — Tick a model in Settings > Providers.";
3971
4289
  refreshStartEnabled();
3972
4290
  return;
3973
4291
  }
3974
- authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
4292
+ // the key state of the NEW pick is unknown until its check returns: blocked at once, and a late answer for a line
4293
+ // no longer selected is ignored (it would otherwise unblock Start / Sign & resume for the wrong key)
4294
+ var asked = modelSelect.value;
4295
+ keyOk = false;
4296
+ modelStatusEl.className = "jf-readiness unknown";
4297
+ modelStatusEl.textContent = "checking the key\u2026";
4298
+ refreshStartEnabled();
4299
+ authorGet("/api/author/model-check?" + pickQuery(modelSelect.value)).then(function(r){
4300
+ if(modelSelect.value !== asked) return;
3975
4301
  var line = modelStatusLine(r, modelId);
3976
4302
  keyOk = line.keyOk;
3977
4303
  modelStatusEl.className = "jf-readiness " + (line.ok ? "ok" : "bad");
3978
4304
  modelStatusEl.textContent = line.text;
3979
4305
  refreshStartEnabled();
3980
4306
  }).catch(function(){
4307
+ if(modelSelect.value !== asked) return;
3981
4308
  keyOk = false;
3982
4309
  modelStatusEl.className = "jf-readiness bad";
3983
4310
  modelStatusEl.textContent = "[✗] could not check the key";
@@ -3989,25 +4316,89 @@
3989
4316
  function refreshReuseLine(){
3990
4317
  if(!startFrom) return;
3991
4318
  var q = (startFrom.importId ? "import=" + encodeURIComponent(startFrom.importId) : "runid=" + encodeURIComponent(startFrom.runid)) +
3992
- "&model=" + encodeURIComponent(modelSelect.value);
4319
+ "&" + pickQuery(modelSelect.value);
3993
4320
  authorGet("/api/author/start-from?" + q).then(function(r){
3994
4321
  if(startFrom && r && r.ok){ sfLine.textContent = r.line ? rwMeta({checkType: reuseCheckType, line: r.line}) : ""; }
3995
4322
  }).catch(function(){ /* the line keeps what it had */ });
3996
4323
  }
3997
4324
  modelSelect.addEventListener("change", refreshReuseLine);
3998
4325
  modelSelect.addEventListener("change", function(){ if(!rwBox.hidden && startFrom === null && !reuseSession) loadReuseJobs(); });
3999
- // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
4000
- // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
4326
+ // P8 — the Model menu IS the Settings rows: every TICKED, PRICED line, labelled "<KEY> · <model>", in keys-file order. An option's
4327
+ // value carries the key AND the model ("KEY|model"): two keys can tick the same model id, and the card names which one.
4328
+ // Re-read whenever Settings loads or saves, keeping the pick if it survives.
4329
+ function pickValue(slot, model){ return (slot || "") + "|" + model; }
4330
+ function pickParts(v){
4331
+ var i = String(v).indexOf("|");
4332
+ return i < 0 ? {provider: "", model: String(v)} : {provider: v.slice(0, i), model: v.slice(i + 1)};
4333
+ }
4334
+ function pickQuery(v){ var p = pickParts(v); return "model=" + encodeURIComponent(p.model) + "&provider=" + encodeURIComponent(p.provider); }
4335
+ // restore a saved card's pick: its key and model; an older card names only the model (the first option holding it)
4336
+ function setPick(c){
4337
+ if(!c || !c.model) return;
4338
+ var want = c.provider ? pickValue(c.provider, c.model) : null;
4339
+ var opts = Array.prototype.slice.call(modelSelect.options);
4340
+ var hit = opts.filter(function(o){ return want ? o.value === want : pickParts(o.value).model === c.model; })[0];
4341
+ if(!hit){
4342
+ hit = document.createElement("option");
4343
+ hit.value = want || pickValue("", c.model);
4344
+ hit.textContent = (c.provider ? c.provider + " \u00b7 " : "") + c.model;
4345
+ modelSelect.appendChild(hit);
4346
+ }
4347
+ modelSelect.value = hit.value;
4348
+ syncThinking();
4349
+ }
4350
+ // The "Thinking on" box follows the SERVER's answer for the picked line (`thinking.canChange`): false greys AND unticks it with the
4351
+ // server's words; 'unverified' leaves it enabled with no note. The tick is the person's; the page decides nothing.
4352
+ function syncThinking(){
4353
+ var t = lineThinking[modelSelect.value];
4354
+ var blocked = !!(t && t.canChange === false);
4355
+ if(blocked) thinkingBox.checked = false;
4356
+ thinkingBox.disabled = blocked || sessionLive || resumeRun !== null || reuseOn;
4357
+ thinkingNote.textContent = blocked ? (t.say || "") : "";
4358
+ document.getElementById("jf-thinking-label").hidden = reuseOn;
4359
+ }
4360
+ function setThinkingTick(on){ thinkingBox.checked = on === true; syncThinking(); }
4361
+ modelSelect.addEventListener("change", syncThinking);
4362
+ var modelReads = 0;
4363
+ var modelLines = [];
4364
+ var modelSay = "";
4365
+ // A Resume card that must PICK a model (old spec naming none) offers only the lines resolveWorker (src/providerrows.js) accepts
4366
+ // for that spec: the same shape, and the same endpoint when the spec names one (`resumeRun.worker`, from start-from, endpoint
4367
+ // already normalised by the server). With none, the sentence names the shape to tick (label from providershapes).
4368
+ function renderModelOptions(){
4369
+ var w = resumeRun !== null && resumeRun.pickModel === true ? resumeRun.worker : null;
4370
+ var lines = modelLines.filter(function(m){ return !w || (m.shape === w.provider && (w.endpoint === "" || m.endpoint === w.endpoint)); });
4371
+ var was = modelSelect.value;
4372
+ lineThinking = {};
4373
+ lines.forEach(function(m){ lineThinking[pickValue(m.slot, m.model)] = m.thinking || null; });
4374
+ var none = (w && modelLines.length) ? "Tick a " + w.label + " model in Settings > Providers." : (modelSay || "Tick a model in Settings > Providers.");
4375
+ modelSelect.innerHTML = lines.length ? lines.map(function(m){
4376
+ return '<option value="' + escapeXml(pickValue(m.slot, m.model)) + '">' + escapeXml(m.label) + "</option>";
4377
+ }).join("") : '<option value="">' + escapeXml(none) + "</option>";
4378
+ if(lines.some(function(m){ return pickValue(m.slot, m.model) === was; })) modelSelect.value = was;
4379
+ syncThinking();
4380
+ }
4001
4381
  function loadModels(){
4382
+ if(modelSelect.options.length === 0){
4383
+ modelSelect.innerHTML = '<option value="">Reading your models\u2026</option>';
4384
+ }
4002
4385
  authorGet("/api/author/models").then(function(r){
4003
- var models = r && r.ok ? r.models : [];
4004
- var was = modelSelect.value;
4005
- modelSelect.innerHTML = models.length ? models.map(function(m){
4006
- return '<option value="' + escapeXml(m.id) + '">' + escapeXml(m.id) + " · " + escapeXml(m.envName) + "</option>";
4007
- }).join("") : '<option value="">no model — add a Name in Settings</option>';
4008
- if(models.some(function(m){ return m.id === was; })) modelSelect.value = was;
4386
+ if(!r || !r.ok) throw new Error("read");
4387
+ modelReads = 0;
4388
+ modelLines = r.lines || [];
4389
+ modelSay = r.say || "";
4390
+ renderModelOptions();
4009
4391
  refreshModelStatus();
4010
- }).catch(function(){ refreshModelStatus(); });
4392
+ }).catch(function(){
4393
+ modelReads += 1;
4394
+ if(modelReads <= 5){
4395
+ modelSelect.innerHTML = '<option value="">Could not read your models. Trying again\u2026</option>';
4396
+ setTimeout(loadModels, 1500);
4397
+ } else {
4398
+ modelSelect.innerHTML = '<option value="">Could not read your models. Reload the page.</option>';
4399
+ }
4400
+ refreshModelStatus();
4401
+ });
4011
4402
  }
4012
4403
  document.addEventListener("bareloop-providers-changed", loadModels);
4013
4404
  loadModels();
@@ -4226,6 +4617,7 @@
4226
4617
  renderActions(j.state);
4227
4618
  if(reuseSession && !autoSigned && j.state.phase === "prepared" && j.state.specHash){
4228
4619
  autoSigned = true;
4620
+ knownRunIdsAtSign = currentRunIds();
4229
4621
  authorPost("/api/author/" + sessionId + "/sign", {specHash: j.state.specHash}).then(function(r){
4230
4622
  var o = chatPostOutcome(r);
4231
4623
  if(o.ok){ chatActionOk(); } else { autoSigned = false; chatActionFailed(o.error); }
@@ -4234,6 +4626,7 @@
4234
4626
  }
4235
4627
  if(j.state.phase === "signed"){
4236
4628
  clearInterval(pollTimer);
4629
+ afterSignRefresh(knownRunIdsAtSign || currentRunIds(), 12);
4237
4630
  document.getElementById("tab-runs").click();
4238
4631
  // hamr's ruling 2026-10-05: the run has started, so the Chat card goes back to the empty card (the one reset
4239
4632
  // Clear uses) — it must not keep showing a signed job that invites a rerun. The run itself is untouched.
@@ -4256,6 +4649,8 @@
4256
4649
  authorPost("/api/author/start", body).then(function(r){
4257
4650
  if(r.status !== 200 || !r.body.ok){
4258
4651
  errEl.textContent = (r.body && r.body.error) || "refused";
4652
+ // the refusal is the answer to the click: bring it into view (it sits under the cap fields, above the progress list)
4653
+ try { errEl.scrollIntoView({block: "nearest"}); } catch(e){}
4259
4654
  sessionLive = false;
4260
4655
  refreshStartEnabled();
4261
4656
  return;
@@ -4289,10 +4684,7 @@
4289
4684
  reusePickedName = c.jobName || "job";
4290
4685
  setVerdict(reuseSession ? "reuse" : reuseCheckType);
4291
4686
  if(c.model){
4292
- if(!Array.prototype.some.call(modelSelect.options, function(o){ return o.value === c.model; })){
4293
- modelSelect.insertAdjacentHTML("beforeend", '<option value="' + escapeXml(c.model) + '">' + escapeXml(c.model) + "</option>");
4294
- }
4295
- modelSelect.value = c.model;
4687
+ setPick(c);
4296
4688
  }
4297
4689
  fillCard(c);
4298
4690
 
@@ -4323,6 +4715,7 @@
4323
4715
  setVal("jf-name", c.jobName); setVal("jf-job", c.jobText); setVal("jf-inputs", c.inputs);
4324
4716
  setVal("jf-dest", c.destination); setVal("jf-cap-money", c.capUsd);
4325
4717
  setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4718
+ setThinkingTick(c.thinking === true);
4326
4719
  fitCardBoxes();
4327
4720
  }
4328
4721
  // ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
@@ -4336,7 +4729,7 @@
4336
4729
  reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
4337
4730
  reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
4338
4731
  setVerdict("reuse");
4339
- if(c.model){ modelSelect.value = c.model; }
4732
+ if(c.model){ setPick(c); }
4340
4733
  fillCard(c);
4341
4734
 
4342
4735
  setReuseLocked(true);
@@ -4362,7 +4755,7 @@
4362
4755
  clearStartFrom();
4363
4756
  openNewCard();
4364
4757
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4365
- if(c.model){ modelSelect.value = c.model; }
4758
+ if(c.model){ setPick(c); }
4366
4759
  fillCard(c);
4367
4760
 
4368
4761
  originLine.textContent = d.origin || "";
@@ -4383,10 +4776,12 @@
4383
4776
  var r = d.resume || {};
4384
4777
  clearStartFrom();
4385
4778
  openNewCard();
4779
+ // hamr 2026-10-11 Q2(b): a spec that names no model (the server says so) opens the Model box; the pick rides the POST
4386
4780
  resumeRun = {runid: d.runid, noTime: typeof r.maxWallMin !== "number"};
4781
+ if((d.prefill || {}).specNamesModel === false){ resumeRun.pickModel = true; if(d.prefill.specWorker){ resumeRun.worker = d.prefill.specWorker; } renderModelOptions(); }
4387
4782
  document.getElementById("job-card-title").textContent = "RESUME " + (d.title || d.runid);
4388
4783
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4389
- if(c.model){ modelSelect.value = c.model; }
4784
+ if(c.model && !resumeRun.pickModel){ setPick(c); }
4390
4785
  fillCard(c);
4391
4786
  setVal("jf-cap-money", r.budgetUsd);
4392
4787
  setVal("jf-cap-time", resumeRun.noTime ? "" : r.maxWallMin);
@@ -4408,16 +4803,21 @@
4408
4803
  document.addEventListener("bareloop-resume", function(e){ applyResume(e.detail || {}); });
4409
4804
  document.getElementById("chat-resume-cancel").addEventListener("click", function(){ resetCard(); });
4410
4805
  var resumeSignBtn = document.getElementById("chat-resume-sign");
4806
+ // a Model pick that is needed (older spec) blocks Sign & resume until a ticked line is picked and its key is usable
4807
+ function resumeSignBlocked(){ return resumeRun !== null && resumeRun.pickModel === true && (modelSelect.value === "" || !keyOk); }
4808
+ function syncResumeSign(){ resumeSignBtn.disabled = resumeSignBlocked(); }
4411
4809
  function signResume(){
4412
4810
  var run = resumeRun;
4413
4811
  if(run === null) return;
4812
+ if(resumeSignBlocked()) return;
4414
4813
  var err = document.getElementById("chat-resume-err");
4415
4814
  err.hidden = true;
4416
4815
  resumeSignBtn.disabled = true;
4417
4816
  var body = {budgetUsd: document.getElementById("jf-cap-money").value};
4418
4817
  if(!run.noTime){ body.maxWallMin = document.getElementById("jf-cap-time").value; }
4818
+ if(run.pickModel){ var pk = pickParts(modelSelect.value); body.model = pk.model; if(pk.provider !== ""){ body.provider = pk.provider; } }
4419
4819
  return authorPost("/api/runs/" + encodeURIComponent(run.runid) + "/resume", body).then(function(res){
4420
- resumeSignBtn.disabled = false;
4820
+ resumeSignBtn.disabled = resumeSignBlocked();
4421
4821
  if(res.body && res.body.ok){
4422
4822
  // the SAME run goes on: the card goes back to empty, the right side to its Run tab, which follows it until it is live again
4423
4823
  resetCard();
@@ -4427,7 +4827,7 @@
4427
4827
  err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
4428
4828
  err.hidden = false;
4429
4829
  }
4430
- }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
4830
+ }).catch(function(x){ resumeSignBtn.disabled = resumeSignBlocked(); err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
4431
4831
  }
4432
4832
  resumeSignBtn.addEventListener("click", signResume);
4433
4833
 
@@ -4531,6 +4931,7 @@
4531
4931
  authorGet("/api/author/" + sessionId).then(function(j){
4532
4932
  var hash = j.state && j.state.specHash;
4533
4933
  if(!hash) return;
4934
+ knownRunIdsAtSign = currentRunIds();
4534
4935
  authorPost("/api/author/" + sessionId + "/sign", {specHash: hash}).then(function(r){
4535
4936
  var o = chatPostOutcome(r);
4536
4937
  if(o.ok){ chatActionOk(); } else { chatActionFailed(o.error); return; }