bareloop 0.29.0 → 0.31.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.
@@ -30,6 +30,11 @@
30
30
  html,body{margin:0;padding:0;background:var(--bg);color:var(--text);}
31
31
  body{overflow-x:hidden;}
32
32
  button{font:inherit;cursor:pointer;}
33
+ /* ported verbatim from design/panel-mockup.html: browsers default form
34
+ controls to a UA sans font, not the page's monospace — without this,
35
+ inputs/selects render off-theme even though buttons already inherit it. */
36
+ input,select{font:inherit;padding:6px 8px;border:1px solid var(--border-strong);border-radius:0;background:var(--bg);color:var(--text);}
37
+ input::placeholder{color:var(--text-faint);}
33
38
  table{border-collapse:collapse;width:100%;}
34
39
  th,td{text-align:left;padding:6px 8px;border-bottom:1px dotted var(--border-strong);font-size:12px;}
35
40
  th{color:var(--text-dim);font-weight:700;background:var(--panel2);text-transform:uppercase;letter-spacing:0.04em;font-size:11px;border-bottom:1px solid var(--border-strong);}
@@ -132,7 +137,34 @@
132
137
  .dot::before{content:"[·]";color:var(--grey-dot);}
133
138
  .dot.green::before{content:"[✓]";color:var(--green);}
134
139
  .dot.red::before{content:"[✗]";color:var(--red);}
135
- .dot.amber::before{content:"[▶]";color:var(--amber);}
140
+ /* Panel build P3 addition (2026-09-27, "yes, go"): the part cards below the
141
+ step map (`partResultGlyph`/`attemptGlyph`) and the chat job card render
142
+ their result glyph as PLAIN inline text inside a larger string (never
143
+ through `.dot`, which owns its own bracketed content), so `[✓]`/`[✗]`
144
+ there were colourless until now. `.glyph-green`/`.glyph-red` are the same
145
+ two existing colour tokens, applied via a plain inline `<span>` wrapper
146
+ (`glyphSpan()` in this file's script) rather than a new colour — `[▶]`
147
+ stays amber (already coloured via `.dot.amber` where it renders as a
148
+ dot; these two spans are never minted for `▶`/`·`/`?`) and the wording
149
+ ruling stands: colour only, never the words green/red/soft-green. */
150
+ .glyph-green{color:var(--green);}
151
+ .glyph-red{color:var(--red);}
152
+ /* Panel P2 defect 1 (hamr-watched run mujjtrvd, 2026-09-27): the [▶] glyph
153
+ itself pulses wherever a LIVE run shows it (runs list rows, the
154
+ Workflows job row when its represented run is live, the run header) —
155
+ `.dot.amber` is minted ONLY for glyph "▶" (glyphForOutcome,
156
+ src/panel/server.js: outcome null -> "▶", never for a died [?] or a
157
+ finished [✓]/[✗] run), so scoping the animation to this one selector
158
+ already excludes every other state, no extra "is it live" check needed
159
+ here. Same rhythm as the map box's own running-part dot (this file,
160
+ `buildMapSvg`'s `s.state === "running"` branch): opacity 1 -> 0.3 -> 1,
161
+ 1.2s, matching design/panel-mockup.html's pulsing amber dot exactly —
162
+ one fixed rhythm, never re-derived per call site. */
163
+ @keyframes pulse-glyph{0%,100%{opacity:1;}50%{opacity:0.3;}}
164
+ .dot.amber::before{content:"[▶]";color:var(--amber);animation:pulse-glyph 1.2s ease-in-out infinite;}
165
+ @media (prefers-reduced-motion: reduce){
166
+ .dot.amber::before{animation:none;}
167
+ }
136
168
  .dot.grey::before{content:"[·]";color:var(--grey-dot);}
137
169
  /* died (hamr's ruling B, 2026-09-25): no ending was ever recorded — a
138
170
  DIFFERENT fact from a real [✗] close/arbiter "no", so it gets its own
@@ -150,6 +182,8 @@
150
182
  .badge.cyan{color:var(--cyan);}
151
183
 
152
184
  .hint{font-size:11px;color:var(--text-faint);margin-top:4px;}
185
+ #chat-action-error:empty{display:none;}
186
+ #chat-action-error{font-size:12px;color:var(--red);margin:4px 0;}
153
187
  .btn{padding:8px 14px;min-height:44px;border:1px solid var(--border-strong);background:var(--panel2);color:var(--text);white-space:nowrap;flex:none;position:relative;}
154
188
  .btn::before{content:"[ ";color:var(--text-faint);}
155
189
  .btn::after{content:" ]";color:var(--text-faint);}
@@ -228,6 +262,7 @@
228
262
  .attempt-row{margin-bottom:6px;}
229
263
  .attempt-toggle{margin-bottom:4px;}
230
264
  .rounds-list{margin:6px 0 6px 12px;padding-left:8px;border-left:2px solid var(--border-strong);}
265
+ .checks-list{margin:6px 0 0 12px;padding:6px 8px;border-left:2px solid var(--border-strong);font-size:12px;color:var(--text-faint);}
231
266
  /* item 1 (2026-09-26): a round's own model call renders as this table's
232
267
  header ROW (visually distinct from its tool-call rows below it). */
233
268
  tr.round-header-row td{background:var(--panel2);font-weight:700;}
@@ -275,13 +310,72 @@
275
310
  .job-card h4::before{content:"┤ ";color:var(--text-faint);}
276
311
  .job-card h4::after{content:" ├";color:var(--text-faint);}
277
312
  .field{margin-bottom:10px;}
313
+ .field input,.field select{width:100%;}
278
314
  label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;text-transform:uppercase;letter-spacing:0.04em;}
279
315
  .ro-value{padding:6px 8px;min-height:32px;box-sizing:border-box;border:1px solid var(--border);background:var(--bg);color:var(--text-dim);}
280
316
  .ro-value.scroll{max-height:160px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;}
281
317
  .field-row{display:flex;flex-wrap:wrap;gap:10px;}
282
318
  .field-row .field{flex:1 1 140px;margin-bottom:10px;}
283
319
 
284
- .chat-thread{padding:20px 8px;color:var(--text-faint);font-size:13px;}
320
+ /* P3 build (Chat/authoring): job-card compact mode, radio row, the $/time/
321
+ drafting cap row, and the chat thread's message bubbles — ported from
322
+ design/panel-mockup.html (the visual contract), re-themed onto this
323
+ page's own existing colour tokens rather than the mockup's own. */
324
+ .job-card.compact{padding:14px 8px 8px;}
325
+ .job-card.compact .field{margin-bottom:6px;}
326
+ .job-card.compact label{margin-bottom:1px;font-size:11px;}
327
+ .job-card.compact input,
328
+ .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
+ .radio-row{display:flex;gap:14px;align-items:center;margin-bottom:10px;}
332
+ .radio-row label{display:flex;align-items:center;gap:5px;font-size:13px;color:var(--text);margin:0;text-transform:none;}
333
+ .cap-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-bottom:6px;}
334
+ .cap-field label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:1px;}
335
+ .cap-field input{width:100%;padding:5px 6px;box-sizing:border-box;}
336
+ @media (max-width: 899px){
337
+ .cap-row{grid-template-columns:1fr;}
338
+ }
339
+ .btn.primary{background:var(--primary-bg);color:var(--primary-text);border-color:var(--primary-border);font-weight:700;}
340
+ .btn.primary::before,.btn.primary::after{color:var(--primary-text);opacity:0.6;}
341
+ .btn:disabled{opacity:0.5;cursor:not-allowed;}
342
+ /* a disabled .btn.primary must read as disabled, not just a dimmed version
343
+ of the enabled primary fill — .btn.primary's own background/color
344
+ (declared above, equal specificity) would otherwise win over the plain
345
+ .btn:disabled rule and still look clickable at 50% opacity. */
346
+ .btn.primary:disabled{background:var(--btn-bg);color:var(--text-faint);border-color:var(--border);}
347
+ .btn.primary:disabled::before,.btn.primary:disabled::after{color:var(--text-faint);opacity:0.4;}
348
+
349
+ /* P3 build item 3 — the single live progress line: an amber cycling
350
+ [progress.]/[progress..]/[progress...] glyph while a phase is running,
351
+ [done] in green once it finishes, [stopped] in red on a refusal. A
352
+ static [progress...] under prefers-reduced-motion (no animation). */
353
+ .chat-progress-row{display:flex;align-items:center;gap:8px;margin:2px 0 10px;font-size:12px;}
354
+ .chat-progress-glyph{font-weight:700;color:var(--amber);}
355
+ .chat-progress-glyph.done{color:var(--green);}
356
+ .chat-progress-glyph.stopped{color:var(--red);}
357
+ .chat-progress-label{color:var(--text-faint);}
358
+ /* build item 7 — the two $0 readiness lines under the Model field. */
359
+ .jf-readiness{font-size:11px;margin-top:2px;}
360
+ .jf-readiness.ok{color:var(--green);}
361
+ .jf-readiness.bad{color:var(--red);}
362
+ .jf-readiness.unknown{color:var(--text-faint);}
363
+
364
+ .chat-thread{display:flex;flex-direction:column;gap:10px;margin-bottom:10px;}
365
+ .msg{padding:8px 10px;border:1px solid var(--border);max-width:100%;background:var(--panel2);font-size:13px;word-break:break-word;white-space:pre-wrap;}
366
+ .msg.bot{border-left:3px solid var(--border-strong);}
367
+ .msg.bot .who::before{content:"$ ";}
368
+ .msg.you{background:var(--bg);margin-left:20px;border-left:3px solid var(--accent);}
369
+ .msg.you .who::before{content:"> ";}
370
+ .msg.system{color:var(--text-faint);border-left:3px dotted var(--text-faint);font-size:12px;}
371
+ .msg .who{font-size:11px;color:var(--text-faint);margin-bottom:3px;text-transform:none;font-weight:400;}
372
+ .chat-input-row{margin-top:10px;border-top:1px dotted var(--border-strong);padding-top:10px;}
373
+ .chat-input-row input{width:100%;box-sizing:border-box;}
374
+ /* chat message box reads as an unmistakable text box: ~2x the default
375
+ input height, thicker 2px border (ported verbatim from the mockup). */
376
+ #chat-msg{min-height:64px;padding:8px 12px;border:2px solid var(--border-strong);}
377
+ .actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
378
+ .actions-row > .btn{flex:1 1 auto;}
285
379
 
286
380
  @media (max-width: 899px){
287
381
  .main{flex-direction:column;}
@@ -320,15 +414,109 @@
320
414
  <button role="tab" id="tab-runs" aria-controls="panel-runs" aria-selected="false" data-testid="left-tab-runs">Runs</button>
321
415
  </div>
322
416
 
323
- <div class="pane-body">
417
+ <div class="pane-body" id="left-pane-body">
324
418
 
325
419
  <section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
326
- <div class="chat-thread">
327
- Authoring a new job from this page comes later (PANEL-BUILD.md P3). This build
328
- (P1) is read-only: it shows runs already started from the command line
329
- (<code>bareloop run</code> / <code>bareloop run-u</code>). Use the Runs tab to
330
- look at one.
420
+ <button class="btn" type="button" id="chat-new-btn" data-testid="chat-new">+ New</button>
421
+
422
+ <div class="job-card compact" id="job-card" data-testid="job-card" hidden>
423
+ <h4 id="job-card-title">Job card — draft</h4>
424
+
425
+ <div class="field">
426
+ <label id="jf-verdict-label">Check type</label>
427
+ <div class="radio-row" role="radiogroup" aria-labelledby="jf-verdict-label">
428
+ <label><input type="radio" name="jf-verdict" value="deterministic" checked> Deterministic</label>
429
+ <label><input type="radio" name="jf-verdict" value="rubric"> Rubric</label>
430
+ </div>
431
+ </div>
432
+ <div class="field">
433
+ <label for="jf-model">Model</label>
434
+ <select id="jf-model">
435
+ <option value="claude-sonnet-5">claude-sonnet-5 · Anthropic</option>
436
+ <option value="deepseek-flash">deepseek-flash · DeepSeek</option>
437
+ </select>
438
+ <!-- build item 5 (2026-09-28) — ONE line under Model, not two:
439
+ key + reachability collapsed into a single readiness read. -->
440
+ <div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
441
+ </div>
442
+ <div class="field">
443
+ <label for="jf-name">Job name</label>
444
+ <input id="jf-name" type="text" placeholder="kebab-case, unique">
445
+ </div>
446
+ <div class="field">
447
+ <label for="jf-goal">Goal</label>
448
+ <input class="expand-field" id="jf-goal" type="text">
449
+ </div>
450
+ <div class="field">
451
+ <label for="jf-source">Source</label>
452
+ <input id="jf-source" type="text" placeholder="absolute path to a local repo">
453
+ </div>
454
+ <div class="field">
455
+ <label for="jf-dest">Destination</label>
456
+ <input id="jf-dest" type="text" placeholder="the write fence — where in the repo this run may change files">
457
+ </div>
458
+ <div class="field">
459
+ <label for="jf-success">Success</label>
460
+ <input class="expand-field" id="jf-success" type="text">
461
+ </div>
462
+ <div class="field">
463
+ <label for="jf-guardrails">Guardrails</label>
464
+ <input id="jf-guardrails" type="text">
465
+ </div>
466
+ <div class="field">
467
+ <label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
468
+ <input class="expand-field" id="jf-judge" type="text" disabled>
469
+ </div>
470
+
471
+ <div class="cap-row" data-testid="cap-row">
472
+ <div class="cap-field">
473
+ <label for="jf-cap-money">Cap $</label>
474
+ <input id="jf-cap-money" type="text">
475
+ </div>
476
+ <div class="cap-field">
477
+ <label for="jf-cap-time">Time cap (min)</label>
478
+ <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
479
+ </div>
480
+ <div class="cap-field">
481
+ <label for="jf-price">Token price $</label>
482
+ <input id="jf-price" type="text" placeholder="est." disabled>
483
+ </div>
484
+ </div>
485
+ <button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
486
+ <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
487
+ <div class="hint" id="chat-hash-line" data-testid="chat-hash-line" hidden></div>
331
488
  </div>
489
+
490
+ <!-- build item 3 — the label comes FIRST, then the animated
491
+ indicator, so the cycling dots ([progress.] .. [progress....])
492
+ never push the label around. -->
493
+ <div class="chat-progress-row" id="chat-progress-row" data-testid="chat-progress-row" hidden>
494
+ <span class="chat-progress-label" id="chat-progress-label" data-testid="chat-progress-label"></span>
495
+ <span class="chat-progress-glyph" id="chat-progress-glyph" data-testid="chat-progress-glyph"></span>
496
+ </div>
497
+
498
+ <div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
499
+
500
+ <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
501
+ sign or check-deps POST shows here and STAYS shown: it is set
502
+ only by this tab's own action handlers below and is never
503
+ touched by renderActions()/poll() (which own #chat-card-error
504
+ instead), so it can never be wiped by the next 2s poll tick
505
+ before a person reads it. -->
506
+ <div class="hint" id="chat-action-error" data-testid="chat-action-error"></div>
507
+
508
+ <div class="chat-input-row">
509
+ <label for="chat-msg" style="display:none">Message</label>
510
+ <input id="chat-msg" type="text" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg">
511
+ </div>
512
+ <button class="btn" type="button" id="chat-check-deps-btn" data-testid="chat-check-deps" hidden>Check again</button>
513
+
514
+ <div class="actions-row">
515
+ <button class="btn" type="button" id="chat-send-btn" data-testid="chat-send" disabled>Send</button>
516
+ <button class="btn primary" type="button" id="chat-sign-btn" data-testid="btn-sign-run" disabled>Sign &amp; run</button>
517
+ <button class="btn" type="button" id="chat-revise-btn" data-testid="btn-revise" disabled>Revise (2 left)</button>
518
+ </div>
519
+ <div class="hint">Only your click signs. The chat can't.</div>
332
520
  </section>
333
521
 
334
522
  <section id="panel-runs" class="tabpanel" role="tabpanel" aria-labelledby="tab-runs" hidden>
@@ -361,7 +549,7 @@
361
549
  <button role="tab" id="tab-details" aria-controls="panel-details" aria-selected="false" data-testid="right-tab-details">Job</button>
362
550
  </div>
363
551
 
364
- <div class="pane-body">
552
+ <div class="pane-body" id="right-pane-body">
365
553
 
366
554
  <section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
367
555
  <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
@@ -408,11 +596,14 @@
408
596
 
409
597
  <section id="panel-details" class="tabpanel" role="tabpanel" aria-labelledby="tab-details" hidden data-testid="panel-details">
410
598
  <div id="job-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
411
- <div class="job-card" id="job-card" hidden data-testid="job-card-readonly">
599
+ <div class="job-card" id="job-card-readonly" hidden data-testid="job-card-readonly">
412
600
  <h4 id="details-title">Job card — signed</h4>
413
601
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
414
602
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
415
- <div class="field"><label>Job name</label><div class="ro-value" id="details-jobname">—</div></div>
603
+ <div class="field-row">
604
+ <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
605
+ <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
606
+ </div>
416
607
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
417
608
  <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
418
609
  <div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
@@ -420,10 +611,6 @@
420
611
  <div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
421
612
  <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
422
613
  <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
423
- <div class="field-row">
424
- <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
425
- <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
426
- </div>
427
614
  <div class="hint" id="job-note"></div>
428
615
  </div>
429
616
  </section>
@@ -440,8 +627,24 @@
440
627
  "use strict";
441
628
 
442
629
  var PORT = "__BARELOOP_PANEL_PORT__";
630
+ // P3 human-click guard: minted once per server start, sent on every POST
631
+ // this page ever makes (`x-bareloop-token`) — never logged, never put in
632
+ // a URL, and it never signs anything on its own (the sign route also
633
+ // requires the exact spec hash, checked server-side).
634
+ var TOKEN = "__BARELOOP_PANEL_TOKEN__";
443
635
  document.getElementById("host-line").textContent = "127.0.0.1:" + PORT;
444
636
 
637
+ function authorPost(path, body){
638
+ return fetch(path, {
639
+ method: "POST",
640
+ headers: {"content-type": "application/json", "x-bareloop-token": TOKEN},
641
+ body: JSON.stringify(body || {}),
642
+ }).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: j}; }); });
643
+ }
644
+ function authorGet(path){
645
+ return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json(); });
646
+ }
647
+
445
648
  function wireTabs(tabButtons){
446
649
  tabButtons.forEach(function(btn){
447
650
  btn.addEventListener("click", function(){
@@ -496,14 +699,41 @@
496
699
  // "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
497
700
  // (the existing wrapTitleLines 2-line wrap handles overflow the same way
498
701
  // it already does for a long step id; no third renderer, no new box shape).
499
- function attemptGlyph(outcome){ return outcome === "green" ? "✓" : (outcome === null || outcome === undefined ? "?" : "✗"); }
500
- function attemptsInlineText(attempts){
702
+ // item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
703
+ // in exactly two situations — its window is still open on a LIVE run (the
704
+ // box carrying it is the run's own live/`running` part), or the run DIED
705
+ // before that window ever closed. hamr's glyph ruling reserves "?" for
706
+ // DIED only; a live open attempt reads the same "▶" the run glyph itself
707
+ // uses for "in progress / unresolved". `boxIsLive` is the ONE place that
708
+ // context is decided (every caller passes its own box's `state ===
709
+ // "running"` — never re-derives the rule itself), so a finished or died
710
+ // run's attempts (boxIsLive always false there) render exactly as before.
711
+ function attemptGlyph(outcome, boxIsLive){
712
+ if(outcome === "green") return "✓";
713
+ if(outcome === null || outcome === undefined) return boxIsLive ? "▶" : "?";
714
+ return "✗";
715
+ }
716
+ // P3 addition: colour ONLY [✓]/[✗] (the standing wording ruling's own
717
+ // words/glyph vocabulary is unchanged — this wraps the same glyph
718
+ // character in a plain inline colour span for the two result-carrying
719
+ // cases; [▶]/[?] pass through unwrapped, exactly as before this addition).
720
+ function glyphSpan(g){
721
+ if(g === "✓") return '<span class="glyph-green">' + g + '</span>';
722
+ if(g === "✗") return '<span class="glyph-red">' + g + '</span>';
723
+ return g;
724
+ }
725
+ // PLAIN TEXT ONLY — feeds the SVG step map's title/width calculation
726
+ // (`stepTitleText`, an SVG <text> node), never innerHTML; a colour span
727
+ // here would render as literal "<span>" characters inside the map.
728
+ // {@link glyphSpan} (the P3 colour addition) is used only by the DOM
729
+ // part-card renderer below, never by this function.
730
+ function attemptsInlineText(attempts, boxIsLive){
501
731
  if(!attempts || !attempts.length) return "";
502
- return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome); }).join(" · ");
732
+ return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome, boxIsLive); }).join(" · ");
503
733
  }
504
734
  function stepTitleText(idx, s){
505
735
  var base = s.noNumber ? s.title : (idx + 1) + ' ' + s.title;
506
- var at = attemptsInlineText(s.attempts);
736
+ var at = attemptsInlineText(s.attempts, s.state === "running");
507
737
  return at ? base + ' — ' + at : base;
508
738
  }
509
739
  // item 5 (F197): a box's step NUMBER must count only over boxes without
@@ -550,9 +780,10 @@
550
780
  // reads the part's own outcome.
551
781
  function partResultGlyph(part, box){
552
782
  if(partHasNoVerdict(part)) return null;
553
- if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome); }).join("");
783
+ var boxIsLive = box.state === "running";
784
+ if(box.attempts.length > 1) return box.attempts.map(function(a){ return glyphSpan(attemptGlyph(a.outcome, boxIsLive)); }).join("");
554
785
  var outcome = (part.outcome !== null && part.outcome !== undefined) ? part.outcome : (box.attempts[0] ? box.attempts[0].outcome : null);
555
- return attemptGlyph(outcome);
786
+ return glyphSpan(attemptGlyph(outcome, boxIsLive));
556
787
  }
557
788
  function buildOrderedBoxes(parts, isLive){
558
789
  var list = Array.isArray(parts) ? parts : [];
@@ -607,6 +838,23 @@
607
838
  var line2 = words.slice(i).join(' ');
608
839
  return line2 ? [line1, line2] : [line1];
609
840
  }
841
+ // ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
842
+ // box (a re-run step, or the fix loop; `run` on an old-shape spine falls in here too, naturally,
843
+ // never special-cased) gets a dashed self-loop labelled with its final try number; anything
844
+ // else (incl. every no-verdict kind, whose attempts is already forced to [] upstream) gets 0.
845
+ function boxRetryTry(box){
846
+ var attempts = Array.isArray(box.attempts) ? box.attempts : [];
847
+ return attempts.length > 1 ? attempts.length : 0;
848
+ }
849
+ // item 2 (2026-09-27): the ONE place a retry loop's x-coordinate (curve
850
+ // start/control/end, and the "try N" label) is kept inside the box that
851
+ // owns it — MIN_BOX_W (100) is comfortably wider than 2*RETRY_INSET, so
852
+ // this never inverts the clamp range.
853
+ var RETRY_INSET = 4;
854
+ function clampRetryX(v, boxX, boxW){
855
+ var lo = boxX + RETRY_INSET, hi = boxX + boxW - RETRY_INSET;
856
+ return Math.min(hi, Math.max(lo, v));
857
+ }
610
858
  function buildStepMapSVG(steps, availWidth){
611
859
  var n = steps.length;
612
860
  var naturalW = naturalBoxWidth(steps);
@@ -626,6 +874,8 @@
626
874
  '<defs>' +
627
875
  '<marker id="' + uid + '-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
628
876
  '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-dim)"></path></marker>' +
877
+ '<marker id="' + uid + '-arrow-grey" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
878
+ '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-faint)"></path></marker>' +
629
879
  '</defs>';
630
880
  steps.forEach(function(s, idx){
631
881
  var row = Math.floor(idx / perRow);
@@ -636,6 +886,8 @@
636
886
  var y = 30 + row * rowH;
637
887
  var colors = stepMapColors(s.state);
638
888
  var lines = titleLines[idx];
889
+ var nextRow = idx < n - 1 ? Math.floor((idx + 1) / perRow) : null;
890
+ var hasDrop = nextRow !== null && nextRow !== row;
639
891
  var stateY = y + boxH - 18;
640
892
  var partIdxAttr = typeof s.partIndex === "number" ? (' data-part-index="' + s.partIndex + '" style="cursor:pointer;"') : '';
641
893
  body += '<rect x="' + x + '" y="' + y + '" width="' + boxW + '" height="' + boxH +
@@ -655,8 +907,44 @@
655
907
  }
656
908
  body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
657
909
  '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(s.state) + '</text>';
910
+ // P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
911
+ // amber dot (design/panel-mockup.html ~line 1648) — same fixed color
912
+ // and animation, never re-derived from the state's own stroke color.
913
+ if(s.state === "running"){
914
+ body += '<circle cx="' + (x + 20) + '" cy="' + (y + 12) + '" r="4" fill="#b8860b">' +
915
+ '<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"></animate></circle>';
916
+ }
917
+ var retryTry = boxRetryTry(s);
918
+ if(retryTry){
919
+ // dashed self-loop under the box, same proportions as the design mockup's curve,
920
+ // scaled to this box's own width. When this same box also carries the row-to-row
921
+ // snake drop (which leaves from dead-center, x=cx), shift the loop's start away from
922
+ // center by a fixed pixel offset so the two never begin at the same point.
923
+ // item 2 (2026-09-27): at a narrow box width, subtracting that same
924
+ // fixed pixel shift from proportionally-small x-fractions (e.g. the
925
+ // curve's own endpoint at 2.8% of boxW) can push the coordinate past
926
+ // the box's own left edge — the fixed shift was sized for desktop
927
+ // widths and never scaled down with boxW. clampRetryX is the ONE
928
+ // place every retry x-coordinate (curve start/control/end AND the
929
+ // "try N" label) is kept inside [x + RETRY_INSET, x + boxW -
930
+ // RETRY_INSET] — never re-clamped ad hoc per coordinate — so the
931
+ // whole loop and its label always stay inside the box that owns it,
932
+ // at every width, while keeping the mockup's proportions untouched
933
+ // wherever they already fit (every desktop-width box already fits).
934
+ var retryShift = hasDrop ? 14 : 0;
935
+ var bottom = y + boxH;
936
+ var sx = clampRetryX(x + boxW * 0.50 - retryShift, x, boxW);
937
+ var sy = bottom + 5;
938
+ var c1x = clampRetryX(x + boxW * 0.278 - retryShift, x, boxW), c1y = bottom + 30;
939
+ var c2x = clampRetryX(x + boxW * 0.167 - retryShift, x, boxW), c2y = bottom + 30;
940
+ var ex = clampRetryX(x + boxW * 0.028 - retryShift, x, boxW), ey = bottom + 2;
941
+ var labelX = clampRetryX(x + boxW * 0.267 - retryShift, x, boxW);
942
+ body += '<path d="M ' + sx + ' ' + sy + ' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + ex + ' ' + ey +
943
+ '" fill="none" stroke="var(--text-faint)" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#' + uid + '-arrow-grey)"></path>';
944
+ body += '<text x="' + labelX + '" y="' + (bottom + 32) +
945
+ '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
946
+ }
658
947
  if(idx < n - 1){
659
- var nextRow = Math.floor((idx + 1) / perRow);
660
948
  if(nextRow === row){
661
949
  var midY = y + boxH / 2;
662
950
  if(!rtl){
@@ -684,6 +972,7 @@
684
972
  '<span><span class="dot red"></span>stopped</span>' +
685
973
  '<span><span class="dot grey"></span>waiting</span>' +
686
974
  '<span><span class="dot magenta"></span>died</span>' +
975
+ '<span>⤾ dashed = retry</span>' +
687
976
  '</div>';
688
977
  }
689
978
  var lastSteps = null;
@@ -785,7 +1074,7 @@
785
1074
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
786
1075
  '<span class="wf-meta-line">' +
787
1076
  '<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
788
- '<span class="wf-meta">' + escapeXml(r.spend) + '</span>' +
1077
+ '<span class="wf-meta">' + escapeXml(rowSpendText(r)) + '</span>' +
789
1078
  '<span class="wf-meta">' + escapeXml(r.wall) + '</span>' +
790
1079
  '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
791
1080
  '</span>';
@@ -832,7 +1121,7 @@
832
1121
  lastGlyph: last.glyph || "▶",
833
1122
  lastCheckType: last.checkType || "unknown",
834
1123
  lastModel: last.model || null,
835
- lastSpend: last.spend || "unknown",
1124
+ lastSpend: rowSpendText(last),
836
1125
  lastWall: last.wall || "unknown",
837
1126
  lastDate: last.date,
838
1127
  };
@@ -1248,7 +1537,78 @@
1248
1537
  }
1249
1538
 
1250
1539
  // ---- Run tab ----
1251
- function money(n){ return (n === null || n === undefined) ? "unknown" : "$" + Number(n).toFixed(4); }
1540
+ // panelMoney — hamr's ruling 2026-09-28 ("panel money 2-decimals"): every
1541
+ // money display in THIS page (Run tab summary/counters, Runs list rows,
1542
+ // Audit header/part cards, Job tab, chat cost lines) renders through this
1543
+ // ONE function — no per-site rounding. The CLI's own 4-decimal `money()`
1544
+ // (src/replay.js) is UNCHANGED; this is the panel-only render, the
1545
+ // browser-script sibling of `src/panel/authorroutes.js`'s own
1546
+ // `panelMoney2` (same rule, can't share code — one runs in Node, one
1547
+ // inlined in the page).
1548
+ // - null/undefined/non-finite -> "unknown" (never a fabricated $0).
1549
+ // - a REAL positive amount below a cent -> "<$0.01", never "$0.00" (a
1550
+ // tiny real cost must never read as free).
1551
+ // - `floor` (this is a FLOOR/"at least" figure, never exact) rounds DOWN
1552
+ // to 2 decimals — never overstates a floor as exact-higher.
1553
+ // - otherwise rounds half-up to 2 decimals (an exact zero prints
1554
+ // "$0.00" — nothing spent is a real number, not an unknown).
1555
+ // Two-step rounding guards against float-representation noise on BOTH
1556
+ // sides: dollar figures here are derived from at most 6-decimal-precision
1557
+ // money (the same precision `src/text.js`'s `tallyCalls`/`priceOf` use),
1558
+ // so rounding to 6 decimals first erases that noise (e.g. 0.815 really
1559
+ // stored as 0.81499999999999995), and ONLY THEN is the value rounded/
1560
+ // floored to cents — a value already exactly on a cent boundary (e.g.
1561
+ // 0.82) can be stored as 81.99999999999999 cents post-multiply, and a
1562
+ // floor there would wrongly drop a whole cent; the tiny `+1e-6` nudge (at
1563
+ // dollar-cents scale, far below anything a real half-cent tie could be)
1564
+ // corrects that without ever crossing a true half-cent boundary the wrong
1565
+ // way.
1566
+ function panelMoney(n, floor){
1567
+ if(n === null || n === undefined) return "unknown";
1568
+ var v = Number(n);
1569
+ if(!isFinite(v)) return "unknown";
1570
+ if(v > 0 && v < 0.01) return "<$0.01";
1571
+ var clean = Math.round(v * 1e6) / 1e6;
1572
+ var cents = floor ? Math.floor(clean * 100 + 1e-6) : Math.round(clean * 100 + 1e-6);
1573
+ if(cents < 0) cents = 0;
1574
+ return "$" + (cents / 100).toFixed(2);
1575
+ }
1576
+ /**
1577
+ * The panel's one composed "spend (drafting) of cap" renderer — the
1578
+ * browser-script sibling of `src/replay.js`'s own `moneyWithDraft`, same
1579
+ * rules, 2-decimal via {@link panelMoney} instead of 4. `spendCoreFloor`
1580
+ * marks the LEADING figure a floor (a died run's own spend-floor, OR a
1581
+ * complete run whose drafting share is itself incomplete — hamr's ruling:
1582
+ * a reader summing the two must never read the leading number as exact
1583
+ * when the bracket next to it isn't). `draftSpendComplete === false` marks
1584
+ * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX".
1585
+ */
1586
+ function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd){
1587
+ var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
1588
+ var draftFloor = hasDraft && draftSpendComplete === false;
1589
+ var core = (spendCoreVal === null || spendCoreVal === undefined)
1590
+ ? "unknown"
1591
+ : ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
1592
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
1593
+ var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
1594
+ return core + draftPart + capPart;
1595
+ }
1596
+ /**
1597
+ * The Runs list row / Workflows grouping's own spend text — off the
1598
+ * numeric fields `/api/runs`' row now carries (never `r.spend`, which is
1599
+ * the LIBRARY's own 4-decimal string kept unchanged for `--all`'s CLI
1600
+ * listing to share; `src/panel/server.js`'s `summarizeRow` doc says why).
1601
+ * Same floor rules as {@link panelMoneyWithDraft}/`renderRun`'s own
1602
+ * `spendText`: a died row's spend is a floor by construction, a live row's
1603
+ * is a floor only when its own drafting share is incomplete.
1604
+ * @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
1605
+ */
1606
+ function rowSpendText(r){
1607
+ var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
1608
+ var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
1609
+ var coreFloor = !!r.died || draftIncomplete;
1610
+ return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete);
1611
+ }
1252
1612
  function duration(ms){
1253
1613
  if(ms === null || ms === undefined) return "unknown";
1254
1614
  if(ms < 0) return "unknown";
@@ -1259,6 +1619,31 @@
1259
1619
  return m + "m" + String(rem).padStart(2, "0") + "s";
1260
1620
  }
1261
1621
 
1622
+ /**
1623
+ * build item 6 (2026-09-28, session mul5fofw) — while a run is LIVE (no
1624
+ * job-end reached yet: `detail.spentUsd === null` and not died), the Run
1625
+ * tab's summary must show a running FLOOR from the spine's own metered
1626
+ * rounds instead of "unknown" — never presented as exact ("so far", never
1627
+ * "at least" — this is a live, still-growing number, not a died run's
1628
+ * fixed floor). Reuses {@link panelMoney} for the actual digits; never a
1629
+ * second hand-rolled money format.
1630
+ * @param {number|null} spendFloorUsd @param {number|null} draftSpentUsd
1631
+ * @param {boolean|null} draftSpendComplete @returns {string}
1632
+ */
1633
+ function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete){
1634
+ if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
1635
+ var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
1636
+ var draftFloor = hasDraft && draftSpendComplete === false;
1637
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
1638
+ return panelMoney(spendFloorUsd) + " so far" + draftPart;
1639
+ }
1640
+ /** build item 6 — the matching wall-clock phrase: "running <duration>",
1641
+ * never "unknown" once the spine has at least one timestamped record.
1642
+ * @param {number|null} wallFloorMs @returns {string} */
1643
+ function liveWallPhrase(wallFloorMs){
1644
+ return (wallFloorMs === null || wallFloorMs === undefined) ? "elapsed unknown" : ("running " + duration(wallFloorMs));
1645
+ }
1646
+
1262
1647
  // ---- Audit row cell builders — ONE shared renderer for a gate-audit row,
1263
1648
  // used by both the Flat table (renderAudit) and the Grouped view's
1264
1649
  // per-attempt rounds table (renderRoundsTable) so the two views can never
@@ -1285,7 +1670,7 @@
1285
1670
  function auditDecisionCellHtml(r){
1286
1671
  if(auditIsModelCall(r)){
1287
1672
  var bits = [];
1288
- bits.push(typeof r.costUsd === "number" ? money(r.costUsd) : "unknown");
1673
+ bits.push(typeof r.costUsd === "number" ? panelMoney(r.costUsd) : "unknown");
1289
1674
  bits.push((typeof r.tokens === "number" ? r.tokens : "unknown") + " tokens");
1290
1675
  bits.push(duration(r.durationMs));
1291
1676
  return escapeXml(bits.join(" · "));
@@ -1299,6 +1684,25 @@
1299
1684
  if(auditIsBlocked(r)) cls = (cls ? cls + " " : "") + "row-blocked";
1300
1685
  return cls;
1301
1686
  }
1687
+ // fix (2026-09-28): the Time cell showed the full ISO timestamp
1688
+ // (`2026-09-27T08:20:58.924Z`), overflowing every Audit-tab table sideways
1689
+ // at 390px. Shows time-only `HH:MM:SS` taken from the ISO string AS-IS
1690
+ // (UTC, no timezone conversion), full ISO kept in the cell's `title` for
1691
+ // hover — ONE helper, shared by the Flat table and the Grouped view's
1692
+ // per-attempt rounds table (both the header row and the tool-call rows),
1693
+ // so the three call sites can never drift on how a Time cell reads.
1694
+ function auditShortTime(iso){
1695
+ if(!iso) return null;
1696
+ var m = /T(\d\d:\d\d:\d\d)/.exec(String(iso));
1697
+ return m ? m[1] : null;
1698
+ }
1699
+ function auditTimeCellHtml(r){
1700
+ var iso = r.time;
1701
+ if(!iso) return "unknown";
1702
+ var short = auditShortTime(iso);
1703
+ if(!short) return escapeXml(iso);
1704
+ return '<span title="' + escapeXml(iso) + '">' + escapeXml(short) + '</span>';
1705
+ }
1302
1706
 
1303
1707
  // item 7: tools/cache summary rows — built ONLY from replay's own already-
1304
1708
  // computed `behaviour`/`memoryCache` fields (never recomputed a second way).
@@ -1403,17 +1807,38 @@
1403
1807
  // no job-end means no final totals were ever recorded) are replaced with
1404
1808
  // the priced-rounds/first-to-last-record FLOORS the server derived —
1405
1809
  // never "unknown" when a priced round actually exists.
1406
- var spendText = detail.died ? (detail.spendFloorUsd === null ? "unknown" : "at least " + money(detail.spendFloorUsd)) : money(detail.spentUsd);
1407
- var wallText = detail.died ? (detail.wallFloorMs === null ? "unknown" : "at least " + duration(detail.wallFloorMs)) : duration(detail.wallMs);
1408
- // item 6 (2026-09-25): "took" for a finished (job-end reached) OR died
1409
- // run — both are OVER, so the wall figure is a completed duration, never
1410
- // an in-progress one. "elapsed" is reserved for the one state that IS
1411
- // still in progress: a live [▶] run (glyph "▶" and not died).
1412
- var isLive = detail.glyph === "▶" && !detail.died;
1413
- var wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
1810
+ // hamr's ruling 2026-09-28 ("one cap covers drafting + run", 2nd
1811
+ // addendum) — the leading figure reads as a floor ("at least $X") on a
1812
+ // died run OR when the drafting share itself is incomplete (a reader
1813
+ // summing the two must never take the leading number as exact when the
1814
+ // bracket beside it isn't); the drafting bracket reads its own floor
1815
+ // independently. {@link panelMoneyWithDraft} is the one place that
1816
+ // decides all of this — see its own doc.
1817
+ // build item 6 (2026-09-28): a LIVE run (no job-end reached: died is
1818
+ // false and spentUsd is still null) shows the RUNNING floor instead of
1819
+ // "unknown" — the server now derives the same spendFloorUsd/wallFloorMs
1820
+ // for a still-running spine that it already derived for a died one.
1821
+ var isLiveNoEnd = !detail.died && detail.spentUsd === null;
1822
+ var spendText, wallPhrase;
1823
+ if(isLiveNoEnd){
1824
+ spendText = liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete);
1825
+ wallPhrase = liveWallPhrase(detail.wallFloorMs);
1826
+ } else {
1827
+ var draftIncomplete = typeof detail.draftSpentUsd === "number" && detail.draftSpentUsd !== null && detail.draftSpendComplete === false;
1828
+ var spendCoreVal = detail.died ? detail.spendFloorUsd : detail.spentUsd;
1829
+ var spendCoreFloor = detail.died || draftIncomplete;
1830
+ spendText = panelMoneyWithDraft(spendCoreVal, spendCoreFloor, detail.draftSpentUsd, detail.draftSpendComplete);
1831
+ var wallText = detail.died ? (detail.wallFloorMs === null ? "unknown" : "at least " + duration(detail.wallFloorMs)) : duration(detail.wallMs);
1832
+ // item 6 (2026-09-25): "took" for a finished (job-end reached) OR died
1833
+ // run — both are OVER, so the wall figure is a completed duration, never
1834
+ // an in-progress one. "elapsed" is reserved for the one state that IS
1835
+ // still in progress: a live [▶] run (glyph "▶" and not died).
1836
+ var isLive = detail.glyph === "▶" && !detail.died;
1837
+ wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
1838
+ }
1414
1839
 
1415
1840
  document.getElementById("run-counters").innerHTML =
1416
- spendText + " of $" + (detail.budgetUsd === null ? "unknown" : Number(detail.budgetUsd).toFixed(2)) +
1841
+ spendText + " of " + panelMoney(detail.budgetUsd) +
1417
1842
  " &middot; " + wallPhrase;
1418
1843
 
1419
1844
  var box = document.getElementById("run-summary");
@@ -1460,12 +1885,10 @@
1460
1885
  var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
1461
1886
  var titleText = box.kind === "step" ? ((cardStepNumbers[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
1462
1887
  var resultGlyph = partResultGlyph(part, box);
1463
- var line1Bits = [
1464
- (typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
1465
- typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
1466
- duration(part.wallMs),
1467
- part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
1468
- ];
1888
+ // build item 4: ONE owner (partLine1Text, shared with the Audit tab's
1889
+ // Grouped headers) for the wall/money bits, so a live run's card never
1890
+ // reads a bare "unknown" a second reader independently failed to float.
1891
+ var line1Bits = [partLine1Text(part, detail)];
1469
1892
  if(resultGlyph !== null) line1Bits.push(resultGlyph);
1470
1893
  var card = document.createElement("div");
1471
1894
  card.className = "step-card part-card" + (box.state === "running" ? " running" : "");
@@ -1498,25 +1921,36 @@
1498
1921
 
1499
1922
  // build item C: Audit tab attempt row -> its rounds (server-built,
1500
1923
  // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
1501
- function buildAttemptsList(container, partIndex, attempts){
1924
+ function buildAttemptsList(container, partIndex, attempts, boxIsLive){
1502
1925
  container.innerHTML = "";
1503
1926
  attempts.forEach(function(a){
1504
1927
  var row = document.createElement("div");
1505
1928
  row.className = "attempt-row";
1506
1929
  row.setAttribute("data-testid", "audit-attempt-" + partIndex + "-" + a.n);
1930
+ // plain-checks build (2026-09-28): the withdrawn "checks N/M" headline
1931
+ // is replaced by attemptChecksLine's plain words (including the
1932
+ // opening outer-close grade, which is attempt 1 on this SAME list —
1933
+ // src/replay.js's fixLoop.attempts) — "" for a rubric/command close or
1934
+ // a still-running attempt with no stages yet.
1935
+ var checksText = attemptChecksLine(a);
1936
+ var checksList = fullStageList(a).map(stageLineHtml).join("<br>");
1507
1937
  row.innerHTML =
1508
1938
  '<button class="btn small attempt-toggle" type="button" aria-expanded="false">' +
1509
- 'attempt ' + a.n + ' ' + attemptGlyph(a.outcome) +
1939
+ 'attempt ' + a.n + ' ' + attemptGlyph(a.outcome, boxIsLive) +
1940
+ (checksText ? ' &middot; ' + checksText : '') +
1510
1941
  '</button>' +
1942
+ (checksList ? '<div class="checks-list" data-testid="audit-attempt-checks-' + partIndex + '-' + a.n + '" hidden>' + checksList + '</div>' : '') +
1511
1943
  '<div class="rounds-list" hidden></div>';
1512
1944
  container.appendChild(row);
1513
1945
  var btn = row.querySelector(".attempt-toggle");
1514
1946
  var roundsEl = row.querySelector(".rounds-list");
1947
+ var checksEl = row.querySelector(".checks-list");
1515
1948
  var built = false;
1516
1949
  btn.addEventListener("click", function(){
1517
1950
  var expanded = btn.getAttribute("aria-expanded") === "true";
1518
1951
  btn.setAttribute("aria-expanded", expanded ? "false" : "true");
1519
1952
  roundsEl.hidden = expanded;
1953
+ if(checksEl) checksEl.hidden = expanded;
1520
1954
  if(!expanded && !built){
1521
1955
  built = true;
1522
1956
  loadRounds(roundsEl, partIndex, a, 0);
@@ -1549,7 +1983,7 @@
1549
1983
  '<td>' + auditActionCellHtml(pseudo) + '</td>' +
1550
1984
  '<td>' + auditPathCellHtml(pseudo) + '</td>' +
1551
1985
  '<td>' + auditDecisionCellHtml(pseudo) + '</td>' +
1552
- '<td>' + escapeXml(pseudo.time || "unknown") + '</td>' +
1986
+ '<td>' + auditTimeCellHtml(pseudo) + '</td>' +
1553
1987
  '</tr>';
1554
1988
  }
1555
1989
  function renderRoundToolRow(tc){
@@ -1559,7 +1993,7 @@
1559
1993
  '<td>' + auditActionCellHtml(tc) + '</td>' +
1560
1994
  '<td>' + auditPathCellHtml(tc) + '</td>' +
1561
1995
  '<td>' + auditDecisionCellHtml(tc) + '</td>' +
1562
- '<td>' + escapeXml(tc.time || "unknown") + '</td>' +
1996
+ '<td>' + auditTimeCellHtml(tc) + '</td>' +
1563
1997
  '</tr>';
1564
1998
  }
1565
1999
  function renderRoundRows(r, toolLogSaved){
@@ -1627,13 +2061,188 @@
1627
2061
  if(!bt) return false;
1628
2062
  return !!(bt.edit || bt.write);
1629
2063
  }
1630
- function partLine1Text(part){
1631
- return [
2064
+ /**
2065
+ * build item 4 (2026-09-28, session mul5fofw's own live run): the wall/money
2066
+ * bits of a part card — ONE owner, used by both the Run tab's cards
2067
+ * ({@link renderRun}) and the Audit tab's Grouped headers
2068
+ * ({@link renderAuditGroups}), which used to compute these independently (a
2069
+ * second wall/spend reader — exactly the class of bug F198 already named).
2070
+ *
2071
+ * A part's `wallMs`/`spentUsd` are `null` while it is genuinely still being
2072
+ * measured (`replay.js`'s own `windowWallMs`/`thisFileSpend`, unresolved
2073
+ * until a later record — or, for the `kind:"run"` synthetic whole-run part,
2074
+ * unresolved until `job-end`). `duration(null)`/`panelMoney(null)` both read
2075
+ * "unknown", which is honest for a DEAD run but wrong for a LIVE one — the
2076
+ * same distinction {@link liveSpendText}/{@link liveWallPhrase} already draw
2077
+ * for the Summary box. Only the run's OWN card (`box.kind === "run"`, the
2078
+ * one shown when no step has started yet) can be substituted this way: its
2079
+ * `wallMs`/`spentUsd` (`replay.js`'s top-level `wallMs`/`thisFileSpend`) ARE
2080
+ * the run's own totals-so-far, so `detail.wallFloorMs`/`spendFloorUsd` (the
2081
+ * server's floor over the SAME spine) is the right number, not a guess. A
2082
+ * step/scout/plan/fix/judge part's own wall/spend has no run-level floor to
2083
+ * borrow, so it is left exactly as before.
2084
+ * @param {any} part @param {any} [detail] the run detail, when available
2085
+ * @returns {string}
2086
+ */
2087
+ function partLine1Text(part, detail){
2088
+ var isLiveRunPart = part && part.kind === "run" && detail && !detail.died && detail.spentUsd === null;
2089
+ var wallText = (isLiveRunPart && part.wallMs === null) ? liveWallPhrase(detail.wallFloorMs) : duration(part.wallMs);
2090
+ var spendText = (isLiveRunPart && part.spentUsd === null)
2091
+ ? liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete)
2092
+ : (part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : panelMoney(part.spentUsd));
2093
+ var bits = [
1632
2094
  (typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
1633
2095
  typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
1634
- duration(part.wallMs),
1635
- part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
1636
- ].join(" &middot; ");
2096
+ wallText,
2097
+ spendText,
2098
+ ];
2099
+ // plain-checks build (2026-09-28): the part's headline check LINE — its
2100
+ // MOST RECENT attempt's own declared-close grading (the opening
2101
+ // outer-close grade counts as attempt 1, same list `buildAttemptsList`
2102
+ // reads), in the withdrawn headline's place.
2103
+ var latestAttempt = Array.isArray(part.attempts) && part.attempts.length ? part.attempts[part.attempts.length - 1] : null;
2104
+ var headline = latestAttempt ? attemptChecksLine(latestAttempt) : "";
2105
+ if(headline) bits.push(headline);
2106
+ return bits.join(" &middot; ");
2107
+ }
2108
+ /**
2109
+ * plain-checks build (2026-09-28, hamr's ruling): the "checks N/M" headline
2110
+ * is WITHDRAWN — "confusing, reads like 6 failed when 5 never ran".
2111
+ * Replaced everywhere by this plain-words line:
2112
+ * - all declared stages passed: "all N checks passed"
2113
+ * - a stage failed: "check #N <plain question> — <number>", where `#N`
2114
+ * is the failing stage's 1-based position in the DECLARED close
2115
+ * ({@link declaredPosition}), the question is `stage.question` (the
2116
+ * server's one owner, `src/panel/server.js`'s `stageQuestionText` —
2117
+ * falls back to the stage's own name when the catalogue has no honest
2118
+ * plain question for it) and the number is {@link stageNumberText}'s
2119
+ * reworded shape, omitted entirely for a pass/fail stage with no
2120
+ * number.
2121
+ * "" (never shown) for a rubric/command close or an unresolved attempt
2122
+ * (still running, no stages yet). `attempt.declaredStagesTotal` is the
2123
+ * DECLARED stage count (never `stages.length`, the RAN count — first-red-
2124
+ * wins means a later attempt's own `stages` array can be shorter than the
2125
+ * close it belongs to; hamr's live catch on run mulbz0ny, checks-count fix
2126
+ * 2026-09-28), falling back to `stages.length` only when no spec resolved.
2127
+ * The caller (buildAttemptsList) prefixes "attempt N ✓/✗ · " for the
2128
+ * attempt header, and the Run tab card / Audit Grouped header show this
2129
+ * SAME text bare (hamr: "same text as its attempt header, minus 'attempt
2130
+ * N'").
2131
+ * @param {any} attempt @returns {string}
2132
+ */
2133
+ function attemptChecksLine(attempt){
2134
+ var stages = attempt && attempt.stages;
2135
+ if(!Array.isArray(stages) || stages.length === 0) return "";
2136
+ var total = (typeof attempt.declaredStagesTotal === "number" && attempt.declaredStagesTotal > 0) ? attempt.declaredStagesTotal : stages.length;
2137
+ var failing = null;
2138
+ for(var i = 0; i < stages.length; i++){
2139
+ if(stages[i] && stages[i].verdict !== "satisfied"){ failing = stages[i]; break; }
2140
+ }
2141
+ if(failing === null) return "all " + total + " checks passed";
2142
+ var pos = declaredPosition(attempt, failing.name);
2143
+ if(pos === null) pos = stages.indexOf(failing) + 1;
2144
+ var numberText = stageNumberText(failing);
2145
+ return "check #" + pos + " " + escapeXml(stageQuestionText(failing)) + (numberText ? " — " + numberText : "");
2146
+ }
2147
+ /** a stage's DECLARED 1-based position, from `attempt.declaredStages` (the
2148
+ * server's full declared-order list, {@link enrichPartsWithStageKind} in
2149
+ * src/panel/server.js) — matched by NAME, never by array index (robust to
2150
+ * any reordering between the resolved spec and the stages that actually
2151
+ * ran). `null` when no `declaredStages` travelled (no spec resolvable) or
2152
+ * the name isn't found there; the caller falls back to the stage's
2153
+ * position within whatever ran.
2154
+ * @param {any} attempt @param {string|null|undefined} stageName @returns {number|null} */
2155
+ function declaredPosition(attempt, stageName){
2156
+ if(!Array.isArray(attempt.declaredStages)) return null;
2157
+ var idx = attempt.declaredStages.findIndex(function(ds){ return ds && ds.name === stageName; });
2158
+ return idx === -1 ? null : idx + 1;
2159
+ }
2160
+ /** a stage's plain-English question — `stage.question` (the server's one
2161
+ * owner, `src/panel/server.js`'s `stageQuestionText`) when the catalogue
2162
+ * has an honest one for this stage's kind+params, else the stage's OWN
2163
+ * name (a command-close's hand-written stage name, or a kind/genre shape
2164
+ * the owner has no wording for — never invented here).
2165
+ * @param {any} stage @returns {string} */
2166
+ function stageQuestionText(stage){
2167
+ if(stage && typeof stage.question === "string" && stage.question) return stage.question;
2168
+ return (stage && stage.name) ? stage.name : "unknown";
2169
+ }
2170
+ /**
2171
+ * §3a's closed numeric wording, one owner (checks-wording build spec,
2172
+ * 2026-09-28, reworded by the plain-checks build's own separators): a
2173
+ * numeric stage's own DECLARED `direction`/`baselineKind` (server-sent,
2174
+ * {@link stageKindMetaFromSpec} on the panel server — the signed close
2175
+ * declaration's own per-stage facts, never guessed from the runtime number
2176
+ * alone) picks one of hamr's 3 shapes:
2177
+ * - lower-is-better toward a DECLARED-ZERO goal (`baselineKind === 0`):
2178
+ * "N left, need T"
2179
+ * - higher-is-better (a floor): "N of total"
2180
+ * - lower-is-better toward a SEED-MEASURED baseline (`baselineKind ===
2181
+ * "seed"`): "N, limit M"
2182
+ * Only `count-not-worse` ever carries a numeric value/baseline at all
2183
+ * (src/kinds.js — every other kind's StageResult defaults both to null),
2184
+ * so a value-less stage always renders "" here (pass/fail, glyph only).
2185
+ * Every shipped kind fits one of the 3 shapes above; a stage that somehow
2186
+ * carries a number with no resolvable direction/baselineKind (no spec
2187
+ * resolved server-side, or an old archived run) falls back to the spec's
2188
+ * own explicit "N (baseline M)" wording verbatim, never a 4th invented
2189
+ * shape.
2190
+ * @param {any} stage @returns {string}
2191
+ */
2192
+ function stageNumberText(stage){
2193
+ var hasValue = typeof stage.value === "number";
2194
+ var hasBaseline = typeof stage.baseline === "number";
2195
+ if(!hasValue) return "";
2196
+ if(stage.direction === "higher-is-better" && hasBaseline) return stage.value + " of " + stage.baseline;
2197
+ if(stage.direction === "lower-is-better" && stage.baselineKind === 0 && hasBaseline) return stage.value + " left, need " + stage.baseline;
2198
+ if(stage.direction === "lower-is-better" && stage.baselineKind === "seed" && hasBaseline) return stage.value + ", limit " + stage.baseline;
2199
+ return hasBaseline ? (stage.value + " (baseline " + stage.baseline + ")") : String(stage.value);
2200
+ }
2201
+ /**
2202
+ * plain-checks build (2026-09-28): the expanded attempt row's FULL declared
2203
+ * stage list, in declared order, including stages that never ran (first-
2204
+ * red-wins stopped the close early) — hamr's ruling: "one line per DECLARED
2205
+ * stage, in order, including stages that never ran". Zips
2206
+ * `attempt.declaredStages` (the server's full ordered `{name, question}`
2207
+ * list) against `attempt.stages` (the shorter, possibly-partial RAN list)
2208
+ * BY NAME: a declared stage with a matching ran entry renders that entry
2209
+ * (`ran: true`); one with no match renders a synthetic not-run stub
2210
+ * (`ran: false`, `verdict: null`). When no `declaredStages` travelled at
2211
+ * all (no spec resolvable), falls back to whatever ran, unpadded — exactly
2212
+ * today's behaviour for a rubric/command close or an unresolvable spec.
2213
+ * @param {any} attempt @returns {{n: number, ran: boolean, stage: any}[]}
2214
+ */
2215
+ function fullStageList(attempt){
2216
+ var ran = Array.isArray(attempt.stages) ? attempt.stages : [];
2217
+ if(!Array.isArray(attempt.declaredStages) || attempt.declaredStages.length === 0){
2218
+ return ran.map(function(s, i){ return { n: i + 1, ran: true, stage: s }; });
2219
+ }
2220
+ var byName = {};
2221
+ ran.forEach(function(s){ if(s && typeof s.name === "string") byName[s.name] = s; });
2222
+ return attempt.declaredStages.map(function(ds, i){
2223
+ var s = ds && Object.prototype.hasOwnProperty.call(byName, ds.name) ? byName[ds.name] : null;
2224
+ if(s) return { n: i + 1, ran: true, stage: s };
2225
+ return { n: i + 1, ran: false, stage: { name: ds ? ds.name : null, question: ds ? ds.question : null, verdict: null } };
2226
+ });
2227
+ }
2228
+ /**
2229
+ * one stage's expand-view line — "#N <question> <mark> <number>", hamr's
2230
+ * closed illustration:
2231
+ * #1 did it change any file? ✓
2232
+ * #2 src/checks.js has 0 type errors? ✗ 4 left, need 0
2233
+ * #3 other files didn't get more type errors? · not run
2234
+ * A never-run declared stage (`entry.ran === false`) always reads "· not
2235
+ * run", no number. A ran stage with no recorded number (a guard, a
2236
+ * pass/fail command stage) renders the mark alone.
2237
+ * @param {{n: number, ran: boolean, stage: any}} entry @returns {string}
2238
+ */
2239
+ function stageLineHtml(entry){
2240
+ var s = entry.stage;
2241
+ var q = escapeXml(stageQuestionText(s));
2242
+ if(!entry.ran) return "#" + entry.n + " " + q + " &middot; not run";
2243
+ var mark = s.verdict === "satisfied" ? glyphSpan("✓") : (s.verdict ? glyphSpan("✗") : "&ndash;");
2244
+ var numberText = stageNumberText(s);
2245
+ return "#" + entry.n + " " + q + " " + mark + (numberText ? " " + numberText : "");
1637
2246
  }
1638
2247
  var currentAuditFilter = "all";
1639
2248
  function auditPartMatchesFilter(rowWrap, filter){
@@ -1677,7 +2286,7 @@
1677
2286
  headBtn.setAttribute("data-testid", "audit-part-toggle-" + idx);
1678
2287
  var groupResultGlyph = partResultGlyph(part, box);
1679
2288
  headBtn.innerHTML =
1680
- '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part) +
2289
+ '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part, detail) +
1681
2290
  (groupResultGlyph !== null ? ' &middot; ' + groupResultGlyph : '') +
1682
2291
  (box.blocked > 0 ? ' <span class="badge red" data-testid="audit-part-blocked-' + idx + '">' + box.blocked + ' blocked</span>' : '');
1683
2292
  var body = document.createElement("div");
@@ -1699,7 +2308,7 @@
1699
2308
  var attemptsWrap = document.createElement("div");
1700
2309
  attemptsWrap.className = "attempts-list";
1701
2310
  body.appendChild(attemptsWrap);
1702
- buildAttemptsList(attemptsWrap, idx, attempts);
2311
+ buildAttemptsList(attemptsWrap, idx, attempts, box.state === "running");
1703
2312
  } else {
1704
2313
  // parts with NO sub-loop (scout, plan, replan, judge, run, or a
1705
2314
  // single-attempt step) expand STRAIGHT to their rounds table.
@@ -1788,7 +2397,7 @@
1788
2397
  "<td>" + auditActionCellHtml(r) + "</td>" +
1789
2398
  "<td>" + pathCell + "</td>" +
1790
2399
  "<td>" + decisionCell + "</td>" +
1791
- "<td>" + escapeXml(r.time || "unknown") + "</td>";
2400
+ "<td>" + auditTimeCellHtml(r) + "</td>";
1792
2401
  tbody.appendChild(tr);
1793
2402
  });
1794
2403
  document.getElementById("audit-rawlog").textContent = result.raw || "";
@@ -1821,7 +2430,7 @@
1821
2430
  // ---- Job tab ----
1822
2431
  function renderJob(job){
1823
2432
  var empty = document.getElementById("job-empty");
1824
- var card = document.getElementById("job-card");
2433
+ var card = document.getElementById("job-card-readonly");
1825
2434
  if(!job){ empty.hidden = false; card.hidden = true; return; }
1826
2435
  empty.hidden = true;
1827
2436
  card.hidden = false;
@@ -1830,7 +2439,6 @@
1830
2439
  var verdictEl = document.getElementById("details-verdict");
1831
2440
  if(job.checkTypeTitle){ verdictEl.title = job.checkTypeTitle; } else { verdictEl.removeAttribute("title"); }
1832
2441
  document.getElementById("details-model").textContent = job.model;
1833
- document.getElementById("details-jobname").textContent = job.job;
1834
2442
  var descField = document.getElementById("details-description-field");
1835
2443
  if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
1836
2444
  else { descField.hidden = true; }
@@ -1840,7 +2448,7 @@
1840
2448
  document.getElementById("details-source").textContent = job.source;
1841
2449
  document.getElementById("details-dest").textContent = job.destination;
1842
2450
  document.getElementById("details-tools").textContent = job.tools;
1843
- document.getElementById("details-cap-money").textContent = job.budgetUsd === null ? "unknown" : ("$" + Number(job.budgetUsd).toFixed(2));
2451
+ document.getElementById("details-cap-money").textContent = panelMoney(job.budgetUsd);
1844
2452
  document.getElementById("details-cap-time").textContent = job.maxWallMs === null ? "unknown" : duration(job.maxWallMs);
1845
2453
  document.getElementById("job-note").textContent = job.note || "";
1846
2454
  lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
@@ -1849,37 +2457,197 @@
1849
2457
 
1850
2458
  // ---- selection + loading ----
1851
2459
  var currentRunid = null;
2460
+ // P2: guards a stale in-flight response (detail/audit/job, OR the poll
2461
+ // loop's own run-detail refetch) from overwriting whatever run is
2462
+ // actually selected by the time it resolves — bumped on every selectRun
2463
+ // call, checked before any of those responses touch the DOM.
2464
+ var selectToken = 0;
2465
+ // P2: true while the OPEN run's own detail poll should keep firing (set
2466
+ // from the run's own `glyph`/`died` fields, same rule `renderRun` already
2467
+ // uses for `isLive` — never re-derived a second way).
2468
+ var runIsLive = false;
1852
2469
  function selectRun(runid, rowEl, siblingSelector){
1853
2470
  clearSelection();
1854
2471
  document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1855
2472
  if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
1856
2473
  currentRunid = runid;
1857
2474
  lastJobToolsList = null;
1858
- getJSON("/api/runs/" + encodeURIComponent(runid)).then(renderRun).catch(function(e){
2475
+ lastRunDetailSig = null;
2476
+ runIsLive = false;
2477
+ var myToken = ++selectToken;
2478
+ getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
2479
+ if(myToken !== selectToken) return; // a different run was selected before this resolved
2480
+ lastRunDetailSig = sig(detail);
2481
+ runIsLive = detail.glyph === "▶" && !detail.died;
2482
+ renderRun(detail);
2483
+ }).catch(function(e){
2484
+ if(myToken !== selectToken) return;
1859
2485
  document.getElementById("run-empty").hidden = false;
1860
2486
  document.getElementById("run-empty").textContent = "failed to load run: " + e.message;
1861
2487
  document.getElementById("run-content").hidden = true;
1862
2488
  });
1863
- getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(renderAudit).catch(function(){ renderAudit(null); });
1864
- getJSON("/api/runs/" + encodeURIComponent(runid) + "/job").then(renderJob).catch(function(){ renderJob(null); });
2489
+ getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(function(result){
2490
+ if(myToken !== selectToken) return;
2491
+ renderAudit(result);
2492
+ }).catch(function(){ if(myToken === selectToken) renderAudit(null); });
2493
+ getJSON("/api/runs/" + encodeURIComponent(runid) + "/job").then(function(job){
2494
+ if(myToken !== selectToken) return;
2495
+ renderJob(job);
2496
+ }).catch(function(){ if(myToken === selectToken) renderJob(null); });
2497
+ }
2498
+
2499
+ // Q2 (2026-09-27 ruling): the Audit tab does NOT live-refresh on the 2s
2500
+ // poll — it re-fetches only when the person opens/switches to it.
2501
+ document.getElementById("tab-audit").addEventListener("click", function(){
2502
+ if(!currentRunid) return;
2503
+ var myToken = selectToken;
2504
+ getJSON("/api/runs/" + encodeURIComponent(currentRunid) + "/audit").then(function(result){
2505
+ if(myToken !== selectToken) return;
2506
+ renderAudit(result);
2507
+ }).catch(function(){});
2508
+ });
2509
+
2510
+ // ---- P2: live polling — ONE owner for both the left Runs list and the
2511
+ // open live run's map/cards/counters/summary (hamr's ruling, 2026-09-27:
2512
+ // 2s interval for the open run's own detail; the open run's own detail
2513
+ // refetch stops once it is no longer live; the Audit tab is wired
2514
+ // separately above and never joins this loop). A single `setInterval`
2515
+ // drives both fetches — never two independent timers that could race
2516
+ // each other. ----
2517
+ //
2518
+ // F195 (docs/logs/FINDINGS.md): /api/runs re-replays every spine on
2519
+ // every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
2520
+ // "refresh less often, up to 30s at most, choose the lesser when
2521
+ // possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
2522
+ // of every 2s tick; the open run's own detail is unaffected. The 2s
2523
+ // `pollTick` still fires the list check every tick, but `refreshRunsList`
2524
+ // itself skips the fetch unless RUNS_LIST_POLL_MS has elapsed since the
2525
+ // last one — one timer, one owner, just a throttle inside it. The one
2526
+ // exception: the instant the open run's own poll sees it stop being live
2527
+ // (a real verdict or died), `pollTick` forces ONE immediate list refresh
2528
+ // so that run's left-list glyph flips right away instead of waiting up
2529
+ // to 10s.
2530
+ var POLL_MS = 2000;
2531
+ var RUNS_LIST_POLL_MS = 10000;
2532
+ var pollTimer = null;
2533
+ var lastRunsSig = null;
2534
+ var lastRunDetailSig = null;
2535
+ var lastRunsListRefreshAt = 0;
2536
+ var pollFailStreak = 0;
2537
+
2538
+ function sig(obj){
2539
+ try { return JSON.stringify(obj); } catch(e){ return null; }
2540
+ }
2541
+
2542
+ // Saves/restores scrollTop of the given element ids around a re-render —
2543
+ // both `.pane-body` scroll containers keep their offset across a poll-
2544
+ // driven rebuild instead of snapping back to the top.
2545
+ function withScrollPreserved(ids, fn){
2546
+ var saved = ids.map(function(id){
2547
+ var el = document.getElementById(id);
2548
+ return el ? el.scrollTop : null;
2549
+ });
2550
+ fn();
2551
+ ids.forEach(function(id, i){
2552
+ if(saved[i] === null) return;
2553
+ var el = document.getElementById(id);
2554
+ if(el) el.scrollTop = saved[i];
2555
+ });
2556
+ }
2557
+
2558
+ function refreshRunsList(force){
2559
+ var now = Date.now();
2560
+ if(!force && (now - lastRunsListRefreshAt) < RUNS_LIST_POLL_MS) return Promise.resolve();
2561
+ lastRunsListRefreshAt = now;
2562
+ return getJSON("/api/runs").then(function(result){
2563
+ pollFailStreak = 0;
2564
+ var runs = result.runs || [];
2565
+ var next = sig(runs);
2566
+ if(next === lastRunsSig) return; // unchanged payload — never rebuild the DOM for nothing
2567
+ lastRunsSig = next;
2568
+ withScrollPreserved(["left-pane-body"], function(){
2569
+ // setItems re-runs the SAME filter/search state and view mode
2570
+ // (runsFilterFn/runsApplyFn close over runsViewMode and the filter
2571
+ // bar's own state) — a poll tick never resets either.
2572
+ runsFilterBar.setItems(runs);
2573
+ });
2574
+ }).catch(function(){
2575
+ pollFailStreak++;
2576
+ // Keep the last good render; a transient failure is invisible. Only a
2577
+ // PERSISTENT failure (3+ ticks, ~6s) gets a quiet, non-alarming note —
2578
+ // the page itself is never wiped.
2579
+ if(pollFailStreak === 3){
2580
+ var countEl = document.getElementById("runs-filter-count");
2581
+ if(countEl) countEl.textContent = "runs list: fetch failing, showing last known data";
2582
+ }
2583
+ });
1865
2584
  }
1866
2585
 
2586
+ function refreshOpenRun(){
2587
+ if(!currentRunid || !runIsLive) return Promise.resolve();
2588
+ var runid = currentRunid;
2589
+ var myToken = selectToken;
2590
+ return getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
2591
+ // stale guard: a different run was selected (or re-selected) while
2592
+ // this request was in flight — never let it overwrite what's open now.
2593
+ if(myToken !== selectToken || runid !== currentRunid) return;
2594
+ var next = sig(detail);
2595
+ var changed = next !== lastRunDetailSig;
2596
+ lastRunDetailSig = next;
2597
+ runIsLive = detail.glyph === "▶" && !detail.died;
2598
+ if(changed){
2599
+ withScrollPreserved(["right-pane-body"], function(){ renderRun(detail); });
2600
+ }
2601
+ }).catch(function(){ /* keep last good render; retry next tick */ });
2602
+ }
2603
+
2604
+ function pollTick(forceList){
2605
+ if(document.hidden) return;
2606
+ refreshRunsList(forceList);
2607
+ var wasLive = runIsLive;
2608
+ return refreshOpenRun().then(function(){
2609
+ // the open run just stopped being live (real verdict or died) —
2610
+ // don't make the left-list glyph wait up to RUNS_LIST_POLL_MS to
2611
+ // catch up; force one immediate refresh.
2612
+ if(wasLive && !runIsLive) refreshRunsList(true);
2613
+ });
2614
+ }
2615
+
2616
+ function startPolling(){
2617
+ if(pollTimer) return;
2618
+ pollTimer = setInterval(pollTick, POLL_MS);
2619
+ }
2620
+ // Pause while the tab is hidden (cheap, standard); resume — with an
2621
+ // immediate catch-up tick, forcing BOTH fetches regardless of the list's
2622
+ // 10s throttle — on return, rather than waiting a full 2s (or up to 10s
2623
+ // for the list).
2624
+ document.addEventListener("visibilitychange", function(){
2625
+ if(!document.hidden) pollTick(true);
2626
+ });
2627
+
1867
2628
  getJSON("/api/runs").then(function(result){
1868
2629
  var runs = result.runs || [];
2630
+ lastRunsSig = sig(runs);
1869
2631
  runsFilterBar.setItems(runs);
1870
2632
  if(runs.length && !runs[0].fileMissing){
1871
- document.getElementById("tab-runs").click();
1872
- // newest run overall, opened directly — the Workflows view starts
1873
- // collapsed, but its job row still renders "selected" (build item,
1874
- // 2026-09-26: a job row shows selected state like a child row) since
1875
- // `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is set.
2633
+ // build item 5 — the LEFT pane opens on Chat by default; this no
2634
+ // longer force-clicks "tab-runs" (it used to switch the left pane
2635
+ // away from Chat on every load that had a run). The RIGHT pane still
2636
+ // loads the newest run's details so they're ready the moment a
2637
+ // person switches to the Runs tab themselves — the Workflows view
2638
+ // starts collapsed, but its job row still renders "selected" (build
2639
+ // item, 2026-09-26: a job row shows selected state like a child row)
2640
+ // since `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is
2641
+ // set.
1876
2642
  selectRun(runs[0].runid, null, ".hist-row");
1877
2643
  document.getElementById("tab-run").click();
1878
2644
  runsFilterBar.refresh();
1879
2645
  }
2646
+ startPolling();
1880
2647
  }).catch(function(e){
1881
2648
  document.getElementById("wf-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
1882
2649
  document.getElementById("history-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
2650
+ startPolling(); // still start — a transient initial failure shouldn't block all future polling
1883
2651
  });
1884
2652
 
1885
2653
  // theme toggle
@@ -1904,6 +2672,407 @@
1904
2672
  });
1905
2673
  })();
1906
2674
 
2675
+ // ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
2676
+ (function(){
2677
+ var newBtn = document.getElementById("chat-new-btn");
2678
+ var card = document.getElementById("job-card");
2679
+ var thread = document.getElementById("chat-thread");
2680
+ var msgInput = document.getElementById("chat-msg");
2681
+ var sendBtn = document.getElementById("chat-send-btn");
2682
+ var signBtn = document.getElementById("chat-sign-btn");
2683
+ var reviseBtn = document.getElementById("chat-revise-btn");
2684
+ var startBtn = document.getElementById("chat-start-btn");
2685
+ var checkDepsBtn = document.getElementById("chat-check-deps-btn");
2686
+ var errEl = document.getElementById("chat-card-error");
2687
+ var actionErrEl = document.getElementById("chat-action-error");
2688
+ var hashLine = document.getElementById("chat-hash-line");
2689
+ var moneyCapInput = document.getElementById("jf-cap-money");
2690
+ var modelSelect = document.getElementById("jf-model");
2691
+ var modelStatusEl = document.getElementById("jf-model-status");
2692
+ var progressRow = document.getElementById("chat-progress-row");
2693
+ var progressGlyph = document.getElementById("chat-progress-glyph");
2694
+ var progressLabelEl = document.getElementById("chat-progress-label");
2695
+
2696
+ var sessionId = null;
2697
+ var pollTimer = null;
2698
+ var lastPhase = null;
2699
+ var signClickedOnce = false;
2700
+ // build item 4 — Start disables the moment it's clicked and stays
2701
+ // disabled for the whole life of a non-terminal session (the server's
2702
+ // own one-at-a-time rule, `TERMINAL_PHASES` in
2703
+ // src/panel/authorroutes.js, mirrored here so Start never even offers a
2704
+ // click the server would 409 anyway).
2705
+ var CLIENT_TERMINAL_PHASES = ["refused", "abandoned", "error", "signed", "signing-failed"];
2706
+ var DONE_PHASES = ["prepared", "signed"];
2707
+ var sessionLive = false;
2708
+ // build item 7 — Start also stays disabled while the chosen model's key
2709
+ // isn't usable; updated by refreshModelStatus() below.
2710
+ var keyOk = false;
2711
+
2712
+ newBtn.addEventListener("click", function(){
2713
+ card.hidden = false;
2714
+ newBtn.hidden = true;
2715
+ thread.innerHTML = "";
2716
+ errEl.textContent = "";
2717
+ actionErrEl.textContent = "";
2718
+ sessionId = null;
2719
+ sessionLive = false;
2720
+ signClickedOnce = false;
2721
+ if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
2722
+ stopProgressDots();
2723
+ progressRow.hidden = true;
2724
+ checkDepsBtn.hidden = true;
2725
+ refreshStartEnabled();
2726
+ });
2727
+
2728
+ function currentCard(){
2729
+ var moneyCap = Number(document.getElementById("jf-cap-money").value);
2730
+ var timeCapMin = document.getElementById("jf-cap-time").value.trim();
2731
+ return {
2732
+ checkType: document.querySelector('input[name="jf-verdict"]:checked').value === "rubric" ? "rubric" : "deterministic",
2733
+ model: document.getElementById("jf-model").value,
2734
+ jobName: document.getElementById("jf-name").value.trim(),
2735
+ goal: document.getElementById("jf-goal").value.trim(),
2736
+ source: document.getElementById("jf-source").value.trim(),
2737
+ destination: document.getElementById("jf-dest").value.trim(),
2738
+ success: document.getElementById("jf-success").value.trim(),
2739
+ guardrails: document.getElementById("jf-guardrails").value.trim(),
2740
+ judgeExamples: document.getElementById("jf-judge").value.trim(),
2741
+ capUsd: moneyCap,
2742
+ maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
2743
+ };
2744
+ }
2745
+
2746
+ // hamr's ruling 2026-09-28 ("one cap covers drafting + run") — the
2747
+ // separate Drafting cap field is GONE; drafting runs under the same
2748
+ // `Cap $` field every run does. Start stays disabled until Cap $ parses
2749
+ // as a positive number, checked client-side as a UX convenience only;
2750
+ // the server (`validateJobCard`) is the real gate. Also disabled while a
2751
+ // session is already live (build item 4) or the chosen model's key
2752
+ // isn't usable (build item 7).
2753
+ function refreshStartEnabled(){
2754
+ var v = Number(moneyCapInput.value);
2755
+ var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
2756
+ startBtn.disabled = !capOk || sessionLive || !keyOk;
2757
+ }
2758
+ moneyCapInput.addEventListener("input", refreshStartEnabled);
2759
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
2760
+ r.addEventListener("change", function(){
2761
+ var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
2762
+ document.getElementById("jf-judge").disabled = !isRubric;
2763
+ });
2764
+ });
2765
+
2766
+ // build item 7 / build item 5 (2026-09-28) — key + reachability, $0,
2767
+ // shown right under Model, on load and whenever the selection changes.
2768
+ // Start stays disabled while the key isn't usable; a reachability
2769
+ // failure shows red but never blocks (an endpoint can be flaky).
2770
+ // fix (2026-09-28): a 401/403 from the reachability probe is not
2771
+ // flakiness — it means the key itself was REJECTED by the provider.
2772
+ // Neither blocks Start (unchanged rule); only the WORDING differs.
2773
+ // Every other status (timeout, network error, 5xx, 429,
2774
+ // unsupported-provider) keeps the original "may be flaky" wording.
2775
+ function reachStatusText(status){
2776
+ var rejectedMatch = /^HTTP (401|403)$/.exec(status);
2777
+ return rejectedMatch
2778
+ ? "[✗] key rejected (HTTP " + rejectedMatch[1] + ")"
2779
+ : "[✗] " + status + " — may be flaky, not blocking";
2780
+ }
2781
+ // build item 5 — ONE line, not two: pure decision from the model-check
2782
+ // response (`r`) + the selected model's display id, so the whole
2783
+ // key/reachability read can be unit-tested without any DOM at all.
2784
+ // All good: "[✓] <ENV_KEY> found · <modelId> reachable". Otherwise: one
2785
+ // red line with the FIRST problem — a missing/bad key short-circuits
2786
+ // before reachability is even mentioned (reachability was never
2787
+ // checked in that case anyway — see the server route).
2788
+ // `keyOk` (Start-gating) and `ok` (display color) are DIFFERENT axes: a
2789
+ // reachability failure shows red (`ok:false`) but never blocks Start
2790
+ // (`keyOk` stays true) — an endpoint can be flaky; a missing/bad KEY
2791
+ // blocks Start and always shows red.
2792
+ function modelStatusLine(r, modelId){
2793
+ if(!r || !r.ok) return { keyOk: false, ok: false, text: "[✗] could not check the key" };
2794
+ var keyFound = r.keyStatus === "found";
2795
+ if(!keyFound){
2796
+ var problem = r.keyStatus === "missing" ? " not set" : (" " + r.keyProblem);
2797
+ return { keyOk: false, ok: false, text: "[✗] " + r.envKey + problem };
2798
+ }
2799
+ var reach = r.reachability;
2800
+ if(!reach || !reach.checked) return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found" };
2801
+ if(reach.reachable){
2802
+ var suffix = reach.modelListed === false ? " (model not listed)" : "";
2803
+ return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found · " + modelId + " reachable" + suffix };
2804
+ }
2805
+ return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
2806
+ }
2807
+ function refreshModelStatus(){
2808
+ var modelId = modelSelect.value;
2809
+ authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
2810
+ var line = modelStatusLine(r, modelId);
2811
+ keyOk = line.keyOk;
2812
+ modelStatusEl.className = "jf-readiness " + (line.ok ? "ok" : "bad");
2813
+ modelStatusEl.textContent = line.text;
2814
+ refreshStartEnabled();
2815
+ }).catch(function(){
2816
+ keyOk = false;
2817
+ modelStatusEl.className = "jf-readiness bad";
2818
+ modelStatusEl.textContent = "[✗] could not check the key";
2819
+ refreshStartEnabled();
2820
+ });
2821
+ }
2822
+ modelSelect.addEventListener("change", refreshModelStatus);
2823
+ refreshModelStatus();
2824
+
2825
+ // build item 2 — one plain line per event, no "(progress)" jargon
2826
+ // suffix: `onPhase` no longer posts its own bubble (see
2827
+ // src/panel/authorsession.js), so every remaining message is already
2828
+ // something a person needs to read.
2829
+ // build item 4 — a system line (a step notice: "Copying source ($0)",
2830
+ // "Packages found", …) carries no who-label at all — it is not a
2831
+ // message FROM anyone, just a plain step line. Only "you" and the
2832
+ // model's own bot replies get one.
2833
+ function chatWhoLabel(role){
2834
+ if(role === "you") return "you";
2835
+ if(role === "system") return "";
2836
+ return "bareloop";
2837
+ }
2838
+ function renderMessages(state){
2839
+ thread.innerHTML = state.messages.map(function(m){
2840
+ var cls = m.role === "you" ? "you" : (m.role === "system" ? "system" : "bot");
2841
+ var who = chatWhoLabel(m.role);
2842
+ var whoHtml = who ? ('<div class="who">' + escapeXml(who) + '</div>') : "";
2843
+ return '<div class="msg ' + cls + '">' + whoHtml + escapeXml(m.text) + '</div>';
2844
+ }).join("");
2845
+ thread.scrollTop = thread.scrollHeight;
2846
+ }
2847
+
2848
+ // build item 3 — the single live progress line: the LABEL sits first in
2849
+ // the markup, then the animated indicator (never the other way — the
2850
+ // dots must not push the label around as they cycle 1..4).
2851
+ var progressDotTimer = null;
2852
+ var progressDotCount = 1;
2853
+ var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
2854
+ function stopProgressDots(){
2855
+ if(progressDotTimer){ clearInterval(progressDotTimer); progressDotTimer = null; }
2856
+ }
2857
+ function startProgressDots(){
2858
+ if(reducedMotion){ progressGlyph.textContent = "[progress....]"; return; }
2859
+ if(progressDotTimer) return;
2860
+ progressDotCount = 1;
2861
+ progressGlyph.textContent = "[progress.]";
2862
+ progressDotTimer = setInterval(function(){
2863
+ progressDotCount = (progressDotCount % 4) + 1;
2864
+ progressGlyph.textContent = "[progress" + new Array(progressDotCount + 1).join(".") + "]";
2865
+ }, 500);
2866
+ }
2867
+ // build item 3 — pure: what the label must say for a given state. A
2868
+ // person waiting on their OWN next action (an ask is pending) must never
2869
+ // see a stale machine step name (observed: "checking prior behaviour"
2870
+ // shown while waiting on hamr; "plan confirmed" shown while the plan was
2871
+ // awaiting his confirm) — a pending ask always wins over progressLabel.
2872
+ function progressLabelFor(state){
2873
+ var pending = state && state.pendingAsk;
2874
+ if(pending && pending.kind === "menu") return "waiting for your OK";
2875
+ if(pending && pending.kind === "install-needed") return "install needed";
2876
+ if(pending) return "waiting for your answer";
2877
+ return (state && state.progressLabel) || "working";
2878
+ }
2879
+ function renderProgress(state){
2880
+ progressRow.hidden = false;
2881
+ if(DONE_PHASES.indexOf(state.phase) !== -1){
2882
+ stopProgressDots();
2883
+ progressGlyph.className = "chat-progress-glyph done";
2884
+ progressGlyph.textContent = "[done]";
2885
+ progressLabelEl.textContent = "";
2886
+ } else if(CLIENT_TERMINAL_PHASES.indexOf(state.phase) !== -1){
2887
+ stopProgressDots();
2888
+ progressGlyph.className = "chat-progress-glyph stopped";
2889
+ progressGlyph.textContent = "[stopped]";
2890
+ progressLabelEl.textContent = state.error || "";
2891
+ } else {
2892
+ progressGlyph.className = "chat-progress-glyph";
2893
+ progressLabelEl.textContent = progressLabelFor(state);
2894
+ startProgressDots();
2895
+ }
2896
+ }
2897
+
2898
+ function renderActions(state){
2899
+ var pending = state.pendingAsk;
2900
+ var atMenu = !!pending && pending.kind === "menu";
2901
+ var atDepsWait = !!pending && pending.kind === "install-needed";
2902
+ sendBtn.disabled = !pending || atMenu || atDepsWait;
2903
+ reviseBtn.disabled = !atMenu || state.revisesLeft <= 0;
2904
+ reviseBtn.textContent = "Revise (" + state.revisesLeft + " left)";
2905
+ checkDepsBtn.hidden = !atDepsWait;
2906
+ checkDepsBtn.disabled = !atDepsWait;
2907
+ if(state.phase === "prepared" && !signClickedOnce){
2908
+ signBtn.disabled = false;
2909
+ signBtn.textContent = "Sign & run";
2910
+ } else if(state.phase === "prepared" && signClickedOnce){
2911
+ signBtn.disabled = false;
2912
+ signBtn.textContent = "Sign " + (state.specHash || "").slice(0, 8) + " & run";
2913
+ } else {
2914
+ signBtn.disabled = !atMenu;
2915
+ signBtn.textContent = "Sign & run";
2916
+ }
2917
+ if(state.specHash){
2918
+ hashLine.hidden = false;
2919
+ hashLine.textContent = "spec hash: " + state.specHash;
2920
+ }
2921
+ if(state.phase === "refused" || state.phase === "error" || state.phase === "abandoned"){
2922
+ errEl.textContent = state.error || ("stopped: " + state.phase);
2923
+ } else {
2924
+ errEl.textContent = "";
2925
+ }
2926
+ renderProgress(state);
2927
+ // build item 4 — re-enable Start only once the session has settled
2928
+ // into a terminal phase (a fresh Start becomes possible again).
2929
+ sessionLive = CLIENT_TERMINAL_PHASES.indexOf(state.phase) === -1;
2930
+ refreshStartEnabled();
2931
+ }
2932
+
2933
+ function poll(){
2934
+ if(!sessionId) return;
2935
+ authorGet("/api/author/" + sessionId).then(function(j){
2936
+ if(!j || !j.state) return;
2937
+ renderMessages(j.state);
2938
+ renderActions(j.state);
2939
+ if(j.state.phase === "signed"){
2940
+ clearInterval(pollTimer);
2941
+ document.getElementById("tab-runs").click();
2942
+ }
2943
+ lastPhase = j.state.phase;
2944
+ }).catch(function(){ /* transient — next poll tries again */ });
2945
+ }
2946
+
2947
+ startBtn.addEventListener("click", function(){
2948
+ errEl.textContent = "";
2949
+ actionErrEl.textContent = "";
2950
+ // build item 4 — disable the moment it's clicked, not just once the
2951
+ // response comes back; re-enabled below only if the start is refused.
2952
+ sessionLive = true;
2953
+ refreshStartEnabled();
2954
+ var body = currentCard();
2955
+ authorPost("/api/author/start", body).then(function(r){
2956
+ if(r.status !== 200 || !r.body.ok){
2957
+ errEl.textContent = (r.body && r.body.error) || "refused";
2958
+ sessionLive = false;
2959
+ refreshStartEnabled();
2960
+ return;
2961
+ }
2962
+ sessionId = r.body.sessionId;
2963
+ signClickedOnce = false;
2964
+ renderMessages(r.body.state);
2965
+ renderActions(r.body.state);
2966
+ if(pollTimer) clearInterval(pollTimer);
2967
+ pollTimer = setInterval(poll, 2000);
2968
+ });
2969
+ });
2970
+
2971
+ // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
2972
+ // (fed by the every-2s `poll()`) unconditionally overwrites
2973
+ // `#chat-card-error` on every tick (`errEl.textContent = state.error ||
2974
+ // ""`), including the SAME tick the send/revise/sign handlers below
2975
+ // used to call `poll()` in their own `.then` — so a refused/failed POST's
2976
+ // error was wiped before a person could read it (often on the very next
2977
+ // line of the same callback). Compounding it, `send`/`revise` cleared
2978
+ // `msgInput.value` unconditionally, even on failure — so a rejected
2979
+ // answer vanished from the box with no trace at all. Fixed by: (1) a
2980
+ // dedicated `#chat-action-error` element only these handlers touch,
2981
+ // never `poll()`/`renderActions`; (2) only clearing the input on a
2982
+ // confirmed 200+ok response; (3) an explicit `.catch` so a rejected
2983
+ // fetch (network drop, server restart) shows an error too, instead of
2984
+ // silently doing nothing.
2985
+ // build item 1 — the ONE place that decides "did this chat POST
2986
+ // succeed" — pure, no DOM, unit-tested directly
2987
+ // (tests/panel-page.test.js). Before this existed, each handler decided
2988
+ // ad hoc via `r.body && r.body.error && !r.body.ok`, which is true only
2989
+ // when the server bothered to hand back BOTH `ok:false` AND a non-empty
2990
+ // `error` string — a malformed/short-circuited response (e.g. a 403 the
2991
+ // guard returns with `{ok:false, error:'refused — ...'}` reaching a
2992
+ // caller that never checked `r.status`, or any response missing
2993
+ // `.error`) read as neither branch and was silently treated as fine.
2994
+ function chatPostOutcome(r){
2995
+ var ok = !!(r && r.status === 200 && r.body && r.body.ok === true);
2996
+ return { ok: ok, error: ok ? "" : ((r && r.body && r.body.error) || "the request failed — nothing was sent") };
2997
+ }
2998
+ function chatActionFailed(message){
2999
+ actionErrEl.textContent = message || "the request failed — nothing was sent";
3000
+ }
3001
+ function chatActionOk(){
3002
+ actionErrEl.textContent = "";
3003
+ }
3004
+
3005
+ checkDepsBtn.addEventListener("click", function(){
3006
+ if(!sessionId) return;
3007
+ checkDepsBtn.disabled = true;
3008
+ authorPost("/api/author/" + sessionId + "/check-deps", {}).then(function(r){
3009
+ var o = chatPostOutcome(r);
3010
+ if(o.ok) chatActionOk(); else chatActionFailed(o.error);
3011
+ poll();
3012
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, nothing was sent"); });
3013
+ });
3014
+
3015
+ sendBtn.addEventListener("click", function(){
3016
+ if(!sessionId) return;
3017
+ var text = msgInput.value;
3018
+ if(!text.trim()) return;
3019
+ authorPost("/api/author/" + sessionId + "/send", {text: text}).then(function(r){
3020
+ var o = chatPostOutcome(r);
3021
+ if(o.ok){
3022
+ chatActionOk();
3023
+ msgInput.value = "";
3024
+ } else {
3025
+ // failure — the text stays in the box so nothing is lost; the
3026
+ // person can just click Send again once the error is fixed.
3027
+ chatActionFailed(o.error);
3028
+ }
3029
+ poll();
3030
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3031
+ });
3032
+
3033
+ reviseBtn.addEventListener("click", function(){
3034
+ if(!sessionId) return;
3035
+ var text = msgInput.value;
3036
+ authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
3037
+ var o = chatPostOutcome(r);
3038
+ if(o.ok){
3039
+ chatActionOk();
3040
+ msgInput.value = "";
3041
+ } else {
3042
+ chatActionFailed(o.error);
3043
+ }
3044
+ poll();
3045
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3046
+ });
3047
+
3048
+ signBtn.addEventListener("click", function(){
3049
+ if(!sessionId) return;
3050
+ if(!signClickedOnce){
3051
+ // click 1 — the confirm turn's own "confirm" pick. Never signs.
3052
+ authorPost("/api/author/" + sessionId + "/sign-prepare", {}).then(function(r){
3053
+ var o = chatPostOutcome(r);
3054
+ if(o.ok) chatActionOk(); else chatActionFailed(o.error);
3055
+ signClickedOnce = true;
3056
+ poll();
3057
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server"); });
3058
+ return;
3059
+ }
3060
+ // click 2 — the ONLY route that signs. Carries the hash shown on the
3061
+ // card; the server re-checks it against signing.json regardless.
3062
+ authorGet("/api/author/" + sessionId).then(function(j){
3063
+ var hash = j.state && j.state.specHash;
3064
+ if(!hash) return;
3065
+ authorPost("/api/author/" + sessionId + "/sign", {specHash: hash}).then(function(r){
3066
+ var o = chatPostOutcome(r);
3067
+ if(o.ok){ chatActionOk(); } else { chatActionFailed(o.error); return; }
3068
+ poll();
3069
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not signed"); });
3070
+ });
3071
+ });
3072
+
3073
+ refreshStartEnabled();
3074
+ })();
3075
+
1907
3076
  })();
1908
3077
  </script>
1909
3078
  </body>