bareloop 0.34.0 → 0.35.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 (53) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bareloop.context.md +241 -13
  3. package/package.json +1 -1
  4. package/src/bundlerun.js +13 -4
  5. package/src/draftspend.js +82 -0
  6. package/src/exits.js +10 -1
  7. package/src/interviewrun.js +7 -7
  8. package/src/job.js +37 -0
  9. package/src/judged.js +2 -2
  10. package/src/kinds.js +2 -1
  11. package/src/ledger.js +101 -5
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +111 -72
  14. package/src/panel/authorroutes.js +84 -14
  15. package/src/panel/authorsession.js +408 -113
  16. package/src/panel/importroutes.js +359 -0
  17. package/src/panel/importrun.js +203 -0
  18. package/src/panel/index.html +1071 -184
  19. package/src/panel/runroutes.js +219 -0
  20. package/src/panel/server.js +839 -70
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +60 -26
  23. package/src/providerrows.js +17 -0
  24. package/src/ralph.js +3 -0
  25. package/src/replay.js +139 -19
  26. package/src/replayio.js +11 -4
  27. package/src/reuse.js +20 -3
  28. package/src/run.js +6 -3
  29. package/src/runlist.js +75 -7
  30. package/src/text.js +20 -0
  31. package/src/u-readout.js +9 -1
  32. package/src/u-watchdog.mjs +4 -2
  33. package/src/userrun.js +138 -57
  34. package/types/draftspend.d.ts +53 -0
  35. package/types/job.d.ts +25 -0
  36. package/types/ledger.d.ts +44 -0
  37. package/types/legs.d.ts +103 -0
  38. package/types/monthly.d.ts +23 -18
  39. package/types/panel/authorroutes.d.ts +23 -1
  40. package/types/panel/authorsession.d.ts +109 -2
  41. package/types/panel/importroutes.d.ts +129 -0
  42. package/types/panel/importrun.d.ts +77 -0
  43. package/types/panel/runroutes.d.ts +32 -0
  44. package/types/panel/server.d.ts +255 -2
  45. package/types/panel/status.d.ts +51 -0
  46. package/types/planrun.d.ts +13 -21
  47. package/types/providerrows.d.ts +12 -0
  48. package/types/replay.d.ts +25 -1
  49. package/types/replayio.d.ts +2 -2
  50. package/types/reuse.d.ts +5 -0
  51. package/types/run.d.ts +6 -2
  52. package/types/runlist.d.ts +52 -2
  53. package/types/text.d.ts +19 -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-rowmeta{margin-left:auto;}
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);}
@@ -441,10 +515,14 @@
441
515
  <div class="pane-body" id="left-pane-body">
442
516
 
443
517
  <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>
518
+ <!-- hamr 2026-10-05: the empty card is ALWAYS there when Chat opens (no + New). The top-right button reads
519
+ "Abandon" while a session is live and unsigned, else "Clear"; both leave an empty card. -->
520
+ <div class="job-card compact" id="job-card" data-testid="job-card">
521
+ <div class="startfrom-origin" id="chat-startfrom-origin" data-testid="startfrom-origin" hidden></div>
447
522
  <h4 id="job-card-title">Job card — draft</h4>
523
+ <button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
524
+ <div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
525
+ <div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
448
526
 
449
527
  <div class="field">
450
528
  <label id="jf-verdict-label">Check type</label>
@@ -466,27 +544,27 @@
466
544
  </div>
467
545
  <div class="field">
468
546
  <label for="jf-goal">Goal</label>
469
- <input class="expand-field" id="jf-goal" type="text">
547
+ <textarea class="jf-wrap" id="jf-goal" rows="2"></textarea>
470
548
  </div>
471
549
  <div class="field">
472
550
  <label for="jf-source">Source</label>
473
- <input id="jf-source" type="text" placeholder="absolute path to a local repo">
551
+ <textarea class="jf-wrap" id="jf-source" rows="1" placeholder="absolute path to a local repo"></textarea>
474
552
  </div>
475
553
  <div class="field">
476
554
  <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">
555
+ <textarea class="jf-wrap" id="jf-dest" rows="1" placeholder="the write fence — where in the repo this run may change files"></textarea>
478
556
  </div>
479
557
  <div class="field">
480
558
  <label for="jf-success">Success</label>
481
- <input class="expand-field" id="jf-success" type="text">
559
+ <textarea class="jf-wrap" id="jf-success" rows="2"></textarea>
482
560
  </div>
483
561
  <div class="field">
484
562
  <label for="jf-guardrails">Guardrails</label>
485
- <input id="jf-guardrails" type="text">
563
+ <textarea class="jf-wrap" id="jf-guardrails" rows="2"></textarea>
486
564
  </div>
487
565
  <div class="field">
488
566
  <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>
567
+ <textarea class="jf-wrap" id="jf-judge" rows="2" disabled></textarea>
490
568
  </div>
491
569
 
492
570
  <div class="cap-row" data-testid="cap-row">
@@ -500,21 +578,15 @@
500
578
  <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
501
579
  </div>
502
580
  </div>
503
- <button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
504
581
  <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
582
  </div>
515
583
 
516
584
  <div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
517
585
 
586
+ <!-- ONE progress list (hamr 2026-10-04): every pipeline step is one line that updates in place, running with
587
+ 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. -->
588
+ <ol class="chat-steps" id="chat-progress-row" data-testid="chat-steps" aria-live="polite" hidden></ol>
589
+
518
590
  <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
519
591
  sign or check-deps POST shows here and STAYS shown: it is set
520
592
  only by this tab's own action handlers below and is never
@@ -525,15 +597,10 @@
525
597
 
526
598
  <div class="chat-input-row">
527
599
  <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>
600
+ <textarea id="chat-msg" rows="2" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg"></textarea>
536
601
  </div>
602
+ <button class="btn primary wide" type="button" id="chat-main-btn" data-testid="chat-main" disabled>Start drafting</button>
603
+ <div class="hint" id="chat-main-hint" data-testid="chat-main-hint"></div>
537
604
  <div class="hint">Only your click signs. The chat can't.</div>
538
605
  </section>
539
606
 
@@ -541,9 +608,25 @@
541
608
  <div class="chips" role="group" aria-label="Runs view" style="margin-bottom:8px;">
542
609
  <button class="chip" type="button" aria-pressed="true" data-runs-view="workflows" data-testid="runs-view-workflows">Workflows</button>
543
610
  <button class="chip" type="button" aria-pressed="false" data-runs-view="history" data-testid="runs-view-history">History</button>
611
+ <button class="btn small" type="button" id="wf-import" data-testid="wf-import" title="look at an exported job folder (read only)">Import</button>
612
+ </div>
613
+ <div class="import-box" id="import-box" data-testid="import-box" hidden>
614
+ <label for="import-path">Import a job folder</label>
615
+ <div class="import-pathrow">
616
+ <input id="import-path" data-testid="import-path" type="text" placeholder="/path/to/job.bareloop — or pick below" autocomplete="off">
617
+ <button class="btn small" type="button" id="import-open" data-testid="import-open">Go</button>
618
+ </div>
619
+ <div class="resume-err" id="import-err" data-testid="import-err" hidden></div>
620
+ <div class="hint" id="import-resolved" data-testid="import-resolved"></div>
621
+ <div class="import-entries" id="import-entries" data-testid="import-entries"></div>
622
+ <div class="ended-actions">
623
+ <button class="btn primary" type="button" id="import-go" data-testid="import-go">Import</button>
624
+ <button class="btn" type="button" id="import-cancel" data-testid="import-cancel">Cancel</button>
625
+ </div>
544
626
  </div>
545
627
  <div id="runs-filterbar-anchor"></div>
546
628
  <div id="wf-list" data-testid="wf-list"></div>
629
+ <div id="import-list" data-testid="import-list"></div>
547
630
  <div id="history-list" data-testid="history-list" hidden></div>
548
631
  </section>
549
632
 
@@ -556,8 +639,7 @@
556
639
  <div class="rp-header-title">
557
640
  <span class="dot grey" id="active-wf-dot"></span>
558
641
  <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>
642
+ <span class="rp-status" id="active-wf-verdict" data-testid="active-wf-verdict"></span>
561
643
  </div>
562
644
  </div>
563
645
 
@@ -570,8 +652,10 @@
570
652
  <div class="pane-body" id="right-pane-body">
571
653
 
572
654
  <section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
655
+ <div class="import-view" id="import-view" data-testid="import-view" hidden></div>
573
656
  <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
574
657
  <div id="run-content" hidden>
658
+ <div class="run-actions" id="run-actions" data-testid="run-actions" hidden></div>
575
659
  <div class="counters" id="run-counters" data-testid="live-counters"></div>
576
660
  <div class="summary-box" id="run-summary" data-testid="run-summary"></div>
577
661
  <div class="map-box" data-testid="step-map" id="map-box"><div id="map-mount"></div></div>
@@ -582,6 +666,7 @@
582
666
  <section id="panel-audit" class="tabpanel" role="tabpanel" aria-labelledby="tab-audit" hidden>
583
667
  <div id="audit-select-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
584
668
  <div id="audit-body" hidden>
669
+ <div id="ended-block" class="ended-block" data-testid="ended-block" hidden></div>
585
670
  <div class="audit-toolbar" style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
586
671
  <div class="chips" role="group" aria-label="Filter audit rows" style="margin-bottom:0;">
587
672
  <button class="chip" aria-pressed="true" data-filter="all" data-testid="chip-all">All</button>
@@ -619,8 +704,8 @@
619
704
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
620
705
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
621
706
  <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>
707
+ <div class="field"><label id="details-cap-money-label">$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
708
+ <div class="field"><label id="details-cap-time-label">Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
624
709
  </div>
625
710
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
626
711
  <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
@@ -630,6 +715,7 @@
630
715
  <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
631
716
  <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
632
717
  <div class="hint" id="job-note"></div>
718
+ <div id="resume-job" class="resume-job" data-testid="resume-job" hidden></div>
633
719
  </div>
634
720
  </section>
635
721
 
@@ -656,7 +742,7 @@
656
742
  <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
657
743
  <div class="keyfile-strip" data-testid="keyfile-box">
658
744
  <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>
745
+ <span>Put <code>NAME=key</code> lines in <code id="pv-keyfile-path" data-testid="keyfile-path">the keys file (path loading…)</code>, then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
660
746
  <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
661
747
  </div>
662
748
  <div class="keyfile-strip-actions">
@@ -720,15 +806,28 @@
720
806
  var TOKEN = "__BARELOOP_PANEL_TOKEN__";
721
807
  document.getElementById("host-line").textContent = "127.0.0.1:" + PORT;
722
808
 
809
+ // After a panel restart this page's token is dead: the server refuses EVERY call with 403 and the exact reason
810
+ // below (checkHumanGuard, src/panel/authorroutes.js). The ONE place the page makes calls swaps that raw text for
811
+ // one clear line and tells the Chat poll to stop (staleTokenHook, set by the Chat code). No auto-reload.
812
+ var STALE_TOKEN_REFUSAL = "refused \u2014 missing or wrong token";
813
+ var STALE_TOKEN_LINE = "The panel was restarted \u2014 reload this page. A drafting session that was open is gone (nothing more is spent).";
814
+ var staleTokenHook = function(){};
815
+ function staleTokenCheck(j, status){
816
+ if(j && j.ok === false && j.error === STALE_TOKEN_REFUSAL && (status === undefined || status === 403)){
817
+ j.error = STALE_TOKEN_LINE;
818
+ staleTokenHook();
819
+ }
820
+ return j;
821
+ }
723
822
  function authorPost(path, body){
724
823
  return fetch(path, {
725
824
  method: "POST",
726
825
  headers: {"content-type": "application/json", "x-bareloop-token": TOKEN},
727
826
  body: JSON.stringify(body || {}),
728
- }).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: j}; }); });
827
+ }).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: staleTokenCheck(j, r.status)}; }); });
729
828
  }
730
829
  function authorGet(path){
731
- return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json(); });
830
+ return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json().then(function(j){ return staleTokenCheck(j, r.status); }); });
732
831
  }
733
832
 
734
833
  function wireTabs(tabButtons){
@@ -832,6 +931,8 @@
832
931
  var n = 0;
833
932
  return steps.map(function(s){
834
933
  if(s.noNumber) return -1;
934
+ // P5-R: a step continuing from the leg before is the SAME step — it keeps its number
935
+ if(s.continued && n > 0) return n - 1;
835
936
  n++;
836
937
  return n - 1;
837
938
  });
@@ -847,6 +948,8 @@
847
948
  function partBoxState(part, isLastLivePart){
848
949
  if(part.outcome === "green" || part.outcome === "satisfied" || part.outcome === "already-green") return "done";
849
950
  if(typeof part.outcome === "string") return "stopped";
951
+ // P5-R: its leg's halt cut this part off — the map's existing "stopped" state, never "done"
952
+ if(part.stopReason) return "stopped";
850
953
  if(isLastLivePart) return "running";
851
954
  return "done"; // scout/plan/replan/judge: no per-part verdict, but the part itself already happened
852
955
  }
@@ -875,7 +978,9 @@
875
978
  var list = Array.isArray(parts) ? parts : [];
876
979
  return list.map(function(part, idx){
877
980
  var isLastLivePart = !!isLive && idx === list.length - 1;
878
- var occSuffix = part.kind === "step" && part.occurrence > 1 ? " (try " + part.occurrence + ")" : "";
981
+ // "(try N)" is for real in-leg retries only; a step re-entered across a leg boundary is the same step continuing
982
+ var tryN = typeof part.tryNumber === "number" ? part.tryNumber : part.occurrence;
983
+ var occSuffix = part.kind === "step" && part.continued ? " (continued)" : (part.kind === "step" && tryN > 1 ? " (try " + tryN + ")" : "");
879
984
  return {
880
985
  kind: part.kind,
881
986
  partIndex: idx,
@@ -888,6 +993,9 @@
888
993
  // Audit tab's expand-to-rounds, read off `box.part.attempts` directly).
889
994
  attempts: partHasNoVerdict(part) ? [] : (Array.isArray(part.attempts) ? part.attempts : []),
890
995
  blocked: typeof part.blocked === "number" ? part.blocked : 0,
996
+ // P5-R: the leg that ran this part ENDED here and the next part is where the next leg picked up
997
+ resumedNext: part.resumedNext === true,
998
+ continued: part.continued === true,
891
999
  part: part,
892
1000
  };
893
1001
  });
@@ -1031,34 +1139,35 @@
1031
1139
  '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
1032
1140
  }
1033
1141
  if(idx < n - 1){
1142
+ // P5-R: where a leg ended and the next leg picked up, the connector is DOTTED (and says so) —
1143
+ // one run, one map; the dotted line is the only mark of the resume.
1144
+ var dash = s.resumedNext ? ' stroke-dasharray="2,4" data-resumed="1"' : '';
1034
1145
  if(nextRow === row){
1035
1146
  var midY = y + boxH / 2;
1036
1147
  if(!rtl){
1037
1148
  var nx = x + boxW + gap;
1038
1149
  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>';
1150
+ '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1040
1151
  } else {
1041
1152
  var px = x - gap - boxW;
1042
1153
  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>';
1154
+ '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1044
1155
  }
1045
1156
  } else {
1046
1157
  var cx = x + boxW / 2;
1047
1158
  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>';
1159
+ '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1049
1160
  }
1050
1161
  }
1051
1162
  });
1052
1163
  return '<svg width="' + totalW + '" height="' + totalH + '" viewBox="0 0 ' + totalW + ' ' + totalH + '" role="img" aria-label="step map">' + body + '</svg>';
1053
1164
  }
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>' +
1165
+ // the line-style key under the MAP (hamr 2026-10-04): only the two line styles, never a sign legend
1166
+ function stepMapKeyHTML(steps){
1167
+ var resumed = (steps || []).some(function(b){ return b.resumedNext; });
1168
+ return '<div class="map-key" data-testid="map-key">' +
1061
1169
  '<span>⤾ dashed = retry</span>' +
1170
+ (resumed ? '<span>&middot;&middot;&middot; dotted = resumed</span>' : '') +
1062
1171
  '</div>';
1063
1172
  }
1064
1173
  var lastSteps = null;
@@ -1081,7 +1190,7 @@
1081
1190
  wrapper.hidden = false;
1082
1191
  var w = mapAvailWidth(wrapper);
1083
1192
  lastWidth = w;
1084
- mount.innerHTML = buildStepMapSVG(steps, w) + stepMapLegendHTML();
1193
+ mount.innerHTML = buildStepMapSVG(steps, w) + stepMapKeyHTML(steps);
1085
1194
  // build item B: a map box click jumps to the Audit tab, that same part
1086
1195
  // expanded and scrolled into view — same click-through as the card list,
1087
1196
  // sharing partIndex so the two can never disagree about which row a box
@@ -1101,7 +1210,7 @@
1101
1210
  var w = mapAvailWidth(wrapper);
1102
1211
  if(w === null || w === lastWidth) return;
1103
1212
  lastWidth = w;
1104
- document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapLegendHTML();
1213
+ document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapKeyHTML(lastSteps);
1105
1214
  }
1106
1215
  if(typeof ResizeObserver !== "undefined"){
1107
1216
  var ro = new ResizeObserver(function(){ reflowMap(); });
@@ -1126,11 +1235,51 @@
1126
1235
  if(g === "?") return "magenta";
1127
1236
  return "grey";
1128
1237
  }
1238
+ // ONE spelling of "**word** — reason": the sign and the word come from the server's status table
1239
+ // (src/panel/status.js) — the page never maps an outcome to a word itself. No status = nothing to say.
1240
+ function statusWordHtml(status, reason){
1241
+ if(!status || !status.word) return "";
1242
+ return '<b class="st-word">' + escapeXml(status.word) + '</b>' + (reason ? " \u2014 " + escapeXml(reason) : "");
1243
+ }
1244
+ // local date + time as the header shows it: "10/4/2026, 6:01 PM" ("unknown" when the spine carried no time)
1245
+ function fmtLocalDateTime(iso){
1246
+ var d = iso ? new Date(iso) : null;
1247
+ if(!d || isNaN(d.getTime())) return "unknown";
1248
+ return d.toLocaleString("en-US", {year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit"});
1249
+ }
1250
+ // the right-side header: `[sign] job (runid) │ **word** — reason · ended 10/4/2026, 6:01 PM` (no capitals: the job
1251
+ // name is shown as written). `bodyHtml` is already escaped.
1252
+ function setRunHeader(sign, nameText, bodyHtml){
1253
+ document.getElementById("active-wf-name").textContent = nameText;
1254
+ document.getElementById("active-wf-dot").className = "dot " + glyphClass(sign);
1255
+ var st = document.getElementById("active-wf-verdict");
1256
+ st.innerHTML = bodyHtml ? '<span class="rp-sep">\u2502</span>' + bodyHtml : "";
1257
+ st.removeAttribute("title");
1258
+ }
1259
+ // "step 2 of 3" for a live run: the step it is on (first one not yet done) of the real steps
1260
+ function liveStepText(detail){
1261
+ var rs = realSteps(detail.steps || []);
1262
+ if(rs.length === 0) return "";
1263
+ var done = rs.filter(function(s){ return s.state === "done"; }).length;
1264
+ return "step " + Math.min(done + 1, rs.length) + " of " + rs.length;
1265
+ }
1266
+ function runHeaderBody(detail){
1267
+ var live = detail.status && detail.status.word === "running";
1268
+ var reason = detail.ended ? detail.ended.line : (live ? liveStepText(detail) : "");
1269
+ var when = detail.endedAt ? ("ended " + fmtLocalDateTime(detail.endedAt)) : ("started " + fmtLocalDateTime(detail.startedAt || detail.at));
1270
+ return statusWordHtml(detail.status, reason) + " \u00B7 " + escapeXml(when);
1271
+ }
1272
+
1273
+ // P5-R: a resumed run is the SAME run — one card, never two. This is only a small tag on it.
1274
+ function resumedTagHtml(count, testid){
1275
+ return (typeof count === "number" && count > 0)
1276
+ ? '<span class="wf-meta wf-resumed" data-testid="' + escapeXml(testid) + '">resumed &times;' + count + '</span>' : '';
1277
+ }
1129
1278
 
1130
1279
  // ---- Runs tab: ONE run-row builder shared by History (flat) and the
1131
1280
  // Workflows view's per-job expansion (item 2, 2026-09-26 merge) — the two
1132
1281
  // never drift on how a run row reads.
1133
- function buildRunRowEl(r){
1282
+ function buildRunRowEl(r, compact){
1134
1283
  var row = document.createElement("div");
1135
1284
  // build item (2026-09-26 fix): compute selected state here, off the
1136
1285
  // module's own `currentRunid`, so a row freshly built by a RE-RENDER
@@ -1146,7 +1295,15 @@
1146
1295
  row.setAttribute("role", "button");
1147
1296
  row.setAttribute("aria-pressed", selected ? "true" : "false");
1148
1297
  var histFullName = r.job + ' (' + r.runid + ')';
1149
- if(r.fileMissing){
1298
+ if(r.starting){
1299
+ row.innerHTML =
1300
+ '<span class="wf-line1"><span class="dot amber"></span>' +
1301
+ '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1302
+ '<span class="wf-meta-line">' +
1303
+ '<span class="wf-meta" data-testid="hist-starting-' + escapeXml(r.runid) + '">starting…</span>' +
1304
+ '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1305
+ '</span>';
1306
+ } else if(r.fileMissing){
1150
1307
  row.innerHTML =
1151
1308
  '<span class="wf-line1"><span class="dot grey"></span>' +
1152
1309
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
@@ -1154,15 +1311,25 @@
1154
1311
  '<span class="wf-meta">file missing</span>' +
1155
1312
  '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1156
1313
  '</span>';
1314
+ } else if(compact){
1315
+ // an EXPANDED per-run row under a workflow: `[sign] **word** — reason runid · $ · date`
1316
+ row.innerHTML =
1317
+ '<span class="wf-line1 wf-compact"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1318
+ '<span class="wf-ended wf-ended-inline" title="' + escapeXml(histFullName) + '" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span>' +
1319
+ '<span class="wf-meta wf-rowmeta">' + escapeXml(r.runid) + ' \u00B7 ' + escapeXml(rowSpendText(r)) + ' \u00B7 ' + escapeXml(r.date || "unknown") + '</span>' +
1320
+ resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) + '</span>';
1157
1321
  } else {
1322
+ // History card: line 1 sign + job (runid); line 2 **word** — reason; line 3 the facts
1158
1323
  row.innerHTML =
1159
1324
  '<span class="wf-line1"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1160
1325
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1326
+ (r.status ? '<span class="wf-ended" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span>' : '') +
1161
1327
  '<span class="wf-meta-line">' +
1162
1328
  '<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
1163
1329
  '<span class="wf-meta">' + escapeXml(rowSpendText(r)) + '</span>' +
1164
- '<span class="wf-meta">' + escapeXml(r.wall) + '</span>' +
1330
+ '<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
1165
1331
  '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
1332
+ resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
1166
1333
  '</span>';
1167
1334
  }
1168
1335
  row.addEventListener("click", function(){ selectRun(r.runid, row, ".hist-row"); document.getElementById("tab-run").click(); scrollRunIntoViewMobile(); });
@@ -1205,10 +1372,13 @@
1205
1372
  lastRunid: last.runid,
1206
1373
  lastAt: last.at,
1207
1374
  lastGlyph: last.glyph || "▶",
1375
+ lastStatus: last.status || null,
1376
+ lastEndedLine: last.endedLine || null,
1377
+ lastResumedCount: last.resumedCount || 0,
1208
1378
  lastCheckType: last.checkType || "unknown",
1209
1379
  lastModel: last.model || null,
1210
1380
  lastSpend: rowSpendText(last),
1211
- lastWall: last.wall || "unknown",
1381
+ lastWall: rowWallText(last) || "unknown",
1212
1382
  lastDate: last.date,
1213
1383
  };
1214
1384
  });
@@ -1337,11 +1507,13 @@
1337
1507
  row.innerHTML =
1338
1508
  '<span class="wf-line1"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
1339
1509
  '<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span></span>' +
1510
+ (g.lastStatus ? '<span class="wf-ended" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') +
1340
1511
  '<span class="wf-meta-line">' +
1341
1512
  '<span class="wf-meta">' + escapeXml(g.lastCheckType) + '</span>' +
1342
1513
  '<span class="wf-meta">' + escapeXml(g.lastSpend) + '</span>' +
1343
1514
  '<span class="wf-meta">' + escapeXml(g.lastWall) + '</span>' +
1344
1515
  '<span class="wf-meta">' + escapeXml(g.lastDate || "unknown") + '</span>' +
1516
+ resumedTagHtml(g.lastResumedCount, "wf-resumed-" + g.job) +
1345
1517
  '<span class="wf-meta">' + g.runCount + ' run' + (g.runCount === 1 ? '' : 's') + '</span>' +
1346
1518
  '</span>';
1347
1519
  row.addEventListener("click", function(){
@@ -1368,7 +1540,7 @@
1368
1540
  if(expanded){
1369
1541
  var runsWrap = document.createElement("div");
1370
1542
  runsWrap.className = "wf-runs";
1371
- childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r)); });
1543
+ childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1372
1544
  wrap.appendChild(runsWrap);
1373
1545
  }
1374
1546
  el.appendChild(wrap);
@@ -1585,6 +1757,10 @@
1585
1757
  });
1586
1758
  document.getElementById("wf-list").hidden = runsViewMode !== "workflows";
1587
1759
  document.getElementById("history-list").hidden = runsViewMode !== "history";
1760
+ // P5 item 4: Import lives on the Workflows view only
1761
+ document.getElementById("wf-import").hidden = runsViewMode !== "workflows";
1762
+ document.getElementById("import-list").hidden = runsViewMode !== "workflows";
1763
+ if(runsViewMode !== "workflows") document.getElementById("import-box").hidden = true;
1588
1764
  }
1589
1765
  document.querySelectorAll('.chip[data-runs-view]').forEach(function(chip){
1590
1766
  chip.addEventListener("click", function(){
@@ -1622,6 +1798,264 @@
1622
1798
  document.querySelectorAll(".wf-row, .hist-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1623
1799
  }
1624
1800
 
1801
+ // ---- P5 item 4: IMPORT (read only) — the folder browser, the imported rows, the view ----
1802
+ // The folder listing, the import and every re-read are SERVER routes behind the human guard
1803
+ // (src/panel/importroutes.js): the page only asks and paints. An imported job has ONE button: Reuse workflow.
1804
+ var currentImportId = null;
1805
+ var importBox = document.getElementById("import-box");
1806
+ var importPathEl = document.getElementById("import-path");
1807
+ var importErrEl = document.getElementById("import-err");
1808
+ function importShowErr(msg){ importErrEl.textContent = msg; importErrEl.hidden = !msg; }
1809
+ // the last folder the browser showed — per-viewer convenience only (try/catch, the browser works without it)
1810
+ var IMPORT_DIR_KEY = "bareloop.importDir";
1811
+ function rememberImportDir(path){
1812
+ try{ localStorage.setItem(IMPORT_DIR_KEY, path); }catch(e){ /* best-effort only */ }
1813
+ }
1814
+ function recallImportDir(){
1815
+ try{ var v = localStorage.getItem(IMPORT_DIR_KEY); if(typeof v === "string") return v; }catch(e){ /* per-viewer convenience only */ }
1816
+ return "";
1817
+ }
1818
+ // one directory: its folders (names + a "bundle" tag), a way up, and which folder the server actually chose
1819
+ function importLoadDir(path){
1820
+ importShowErr("");
1821
+ return authorGet("/api/fs/list?path=" + encodeURIComponent(path || "")).then(function(r){
1822
+ if(!r || !r.ok){
1823
+ // a refused path leaves ONLY the error: no stale "showing <folder>" line, no stale list
1824
+ document.getElementById("import-resolved").textContent = "";
1825
+ document.getElementById("import-entries").innerHTML = "";
1826
+ importShowErr((r && r.error) || "could not open that folder");
1827
+ return false;
1828
+ }
1829
+ rememberImportDir(r.path);
1830
+ importPathEl.value = r.path;
1831
+ 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" : "");
1832
+ var el = document.getElementById("import-entries");
1833
+ el.innerHTML = "";
1834
+ if(r.parent){
1835
+ var up = document.createElement("button");
1836
+ up.type = "button"; up.className = "import-entry import-up"; up.setAttribute("data-testid", "import-up");
1837
+ up.setAttribute("aria-label", "up one folder"); up.setAttribute("title", "up one folder");
1838
+ up.textContent = "..";
1839
+ up.addEventListener("click", function(){ importLoadDir(r.parent); });
1840
+ el.appendChild(up);
1841
+ }
1842
+ r.entries.forEach(function(e){
1843
+ var b = document.createElement("button");
1844
+ b.type = "button"; b.className = "import-entry"; b.setAttribute("data-testid", "import-entry-" + e.name);
1845
+ b.setAttribute("aria-pressed", "false");
1846
+ b.innerHTML = escapeXml(e.name) + ' <span class="tag">(bundle)</span>';
1847
+ b.addEventListener("click", function(){
1848
+ el.querySelectorAll(".import-entry").forEach(function(x){ x.classList.remove("selected"); x.setAttribute("aria-pressed", "false"); });
1849
+ b.classList.add("selected"); b.setAttribute("aria-pressed", "true");
1850
+ importPathEl.value = e.path; importShowErr("");
1851
+ });
1852
+ el.appendChild(b);
1853
+ });
1854
+ if(!r.entries.length) el.insertAdjacentHTML("beforeend", '<div class="hint" data-testid="import-empty">no job bundles here (searched ' + r.depth + ' folders deep)</div>');
1855
+ 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>');
1856
+ 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>');
1857
+ return true;
1858
+ }).catch(function(x){ importShowErr("could not reach the panel: " + x.message); });
1859
+ }
1860
+ document.getElementById("wf-import").addEventListener("click", function(){
1861
+ importBox.hidden = !importBox.hidden;
1862
+ if(importBox.hidden) return;
1863
+ var want = importPathEl.value || recallImportDir();
1864
+ importLoadDir(want).then(function(ok){ if(ok === false && want && !importPathEl.value) { importShowErr(""); return importLoadDir(""); } });
1865
+ });
1866
+ document.getElementById("import-open").addEventListener("click", function(){ importLoadDir(importPathEl.value); });
1867
+ importPathEl.addEventListener("keydown", function(e){ if(e.key === "Enter"){ e.preventDefault(); importLoadDir(importPathEl.value); } });
1868
+ document.getElementById("import-cancel").addEventListener("click", function(){ importBox.hidden = true; importShowErr(""); });
1869
+ document.getElementById("import-go").addEventListener("click", function(){
1870
+ importShowErr("");
1871
+ authorPost("/api/imports", {path: importPathEl.value}).then(function(res){
1872
+ if(!res.body || !res.body.ok){ importShowErr((res.body && res.body.error) || ("refused (" + res.status + ")")); return; }
1873
+ importBox.hidden = true;
1874
+ return loadImports().then(function(){ return selectImport(res.body.id); }).then(function(){ revealImportRow(res.body.id); scrollRunIntoViewMobile(); });
1875
+ }).catch(function(x){ importShowErr("could not reach the panel: " + x.message); });
1876
+ });
1877
+
1878
+ // after [Import] the box above the list closes and the left pane shrinks: bring the job just opened into view
1879
+ function revealImportRow(id){
1880
+ var row = document.querySelector('[data-testid="import-row-' + id + '"]');
1881
+ if(row && row.scrollIntoView) row.scrollIntoView({block: "nearest"});
1882
+ }
1883
+
1884
+ // the imported rows (Workflows view): every bundle is re-read by the server each time this list is fetched
1885
+ function loadImports(){
1886
+ return authorGet("/api/imports").then(function(r){ renderImports((r && r.imports) || []); }).catch(function(){ /* keep what is shown */ });
1887
+ }
1888
+ function renderImports(list){
1889
+ var el = document.getElementById("import-list");
1890
+ el.innerHTML = "";
1891
+ list.forEach(function(imp){
1892
+ var row = document.createElement("div");
1893
+ row.className = "wf-row" + (imp.id === currentImportId ? " selected" : "");
1894
+ row.setAttribute("data-testid", "import-row-" + imp.id);
1895
+ row.setAttribute("tabindex", "0");
1896
+ row.setAttribute("role", "button");
1897
+ row.setAttribute("aria-pressed", imp.id === currentImportId ? "true" : "false");
1898
+ row.innerHTML =
1899
+ '<span class="wf-line1"><span class="wf-caret"></span><span class="dot ' + (imp.runStatus ? glyphClass(imp.runStatus.sign) : "grey") + '"></span>' +
1900
+ '<span class="wf-name" title="' + escapeXml(imp.dir) + '">' + escapeXml(imp.job) + '</span></span>' +
1901
+ (imp.runStatus ? '<span class="wf-ended" data-testid="import-ended-' + escapeXml(imp.id) + '">' + statusWordHtml(imp.runStatus, imp.runReason) + '</span>' : '') +
1902
+ '<span class="wf-meta-line"><span class="wf-meta wf-imported" data-testid="import-tag-' + escapeXml(imp.id) + '">imported · view only</span></span>' +
1903
+ (imp.status !== "ok" ? '<span class="imp-bad" data-testid="import-changed-' + escapeXml(imp.id) + '">' + escapeXml(imp.statusText || "changed since import") + '</span>' : "");
1904
+ row.addEventListener("click", function(){ selectImport(imp.id); scrollRunIntoViewMobile(); });
1905
+ row.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); row.click(); } });
1906
+ el.appendChild(row);
1907
+ });
1908
+ }
1909
+ function hideImportView(){
1910
+ currentImportId = null;
1911
+ document.getElementById("import-view").hidden = true;
1912
+ document.getElementById("run-counters").hidden = false;
1913
+ document.getElementById("run-empty").textContent = "select a run from Workflows or History";
1914
+ document.getElementById("audit-select-empty").textContent = "select a run first";
1915
+ document.querySelectorAll("#import-list .wf-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed", "false"); });
1916
+ }
1917
+ function selectImport(id){
1918
+ clearSelection();
1919
+ currentRunid = null;
1920
+ runIsLive = false;
1921
+ resumeMode = null;
1922
+ currentImportId = id;
1923
+ document.querySelectorAll("#import-list .wf-row").forEach(function(r){
1924
+ var on = r.getAttribute("data-testid") === "import-row-" + id;
1925
+ r.classList.toggle("selected", on); r.setAttribute("aria-pressed", on ? "true" : "false");
1926
+ });
1927
+ return authorGet("/api/imports/" + encodeURIComponent(id)).then(function(v){
1928
+ if(currentImportId !== id) return;
1929
+ renderImportView(v);
1930
+ }).catch(function(x){ if(currentImportId === id) renderImportView({ok: false, error: x.message}); });
1931
+ }
1932
+ // An imported job reads like a normal run page: the SAME Run / Audit / Job tabs. [Reuse workflow] sits in the top
1933
+ // action row, then the IMPORTED box (what the folder says about itself), then the latest GREEN run in the bundle
1934
+ // painted by the very same renderRun a run uses — its SUMMARY and MAP, and its Audit when the bundle carries the
1935
+ // run's log; a bundle with only a bridge paints the bridge's numbers through that same renderer ("not recorded"
1936
+ // where the bridge is silent); a bundle with no green says so. The Job tab carries the spec. Nothing here writes.
1937
+ function importedHeader(v){
1938
+ var tag = "imported \u00B7 view only";
1939
+ if(v.runStatus) setRunHeader(v.runStatus.sign, v.job, statusWordHtml(v.runStatus, v.runReason) + " \u00B7 " + tag);
1940
+ else setRunHeader("\u00B7", v.job, tag);
1941
+ }
1942
+ function importedNoLog(){
1943
+ document.getElementById("audit-body").hidden = true;
1944
+ var auditEmpty = document.getElementById("audit-select-empty");
1945
+ auditEmpty.hidden = false;
1946
+ auditEmpty.textContent = "no log — this job ran on another machine";
1947
+ }
1948
+ // the green run below the IMPORTED box: spine (read through the ordinary run routes), bridge, or nothing
1949
+ function paintImportedRun(v){
1950
+ var run = v.run || {kind: "none"};
1951
+ var content = document.getElementById("run-content");
1952
+ var guard = function(){ return currentImportId === v.id; };
1953
+ document.getElementById("step-list").innerHTML = "";
1954
+ if(run.kind === "spine"){
1955
+ currentRunid = run.id;
1956
+ getJSON("/api/runs/" + encodeURIComponent(run.id)).then(function(d){
1957
+ if(!guard()) return;
1958
+ d.ended = null; d.resume = null; d.live = false; d.stopping = false;
1959
+ renderRun(d);
1960
+ importedHeader(v);
1961
+ document.getElementById("run-actions").hidden = true;
1962
+ document.getElementById("run-counters").hidden = true; // the SUMMARY headline already says it
1963
+ content.hidden = false;
1964
+ }).catch(function(e){ if(guard()) paintImportedRunPlain(v, "could not read this run: " + e.message); });
1965
+ getJSON("/api/runs/" + encodeURIComponent(run.id) + "/audit").then(function(result){
1966
+ if(guard()) renderAudit(result);
1967
+ }).catch(function(){ if(guard()) renderAudit(null); });
1968
+ return;
1969
+ }
1970
+ importedNoLog();
1971
+ if(run.kind === "bridge" && run.detail){
1972
+ renderRun(run.detail);
1973
+ importedHeader(v);
1974
+ importedNoLog();
1975
+ document.getElementById("run-actions").hidden = true;
1976
+ document.getElementById("run-counters").hidden = true;
1977
+ document.getElementById("step-list").innerHTML = "";
1978
+ if(!run.detail.parts || run.detail.parts.length === 0){
1979
+ document.getElementById("map-box").hidden = false;
1980
+ document.getElementById("map-mount").innerHTML = '<div class="hint" data-testid="import-no-steps">no steps recorded</div>';
1981
+ }
1982
+ content.hidden = false;
1983
+ return;
1984
+ }
1985
+ paintImportedRunPlain(v, "no green run in this bundle");
1986
+ }
1987
+ // a plain sentence in the SUMMARY box and nothing else (no counters, no map, no cards)
1988
+ function paintImportedRunPlain(v, text){
1989
+ var box = document.getElementById("run-summary");
1990
+ box.className = "summary-box";
1991
+ box.innerHTML = '<div class="summary-row"><span class="sum-val" data-testid="import-no-green">' + escapeXml(text) + '</span></div>';
1992
+ document.getElementById("run-counters").hidden = true;
1993
+ document.getElementById("map-box").hidden = true;
1994
+ document.getElementById("step-list").innerHTML = "";
1995
+ document.getElementById("run-actions").hidden = true;
1996
+ importedNoLog();
1997
+ document.getElementById("run-content").hidden = false;
1998
+ }
1999
+ function renderImportView(v){
2000
+ var el = document.getElementById("import-view");
2001
+ el.hidden = false;
2002
+ document.getElementById("run-empty").hidden = true;
2003
+ document.getElementById("run-content").hidden = true;
2004
+ importedNoLog();
2005
+ if(!v || !v.ok){
2006
+ setRunHeader("\u00B7", "imported job", "imported \u00B7 view only");
2007
+ el.innerHTML = '<div class="imp-bad">' + escapeXml((v && v.error) || "could not read this imported job") + '</div>';
2008
+ renderJob(null);
2009
+ return;
2010
+ }
2011
+ importedHeader(v);
2012
+ var h = v.history || {greens: 0, reds: 0, total: 0, recent: []};
2013
+ var row = function(label, text, testid){
2014
+ return '<div class="summary-row"><span class="sum-label">' + label + '</span><span class="sum-val" data-testid="' + testid + '">' + escapeXml(text) + '</span></div>';
2015
+ };
2016
+ var recent = (h.recent || []).map(function(r){
2017
+ var line = (r.at || "").slice(0, 10) || "—";
2018
+ line += " · " + (r.outcome || "?") + (typeof r.costUsd === "number" ? " · " + panelMoney(r.costUsd) : "") + (r.runid ? " · run " + r.runid : "");
2019
+ return '<div class="summary-row"><span class="sum-label"></span><span class="sum-val">' + escapeXml(line) + '</span></div>';
2020
+ }).join("");
2021
+ var run = v.run || {kind: "none"};
2022
+ var runsHere = typeof v.runsHere === "number" ? v.runsHere : 0;
2023
+ var shown = run.kind === "spine" ? ("latest green run here, " + (run.runid || "")) :
2024
+ (run.kind === "bridge" ? ("the exported green run" + (run.runid ? " " + run.runid : "") + (run.at ? ", " + run.at.slice(0, 10) : "")) : "no green run");
2025
+ el.innerHTML =
2026
+ '<div class="run-actions" data-testid="run-actions-import"><button class="btn" type="button" data-testid="btn-reuse-import">Reuse workflow</button></div>' +
2027
+ '<div class="summary-box imported-box" data-testid="import-box">' +
2028
+ (v.status !== "ok" ? '<div class="imp-bad" data-testid="import-view-changed">' + escapeXml(v.changedLine || v.statusText || "changed since import") + '</div>' : "") +
2029
+ row("runs here:", runsHere === 0 ? "none yet" : (runsHere + (runsHere === 1 ? " run" : " runs")), "import-no-runs") +
2030
+ row("exported:", h.greens + " green · " + h.reds + " not green", "import-history") +
2031
+ recent +
2032
+ row("this machine:", v.approvedText || "unknown", "import-approved") +
2033
+ row("shown below:", shown, "import-shown") +
2034
+ '</div>';
2035
+ el.querySelector('[data-testid="btn-reuse-import"]').addEventListener("click", function(){ reuseImportedWorkflow(v.id); });
2036
+ var nr = "not recorded";
2037
+ renderJob({
2038
+ job: v.job, resolved: false, checkType: v.checkType || nr, model: v.model || nr,
2039
+ goal: v.goal || nr, success: v.success || nr, guardrails: v.guardrails || nr,
2040
+ source: nr, destination: nr, tools: nr,
2041
+ budgetUsd: (typeof v.budgetUsd === "number") ? v.budgetUsd : null,
2042
+ maxWallMs: (typeof v.maxWallMs === "number") ? v.maxWallMs : null,
2043
+ note: "imported · view only — the exported job's own spec",
2044
+ });
2045
+ paintImportedRun(v);
2046
+ document.getElementById("tab-run").click();
2047
+ }
2048
+ // Reuse workflow, on an IMPORTED job: the server reads the bundle again now and prefills from its signed spec.
2049
+ function reuseImportedWorkflow(id){
2050
+ authorGet("/api/author/start-from?import=" + encodeURIComponent(id)).then(function(r){
2051
+ if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2052
+ document.getElementById("tab-chat").click();
2053
+ document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: null, importId: id, prefill: r}}));
2054
+ }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2055
+ }
2056
+ loadImports();
2057
+ document.getElementById("tab-runs").addEventListener("click", function(){ loadImports(); });
2058
+
1625
2059
  // ---- Run tab ----
1626
2060
  // panelMoney — hamr's ruling 2026-09-28 ("panel money 2-decimals"): every
1627
2061
  // money display in THIS page (Run tab summary/counters, Runs list rows,
@@ -1679,6 +2113,17 @@
1679
2113
  var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
1680
2114
  return core + draftPart + capPart;
1681
2115
  }
2116
+ // P5 item 6: a LIVE row (no job-end yet, runner alive) reads the same running floors the
2117
+ // right pane reads — `liveSpendText`/`liveWallPhrase` are the one spelling — and a row whose
2118
+ // spine is not written yet reads "starting…", never "file missing" or "unknown".
2119
+ function rowIsLive(r){
2120
+ return r.glyph === "▶" && !r.died && !r.starting && (r.spentUsd === null || r.spentUsd === undefined);
2121
+ }
2122
+ function rowWallText(r){
2123
+ if(r.starting) return "just started";
2124
+ if(rowIsLive(r)) return liveWallPhrase(r.wallFloorMs);
2125
+ return r.wall;
2126
+ }
1682
2127
  /**
1683
2128
  * The Runs list row / Workflows grouping's own spend text — off the
1684
2129
  * numeric fields `/api/runs`' row now carries (never `r.spend`, which is
@@ -1690,6 +2135,8 @@
1690
2135
  * @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
1691
2136
  */
1692
2137
  function rowSpendText(r){
2138
+ if(r.starting) return "starting…";
2139
+ if(rowIsLive(r)) return liveSpendText(r.spendFloorUsd, r.draftSpentUsd, r.draftSpendComplete);
1693
2140
  var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
1694
2141
  var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
1695
2142
  var coreFloor = !!r.died || draftIncomplete;
@@ -1807,6 +2254,11 @@
1807
2254
  if(!detail.model) return "not recorded";
1808
2255
  return detail.provider ? (detail.model + " (" + detail.provider + ")") : detail.model;
1809
2256
  }
2257
+ // an imported job's bridge names the tools its green run used, with no counts
2258
+ function bridgeToolsLine(list){
2259
+ if(!Array.isArray(list) || list.length === 0) return "not recorded";
2260
+ return list.map(function(k){ return toolDisplayLabel(k); }).join(" \u00b7 ");
2261
+ }
1810
2262
  function toolsLine(behaviour){
1811
2263
  if(!behaviour) return "unknown (no log saved)";
1812
2264
  if(behaviour.totalCalls === 0) return "0 calls";
@@ -1858,36 +2310,199 @@
1858
2310
  else { row.hidden = true; val.textContent = ""; }
1859
2311
  }
1860
2312
 
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");
2313
+ // ---- P5: the ENDED block (Audit tab, first block, every finished run) and Resume ----
2314
+ // The sentences are CODE-OWNED on the server (`endedFor`, src/panel/server.js) — this only
2315
+ // paints them. `detail.ended` is null while a run is live (no block while running).
2316
+ var lastEndedSig = null;
2317
+ // P5-R (hamr 2026-10-02): Resume opens the run's OWN Job tab. There ONLY the money cap and the time cap are
2318
+ // editable; every other field is the signed job, shown read-only exactly as the tab already shows it. The click
2319
+ // on [Sign & resume] is the signature when a cap changed. A resume is the SAME run: no new card, no new job.
2320
+ var resumeMode = null;
2321
+ var lastJobShown = null;
2322
+ function renderEnded(detail){
2323
+ var box = document.getElementById("ended-block");
2324
+ var e = detail.ended;
2325
+ if(!e){ box.hidden = true; box.innerHTML = ""; lastEndedSig = null; return; }
2326
+ // rebuilt only when what it shows changed
2327
+ var sig2 = detail.runid + "|" + JSON.stringify(e) + "|" + JSON.stringify(detail.resume);
2328
+ if(sig2 === lastEndedSig) return;
2329
+ lastEndedSig = sig2;
2330
+ box.hidden = false;
2331
+ box.className = "ended-block " + (detail.died ? "magenta" : (detail.glyph === "✓" ? "green" : "red"));
2332
+ // text only: the ONE place for [Stop] / [Resume] / [Reuse workflow] is the Run tab's top action row (renderRunActions)
2333
+ box.innerHTML =
2334
+ '<div class="ended-row"><span class="ended-label">ENDED</span><span class="ended-text" data-testid="ended-reason">' + escapeXml(e.reason) + '</span></div>' +
2335
+ '<div class="ended-row"><span class="ended-label">NEXT</span><span class="ended-text" data-testid="ended-next">' + escapeXml(e.next) + '</span></div>';
2336
+ }
2337
+ // ---- P5 item 5: the run's ACTION AREA (Run tab, above the counters) — Stop while the run is live, Resume once
2338
+ // it ended in a way the engine would accept (the SAME button as the Ended block's, same Job-tab resume mode).
2339
+ // The Ended block (Audit tab) is text only and carries no buttons.
2340
+ var stopAsked = {};
2341
+ // Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
2342
+ // hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
2343
+ function reuseWorkflow(runid){
2344
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid)).then(function(r){
2345
+ if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2346
+ document.getElementById("tab-chat").click();
2347
+ document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
2348
+ }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2349
+ }
2350
+ function renderRunActions(detail){
2351
+ var bar = document.getElementById("run-actions");
2352
+ var html = "";
2353
+ var live = !!detail.live && !detail.died && !detail.ended;
2354
+ if(live){
2355
+ if(detail.stopping || stopAsked[detail.runid]){
2356
+ html = '<span class="stopping" data-testid="stopping-note">stopping after this turn…</span>';
2357
+ } else {
2358
+ html = '<button class="btn" type="button" data-testid="btn-stop">Stop</button>';
2359
+ }
2360
+ } else {
2361
+ delete stopAsked[detail.runid];
2362
+ var canResume = !!detail.resume && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "resume"; });
2363
+ if(canResume) html = '<button class="btn primary" type="button" data-testid="btn-resume-run">Resume</button>';
2364
+ }
2365
+ // Reuse workflow: on a GREEN run only (the server's Ended block says so) — a new run of the same signed job
2366
+ if(!live && !detail.died && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "reuse"; })){
2367
+ html += '<button class="btn" type="button" data-testid="btn-reuse">Reuse workflow</button>';
2368
+ }
2369
+ bar.innerHTML = html;
2370
+ bar.hidden = html === "";
2371
+ var reuseBtn = bar.querySelector('[data-testid="btn-reuse"]');
2372
+ if(reuseBtn) reuseBtn.addEventListener("click", function(){ reuseWorkflow(detail.runid); });
2373
+ var stopBtn = bar.querySelector('[data-testid="btn-stop"]');
2374
+ if(stopBtn){
2375
+ stopBtn.addEventListener("click", function(){
2376
+ stopBtn.disabled = true;
2377
+ authorPost("/api/runs/" + encodeURIComponent(detail.runid) + "/stop", {}).then(function(res){
2378
+ if(res.body && res.body.ok){ stopAsked[detail.runid] = true; renderRunActions(detail); }
2379
+ 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>'); }
2380
+ }).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>'); });
2381
+ });
2382
+ }
2383
+ var resBtn = bar.querySelector('[data-testid="btn-resume-run"]');
2384
+ if(resBtn) resBtn.addEventListener("click", function(){ openResumeOnJobTab(detail); });
2385
+ }
2386
+ function openResumeOnJobTab(detail){
2387
+ var r = detail.resume || {};
2388
+ resumeMode = {
2389
+ runid: detail.runid, spentUsd: r.spentUsd, spendComplete: r.spendComplete,
2390
+ wallUsedMs: r.wallUsedMs,
2391
+ budget: (r.budgetUsd === null || r.budgetUsd === undefined) ? "" : String(r.budgetUsd),
2392
+ wall: typeof r.maxWallMin === "number" ? String(r.maxWallMin) : null,
2393
+ painted: false,
2394
+ };
2395
+ document.getElementById("tab-details").click();
2396
+ paintResumeMode();
2397
+ }
2398
+ // Turns the Job tab's two cap values into inputs (and shows the Sign box) while a resume is open on this run;
2399
+ // called again after every Job re-render so a re-render never drops the typed numbers.
2400
+ function paintResumeMode(){
2401
+ var box = document.getElementById("resume-job");
2402
+ var moneyLabel = document.getElementById("details-cap-money-label");
2403
+ var timeLabel = document.getElementById("details-cap-time-label");
2404
+ if(!resumeMode || resumeMode.runid !== currentRunid){
2405
+ box.hidden = true; box.innerHTML = "";
2406
+ moneyLabel.textContent = "$ cap"; timeLabel.textContent = "Time cap";
2407
+ return;
2408
+ }
2409
+ var m = resumeMode;
2410
+ // the unit lives in the caption, never as loose text around the input
2411
+ moneyLabel.textContent = "Money cap ($)";
2412
+ timeLabel.textContent = "Time cap (min)";
2413
+ document.getElementById("details-cap-money").innerHTML =
2414
+ '<span class="resume-cap"><input type="number" min="0" step="0.01" id="resume-budget" data-testid="resume-budget" value="' + escapeXml(m.budget) + '"></span>';
2415
+ if(m.wall !== null){
2416
+ document.getElementById("details-cap-time").innerHTML =
2417
+ '<span class="resume-cap"><input type="number" min="1" step="1" id="resume-wall" data-testid="resume-wall" value="' + escapeXml(m.wall) + '"></span>';
2418
+ }
2419
+ var budgetEl = document.getElementById("resume-budget");
2420
+ budgetEl.addEventListener("input", function(){ m.budget = budgetEl.value; });
2421
+ var wallEl = document.getElementById("resume-wall");
2422
+ if(wallEl) wallEl.addEventListener("input", function(){ m.wall = wallEl.value; });
2423
+ box.hidden = false;
2424
+ if(m.painted) return;
2425
+ m.painted = true;
2426
+ var spent = (m.spentUsd === null || m.spentUsd === undefined) ? "unknown" : ((m.spendComplete ? "" : "at least ") + panelMoney(m.spentUsd));
2427
+ box.innerHTML =
2428
+ '<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>' +
2429
+ '<div class="hint" data-testid="resume-spent">spent so far ' + escapeXml(spent) +
2430
+ (typeof m.wallUsedMs === "number" && isFinite(m.wallUsedMs) ? ' &middot; time used so far ' + Math.ceil(m.wallUsedMs / 60000) + ' min' : '') + '</div>' +
2431
+ '<div class="ended-actions"><button class="btn primary" type="button" data-testid="btn-sign-resume">Sign &amp; resume</button> ' +
2432
+ '<button class="btn" type="button" data-testid="btn-resume-cancel">Cancel</button></div>' +
2433
+ '<div class="resume-err" id="resume-err" data-testid="resume-err" hidden></div>';
2434
+ var resumeSignBtn = box.querySelector('[data-testid="btn-sign-resume"]');
2435
+ box.querySelector('[data-testid="btn-resume-cancel"]').addEventListener("click", function(){
2436
+ resumeMode = null;
2437
+ renderJob(lastJobShown);
2438
+ });
2439
+ resumeSignBtn.addEventListener("click", function(){
2440
+ var err = document.getElementById("resume-err");
2441
+ err.hidden = true;
2442
+ resumeSignBtn.disabled = true;
2443
+ var body = {budgetUsd: document.getElementById("resume-budget").value};
2444
+ var wall = document.getElementById("resume-wall");
2445
+ if(wall){ body.maxWallMin = wall.value; }
2446
+ return authorPost("/api/runs/" + encodeURIComponent(m.runid) + "/resume", body).then(function(res){
2447
+ if(res.body && res.body.ok){
2448
+ // the SAME run goes on: back to its Run tab, which follows it until it is live again
2449
+ resumeMode = null;
2450
+ renderJob(lastJobShown);
2451
+ document.getElementById("tab-run").click();
2452
+ afterResumeRefresh(m.runid, 8);
2453
+ } else {
2454
+ resumeSignBtn.disabled = false;
2455
+ err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
2456
+ err.hidden = false;
2457
+ }
2458
+ }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
2459
+ });
2460
+ }
2461
+ // After a resume starts, the open run's own page follows it: re-select once the run reads live again (the spine's
2462
+ // new leg appears a moment after the engine starts), a few tries, never forever.
2463
+ function afterResumeRefresh(runid, tries){
2464
+ refreshRunsList(true);
2465
+ setTimeout(function(){
2466
+ if(currentRunid !== runid) return;
2467
+ getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(d){
2468
+ if(currentRunid !== runid) return;
2469
+ if(d.glyph === "▶" && !d.died){ selectRun(runid, null, ".hist-row"); return; }
2470
+ if(tries > 0) afterResumeRefresh(runid, tries - 1);
2471
+ }).catch(function(){ if(tries > 0) afterResumeRefresh(runid, tries - 1); });
2472
+ }, 1500);
2473
+ }
1866
2474
 
2475
+ function renderRun(detail){
1867
2476
  if(detail.fileMissing){
1868
- name.textContent = detail.job + " (" + detail.runid + ")";
1869
- dot.className = "dot grey";
1870
- verdict.textContent = "unknown";
1871
- date.textContent = "file missing";
2477
+ setRunHeader("\u00B7", detail.job + " (" + detail.runid + ")", "file missing");
1872
2478
  document.getElementById("run-empty").hidden = false;
1873
2479
  document.getElementById("run-empty").textContent = "spine file no longer on disk";
1874
2480
  document.getElementById("run-content").hidden = true;
1875
2481
  document.getElementById("audit-select-empty").hidden = false;
2482
+ document.getElementById("audit-select-empty").textContent = "select a run first";
1876
2483
  document.getElementById("audit-body").hidden = true;
1877
2484
  return;
1878
2485
  }
1879
2486
 
2487
+ if(detail.starting){
2488
+ setRunHeader("\u25B6", detail.job + " (" + detail.runid + ")", statusWordHtml(detail.status, "starting\u2026") + " \u00B7 " + escapeXml("started " + fmtLocalDateTime(detail.at)));
2489
+ document.getElementById("run-empty").hidden = false;
2490
+ document.getElementById("run-empty").textContent = "starting… the run has not written its log yet";
2491
+ document.getElementById("run-content").hidden = true;
2492
+ document.getElementById("audit-select-empty").hidden = false;
2493
+ document.getElementById("audit-select-empty").textContent = "starting… the run has not written its log yet";
2494
+ document.getElementById("audit-body").hidden = true;
2495
+ return;
2496
+ }
1880
2497
  document.getElementById("audit-select-empty").hidden = true;
1881
2498
  document.getElementById("audit-body").hidden = false;
2499
+ renderEnded(detail);
1882
2500
 
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";
2501
+ setRunHeader(detail.glyph, detail.job + " (" + detail.runid + ")", runHeaderBody(detail));
1888
2502
 
1889
2503
  document.getElementById("run-empty").hidden = true;
1890
2504
  document.getElementById("run-content").hidden = false;
2505
+ renderRunActions(detail);
1891
2506
 
1892
2507
  // died: the two REAL figures the panel has (spentUsd/wallMs, both null —
1893
2508
  // no job-end means no final totals were ever recorded) are replaced with
@@ -1904,11 +2519,12 @@
1904
2519
  // false and spentUsd is still null) shows the RUNNING floor instead of
1905
2520
  // "unknown" — the server now derives the same spendFloorUsd/wallFloorMs
1906
2521
  // 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;
2522
+ var isLiveNoEnd = !detail.died && detail.spentUsd === null && detail.fromBridge !== true;
2523
+ var spendText, wallPhrase, wallText;
1909
2524
  if(isLiveNoEnd){
1910
2525
  spendText = liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete);
1911
2526
  wallPhrase = liveWallPhrase(detail.wallFloorMs);
2527
+ wallText = wallPhrase; // the summary headline reads wallText in BOTH branches
1912
2528
  } else {
1913
2529
  var draftIncomplete = typeof detail.draftSpentUsd === "number" && detail.draftSpentUsd !== null && detail.draftSpendComplete === false;
1914
2530
  var spendCoreVal = detail.died ? detail.spendFloorUsd : detail.spentUsd;
@@ -1921,10 +2537,16 @@
1921
2537
  // still in progress: a live [▶] run (glyph "▶" and not died).
1922
2538
  var isLive = detail.glyph === "▶" && !detail.died;
1923
2539
  wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
2540
+ // an imported job's green run read from its bridge: only what the bridge carries; the rest says so
2541
+ if(detail.fromBridge === true){
2542
+ spendText = typeof detail.spentUsd === "number" ? ((detail.spendComplete === false ? "at least " : "") + panelMoney(detail.spentUsd)) : "not recorded";
2543
+ wallText = typeof detail.wallMs === "number" ? duration(detail.wallMs) : "not recorded";
2544
+ wallPhrase = typeof detail.wallMs === "number" ? ("took " + wallText) : "time not recorded";
2545
+ }
1924
2546
  }
1925
2547
 
1926
2548
  document.getElementById("run-counters").innerHTML =
1927
- spendText + " of " + panelMoney(detail.budgetUsd) +
2549
+ spendText + ((detail.fromBridge === true && typeof detail.budgetUsd !== "number") ? "" : (" of " + panelMoney(detail.budgetUsd))) +
1928
2550
  " &middot; " + wallPhrase;
1929
2551
 
1930
2552
  var box = document.getElementById("run-summary");
@@ -1935,9 +2557,9 @@
1935
2557
  // "0 of 1 done".
1936
2558
  var rSteps = realSteps(detail.steps);
1937
2559
  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');
2560
+ 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
2561
  box.innerHTML =
1940
- '<div class="summary-headline"><span class="dot ' + glyphClass(detail.glyph) + '"></span><b>' + escapeXml(detail.checkType) + '</b> &middot; ' +
2562
+ '<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
2563
  '<span class="num">' + spendText + '</span> &middot; <span class="num">' + wallText + '</span></div>' +
1942
2564
  '<div class="summary-row" data-testid="summary-model"><span class="sum-label">model:</span><span class="sum-val">' + escapeXml(modelLine(detail)) + '</span></div>' +
1943
2565
  (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 +2567,7 @@
1945
2567
  (detail.replans ? ' &middot; ' + detail.replans + ' replan' + (detail.replans === 1 ? '' : 's') : '') + '</span></div>' +
1946
2568
  '<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
2569
  '<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>' +
2570
+ '<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
2571
  '<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
2572
  '<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
2573
 
@@ -1968,7 +2590,6 @@
1968
2590
  listEl.innerHTML = "";
1969
2591
  orderedBoxes.forEach(function(box, idx){
1970
2592
  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
2593
  var titleText = box.kind === "step" ? ((cardStepNumbers[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
1973
2594
  var resultGlyph = partResultGlyph(part, box);
1974
2595
  // build item 4: ONE owner (partLine1Text, shared with the Audit tab's
@@ -1983,7 +2604,7 @@
1983
2604
  card.setAttribute("role", "button");
1984
2605
  card.innerHTML =
1985
2606
  '<div class="step-head"><h4>' + titleText + '</h4>' +
1986
- '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span>' +
2607
+ stepStateHTML(box, 'part-state-' + idx) +
1987
2608
  (box.blocked > 0 ? '<span class="badge red" data-testid="part-blocked-badge">' + box.blocked + ' blocked</span>' : '') +
1988
2609
  '</div>' +
1989
2610
  '<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>' +
@@ -2002,9 +2623,15 @@
2002
2623
  // for the map+cards, never a second independently-fetched data source
2003
2624
  // (the flat one-row-per-call table still comes from its own `/audit`
2004
2625
  // fetch, wired separately in selectRun below).
2626
+ lastLegDividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
2005
2627
  renderAuditGroups(detail);
2006
2628
  }
2007
2629
 
2630
+ // ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
2631
+ function stepStateHTML(box, testid){
2632
+ var cls = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2633
+ return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(box.state) + '</b></span>';
2634
+ }
2008
2635
  // build item C: Audit tab attempt row -> its rounds (server-built,
2009
2636
  // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
2010
2637
  function buildAttemptsList(container, partIndex, attempts, boxIsLive){
@@ -2189,6 +2816,8 @@
2189
2816
  var latestAttempt = Array.isArray(part.attempts) && part.attempts.length ? part.attempts[part.attempts.length - 1] : null;
2190
2817
  var headline = latestAttempt ? attemptChecksLine(latestAttempt) : "";
2191
2818
  if(headline) bits.push(headline);
2819
+ // P5-R: a part its leg's halt cut off says why, in words
2820
+ if(part && part.stopReason) bits.push("stopped &mdash; " + escapeXml(part.stopReason));
2192
2821
  return bits.join(" &middot; ");
2193
2822
  }
2194
2823
  /**
@@ -2355,10 +2984,12 @@
2355
2984
  var isLiveRun = detail.glyph === "▶" && !detail.died;
2356
2985
  var boxes = buildOrderedBoxes(parts, isLiveRun);
2357
2986
  var numberMap = stepNumberIndices(boxes);
2987
+ // P5-R: a resumed run is ONE run — its legs are told apart by a divider line (code-owned text from the server)
2988
+ var dividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
2358
2989
  boxes.forEach(function(box, idx){
2990
+ dividers.forEach(function(dv){ if(dv.beforePart === idx) container.appendChild(legDividerEl(dv)); });
2359
2991
  var part = box.part;
2360
2992
  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
2993
  var rowWrap = document.createElement("div");
2363
2994
  rowWrap.className = "audit-part";
2364
2995
  rowWrap.setAttribute("data-testid", "audit-part-" + idx);
@@ -2372,7 +3003,7 @@
2372
3003
  headBtn.setAttribute("data-testid", "audit-part-toggle-" + idx);
2373
3004
  var groupResultGlyph = partResultGlyph(part, box);
2374
3005
  headBtn.innerHTML =
2375
- '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part, detail) +
3006
+ stepStateHTML(box, 'audit-part-state-' + idx) + ' ' + titleText + ' &middot; ' + partLine1Text(part, detail) +
2376
3007
  (groupResultGlyph !== null ? ' &middot; ' + groupResultGlyph : '') +
2377
3008
  (box.blocked > 0 ? ' <span class="badge red" data-testid="audit-part-blocked-' + idx + '">' + box.blocked + ' blocked</span>' : '');
2378
3009
  var body = document.createElement("div");
@@ -2415,8 +3046,17 @@
2415
3046
  container.appendChild(rowWrap);
2416
3047
  if(startExpanded && !built) buildBody(); // a live run's currently-running part opens by default
2417
3048
  });
3049
+ // a leg that has not started a part yet (or died before one) closes the list
3050
+ dividers.forEach(function(dv){ if(dv.beforePart >= boxes.length) container.appendChild(legDividerEl(dv)); });
2418
3051
  applyAuditFilter(currentAuditFilter);
2419
3052
  }
3053
+ function legDividerEl(dv){
3054
+ var d = document.createElement("div");
3055
+ d.className = "audit-leg-divider";
3056
+ d.setAttribute("data-testid", "audit-leg-divider-" + dv.leg);
3057
+ d.textContent = "\u2500\u2500 " + dv.text + " \u2500\u2500";
3058
+ return d;
3059
+ }
2420
3060
  // build item B: a Run-tab card (or map box) click switches to the Audit
2421
3061
  // tab, that same part's row expanded and scrolled into view — sharing
2422
3062
  // partIndex so the two tabs can never disagree about which row a click maps to.
@@ -2439,6 +3079,9 @@
2439
3079
 
2440
3080
  // ---- Audit tab: FLAT view (the old one-row-per-call time-ordered table) ----
2441
3081
  var auditRowEls = [];
3082
+ // the open run's leg dividers (set by renderRun); the flat table places them by time
3083
+ var lastLegDividers = [];
3084
+ function dividerAtMs(dv){ var t = Date.parse(dv.at); return Number.isFinite(t) ? t : Infinity; }
2442
3085
  function renderAudit(result){
2443
3086
  var empty = document.getElementById("audit-empty");
2444
3087
  var content = document.getElementById("audit-content");
@@ -2455,7 +3098,18 @@
2455
3098
  content.hidden = false;
2456
3099
  var tbody = document.getElementById("audit-tbody");
2457
3100
  tbody.innerHTML = "";
3101
+ // P5-R: the flat table carries the same leg dividers, placed by time (a divider sits before the first call
3102
+ // made at or after the moment that leg picked up; any that remain close the table)
3103
+ var pendingDividers = Array.isArray(lastLegDividers) ? lastLegDividers.slice() : [];
3104
+ function flatDividerRow(dv){
3105
+ var dividerRow = document.createElement("tr");
3106
+ dividerRow.className = "audit-leg-divider-row";
3107
+ dividerRow.setAttribute("data-testid", "audit-leg-divider-row-" + dv.leg);
3108
+ dividerRow.innerHTML = '<td colspan="6">\u2500\u2500 ' + escapeXml(dv.text) + ' \u2500\u2500</td>';
3109
+ return dividerRow;
3110
+ }
2458
3111
  result.rows.forEach(function(r){
3112
+ while(pendingDividers.length && dividerAtMs(pendingDividers[0]) <= Date.parse(r.time)){ tbody.appendChild(flatDividerRow(pendingDividers.shift())); }
2459
3113
  var tr = document.createElement("tr");
2460
3114
  var rowCls = auditRowClassName(r);
2461
3115
  if(rowCls) tr.className = rowCls;
@@ -2486,8 +3140,9 @@
2486
3140
  "<td>" + auditTimeCellHtml(r) + "</td>";
2487
3141
  tbody.appendChild(tr);
2488
3142
  });
3143
+ pendingDividers.forEach(function(dv){ tbody.appendChild(flatDividerRow(dv)); });
2489
3144
  document.getElementById("audit-rawlog").textContent = result.raw || "";
2490
- auditRowEls = Array.prototype.slice.call(tbody.querySelectorAll("tr"));
3145
+ auditRowEls = Array.prototype.slice.call(tbody.querySelectorAll("tr:not(.audit-leg-divider-row)"));
2491
3146
  // Scoped to [data-filter] — the audit tab's OWN chips (chip-all/writes/
2492
3147
  // blocked). A bare ".chip" here also matched the History filter bar's
2493
3148
  // chips (same class, different data- attributes), binding a second
@@ -2517,6 +3172,7 @@
2517
3172
  function renderJob(job){
2518
3173
  var empty = document.getElementById("job-empty");
2519
3174
  var card = document.getElementById("job-card-readonly");
3175
+ lastJobShown = job;
2520
3176
  if(!job){ empty.hidden = false; card.hidden = true; return; }
2521
3177
  empty.hidden = true;
2522
3178
  card.hidden = false;
@@ -2539,6 +3195,7 @@
2539
3195
  document.getElementById("job-note").textContent = job.note || "";
2540
3196
  lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
2541
3197
  paintOfferedRow();
3198
+ paintResumeMode();
2542
3199
  }
2543
3200
 
2544
3201
  // ---- selection + loading ----
@@ -2553,10 +3210,13 @@
2553
3210
  // uses for `isLive` — never re-derived a second way).
2554
3211
  var runIsLive = false;
2555
3212
  function selectRun(runid, rowEl, siblingSelector){
3213
+ hideImportView();
2556
3214
  clearSelection();
2557
3215
  document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
2558
3216
  if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
2559
3217
  currentRunid = runid;
3218
+ resumeMode = null;
3219
+ lastLegDividers = [];
2560
3220
  lastJobToolsList = null;
2561
3221
  lastRunDetailSig = null;
2562
3222
  runIsLive = false;
@@ -2818,7 +3478,7 @@
2818
3478
  }
2819
3479
  function loadProviders(){
2820
3480
  authorGet("/api/settings/providers").then(function(r){
2821
- if(!r || !r.ok){ pvNote.textContent = "could not read providers"; return; }
3481
+ 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
3482
  var kf = r.keysFile;
2823
3483
  document.getElementById("pv-keyfile-path").textContent = kf.path;
2824
3484
  document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
@@ -2910,24 +3570,21 @@
2910
3570
 
2911
3571
  // ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
2912
3572
  (function(){
2913
- var newBtn = document.getElementById("chat-new-btn");
3573
+ var clearBtn = document.getElementById("chat-clear-btn");
2914
3574
  var card = document.getElementById("job-card");
2915
3575
  var thread = document.getElementById("chat-thread");
2916
3576
  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");
3577
+ var mainBtn = document.getElementById("chat-main-btn");
3578
+ var mainHint = document.getElementById("chat-main-hint");
3579
+ var mainAction = "none";
3580
+ var lastState = null;
3581
+ var startOk = false;
2922
3582
  var errEl = document.getElementById("chat-card-error");
2923
3583
  var actionErrEl = document.getElementById("chat-action-error");
2924
- var hashLine = document.getElementById("chat-hash-line");
2925
3584
  var moneyCapInput = document.getElementById("jf-cap-money");
2926
3585
  var modelSelect = document.getElementById("jf-model");
2927
3586
  var modelStatusEl = document.getElementById("jf-model-status");
2928
3587
  var progressRow = document.getElementById("chat-progress-row");
2929
- var progressGlyph = document.getElementById("chat-progress-glyph");
2930
- var progressLabelEl = document.getElementById("chat-progress-label");
2931
3588
 
2932
3589
  var sessionId = null;
2933
3590
  var pollTimer = null;
@@ -2939,26 +3596,98 @@
2939
3596
  // src/panel/authorroutes.js, mirrored here so Start never even offers a
2940
3597
  // click the server would 409 anyway).
2941
3598
  var CLIENT_TERMINAL_PHASES = ["refused", "abandoned", "error", "signed", "signing-failed"];
2942
- var DONE_PHASES = ["prepared", "signed"];
2943
3599
  var sessionLive = false;
2944
3600
  // build item 7 — Start also stays disabled while the chosen model's key
2945
3601
  // isn't usable; updated by refreshModelStatus() below.
2946
3602
  var keyOk = false;
2947
3603
 
2948
- newBtn.addEventListener("click", function(){
3604
+ // Reuse workflow — set when the card was opened by [Reuse workflow]: the run (or imported job) it came from.
3605
+ // Only Source, Destination and the two caps are open on that card; the SERVER refuses any other change.
3606
+ var startFrom = null;
3607
+ var reuseSession = false;
3608
+ var autoSigned = false;
3609
+ var sfOrigin = document.getElementById("chat-startfrom-origin");
3610
+ var sfLine = document.getElementById("chat-startfrom-line");
3611
+ var sfNote = document.getElementById("chat-startfrom-note");
3612
+ // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3613
+ var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3614
+ // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3615
+ // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3616
+ var OPEN_IDS = ["jf-source", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3617
+ var reuseOn = false;
3618
+ function syncCardLock(){
3619
+ var live = sessionLive;
3620
+ var setLock = function(id, on){ var el = document.getElementById(id); el.readOnly = on; el.classList.toggle("locked", on); };
3621
+ LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live); });
3622
+ OPEN_IDS.forEach(function(id){ setLock(id, live); });
3623
+ document.getElementById("jf-model").disabled = live;
3624
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = reuseOn || live; });
3625
+ document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3626
+ if(!reuseOn && !live){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3627
+ }
3628
+ function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3629
+ function clearStartFrom(){
3630
+ // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes
3631
+ if(startFrom){
3632
+ ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"].forEach(function(id){ document.getElementById(id).value = ""; });
3633
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3634
+ }
3635
+ startFrom = null; reuseSession = false; autoSigned = false;
3636
+ sfOrigin.hidden = true; sfLine.hidden = true; sfNote.hidden = true;
3637
+ setReuseLocked(false);
3638
+ renderMain();
3639
+ }
3640
+ function openNewCard(){
2949
3641
  card.hidden = false;
2950
- newBtn.hidden = true;
2951
3642
  thread.innerHTML = "";
2952
3643
  errEl.textContent = "";
2953
3644
  actionErrEl.textContent = "";
2954
3645
  sessionId = null;
2955
3646
  sessionLive = false;
2956
3647
  signClickedOnce = false;
3648
+ reuseSession = false;
3649
+ autoSigned = false;
2957
3650
  if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
2958
- stopProgressDots();
2959
3651
  progressRow.hidden = true;
2960
- checkDepsBtn.hidden = true;
3652
+ progressRow.innerHTML = "";
3653
+ lastState = null;
2961
3654
  refreshStartEnabled();
3655
+ }
3656
+ // hamr's ruling 2026-10-05: ONE button, two states. A live, unsigned session -> "Abandon" (the server ends it and
3657
+ // releases the one-at-a-time lock; money already spent stays booked). Anything else (signed and running, terminal,
3658
+ // nothing started) -> "Clear", which empties the card only and never stops a run (Stop lives on the Run tab).
3659
+ function clearButtonFor(live, hasId){
3660
+ return live ? {text: "Abandon", disabled: !hasId} : {text: "Clear", disabled: false};
3661
+ }
3662
+ function refreshClearBtn(){
3663
+ var b = clearButtonFor(sessionLive, !!sessionId);
3664
+ clearBtn.textContent = b.text;
3665
+ clearBtn.disabled = b.disabled;
3666
+ }
3667
+ // back to a fresh empty card: every box, check type deterministic, the default (first) Model, origin/estimate/notes
3668
+ // hidden, progress list and thread cleared, reuse lock lifted
3669
+ function resetCard(){
3670
+ ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"].forEach(function(id){ document.getElementById(id).value = ""; });
3671
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3672
+ document.getElementById("jf-judge").disabled = true;
3673
+ modelSelect.selectedIndex = 0;
3674
+ msgInput.value = "";
3675
+ document.getElementById("jf-cap-note").textContent = "";
3676
+ lastPhase = null;
3677
+ clearStartFrom();
3678
+ openNewCard();
3679
+ refreshModelStatus();
3680
+ }
3681
+ clearBtn.addEventListener("click", function(){
3682
+ if(clearBtn.textContent === "Abandon" && sessionId){
3683
+ authorPost("/api/author/" + sessionId + "/abandon", {}).then(function(r){
3684
+ var o = chatPostOutcome(r);
3685
+ if(!o.ok){ chatActionFailed(o.error); return; }
3686
+ resetCard();
3687
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not abandoned"); });
3688
+ return;
3689
+ }
3690
+ resetCard();
2962
3691
  });
2963
3692
 
2964
3693
  function currentCard(){
@@ -2989,7 +3718,10 @@
2989
3718
  function refreshStartEnabled(){
2990
3719
  var v = Number(moneyCapInput.value);
2991
3720
  var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
2992
- startBtn.disabled = !capOk || sessionLive || !keyOk;
3721
+ startOk = capOk && !sessionLive && keyOk;
3722
+ syncCardLock();
3723
+ renderMain();
3724
+ refreshClearBtn();
2993
3725
  }
2994
3726
  moneyCapInput.addEventListener("input", refreshStartEnabled);
2995
3727
  // P4a — the monthly-limit note sits right under the cap, as the person types.
@@ -3080,6 +3812,16 @@
3080
3812
  });
3081
3813
  }
3082
3814
  modelSelect.addEventListener("change", refreshModelStatus);
3815
+ // Model is open on a reuse card: the estimate line counts only runs on the worker the box names right now
3816
+ function refreshReuseLine(){
3817
+ if(!startFrom) return;
3818
+ var q = (startFrom.importId ? "import=" + encodeURIComponent(startFrom.importId) : "runid=" + encodeURIComponent(startFrom.runid)) +
3819
+ "&model=" + encodeURIComponent(modelSelect.value);
3820
+ authorGet("/api/author/start-from?" + q).then(function(r){
3821
+ if(startFrom && r && r.ok){ sfLine.textContent = r.line || ""; }
3822
+ }).catch(function(){ /* the line keeps what it had */ });
3823
+ }
3824
+ modelSelect.addEventListener("change", refreshReuseLine);
3083
3825
  // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
3084
3826
  // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
3085
3827
  function loadModels(){
@@ -3119,25 +3861,6 @@
3119
3861
  thread.scrollTop = thread.scrollHeight;
3120
3862
  }
3121
3863
 
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
3864
  // build item 3 — pure: what the label must say for a given state. A
3142
3865
  // person waiting on their OWN next action (an ask is pending) must never
3143
3866
  // see a stale machine step name (observed: "checking prior behaviour"
@@ -3150,75 +3873,148 @@
3150
3873
  if(pending) return "waiting for your answer";
3151
3874
  return (state && state.progressLabel) || "working";
3152
3875
  }
3876
+ // the ONE progress list: a step per line, in the server's order (STEP_LABELS in src/panel/authorsession.js owns
3877
+ // the ids and words). While a step runs its dots are CSS (still under prefers-reduced-motion); it ends as a check
3878
+ // or a cross, a cross carrying the code-owned reason on the same line. A pending ask for the person replaces the
3879
+ // running step's label with what is being waited on, never an animation.
3880
+ // Returns true when the list's content actually changed (a new line, or the latest line changed) so the caller can
3881
+ // scroll only then; an identical 2s poll tick returns false and leaves the person's scroll position alone.
3153
3882
  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
- }
3883
+ var steps = (state && state.steps) || [];
3884
+ var before = progressRow.innerHTML;
3885
+ progressRow.hidden = steps.length === 0;
3886
+ progressRow.innerHTML = steps.map(function(st, i){
3887
+ var waiting = st.status === "running" && i === steps.length - 1 && state.pendingAsk;
3888
+ var cls = waiting ? "wait" : (st.status === "running" ? "run" : (st.status === "failed" ? "bad" : "ok"));
3889
+ var sign = cls === "ok" ? "\u2713" : (cls === "bad" ? "\u2717" : "");
3890
+ var label = waiting ? progressLabelFor(state) : st.label;
3891
+ return '<li class="step ' + cls + '" data-step="' + escapeXml(st.id) + '"><div class="step-line"><span class="step-label">' + escapeXml(label) + '</span>' +
3892
+ '<span class="step-sign">' + sign + '</span></div>' +
3893
+ (st.detail ? '<div class="step-detail">' + escapeXml("> " + st.detail) + '</div>' : "") + '</li>';
3894
+ }).join("");
3895
+ return progressRow.innerHTML !== before;
3170
3896
  }
3171
3897
 
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";
3898
+ // hamr's ruling 2026-10-05: ONE wide button below the message box. Its wording, action and enabled state are ONE
3899
+ // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk, revisesLeft,
3900
+ // signClicked, specHash}. Actions: start | check-deps | send | revise | sign-prepare | sign | none.
3901
+ function mainButtonFor(st, textEmpty, reuse){
3902
+ var ask = st.pendingAsk;
3903
+ var atMenu = !!ask && ask.kind === "menu";
3904
+ var left = st.revisesLeft || 0;
3905
+ var hint = atMenu ? left + (left === 1 ? " change left" : " changes left") : "";
3906
+ if(!st.session){
3907
+ return {text: reuse ? "Sign & run" : "Start drafting", action: "start", disabled: !st.startOk, hint: ""};
3190
3908
  }
3191
- if(state.specHash){
3192
- hashLine.hidden = false;
3193
- hashLine.textContent = "spec hash: " + state.specHash;
3909
+ if(ask && ask.kind === "install-needed"){
3910
+ return {text: "Check again", action: "check-deps", disabled: false, hint: ""};
3194
3911
  }
3195
- if(state.phase === "refused" || state.phase === "error" || state.phase === "abandoned"){
3196
- errEl.textContent = state.error || ("stopped: " + state.phase);
3197
- } else {
3198
- errEl.textContent = "";
3912
+ if(ask && !textEmpty){
3913
+ return atMenu
3914
+ ? {text: "Send", action: "revise", disabled: left <= 0, hint: hint}
3915
+ : {text: "Send", action: "send", disabled: false, hint: ""};
3199
3916
  }
3200
- renderProgress(state);
3917
+ if(ask && !atMenu){
3918
+ return {text: "Send", action: "send", disabled: true, hint: ""};
3919
+ }
3920
+ if(atMenu || st.phase === "prepared"){
3921
+ if(st.phase === "prepared" && st.signClicked){
3922
+ return {text: "Sign " + (st.specHash || "").slice(0, 8) + " & run", action: "sign", disabled: false, hint: hint};
3923
+ }
3924
+ return {text: "Sign & run", action: "sign-prepare", disabled: false, hint: hint};
3925
+ }
3926
+ return {text: "Sign & run", action: "none", disabled: true, hint: ""};
3927
+ }
3928
+ // pure: the ask box takes typed text only when the session has a pending ask that accepts it (the plan menu -> a
3929
+ // change request, or any question); not with no session, no ask, or while waiting on an install
3930
+ function askBoxOpenFor(st){
3931
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed";
3932
+ }
3933
+ function renderMain(){
3934
+ var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce};
3935
+ if(sessionLive && lastState){
3936
+ st.phase = lastState.phase; st.pendingAsk = lastState.pendingAsk;
3937
+ st.revisesLeft = lastState.revisesLeft; st.specHash = lastState.specHash;
3938
+ }
3939
+ // hamr 2026-10-05: the ask box is open only while something asks for a reply; otherwise dimmed and emptied
3940
+ var open = askBoxOpenFor(st);
3941
+ msgInput.disabled = !open;
3942
+ if(!open) msgInput.value = "";
3943
+ var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom);
3944
+ mainBtn.textContent = b.text;
3945
+ mainBtn.disabled = b.disabled;
3946
+ mainAction = b.action;
3947
+ mainHint.textContent = b.hint;
3948
+ fitBox(msgInput);
3949
+ fitCardBoxes();
3950
+ }
3951
+ msgInput.addEventListener("input", renderMain);
3952
+ // grow a text box with its text (a few rows, then it scrolls inside); ONE function for the ask box and the card boxes.
3953
+ // A box that is hidden (or not laid out) measures 0 and gets no inline height.
3954
+ function fitBox(el){
3955
+ el.style.height = "auto";
3956
+ if(!el.scrollHeight){ el.style.height = ""; return; }
3957
+ var border = el.offsetHeight - el.clientHeight;
3958
+ el.style.height = Math.min(el.scrollHeight + border, 200) + "px";
3959
+ }
3960
+ function fitCardBoxes(){
3961
+ ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){ fitBox(document.getElementById(id)); });
3962
+ }
3963
+ window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
3964
+ document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
3965
+ // Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
3966
+ msgInput.addEventListener("keydown", function(e){
3967
+ if(e.key === "Enter" && !e.shiftKey && !e.isComposing){
3968
+ e.preventDefault();
3969
+ if(mainAction !== "start") mainBtn.click(); // never starts a (paid) draft from a stray Enter
3970
+ }
3971
+ });
3972
+
3973
+ function renderActions(state){
3974
+ lastState = state;
3975
+ // a session's stop is shown ONCE, in the progress line (renderProgress); #chat-card-error carries only a
3976
+ // refused START (its own handler writes it) — echoing state.error here made the same refusal appear twice
3977
+ errEl.textContent = "";
3978
+ var progressChanged = renderProgress(state);
3201
3979
  // build item 4 — re-enable Start only once the session has settled
3202
3980
  // into a terminal phase (a fresh Start becomes possible again).
3203
3981
  sessionLive = CLIENT_TERMINAL_PHASES.indexOf(state.phase) === -1;
3204
3982
  refreshStartEnabled();
3983
+ // hamr 2026-10-05: while the session is live and the list changed, bring the newest progress line into view.
3984
+ // Never moves keyboard focus, and an unchanged poll tick never scrolls.
3985
+ if(progressChanged && sessionLive && progressRow.lastElementChild){
3986
+ progressRow.lastElementChild.scrollIntoView({block: "nearest"});
3987
+ }
3205
3988
  }
3206
3989
 
3207
3990
  function poll(){
3208
3991
  if(!sessionId) return;
3992
+ var polledId = sessionId;
3209
3993
  authorGet("/api/author/" + sessionId).then(function(j){
3210
- if(!j || !j.state) return;
3994
+ if(!j || !j.state || polledId !== sessionId) return;
3211
3995
  renderMessages(j.state);
3212
3996
  renderActions(j.state);
3997
+ if(reuseSession && !autoSigned && j.state.phase === "prepared" && j.state.specHash){
3998
+ autoSigned = true;
3999
+ authorPost("/api/author/" + sessionId + "/sign", {specHash: j.state.specHash}).then(function(r){
4000
+ var o = chatPostOutcome(r);
4001
+ if(o.ok){ chatActionOk(); } else { autoSigned = false; chatActionFailed(o.error); }
4002
+ poll();
4003
+ }).catch(function(){ autoSigned = false; chatActionFailed("network error — could not reach the panel server, not signed"); });
4004
+ }
3213
4005
  if(j.state.phase === "signed"){
3214
4006
  clearInterval(pollTimer);
3215
4007
  document.getElementById("tab-runs").click();
4008
+ // hamr's ruling 2026-10-05: the run has started, so the Chat card goes back to the empty card (the one reset
4009
+ // Clear uses) — it must not keep showing a signed job that invites a rerun. The run itself is untouched.
4010
+ resetCard();
4011
+ return;
3216
4012
  }
3217
4013
  lastPhase = j.state.phase;
3218
4014
  }).catch(function(){ /* transient — next poll tries again */ });
3219
4015
  }
3220
4016
 
3221
- startBtn.addEventListener("click", function(){
4017
+ function doStart(){
3222
4018
  errEl.textContent = "";
3223
4019
  actionErrEl.textContent = "";
3224
4020
  // build item 4 — disable the moment it's clicked, not just once the
@@ -3226,6 +4022,7 @@
3226
4022
  sessionLive = true;
3227
4023
  refreshStartEnabled();
3228
4024
  var body = currentCard();
4025
+ if(startFrom) body.startFrom = startFrom.importId ? {importId: startFrom.importId} : startFrom.runid;
3229
4026
  authorPost("/api/author/start", body).then(function(r){
3230
4027
  if(r.status !== 200 || !r.body.ok){
3231
4028
  errEl.textContent = (r.body && r.body.error) || "refused";
@@ -3234,12 +4031,88 @@
3234
4031
  return;
3235
4032
  }
3236
4033
  sessionId = r.body.sessionId;
3237
- signClickedOnce = false;
4034
+ // a same-job session has no confirm turn: it goes straight to `prepared`, and THIS click (Sign & run) is the
4035
+ // signature — the page signs the hash the server prepared as soon as it is ready (the server re-checks it)
4036
+ reuseSession = r.body.reuse === true;
4037
+ autoSigned = false;
4038
+ signClickedOnce = reuseSession;
3238
4039
  renderMessages(r.body.state);
3239
4040
  renderActions(r.body.state);
3240
4041
  if(pollTimer) clearInterval(pollTimer);
3241
4042
  pollTimer = setInterval(poll, 2000);
3242
4043
  });
4044
+ }
4045
+
4046
+ // Re-attach after a page load/refresh: the server holds the one live (non-terminal) authoring session, so ask it.
4047
+ // If one exists, this tab takes it over exactly as if it had started it: the card filled from the session's own
4048
+ // copy, the poll loop, progress list, thread, ask box, main button and Abandon. A signed session is terminal, so it
4049
+ // never comes back (the card stays empty). Nothing is stored page-side.
4050
+ function attachSession(j){
4051
+ var st = j.state, c = st.card || {};
4052
+ openNewCard();
4053
+ sessionId = j.sessionId;
4054
+ sessionLive = true;
4055
+ reuseSession = st.reuse === true;
4056
+ autoSigned = false;
4057
+ signClickedOnce = reuseSession;
4058
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
4059
+ if(c.model){
4060
+ if(!Array.prototype.some.call(modelSelect.options, function(o){ return o.value === c.model; })){
4061
+ modelSelect.insertAdjacentHTML("beforeend", '<option value="' + escapeXml(c.model) + '">' + escapeXml(c.model) + "</option>");
4062
+ }
4063
+ modelSelect.value = c.model;
4064
+ }
4065
+ setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4066
+ setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4067
+ setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4068
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4069
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4070
+ if(reuseSession) setReuseLocked(true);
4071
+ renderMessages(st);
4072
+ renderActions(st);
4073
+ if(pollTimer) clearInterval(pollTimer);
4074
+ pollTimer = setInterval(poll, 2000);
4075
+ poll();
4076
+ }
4077
+ function reattachLive(){
4078
+ if(sessionId || sessionLive) return;
4079
+ authorGet("/api/author/live").then(function(j){
4080
+ if(sessionId || sessionLive || !j || !j.ok || !j.sessionId || !j.state) return;
4081
+ attachSession(j);
4082
+ }).catch(function(){ /* nothing to attach to — the empty card stays */ });
4083
+ }
4084
+ document.getElementById("tab-chat").addEventListener("click", reattachLive);
4085
+ reattachLive();
4086
+
4087
+ // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4088
+ function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
4089
+ document.addEventListener("bareloop-reuse", function(e){
4090
+ var d = e.detail || {};
4091
+ var pre = d.prefill || {};
4092
+ var c = pre.card || {};
4093
+ clearStartFrom();
4094
+ openNewCard();
4095
+ startFrom = d.importId ? {importId: d.importId} : {runid: d.runid};
4096
+ var radios = document.querySelectorAll('input[name="jf-verdict"]');
4097
+ radios.forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
4098
+ if(c.model){ modelSelect.value = c.model; }
4099
+ setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4100
+ setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4101
+ setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4102
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4103
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4104
+ setReuseLocked(true);
4105
+ sfOrigin.hidden = false;
4106
+ sfOrigin.textContent = "Reuse: " + ((pre.origin && pre.origin.job) || "job") + (d.runid ? " (run " + d.runid + ")" : " (imported)");
4107
+ sfLine.hidden = false;
4108
+ sfLine.textContent = pre.line || "";
4109
+ sfLine.className = "startfrom-line";
4110
+ sfNote.hidden = !pre.note;
4111
+ sfNote.textContent = pre.note || "";
4112
+ refreshModelStatus();
4113
+ refreshReuseLine();
4114
+ refreshCapNote();
4115
+ refreshStartEnabled();
3243
4116
  });
3244
4117
 
3245
4118
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
@@ -3275,18 +4148,23 @@
3275
4148
  function chatActionOk(){
3276
4149
  actionErrEl.textContent = "";
3277
4150
  }
4151
+ // a dead token (panel restarted): show the one line where Chat actions show errors and stop polling
4152
+ staleTokenHook = function(){
4153
+ actionErrEl.textContent = STALE_TOKEN_LINE;
4154
+ if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
4155
+ };
3278
4156
 
3279
- checkDepsBtn.addEventListener("click", function(){
4157
+ function doCheckDeps(){
3280
4158
  if(!sessionId) return;
3281
- checkDepsBtn.disabled = true;
4159
+ mainBtn.disabled = true;
3282
4160
  authorPost("/api/author/" + sessionId + "/check-deps", {}).then(function(r){
3283
4161
  var o = chatPostOutcome(r);
3284
4162
  if(o.ok) chatActionOk(); else chatActionFailed(o.error);
3285
4163
  poll();
3286
4164
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, nothing was sent"); });
3287
- });
4165
+ }
3288
4166
 
3289
- sendBtn.addEventListener("click", function(){
4167
+ function doSend(){
3290
4168
  if(!sessionId) return;
3291
4169
  var text = msgInput.value;
3292
4170
  if(!text.trim()) return;
@@ -3302,9 +4180,9 @@
3302
4180
  }
3303
4181
  poll();
3304
4182
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3305
- });
4183
+ }
3306
4184
 
3307
- reviseBtn.addEventListener("click", function(){
4185
+ function doRevise(){
3308
4186
  if(!sessionId) return;
3309
4187
  var text = msgInput.value;
3310
4188
  authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
@@ -3317,9 +4195,9 @@
3317
4195
  }
3318
4196
  poll();
3319
4197
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3320
- });
4198
+ }
3321
4199
 
3322
- signBtn.addEventListener("click", function(){
4200
+ function doSign(){
3323
4201
  if(!sessionId) return;
3324
4202
  if(!signClickedOnce){
3325
4203
  // click 1 — the confirm turn's own "confirm" pick. Never signs.
@@ -3342,6 +4220,15 @@
3342
4220
  poll();
3343
4221
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not signed"); });
3344
4222
  });
4223
+ }
4224
+
4225
+ mainBtn.addEventListener("click", function(){
4226
+ if(mainBtn.disabled) return;
4227
+ if(mainAction === "start") doStart();
4228
+ else if(mainAction === "check-deps") doCheckDeps();
4229
+ else if(mainAction === "send") doSend();
4230
+ else if(mainAction === "revise") doRevise();
4231
+ else if(mainAction === "sign-prepare" || mainAction === "sign") doSign();
3345
4232
  });
3346
4233
 
3347
4234
  refreshStartEnabled();