bareloop 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. package/types/worktree.d.ts +48 -0
@@ -23,6 +23,7 @@
23
23
  --magenta:#bb9af7;
24
24
  --accent:#7aa2f7;
25
25
  --btn-bg:#1f2335; --btn-text:#c0caf5;
26
+ --field-bg:var(--bg); --field-soft-bg:var(--bg); --field-border:var(--text-dim);
26
27
  --primary-bg:#7aa2f7; --primary-text:#1a1b26; --primary-border:#7aa2f7;
27
28
  font: 14px/1.5 "Courier Prime", "Courier New", Courier, ui-monospace, monospace;
28
29
  }
@@ -68,6 +69,7 @@
68
69
  --magenta:#9854f1;
69
70
  --accent:#2668c2;
70
71
  --btn-bg:#e9e9ed; --btn-text:#3760bf;
72
+ --field-bg:#ffffff; --field-soft-bg:#f7f7f9;
71
73
  --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
72
74
  }
73
75
  }
@@ -82,6 +84,7 @@
82
84
  --magenta:#9854f1;
83
85
  --accent:#2668c2;
84
86
  --btn-bg:#e9e9ed; --btn-text:#3760bf;
87
+ --field-bg:#ffffff; --field-soft-bg:#f7f7f9;
85
88
  --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
86
89
  }
87
90
  .theme-toggle{padding:4px 9px;min-height:32px;border:1px solid var(--border-strong);background:var(--btn-bg);color:var(--text);}
@@ -171,6 +174,14 @@
171
174
  glyph and its own color (magenta), never red or amber. */
172
175
  .dot.magenta::before{content:"[?]";color:var(--magenta);}
173
176
 
177
+ .rp-header-title h2{text-transform:none;letter-spacing:0;}
178
+ .rp-status{font-size:13px;color:var(--text-dim);min-width:0;overflow-wrap:anywhere;}
179
+ .rp-sep{color:var(--text-faint);margin-right:6px;}
180
+ .st-word{font-weight:700;color:var(--text);}
181
+ .wf-ended-inline{flex:1 1 auto;white-space:normal;}
182
+ .wf-compact{flex-wrap:wrap;}
183
+ .wf-runno{color:var(--accent);flex:none;}
184
+ .step-state{display:inline-flex;align-items:center;gap:2px;font-size:12px;}
174
185
  .badge{display:inline-block;font-size:11px;padding:1px 4px;color:var(--text-dim);background:none;font-weight:700;}
175
186
  .badge::before{content:"[";color:var(--text-faint);}
176
187
  .badge::after{content:"]";color:var(--text-faint);}
@@ -207,6 +218,7 @@
207
218
  than wrapping onto a 3rd/4th line. */
208
219
  .wf-meta-line{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
209
220
  .wf-meta{font-size:12px;color:var(--text-dim);}
221
+ .wf-ended{display:block;font-size:12px;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
210
222
  .wf-meta + .wf-meta::before{content:" · ";color:var(--text-faint);}
211
223
  .wf-row.selected,.hist-row.selected{border:2px solid var(--accent);border-radius:4px;}
212
224
  /* item 2 (2026-09-26 merge): a Workflows-view job row expands inline to
@@ -220,8 +232,6 @@
220
232
 
221
233
  .rp-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);flex-wrap:wrap;gap:8px;}
222
234
  .rp-header h2{margin:0;font-size:15px;}
223
- .rp-header h2::before{content:"┤ ";color:var(--text-faint);}
224
- .rp-header h2::after{content:" ├";color:var(--text-faint);}
225
235
  .rp-header-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
226
236
  /* item 4: phone-only jump-back link — hidden on desktop (the two panes
227
237
  are already both on screen there, nothing to jump back to). */
@@ -237,6 +247,47 @@
237
247
  .counters b,.counters span{color:var(--accent);}
238
248
 
239
249
  .summary-box{position:relative;border:1px solid var(--border-strong);padding:16px 12px 12px;margin-top:16px;background:var(--panel2);font-size:12px;}
250
+ .ended-block{position:relative;border:1px solid var(--border-strong);padding:14px 12px 12px;margin-bottom:14px;background:var(--panel2);font-size:13px;}
251
+ .ended-block.magenta{border-color:var(--magenta);}
252
+ .ended-block.red{border-color:var(--red);}
253
+ .ended-block.green{border-color:var(--green);}
254
+ .ended-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:6px;}
255
+ .ended-label{color:var(--text-faint);font-size:11px;letter-spacing:0.08em;min-width:52px;}
256
+ .ended-text{flex:1 1 220px;min-width:0;overflow-wrap:anywhere;}
257
+ .ended-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;}
258
+ /* P5 item 4: Import (read only) — the folder browser, the imported rows, the view */
259
+ .import-box{border:1px dashed var(--border-strong);padding:10px;margin-bottom:10px;display:grid;gap:8px;}
260
+ .import-pathrow{display:flex;gap:8px;flex-wrap:wrap;}
261
+ .import-pathrow input{flex:1 1 180px;min-width:0;min-height:36px;background:var(--bg);color:var(--text);border:1px solid var(--border-strong);padding:4px 8px;font:inherit;}
262
+ .import-entries{display:grid;gap:2px;max-height:240px;overflow-y:auto;overflow-x:hidden;padding:2px;}
263
+ .import-entry{display:flex;gap:8px;align-items:baseline;text-align:left;background:none;border:1px solid transparent;color:var(--text);padding:3px 2px;min-height:32px;cursor:pointer;font:inherit;overflow-wrap:anywhere;}
264
+ .import-entry:hover{background:var(--panel);}
265
+ .import-up{font-weight:700;}
266
+ .import-entry.selected{background:var(--panel);border-color:var(--accent);}
267
+ .import-entry .tag{color:var(--accent);font-size:11px;}
268
+ .import-view[hidden]{display:none;}
269
+ .imp-bad{color:var(--red);font-size:12px;font-weight:700;overflow-wrap:anywhere;}
270
+ .wf-imported{color:var(--accent);}
271
+ .import-view{display:grid;gap:10px;font-size:13px;}
272
+ .import-view .imp-row{display:flex;gap:10px;flex-wrap:wrap;align-items:baseline;}
273
+ .import-view .imp-label{color:var(--text-faint);font-size:11px;letter-spacing:0.08em;min-width:84px;}
274
+ .import-view .imp-text{flex:1 1 220px;min-width:0;overflow-wrap:anywhere;}
275
+ .startfrom-line{margin:2px 0 8px;font-size:12px;color:var(--accent);overflow-wrap:anywhere;}
276
+ .job-card .locked{opacity:.55;cursor:not-allowed;}
277
+ .job-card.reuse .radio-row label{opacity:.55;cursor:not-allowed;}
278
+ .startfrom-origin{font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 4px;padding-right:112px;overflow-wrap:anywhere;}
279
+ .run-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
280
+ .run-actions[hidden]{display:none;}
281
+ .run-actions .stopping{color:var(--amber);font-size:13px;overflow-wrap:anywhere;}
282
+ .resume-err{color:var(--red);white-space:pre-wrap;overflow-wrap:anywhere;}
283
+ /* P5-R: a resumed run is ONE card; its legs are told apart by a divider line, a dotted map connector, a small tag */
284
+ .wf-resumed{color:var(--amber);}
285
+ .audit-leg-divider{text-align:center;color:var(--text-dim);font-size:12px;margin:10px 0;overflow-wrap:anywhere;}
286
+ .audit-leg-divider-row td{text-align:center;color:var(--text-dim);font-size:12px;background:var(--panel2);}
287
+ /* Resume opens the run's own Job tab: only the two caps turn into inputs, everything else stays read-only */
288
+ .resume-job{border:1px dashed var(--border-strong);margin-top:12px;padding:10px;display:grid;gap:8px;}
289
+ .resume-cap{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
290
+ .resume-cap input{width:7em;max-width:100%;min-height:36px;background:var(--panel);color:var(--text);border:1px solid var(--border-strong);padding:4px 8px;font:inherit;}
240
291
  .summary-box.red{border-color:var(--red);}
241
292
  .summary-box.amber{border-color:var(--amber);}
242
293
  .summary-box.magenta{border-color:var(--magenta);}
@@ -246,16 +297,20 @@
246
297
  .sum-label{flex:none;width:66px;color:var(--text-faint);}
247
298
  .sum-val{color:var(--text);}
248
299
  .summary-box .num{color:var(--accent);font-weight:700;}
300
+ .summary-box.imported-box::before{content:"┤ IMPORTED ├";}
301
+ .imported-box .sum-label{width:104px;}
302
+ .imported-box .summary-row{flex-wrap:wrap;}
303
+ .imported-box .sum-val{flex:1 1 180px;min-width:0;overflow-wrap:anywhere;}
249
304
 
250
305
  .map-box{border:1px solid var(--border-strong);padding:10px;overflow-x:auto;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
251
306
  .map-box::before{content:"┤ MAP ├";display:block;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;margin:-2px 0 8px 0;}
252
307
  .map-box svg{display:block;max-width:100%;height:auto;}
253
- .map-legend{display:flex;gap:14px;font-size:11px;color:var(--text-dim);margin-top:6px;flex-wrap:wrap;}
308
+ .map-key{display:flex;gap:14px;font-size:11px;color:var(--text-dim);margin-top:6px;flex-wrap:wrap;}
254
309
 
255
310
  .step-card{position:relative;border:1px solid var(--border);padding:10px;margin-bottom:8px;background:var(--panel2);}
256
311
  .step-card.running{border:1px double var(--amber);}
257
312
  .step-card .step-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;}
258
- .step-card h4{margin:0;font-size:13px;}
313
+ .step-card h4{margin:0;font-size:13px;text-transform:none;letter-spacing:0;}
259
314
  .step-meta{font-size:12px;color:var(--text-dim);margin-top:4px;}
260
315
  /* build item C: Audit tab attempt/rounds detail (reused by audit-part-toggle expansion) */
261
316
  .attempts-list{margin-top:8px;border-top:1px dotted var(--border-strong);padding-top:8px;}
@@ -307,6 +362,7 @@
307
362
 
308
363
  .job-card{position:relative;border:1px solid var(--border-strong);padding:14px 10px 10px;background:var(--panel2);margin-top:10px;margin-bottom:10px;}
309
364
  .job-card h4{margin:0 0 8px;font-size:13px;position:absolute;top:-10px;left:10px;background:var(--panel2);padding:0 6px;}
365
+ .job-card .card-clear{position:absolute;top:-13px;right:10px;padding:1px 8px;font-size:11px;line-height:1.4;background:var(--panel2);}
310
366
  .job-card h4::before{content:"┤ ";color:var(--text-faint);}
311
367
  .job-card h4::after{content:" ├";color:var(--text-faint);}
312
368
  .field{margin-bottom:10px;}
@@ -326,8 +382,6 @@
326
382
  .job-card.compact label{margin-bottom:1px;font-size:11px;}
327
383
  .job-card.compact input,
328
384
  .job-card.compact select{padding:5px 7px;}
329
- .expand-field{text-overflow:ellipsis;transition:height 0.12s ease;}
330
- .expand-field:focus{height:52px;white-space:normal;}
331
385
  .radio-row{display:flex;gap:14px;align-items:center;margin-bottom:10px;}
332
386
  .radio-row label{display:flex;align-items:center;gap:5px;font-size:13px;color:var(--text);margin:0;text-transform:none;}
333
387
  .cap-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:6px;margin-bottom:6px;}
@@ -343,15 +397,23 @@
343
397
  .btn.primary:disabled{background:var(--btn-bg);color:var(--text-faint);border-color:var(--border);}
344
398
  .btn.primary:disabled::before,.btn.primary:disabled::after{color:var(--text-faint);opacity:0.4;}
345
399
 
346
- /* P3 build item 3 — the single live progress line: an amber cycling
347
- [progress.]/[progress..]/[progress...] glyph while a phase is running,
348
- [done] in green once it finishes, [stopped] in red on a refusal. A
349
- static [progress...] under prefers-reduced-motion (no animation). */
350
- .chat-progress-row{display:flex;align-items:center;gap:8px;margin:2px 0 10px;font-size:12px;}
351
- .chat-progress-glyph{font-weight:700;color:var(--amber);}
352
- .chat-progress-glyph.done{color:var(--green);}
353
- .chat-progress-glyph.stopped{color:var(--red);}
354
- .chat-progress-label{color:var(--text-faint);}
400
+ /* the ONE progress list: a step per line — label, then dots while running / check / cross, then its reason */
401
+ .chat-steps{list-style:none;margin:2px 0 10px;padding:0;font-size:12px;}
402
+ .chat-steps .step{padding:1px 0;}
403
+ .chat-steps .step-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 10px;}
404
+ .chat-steps .step-label{color:var(--text);min-width:11em;}
405
+ .chat-steps .step-sign{font-weight:700;}
406
+ .chat-steps .step.run .step-sign{color:var(--amber);}
407
+ .chat-steps .step.ok .step-sign{color:var(--green);}
408
+ .chat-steps .step.bad .step-sign{color:var(--red);}
409
+ .chat-steps .step.wait .step-label{color:var(--amber);}
410
+ .chat-steps .step-detail{display:block;flex-basis:100%;min-width:0;padding-left:1.2em;color:var(--text-faint);word-break:break-word;white-space:pre-wrap;}
411
+ .chat-steps .step.bad .step-detail{color:var(--red);}
412
+ .chat-steps .step.run .step-sign::after{content:"\2026";}
413
+ @keyframes step-dots{0%{content:"";}25%{content:".";}50%{content:"..";}75%{content:"...";}}
414
+ @media (prefers-reduced-motion: no-preference){
415
+ .chat-steps .step.run .step-sign::after{content:"";animation:step-dots 1.2s steps(1,end) infinite;}
416
+ }
355
417
  /* build item 7 — the two $0 readiness lines under the Model field. */
356
418
  .jf-readiness{font-size:11px;margin-top:2px;}
357
419
  .jf-readiness.ok{color:var(--green);}
@@ -368,12 +430,24 @@
368
430
  .msg .who{font-size:11px;color:var(--text-faint);margin-bottom:3px;text-transform:none;font-weight:400;}
369
431
  .chat-input-row{margin-top:10px;border-top:1px dotted var(--border-strong);padding-top:10px;}
370
432
  .chat-input-row input{width:100%;box-sizing:border-box;}
433
+ /* the ask box wraps long text (textarea, hamr 2026-10-05): grows a few rows (fitBox), then scrolls inside; Enter sends, Shift+Enter is a newline */
434
+ textarea[data-testid="chat-msg"]{width:100%;box-sizing:border-box;font:inherit;color:var(--text);resize:vertical;overflow-y:auto;max-height:200px;}
435
+ /* 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 */
436
+ /* same look as the card's one-line inputs (white when editable, ordinary 1px border) — a textarea has its own browser border otherwise */
437
+ .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;}
438
+ /* hamr 2026-10-05: the focused/selected Chat-card field gets a blue border (the accent), no outline ring */
439
+ #panel-chat :is(input,select,textarea):focus{border-color:var(--accent);outline:none;}
440
+ /* hamr 2026-10-05: the ask box is dimmed until something asks for a reply (placeholder stays readable) */
441
+ #chat-msg:disabled{background:var(--bg);opacity:.55;cursor:not-allowed;}
371
442
  /* chat message box reads as an unmistakable text box: ~2x the default
372
443
  input height, thicker 2px border (ported verbatim from the mockup). */
373
- #chat-msg{min-height:64px;padding:8px 12px;border:2px solid var(--border-strong);}
374
- .actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
375
- .actions-row > .btn{flex:1 1 auto;}
376
-
444
+ #chat-msg{min-height:64px;padding:8px 12px;border:2px solid var(--field-border);background:var(--field-bg);}
445
+ /* hamr 2026-10-05: editable Chat-card fields get the standard soft white (a step below the ask box); disabled/readonly (dimmed) keep the base colour */
446
+ #panel-chat input:not(:disabled):not([readonly]):not(#chat-msg):not([type="radio"]):not([type="checkbox"]),
447
+ #panel-chat select:not(:disabled),
448
+ #panel-chat textarea:not(:disabled):not([readonly]):not(#chat-msg){background:var(--field-soft-bg);}
449
+ /* ONE wide button below the message box (hamr 2026-10-05); its wording comes from mainButtonFor */
450
+ .btn.wide{width:100%;margin-top:10px;white-space:normal;}
377
451
  /* Settings view (P4a) — same tokens as the rest of the page; the mockup's settings styles */
378
452
  .settings-view{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
379
453
  .settings-header{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);}
@@ -393,6 +467,7 @@
393
467
  .pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
394
468
  .pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
395
469
  .pv-table .hint{display:block;margin-top:3px;}
470
+ .pv-table td.pv-price-cell input{width:90px;}
396
471
  /* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
397
472
  .pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
398
473
  .pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
@@ -441,10 +516,14 @@
441
516
  <div class="pane-body" id="left-pane-body">
442
517
 
443
518
  <section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
444
- <button class="btn" type="button" id="chat-new-btn" data-testid="chat-new">+ New</button>
445
-
446
- <div class="job-card compact" id="job-card" data-testid="job-card" hidden>
519
+ <!-- hamr 2026-10-05: the empty card is ALWAYS there when Chat opens (no + New). The top-right button reads
520
+ "Abandon" while a session is live and unsigned, else "Clear"; both leave an empty card. -->
521
+ <div class="job-card compact" id="job-card" data-testid="job-card">
522
+ <div class="startfrom-origin" id="chat-startfrom-origin" data-testid="startfrom-origin" hidden></div>
447
523
  <h4 id="job-card-title">Job card — draft</h4>
524
+ <button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
525
+ <div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
526
+ <div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
448
527
 
449
528
  <div class="field">
450
529
  <label id="jf-verdict-label">Check type</label>
@@ -466,27 +545,27 @@
466
545
  </div>
467
546
  <div class="field">
468
547
  <label for="jf-goal">Goal</label>
469
- <input class="expand-field" id="jf-goal" type="text">
548
+ <textarea class="jf-wrap" id="jf-goal" rows="2"></textarea>
470
549
  </div>
471
550
  <div class="field">
472
551
  <label for="jf-source">Source</label>
473
- <input id="jf-source" type="text" placeholder="absolute path to a local repo">
552
+ <textarea class="jf-wrap" id="jf-source" rows="1" placeholder="absolute path to a local repo"></textarea>
474
553
  </div>
475
554
  <div class="field">
476
555
  <label for="jf-dest">Destination</label>
477
- <input id="jf-dest" type="text" placeholder="the write fence — where in the repo this run may change files">
556
+ <textarea class="jf-wrap" id="jf-dest" rows="1" placeholder="the write fence — where in the repo this run may change files"></textarea>
478
557
  </div>
479
558
  <div class="field">
480
559
  <label for="jf-success">Success</label>
481
- <input class="expand-field" id="jf-success" type="text">
560
+ <textarea class="jf-wrap" id="jf-success" rows="2"></textarea>
482
561
  </div>
483
562
  <div class="field">
484
563
  <label for="jf-guardrails">Guardrails</label>
485
- <input id="jf-guardrails" type="text">
564
+ <textarea class="jf-wrap" id="jf-guardrails" rows="2"></textarea>
486
565
  </div>
487
566
  <div class="field">
488
567
  <label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
489
- <input class="expand-field" id="jf-judge" type="text" disabled>
568
+ <textarea class="jf-wrap" id="jf-judge" rows="2" disabled></textarea>
490
569
  </div>
491
570
 
492
571
  <div class="cap-row" data-testid="cap-row">
@@ -500,21 +579,15 @@
500
579
  <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
501
580
  </div>
502
581
  </div>
503
- <button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
504
582
  <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
505
- <div class="hint" id="chat-hash-line" data-testid="chat-hash-line" hidden></div>
506
- </div>
507
-
508
- <!-- build item 3 — the label comes FIRST, then the animated
509
- indicator, so the cycling dots ([progress.] .. [progress....])
510
- never push the label around. -->
511
- <div class="chat-progress-row" id="chat-progress-row" data-testid="chat-progress-row" hidden>
512
- <span class="chat-progress-label" id="chat-progress-label" data-testid="chat-progress-label"></span>
513
- <span class="chat-progress-glyph" id="chat-progress-glyph" data-testid="chat-progress-glyph"></span>
514
583
  </div>
515
584
 
516
585
  <div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
517
586
 
587
+ <!-- ONE progress list (hamr 2026-10-04): every pipeline step is one line that updates in place, running with
588
+ animated dots, then a check or a cross with its code-owned reason; chat turns stay in the thread above it, right over the ask box. -->
589
+ <ol class="chat-steps" id="chat-progress-row" data-testid="chat-steps" aria-live="polite" hidden></ol>
590
+
518
591
  <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
519
592
  sign or check-deps POST shows here and STAYS shown: it is set
520
593
  only by this tab's own action handlers below and is never
@@ -525,15 +598,10 @@
525
598
 
526
599
  <div class="chat-input-row">
527
600
  <label for="chat-msg" style="display:none">Message</label>
528
- <input id="chat-msg" type="text" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg">
529
- </div>
530
- <button class="btn" type="button" id="chat-check-deps-btn" data-testid="chat-check-deps" hidden>Check again</button>
531
-
532
- <div class="actions-row">
533
- <button class="btn" type="button" id="chat-send-btn" data-testid="chat-send" disabled>Send</button>
534
- <button class="btn primary" type="button" id="chat-sign-btn" data-testid="btn-sign-run" disabled>Sign &amp; run</button>
535
- <button class="btn" type="button" id="chat-revise-btn" data-testid="btn-revise" disabled>Revise (2 left)</button>
601
+ <textarea id="chat-msg" rows="2" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg"></textarea>
536
602
  </div>
603
+ <button class="btn primary wide" type="button" id="chat-main-btn" data-testid="chat-main" disabled>Start drafting</button>
604
+ <div class="hint" id="chat-main-hint" data-testid="chat-main-hint"></div>
537
605
  <div class="hint">Only your click signs. The chat can't.</div>
538
606
  </section>
539
607
 
@@ -541,9 +609,25 @@
541
609
  <div class="chips" role="group" aria-label="Runs view" style="margin-bottom:8px;">
542
610
  <button class="chip" type="button" aria-pressed="true" data-runs-view="workflows" data-testid="runs-view-workflows">Workflows</button>
543
611
  <button class="chip" type="button" aria-pressed="false" data-runs-view="history" data-testid="runs-view-history">History</button>
612
+ <button class="btn small" type="button" id="wf-import" data-testid="wf-import" title="look at an exported job folder (read only)">Import</button>
613
+ </div>
614
+ <div class="import-box" id="import-box" data-testid="import-box" hidden>
615
+ <label for="import-path">Import a job folder</label>
616
+ <div class="import-pathrow">
617
+ <input id="import-path" data-testid="import-path" type="text" placeholder="/path/to/job.bareloop — or pick below" autocomplete="off">
618
+ <button class="btn small" type="button" id="import-open" data-testid="import-open">Go</button>
619
+ </div>
620
+ <div class="resume-err" id="import-err" data-testid="import-err" hidden></div>
621
+ <div class="hint" id="import-resolved" data-testid="import-resolved"></div>
622
+ <div class="import-entries" id="import-entries" data-testid="import-entries"></div>
623
+ <div class="ended-actions">
624
+ <button class="btn primary" type="button" id="import-go" data-testid="import-go">Import</button>
625
+ <button class="btn" type="button" id="import-cancel" data-testid="import-cancel">Cancel</button>
626
+ </div>
544
627
  </div>
545
628
  <div id="runs-filterbar-anchor"></div>
546
629
  <div id="wf-list" data-testid="wf-list"></div>
630
+ <div id="import-list" data-testid="import-list"></div>
547
631
  <div id="history-list" data-testid="history-list" hidden></div>
548
632
  </section>
549
633
 
@@ -556,8 +640,7 @@
556
640
  <div class="rp-header-title">
557
641
  <span class="dot grey" id="active-wf-dot"></span>
558
642
  <h2 id="active-wf-name">(none selected)</h2>
559
- <span class="badge" id="active-wf-verdict">—</span>
560
- <span class="badge" id="active-wf-date">—</span>
643
+ <span class="rp-status" id="active-wf-verdict" data-testid="active-wf-verdict"></span>
561
644
  </div>
562
645
  </div>
563
646
 
@@ -570,8 +653,10 @@
570
653
  <div class="pane-body" id="right-pane-body">
571
654
 
572
655
  <section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
656
+ <div class="import-view" id="import-view" data-testid="import-view" hidden></div>
573
657
  <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
574
658
  <div id="run-content" hidden>
659
+ <div class="run-actions" id="run-actions" data-testid="run-actions" hidden></div>
575
660
  <div class="counters" id="run-counters" data-testid="live-counters"></div>
576
661
  <div class="summary-box" id="run-summary" data-testid="run-summary"></div>
577
662
  <div class="map-box" data-testid="step-map" id="map-box"><div id="map-mount"></div></div>
@@ -582,6 +667,7 @@
582
667
  <section id="panel-audit" class="tabpanel" role="tabpanel" aria-labelledby="tab-audit" hidden>
583
668
  <div id="audit-select-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
584
669
  <div id="audit-body" hidden>
670
+ <div id="ended-block" class="ended-block" data-testid="ended-block" hidden></div>
585
671
  <div class="audit-toolbar" style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
586
672
  <div class="chips" role="group" aria-label="Filter audit rows" style="margin-bottom:0;">
587
673
  <button class="chip" aria-pressed="true" data-filter="all" data-testid="chip-all">All</button>
@@ -619,8 +705,8 @@
619
705
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
620
706
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
621
707
  <div class="field-row">
622
- <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
623
- <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
708
+ <div class="field"><label id="details-cap-money-label">$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
709
+ <div class="field"><label id="details-cap-time-label">Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
624
710
  </div>
625
711
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
626
712
  <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
@@ -630,6 +716,7 @@
630
716
  <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
631
717
  <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
632
718
  <div class="hint" id="job-note"></div>
719
+ <div id="resume-job" class="resume-job" data-testid="resume-job" hidden></div>
633
720
  </div>
634
721
  </section>
635
722
 
@@ -656,21 +743,24 @@
656
743
  <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
657
744
  <div class="keyfile-strip" data-testid="keyfile-box">
658
745
  <div class="keyfile-strip-main">
659
- <span>Put <code>NAME=key</code> lines in <code id="pv-keyfile-path">~/.config/bareloop/.env</code>, then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
746
+ <span>Put <code>NAME=key</code> lines in your keys file (Open keys folder), then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
747
+ <span class="hint" id="pv-keyfile-path" data-testid="keyfile-path"></span>
660
748
  <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
661
749
  </div>
662
750
  <div class="keyfile-strip-actions">
751
+ <button class="btn small" type="button" id="btn-open-keys-folder" data-testid="btn-open-keys-folder">Open keys folder</button>
663
752
  <button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
664
753
  </div>
665
754
  </div>
666
755
  <div class="table-wrap" data-testid="provider-table-wrap">
667
756
  <table class="pv-table" data-testid="provider-table">
668
757
  <thead>
669
- <tr><th>Key</th><th>Name</th><th>API shape</th><th>Base URL</th><th>Test</th><th>Tokens used</th><th>Balance</th></tr>
758
+ <tr><th>Key</th><th>Name</th><th>API shape</th><th>Base URL</th><th>Test</th><th>Tokens used</th><th>Balance</th><th>In $/1M</th><th>Out $/1M</th></tr>
670
759
  </thead>
671
760
  <tbody id="pv-rows"></tbody>
672
761
  </table>
673
762
  </div>
763
+ <div class="hint" id="pv-price-hint" data-testid="pv-price-hint">If your vendor lists two prices, enter the higher one.</div>
674
764
  <div id="pv-shell" data-testid="pv-shell"></div>
675
765
  <div class="hint" id="pv-note" data-testid="pv-note" style="margin-top:6px;">Name is the model id Chat uses. Base URL blank = the API shape's own host. Test asks the provider for its model list — it costs nothing.</div>
676
766
  </section>
@@ -720,15 +810,28 @@
720
810
  var TOKEN = "__BARELOOP_PANEL_TOKEN__";
721
811
  document.getElementById("host-line").textContent = "127.0.0.1:" + PORT;
722
812
 
813
+ // After a panel restart this page's token is dead: the server refuses EVERY call with 403 and the exact reason
814
+ // below (checkHumanGuard, src/panel/authorroutes.js). The ONE place the page makes calls swaps that raw text for
815
+ // one clear line and tells the Chat poll to stop (staleTokenHook, set by the Chat code). No auto-reload.
816
+ var STALE_TOKEN_REFUSAL = "refused \u2014 missing or wrong token";
817
+ var STALE_TOKEN_LINE = "The panel was restarted \u2014 reload this page. A drafting session that was open is gone (nothing more is spent).";
818
+ var staleTokenHook = function(){};
819
+ function staleTokenCheck(j, status){
820
+ if(j && j.ok === false && j.error === STALE_TOKEN_REFUSAL && (status === undefined || status === 403)){
821
+ j.error = STALE_TOKEN_LINE;
822
+ staleTokenHook();
823
+ }
824
+ return j;
825
+ }
723
826
  function authorPost(path, body){
724
827
  return fetch(path, {
725
828
  method: "POST",
726
829
  headers: {"content-type": "application/json", "x-bareloop-token": TOKEN},
727
830
  body: JSON.stringify(body || {}),
728
- }).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: j}; }); });
831
+ }).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: staleTokenCheck(j, r.status)}; }); });
729
832
  }
730
833
  function authorGet(path){
731
- return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json(); });
834
+ return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json().then(function(j){ return staleTokenCheck(j, r.status); }); });
732
835
  }
733
836
 
734
837
  function wireTabs(tabButtons){
@@ -832,6 +935,8 @@
832
935
  var n = 0;
833
936
  return steps.map(function(s){
834
937
  if(s.noNumber) return -1;
938
+ // P5-R: a step continuing from the leg before is the SAME step — it keeps its number
939
+ if(s.continued && n > 0) return n - 1;
835
940
  n++;
836
941
  return n - 1;
837
942
  });
@@ -845,8 +950,12 @@
845
950
  // jump straight to that SAME part's Audit-tab row (build item B's
846
951
  // click-through), with no re-lookup needed.
847
952
  function partBoxState(part, isLastLivePart){
953
+ // the drafting part happened before the run existed: it is always done
954
+ if(part.kind === "drafting") return "done";
848
955
  if(part.outcome === "green" || part.outcome === "satisfied" || part.outcome === "already-green") return "done";
849
956
  if(typeof part.outcome === "string") return "stopped";
957
+ // P5-R: its leg's halt cut this part off — the map's existing "stopped" state, never "done"
958
+ if(part.stopReason) return "stopped";
850
959
  if(isLastLivePart) return "running";
851
960
  return "done"; // scout/plan/replan/judge: no per-part verdict, but the part itself already happened
852
961
  }
@@ -865,6 +974,7 @@
865
974
  // (a single-attempt step, or the whole "run" box on an old-shape spine)
866
975
  // reads the part's own outcome.
867
976
  function partResultGlyph(part, box){
977
+ if(part.kind === "drafting") return glyphSpan(attemptGlyph("green", false)); // [✓]: drafting is done once the run exists
868
978
  if(partHasNoVerdict(part)) return null;
869
979
  var boxIsLive = box.state === "running";
870
980
  if(box.attempts.length > 1) return box.attempts.map(function(a){ return glyphSpan(attemptGlyph(a.outcome, boxIsLive)); }).join("");
@@ -875,7 +985,9 @@
875
985
  var list = Array.isArray(parts) ? parts : [];
876
986
  return list.map(function(part, idx){
877
987
  var isLastLivePart = !!isLive && idx === list.length - 1;
878
- var occSuffix = part.kind === "step" && part.occurrence > 1 ? " (try " + part.occurrence + ")" : "";
988
+ // "(try N)" is for real in-leg retries only; a step re-entered across a leg boundary is the same step continuing
989
+ var tryN = typeof part.tryNumber === "number" ? part.tryNumber : part.occurrence;
990
+ var occSuffix = part.kind === "step" && part.continued ? " (continued)" : (part.kind === "step" && tryN > 1 ? " (try " + tryN + ")" : "");
879
991
  return {
880
992
  kind: part.kind,
881
993
  partIndex: idx,
@@ -888,6 +1000,9 @@
888
1000
  // Audit tab's expand-to-rounds, read off `box.part.attempts` directly).
889
1001
  attempts: partHasNoVerdict(part) ? [] : (Array.isArray(part.attempts) ? part.attempts : []),
890
1002
  blocked: typeof part.blocked === "number" ? part.blocked : 0,
1003
+ // P5-R: the leg that ran this part ENDED here and the next part is where the next leg picked up
1004
+ resumedNext: part.resumedNext === true,
1005
+ continued: part.continued === true,
891
1006
  part: part,
892
1007
  };
893
1008
  });
@@ -1031,34 +1146,35 @@
1031
1146
  '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
1032
1147
  }
1033
1148
  if(idx < n - 1){
1149
+ // P5-R: where a leg ended and the next leg picked up, the connector is DOTTED (and says so) —
1150
+ // one run, one map; the dotted line is the only mark of the resume.
1151
+ var dash = s.resumedNext ? ' stroke-dasharray="2,4" data-resumed="1"' : '';
1034
1152
  if(nextRow === row){
1035
1153
  var midY = y + boxH / 2;
1036
1154
  if(!rtl){
1037
1155
  var nx = x + boxW + gap;
1038
1156
  body += '<line x1="' + (x + boxW) + '" y1="' + midY + '" x2="' + (nx - 2) + '" y2="' + midY +
1039
- '" stroke="var(--text-dim)" stroke-width="2" marker-end="url(#' + uid + '-arrow)"></line>';
1157
+ '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1040
1158
  } else {
1041
1159
  var px = x - gap - boxW;
1042
1160
  body += '<line x1="' + x + '" y1="' + midY + '" x2="' + (px + boxW + 2) + '" y2="' + midY +
1043
- '" stroke="var(--text-dim)" stroke-width="2" marker-end="url(#' + uid + '-arrow)"></line>';
1161
+ '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1044
1162
  }
1045
1163
  } else {
1046
1164
  var cx = x + boxW / 2;
1047
1165
  body += '<line x1="' + cx + '" y1="' + (y + boxH) + '" x2="' + cx + '" y2="' + (y + rowH - 2) +
1048
- '" stroke="var(--text-dim)" stroke-width="2" marker-end="url(#' + uid + '-arrow)"></line>';
1166
+ '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1049
1167
  }
1050
1168
  }
1051
1169
  });
1052
1170
  return '<svg width="' + totalW + '" height="' + totalH + '" viewBox="0 0 ' + totalW + ' ' + totalH + '" role="img" aria-label="step map">' + body + '</svg>';
1053
1171
  }
1054
- function stepMapLegendHTML(){
1055
- return '<div class="map-legend">' +
1056
- '<span><span class="dot green"></span>done</span>' +
1057
- '<span><span class="dot amber"></span>running</span>' +
1058
- '<span><span class="dot red"></span>stopped</span>' +
1059
- '<span><span class="dot grey"></span>waiting</span>' +
1060
- '<span><span class="dot magenta"></span>died</span>' +
1172
+ // the line-style key under the MAP (hamr 2026-10-04): only the two line styles, never a sign legend
1173
+ function stepMapKeyHTML(steps){
1174
+ var resumed = (steps || []).some(function(b){ return b.resumedNext; });
1175
+ return '<div class="map-key" data-testid="map-key">' +
1061
1176
  '<span>⤾ dashed = retry</span>' +
1177
+ (resumed ? '<span>&middot;&middot;&middot; dotted = resumed</span>' : '') +
1062
1178
  '</div>';
1063
1179
  }
1064
1180
  var lastSteps = null;
@@ -1081,7 +1197,7 @@
1081
1197
  wrapper.hidden = false;
1082
1198
  var w = mapAvailWidth(wrapper);
1083
1199
  lastWidth = w;
1084
- mount.innerHTML = buildStepMapSVG(steps, w) + stepMapLegendHTML();
1200
+ mount.innerHTML = buildStepMapSVG(steps, w) + stepMapKeyHTML(steps);
1085
1201
  // build item B: a map box click jumps to the Audit tab, that same part
1086
1202
  // expanded and scrolled into view — same click-through as the card list,
1087
1203
  // sharing partIndex so the two can never disagree about which row a box
@@ -1101,7 +1217,7 @@
1101
1217
  var w = mapAvailWidth(wrapper);
1102
1218
  if(w === null || w === lastWidth) return;
1103
1219
  lastWidth = w;
1104
- document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapLegendHTML();
1220
+ document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapKeyHTML(lastSteps);
1105
1221
  }
1106
1222
  if(typeof ResizeObserver !== "undefined"){
1107
1223
  var ro = new ResizeObserver(function(){ reflowMap(); });
@@ -1126,11 +1242,55 @@
1126
1242
  if(g === "?") return "magenta";
1127
1243
  return "grey";
1128
1244
  }
1245
+ // ONE spelling of "**word** — reason": the sign and the word come from the server's status table
1246
+ // (src/panel/status.js) — the page never maps an outcome to a word itself. No status = nothing to say.
1247
+ function statusWordHtml(status, reason){
1248
+ if(!status || !status.word) return "";
1249
+ return '<b class="st-word">' + escapeXml(status.word) + '</b>' + (reason ? " \u2014 " + escapeXml(reason) : "");
1250
+ }
1251
+ // local date + time as the header shows it: "10/4/2026, 6:01 PM" ("unknown" when the spine carried no time)
1252
+ function fmtLocalDateTime(iso){
1253
+ var d = iso ? new Date(iso) : null;
1254
+ if(!d || isNaN(d.getTime())) return "unknown";
1255
+ return d.toLocaleString("en-US", {year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit"});
1256
+ }
1257
+ // the right-side header: `[sign] job (run-N) │ **word** — reason · ended 10/4/2026, 6:01 PM` (no capitals: the job
1258
+ // name is shown as written). `bodyHtml` is already escaped.
1259
+ // a run's NAME for a person (P6 item 7): `job (run-N)`, N from the server (`runNo`, one owner) — the page never counts.
1260
+ // A run with no number (an imported bundle's run is not in the run list) falls back to its id.
1261
+ function runLabel(runNo, runid){ return typeof runNo === "number" ? "run-" + runNo : String(runid || ""); }
1262
+ function runName(job, runNo, runid){ return job + " (" + runLabel(runNo, runid) + ")"; }
1263
+ function setRunHeader(sign, nameText, bodyHtml){
1264
+ document.getElementById("active-wf-name").textContent = nameText;
1265
+ document.getElementById("active-wf-dot").className = "dot " + glyphClass(sign);
1266
+ var st = document.getElementById("active-wf-verdict");
1267
+ st.innerHTML = bodyHtml ? '<span class="rp-sep">\u2502</span>' + bodyHtml : "";
1268
+ st.removeAttribute("title");
1269
+ }
1270
+ // "step 2 of 3" for a live run: the step it is on (first one not yet done) of the real steps
1271
+ function liveStepText(detail){
1272
+ var rs = realSteps(detail.steps || []);
1273
+ if(rs.length === 0) return "";
1274
+ var done = rs.filter(function(s){ return s.state === "done"; }).length;
1275
+ return "step " + Math.min(done + 1, rs.length) + " of " + rs.length;
1276
+ }
1277
+ function runHeaderBody(detail){
1278
+ var live = detail.status && detail.status.word === "running";
1279
+ var reason = detail.ended ? detail.ended.line : (live ? liveStepText(detail) : "");
1280
+ var when = detail.endedAt ? ("ended " + fmtLocalDateTime(detail.endedAt)) : ("started " + fmtLocalDateTime(detail.startedAt || detail.at));
1281
+ return statusWordHtml(detail.status, reason) + " \u00B7 " + escapeXml(when);
1282
+ }
1283
+
1284
+ // P5-R: a resumed run is the SAME run — one card, never two. This is only a small tag on it.
1285
+ function resumedTagHtml(count, testid){
1286
+ return (typeof count === "number" && count > 0)
1287
+ ? '<span class="wf-meta wf-resumed" data-testid="' + escapeXml(testid) + '">resumed &times;' + count + '</span>' : '';
1288
+ }
1129
1289
 
1130
1290
  // ---- Runs tab: ONE run-row builder shared by History (flat) and the
1131
1291
  // Workflows view's per-job expansion (item 2, 2026-09-26 merge) — the two
1132
1292
  // never drift on how a run row reads.
1133
- function buildRunRowEl(r){
1293
+ function buildRunRowEl(r, compact){
1134
1294
  var row = document.createElement("div");
1135
1295
  // build item (2026-09-26 fix): compute selected state here, off the
1136
1296
  // module's own `currentRunid`, so a row freshly built by a RE-RENDER
@@ -1145,8 +1305,16 @@
1145
1305
  row.setAttribute("tabindex", "0");
1146
1306
  row.setAttribute("role", "button");
1147
1307
  row.setAttribute("aria-pressed", selected ? "true" : "false");
1148
- var histFullName = r.job + ' (' + r.runid + ')';
1149
- if(r.fileMissing){
1308
+ var histFullName = runName(r.job, r.runNo, r.runid);
1309
+ if(r.starting){
1310
+ row.innerHTML =
1311
+ '<span class="wf-line1"><span class="dot amber"></span>' +
1312
+ '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1313
+ '<span class="wf-meta-line">' +
1314
+ '<span class="wf-meta" data-testid="hist-starting-' + escapeXml(r.runid) + '">starting…</span>' +
1315
+ '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1316
+ '</span>';
1317
+ } else if(r.fileMissing){
1150
1318
  row.innerHTML =
1151
1319
  '<span class="wf-line1"><span class="dot grey"></span>' +
1152
1320
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
@@ -1154,15 +1322,31 @@
1154
1322
  '<span class="wf-meta">file missing</span>' +
1155
1323
  '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1156
1324
  '</span>';
1325
+ } else if(compact){
1326
+ // an EXPANDED per-run row under a workflow (hamr 2026-10-06), 2 lines, left-aligned:
1327
+ // `[sign] (run-N) **word** — reason` / `$ ($drafting) · wall · date`
1328
+ row.innerHTML =
1329
+ '<span class="wf-line1 wf-compact"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1330
+ '<span class="wf-name wf-runno" title="' + escapeXml(histFullName) + '" data-testid="hist-runno-' + escapeXml(r.runid) + '">(' + escapeXml(runLabel(r.runNo, r.runid)) + ')</span>' +
1331
+ '<span class="wf-ended wf-ended-inline" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span></span>' +
1332
+ '<span class="wf-meta-line">' +
1333
+ '<span class="wf-meta">' + escapeXml(rowSpendText(r, true)) + '</span>' +
1334
+ '<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
1335
+ '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
1336
+ resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
1337
+ '</span>';
1157
1338
  } else {
1339
+ // History card: line 1 sign + job (run-N); line 2 **word** — reason; line 3 the facts
1158
1340
  row.innerHTML =
1159
1341
  '<span class="wf-line1"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1160
1342
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1343
+ (r.status ? '<span class="wf-ended" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span>' : '') +
1161
1344
  '<span class="wf-meta-line">' +
1162
1345
  '<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
1163
1346
  '<span class="wf-meta">' + escapeXml(rowSpendText(r)) + '</span>' +
1164
- '<span class="wf-meta">' + escapeXml(r.wall) + '</span>' +
1347
+ '<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
1165
1348
  '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
1349
+ resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
1166
1350
  '</span>';
1167
1351
  }
1168
1352
  row.addEventListener("click", function(){ selectRun(r.runid, row, ".hist-row"); document.getElementById("tab-run").click(); scrollRunIntoViewMobile(); });
@@ -1203,12 +1387,16 @@
1203
1387
  runs: jobRuns,
1204
1388
  runCount: jobRuns.length,
1205
1389
  lastRunid: last.runid,
1390
+ lastRunNo: last.runNo,
1206
1391
  lastAt: last.at,
1207
1392
  lastGlyph: last.glyph || "▶",
1393
+ lastStatus: last.status || null,
1394
+ lastEndedLine: last.endedLine || null,
1395
+ lastResumedCount: last.resumedCount || 0,
1208
1396
  lastCheckType: last.checkType || "unknown",
1209
1397
  lastModel: last.model || null,
1210
- lastSpend: rowSpendText(last),
1211
- lastWall: last.wall || "unknown",
1398
+ lastSpend: rowSpendText(last, true),
1399
+ lastWall: rowWallText(last) || "unknown",
1212
1400
  lastDate: last.date,
1213
1401
  };
1214
1402
  });
@@ -1333,16 +1521,16 @@
1333
1521
  row.setAttribute("role", "button");
1334
1522
  row.setAttribute("aria-expanded", expanded ? "true" : "false");
1335
1523
  row.setAttribute("aria-pressed", isSelected ? "true" : "false");
1336
- var wfFullName = g.job + ' (' + g.lastRunid + ')';
1524
+ var wfFullName = runName(g.job, g.lastRunNo, g.lastRunid);
1337
1525
  row.innerHTML =
1338
- '<span class="wf-line1"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
1339
- '<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span></span>' +
1526
+ '<span class="wf-line1 wf-compact"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
1527
+ '<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span>' +
1528
+ (g.lastStatus ? '<span class="wf-ended wf-ended-inline" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') + '</span>' +
1340
1529
  '<span class="wf-meta-line">' +
1341
1530
  '<span class="wf-meta">' + escapeXml(g.lastCheckType) + '</span>' +
1342
1531
  '<span class="wf-meta">' + escapeXml(g.lastSpend) + '</span>' +
1343
1532
  '<span class="wf-meta">' + escapeXml(g.lastWall) + '</span>' +
1344
1533
  '<span class="wf-meta">' + escapeXml(g.lastDate || "unknown") + '</span>' +
1345
- '<span class="wf-meta">' + g.runCount + ' run' + (g.runCount === 1 ? '' : 's') + '</span>' +
1346
1534
  '</span>';
1347
1535
  row.addEventListener("click", function(){
1348
1536
  // a single-run job has nothing to expand — the click still opens
@@ -1368,7 +1556,7 @@
1368
1556
  if(expanded){
1369
1557
  var runsWrap = document.createElement("div");
1370
1558
  runsWrap.className = "wf-runs";
1371
- childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r)); });
1559
+ childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1372
1560
  wrap.appendChild(runsWrap);
1373
1561
  }
1374
1562
  el.appendChild(wrap);
@@ -1387,7 +1575,7 @@
1387
1575
  function filterBarHTML(scope){
1388
1576
  return (
1389
1577
  '<div class="filterbar" data-scope="' + scope + '" data-testid="' + scope + '-filters">' +
1390
- '<input type="text" class="search-input" placeholder="search job, run id or model…" ' +
1578
+ '<input type="text" class="search-input" placeholder="search job, run-N, run id or model…" ' +
1391
1579
  'id="' + scope + '-filter-search" data-testid="' + scope + '-filter-search" autocomplete="off">' +
1392
1580
  '<div class="chips" role="group" aria-label="Filter">' +
1393
1581
  '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="checkType" data-filter-value="deterministic" title="deterministic" data-testid="chip-' + scope + '-ct-deterministic">D</button>' +
@@ -1416,15 +1604,18 @@
1416
1604
  // means "no search filter" — everything passes it, same posture as an
1417
1605
  // empty chip group). item C (2026-09-26): also matches the run's model
1418
1606
  // name (e.g. "deepseek", "sonnet") — `model` is `null`/absent on an older
1419
- // archived run, same posture as a missing job/runid.
1420
- function matchesSearch(job, runid, model, query){
1607
+ // archived run, same posture as a missing job/runid. Also the run's shown
1608
+ // name: `run-N` and `job (run-N)` (N is the server's `runNo`, never counted here).
1609
+ function matchesSearch(job, runid, model, query, runNo){
1421
1610
  if(!query) return true;
1422
1611
  var q = String(query).trim().toLowerCase();
1423
1612
  if(!q) return true;
1424
1613
  var j = String(job || "").toLowerCase();
1425
1614
  var r = String(runid || "").toLowerCase();
1426
1615
  var m = String(model || "").toLowerCase();
1427
- return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1;
1616
+ var label = typeof runNo === "number" ? "run-" + runNo : "";
1617
+ var shown = label ? j + " (" + label + ")" : "";
1618
+ return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1 || (label !== "" && (label.indexOf(q) !== -1 || shown.indexOf(q) !== -1));
1428
1619
  }
1429
1620
  function filterRuns(runs, filters, nowMs){
1430
1621
  nowMs = (typeof nowMs === "number") ? nowMs : Date.now();
@@ -1435,7 +1626,7 @@
1435
1626
  return (runs || []).filter(function(r){
1436
1627
  if(ct.length && ct.indexOf(r.checkType) === -1) return false;
1437
1628
  if(rs.length && rs.indexOf(r.glyph) === -1) return false;
1438
- if(!matchesSearch(r.job, r.runid, r.model, q)) return false;
1629
+ if(!matchesSearch(r.job, r.runid, r.model, q, r.runNo)) return false;
1439
1630
  if(time !== "all"){
1440
1631
  var days = time === "7d" ? 7 : 30;
1441
1632
  var t = typeof r.at === "string" ? Date.parse(r.at) : NaN;
@@ -1585,6 +1776,10 @@
1585
1776
  });
1586
1777
  document.getElementById("wf-list").hidden = runsViewMode !== "workflows";
1587
1778
  document.getElementById("history-list").hidden = runsViewMode !== "history";
1779
+ // P5 item 4: Import lives on the Workflows view only
1780
+ document.getElementById("wf-import").hidden = runsViewMode !== "workflows";
1781
+ document.getElementById("import-list").hidden = runsViewMode !== "workflows";
1782
+ if(runsViewMode !== "workflows") document.getElementById("import-box").hidden = true;
1588
1783
  }
1589
1784
  document.querySelectorAll('.chip[data-runs-view]').forEach(function(chip){
1590
1785
  chip.addEventListener("click", function(){
@@ -1622,6 +1817,264 @@
1622
1817
  document.querySelectorAll(".wf-row, .hist-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1623
1818
  }
1624
1819
 
1820
+ // ---- P5 item 4: IMPORT (read only) — the folder browser, the imported rows, the view ----
1821
+ // The folder listing, the import and every re-read are SERVER routes behind the human guard
1822
+ // (src/panel/importroutes.js): the page only asks and paints. An imported job has ONE button: Reuse workflow.
1823
+ var currentImportId = null;
1824
+ var importBox = document.getElementById("import-box");
1825
+ var importPathEl = document.getElementById("import-path");
1826
+ var importErrEl = document.getElementById("import-err");
1827
+ function importShowErr(msg){ importErrEl.textContent = msg; importErrEl.hidden = !msg; }
1828
+ // the last folder the browser showed — per-viewer convenience only (try/catch, the browser works without it)
1829
+ var IMPORT_DIR_KEY = "bareloop.importDir";
1830
+ function rememberImportDir(path){
1831
+ try{ localStorage.setItem(IMPORT_DIR_KEY, path); }catch(e){ /* best-effort only */ }
1832
+ }
1833
+ function recallImportDir(){
1834
+ try{ var v = localStorage.getItem(IMPORT_DIR_KEY); if(typeof v === "string") return v; }catch(e){ /* per-viewer convenience only */ }
1835
+ return "";
1836
+ }
1837
+ // one directory: its folders (names + a "bundle" tag), a way up, and which folder the server actually chose
1838
+ function importLoadDir(path){
1839
+ importShowErr("");
1840
+ return authorGet("/api/fs/list?path=" + encodeURIComponent(path || "")).then(function(r){
1841
+ if(!r || !r.ok){
1842
+ // a refused path leaves ONLY the error: no stale "showing <folder>" line, no stale list
1843
+ document.getElementById("import-resolved").textContent = "";
1844
+ document.getElementById("import-entries").innerHTML = "";
1845
+ importShowErr((r && r.error) || "could not open that folder");
1846
+ return false;
1847
+ }
1848
+ rememberImportDir(r.path);
1849
+ importPathEl.value = r.path;
1850
+ document.getElementById("import-resolved").textContent = "showing " + r.path + (r.requested !== r.path ? " (you asked for " + r.requested + ")" : "") + (r.bundle ? " — this folder is an exported job" : "");
1851
+ var el = document.getElementById("import-entries");
1852
+ el.innerHTML = "";
1853
+ if(r.parent){
1854
+ var up = document.createElement("button");
1855
+ up.type = "button"; up.className = "import-entry import-up"; up.setAttribute("data-testid", "import-up");
1856
+ up.setAttribute("aria-label", "up one folder"); up.setAttribute("title", "up one folder");
1857
+ up.textContent = "..";
1858
+ up.addEventListener("click", function(){ importLoadDir(r.parent); });
1859
+ el.appendChild(up);
1860
+ }
1861
+ r.entries.forEach(function(e){
1862
+ var b = document.createElement("button");
1863
+ b.type = "button"; b.className = "import-entry"; b.setAttribute("data-testid", "import-entry-" + e.name);
1864
+ b.setAttribute("aria-pressed", "false");
1865
+ b.innerHTML = escapeXml(e.name) + ' <span class="tag">(bundle)</span>';
1866
+ b.addEventListener("click", function(){
1867
+ el.querySelectorAll(".import-entry").forEach(function(x){ x.classList.remove("selected"); x.setAttribute("aria-pressed", "false"); });
1868
+ b.classList.add("selected"); b.setAttribute("aria-pressed", "true");
1869
+ importPathEl.value = e.path; importShowErr("");
1870
+ });
1871
+ el.appendChild(b);
1872
+ });
1873
+ if(!r.entries.length) el.insertAdjacentHTML("beforeend", '<div class="hint" data-testid="import-empty">no job bundles here (searched ' + r.depth + ' folders deep)</div>');
1874
+ if(r.stopped) el.insertAdjacentHTML("beforeend", '<div class="hint" data-testid="import-capped">stopped after ' + r.visited + ' folders \u2014 go into a narrower folder</div>');
1875
+ else if(r.truncated) el.insertAdjacentHTML("beforeend", '<div class="hint" data-testid="import-capped">stopped after ' + r.entries.length + ' job bundles \u2014 go into a narrower folder</div>');
1876
+ return true;
1877
+ }).catch(function(x){ importShowErr("could not reach the panel: " + x.message); });
1878
+ }
1879
+ document.getElementById("wf-import").addEventListener("click", function(){
1880
+ importBox.hidden = !importBox.hidden;
1881
+ if(importBox.hidden) return;
1882
+ var want = importPathEl.value || recallImportDir();
1883
+ importLoadDir(want).then(function(ok){ if(ok === false && want && !importPathEl.value) { importShowErr(""); return importLoadDir(""); } });
1884
+ });
1885
+ document.getElementById("import-open").addEventListener("click", function(){ importLoadDir(importPathEl.value); });
1886
+ importPathEl.addEventListener("keydown", function(e){ if(e.key === "Enter"){ e.preventDefault(); importLoadDir(importPathEl.value); } });
1887
+ document.getElementById("import-cancel").addEventListener("click", function(){ importBox.hidden = true; importShowErr(""); });
1888
+ document.getElementById("import-go").addEventListener("click", function(){
1889
+ importShowErr("");
1890
+ authorPost("/api/imports", {path: importPathEl.value}).then(function(res){
1891
+ if(!res.body || !res.body.ok){ importShowErr((res.body && res.body.error) || ("refused (" + res.status + ")")); return; }
1892
+ importBox.hidden = true;
1893
+ return loadImports().then(function(){ return selectImport(res.body.id); }).then(function(){ revealImportRow(res.body.id); scrollRunIntoViewMobile(); });
1894
+ }).catch(function(x){ importShowErr("could not reach the panel: " + x.message); });
1895
+ });
1896
+
1897
+ // after [Import] the box above the list closes and the left pane shrinks: bring the job just opened into view
1898
+ function revealImportRow(id){
1899
+ var row = document.querySelector('[data-testid="import-row-' + id + '"]');
1900
+ if(row && row.scrollIntoView) row.scrollIntoView({block: "nearest"});
1901
+ }
1902
+
1903
+ // the imported rows (Workflows view): every bundle is re-read by the server each time this list is fetched
1904
+ function loadImports(){
1905
+ return authorGet("/api/imports").then(function(r){ renderImports((r && r.imports) || []); }).catch(function(){ /* keep what is shown */ });
1906
+ }
1907
+ function renderImports(list){
1908
+ var el = document.getElementById("import-list");
1909
+ el.innerHTML = "";
1910
+ list.forEach(function(imp){
1911
+ var row = document.createElement("div");
1912
+ row.className = "wf-row" + (imp.id === currentImportId ? " selected" : "");
1913
+ row.setAttribute("data-testid", "import-row-" + imp.id);
1914
+ row.setAttribute("tabindex", "0");
1915
+ row.setAttribute("role", "button");
1916
+ row.setAttribute("aria-pressed", imp.id === currentImportId ? "true" : "false");
1917
+ row.innerHTML =
1918
+ '<span class="wf-line1"><span class="wf-caret"></span><span class="dot ' + (imp.runStatus ? glyphClass(imp.runStatus.sign) : "grey") + '"></span>' +
1919
+ '<span class="wf-name" title="' + escapeXml(imp.dir) + '">' + escapeXml(imp.job) + '</span></span>' +
1920
+ (imp.runStatus ? '<span class="wf-ended" data-testid="import-ended-' + escapeXml(imp.id) + '">' + statusWordHtml(imp.runStatus, imp.runReason) + '</span>' : '') +
1921
+ '<span class="wf-meta-line"><span class="wf-meta wf-imported" data-testid="import-tag-' + escapeXml(imp.id) + '">imported · view only</span></span>' +
1922
+ (imp.status !== "ok" ? '<span class="imp-bad" data-testid="import-changed-' + escapeXml(imp.id) + '">' + escapeXml(imp.statusText || "changed since import") + '</span>' : "");
1923
+ row.addEventListener("click", function(){ selectImport(imp.id); scrollRunIntoViewMobile(); });
1924
+ row.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); row.click(); } });
1925
+ el.appendChild(row);
1926
+ });
1927
+ }
1928
+ function hideImportView(){
1929
+ currentImportId = null;
1930
+ document.getElementById("import-view").hidden = true;
1931
+ document.getElementById("run-counters").hidden = false;
1932
+ document.getElementById("run-empty").textContent = "select a run from Workflows or History";
1933
+ document.getElementById("audit-select-empty").textContent = "select a run first";
1934
+ document.querySelectorAll("#import-list .wf-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed", "false"); });
1935
+ }
1936
+ function selectImport(id){
1937
+ clearSelection();
1938
+ currentRunid = null;
1939
+ runIsLive = false;
1940
+ resumeMode = null;
1941
+ currentImportId = id;
1942
+ document.querySelectorAll("#import-list .wf-row").forEach(function(r){
1943
+ var on = r.getAttribute("data-testid") === "import-row-" + id;
1944
+ r.classList.toggle("selected", on); r.setAttribute("aria-pressed", on ? "true" : "false");
1945
+ });
1946
+ return authorGet("/api/imports/" + encodeURIComponent(id)).then(function(v){
1947
+ if(currentImportId !== id) return;
1948
+ renderImportView(v);
1949
+ }).catch(function(x){ if(currentImportId === id) renderImportView({ok: false, error: x.message}); });
1950
+ }
1951
+ // An imported job reads like a normal run page: the SAME Run / Audit / Job tabs. [Reuse workflow] sits in the top
1952
+ // action row, then the IMPORTED box (what the folder says about itself), then the latest GREEN run in the bundle
1953
+ // painted by the very same renderRun a run uses — its SUMMARY and MAP, and its Audit when the bundle carries the
1954
+ // run's log; a bundle with only a bridge paints the bridge's numbers through that same renderer ("not recorded"
1955
+ // where the bridge is silent); a bundle with no green says so. The Job tab carries the spec. Nothing here writes.
1956
+ function importedHeader(v){
1957
+ var tag = "imported \u00B7 view only";
1958
+ if(v.runStatus) setRunHeader(v.runStatus.sign, v.job, statusWordHtml(v.runStatus, v.runReason) + " \u00B7 " + tag);
1959
+ else setRunHeader("\u00B7", v.job, tag);
1960
+ }
1961
+ function importedNoLog(){
1962
+ document.getElementById("audit-body").hidden = true;
1963
+ var auditEmpty = document.getElementById("audit-select-empty");
1964
+ auditEmpty.hidden = false;
1965
+ auditEmpty.textContent = "no log — this job ran on another machine";
1966
+ }
1967
+ // the green run below the IMPORTED box: spine (read through the ordinary run routes), bridge, or nothing
1968
+ function paintImportedRun(v){
1969
+ var run = v.run || {kind: "none"};
1970
+ var content = document.getElementById("run-content");
1971
+ var guard = function(){ return currentImportId === v.id; };
1972
+ document.getElementById("step-list").innerHTML = "";
1973
+ if(run.kind === "spine"){
1974
+ currentRunid = run.id;
1975
+ getJSON("/api/runs/" + encodeURIComponent(run.id)).then(function(d){
1976
+ if(!guard()) return;
1977
+ d.ended = null; d.resume = null; d.live = false; d.stopping = false;
1978
+ renderRun(d);
1979
+ importedHeader(v);
1980
+ document.getElementById("run-actions").hidden = true;
1981
+ document.getElementById("run-counters").hidden = true; // the SUMMARY headline already says it
1982
+ content.hidden = false;
1983
+ }).catch(function(e){ if(guard()) paintImportedRunPlain(v, "could not read this run: " + e.message); });
1984
+ getJSON("/api/runs/" + encodeURIComponent(run.id) + "/audit").then(function(result){
1985
+ if(guard()) renderAudit(result);
1986
+ }).catch(function(){ if(guard()) renderAudit(null); });
1987
+ return;
1988
+ }
1989
+ importedNoLog();
1990
+ if(run.kind === "bridge" && run.detail){
1991
+ renderRun(run.detail);
1992
+ importedHeader(v);
1993
+ importedNoLog();
1994
+ document.getElementById("run-actions").hidden = true;
1995
+ document.getElementById("run-counters").hidden = true;
1996
+ document.getElementById("step-list").innerHTML = "";
1997
+ if(!run.detail.parts || run.detail.parts.length === 0){
1998
+ document.getElementById("map-box").hidden = false;
1999
+ document.getElementById("map-mount").innerHTML = '<div class="hint" data-testid="import-no-steps">no steps recorded</div>';
2000
+ }
2001
+ content.hidden = false;
2002
+ return;
2003
+ }
2004
+ paintImportedRunPlain(v, "no green run in this bundle");
2005
+ }
2006
+ // a plain sentence in the SUMMARY box and nothing else (no counters, no map, no cards)
2007
+ function paintImportedRunPlain(v, text){
2008
+ var box = document.getElementById("run-summary");
2009
+ box.className = "summary-box";
2010
+ box.innerHTML = '<div class="summary-row"><span class="sum-val" data-testid="import-no-green">' + escapeXml(text) + '</span></div>';
2011
+ document.getElementById("run-counters").hidden = true;
2012
+ document.getElementById("map-box").hidden = true;
2013
+ document.getElementById("step-list").innerHTML = "";
2014
+ document.getElementById("run-actions").hidden = true;
2015
+ importedNoLog();
2016
+ document.getElementById("run-content").hidden = false;
2017
+ }
2018
+ function renderImportView(v){
2019
+ var el = document.getElementById("import-view");
2020
+ el.hidden = false;
2021
+ document.getElementById("run-empty").hidden = true;
2022
+ document.getElementById("run-content").hidden = true;
2023
+ importedNoLog();
2024
+ if(!v || !v.ok){
2025
+ setRunHeader("\u00B7", "imported job", "imported \u00B7 view only");
2026
+ el.innerHTML = '<div class="imp-bad">' + escapeXml((v && v.error) || "could not read this imported job") + '</div>';
2027
+ renderJob(null);
2028
+ return;
2029
+ }
2030
+ importedHeader(v);
2031
+ var h = v.history || {greens: 0, reds: 0, total: 0, recent: []};
2032
+ var row = function(label, text, testid){
2033
+ return '<div class="summary-row"><span class="sum-label">' + label + '</span><span class="sum-val" data-testid="' + testid + '">' + escapeXml(text) + '</span></div>';
2034
+ };
2035
+ var recent = (h.recent || []).map(function(r){
2036
+ var line = (r.at || "").slice(0, 10) || "—";
2037
+ line += " · " + (r.outcome || "?") + (typeof r.costUsd === "number" ? " · " + panelMoney(r.costUsd) : "") + (r.runid ? " · run " + r.runid : "");
2038
+ return '<div class="summary-row"><span class="sum-label"></span><span class="sum-val">' + escapeXml(line) + '</span></div>';
2039
+ }).join("");
2040
+ var run = v.run || {kind: "none"};
2041
+ var runsHere = typeof v.runsHere === "number" ? v.runsHere : 0;
2042
+ var shown = run.kind === "spine" ? ("latest green run here, " + (run.runid || "")) :
2043
+ (run.kind === "bridge" ? ("the exported green run" + (run.runid ? " " + run.runid : "") + (run.at ? ", " + run.at.slice(0, 10) : "")) : "no green run");
2044
+ el.innerHTML =
2045
+ '<div class="run-actions" data-testid="run-actions-import"><button class="btn" type="button" data-testid="btn-reuse-import">Reuse workflow</button></div>' +
2046
+ '<div class="summary-box imported-box" data-testid="import-box">' +
2047
+ (v.status !== "ok" ? '<div class="imp-bad" data-testid="import-view-changed">' + escapeXml(v.changedLine || v.statusText || "changed since import") + '</div>' : "") +
2048
+ row("runs here:", runsHere === 0 ? "none yet" : (runsHere + (runsHere === 1 ? " run" : " runs")), "import-no-runs") +
2049
+ row("exported:", h.greens + " green · " + h.reds + " not green", "import-history") +
2050
+ recent +
2051
+ row("this machine:", v.approvedText || "unknown", "import-approved") +
2052
+ row("shown below:", shown, "import-shown") +
2053
+ '</div>';
2054
+ el.querySelector('[data-testid="btn-reuse-import"]').addEventListener("click", function(){ reuseImportedWorkflow(v.id); });
2055
+ var nr = "not recorded";
2056
+ renderJob({
2057
+ job: v.job, resolved: false, checkType: v.checkType || nr, model: v.model || nr,
2058
+ goal: v.goal || nr, success: v.success || nr, guardrails: v.guardrails || nr,
2059
+ source: nr, destination: nr, tools: nr,
2060
+ budgetUsd: (typeof v.budgetUsd === "number") ? v.budgetUsd : null,
2061
+ maxWallMs: (typeof v.maxWallMs === "number") ? v.maxWallMs : null,
2062
+ note: "imported · view only — the exported job's own spec",
2063
+ });
2064
+ paintImportedRun(v);
2065
+ document.getElementById("tab-run").click();
2066
+ }
2067
+ // Reuse workflow, on an IMPORTED job: the server reads the bundle again now and prefills from its signed spec.
2068
+ function reuseImportedWorkflow(id){
2069
+ authorGet("/api/author/start-from?import=" + encodeURIComponent(id)).then(function(r){
2070
+ if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2071
+ document.getElementById("tab-chat").click();
2072
+ document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: null, importId: id, prefill: r}}));
2073
+ }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2074
+ }
2075
+ loadImports();
2076
+ document.getElementById("tab-runs").addEventListener("click", function(){ loadImports(); });
2077
+
1625
2078
  // ---- Run tab ----
1626
2079
  // panelMoney — hamr's ruling 2026-09-28 ("panel money 2-decimals"): every
1627
2080
  // money display in THIS page (Run tab summary/counters, Runs list rows,
@@ -1667,18 +2120,30 @@
1667
2120
  * complete run whose drafting share is itself incomplete — hamr's ruling:
1668
2121
  * a reader summing the two must never read the leading number as exact
1669
2122
  * when the bracket next to it isn't). `draftSpendComplete === false` marks
1670
- * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX".
2123
+ * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX". `shortForm` drops the word
2124
+ * "drafting" (the Runs list cards only — "$5.00 ($0.08)").
1671
2125
  */
1672
- function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd){
2126
+ function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd, shortForm){
1673
2127
  var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
1674
2128
  var draftFloor = hasDraft && draftSpendComplete === false;
1675
2129
  var core = (spendCoreVal === null || spendCoreVal === undefined)
1676
2130
  ? "unknown"
1677
2131
  : ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
1678
- var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
2132
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
1679
2133
  var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
1680
2134
  return core + draftPart + capPart;
1681
2135
  }
2136
+ // P5 item 6: a LIVE row (no job-end yet, runner alive) reads the same running floors the
2137
+ // right pane reads — `liveSpendText`/`liveWallPhrase` are the one spelling — and a row whose
2138
+ // spine is not written yet reads "starting…", never "file missing" or "unknown".
2139
+ function rowIsLive(r){
2140
+ return r.glyph === "▶" && !r.died && !r.starting && (r.spentUsd === null || r.spentUsd === undefined);
2141
+ }
2142
+ function rowWallText(r){
2143
+ if(r.starting) return "just started";
2144
+ if(rowIsLive(r)) return liveWallPhrase(r.wallFloorMs);
2145
+ return r.wall;
2146
+ }
1682
2147
  /**
1683
2148
  * The Runs list row / Workflows grouping's own spend text — off the
1684
2149
  * numeric fields `/api/runs`' row now carries (never `r.spend`, which is
@@ -1689,11 +2154,13 @@
1689
2154
  * is a floor only when its own drafting share is incomplete.
1690
2155
  * @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
1691
2156
  */
1692
- function rowSpendText(r){
2157
+ function rowSpendText(r, shortForm){
2158
+ if(r.starting) return "starting…";
2159
+ if(rowIsLive(r)) return liveSpendText(r.spendFloorUsd, r.draftSpentUsd, r.draftSpendComplete, shortForm);
1693
2160
  var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
1694
2161
  var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
1695
2162
  var coreFloor = !!r.died || draftIncomplete;
1696
- return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete);
2163
+ return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete, undefined, shortForm);
1697
2164
  }
1698
2165
  function duration(ms){
1699
2166
  if(ms === null || ms === undefined) return "unknown";
@@ -1716,11 +2183,11 @@
1716
2183
  * @param {number|null} spendFloorUsd @param {number|null} draftSpentUsd
1717
2184
  * @param {boolean|null} draftSpendComplete @returns {string}
1718
2185
  */
1719
- function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete){
2186
+ function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete, shortForm){
1720
2187
  if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
1721
2188
  var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
1722
2189
  var draftFloor = hasDraft && draftSpendComplete === false;
1723
- var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
2190
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
1724
2191
  return panelMoney(spendFloorUsd) + " so far" + draftPart;
1725
2192
  }
1726
2193
  /** build item 6 — the matching wall-clock phrase: "running <duration>",
@@ -1807,6 +2274,11 @@
1807
2274
  if(!detail.model) return "not recorded";
1808
2275
  return detail.provider ? (detail.model + " (" + detail.provider + ")") : detail.model;
1809
2276
  }
2277
+ // an imported job's bridge names the tools its green run used, with no counts
2278
+ function bridgeToolsLine(list){
2279
+ if(!Array.isArray(list) || list.length === 0) return "not recorded";
2280
+ return list.map(function(k){ return toolDisplayLabel(k); }).join(" \u00b7 ");
2281
+ }
1810
2282
  function toolsLine(behaviour){
1811
2283
  if(!behaviour) return "unknown (no log saved)";
1812
2284
  if(behaviour.totalCalls === 0) return "0 calls";
@@ -1858,36 +2330,199 @@
1858
2330
  else { row.hidden = true; val.textContent = ""; }
1859
2331
  }
1860
2332
 
1861
- function renderRun(detail){
1862
- var name = document.getElementById("active-wf-name");
1863
- var dot = document.getElementById("active-wf-dot");
1864
- var verdict = document.getElementById("active-wf-verdict");
1865
- var date = document.getElementById("active-wf-date");
2333
+ // ---- P5: the ENDED block (Audit tab, first block, every finished run) and Resume ----
2334
+ // The sentences are CODE-OWNED on the server (`endedFor`, src/panel/server.js) — this only
2335
+ // paints them. `detail.ended` is null while a run is live (no block while running).
2336
+ var lastEndedSig = null;
2337
+ // P5-R (hamr 2026-10-02): Resume opens the run's OWN Job tab. There ONLY the money cap and the time cap are
2338
+ // editable; every other field is the signed job, shown read-only exactly as the tab already shows it. The click
2339
+ // on [Sign & resume] is the signature when a cap changed. A resume is the SAME run: no new card, no new job.
2340
+ var resumeMode = null;
2341
+ var lastJobShown = null;
2342
+ function renderEnded(detail){
2343
+ var box = document.getElementById("ended-block");
2344
+ var e = detail.ended;
2345
+ if(!e){ box.hidden = true; box.innerHTML = ""; lastEndedSig = null; return; }
2346
+ // rebuilt only when what it shows changed
2347
+ var sig2 = detail.runid + "|" + JSON.stringify(e) + "|" + JSON.stringify(detail.resume);
2348
+ if(sig2 === lastEndedSig) return;
2349
+ lastEndedSig = sig2;
2350
+ box.hidden = false;
2351
+ box.className = "ended-block " + (detail.died ? "magenta" : (detail.glyph === "✓" ? "green" : "red"));
2352
+ // text only: the ONE place for [Stop] / [Resume] / [Reuse workflow] is the Run tab's top action row (renderRunActions)
2353
+ box.innerHTML =
2354
+ '<div class="ended-row"><span class="ended-label">ENDED</span><span class="ended-text" data-testid="ended-reason">' + escapeXml(e.reason) + '</span></div>' +
2355
+ '<div class="ended-row"><span class="ended-label">NEXT</span><span class="ended-text" data-testid="ended-next">' + escapeXml(e.next) + '</span></div>';
2356
+ }
2357
+ // ---- P5 item 5: the run's ACTION AREA (Run tab, above the counters) — Stop while the run is live, Resume once
2358
+ // it ended in a way the engine would accept (the SAME button as the Ended block's, same Job-tab resume mode).
2359
+ // The Ended block (Audit tab) is text only and carries no buttons.
2360
+ var stopAsked = {};
2361
+ // Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
2362
+ // hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
2363
+ function reuseWorkflow(runid){
2364
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid)).then(function(r){
2365
+ if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2366
+ document.getElementById("tab-chat").click();
2367
+ document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
2368
+ }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2369
+ }
2370
+ function renderRunActions(detail){
2371
+ var bar = document.getElementById("run-actions");
2372
+ var html = "";
2373
+ var live = !!detail.live && !detail.died && !detail.ended;
2374
+ if(live){
2375
+ if(detail.stopping || stopAsked[detail.runid]){
2376
+ html = '<span class="stopping" data-testid="stopping-note">stopping after this turn…</span>';
2377
+ } else {
2378
+ html = '<button class="btn" type="button" data-testid="btn-stop">Stop</button>';
2379
+ }
2380
+ } else {
2381
+ delete stopAsked[detail.runid];
2382
+ var canResume = !!detail.resume && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "resume"; });
2383
+ if(canResume) html = '<button class="btn primary" type="button" data-testid="btn-resume-run">Resume</button>';
2384
+ }
2385
+ // Reuse workflow: on a GREEN run only (the server's Ended block says so) — a new run of the same signed job
2386
+ if(!live && !detail.died && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "reuse"; })){
2387
+ html += '<button class="btn" type="button" data-testid="btn-reuse">Reuse workflow</button>';
2388
+ }
2389
+ bar.innerHTML = html;
2390
+ bar.hidden = html === "";
2391
+ var reuseBtn = bar.querySelector('[data-testid="btn-reuse"]');
2392
+ if(reuseBtn) reuseBtn.addEventListener("click", function(){ reuseWorkflow(detail.runid); });
2393
+ var stopBtn = bar.querySelector('[data-testid="btn-stop"]');
2394
+ if(stopBtn){
2395
+ stopBtn.addEventListener("click", function(){
2396
+ stopBtn.disabled = true;
2397
+ authorPost("/api/runs/" + encodeURIComponent(detail.runid) + "/stop", {}).then(function(res){
2398
+ if(res.body && res.body.ok){ stopAsked[detail.runid] = true; renderRunActions(detail); }
2399
+ else { stopBtn.disabled = false; bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="stop-err">' + escapeXml((res.body && res.body.error) || ("refused (" + res.status + ")")) + '</span>'); }
2400
+ }).catch(function(x){ stopBtn.disabled = false; bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="stop-err">could not reach the panel: ' + escapeXml(x.message) + '</span>'); });
2401
+ });
2402
+ }
2403
+ var resBtn = bar.querySelector('[data-testid="btn-resume-run"]');
2404
+ if(resBtn) resBtn.addEventListener("click", function(){ openResumeOnJobTab(detail); });
2405
+ }
2406
+ function openResumeOnJobTab(detail){
2407
+ var r = detail.resume || {};
2408
+ resumeMode = {
2409
+ runid: detail.runid, spentUsd: r.spentUsd, spendComplete: r.spendComplete,
2410
+ wallUsedMs: r.wallUsedMs,
2411
+ budget: (r.budgetUsd === null || r.budgetUsd === undefined) ? "" : String(r.budgetUsd),
2412
+ wall: typeof r.maxWallMin === "number" ? String(r.maxWallMin) : null,
2413
+ painted: false,
2414
+ };
2415
+ document.getElementById("tab-details").click();
2416
+ paintResumeMode();
2417
+ }
2418
+ // Turns the Job tab's two cap values into inputs (and shows the Sign box) while a resume is open on this run;
2419
+ // called again after every Job re-render so a re-render never drops the typed numbers.
2420
+ function paintResumeMode(){
2421
+ var box = document.getElementById("resume-job");
2422
+ var moneyLabel = document.getElementById("details-cap-money-label");
2423
+ var timeLabel = document.getElementById("details-cap-time-label");
2424
+ if(!resumeMode || resumeMode.runid !== currentRunid){
2425
+ box.hidden = true; box.innerHTML = "";
2426
+ moneyLabel.textContent = "$ cap"; timeLabel.textContent = "Time cap";
2427
+ return;
2428
+ }
2429
+ var m = resumeMode;
2430
+ // the unit lives in the caption, never as loose text around the input
2431
+ moneyLabel.textContent = "Money cap ($)";
2432
+ timeLabel.textContent = "Time cap (min)";
2433
+ document.getElementById("details-cap-money").innerHTML =
2434
+ '<span class="resume-cap"><input type="number" min="0" step="0.01" id="resume-budget" data-testid="resume-budget" value="' + escapeXml(m.budget) + '"></span>';
2435
+ if(m.wall !== null){
2436
+ document.getElementById("details-cap-time").innerHTML =
2437
+ '<span class="resume-cap"><input type="number" min="1" step="1" id="resume-wall" data-testid="resume-wall" value="' + escapeXml(m.wall) + '"></span>';
2438
+ }
2439
+ var budgetEl = document.getElementById("resume-budget");
2440
+ budgetEl.addEventListener("input", function(){ m.budget = budgetEl.value; });
2441
+ var wallEl = document.getElementById("resume-wall");
2442
+ if(wallEl) wallEl.addEventListener("input", function(){ m.wall = wallEl.value; });
2443
+ box.hidden = false;
2444
+ if(m.painted) return;
2445
+ m.painted = true;
2446
+ var spent = (m.spentUsd === null || m.spentUsd === undefined) ? "unknown" : ((m.spendComplete ? "" : "at least ") + panelMoney(m.spentUsd));
2447
+ box.innerHTML =
2448
+ '<div data-testid="resume-intro">Resume run ' + escapeXml(m.runid) + ' &mdash; the same run, not a new one. Only the money cap and the time cap can change; everything else is the signed job.</div>' +
2449
+ '<div class="hint" data-testid="resume-spent">spent so far ' + escapeXml(spent) +
2450
+ (typeof m.wallUsedMs === "number" && isFinite(m.wallUsedMs) ? ' &middot; time used so far ' + Math.ceil(m.wallUsedMs / 60000) + ' min' : '') + '</div>' +
2451
+ '<div class="ended-actions"><button class="btn primary" type="button" data-testid="btn-sign-resume">Sign &amp; resume</button> ' +
2452
+ '<button class="btn" type="button" data-testid="btn-resume-cancel">Cancel</button></div>' +
2453
+ '<div class="resume-err" id="resume-err" data-testid="resume-err" hidden></div>';
2454
+ var resumeSignBtn = box.querySelector('[data-testid="btn-sign-resume"]');
2455
+ box.querySelector('[data-testid="btn-resume-cancel"]').addEventListener("click", function(){
2456
+ resumeMode = null;
2457
+ renderJob(lastJobShown);
2458
+ });
2459
+ resumeSignBtn.addEventListener("click", function(){
2460
+ var err = document.getElementById("resume-err");
2461
+ err.hidden = true;
2462
+ resumeSignBtn.disabled = true;
2463
+ var body = {budgetUsd: document.getElementById("resume-budget").value};
2464
+ var wall = document.getElementById("resume-wall");
2465
+ if(wall){ body.maxWallMin = wall.value; }
2466
+ return authorPost("/api/runs/" + encodeURIComponent(m.runid) + "/resume", body).then(function(res){
2467
+ if(res.body && res.body.ok){
2468
+ // the SAME run goes on: back to its Run tab, which follows it until it is live again
2469
+ resumeMode = null;
2470
+ renderJob(lastJobShown);
2471
+ document.getElementById("tab-run").click();
2472
+ afterResumeRefresh(m.runid, 8);
2473
+ } else {
2474
+ resumeSignBtn.disabled = false;
2475
+ err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
2476
+ err.hidden = false;
2477
+ }
2478
+ }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
2479
+ });
2480
+ }
2481
+ // After a resume starts, the open run's own page follows it: re-select once the run reads live again (the spine's
2482
+ // new leg appears a moment after the engine starts), a few tries, never forever.
2483
+ function afterResumeRefresh(runid, tries){
2484
+ refreshRunsList(true);
2485
+ setTimeout(function(){
2486
+ if(currentRunid !== runid) return;
2487
+ getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(d){
2488
+ if(currentRunid !== runid) return;
2489
+ if(d.glyph === "▶" && !d.died){ selectRun(runid, null, ".hist-row"); return; }
2490
+ if(tries > 0) afterResumeRefresh(runid, tries - 1);
2491
+ }).catch(function(){ if(tries > 0) afterResumeRefresh(runid, tries - 1); });
2492
+ }, 1500);
2493
+ }
1866
2494
 
2495
+ function renderRun(detail){
1867
2496
  if(detail.fileMissing){
1868
- name.textContent = detail.job + " (" + detail.runid + ")";
1869
- dot.className = "dot grey";
1870
- verdict.textContent = "unknown";
1871
- date.textContent = "file missing";
2497
+ setRunHeader("\u00B7", runName(detail.job, detail.runNo, detail.runid), "file missing");
1872
2498
  document.getElementById("run-empty").hidden = false;
1873
2499
  document.getElementById("run-empty").textContent = "spine file no longer on disk";
1874
2500
  document.getElementById("run-content").hidden = true;
1875
2501
  document.getElementById("audit-select-empty").hidden = false;
2502
+ document.getElementById("audit-select-empty").textContent = "select a run first";
1876
2503
  document.getElementById("audit-body").hidden = true;
1877
2504
  return;
1878
2505
  }
1879
2506
 
2507
+ if(detail.starting){
2508
+ setRunHeader("\u25B6", runName(detail.job, detail.runNo, detail.runid), statusWordHtml(detail.status, "starting\u2026") + " \u00B7 " + escapeXml("started " + fmtLocalDateTime(detail.at)));
2509
+ document.getElementById("run-empty").hidden = false;
2510
+ document.getElementById("run-empty").textContent = "starting… the run has not written its log yet";
2511
+ document.getElementById("run-content").hidden = true;
2512
+ document.getElementById("audit-select-empty").hidden = false;
2513
+ document.getElementById("audit-select-empty").textContent = "starting… the run has not written its log yet";
2514
+ document.getElementById("audit-body").hidden = true;
2515
+ return;
2516
+ }
1880
2517
  document.getElementById("audit-select-empty").hidden = true;
1881
2518
  document.getElementById("audit-body").hidden = false;
2519
+ renderEnded(detail);
1882
2520
 
1883
- name.textContent = detail.job + " (" + detail.runid + ")";
1884
- dot.className = "dot " + glyphClass(detail.glyph);
1885
- verdict.textContent = detail.checkType;
1886
- if(detail.checkTypeTitle){ verdict.title = detail.checkTypeTitle; } else { verdict.removeAttribute("title"); }
1887
- date.textContent = detail.date || "unknown";
2521
+ setRunHeader(detail.glyph, runName(detail.job, detail.runNo, detail.runid), runHeaderBody(detail));
1888
2522
 
1889
2523
  document.getElementById("run-empty").hidden = true;
1890
2524
  document.getElementById("run-content").hidden = false;
2525
+ renderRunActions(detail);
1891
2526
 
1892
2527
  // died: the two REAL figures the panel has (spentUsd/wallMs, both null —
1893
2528
  // no job-end means no final totals were ever recorded) are replaced with
@@ -1904,11 +2539,12 @@
1904
2539
  // false and spentUsd is still null) shows the RUNNING floor instead of
1905
2540
  // "unknown" — the server now derives the same spendFloorUsd/wallFloorMs
1906
2541
  // for a still-running spine that it already derived for a died one.
1907
- var isLiveNoEnd = !detail.died && detail.spentUsd === null;
1908
- var spendText, wallPhrase;
2542
+ var isLiveNoEnd = !detail.died && detail.spentUsd === null && detail.fromBridge !== true;
2543
+ var spendText, wallPhrase, wallText;
1909
2544
  if(isLiveNoEnd){
1910
2545
  spendText = liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete);
1911
2546
  wallPhrase = liveWallPhrase(detail.wallFloorMs);
2547
+ wallText = wallPhrase; // the summary headline reads wallText in BOTH branches
1912
2548
  } else {
1913
2549
  var draftIncomplete = typeof detail.draftSpentUsd === "number" && detail.draftSpentUsd !== null && detail.draftSpendComplete === false;
1914
2550
  var spendCoreVal = detail.died ? detail.spendFloorUsd : detail.spentUsd;
@@ -1921,10 +2557,16 @@
1921
2557
  // still in progress: a live [▶] run (glyph "▶" and not died).
1922
2558
  var isLive = detail.glyph === "▶" && !detail.died;
1923
2559
  wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
2560
+ // an imported job's green run read from its bridge: only what the bridge carries; the rest says so
2561
+ if(detail.fromBridge === true){
2562
+ spendText = typeof detail.spentUsd === "number" ? ((detail.spendComplete === false ? "at least " : "") + panelMoney(detail.spentUsd)) : "not recorded";
2563
+ wallText = typeof detail.wallMs === "number" ? duration(detail.wallMs) : "not recorded";
2564
+ wallPhrase = typeof detail.wallMs === "number" ? ("took " + wallText) : "time not recorded";
2565
+ }
1924
2566
  }
1925
2567
 
1926
2568
  document.getElementById("run-counters").innerHTML =
1927
- spendText + " of " + panelMoney(detail.budgetUsd) +
2569
+ spendText + ((detail.fromBridge === true && typeof detail.budgetUsd !== "number") ? "" : (" of " + panelMoney(detail.budgetUsd))) +
1928
2570
  " &middot; " + wallPhrase;
1929
2571
 
1930
2572
  var box = document.getElementById("run-summary");
@@ -1935,9 +2577,9 @@
1935
2577
  // "0 of 1 done".
1936
2578
  var rSteps = realSteps(detail.steps);
1937
2579
  var stepsDone = rSteps.filter(function(s){ return s.state === "done"; }).length;
1938
- var stepsText = rSteps.length === 0 ? "none started" : ('<span class="num">' + stepsDone + '</span> of <span class="num">' + rSteps.length + '</span> done');
2580
+ var stepsText = rSteps.length === 0 ? (detail.fromBridge === true ? "not recorded" : "none started") : ('<span class="num">' + stepsDone + '</span> of <span class="num">' + rSteps.length + '</span> done');
1939
2581
  box.innerHTML =
1940
- '<div class="summary-headline"><span class="dot ' + glyphClass(detail.glyph) + '"></span><b>' + escapeXml(detail.checkType) + '</b> &middot; ' +
2582
+ '<div class="summary-headline"><span class="dot ' + glyphClass(detail.glyph) + '"></span>' + (detail.status && detail.status.word ? '<b class="st-word" data-testid="summary-status">' + escapeXml(detail.status.word) + '</b> &middot; ' : '') + '<b>' + escapeXml(detail.checkType) + '</b> &middot; ' +
1941
2583
  '<span class="num">' + spendText + '</span> &middot; <span class="num">' + wallText + '</span></div>' +
1942
2584
  '<div class="summary-row" data-testid="summary-model"><span class="sum-label">model:</span><span class="sum-val">' + escapeXml(modelLine(detail)) + '</span></div>' +
1943
2585
  (detail.judgeModel ? '<div class="summary-row" data-testid="summary-judge"><span class="sum-label">judge:</span><span class="sum-val">' + escapeXml(detail.judgeModel) + '</span></div>' : '') +
@@ -1945,7 +2587,7 @@
1945
2587
  (detail.replans ? ' &middot; ' + detail.replans + ' replan' + (detail.replans === 1 ? '' : 's') : '') + '</span></div>' +
1946
2588
  '<div class="summary-row"><span class="sum-label">why:</span><span class="sum-val">' + escapeXml(detail.stopReason || (detail.glyph === "✓" ? "run ended clean" : "not recorded")) + '</span></div>' +
1947
2589
  '<div class="summary-row"><span class="sum-label">close:</span><span class="sum-val">' + escapeXml(detail.close && detail.close.verdict ? detail.close.verdict : "none — the run ended before any close ran") + '</span></div>' +
1948
- '<div class="summary-row" data-testid="summary-tools"><span class="sum-label">tools:</span><span class="sum-val">' + escapeXml(toolsLine(detail.behaviour)) + '</span></div>' +
2590
+ '<div class="summary-row" data-testid="summary-tools"><span class="sum-label">tools:</span><span class="sum-val">' + escapeXml(detail.fromBridge === true ? bridgeToolsLine(detail.toolsUsed) : toolsLine(detail.behaviour)) + '</span></div>' +
1949
2591
  '<div class="summary-row" data-testid="summary-offered" id="summary-offered-row" hidden><span class="sum-label">offered:</span><span class="sum-val" id="summary-offered-val"></span></div>' +
1950
2592
  '<div class="summary-row" data-testid="summary-cache"><span class="sum-label">cache:</span><span class="sum-val">' + escapeXml(cacheLine(detail.memoryCache)) + '</span></div>';
1951
2593
 
@@ -1968,7 +2610,6 @@
1968
2610
  listEl.innerHTML = "";
1969
2611
  orderedBoxes.forEach(function(box, idx){
1970
2612
  var part = box.part;
1971
- var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
1972
2613
  var titleText = box.kind === "step" ? ((cardStepNumbers[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
1973
2614
  var resultGlyph = partResultGlyph(part, box);
1974
2615
  // build item 4: ONE owner (partLine1Text, shared with the Audit tab's
@@ -1983,11 +2624,11 @@
1983
2624
  card.setAttribute("role", "button");
1984
2625
  card.innerHTML =
1985
2626
  '<div class="step-head"><h4>' + titleText + '</h4>' +
1986
- '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span>' +
2627
+ stepStateHTML(box, 'part-state-' + idx) +
1987
2628
  (box.blocked > 0 ? '<span class="badge red" data-testid="part-blocked-badge">' + box.blocked + ' blocked</span>' : '') +
1988
2629
  '</div>' +
1989
2630
  '<div class="step-meta" title="a part is one piece of the run (scout, plan, a step, replan, fix, judge); a call is one model round">' + line1Bits.join(" &middot; ") + '</div>' +
1990
- '<div class="step-meta">' + escapeXml(toolBreakdownLine(box.part.byTool)) + '</div>';
2631
+ '<div class="step-meta">' + escapeXml(partLine2Text(box.part)) + '</div>';
1991
2632
  listEl.appendChild(card);
1992
2633
  var jumpToAudit = function(){
1993
2634
  document.getElementById("tab-audit").click();
@@ -2002,9 +2643,15 @@
2002
2643
  // for the map+cards, never a second independently-fetched data source
2003
2644
  // (the flat one-row-per-call table still comes from its own `/audit`
2004
2645
  // fetch, wired separately in selectRun below).
2646
+ lastLegDividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
2005
2647
  renderAuditGroups(detail);
2006
2648
  }
2007
2649
 
2650
+ // ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
2651
+ function stepStateHTML(box, testid){
2652
+ var cls = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2653
+ return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(box.state) + '</b></span>';
2654
+ }
2008
2655
  // build item C: Audit tab attempt row -> its rounds (server-built,
2009
2656
  // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
2010
2657
  function buildAttemptsList(container, partIndex, attempts, boxIsLive){
@@ -2142,6 +2789,46 @@
2142
2789
  // shows — expand -> attempts (parts with >1, e.g. a multi-attempt step or
2143
2790
  // the fix loop) or straight to a rounds table (every other part kind,
2144
2791
  // which carries exactly one synthetic whole-part attempt).
2792
+ // The DRAFTING part (a panel-authored run's first part; src/draftspend.js `draftingPartFor`): ONE owner of its
2793
+ // money wording, line 1, line 2 and its Audit table. Money is the spine's own drafting figure ("at least" when it
2794
+ // is a floor); a call with no price reads "unknown", never $0.
2795
+ function draftingMoneyText(part){
2796
+ return (part.spendComplete === false ? "at least " : "") + panelMoney(part.spentUsd);
2797
+ }
2798
+ function draftingLine1Text(part){
2799
+ var n = typeof part.rounds === "number" ? part.rounds : 0;
2800
+ return (part.wallMs === null || part.wallMs === undefined ? "–" : duration(part.wallMs)) + " &middot; " + draftingMoneyText(part) + " &middot; " + n + " call" + (n === 1 ? "" : "s");
2801
+ }
2802
+ // line 2 of a part's card / Audit body: the drafting step names, or a worker part's tool breakdown
2803
+ function partLine2Text(part){
2804
+ if(part && part.kind === "drafting") return (part.draftSteps || []).map(function(x){ return x.label; }).join(" · ");
2805
+ return toolBreakdownLine(part.byTool);
2806
+ }
2807
+ function draftingCallCost(c){
2808
+ if(c.costUsd === null || c.costUsd === undefined) return "unknown";
2809
+ if(c.unpricedRounds > 0) return "unknown (" + c.unpricedRounds + " unpriced round" + (c.unpricedRounds === 1 ? "" : "s") + ")";
2810
+ return panelMoney(c.costUsd);
2811
+ }
2812
+ // one row per drafting step ("–" where it made no call); a step's further calls get their own row named by the
2813
+ // call's own label (confirm#2, revise-1…); the last row is the total, equal to the header's money
2814
+ function draftingTableHtml(part){
2815
+ var rows = "";
2816
+ (part.draftSteps || []).forEach(function(st){
2817
+ var wall = st.wallMs === null || st.wallMs === undefined ? "–" : duration(st.wallMs);
2818
+ var calls = Array.isArray(st.calls) ? st.calls : [];
2819
+ var first = calls[0];
2820
+ rows += "<tr><td>" + escapeXml(st.label) + "</td><td>" + wall + "</td><td>" + (first ? "1" : "–") + "</td><td>" +
2821
+ (first && first.model ? escapeXml(first.model) : "–") + "</td><td>" + (first ? draftingCallCost(first) : "–") + "</td></tr>";
2822
+ calls.slice(1).forEach(function(c){
2823
+ rows += "<tr><td>" + escapeXml(st.label) + " (" + escapeXml(c.label) + ")</td><td>–</td><td>1</td><td>" +
2824
+ (c.model ? escapeXml(c.model) : "–") + "</td><td>" + draftingCallCost(c) + "</td></tr>";
2825
+ });
2826
+ });
2827
+ rows += '<tr class="drafting-total"><td><b>total</b></td><td>' + (part.wallMs === null || part.wallMs === undefined ? "–" : duration(part.wallMs)) +
2828
+ "</td><td>" + (typeof part.rounds === "number" ? part.rounds : 0) + "</td><td>–</td><td>" + draftingMoneyText(part) + "</td></tr>";
2829
+ return '<div class="audit-table-scroll"><table class="rounds-table drafting-table" data-testid="drafting-table">' +
2830
+ "<thead><tr><th>Step</th><th>Time</th><th>Calls</th><th>Model</th><th>Cost</th></tr></thead><tbody>" + rows + "</tbody></table></div>";
2831
+ }
2145
2832
  function partHasWrites(part){
2146
2833
  var bt = part.byTool;
2147
2834
  if(!bt) return false;
@@ -2171,6 +2858,7 @@
2171
2858
  * @returns {string}
2172
2859
  */
2173
2860
  function partLine1Text(part, detail){
2861
+ if(part && part.kind === "drafting") return draftingLine1Text(part);
2174
2862
  var isLiveRunPart = part && part.kind === "run" && detail && !detail.died && detail.spentUsd === null;
2175
2863
  var wallText = (isLiveRunPart && part.wallMs === null) ? liveWallPhrase(detail.wallFloorMs) : duration(part.wallMs);
2176
2864
  var spendText = (isLiveRunPart && part.spentUsd === null)
@@ -2189,6 +2877,8 @@
2189
2877
  var latestAttempt = Array.isArray(part.attempts) && part.attempts.length ? part.attempts[part.attempts.length - 1] : null;
2190
2878
  var headline = latestAttempt ? attemptChecksLine(latestAttempt) : "";
2191
2879
  if(headline) bits.push(headline);
2880
+ // P5-R: a part its leg's halt cut off says why, in words
2881
+ if(part && part.stopReason) bits.push("stopped &mdash; " + escapeXml(part.stopReason));
2192
2882
  return bits.join(" &middot; ");
2193
2883
  }
2194
2884
  /**
@@ -2355,10 +3045,12 @@
2355
3045
  var isLiveRun = detail.glyph === "▶" && !detail.died;
2356
3046
  var boxes = buildOrderedBoxes(parts, isLiveRun);
2357
3047
  var numberMap = stepNumberIndices(boxes);
3048
+ // P5-R: a resumed run is ONE run — its legs are told apart by a divider line (code-owned text from the server)
3049
+ var dividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
2358
3050
  boxes.forEach(function(box, idx){
3051
+ dividers.forEach(function(dv){ if(dv.beforePart === idx) container.appendChild(legDividerEl(dv)); });
2359
3052
  var part = box.part;
2360
3053
  var titleText = box.kind === "step" ? ((numberMap[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
2361
- var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2362
3054
  var rowWrap = document.createElement("div");
2363
3055
  rowWrap.className = "audit-part";
2364
3056
  rowWrap.setAttribute("data-testid", "audit-part-" + idx);
@@ -2372,7 +3064,7 @@
2372
3064
  headBtn.setAttribute("data-testid", "audit-part-toggle-" + idx);
2373
3065
  var groupResultGlyph = partResultGlyph(part, box);
2374
3066
  headBtn.innerHTML =
2375
- '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part, detail) +
3067
+ stepStateHTML(box, 'audit-part-state-' + idx) + ' ' + titleText + ' &middot; ' + partLine1Text(part, detail) +
2376
3068
  (groupResultGlyph !== null ? ' &middot; ' + groupResultGlyph : '') +
2377
3069
  (box.blocked > 0 ? ' <span class="badge red" data-testid="audit-part-blocked-' + idx + '">' + box.blocked + ' blocked</span>' : '');
2378
3070
  var body = document.createElement("div");
@@ -2385,9 +3077,15 @@
2385
3077
  built = true;
2386
3078
  var toolsHeader = document.createElement("div");
2387
3079
  toolsHeader.className = "hint";
2388
- toolsHeader.textContent = toolBreakdownLine(part.byTool);
3080
+ toolsHeader.textContent = partLine2Text(part);
2389
3081
  body.appendChild(toolsHeader);
2390
- if(attempts.length > 1){
3082
+ if(part.kind === "drafting"){
3083
+ // no tool calls and no rounds: one row per drafting step instead (the drafting part is index 0 of the
3084
+ // shown list; the rounds endpoint has nothing to serve for it)
3085
+ var draftingEl = document.createElement("div");
3086
+ draftingEl.innerHTML = draftingTableHtml(part);
3087
+ body.appendChild(draftingEl);
3088
+ } else if(attempts.length > 1){
2391
3089
  // parts WITH an attempt sub-loop (a multi-attempt step, or the fix
2392
3090
  // loop) expand to attempt rows first — each attempt expands to its
2393
3091
  // own rounds table on its own click, same as the Run tab used to.
@@ -2415,8 +3113,17 @@
2415
3113
  container.appendChild(rowWrap);
2416
3114
  if(startExpanded && !built) buildBody(); // a live run's currently-running part opens by default
2417
3115
  });
3116
+ // a leg that has not started a part yet (or died before one) closes the list
3117
+ dividers.forEach(function(dv){ if(dv.beforePart >= boxes.length) container.appendChild(legDividerEl(dv)); });
2418
3118
  applyAuditFilter(currentAuditFilter);
2419
3119
  }
3120
+ function legDividerEl(dv){
3121
+ var d = document.createElement("div");
3122
+ d.className = "audit-leg-divider";
3123
+ d.setAttribute("data-testid", "audit-leg-divider-" + dv.leg);
3124
+ d.textContent = "\u2500\u2500 " + dv.text + " \u2500\u2500";
3125
+ return d;
3126
+ }
2420
3127
  // build item B: a Run-tab card (or map box) click switches to the Audit
2421
3128
  // tab, that same part's row expanded and scrolled into view — sharing
2422
3129
  // partIndex so the two tabs can never disagree about which row a click maps to.
@@ -2439,6 +3146,9 @@
2439
3146
 
2440
3147
  // ---- Audit tab: FLAT view (the old one-row-per-call time-ordered table) ----
2441
3148
  var auditRowEls = [];
3149
+ // the open run's leg dividers (set by renderRun); the flat table places them by time
3150
+ var lastLegDividers = [];
3151
+ function dividerAtMs(dv){ var t = Date.parse(dv.at); return Number.isFinite(t) ? t : Infinity; }
2442
3152
  function renderAudit(result){
2443
3153
  var empty = document.getElementById("audit-empty");
2444
3154
  var content = document.getElementById("audit-content");
@@ -2455,7 +3165,18 @@
2455
3165
  content.hidden = false;
2456
3166
  var tbody = document.getElementById("audit-tbody");
2457
3167
  tbody.innerHTML = "";
3168
+ // P5-R: the flat table carries the same leg dividers, placed by time (a divider sits before the first call
3169
+ // made at or after the moment that leg picked up; any that remain close the table)
3170
+ var pendingDividers = Array.isArray(lastLegDividers) ? lastLegDividers.slice() : [];
3171
+ function flatDividerRow(dv){
3172
+ var dividerRow = document.createElement("tr");
3173
+ dividerRow.className = "audit-leg-divider-row";
3174
+ dividerRow.setAttribute("data-testid", "audit-leg-divider-row-" + dv.leg);
3175
+ dividerRow.innerHTML = '<td colspan="6">\u2500\u2500 ' + escapeXml(dv.text) + ' \u2500\u2500</td>';
3176
+ return dividerRow;
3177
+ }
2458
3178
  result.rows.forEach(function(r){
3179
+ while(pendingDividers.length && dividerAtMs(pendingDividers[0]) <= Date.parse(r.time)){ tbody.appendChild(flatDividerRow(pendingDividers.shift())); }
2459
3180
  var tr = document.createElement("tr");
2460
3181
  var rowCls = auditRowClassName(r);
2461
3182
  if(rowCls) tr.className = rowCls;
@@ -2486,8 +3207,9 @@
2486
3207
  "<td>" + auditTimeCellHtml(r) + "</td>";
2487
3208
  tbody.appendChild(tr);
2488
3209
  });
3210
+ pendingDividers.forEach(function(dv){ tbody.appendChild(flatDividerRow(dv)); });
2489
3211
  document.getElementById("audit-rawlog").textContent = result.raw || "";
2490
- auditRowEls = Array.prototype.slice.call(tbody.querySelectorAll("tr"));
3212
+ auditRowEls = Array.prototype.slice.call(tbody.querySelectorAll("tr:not(.audit-leg-divider-row)"));
2491
3213
  // Scoped to [data-filter] — the audit tab's OWN chips (chip-all/writes/
2492
3214
  // blocked). A bare ".chip" here also matched the History filter bar's
2493
3215
  // chips (same class, different data- attributes), binding a second
@@ -2517,6 +3239,7 @@
2517
3239
  function renderJob(job){
2518
3240
  var empty = document.getElementById("job-empty");
2519
3241
  var card = document.getElementById("job-card-readonly");
3242
+ lastJobShown = job;
2520
3243
  if(!job){ empty.hidden = false; card.hidden = true; return; }
2521
3244
  empty.hidden = true;
2522
3245
  card.hidden = false;
@@ -2539,6 +3262,7 @@
2539
3262
  document.getElementById("job-note").textContent = job.note || "";
2540
3263
  lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
2541
3264
  paintOfferedRow();
3265
+ paintResumeMode();
2542
3266
  }
2543
3267
 
2544
3268
  // ---- selection + loading ----
@@ -2553,10 +3277,13 @@
2553
3277
  // uses for `isLive` — never re-derived a second way).
2554
3278
  var runIsLive = false;
2555
3279
  function selectRun(runid, rowEl, siblingSelector){
3280
+ hideImportView();
2556
3281
  clearSelection();
2557
3282
  document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
2558
3283
  if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
2559
3284
  currentRunid = runid;
3285
+ resumeMode = null;
3286
+ lastLegDividers = [];
2560
3287
  lastJobToolsList = null;
2561
3288
  lastRunDetailSig = null;
2562
3289
  runIsLive = false;
@@ -2818,9 +3545,9 @@
2818
3545
  }
2819
3546
  function loadProviders(){
2820
3547
  authorGet("/api/settings/providers").then(function(r){
2821
- if(!r || !r.ok){ pvNote.textContent = "could not read providers"; return; }
3548
+ if(!r || !r.ok){ pvNote.textContent = "could not read providers"; document.getElementById("pv-keyfile-path").textContent = "the keys file (path unknown — could not read providers)"; return; }
2822
3549
  var kf = r.keysFile;
2823
- document.getElementById("pv-keyfile-path").textContent = kf.path;
3550
+ document.getElementById("pv-keyfile-path").textContent = "";
2824
3551
  document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
2825
3552
  pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
2826
3553
  var e = escapeXml(row.envName);
@@ -2840,8 +3567,10 @@
2840
3567
  ? '<button class="btn small pv-test" type="button" data-testid="test-connection-' + e + '">Test</button><div class="hint pv-test-result" data-testid="test-result-' + e + '"></div>'
2841
3568
  : '<span class="hint">not available</span>') + "</td>"
2842
3569
  + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
2843
- + "<td>" + bal + "</td></tr>";
2844
- }).join("") : '<tr><td colspan="7" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
3570
+ + "<td>" + bal + "</td>"
3571
+ + '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-in" data-testid="pv-price-in-' + e + '" placeholder="not set" value="' + (row.priceInPerM === null ? "" : escapeXml(String(row.priceInPerM))) + '"></td>'
3572
+ + '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-out" data-testid="pv-price-out-' + e + '" placeholder="not set" value="' + (row.priceOutPerM === null ? "" : escapeXml(String(row.priceOutPerM))) + '"></td>';
3573
+ }).join("") : '<tr><td colspan="9" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
2845
3574
  pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
2846
3575
  authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
2847
3576
  fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
@@ -2855,6 +3584,16 @@
2855
3584
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
2856
3585
  }).catch(function(){ pvNote.textContent = "could not read providers"; });
2857
3586
  }
3587
+ // The row's POST body. A price is both boxes or neither (the server refuses a half-set one), so while exactly one
3588
+ // is filled — the normal middle of typing a price — the price fields are LEFT OUT (the server keeps what is saved)
3589
+ // and the other boxes still save; the price posts once both are filled, or both are cleared.
3590
+ function pvRowBody(env, v){
3591
+ var pin = String(v.priceIn).trim();
3592
+ var pout = String(v.priceOut).trim();
3593
+ var body = { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl };
3594
+ if((pin === "") === (pout === "")){ body.priceInPerM = pin; body.priceOutPerM = pout; }
3595
+ return body;
3596
+ }
2858
3597
  pvRows.addEventListener("change", function(e){
2859
3598
  var t = e.target;
2860
3599
  var tr = t.closest("tr");
@@ -2865,14 +3604,15 @@
2865
3604
  }).catch(function(){ pvNote.textContent = "could not save"; });
2866
3605
  return;
2867
3606
  }
2868
- if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url"))) return;
3607
+ if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url") || t.classList.contains("pv-price-in") || t.classList.contains("pv-price-out"))) return;
2869
3608
  var env = tr.getAttribute("data-env");
2870
- authorPost("/api/settings/providers/row", {
2871
- envName: env,
3609
+ authorPost("/api/settings/providers/row", pvRowBody(env, {
2872
3610
  name: tr.querySelector(".pv-name").value,
2873
3611
  shape: tr.querySelector(".pv-shape").value,
2874
3612
  baseUrl: tr.querySelector(".pv-url").value,
2875
- }).then(function(r){
3613
+ priceIn: tr.querySelector(".pv-price-in").value,
3614
+ priceOut: tr.querySelector(".pv-price-out").value,
3615
+ })).then(function(r){
2876
3616
  pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
2877
3617
  // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
2878
3618
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
@@ -2889,6 +3629,14 @@
2889
3629
  }).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
2890
3630
  });
2891
3631
  document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
3632
+ // Open keys folder: the server opens the FOLDER in the file manager; if it cannot, the path shows as text
3633
+ document.getElementById("btn-open-keys-folder").addEventListener("click", function(){
3634
+ var out = document.getElementById("pv-keyfile-path");
3635
+ authorPost("/api/settings/open-keys-folder", {}).then(function(r){
3636
+ var b = r.body || {};
3637
+ out.textContent = b.ok && b.opened ? "" : ("Could not open it here. Your keys folder: " + (b.path || "(unknown)"));
3638
+ }).catch(function(){ out.textContent = "Could not open it here — the keys folder is ~/.config/bareloop"; });
3639
+ });
2892
3640
  wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
2893
3641
  document.getElementById("tab-providers").addEventListener("click", loadProviders);
2894
3642
  // Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
@@ -2910,24 +3658,21 @@
2910
3658
 
2911
3659
  // ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
2912
3660
  (function(){
2913
- var newBtn = document.getElementById("chat-new-btn");
3661
+ var clearBtn = document.getElementById("chat-clear-btn");
2914
3662
  var card = document.getElementById("job-card");
2915
3663
  var thread = document.getElementById("chat-thread");
2916
3664
  var msgInput = document.getElementById("chat-msg");
2917
- var sendBtn = document.getElementById("chat-send-btn");
2918
- var signBtn = document.getElementById("chat-sign-btn");
2919
- var reviseBtn = document.getElementById("chat-revise-btn");
2920
- var startBtn = document.getElementById("chat-start-btn");
2921
- var checkDepsBtn = document.getElementById("chat-check-deps-btn");
3665
+ var mainBtn = document.getElementById("chat-main-btn");
3666
+ var mainHint = document.getElementById("chat-main-hint");
3667
+ var mainAction = "none";
3668
+ var lastState = null;
3669
+ var startOk = false;
2922
3670
  var errEl = document.getElementById("chat-card-error");
2923
3671
  var actionErrEl = document.getElementById("chat-action-error");
2924
- var hashLine = document.getElementById("chat-hash-line");
2925
3672
  var moneyCapInput = document.getElementById("jf-cap-money");
2926
3673
  var modelSelect = document.getElementById("jf-model");
2927
3674
  var modelStatusEl = document.getElementById("jf-model-status");
2928
3675
  var progressRow = document.getElementById("chat-progress-row");
2929
- var progressGlyph = document.getElementById("chat-progress-glyph");
2930
- var progressLabelEl = document.getElementById("chat-progress-label");
2931
3676
 
2932
3677
  var sessionId = null;
2933
3678
  var pollTimer = null;
@@ -2939,26 +3684,98 @@
2939
3684
  // src/panel/authorroutes.js, mirrored here so Start never even offers a
2940
3685
  // click the server would 409 anyway).
2941
3686
  var CLIENT_TERMINAL_PHASES = ["refused", "abandoned", "error", "signed", "signing-failed"];
2942
- var DONE_PHASES = ["prepared", "signed"];
2943
3687
  var sessionLive = false;
2944
3688
  // build item 7 — Start also stays disabled while the chosen model's key
2945
3689
  // isn't usable; updated by refreshModelStatus() below.
2946
3690
  var keyOk = false;
2947
3691
 
2948
- newBtn.addEventListener("click", function(){
3692
+ // Reuse workflow — set when the card was opened by [Reuse workflow]: the run (or imported job) it came from.
3693
+ // Only Source, Destination and the two caps are open on that card; the SERVER refuses any other change.
3694
+ var startFrom = null;
3695
+ var reuseSession = false;
3696
+ var autoSigned = false;
3697
+ var sfOrigin = document.getElementById("chat-startfrom-origin");
3698
+ var sfLine = document.getElementById("chat-startfrom-line");
3699
+ var sfNote = document.getElementById("chat-startfrom-note");
3700
+ // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3701
+ var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3702
+ // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3703
+ // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3704
+ var OPEN_IDS = ["jf-source", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3705
+ var reuseOn = false;
3706
+ function syncCardLock(){
3707
+ var live = sessionLive;
3708
+ var setLock = function(id, on){ var el = document.getElementById(id); el.readOnly = on; el.classList.toggle("locked", on); };
3709
+ LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live); });
3710
+ OPEN_IDS.forEach(function(id){ setLock(id, live); });
3711
+ document.getElementById("jf-model").disabled = live;
3712
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = reuseOn || live; });
3713
+ document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3714
+ if(!reuseOn && !live){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3715
+ }
3716
+ function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3717
+ function clearStartFrom(){
3718
+ // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes
3719
+ if(startFrom){
3720
+ ["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 = ""; });
3721
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3722
+ }
3723
+ startFrom = null; reuseSession = false; autoSigned = false;
3724
+ sfOrigin.hidden = true; sfLine.hidden = true; sfNote.hidden = true;
3725
+ setReuseLocked(false);
3726
+ renderMain();
3727
+ }
3728
+ function openNewCard(){
2949
3729
  card.hidden = false;
2950
- newBtn.hidden = true;
2951
3730
  thread.innerHTML = "";
2952
3731
  errEl.textContent = "";
2953
3732
  actionErrEl.textContent = "";
2954
3733
  sessionId = null;
2955
3734
  sessionLive = false;
2956
3735
  signClickedOnce = false;
3736
+ reuseSession = false;
3737
+ autoSigned = false;
2957
3738
  if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
2958
- stopProgressDots();
2959
3739
  progressRow.hidden = true;
2960
- checkDepsBtn.hidden = true;
3740
+ progressRow.innerHTML = "";
3741
+ lastState = null;
2961
3742
  refreshStartEnabled();
3743
+ }
3744
+ // hamr's ruling 2026-10-05: ONE button, two states. A live, unsigned session -> "Abandon" (the server ends it and
3745
+ // releases the one-at-a-time lock; money already spent stays booked). Anything else (signed and running, terminal,
3746
+ // nothing started) -> "Clear", which empties the card only and never stops a run (Stop lives on the Run tab).
3747
+ function clearButtonFor(live, hasId){
3748
+ return live ? {text: "Abandon", disabled: !hasId} : {text: "Clear", disabled: false};
3749
+ }
3750
+ function refreshClearBtn(){
3751
+ var b = clearButtonFor(sessionLive, !!sessionId);
3752
+ clearBtn.textContent = b.text;
3753
+ clearBtn.disabled = b.disabled;
3754
+ }
3755
+ // back to a fresh empty card: every box, check type deterministic, the default (first) Model, origin/estimate/notes
3756
+ // hidden, progress list and thread cleared, reuse lock lifted
3757
+ function resetCard(){
3758
+ ["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 = ""; });
3759
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3760
+ document.getElementById("jf-judge").disabled = true;
3761
+ modelSelect.selectedIndex = 0;
3762
+ msgInput.value = "";
3763
+ document.getElementById("jf-cap-note").textContent = "";
3764
+ lastPhase = null;
3765
+ clearStartFrom();
3766
+ openNewCard();
3767
+ refreshModelStatus();
3768
+ }
3769
+ clearBtn.addEventListener("click", function(){
3770
+ if(clearBtn.textContent === "Abandon" && sessionId){
3771
+ authorPost("/api/author/" + sessionId + "/abandon", {}).then(function(r){
3772
+ var o = chatPostOutcome(r);
3773
+ if(!o.ok){ chatActionFailed(o.error); return; }
3774
+ resetCard();
3775
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not abandoned"); });
3776
+ return;
3777
+ }
3778
+ resetCard();
2962
3779
  });
2963
3780
 
2964
3781
  function currentCard(){
@@ -2989,7 +3806,10 @@
2989
3806
  function refreshStartEnabled(){
2990
3807
  var v = Number(moneyCapInput.value);
2991
3808
  var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
2992
- startBtn.disabled = !capOk || sessionLive || !keyOk;
3809
+ startOk = capOk && !sessionLive && keyOk;
3810
+ syncCardLock();
3811
+ renderMain();
3812
+ refreshClearBtn();
2993
3813
  }
2994
3814
  moneyCapInput.addEventListener("input", refreshStartEnabled);
2995
3815
  // P4a — the monthly-limit note sits right under the cap, as the person types.
@@ -3080,6 +3900,16 @@
3080
3900
  });
3081
3901
  }
3082
3902
  modelSelect.addEventListener("change", refreshModelStatus);
3903
+ // Model is open on a reuse card: the estimate line counts only runs on the worker the box names right now
3904
+ function refreshReuseLine(){
3905
+ if(!startFrom) return;
3906
+ var q = (startFrom.importId ? "import=" + encodeURIComponent(startFrom.importId) : "runid=" + encodeURIComponent(startFrom.runid)) +
3907
+ "&model=" + encodeURIComponent(modelSelect.value);
3908
+ authorGet("/api/author/start-from?" + q).then(function(r){
3909
+ if(startFrom && r && r.ok){ sfLine.textContent = r.line || ""; }
3910
+ }).catch(function(){ /* the line keeps what it had */ });
3911
+ }
3912
+ modelSelect.addEventListener("change", refreshReuseLine);
3083
3913
  // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
3084
3914
  // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
3085
3915
  function loadModels(){
@@ -3119,25 +3949,6 @@
3119
3949
  thread.scrollTop = thread.scrollHeight;
3120
3950
  }
3121
3951
 
3122
- // build item 3 — the single live progress line: the LABEL sits first in
3123
- // the markup, then the animated indicator (never the other way — the
3124
- // dots must not push the label around as they cycle 1..4).
3125
- var progressDotTimer = null;
3126
- var progressDotCount = 1;
3127
- var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
3128
- function stopProgressDots(){
3129
- if(progressDotTimer){ clearInterval(progressDotTimer); progressDotTimer = null; }
3130
- }
3131
- function startProgressDots(){
3132
- if(reducedMotion){ progressGlyph.textContent = "[progress....]"; return; }
3133
- if(progressDotTimer) return;
3134
- progressDotCount = 1;
3135
- progressGlyph.textContent = "[progress.]";
3136
- progressDotTimer = setInterval(function(){
3137
- progressDotCount = (progressDotCount % 4) + 1;
3138
- progressGlyph.textContent = "[progress" + new Array(progressDotCount + 1).join(".") + "]";
3139
- }, 500);
3140
- }
3141
3952
  // build item 3 — pure: what the label must say for a given state. A
3142
3953
  // person waiting on their OWN next action (an ask is pending) must never
3143
3954
  // see a stale machine step name (observed: "checking prior behaviour"
@@ -3150,75 +3961,153 @@
3150
3961
  if(pending) return "waiting for your answer";
3151
3962
  return (state && state.progressLabel) || "working";
3152
3963
  }
3964
+ // the ONE progress list: a step per line, in the server's order (STEP_LABELS in src/panel/authorsession.js owns
3965
+ // the ids and words). While a step runs its dots are CSS (still under prefers-reduced-motion); it ends as a check
3966
+ // or a cross, a cross carrying the code-owned reason on the same line. A pending ask for the person replaces the
3967
+ // running step's label with what is being waited on, never an animation.
3968
+ // Returns true when the list's content actually changed (a new line, or the latest line changed) so the caller can
3969
+ // scroll only then; an identical 2s poll tick returns false and leaves the person's scroll position alone.
3153
3970
  function renderProgress(state){
3154
- progressRow.hidden = false;
3155
- if(DONE_PHASES.indexOf(state.phase) !== -1){
3156
- stopProgressDots();
3157
- progressGlyph.className = "chat-progress-glyph done";
3158
- progressGlyph.textContent = "[done]";
3159
- progressLabelEl.textContent = "";
3160
- } else if(CLIENT_TERMINAL_PHASES.indexOf(state.phase) !== -1){
3161
- stopProgressDots();
3162
- progressGlyph.className = "chat-progress-glyph stopped";
3163
- progressGlyph.textContent = "[stopped]";
3164
- progressLabelEl.textContent = state.error || "";
3165
- } else {
3166
- progressGlyph.className = "chat-progress-glyph";
3167
- progressLabelEl.textContent = progressLabelFor(state);
3168
- startProgressDots();
3169
- }
3971
+ var steps = (state && state.steps) || [];
3972
+ var before = progressRow.innerHTML;
3973
+ progressRow.hidden = steps.length === 0;
3974
+ progressRow.innerHTML = steps.map(function(st, i){
3975
+ var waiting = st.status === "running" && i === steps.length - 1 && state.pendingAsk;
3976
+ var cls = waiting ? "wait" : (st.status === "running" ? "run" : (st.status === "failed" ? "bad" : "ok"));
3977
+ var sign = cls === "ok" ? "\u2713" : (cls === "bad" ? "\u2717" : "");
3978
+ var label = waiting ? progressLabelFor(state) : st.label;
3979
+ return '<li class="step ' + cls + '" data-step="' + escapeXml(st.id) + '"><div class="step-line"><span class="step-label">' + escapeXml(label) + '</span>' +
3980
+ '<span class="step-sign">' + sign + '</span></div>' +
3981
+ (st.detail ? '<div class="step-detail">' + escapeXml("> " + st.detail) + '</div>' : "") + '</li>';
3982
+ }).join("");
3983
+ return progressRow.innerHTML !== before;
3170
3984
  }
3171
3985
 
3172
- function renderActions(state){
3173
- var pending = state.pendingAsk;
3174
- var atMenu = !!pending && pending.kind === "menu";
3175
- var atDepsWait = !!pending && pending.kind === "install-needed";
3176
- sendBtn.disabled = !pending || atMenu || atDepsWait;
3177
- reviseBtn.disabled = !atMenu || state.revisesLeft <= 0;
3178
- reviseBtn.textContent = "Revise (" + state.revisesLeft + " left)";
3179
- checkDepsBtn.hidden = !atDepsWait;
3180
- checkDepsBtn.disabled = !atDepsWait;
3181
- if(state.phase === "prepared" && !signClickedOnce){
3182
- signBtn.disabled = false;
3183
- signBtn.textContent = "Sign & run";
3184
- } else if(state.phase === "prepared" && signClickedOnce){
3185
- signBtn.disabled = false;
3186
- signBtn.textContent = "Sign " + (state.specHash || "").slice(0, 8) + " & run";
3187
- } else {
3188
- signBtn.disabled = !atMenu;
3189
- signBtn.textContent = "Sign & run";
3986
+ // hamr's ruling 2026-10-05: ONE wide button below the message box. Its wording, action and enabled state are ONE
3987
+ // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk, revisesLeft,
3988
+ // signClicked, specHash}. Actions: start | check-deps | send | revise | sign-prepare | sign | none.
3989
+ function mainButtonFor(st, textEmpty, reuse){
3990
+ var ask = st.pendingAsk;
3991
+ var atMenu = !!ask && ask.kind === "menu";
3992
+ var left = st.revisesLeft || 0;
3993
+ var hint = atMenu ? left + (left === 1 ? " change left" : " changes left") : "";
3994
+ if(!st.session){
3995
+ return {text: reuse ? "Sign & run" : "Start drafting", action: "start", disabled: !st.startOk, hint: ""};
3190
3996
  }
3191
- if(state.specHash){
3192
- hashLine.hidden = false;
3193
- hashLine.textContent = "spec hash: " + state.specHash;
3997
+ if(ask && ask.kind === "install-needed"){
3998
+ return {text: "Check again", action: "check-deps", disabled: false, hint: ""};
3194
3999
  }
3195
- if(state.phase === "refused" || state.phase === "error" || state.phase === "abandoned"){
3196
- errEl.textContent = state.error || ("stopped: " + state.phase);
3197
- } else {
3198
- errEl.textContent = "";
4000
+ if(ask && !textEmpty){
4001
+ return atMenu
4002
+ ? {text: "Send", action: "revise", disabled: left <= 0, hint: hint}
4003
+ : {text: "Send", action: "send", disabled: false, hint: ""};
4004
+ }
4005
+ if(ask && !atMenu){
4006
+ return {text: "Send", action: "send", disabled: true, hint: ""};
4007
+ }
4008
+ if(atMenu || st.phase === "prepared"){
4009
+ if(st.phase === "prepared" && st.signClicked){
4010
+ return {text: "Sign " + (st.specHash || "").slice(0, 8) + " & run", action: "sign", disabled: false, hint: hint};
4011
+ }
4012
+ return {text: "Sign & run", action: "sign-prepare", disabled: false, hint: hint};
3199
4013
  }
3200
- renderProgress(state);
4014
+ return {text: "Sign & run", action: "none", disabled: true, hint: ""};
4015
+ }
4016
+ // pure: the ask box takes typed text only when the session has a pending ask that accepts it (the plan menu -> a
4017
+ // change request, or any question); not with no session, no ask, or while waiting on an install
4018
+ function askBoxOpenFor(st){
4019
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed";
4020
+ }
4021
+ function renderMain(){
4022
+ var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce};
4023
+ if(sessionLive && lastState){
4024
+ st.phase = lastState.phase; st.pendingAsk = lastState.pendingAsk;
4025
+ st.revisesLeft = lastState.revisesLeft; st.specHash = lastState.specHash;
4026
+ }
4027
+ // hamr 2026-10-05: the ask box is open only while something asks for a reply; otherwise dimmed and emptied
4028
+ var open = askBoxOpenFor(st);
4029
+ msgInput.disabled = !open;
4030
+ if(!open) msgInput.value = "";
4031
+ var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom);
4032
+ mainBtn.textContent = b.text;
4033
+ mainBtn.disabled = b.disabled;
4034
+ mainAction = b.action;
4035
+ mainHint.textContent = b.hint;
4036
+ fitBox(msgInput);
4037
+ fitCardBoxes();
4038
+ }
4039
+ msgInput.addEventListener("input", renderMain);
4040
+ // grow a text box with its text (a few rows, then it scrolls inside); ONE function for the ask box and the card boxes.
4041
+ // A box that is hidden (or not laid out) measures 0 and gets no inline height.
4042
+ function fitBox(el){
4043
+ el.style.height = "auto";
4044
+ if(!el.scrollHeight){ el.style.height = ""; return; }
4045
+ var border = el.offsetHeight - el.clientHeight;
4046
+ el.style.height = Math.min(el.scrollHeight + border, 200) + "px";
4047
+ }
4048
+ function fitCardBoxes(){
4049
+ ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){ fitBox(document.getElementById(id)); });
4050
+ }
4051
+ // live growth: each card box re-fits on its own input (typing and Enter), through the same fitBox
4052
+ ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){
4053
+ var box = document.getElementById(id);
4054
+ box.addEventListener("input", function(){ fitBox(box); });
4055
+ });
4056
+ window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
4057
+ document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
4058
+ // Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
4059
+ msgInput.addEventListener("keydown", function(e){
4060
+ if(e.key === "Enter" && !e.shiftKey && !e.isComposing){
4061
+ e.preventDefault();
4062
+ if(mainAction !== "start") mainBtn.click(); // never starts a (paid) draft from a stray Enter
4063
+ }
4064
+ });
4065
+
4066
+ function renderActions(state){
4067
+ lastState = state;
4068
+ // a session's stop is shown ONCE, in the progress line (renderProgress); #chat-card-error carries only a
4069
+ // refused START (its own handler writes it) — echoing state.error here made the same refusal appear twice
4070
+ errEl.textContent = "";
4071
+ var progressChanged = renderProgress(state);
3201
4072
  // build item 4 — re-enable Start only once the session has settled
3202
4073
  // into a terminal phase (a fresh Start becomes possible again).
3203
4074
  sessionLive = CLIENT_TERMINAL_PHASES.indexOf(state.phase) === -1;
3204
4075
  refreshStartEnabled();
4076
+ // hamr 2026-10-05: while the session is live and the list changed, bring the newest progress line into view.
4077
+ // Never moves keyboard focus, and an unchanged poll tick never scrolls.
4078
+ if(progressChanged && sessionLive && progressRow.lastElementChild){
4079
+ progressRow.lastElementChild.scrollIntoView({block: "nearest"});
4080
+ }
3205
4081
  }
3206
4082
 
3207
4083
  function poll(){
3208
4084
  if(!sessionId) return;
4085
+ var polledId = sessionId;
3209
4086
  authorGet("/api/author/" + sessionId).then(function(j){
3210
- if(!j || !j.state) return;
4087
+ if(!j || !j.state || polledId !== sessionId) return;
3211
4088
  renderMessages(j.state);
3212
4089
  renderActions(j.state);
4090
+ if(reuseSession && !autoSigned && j.state.phase === "prepared" && j.state.specHash){
4091
+ autoSigned = true;
4092
+ authorPost("/api/author/" + sessionId + "/sign", {specHash: j.state.specHash}).then(function(r){
4093
+ var o = chatPostOutcome(r);
4094
+ if(o.ok){ chatActionOk(); } else { autoSigned = false; chatActionFailed(o.error); }
4095
+ poll();
4096
+ }).catch(function(){ autoSigned = false; chatActionFailed("network error — could not reach the panel server, not signed"); });
4097
+ }
3213
4098
  if(j.state.phase === "signed"){
3214
4099
  clearInterval(pollTimer);
3215
4100
  document.getElementById("tab-runs").click();
4101
+ // hamr's ruling 2026-10-05: the run has started, so the Chat card goes back to the empty card (the one reset
4102
+ // Clear uses) — it must not keep showing a signed job that invites a rerun. The run itself is untouched.
4103
+ resetCard();
4104
+ return;
3216
4105
  }
3217
4106
  lastPhase = j.state.phase;
3218
4107
  }).catch(function(){ /* transient — next poll tries again */ });
3219
4108
  }
3220
4109
 
3221
- startBtn.addEventListener("click", function(){
4110
+ function doStart(){
3222
4111
  errEl.textContent = "";
3223
4112
  actionErrEl.textContent = "";
3224
4113
  // build item 4 — disable the moment it's clicked, not just once the
@@ -3226,6 +4115,7 @@
3226
4115
  sessionLive = true;
3227
4116
  refreshStartEnabled();
3228
4117
  var body = currentCard();
4118
+ if(startFrom) body.startFrom = startFrom.importId ? {importId: startFrom.importId} : startFrom.runid;
3229
4119
  authorPost("/api/author/start", body).then(function(r){
3230
4120
  if(r.status !== 200 || !r.body.ok){
3231
4121
  errEl.textContent = (r.body && r.body.error) || "refused";
@@ -3234,12 +4124,89 @@
3234
4124
  return;
3235
4125
  }
3236
4126
  sessionId = r.body.sessionId;
3237
- signClickedOnce = false;
4127
+ // a same-job session has no confirm turn: it goes straight to `prepared`, and THIS click (Sign & run) is the
4128
+ // signature — the page signs the hash the server prepared as soon as it is ready (the server re-checks it)
4129
+ reuseSession = r.body.reuse === true;
4130
+ autoSigned = false;
4131
+ signClickedOnce = reuseSession;
3238
4132
  renderMessages(r.body.state);
3239
4133
  renderActions(r.body.state);
3240
4134
  if(pollTimer) clearInterval(pollTimer);
3241
4135
  pollTimer = setInterval(poll, 2000);
3242
4136
  });
4137
+ }
4138
+
4139
+ // Re-attach after a page load/refresh: the server holds the one live (non-terminal) authoring session, so ask it.
4140
+ // If one exists, this tab takes it over exactly as if it had started it: the card filled from the session's own
4141
+ // copy, the poll loop, progress list, thread, ask box, main button and Abandon. A signed session is terminal, so it
4142
+ // never comes back (the card stays empty). Nothing is stored page-side.
4143
+ function attachSession(j){
4144
+ var st = j.state, c = st.card || {};
4145
+ openNewCard();
4146
+ sessionId = j.sessionId;
4147
+ sessionLive = true;
4148
+ reuseSession = st.reuse === true;
4149
+ autoSigned = false;
4150
+ signClickedOnce = reuseSession;
4151
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
4152
+ if(c.model){
4153
+ if(!Array.prototype.some.call(modelSelect.options, function(o){ return o.value === c.model; })){
4154
+ modelSelect.insertAdjacentHTML("beforeend", '<option value="' + escapeXml(c.model) + '">' + escapeXml(c.model) + "</option>");
4155
+ }
4156
+ modelSelect.value = c.model;
4157
+ }
4158
+ setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4159
+ setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4160
+ setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4161
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4162
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4163
+ if(reuseSession) setReuseLocked(true);
4164
+ fitCardBoxes();
4165
+ renderMessages(st);
4166
+ renderActions(st);
4167
+ if(pollTimer) clearInterval(pollTimer);
4168
+ pollTimer = setInterval(poll, 2000);
4169
+ poll();
4170
+ }
4171
+ function reattachLive(){
4172
+ if(sessionId || sessionLive) return;
4173
+ authorGet("/api/author/live").then(function(j){
4174
+ if(sessionId || sessionLive || !j || !j.ok || !j.sessionId || !j.state) return;
4175
+ attachSession(j);
4176
+ }).catch(function(){ /* nothing to attach to — the empty card stays */ });
4177
+ }
4178
+ document.getElementById("tab-chat").addEventListener("click", reattachLive);
4179
+ reattachLive();
4180
+
4181
+ // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4182
+ function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
4183
+ document.addEventListener("bareloop-reuse", function(e){
4184
+ var d = e.detail || {};
4185
+ var pre = d.prefill || {};
4186
+ var c = pre.card || {};
4187
+ clearStartFrom();
4188
+ openNewCard();
4189
+ startFrom = d.importId ? {importId: d.importId} : {runid: d.runid};
4190
+ var radios = document.querySelectorAll('input[name="jf-verdict"]');
4191
+ radios.forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
4192
+ if(c.model){ modelSelect.value = c.model; }
4193
+ setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4194
+ setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4195
+ setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4196
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4197
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4198
+ setReuseLocked(true);
4199
+ sfOrigin.hidden = false;
4200
+ sfOrigin.textContent = "Reuse: " + ((pre.origin && pre.origin.job) || "job") + (d.runid ? " (run " + d.runid + ")" : " (imported)");
4201
+ sfLine.hidden = false;
4202
+ sfLine.textContent = pre.line || "";
4203
+ sfLine.className = "startfrom-line";
4204
+ sfNote.hidden = !pre.note;
4205
+ sfNote.textContent = pre.note || "";
4206
+ refreshModelStatus();
4207
+ refreshReuseLine();
4208
+ refreshCapNote();
4209
+ refreshStartEnabled();
3243
4210
  });
3244
4211
 
3245
4212
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
@@ -3275,18 +4242,23 @@
3275
4242
  function chatActionOk(){
3276
4243
  actionErrEl.textContent = "";
3277
4244
  }
4245
+ // a dead token (panel restarted): show the one line where Chat actions show errors and stop polling
4246
+ staleTokenHook = function(){
4247
+ actionErrEl.textContent = STALE_TOKEN_LINE;
4248
+ if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
4249
+ };
3278
4250
 
3279
- checkDepsBtn.addEventListener("click", function(){
4251
+ function doCheckDeps(){
3280
4252
  if(!sessionId) return;
3281
- checkDepsBtn.disabled = true;
4253
+ mainBtn.disabled = true;
3282
4254
  authorPost("/api/author/" + sessionId + "/check-deps", {}).then(function(r){
3283
4255
  var o = chatPostOutcome(r);
3284
4256
  if(o.ok) chatActionOk(); else chatActionFailed(o.error);
3285
4257
  poll();
3286
4258
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, nothing was sent"); });
3287
- });
4259
+ }
3288
4260
 
3289
- sendBtn.addEventListener("click", function(){
4261
+ function doSend(){
3290
4262
  if(!sessionId) return;
3291
4263
  var text = msgInput.value;
3292
4264
  if(!text.trim()) return;
@@ -3302,9 +4274,9 @@
3302
4274
  }
3303
4275
  poll();
3304
4276
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3305
- });
4277
+ }
3306
4278
 
3307
- reviseBtn.addEventListener("click", function(){
4279
+ function doRevise(){
3308
4280
  if(!sessionId) return;
3309
4281
  var text = msgInput.value;
3310
4282
  authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
@@ -3317,9 +4289,9 @@
3317
4289
  }
3318
4290
  poll();
3319
4291
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3320
- });
4292
+ }
3321
4293
 
3322
- signBtn.addEventListener("click", function(){
4294
+ function doSign(){
3323
4295
  if(!sessionId) return;
3324
4296
  if(!signClickedOnce){
3325
4297
  // click 1 — the confirm turn's own "confirm" pick. Never signs.
@@ -3342,6 +4314,15 @@
3342
4314
  poll();
3343
4315
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not signed"); });
3344
4316
  });
4317
+ }
4318
+
4319
+ mainBtn.addEventListener("click", function(){
4320
+ if(mainBtn.disabled) return;
4321
+ if(mainAction === "start") doStart();
4322
+ else if(mainAction === "check-deps") doCheckDeps();
4323
+ else if(mainAction === "send") doSend();
4324
+ else if(mainAction === "revise") doRevise();
4325
+ else if(mainAction === "sign-prepare" || mainAction === "sign") doSign();
3345
4326
  });
3346
4327
 
3347
4328
  refreshStartEnabled();