bareloop 0.39.0 → 0.41.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 (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +137 -112
  3. package/package.json +2 -2
  4. package/src/authorflow.js +333 -58
  5. package/src/authoring.js +65 -2
  6. package/src/authorjob.js +57 -9
  7. package/src/authorreadout.js +50 -2
  8. package/src/authorrun.js +30 -27
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/calibrate.js +1 -1
  14. package/src/cardauthor.js +4 -1
  15. package/src/cli.js +1 -1
  16. package/src/config.js +129 -29
  17. package/src/declaredclose.js +4 -3
  18. package/src/draftspend.js +6 -1
  19. package/src/index.js +2 -4
  20. package/src/interviewrun.js +73 -52
  21. package/src/job.js +50 -11
  22. package/src/jobblock.js +140 -0
  23. package/src/judged.js +12 -44
  24. package/src/keysfile.js +15 -7
  25. package/src/kinds.js +2 -2
  26. package/src/ledger.js +1 -1
  27. package/src/modellist.js +84 -0
  28. package/src/monthly.js +23 -43
  29. package/src/panel/authorroutes.js +34 -19
  30. package/src/panel/authorsession.js +187 -72
  31. package/src/panel/index.html +699 -215
  32. package/src/panel/jobplan.js +107 -0
  33. package/src/panel/runroutes.js +3 -0
  34. package/src/panel/server.js +102 -28
  35. package/src/panel/settingsroutes.js +232 -173
  36. package/src/planrun.js +6 -6
  37. package/src/promptregisters.js +1 -1
  38. package/src/providerrows.js +226 -141
  39. package/src/providers.js +58 -250
  40. package/src/providershapes.js +18 -0
  41. package/src/replay.js +1 -1
  42. package/src/reuse.js +1 -1
  43. package/src/root.js +1 -1
  44. package/src/run.js +3 -3
  45. package/src/selection.js +1 -1
  46. package/src/source.js +64 -1
  47. package/src/text.js +13 -0
  48. package/src/thinking.js +130 -0
  49. package/src/transport.js +1 -1
  50. package/src/userrun.js +53 -76
  51. package/types/authorflow.d.ts +258 -39
  52. package/types/authoring.d.ts +7 -1
  53. package/types/authorjob.d.ts +35 -3
  54. package/types/authorreadout.d.ts +17 -1
  55. package/types/authorscout.d.ts +5 -3
  56. package/types/config.d.ts +23 -6
  57. package/types/declaredclose.d.ts +4 -1
  58. package/types/draftspend.d.ts +8 -2
  59. package/types/index.d.ts +1 -1
  60. package/types/job.d.ts +6 -6
  61. package/types/jobblock.d.ts +102 -0
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/modellist.d.ts +50 -0
  65. package/types/monthly.d.ts +4 -3
  66. package/types/panel/authorsession.d.ts +44 -15
  67. package/types/panel/jobplan.d.ts +58 -0
  68. package/types/panel/server.d.ts +34 -4
  69. package/types/planrun.d.ts +1 -1
  70. package/types/providerrows.d.ts +192 -99
  71. package/types/providers.d.ts +35 -116
  72. package/types/providershapes.d.ts +10 -0
  73. package/types/run.d.ts +1 -1
  74. package/types/source.d.ts +26 -0
  75. package/types/thinking.d.ts +90 -0
  76. package/types/userrun.d.ts +6 -1
@@ -21,7 +21,7 @@
21
21
  --grey-dot:#878eac; --grey-bg:#20242f;
22
22
  --cyan:#7dcfff; --cyan-bg:#182634;
23
23
  --magenta:#bb9af7;
24
- --accent:#7aa2f7;
24
+ --accent:#7aa2f7; --plan:#7aa2f7;
25
25
  --btn-bg:#1f2335; --btn-text:#c0caf5;
26
26
  --field-bg:var(--bg); --field-soft-bg:var(--bg); --field-border:var(--text-dim);
27
27
  --primary-bg:#7aa2f7; --primary-text:#1a1b26; --primary-border:#7aa2f7;
@@ -67,7 +67,7 @@
67
67
  --grey-dot:#626887; --grey-bg:#e2e2e8;
68
68
  --cyan:#007197; --cyan-bg:#d6ecf1;
69
69
  --magenta:#9854f1;
70
- --accent:#2668c2;
70
+ --accent:#2668c2; --plan:#2668c2;
71
71
  --btn-bg:#e9e9ed; --btn-text:#3760bf;
72
72
  --field-bg:#ffffff; --field-soft-bg:#f7f7f9;
73
73
  --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
@@ -82,7 +82,7 @@
82
82
  --grey-dot:#626887; --grey-bg:#e2e2e8;
83
83
  --cyan:#007197; --cyan-bg:#d6ecf1;
84
84
  --magenta:#9854f1;
85
- --accent:#2668c2;
85
+ --accent:#2668c2; --plan:#2668c2;
86
86
  --btn-bg:#e9e9ed; --btn-text:#3760bf;
87
87
  --field-bg:#ffffff; --field-soft-bg:#f7f7f9;
88
88
  --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
@@ -121,6 +121,9 @@
121
121
  [hidden]{display:none !important;}
122
122
  .main{display:flex;flex:1;min-height:0;}
123
123
  .left-pane{width:420px;flex:none;border-right:1px solid var(--border-strong);display:flex;flex-direction:column;background:var(--panel);}
124
+ /* hamr 2026-10-10: the left pane is 630px on wide screens only. 1200px is the floor: the right pane is then >=570px, never
125
+ narrower than the 480px it already had at 900px with the 420px pane (630 at 900 left it 270 and wrapped its tabs) */
126
+ @media (min-width: 1200px){ .left-pane{width:630px;} }
124
127
  .right-pane{flex:1;min-width:0;display:flex;flex-direction:column;}
125
128
 
126
129
  .tabrow{display:flex;border-bottom:1px solid var(--border-strong);background:var(--panel2);counter-reset:tabnum;}
@@ -466,6 +469,41 @@
466
469
  /* hamr 2026-10-05: the card's free-text boxes wrap like the ask box (one shared fitBox); a few rows, then they scroll inside */
467
470
  /* same look as the card's one-line inputs (white when editable, ordinary 1px border) — a textarea has its own browser border otherwise */
468
471
  .job-card textarea.jf-wrap{width:100%;box-sizing:border-box;font:inherit;padding:6px 8px;border:1px solid var(--border-strong);border-radius:0;background:var(--bg);color:var(--text);resize:vertical;overflow-y:auto;max-height:200px;}
472
+ /* P7 — numbered boxes (The job, Inputs): the number gutter sits OUTSIDE the box, left; a hidden mirror measures each
473
+ logical line's wrapped height so a number stays level with its line (docs/product/PANEL-BUILD.md, signed mock) */
474
+ .nbox{display:grid;grid-template-columns:22px minmax(0,1fr);column-gap:6px;align-items:stretch;}
475
+ /* contain:size keeps the gutter's own content out of the row height (the box alone decides it); its padding mirrors the
476
+ textarea's 6px + 1px border top AND bottom so both scroll the same distance and a number never drifts or falls out */
477
+ .nbox .gut{color:var(--text-faint);text-align:right;padding:7px 0;box-sizing:border-box;overflow:hidden;contain:size;font-size:inherit;line-height:1.5;}
478
+ .nbox .gut div{min-height:1.5em;}
479
+ .nbox-in{position:relative;min-width:0;}
480
+ /* the numbered boxes grow with their text up to 8 lines (8 x 1.5em + 6px padding x2 + 1px border x2), then scroll; fitBox reads this cap */
481
+ .job-card .nbox textarea.jf-wrap{display:block;line-height:1.5;max-height:calc(12em + 14px);}
482
+ .nbox-mir{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;box-sizing:border-box;padding:6px 8px;font:inherit;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;}
483
+ .nbox-mir div{min-height:1.5em;}
484
+ /* P7 — the ONE job plan drawing (chat plan bubble + Job tab): job lines numbered, rule under, checks folded; only the marker is blue */
485
+ .jp{white-space:normal;font-size:13px;}
486
+ .jp-legend{font-size:12px;color:var(--text-faint);margin:0 0 8px;}
487
+ .jp-legend b,.jp .mk{color:var(--plan);font-weight:700;}
488
+ /* a wrapped job line hangs: its text lines up after the number, not under it (the signed mock's gutter shape) */
489
+ .jp .jl{margin-bottom:8px;overflow-wrap:anywhere;padding-left:30px;text-indent:-30px;}
490
+ .jp .jl .sub,.jp .jl .fold{padding-left:0;text-indent:0;}
491
+ .jp .num{display:inline-block;text-indent:0;width:22px;text-align:right;color:var(--text-faint);margin-right:8px;}
492
+ .jp .sub{color:var(--text-dim);padding-left:30px;}
493
+ .jp .fold{padding-left:30px;color:var(--text);}
494
+ .jp details.fold{padding-left:0;margin:6px 0;}
495
+ .jp details>summary{cursor:pointer;list-style:none;}
496
+ .jp details>summary::-webkit-details-marker{display:none;}
497
+ .jp details>summary::before{content:"\203A";color:var(--plan);font-weight:700;margin-right:6px;}
498
+ .jp details[open]>summary::before{content:"\2304";}
499
+ .jp details ul{margin:4px 0 0;padding-left:26px;color:var(--text-dim);}
500
+ .jp .older{font-size:12px;color:var(--text-faint);margin:0 0 8px;}
501
+ .jp .jp-sum{margin:0 0 8px;overflow-wrap:anywhere;}
502
+ .jp .jp-sum .lbl{display:inline-block;color:var(--text-faint);margin-right:8px;}
503
+ .jp-qs{margin:8px 0;overflow-wrap:anywhere;}
504
+ .jp-qs .lbl{display:inline-block;color:var(--text-faint);margin-right:8px;}
505
+ .jp-qs ul{margin:4px 0;padding-left:26px;}
506
+ .jp-goal{font-size:12px;color:var(--text-faint);margin-top:8px;}
469
507
  /* hamr 2026-10-05: the focused/selected Chat-card field gets a blue border (the accent), no outline ring */
470
508
  #panel-chat :is(input,select,textarea):focus{border-color:var(--accent);outline:none;}
471
509
  /* hamr 2026-10-05: the ask box is dimmed until something asks for a reply (placeholder stays readable) */
@@ -492,19 +530,92 @@
492
530
  .money-item input{width:100%;height:44px;box-sizing:border-box;}
493
531
  .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;}
494
532
  .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;}
495
- .keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:220px;}
496
- .keyfile-strip-actions{display:flex;gap:6px;flex:none;}
533
+ .keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:0;overflow-wrap:anywhere;}
534
+ .keyfile-strip-actions{display:flex;flex-wrap:wrap;gap:6px;flex:none;max-width:100%;}
497
535
  .keyfile-strip code{background:var(--bg);border:1px solid var(--border-strong);padding:1px 5px;font-size:11px;color:var(--amber);overflow-wrap:anywhere;}
498
- .pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
499
- .pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
500
- .pv-table .hint{display:block;margin-top:3px;}
501
- .pv-table td.pv-price-cell input{width:90px;}
502
- /* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
503
- .pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
504
- .pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
536
+ /* M4d Providers layout F (hamr approved 2026-10-11): ONE shared grid, so a key row, the model sub-head and
537
+ every model row sit on the same column edges. Key band: KEY | API SHAPE | BASE URL (two columns) | OLD MAX_TOKENS | TOKENS | refresh.
538
+ 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
539
+ solid 1px line, a model row a dotted 1px hairline. Below 1280px every row collapses to stacked label : value lines. */
540
+ .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;}
541
+ .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;}
542
+ .pv-head > *,.pv-krow > *,.pv-mhead > *,.pv-mrow > *{min-width:0;}
543
+ .pv-head,.pv-mhead{font-size:11px;font-weight:700;color:var(--text-dim);letter-spacing:0.04em;min-height:26px;}
544
+ .pv-head{background:var(--panel2);border-bottom:1px solid var(--border-strong);}
545
+ .pv-mhead{background:var(--bg);border-bottom:1px dotted var(--border-strong);}
546
+ .pv-key{border-top:1px solid var(--border-strong);}
547
+ .pv-krow{min-height:40px;padding-top:4px;padding-bottom:4px;}
548
+ .pv-c-url{grid-column:3 / span 2;}
549
+ .pv-lbl{display:none;}
550
+ .pv-name{display:flex;align-items:center;flex-wrap:wrap;gap:4px 6px;min-width:0;overflow-wrap:anywhere;}
551
+ .pv-name code{font-weight:700;}
552
+ .pv-ok{color:var(--green);font-weight:700;}
553
+ .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;}
554
+ .pv-krow select,.pv-krow input[type=text]{width:100%;max-width:100%;min-height:28px;box-sizing:border-box;}
555
+ .pv-grid select,.pv-grid input[type=text]{font-size:12px;height:28px;padding:2px 6px;}
556
+ .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;}
557
+ .pv-legacy input,.pv-m-use input{width:16px;height:16px;margin:0;accent-color:var(--accent);cursor:pointer;}
558
+ .pv-tok{text-align:right;font-variant-numeric:tabular-nums;}
559
+ .pv-refcell{display:flex;justify-content:flex-end;}
560
+ .pv-refbox{display:flex;flex-direction:column;align-items:flex-start;max-width:100%;}
561
+ .pv-refbtns{display:flex;gap:6px;}
562
+ .pv-refbox .pv-msg{width:0;min-width:100%;overflow-wrap:anywhere;margin:2px 0 0;}
563
+ .pv-refbox .pv-msg:empty{display:none;}
564
+ .pv-refbtn{white-space:nowrap;flex:0 0 auto;}
565
+ .pv-refbtn:disabled{opacity:.5;cursor:not-allowed;}
566
+ .pv-sum{color:var(--text-faint);font-size:11px;font-weight:400;}
567
+ .pv-key[data-open="1"] .pv-sum{display:none;}
568
+ .pv-knote{padding:0 8px 4px 34px;margin:0;}
569
+ .pv-knote:empty{display:none;}
570
+ .pv-knote > .hint:empty{display:none;}
571
+ .pv-key[data-open="0"] .pv-models{display:none;}
572
+ .pv-mrow{border-top:1px dotted var(--border-strong);min-height:32px;}
573
+ .pv-mrow:hover,.pv-mrow:focus-within{background:var(--panel2);}
574
+ .pv-um{grid-column:1;display:flex;align-items:center;gap:0;min-width:0;}
575
+ .pv-uh,.pv-m-use{flex:0 0 44px;width:44px;display:flex;align-items:center;justify-content:center;}
576
+ .pv-m-use{min-height:32px;}
577
+ .pv-m-name{min-width:0;overflow-wrap:anywhere;font-size:12px;}
578
+ .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;}
579
+ .pv-pair > *{min-width:0;}
580
+ .pv-pair input[type=text]{width:100%;text-align:right;}
581
+ .pv-mhead .pv-tok{text-align:right;}
582
+ .pv-mhead .pv-tok,.pv-m-tok{grid-column:6;}
583
+ .pv-m-msg{grid-column:1 / -1;padding:2px 8px 2px 52px;margin:0;}
584
+ .pv-m-msg:empty{display:none;}
585
+ .pv-more{padding:3px 8px 3px 34px;border-top:1px dotted var(--border-strong);}
586
+ .pv-more:empty{display:none;}
587
+ .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;}
588
+ .pv-more.pv-failed{color:var(--amber);background:var(--amber-bg);font-size:12px;}
589
+ .pv-typed{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin-top:4px;}
590
+ .pv-typed input{flex:1 1 160px;min-width:0;max-width:260px;min-height:28px;box-sizing:border-box;}
591
+ .pv-typed .hint{flex:1 1 100%;margin:0;}
592
+ .hint.bad{color:var(--red);}
593
+ .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;}
594
+ .field label.jf-thinking input{width:auto;min-height:0;margin:0;}
505
595
  .table-wrap{overflow-x:auto;border:1px solid var(--border-strong);background:var(--panel2);max-width:100%;}
506
596
  @media (max-width: 899px){
507
597
  .money-strip{grid-template-columns:1fr 1fr;}
598
+ .keyfile-strip-main{flex:1 1 100%;}
599
+ }
600
+ @media (max-width: 1279px){
601
+ .pv-head,.pv-mhead{display:none;}
602
+ .pv-krow,.pv-mrow{display:block;padding:2px 8px;}
603
+ .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);}
604
+ .pv-cell:first-child{border-top:none;}
605
+ .pv-lbl{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);font-weight:700;}
606
+ .pv-cell.nolbl{display:flex;}
607
+ .pv-tok{text-align:left;}
608
+ .pv-grid select,.pv-grid input[type=text]{height:44px;font-size:14px;}
609
+ .pv-krow select,.pv-krow input[type=text]{min-height:44px;}
610
+ .pv-legacy{min-height:44px;gap:10px;}
611
+ .pv-legacy input,.pv-m-use input{width:24px;height:24px;}
612
+ .pv-um{min-height:44px;}
613
+ .pv-uh,.pv-m-use{min-height:44px;}
614
+ .pv-cell.pv-pair{display:grid;grid-template-columns:104px minmax(0,1fr) minmax(0,1fr);}
615
+ .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;}
616
+ .pv-fold,.pv-refbtn,.pv-more .pv-add{min-height:44px;min-width:44px;}
617
+ .pv-knote,.pv-more{padding-left:8px;}
618
+ .pv-m-msg{padding-left:8px;}
508
619
  }
509
620
  @media (max-width: 899px){
510
621
  .main{flex-direction:column;}
@@ -577,35 +688,32 @@
577
688
  <!-- build item 5 (2026-09-28) — ONE line under Model, not two:
578
689
  key + reachability collapsed into a single readiness read. -->
579
690
  <div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
691
+ <!-- P8: the job's "Thinking on" tick (drafting + the rubric judge; never the worker). The SERVER's per-line `thinking.canChange` decides. -->
692
+ <label class="jf-thinking" id="jf-thinking-label"><input type="checkbox" id="jf-thinking" data-testid="jf-thinking"> Thinking on</label>
693
+ <div class="hint" id="jf-thinking-note" data-testid="jf-thinking-note"></div>
580
694
  </div>
581
695
  <div class="field">
582
696
  <label for="jf-name">Job name</label>
583
697
  <input id="jf-name" type="text" placeholder="kebab-case, unique">
584
698
  </div>
585
699
  <div class="field">
586
- <label for="jf-goal">Goal</label>
587
- <textarea class="jf-wrap" id="jf-goal" rows="2"></textarea>
700
+ <label for="jf-job">The job</label>
701
+ <div class="nbox" data-testid="nbox-job">
702
+ <div class="gut" id="jf-job-gut" aria-hidden="true"></div>
703
+ <div class="nbox-in"><textarea class="jf-wrap" id="jf-job" rows="3" data-testid="jf-job" spellcheck="false"></textarea><div class="nbox-mir" id="jf-job-mir" aria-hidden="true"></div></div>
704
+ </div>
588
705
  </div>
589
706
  <div class="field">
590
- <label for="jf-source">Source</label>
591
- <textarea class="jf-wrap" id="jf-source" rows="1" placeholder="absolute path to a local repo"></textarea>
707
+ <label for="jf-inputs">Inputs</label>
708
+ <div class="nbox" data-testid="nbox-inputs">
709
+ <div class="gut" id="jf-inputs-gut" aria-hidden="true"></div>
710
+ <div class="nbox-in"><textarea class="jf-wrap" id="jf-inputs" rows="2" data-testid="jf-inputs" spellcheck="false"></textarea><div class="nbox-mir" id="jf-inputs-mir" aria-hidden="true"></div></div>
711
+ </div>
592
712
  </div>
593
713
  <div class="field">
594
714
  <label for="jf-dest">Destination</label>
595
715
  <textarea class="jf-wrap" id="jf-dest" rows="1" placeholder="the write fence — where in the repo this run may change files"></textarea>
596
716
  </div>
597
- <div class="field">
598
- <label for="jf-success">Success</label>
599
- <textarea class="jf-wrap" id="jf-success" rows="2"></textarea>
600
- </div>
601
- <div class="field">
602
- <label for="jf-guardrails">Guardrails</label>
603
- <textarea class="jf-wrap" id="jf-guardrails" rows="2"></textarea>
604
- </div>
605
- <div class="field">
606
- <label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
607
- <textarea class="jf-wrap" id="jf-judge" rows="2" disabled></textarea>
608
- </div>
609
717
 
610
718
  <div class="cap-row" data-testid="cap-row">
611
719
  <div class="cap-field">
@@ -750,17 +858,16 @@
750
858
  <h4 id="details-title">Job card — signed</h4>
751
859
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
752
860
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
861
+ <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
862
+ <div class="field"><label>The job</label><div class="ro-value" id="details-plan" data-testid="details-plan">—</div></div>
863
+ <div class="field"><label>Inputs</label><div class="ro-value" id="details-inputs" data-testid="details-inputs">—</div></div>
864
+ <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
865
+ <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
753
866
  <div class="field-row">
754
867
  <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
755
868
  <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
756
869
  </div>
757
- <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
758
- <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
759
- <div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
760
- <div class="field"><label>Guardrails</label><div class="ro-value" id="details-guardrails">—</div></div>
761
- <div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
762
- <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
763
- <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
870
+ <div class="field"><label>Signed</label><div class="ro-value" id="details-signed" data-testid="details-signed">—</div></div>
764
871
  <div class="hint" id="job-note"></div>
765
872
  </div>
766
873
  </section>
@@ -784,30 +891,26 @@
784
891
  </div>
785
892
 
786
893
  <div class="pane-body">
787
- <!-- PROVIDERS TAB (P4b: one row per key in the keys file that has a value) -->
894
+ <!-- PROVIDERS TAB (P8: one row per key in the keys file that has a value; layout rewritten from fwdloop src/panel/index.html@f9449e8) -->
788
895
  <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
789
- <div class="keyfile-strip" data-testid="keyfile-box">
896
+ <div class="keyfile-strip" data-testid="keys-strip">
790
897
  <div class="keyfile-strip-main">
791
- <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>
792
- <span class="hint" id="pv-keyfile-path" data-testid="keyfile-path"></span>
793
- <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
898
+ <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>
899
+ <span class="hint bad" id="keys-refusal" data-testid="keys-refusal"></span>
900
+ <span class="hint" id="keys-open-note" data-testid="keys-open-note"></span>
794
901
  </div>
795
902
  <div class="keyfile-strip-actions">
796
- <button class="btn small" type="button" id="btn-open-keys-folder" data-testid="btn-open-keys-folder">Open keys folder</button>
797
- <button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
903
+ <button class="btn small" id="open-keys-folder" type="button" data-testid="open-keys-folder" aria-label="Open keys folder">Open keys folder</button>
904
+ <button class="btn small" id="reload-keys" type="button" data-testid="reload-keys">Reload keys</button>
798
905
  </div>
799
906
  </div>
800
907
  <div class="table-wrap" data-testid="provider-table-wrap">
801
- <table class="pv-table" data-testid="provider-table">
802
- <thead>
803
- <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>
804
- </thead>
805
- <tbody id="pv-rows"></tbody>
806
- </table>
908
+ <div class="pv-grid" data-testid="provider-table">
909
+ <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>
910
+ <div id="pv-rows"></div>
911
+ </div>
807
912
  </div>
808
- <div class="hint" id="pv-price-hint" data-testid="pv-price-hint">If your vendor lists two prices, enter the higher one.</div>
809
- <div id="pv-shell" data-testid="pv-shell"></div>
810
- <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>
913
+ <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>
811
914
  </section>
812
915
 
813
916
  <!-- MONEY & LIMITS TAB -->
@@ -1991,8 +2094,8 @@
1991
2094
  var nr = "not recorded";
1992
2095
  renderJob({
1993
2096
  job: v.job, resolved: false, checkType: v.checkType || nr, model: v.model || nr,
1994
- goal: v.goal || nr, success: v.success || nr, guardrails: v.guardrails || nr,
1995
- source: nr, destination: nr, tools: nr,
2097
+ goal: v.goal || nr, plan: v.plan || null, inputs: v.inputs || null, signed: null,
2098
+ destination: nr, tools: nr,
1996
2099
  budgetUsd: (typeof v.budgetUsd === "number") ? v.budgetUsd : null,
1997
2100
  maxWallMs: (typeof v.maxWallMs === "number") ? v.maxWallMs : null,
1998
2101
  note: "imported · view only — the exported job's own spec",
@@ -2296,7 +2399,7 @@
2296
2399
  // hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
2297
2400
  // `model` (optional) = the Chat card's Model box, when the picker asks: the estimate line then counts only that worker's runs.
2298
2401
  function reuseWorkflow(runid, model){
2299
- authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&model=" + encodeURIComponent(model) : "")).then(function(r){
2402
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&" + model : "")).then(function(r){
2300
2403
  if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2301
2404
  document.getElementById("tab-chat").click();
2302
2405
  document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
@@ -3128,6 +3231,35 @@
3128
3231
  });
3129
3232
  });
3130
3233
 
3234
+ // ---- the ONE job plan drawing (P7): used by the chat's plan bubble AND the Job tab. `plan` is the server's model
3235
+ // (src/panel/jobplan.js): numbered lines with their rule, the checks under the line each serves (folded), the AI's own
3236
+ // "Not checked", the always-on guards. Only the marker is blue (--plan). Returns HTML (every text is escaped).
3237
+ function renderJobPlan(plan){
3238
+ 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>' +
3239
+ (plan.goal && !plan.older ? '<div class="jp-sum" data-testid="plan-goal"><span class="lbl">AI\'s summary</span>' + escapeXml(plan.goal) + "</div>" : "");
3240
+ var checkLine = function(c){
3241
+ 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>";
3242
+ };
3243
+ (plan.lines || []).forEach(function(l){
3244
+ h += '<div class="jl" data-testid="plan-line"><span class="num">' + escapeXml(String(l.n)) + "</span>" + escapeXml(l.text);
3245
+ if(l.rule) h += '<div class="sub">~ ' + escapeXml(l.rule) + "</div>";
3246
+ (l.checks || []).forEach(function(c){ h += checkLine(c); });
3247
+ (l.refused || []).forEach(function(r){ h += '<div class="fold" data-testid="plan-refused"><span class="mk">\u203A</span> not checked \u00B7 ' + escapeXml(r) + "</div>"; });
3248
+ h += "</div>";
3249
+ });
3250
+ if((plan.loose || []).length){
3251
+ h += '<div class="jl"><span class="num"></span>other checks' + plan.loose.map(checkLine).join("") + "</div>";
3252
+ }
3253
+ var fold = function(title, items, testid){
3254
+ if(!items || !items.length) return "";
3255
+ return '<details class="fold" data-testid="' + testid + '"><summary>' + escapeXml(title) + " \u00B7 " + items.length + "</summary><ul>" +
3256
+ items.map(function(x){ return "<li>" + escapeXml(x) + "</li>"; }).join("") + "</ul></details>";
3257
+ };
3258
+ h += fold("Not checked (the AI's own reading)", plan.notChecked, "plan-notchecked");
3259
+ h += fold("Always on", plan.alwaysOn, "plan-alwayson");
3260
+ return h + "</div>";
3261
+ }
3262
+
3131
3263
  // ---- Job tab ----
3132
3264
  function renderJob(job){
3133
3265
  var empty = document.getElementById("job-empty");
@@ -3143,10 +3275,14 @@
3143
3275
  var descField = document.getElementById("details-description-field");
3144
3276
  if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
3145
3277
  else { descField.hidden = true; }
3146
- document.getElementById("details-goal").textContent = job.goal;
3147
- document.getElementById("details-success").textContent = job.success;
3148
- document.getElementById("details-guardrails").textContent = job.guardrails;
3149
- document.getElementById("details-source").textContent = job.source;
3278
+ // P7: the numbered job lines, their rules and checks — the SAME drawing the chat's plan bubble uses (renderJobPlan)
3279
+ var plan = job.plan || {lines: [{n: 1, text: job.goal || "not recorded", rule: "", checks: [], refused: []}], loose: [], notChecked: [], alwaysOn: [], older: true};
3280
+ document.getElementById("details-plan").innerHTML = renderJobPlan(plan);
3281
+ var ins = Array.isArray(job.inputs) ? job.inputs : [];
3282
+ document.getElementById("details-inputs").innerHTML = ins.length
3283
+ ? ins.map(function(i){ return '<div><span class="num-in">' + escapeXml(String(i.n)) + "</span> " + escapeXml(i.label + ": " + i.value) + "</div>"; }).join("")
3284
+ : "not recorded";
3285
+ document.getElementById("details-signed").textContent = job.signed || "not recorded";
3150
3286
  document.getElementById("details-dest").textContent = job.destination;
3151
3287
  document.getElementById("details-tools").textContent = job.tools;
3152
3288
  document.getElementById("details-cap-money").textContent = panelMoney(job.budgetUsd);
@@ -3218,7 +3354,7 @@
3218
3354
  // drives both fetches — never two independent timers that could race
3219
3355
  // each other. ----
3220
3356
  //
3221
- // F195 (docs/logs/FINDINGS.md): /api/runs re-replays every spine on
3357
+ // F195 (docs/wiki/FINDINGS.md): /api/runs re-replays every spine on
3222
3358
  // every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
3223
3359
  // "refresh less often, up to 30s at most, choose the lesser when
3224
3360
  // possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
@@ -3414,7 +3550,7 @@
3414
3550
  document.getElementById("btn-settings").addEventListener("click", function(){
3415
3551
  mainView.hidden = true;
3416
3552
  settingsView.hidden = false;
3417
- loadProviders();
3553
+ loadProviders(true);
3418
3554
  loadMoney();
3419
3555
  });
3420
3556
  document.getElementById("settings-back").addEventListener("click", function(){
@@ -3423,7 +3559,7 @@
3423
3559
  });
3424
3560
  document.getElementById("tab-money").addEventListener("click", loadMoney);
3425
3561
 
3426
- // ---- Providers tab (P4b): one row per key in the keys file that has a value ----
3562
+ // ---- Providers tab (P8): one block per key in the keys file that has a value; rewritten from fwdloop src/panel/index.html@f9449e8 ----
3427
3563
  var pvRows = document.getElementById("pv-rows");
3428
3564
  var pvNote = document.getElementById("pv-note");
3429
3565
  var PV_NOTE_DEFAULT = pvNote.textContent;
@@ -3433,104 +3569,316 @@
3433
3569
  if(n >= 1e3) return (n / 1e3).toFixed(1) + "k";
3434
3570
  return String(n);
3435
3571
  }
3436
- function loadProviders(){
3437
- authorGet("/api/settings/providers").then(function(r){
3438
- 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; }
3439
- var kf = r.keysFile;
3440
- document.getElementById("pv-keyfile-path").textContent = "";
3441
- document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
3442
- pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
3443
- var e = escapeXml(row.envName);
3444
- var shapeOpts = r.shapes.map(function(s){
3445
- return '<option value="' + escapeXml(s.id) + '"' + (s.id === row.shape ? " selected" : "") + ">" + escapeXml(s.label) + "</option>";
3446
- }).join("");
3447
- var bal = row.balance.kind === "note"
3448
- ? '<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>'
3449
- : (row.balance.kind === "fetch" ? '<span class="pv-balance-fetch" data-env="' + e + '">…</span>' : "—");
3450
- return '<tr data-testid="provider-row-' + e + '" data-env="' + e + '">'
3451
- + "<td><code>" + e + "</code>"
3452
- + '<div class="hint" data-testid="pv-status-' + e + '">' + escapeXml(row.keyStatus) + "</div></td>"
3453
- + '<td><input type="text" class="pv-name" data-testid="pv-name-' + e + '" placeholder="model id" value="' + escapeXml(row.name) + '"></td>'
3454
- + '<td><select class="pv-shape" data-testid="pv-shape-' + e + '">' + shapeOpts + "</select></td>"
3455
- + '<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>'
3456
- + '<td class="pv-test-cell">' + (row.canTest
3457
- ? '<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>'
3458
- : '<span class="hint">not available</span>') + "</td>"
3459
- + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
3460
- + "<td>" + bal + "</td>"
3461
- + '<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>'
3462
- + '<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>';
3463
- }).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>';
3464
- pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
3465
- authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
3466
- fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
3467
- }).catch(function(){ fetchEl.textContent = "— unavailable"; });
3468
- });
3469
- // a key exported only in the shell has no row: say so, by NAME, once per name
3470
- document.getElementById("pv-shell").innerHTML = (r.shellOnly || []).map(function(n){
3471
- 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>";
3472
- }).join("");
3473
- pvNote.textContent = r.configProblem ? r.configProblem : PV_NOTE_DEFAULT;
3572
+ // A hint line: plain text, red when it is a refusal.
3573
+ function setNote(el, bad, text){
3574
+ el.className = "hint" + (bad ? " bad" : "");
3575
+ el.textContent = text || "";
3576
+ }
3577
+ // The server's own plain sentence (`say`); the page never shows a code or an HTTP status.
3578
+ function settingsRefusal(r){
3579
+ var b = r && r.body;
3580
+ if(b && typeof b.say === "string" && b.say) return b.say;
3581
+ return "Settings cannot do that. Keys are typed into your keys file by hand.";
3582
+ }
3583
+ function networkText(){ return "Could not reach the panel, so nothing is confirmed. Check it is still running."; }
3584
+ // `list` true = also ask each provider for its model list (free): on page open and on Reload keys. A save repaints without asking again.
3585
+ function loadProviders(list){
3586
+ return authorGet("/api/settings/providers").then(function(d){
3587
+ if(!d || !d.ok){ setNote(document.getElementById("keys-refusal"), true, "Could not read your providers. Reload the page."); return; }
3588
+ paintProviders(d, list === true);
3474
3589
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
3475
- }).catch(function(){ pvNote.textContent = "could not read providers"; });
3590
+ }, function(){ setNote(document.getElementById("keys-refusal"), true, networkText()); });
3591
+ }
3592
+ // Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
3593
+ document.getElementById("open-keys-folder").addEventListener("click", function(){
3594
+ var out = document.getElementById("keys-open-note");
3595
+ out.textContent = "";
3596
+ authorPost("/api/settings/open-keys-folder", {}).then(function(r){
3597
+ var b = r.body || {};
3598
+ if(b.ok === true) out.textContent = b.opened ? "" : "Could not open it here. Your keys folder: " + (b.path || "(unknown)");
3599
+ else out.textContent = settingsRefusal(r);
3600
+ }, function(){ out.textContent = networkText(); });
3601
+ });
3602
+ document.getElementById("reload-keys").addEventListener("click", function(){ loadProviders(true); });
3603
+
3604
+ // 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,
3605
+ // 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
3606
+ // 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).
3607
+ 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
3608
+ var typedOpen = {}; // slot -> true while "+ type a model name" is open
3609
+ function buildProviderRow(row){
3610
+ var key = document.createElement("div");
3611
+ key.className = "pv-key";
3612
+ key.setAttribute("data-slot", row.slot);
3613
+ key.setAttribute("data-testid", "provider-row-" + row.slot);
3614
+ key.innerHTML =
3615
+ '<div class="pv-krow">' +
3616
+ '<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>' +
3617
+ '<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>' +
3618
+ '<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>' +
3619
+ '<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>' +
3620
+ '<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>' +
3621
+ '<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>' +
3622
+ '</div>' +
3623
+ '<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>' +
3624
+ '<div class="pv-models" data-models-for="' + escapeXml(row.slot) + '" data-testid="models-row-' + escapeXml(row.slot) + '">' +
3625
+ '<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>' +
3626
+ '<div data-f="models"></div>' +
3627
+ '<div class="pv-more"><button class="pv-add" type="button" data-act="typed-open" data-testid="model-typed-open">+ type a model name</button>' +
3628
+ '<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"> ' +
3629
+ '<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>' +
3630
+ '</div>';
3631
+ key.querySelector("code").textContent = row.envVar;
3632
+ key.querySelectorAll("input").forEach(function(inp){
3633
+ inp.addEventListener("input", function(){ inp.setAttribute("data-dirty", "1"); });
3634
+ });
3635
+ key.querySelector("select").addEventListener("change", function(ev){ ev.target.setAttribute("data-dirty", "1"); });
3636
+ return key;
3476
3637
  }
3477
- // The row's POST body, or null when exactly ONE price box is filled: a price is both boxes or neither (the server
3478
- // refuses a half-set one), so nothing in that row saves. This runs only when a save would happen (the row's
3479
- // change event), so typing the first box never flashes the message; it shows when the person leaves that box.
3480
- var PV_HALF_PRICE = "Fill both price boxes or clear both.";
3481
- function pvRowBody(env, v){
3482
- var pin = String(v.priceIn).trim();
3483
- var pout = String(v.priceOut).trim();
3484
- if((pin === "") !== (pout === "")) return null;
3485
- return { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl, priceInPerM: pin, priceOutPerM: pout };
3638
+ // 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.
3639
+ function buildModelLine(slot, m){
3640
+ var d = document.createElement("div");
3641
+ d.className = "pv-mrow";
3642
+ d.setAttribute("data-model", m.model);
3643
+ d.setAttribute("data-testid", "model-line");
3644
+ d.innerHTML =
3645
+ '<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>' +
3646
+ '<div class="pv-cell pv-pair"><label><input type="text" inputmode="decimal" data-mf="inPerM" data-testid="model-in" autocomplete="off"></label>' +
3647
+ '<label><input type="text" inputmode="decimal" data-mf="outPerM" data-testid="model-out" autocomplete="off"></label></div>' +
3648
+ '<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>' +
3649
+ '<div class="hint pv-msg pv-m-msg" data-f="msg-model" data-testid="model-msg"></div>';
3650
+ d.querySelector(".pv-m-name").textContent = m.model;
3651
+ d.querySelector("[data-mf=use]").setAttribute("aria-label", slot + " " + m.model + " use");
3652
+ d.querySelector("[data-mf=inPerM]").setAttribute("aria-label", m.model + " price in per 1M tokens");
3653
+ d.querySelector("[data-mf=outPerM]").setAttribute("aria-label", m.model + " price out per 1M tokens");
3654
+ d.querySelectorAll("input[type=text]").forEach(function(inp){
3655
+ inp.addEventListener("input", function(){ inp.setAttribute("data-dirty", "1"); });
3656
+ });
3657
+ return d;
3486
3658
  }
3487
- pvRows.addEventListener("change", function(e){
3488
- var t = e.target;
3489
- var tr = t.closest("tr");
3490
- if(!tr) return;
3491
- if(t.classList.contains("pv-balance")){
3492
- authorPost("/api/settings/providers/balance-note", { usd: t.value.trim() === "" ? null : t.value.trim() }).then(function(r){
3493
- pvNote.textContent = r.body && r.body.ok ? "Saved — balance note." : ((r.body && r.body.error) || "could not save");
3494
- }).catch(function(){ pvNote.textContent = "could not save"; });
3659
+ // 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.
3660
+ var listed = {};
3661
+ var lastData = null;
3662
+ function linesFor(row){
3663
+ var l = listed[row.slot];
3664
+ var have = {};
3665
+ row.models.forEach(function(m){ have[m.model] = true; });
3666
+ var extra = (l && l.models ? l.models : []).filter(function(id){ return !have[id]; }).map(function(id){ return { model: id, use: false }; });
3667
+ return row.models.concat(extra);
3668
+ }
3669
+ // "3 models · none ticked", "2 models · 1 ticked"; a list that failed with nothing saved says so.
3670
+ function sumText(row){
3671
+ var l = listed[row.slot];
3672
+ var lines = linesFor(row);
3673
+ if(l && l.why && lines.length === 0) return "failed — type a name";
3674
+ var ticked = lines.filter(function(m){ return m.use === true; }).length;
3675
+ return lines.length + (lines.length === 1 ? " model" : " models") + " · " + (ticked === 0 ? "none ticked" : ticked + " ticked");
3676
+ }
3677
+ function paintModels(row, mr){
3678
+ var box = mr.querySelector("[data-f=models]");
3679
+ var lines = linesFor(row);
3680
+ var l = listed[row.slot];
3681
+ var typed = mr.querySelector("[data-f=typed]");
3682
+ typed.hidden = typedOpen[row.slot] !== true;
3683
+ var wanted = lines.map(function(m){ return m.model; });
3684
+ Array.prototype.slice.call(box.querySelectorAll(".pv-mrow")).forEach(function(el){
3685
+ if(wanted.indexOf(el.getAttribute("data-model")) < 0) box.removeChild(el);
3686
+ });
3687
+ var note = box.querySelector("[data-f=models-note]");
3688
+ lines.forEach(function(m){
3689
+ var line = Array.prototype.find.call(box.querySelectorAll(".pv-mrow"), function(el){ return el.getAttribute("data-model") === m.model; });
3690
+ if(!line){ line = buildModelLine(row.slot, m); box.insertBefore(line, note); }
3691
+ var use = line.querySelector("[data-mf=use]");
3692
+ use.checked = m.use === true;
3693
+ ["inPerM", "outPerM"].forEach(function(f){
3694
+ var inp = line.querySelector('[data-mf="' + f + '"]');
3695
+ inp.placeholder = "—";
3696
+ if(inp.getAttribute("data-dirty") !== "1") inp.value = typeof m[f] === "number" ? String(m[f]) : "";
3697
+ });
3698
+ line.querySelector("[data-f=model-tokens]").textContent = typeof m.tokens === "number" ? tokensText(m.tokens) : "—";
3699
+ });
3700
+ note = box.querySelector("[data-f=models-note]");
3701
+ 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); }
3702
+ 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." : ""));
3703
+ note.className += " pv-more" + (l && l.why ? " pv-failed" : "");
3704
+ box.parentNode.querySelector(".pv-mhead").hidden = lines.length === 0;
3705
+ }
3706
+ var STATUS_WORD = { "set in file": "set in file", "found in shell": "found in shell", "not set": "not set", "refused": "refused — see above" };
3707
+ // The Refresh button: "[ ⟳ Refresh ]", disabled "[ ⟳ refreshing… ]" while the free list call is out.
3708
+ function setRefreshBtn(key, pending){
3709
+ var b = key.querySelector("[data-act=refresh-models]");
3710
+ b.disabled = pending === true;
3711
+ b.innerHTML = pending === true ? "&#10227; refreshing&hellip;" : "&#10227; Refresh";
3712
+ }
3713
+ // 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.
3714
+ function testKey(slot, key){
3715
+ var b = key.querySelector("[data-act=test-key]");
3716
+ var msg = key.querySelector("[data-f=msg-test]");
3717
+ var row = lastData && lastData.rows.filter(function(r){ return r.slot === slot; })[0];
3718
+ if(row && row.baseUrl === ""){
3719
+ setNote(msg, true, "Type a Base URL for " + row.envVar + " in Settings.");
3495
3720
  return;
3496
3721
  }
3497
- 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;
3498
- var env = tr.getAttribute("data-env");
3499
- var rowBody = pvRowBody(env, {
3500
- name: tr.querySelector(".pv-name").value,
3501
- shape: tr.querySelector(".pv-shape").value,
3502
- baseUrl: tr.querySelector(".pv-url").value,
3503
- priceIn: tr.querySelector(".pv-price-in").value,
3504
- priceOut: tr.querySelector(".pv-price-out").value,
3722
+ b.disabled = true;
3723
+ b.innerHTML = "testing&hellip;";
3724
+ setNote(msg, false, "");
3725
+ authorPost("/api/settings/test", { slot: slot }).then(function(r){
3726
+ var d = r.body;
3727
+ if(d && d.ok === true) setNote(msg, false, "OK \u00b7 " + d.ms + " ms");
3728
+ else setNote(msg, true, d && d.why ? d.why : settingsRefusal(r));
3729
+ }, function(){ setNote(msg, true, networkText()); }).then(function(){
3730
+ b.disabled = false;
3731
+ b.innerHTML = "Test";
3505
3732
  });
3506
- if(!rowBody){ pvNote.textContent = PV_HALF_PRICE; return; }
3507
- authorPost("/api/settings/providers/row", rowBody).then(function(r){
3508
- pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
3509
- // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
3510
- document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
3511
- }).catch(function(){ pvNote.textContent = "could not save"; });
3512
- });
3513
- pvRows.addEventListener("click", function(e){
3514
- var t = e.target;
3515
- if(!t.classList || !t.classList.contains("pv-test")) return;
3516
- var out = t.parentNode.querySelector(".pv-test-result");
3517
- out.textContent = "testing…"; t.disabled = true;
3518
- authorPost("/api/settings/providers/test", { envName: t.closest("tr").getAttribute("data-env") }).then(function(r){
3519
- var b = r.body || {};
3520
- out.textContent = b.reachable ? ("OK" + (b.ms !== null && b.ms !== undefined ? " · " + b.ms + " ms" : "")) : ("failed — " + (b.note || b.status || b.error || "no answer"));
3521
- }).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
3733
+ }
3734
+ function refreshModels(slot, tr){
3735
+ listed[slot] = { pending: true };
3736
+ setRefreshBtn(tr, true);
3737
+ return authorPost("/api/settings/models", { slot: slot }).then(function(r){
3738
+ var b = r.body;
3739
+ listed[slot] = b && b.ok ? { models: b.models } : { why: (b && b.why) ? b.why : settingsRefusal(r) };
3740
+ }, function(){ listed[slot] = { why: networkText() }; }).then(function(){
3741
+ if(lastData) paintProviders(lastData, false);
3742
+ });
3743
+ }
3744
+ function paintProviders(data, list){
3745
+ lastData = data;
3746
+ var path = data.keysFile && data.keysFile.path ? data.keysFile.path : "~/.config/bareloop/.env";
3747
+ document.getElementById("keys-path").textContent = path;
3748
+ setNote(document.getElementById("keys-refusal"), true, data.keysFile && data.keysFile.refusal);
3749
+ setNote(pvNote, !!data.configProblem, data.configProblem || PV_NOTE_DEFAULT);
3750
+ data.rows.forEach(function(row){
3751
+ var key = pvRows.querySelector('.pv-key[data-slot="' + row.slot + '"]');
3752
+ if(!key){ key = buildProviderRow(row); pvRows.appendChild(key); }
3753
+ if(folds[row.slot] === undefined) folds[row.slot] = row.models.some(function(m){ return m.use === true; });
3754
+ key.setAttribute("data-open", folds[row.slot] ? "1" : "0");
3755
+ var fold = key.querySelector("[data-act=fold]");
3756
+ fold.textContent = folds[row.slot] ? "▾" : "▸";
3757
+ fold.setAttribute("aria-expanded", folds[row.slot] ? "true" : "false");
3758
+ fold.setAttribute("aria-label", (folds[row.slot] ? "Fold " : "Unfold ") + row.label + " models");
3759
+ paintModels(row, key.querySelector(".pv-models"));
3760
+ setRefreshBtn(key, !!(listed[row.slot] && listed[row.slot].pending));
3761
+ key.querySelector("[data-f=sum]").textContent = sumText(row);
3762
+ var st = key.querySelector("[data-f=status]");
3763
+ var word = STATUS_WORD[row.keyStatus] || row.keyStatus;
3764
+ st.className = row.keyStatus === "set in file" || row.keyStatus === "found in shell" ? "pv-status pv-ok" : "pv-status hint";
3765
+ st.textContent = st.className.indexOf("pv-ok") >= 0 ? "✓" : word;
3766
+ st.setAttribute("title", word);
3767
+ if(st.className.indexOf("pv-ok") >= 0) st.setAttribute("aria-label", word); else st.removeAttribute("aria-label");
3768
+ var legacyIn = key.querySelector('[data-prov=legacyMaxTokens]');
3769
+ if(document.activeElement !== legacyIn) legacyIn.checked = row.legacyMaxTokens === true;
3770
+ key.querySelector("[data-f=legacy-word]").textContent = legacyIn.checked ? "on" : "off";
3771
+ // 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)
3772
+ var sel = key.querySelector("[data-prov=shape]");
3773
+ if(sel.options.length !== data.shapes.length || data.shapes.some(function(sh, i){ return sel.options[i].value !== sh.id; })){
3774
+ sel.textContent = "";
3775
+ data.shapes.forEach(function(sh){ var o = document.createElement("option"); o.value = sh.id; o.textContent = sh.label; sel.appendChild(o); });
3776
+ }
3777
+ if(sel.getAttribute("data-dirty") !== "1") sel.value = row.shape;
3778
+ var urlIn = key.querySelector("[data-prov=baseUrl]");
3779
+ if(urlIn.getAttribute("data-dirty") !== "1" && document.activeElement !== urlIn) urlIn.value = row.baseUrl;
3780
+ key.querySelector("[data-f=tokens]").textContent = tokensText(row.tokens);
3781
+ });
3782
+ // rows that left the keys file leave the page
3783
+ Array.prototype.slice.call(pvRows.querySelectorAll(".pv-key")).forEach(function(key){
3784
+ if(!data.rows.some(function(r){ return r.slot === key.getAttribute("data-slot"); })) pvRows.removeChild(key);
3785
+ });
3786
+ if(list) data.rows.forEach(function(row){
3787
+ var key = pvRows.querySelector('.pv-key[data-slot="' + row.slot + '"]');
3788
+ if(row.baseUrl === ""){ listed[row.slot] = { why: "Type a Base URL for " + row.slot + " in Settings." }; paintProviders(lastData, false); }
3789
+ else refreshModels(row.slot, key);
3790
+ });
3791
+ }
3792
+ // 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
3793
+ // a refusal (nothing saved, the typed value stays so the person can fix it).
3794
+ function saveEndpoint(key){
3795
+ var msg = key.querySelector("[data-f=msg-edit]");
3796
+ var payload = { slot: key.getAttribute("data-slot"), shape: key.querySelector("[data-prov=shape]").value, baseUrl: key.querySelector("[data-prov=baseUrl]").value.trim() };
3797
+ authorPost("/api/settings/provider", payload).then(function(r){
3798
+ if(r.body && r.body.ok){
3799
+ key.querySelector("[data-prov=shape]").removeAttribute("data-dirty");
3800
+ key.querySelector("[data-prov=baseUrl]").removeAttribute("data-dirty");
3801
+ setNote(msg, true, "");
3802
+ loadProviders();
3803
+ } else setNote(msg, true, settingsRefusal(r));
3804
+ }, function(){ setNote(msg, true, networkText()); });
3805
+ }
3806
+ pvRows.addEventListener("click", function(ev){
3807
+ var b = ev.target.closest && ev.target.closest("button[data-act]");
3808
+ if(!b) return;
3809
+ var key = b.closest(".pv-key");
3810
+ var slot = key.getAttribute("data-slot");
3811
+ var act = b.getAttribute("data-act");
3812
+ if(act === "refresh-models") refreshModels(slot, key);
3813
+ else if(act === "test-key") testKey(slot, key);
3814
+ else if(act === "fold"){ folds[slot] = !folds[slot]; paintProviders(lastData, false); }
3815
+ else if(act === "typed-open"){ typedOpen[slot] = !typedOpen[slot]; paintProviders(lastData, false); }
3816
+ else if(act === "add-model") addTypedModel(slot, b.closest("[data-f=typed]"));
3522
3817
  });
3523
- document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
3524
- // Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
3525
- document.getElementById("btn-open-keys-folder").addEventListener("click", function(){
3526
- var out = document.getElementById("pv-keyfile-path");
3527
- authorPost("/api/settings/open-keys-folder", {}).then(function(r){
3528
- var b = r.body || {};
3529
- out.textContent = b.ok && b.opened ? "" : ("Could not open it here. Your keys folder: " + (b.path || "(unknown)"));
3530
- }).catch(function(){ out.textContent = "Could not open it here — the keys folder is ~/.config/bareloop"; });
3818
+ // 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.
3819
+ pvRows.addEventListener("change", function(ev){
3820
+ var inp = ev.target;
3821
+ if(!inp.getAttribute) return;
3822
+ if(inp.getAttribute("data-prov") === "legacyMaxTokens"){ saveProvider(inp.closest(".pv-key"), inp); return; }
3823
+ if(inp.getAttribute("data-prov") === "shape" || inp.getAttribute("data-prov") === "baseUrl"){ saveEndpoint(inp.closest(".pv-key")); return; }
3824
+ if(!inp.getAttribute("data-mf")) return;
3825
+ saveModel(inp.closest(".pv-models") ? inp.closest(".pv-models").getAttribute("data-models-for") : "", inp.closest(".pv-mrow"));
3531
3826
  });
3827
+ // A box the person typed: empty = back to the default (null); a number is sent as a number; anything else is
3828
+ // sent as the text typed, so the server refuses it by naming the box.
3829
+ function priceValue(text){
3830
+ var t = String(text).trim();
3831
+ if(t === "") return null;
3832
+ var n = Number(t);
3833
+ return isFinite(n) ? n : t;
3834
+ }
3835
+ // One model line per save: {slot, model, use, inPerM, outPerM}. A refusal is the line's hint (the server's one rule),
3836
+ // and nothing was saved: the repaint puts the tick back.
3837
+ function saveModel(slot, line){
3838
+ var msg = line.querySelector("[data-f=msg-model]");
3839
+ var payload = {
3840
+ slot: slot,
3841
+ model: line.getAttribute("data-model"),
3842
+ use: line.querySelector("[data-mf=use]").checked,
3843
+ inPerM: priceValue(line.querySelector("[data-mf=inPerM]").value),
3844
+ outPerM: priceValue(line.querySelector("[data-mf=outPerM]").value)
3845
+ };
3846
+ authorPost("/api/settings/model", payload).then(function(r){
3847
+ if(r.body && r.body.ok){
3848
+ line.querySelectorAll("input[type=text]").forEach(function(inp){ inp.removeAttribute("data-dirty"); });
3849
+ loadProviders();
3850
+ setNote(msg, false, "saved — applies from the next model call");
3851
+ } else { setNote(msg, true, settingsRefusal(r)); loadProviders(); }
3852
+ }, function(){ setNote(msg, true, networkText()); });
3853
+ }
3854
+
3855
+ // 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.
3856
+ function addTypedModel(slot, box){
3857
+ var inp = box.querySelector("input");
3858
+ var msg = box.querySelector("[data-f=typed-msg]");
3859
+ authorPost("/api/settings/model", { slot: slot, model: inp.value.trim(), use: false }).then(function(r){
3860
+ if(r.body && r.body.ok){ inp.value = ""; setNote(msg, false, "added"); loadProviders(); }
3861
+ else setNote(msg, true, settingsRefusal(r));
3862
+ }, function(){ setNote(msg, true, networkText()); });
3863
+ }
3864
+
3865
+ // The legacy tick saves alone: {slot, legacyMaxTokens}. A refusal is the cell's hint line.
3866
+ function saveProvider(key, inp){
3867
+ var field = inp.getAttribute("data-prov");
3868
+ var msg = key.querySelector("[data-f=msg-" + field + "]");
3869
+ var payload = { slot: key.getAttribute("data-slot") };
3870
+ payload[field] = inp.checked;
3871
+ authorPost("/api/settings/provider", payload).then(function(r){
3872
+ if(r.body && r.body.ok){
3873
+ inp.removeAttribute("data-dirty");
3874
+ loadProviders();
3875
+ setNote(msg, false, "saved — applies from the next model call");
3876
+ } else setNote(msg, true, settingsRefusal(r));
3877
+ }, function(){ setNote(msg, true, networkText()); });
3878
+ }
3879
+
3880
+ document.getElementById("tab-providers").addEventListener("click", function(){ loadProviders(); });
3532
3881
  wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
3533
- document.getElementById("tab-providers").addEventListener("click", loadProviders);
3534
3882
  // Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
3535
3883
  limitInput.addEventListener("change", function(){
3536
3884
  var text = limitInput.value.trim();
@@ -3564,6 +3912,9 @@
3564
3912
  var actionErrEl = document.getElementById("chat-action-error");
3565
3913
  var moneyCapInput = document.getElementById("jf-cap-money");
3566
3914
  var modelSelect = document.getElementById("jf-model");
3915
+ var thinkingBox = document.getElementById("jf-thinking");
3916
+ var thinkingNote = document.getElementById("jf-thinking-note");
3917
+ var lineThinking = {}; // option value -> the server's {support, canChange, say} for that Model line
3567
3918
  var modelStatusEl = document.getElementById("jf-model-status");
3568
3919
  var progressRow = document.getElementById("chat-progress-row");
3569
3920
 
@@ -3594,10 +3945,10 @@
3594
3945
  var sfNote = document.getElementById("chat-startfrom-note");
3595
3946
  var originLine = document.getElementById("chat-origin-line");
3596
3947
  // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3597
- var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3948
+ var LOCKED_IDS = ["jf-name", "jf-job"];
3598
3949
  // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3599
3950
  // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3600
- var OPEN_IDS = ["jf-source", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3951
+ var OPEN_IDS = ["jf-inputs", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3601
3952
  var reuseOn = false;
3602
3953
  // Resume mode (hamr 2026-10-07): the card shows the signed job of one run, every box locked except the two caps.
3603
3954
  // `resumeRun` = {runid, noTime} while it is on, else null. Never set together with a drafting session or a reuse pick.
@@ -3608,19 +3959,22 @@
3608
3959
  var resuming = resumeRun !== null;
3609
3960
  var setLock = function(id, on){ var el = document.getElementById(id); el.readOnly = on; el.classList.toggle("locked", on); };
3610
3961
  LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live || resuming); });
3611
- OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1)); });
3612
- document.getElementById("jf-model").disabled = live || resuming;
3962
+ // the one exception on a resume card: an older spec that names no model leaves the Model box open (picked at resume)
3963
+ var modelOpen = resuming && resumeRun.pickModel === true;
3964
+ OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1 && !(modelOpen && id === "jf-model"))); });
3965
+ document.getElementById("jf-model").disabled = live || (resuming && !modelOpen);
3613
3966
  // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3614
3967
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3615
3968
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3616
- if(!reuseOn && !live && !resuming){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3969
+ syncThinking();
3617
3970
  }
3618
3971
  function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3619
3972
  function clearStartFrom(keepBoxes){
3620
3973
  // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes — except when a Check type
3621
3974
  // radio click leaves reuse (keepBoxes): a radio never blanks what the person has on the card
3622
3975
  if(startFrom && !keepBoxes){
3623
- ["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 = ""; });
3976
+ CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
3977
+ NUMBERED_IDS.forEach(gutterSync);
3624
3978
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3625
3979
  }
3626
3980
  startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
@@ -3632,7 +3986,7 @@
3632
3986
  // ---- Reuse workflow picker (hamr 2026-10-06): Check type is ONE radio group of three; "Reuse workflow" shows a search
3633
3987
  // box over the jobs that have a green local run (GET /api/author/reuse-jobs), a pick fills the card through the SAME
3634
3988
  // path the Run tab's button uses (reuseWorkflow -> bareloop-reuse -> applyReuse), then a picked box with `change`.
3635
- var CARD_BOX_IDS = ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"];
3989
+ var CARD_BOX_IDS = ["jf-name", "jf-job", "jf-inputs", "jf-dest", "jf-cap-money", "jf-cap-time"];
3636
3990
  var rwBox = document.getElementById("rw");
3637
3991
  var rwSearch = document.getElementById("rw-search");
3638
3992
  var rwQ = document.getElementById("rw-q");
@@ -3649,9 +4003,11 @@
3649
4003
  }
3650
4004
  function setVerdict(v){
3651
4005
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === v); });
4006
+ setJobPlaceholders(v);
3652
4007
  }
3653
4008
  function clearCardBoxes(){
3654
4009
  CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
4010
+ NUMBERED_IDS.forEach(gutterSync);
3655
4011
  document.getElementById("jf-cap-note").textContent = "";
3656
4012
  }
3657
4013
  // ONE function shows/hides the picker: visible only while the radio is on Reuse workflow; the search box before a
@@ -3688,7 +4044,7 @@
3688
4044
  }
3689
4045
  }
3690
4046
  function loadReuseJobs(){
3691
- authorGet("/api/author/reuse-jobs" + (modelSelect.value ? "?model=" + encodeURIComponent(modelSelect.value) : "")).then(function(r){
4047
+ authorGet("/api/author/reuse-jobs" + (modelSelect.value ? "?" + pickQuery(modelSelect.value) : "")).then(function(r){
3692
4048
  rwFailed = !(r && r.ok);
3693
4049
  rwJobs = rwFailed ? null : r.jobs;
3694
4050
  renderReuseList();
@@ -3699,14 +4055,13 @@
3699
4055
  function openPicker(){
3700
4056
  rwQ.value = ""; rwHl = 0;
3701
4057
  rwJobs = null; rwFailed = false; // a fresh open shows "Loading…", never the previous list
3702
- document.getElementById("jf-judge").disabled = true;
3703
4058
  renderPicker();
3704
4059
  renderReuseList();
3705
4060
  loadReuseJobs();
3706
4061
  refreshStartEnabled();
3707
4062
  rwQ.focus();
3708
4063
  }
3709
- function pickReuseJob(j){ if(j) reuseWorkflow(j.runid, modelSelect.value); }
4064
+ function pickReuseJob(j){ if(j) reuseWorkflow(j.runid, pickQuery(modelSelect.value)); }
3710
4065
  rwQ.addEventListener("input", function(){ rwHl = 0; renderReuseList(); });
3711
4066
  rwQ.addEventListener("keydown", function(e){
3712
4067
  if(e.key === "ArrowDown"){ rwHl = Math.min(rwHl + 1, rwShown.length - 1); renderReuseList(); e.preventDefault(); }
@@ -3726,7 +4081,9 @@
3726
4081
  document.getElementById("rw-change").addEventListener("click", unpickReuse);
3727
4082
  // leaving resume mode: the title, the hidden drafting chrome, the time box and the resume box all come back
3728
4083
  function exitResumeMode(){
4084
+ var filtered = resumeRun !== null && !!resumeRun.worker;
3729
4085
  resumeRun = null;
4086
+ if(filtered){ renderModelOptions(); }
3730
4087
  originLine.classList.remove("res");
3731
4088
  document.getElementById("job-card-title").textContent = "Job card \u2014 draft";
3732
4089
  document.getElementById("chat-resume-box").hidden = true;
@@ -3740,7 +4097,7 @@
3740
4097
  card.hidden = false;
3741
4098
  originLine.hidden = true;
3742
4099
  exitResumeMode();
3743
- thread.innerHTML = "";
4100
+ thread.innerHTML = ""; lastMsgSig = null;
3744
4101
  errEl.textContent = "";
3745
4102
  actionErrEl.textContent = "";
3746
4103
  sessionId = null;
@@ -3770,8 +4127,8 @@
3770
4127
  function resetCard(){
3771
4128
  clearCardBoxes();
3772
4129
  setVerdict("deterministic");
3773
- document.getElementById("jf-judge").disabled = true;
3774
4130
  modelSelect.selectedIndex = 0;
4131
+ setThinkingTick(false);
3775
4132
  msgInput.value = "";
3776
4133
  rwQ.value = "";
3777
4134
  lastPhase = null;
@@ -3797,14 +4154,14 @@
3797
4154
  return {
3798
4155
  // on "Reuse workflow" the type is the picked job's own, never the radio's
3799
4156
  checkType: (verdictValue() === "reuse" ? reuseCheckType : verdictValue()) === "rubric" ? "rubric" : "deterministic",
3800
- model: document.getElementById("jf-model").value,
4157
+ provider: pickParts(document.getElementById("jf-model").value).provider,
4158
+ model: pickParts(document.getElementById("jf-model").value).model,
3801
4159
  jobName: document.getElementById("jf-name").value.trim(),
3802
- goal: document.getElementById("jf-goal").value.trim(),
3803
- source: document.getElementById("jf-source").value.trim(),
4160
+ jobText: document.getElementById("jf-job").value.trim(),
4161
+ inputs: document.getElementById("jf-inputs").value.trim(),
3804
4162
  destination: document.getElementById("jf-dest").value.trim(),
3805
- success: document.getElementById("jf-success").value.trim(),
3806
- guardrails: document.getElementById("jf-guardrails").value.trim(),
3807
- judgeExamples: document.getElementById("jf-judge").value.trim(),
4163
+ // the "Thinking on" tick: sent as a boolean; the server judges it per line and signs it only when ticked
4164
+ thinking: thinkingBox.checked === true,
3808
4165
  capUsd: moneyCap,
3809
4166
  maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
3810
4167
  };
@@ -3822,6 +4179,7 @@
3822
4179
  var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
3823
4180
  // on Reuse workflow nothing starts before a job is picked
3824
4181
  startOk = capOk && !sessionLive && keyOk && (verdictValue() !== "reuse" || startFrom !== null);
4182
+ if(typeof syncResumeSign === "function"){ syncResumeSign(); }
3825
4183
  syncCardLock();
3826
4184
  renderMain();
3827
4185
  refreshClearBtn();
@@ -3853,7 +4211,7 @@
3853
4211
  setVerdict(v);
3854
4212
  renderPicker();
3855
4213
  }
3856
- document.getElementById("jf-judge").disabled = v !== "rubric";
4214
+ setJobPlaceholders(v);
3857
4215
  refreshStartEnabled();
3858
4216
  }
3859
4217
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
@@ -3902,21 +4260,30 @@
3902
4260
  return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
3903
4261
  }
3904
4262
  function refreshModelStatus(){
3905
- var modelId = modelSelect.value;
3906
- if(modelId === ""){
4263
+ var modelId = pickParts(modelSelect.value).model;
4264
+ if(modelSelect.value === ""){
3907
4265
  keyOk = false;
3908
4266
  modelStatusEl.className = "jf-readiness bad";
3909
- modelStatusEl.textContent = "[✗] no model — give a key a Name in Settings > Providers";
4267
+ modelStatusEl.textContent = "[✗] no model — Tick a model in Settings > Providers.";
3910
4268
  refreshStartEnabled();
3911
4269
  return;
3912
4270
  }
3913
- authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
4271
+ // the key state of the NEW pick is unknown until its check returns: blocked at once, and a late answer for a line
4272
+ // no longer selected is ignored (it would otherwise unblock Start / Sign & resume for the wrong key)
4273
+ var asked = modelSelect.value;
4274
+ keyOk = false;
4275
+ modelStatusEl.className = "jf-readiness unknown";
4276
+ modelStatusEl.textContent = "checking the key\u2026";
4277
+ refreshStartEnabled();
4278
+ authorGet("/api/author/model-check?" + pickQuery(modelSelect.value)).then(function(r){
4279
+ if(modelSelect.value !== asked) return;
3914
4280
  var line = modelStatusLine(r, modelId);
3915
4281
  keyOk = line.keyOk;
3916
4282
  modelStatusEl.className = "jf-readiness " + (line.ok ? "ok" : "bad");
3917
4283
  modelStatusEl.textContent = line.text;
3918
4284
  refreshStartEnabled();
3919
4285
  }).catch(function(){
4286
+ if(modelSelect.value !== asked) return;
3920
4287
  keyOk = false;
3921
4288
  modelStatusEl.className = "jf-readiness bad";
3922
4289
  modelStatusEl.textContent = "[✗] could not check the key";
@@ -3928,25 +4295,89 @@
3928
4295
  function refreshReuseLine(){
3929
4296
  if(!startFrom) return;
3930
4297
  var q = (startFrom.importId ? "import=" + encodeURIComponent(startFrom.importId) : "runid=" + encodeURIComponent(startFrom.runid)) +
3931
- "&model=" + encodeURIComponent(modelSelect.value);
4298
+ "&" + pickQuery(modelSelect.value);
3932
4299
  authorGet("/api/author/start-from?" + q).then(function(r){
3933
4300
  if(startFrom && r && r.ok){ sfLine.textContent = r.line ? rwMeta({checkType: reuseCheckType, line: r.line}) : ""; }
3934
4301
  }).catch(function(){ /* the line keeps what it had */ });
3935
4302
  }
3936
4303
  modelSelect.addEventListener("change", refreshReuseLine);
3937
4304
  modelSelect.addEventListener("change", function(){ if(!rwBox.hidden && startFrom === null && !reuseSession) loadReuseJobs(); });
3938
- // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
3939
- // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
4305
+ // P8 — the Model menu IS the Settings rows: every TICKED, PRICED line, labelled "<KEY> · <model>", in keys-file order. An option's
4306
+ // value carries the key AND the model ("KEY|model"): two keys can tick the same model id, and the card names which one.
4307
+ // Re-read whenever Settings loads or saves, keeping the pick if it survives.
4308
+ function pickValue(slot, model){ return (slot || "") + "|" + model; }
4309
+ function pickParts(v){
4310
+ var i = String(v).indexOf("|");
4311
+ return i < 0 ? {provider: "", model: String(v)} : {provider: v.slice(0, i), model: v.slice(i + 1)};
4312
+ }
4313
+ function pickQuery(v){ var p = pickParts(v); return "model=" + encodeURIComponent(p.model) + "&provider=" + encodeURIComponent(p.provider); }
4314
+ // restore a saved card's pick: its key and model; an older card names only the model (the first option holding it)
4315
+ function setPick(c){
4316
+ if(!c || !c.model) return;
4317
+ var want = c.provider ? pickValue(c.provider, c.model) : null;
4318
+ var opts = Array.prototype.slice.call(modelSelect.options);
4319
+ var hit = opts.filter(function(o){ return want ? o.value === want : pickParts(o.value).model === c.model; })[0];
4320
+ if(!hit){
4321
+ hit = document.createElement("option");
4322
+ hit.value = want || pickValue("", c.model);
4323
+ hit.textContent = (c.provider ? c.provider + " \u00b7 " : "") + c.model;
4324
+ modelSelect.appendChild(hit);
4325
+ }
4326
+ modelSelect.value = hit.value;
4327
+ syncThinking();
4328
+ }
4329
+ // The "Thinking on" box follows the SERVER's answer for the picked line (`thinking.canChange`): false greys AND unticks it with the
4330
+ // server's words; 'unverified' leaves it enabled with no note. The tick is the person's; the page decides nothing.
4331
+ function syncThinking(){
4332
+ var t = lineThinking[modelSelect.value];
4333
+ var blocked = !!(t && t.canChange === false);
4334
+ if(blocked) thinkingBox.checked = false;
4335
+ thinkingBox.disabled = blocked || sessionLive || resumeRun !== null || reuseOn;
4336
+ thinkingNote.textContent = blocked ? (t.say || "") : "";
4337
+ document.getElementById("jf-thinking-label").hidden = reuseOn;
4338
+ }
4339
+ function setThinkingTick(on){ thinkingBox.checked = on === true; syncThinking(); }
4340
+ modelSelect.addEventListener("change", syncThinking);
4341
+ var modelReads = 0;
4342
+ var modelLines = [];
4343
+ var modelSay = "";
4344
+ // A Resume card that must PICK a model (old spec naming none) offers only the lines resolveWorker (src/providerrows.js) accepts
4345
+ // for that spec: the same shape, and the same endpoint when the spec names one (`resumeRun.worker`, from start-from, endpoint
4346
+ // already normalised by the server). With none, the sentence names the shape to tick (label from providershapes).
4347
+ function renderModelOptions(){
4348
+ var w = resumeRun !== null && resumeRun.pickModel === true ? resumeRun.worker : null;
4349
+ var lines = modelLines.filter(function(m){ return !w || (m.shape === w.provider && (w.endpoint === "" || m.endpoint === w.endpoint)); });
4350
+ var was = modelSelect.value;
4351
+ lineThinking = {};
4352
+ lines.forEach(function(m){ lineThinking[pickValue(m.slot, m.model)] = m.thinking || null; });
4353
+ var none = (w && modelLines.length) ? "Tick a " + w.label + " model in Settings > Providers." : (modelSay || "Tick a model in Settings > Providers.");
4354
+ modelSelect.innerHTML = lines.length ? lines.map(function(m){
4355
+ return '<option value="' + escapeXml(pickValue(m.slot, m.model)) + '">' + escapeXml(m.label) + "</option>";
4356
+ }).join("") : '<option value="">' + escapeXml(none) + "</option>";
4357
+ if(lines.some(function(m){ return pickValue(m.slot, m.model) === was; })) modelSelect.value = was;
4358
+ syncThinking();
4359
+ }
3940
4360
  function loadModels(){
4361
+ if(modelSelect.options.length === 0){
4362
+ modelSelect.innerHTML = '<option value="">Reading your models\u2026</option>';
4363
+ }
3941
4364
  authorGet("/api/author/models").then(function(r){
3942
- var models = r && r.ok ? r.models : [];
3943
- var was = modelSelect.value;
3944
- modelSelect.innerHTML = models.length ? models.map(function(m){
3945
- return '<option value="' + escapeXml(m.id) + '">' + escapeXml(m.id) + " · " + escapeXml(m.envName) + "</option>";
3946
- }).join("") : '<option value="">no model — add a Name in Settings</option>';
3947
- if(models.some(function(m){ return m.id === was; })) modelSelect.value = was;
4365
+ if(!r || !r.ok) throw new Error("read");
4366
+ modelReads = 0;
4367
+ modelLines = r.lines || [];
4368
+ modelSay = r.say || "";
4369
+ renderModelOptions();
4370
+ refreshModelStatus();
4371
+ }).catch(function(){
4372
+ modelReads += 1;
4373
+ if(modelReads <= 5){
4374
+ modelSelect.innerHTML = '<option value="">Could not read your models. Trying again\u2026</option>';
4375
+ setTimeout(loadModels, 1500);
4376
+ } else {
4377
+ modelSelect.innerHTML = '<option value="">Could not read your models. Reload the page.</option>';
4378
+ }
3948
4379
  refreshModelStatus();
3949
- }).catch(function(){ refreshModelStatus(); });
4380
+ });
3950
4381
  }
3951
4382
  document.addEventListener("bareloop-providers-changed", loadModels);
3952
4383
  loadModels();
@@ -3964,11 +4395,23 @@
3964
4395
  if(role === "system") return "";
3965
4396
  return "bareloop";
3966
4397
  }
4398
+ // the thread is redrawn only when its messages changed: a 2 s poll tick must not close a fold the person just opened
4399
+ var lastMsgSig = null;
3967
4400
  function renderMessages(state){
4401
+ var sig = JSON.stringify(state.messages);
4402
+ if(sig === lastMsgSig) return;
4403
+ lastMsgSig = sig;
3968
4404
  thread.innerHTML = state.messages.map(function(m){
3969
4405
  var cls = m.role === "you" ? "you" : (m.role === "system" ? "system" : "bot");
3970
4406
  var who = chatWhoLabel(m.role);
3971
4407
  var whoHtml = who ? ('<div class="who">' + escapeXml(who) + '</div>') : "";
4408
+ if(m.kind === "plan" && m.plan){
4409
+ // P7: the confirm turn's plan is drawn by the one job-plan renderer (the Job tab uses the same)
4410
+ var qs = (m.plan.questions || []).length
4411
+ ? '<div class="jp-qs" data-testid="plan-questions"><span class="lbl">AI\'s open questions</span><ul>' +
4412
+ m.plan.questions.map(function(q){ return "<li>" + escapeXml(q) + "</li>"; }).join("") + "</ul><b>Revise if you need to.</b></div>" : "";
4413
+ return '<div class="msg ' + cls + '" data-testid="plan-bubble">' + whoHtml + renderJobPlan(m.plan) + qs + '<div class="jp-goal">' + escapeXml(m.text) + "</div></div>";
4414
+ }
3972
4415
  return '<div class="msg ' + cls + '">' + whoHtml + escapeXml(m.text) + '</div>';
3973
4416
  }).join("");
3974
4417
  thread.scrollTop = thread.scrollHeight;
@@ -4062,22 +4505,61 @@
4062
4505
  fitCardBoxes();
4063
4506
  }
4064
4507
  msgInput.addEventListener("input", renderMain);
4508
+ // P7 — the numbered boxes. A plain job line is numbered 1, 2, 3; a line starting `~` is a rule for the line above and has no
4509
+ // number; Inputs numbers every line; empty lines have none. A hidden mirror (same font/width/padding) measures each logical
4510
+ // line's wrapped height so every number stays level with its own line — the same rules src/jobblock.js parses by.
4511
+ var NUMBERED_IDS = ["jf-job", "jf-inputs"];
4512
+ var FIT_IDS = ["jf-job", "jf-inputs", "jf-dest"];
4513
+ function gutterSync(id){
4514
+ var ta = document.getElementById(id), gut = document.getElementById(id + "-gut"), mir = document.getElementById(id + "-mir");
4515
+ if(!ta || !gut || !mir) return;
4516
+ var lines = ta.value.split("\n");
4517
+ var isJob = id === "jf-job";
4518
+ var n = 0;
4519
+ var nums = lines.map(function(l){
4520
+ var t = l.trim();
4521
+ if(t === "" || (isJob && t.charAt(0) === "~")) return "";
4522
+ n += 1; return String(n);
4523
+ });
4524
+ mir.style.width = ta.clientWidth ? ta.clientWidth + "px" : "";
4525
+ mir.innerHTML = lines.map(function(l){ return "<div>" + (l === "" ? "\u200b" : escapeXml(l)) + "</div>"; }).join("");
4526
+ var rows = mir.children;
4527
+ gut.innerHTML = nums.map(function(t, i){
4528
+ var hgt = rows[i] ? rows[i].offsetHeight : 0;
4529
+ return '<div' + (hgt ? ' style="height:' + hgt + 'px"' : "") + ">" + t + "</div>";
4530
+ }).join("");
4531
+ gut.scrollTop = ta.scrollTop;
4532
+ }
4533
+ // the empty-box examples (grey placeholders that vanish on typing) change with the Check type radio
4534
+ var JOB_PLACEHOLDERS = {
4535
+ deterministic: "Fix the failing date tests in src/date.js\n~ don't edit any test file\nMake npm test pass",
4536
+ rubric: "Write a one-page summary of docs/plan.md\n~ PASS: every number is quoted from the file\n~ FAIL: a number that is not in the file\nKeep it under 300 words",
4537
+ };
4538
+ var INPUTS_PLACEHOLDER = "repo: /home/me/myrepo\nspec: docs/spec.md";
4539
+ function setJobPlaceholders(v){
4540
+ document.getElementById("jf-job").setAttribute("placeholder", JOB_PLACEHOLDERS[v === "rubric" ? "rubric" : "deterministic"]);
4541
+ document.getElementById("jf-inputs").setAttribute("placeholder", INPUTS_PLACEHOLDER);
4542
+ }
4543
+ setJobPlaceholders("deterministic");
4065
4544
  // grow a text box with its text (a few rows, then it scrolls inside); ONE function for the ask box and the card boxes.
4066
4545
  // A box that is hidden (or not laid out) measures 0 and gets no inline height.
4067
4546
  function fitBox(el){
4068
4547
  el.style.height = "auto";
4069
4548
  if(!el.scrollHeight){ el.style.height = ""; return; }
4070
4549
  var border = el.offsetHeight - el.clientHeight;
4071
- el.style.height = Math.min(el.scrollHeight + border, 200) + "px";
4550
+ var cap = parseFloat(window.getComputedStyle(el).maxHeight);
4551
+ el.style.height = Math.min(el.scrollHeight + border, cap > 0 ? cap : 200) + "px";
4072
4552
  }
4073
4553
  function fitCardBoxes(){
4074
- ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){ fitBox(document.getElementById(id)); });
4554
+ FIT_IDS.forEach(function(id){ fitBox(document.getElementById(id)); });
4555
+ NUMBERED_IDS.forEach(gutterSync);
4075
4556
  }
4076
4557
  // live growth: each card box re-fits on its own input (typing and Enter), through the same fitBox
4077
- ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){
4558
+ FIT_IDS.forEach(function(id){
4078
4559
  var box = document.getElementById(id);
4079
- box.addEventListener("input", function(){ fitBox(box); });
4560
+ box.addEventListener("input", function(){ fitBox(box); if(NUMBERED_IDS.indexOf(id) !== -1) gutterSync(id); });
4080
4561
  });
4562
+ NUMBERED_IDS.forEach(function(id){ var box = document.getElementById(id); box.addEventListener("scroll", function(){ document.getElementById(id + "-gut").scrollTop = box.scrollTop; }); });
4081
4563
  window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
4082
4564
  document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
4083
4565
  // Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
@@ -4177,16 +4659,10 @@
4177
4659
  reusePickedName = c.jobName || "job";
4178
4660
  setVerdict(reuseSession ? "reuse" : reuseCheckType);
4179
4661
  if(c.model){
4180
- if(!Array.prototype.some.call(modelSelect.options, function(o){ return o.value === c.model; })){
4181
- modelSelect.insertAdjacentHTML("beforeend", '<option value="' + escapeXml(c.model) + '">' + escapeXml(c.model) + "</option>");
4182
- }
4183
- modelSelect.value = c.model;
4662
+ setPick(c);
4184
4663
  }
4185
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4186
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4187
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4188
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4189
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4664
+ fillCard(c);
4665
+
4190
4666
  if(reuseSession) setReuseLocked(true);
4191
4667
  renderPicker();
4192
4668
  fitCardBoxes();
@@ -4208,6 +4684,15 @@
4208
4684
 
4209
4685
  // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4210
4686
  function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
4687
+ // ONE owner of filling the card from a prefill (a re-attached session, Reuse, Edit in chat, Resume): the six boxes P7 left
4688
+ // (the old Goal/Source/Success/Guardrails/Judge boxes are gone), then every box re-fits and its number gutter re-measures.
4689
+ function fillCard(c){
4690
+ setVal("jf-name", c.jobName); setVal("jf-job", c.jobText); setVal("jf-inputs", c.inputs);
4691
+ setVal("jf-dest", c.destination); setVal("jf-cap-money", c.capUsd);
4692
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4693
+ setThinkingTick(c.thinking === true);
4694
+ fitCardBoxes();
4695
+ }
4211
4696
  // ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
4212
4697
  // `bareloop-reuse` event), so the card ends up in the same picked state whichever door was used.
4213
4698
  function applyReuse(d){
@@ -4219,12 +4704,9 @@
4219
4704
  reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
4220
4705
  reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
4221
4706
  setVerdict("reuse");
4222
- if(c.model){ modelSelect.value = c.model; }
4223
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4224
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4225
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4226
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4227
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4707
+ if(c.model){ setPick(c); }
4708
+ fillCard(c);
4709
+
4228
4710
  setReuseLocked(true);
4229
4711
  renderPicker();
4230
4712
  sfLine.hidden = false;
@@ -4248,12 +4730,9 @@
4248
4730
  clearStartFrom();
4249
4731
  openNewCard();
4250
4732
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4251
- if(c.model){ modelSelect.value = c.model; }
4252
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4253
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4254
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4255
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4256
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4733
+ if(c.model){ setPick(c); }
4734
+ fillCard(c);
4735
+
4257
4736
  originLine.textContent = d.origin || "";
4258
4737
  originLine.hidden = !d.origin;
4259
4738
  renderPicker();
@@ -4272,17 +4751,16 @@
4272
4751
  var r = d.resume || {};
4273
4752
  clearStartFrom();
4274
4753
  openNewCard();
4754
+ // 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
4275
4755
  resumeRun = {runid: d.runid, noTime: typeof r.maxWallMin !== "number"};
4756
+ if((d.prefill || {}).specNamesModel === false){ resumeRun.pickModel = true; if(d.prefill.specWorker){ resumeRun.worker = d.prefill.specWorker; } renderModelOptions(); }
4276
4757
  document.getElementById("job-card-title").textContent = "RESUME " + (d.title || d.runid);
4277
4758
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4278
- if(c.model){ modelSelect.value = c.model; }
4279
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4280
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4281
- setVal("jf-judge", c.judgeExamples);
4759
+ if(c.model && !resumeRun.pickModel){ setPick(c); }
4760
+ fillCard(c);
4282
4761
  setVal("jf-cap-money", r.budgetUsd);
4283
4762
  setVal("jf-cap-time", resumeRun.noTime ? "" : r.maxWallMin);
4284
4763
  document.getElementById("jf-cap-time").parentNode.hidden = resumeRun.noTime;
4285
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4286
4764
  originLine.textContent = "The same run goes on, not a new one. Only the money cap and the time cap can change; everything else is the signed job.";
4287
4765
  originLine.classList.add("res");
4288
4766
  originLine.hidden = false;
@@ -4300,16 +4778,21 @@
4300
4778
  document.addEventListener("bareloop-resume", function(e){ applyResume(e.detail || {}); });
4301
4779
  document.getElementById("chat-resume-cancel").addEventListener("click", function(){ resetCard(); });
4302
4780
  var resumeSignBtn = document.getElementById("chat-resume-sign");
4781
+ // a Model pick that is needed (older spec) blocks Sign & resume until a ticked line is picked and its key is usable
4782
+ function resumeSignBlocked(){ return resumeRun !== null && resumeRun.pickModel === true && (modelSelect.value === "" || !keyOk); }
4783
+ function syncResumeSign(){ resumeSignBtn.disabled = resumeSignBlocked(); }
4303
4784
  function signResume(){
4304
4785
  var run = resumeRun;
4305
4786
  if(run === null) return;
4787
+ if(resumeSignBlocked()) return;
4306
4788
  var err = document.getElementById("chat-resume-err");
4307
4789
  err.hidden = true;
4308
4790
  resumeSignBtn.disabled = true;
4309
4791
  var body = {budgetUsd: document.getElementById("jf-cap-money").value};
4310
4792
  if(!run.noTime){ body.maxWallMin = document.getElementById("jf-cap-time").value; }
4793
+ if(run.pickModel){ var pk = pickParts(modelSelect.value); body.model = pk.model; if(pk.provider !== ""){ body.provider = pk.provider; } }
4311
4794
  return authorPost("/api/runs/" + encodeURIComponent(run.runid) + "/resume", body).then(function(res){
4312
- resumeSignBtn.disabled = false;
4795
+ resumeSignBtn.disabled = resumeSignBlocked();
4313
4796
  if(res.body && res.body.ok){
4314
4797
  // 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
4315
4798
  resetCard();
@@ -4319,7 +4802,7 @@
4319
4802
  err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
4320
4803
  err.hidden = false;
4321
4804
  }
4322
- }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
4805
+ }).catch(function(x){ resumeSignBtn.disabled = resumeSignBlocked(); err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
4323
4806
  }
4324
4807
  resumeSignBtn.addEventListener("click", signResume);
4325
4808
 
@@ -4400,6 +4883,7 @@
4400
4883
  if(!o.ok){ chatActionFailed(o.error); return; }
4401
4884
  openNewCard();
4402
4885
  chatActionOk();
4886
+ lastMsgSig = null;
4403
4887
  thread.innerHTML = '<div class="msg system">' + escapeXml("The boxes are open as you drafted them. Edit what you want, then Start drafting. The first draft's cost stays counted.") + "</div>";
4404
4888
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not revised"); });
4405
4889
  }