bareloop 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/bareloop.context.md +205 -10
  3. package/bin/bareloop.mjs +0 -1
  4. package/package.json +1 -1
  5. package/src/authorflow.js +22 -42
  6. package/src/authorjob.js +5 -8
  7. package/src/authorrun.js +42 -16
  8. package/src/cli.js +12 -6
  9. package/src/config.js +135 -0
  10. package/src/interviewrun.js +10 -2
  11. package/src/keysfile.js +156 -0
  12. package/src/ledger.js +26 -0
  13. package/src/monthly.js +352 -0
  14. package/src/panel/authorroutes.js +328 -0
  15. package/src/panel/authorsession.js +555 -0
  16. package/src/panel/index.html +1247 -50
  17. package/src/panel/server.js +449 -51
  18. package/src/panel/settingsroutes.js +230 -0
  19. package/src/planrun.js +61 -5
  20. package/src/promptregisters.js +3 -0
  21. package/src/providerrows.js +217 -0
  22. package/src/providers.js +84 -0
  23. package/src/replay.js +80 -2
  24. package/src/replayio.js +14 -3
  25. package/src/run.js +48 -2
  26. package/src/runlist.js +13 -0
  27. package/src/userrun.js +134 -13
  28. package/types/authorflow.d.ts +4 -21
  29. package/types/authorrun.d.ts +5 -0
  30. package/types/cli.d.ts +2 -1
  31. package/types/config.d.ts +48 -0
  32. package/types/interviewrun.d.ts +5 -0
  33. package/types/keysfile.d.ts +90 -0
  34. package/types/ledger.d.ts +14 -0
  35. package/types/monthly.d.ts +192 -0
  36. package/types/panel/authorroutes.d.ts +67 -0
  37. package/types/panel/authorsession.d.ts +53 -0
  38. package/types/panel/server.d.ts +42 -5
  39. package/types/panel/settingsroutes.d.ts +14 -0
  40. package/types/providerrows.d.ts +164 -0
  41. package/types/providers.d.ts +34 -0
  42. package/types/replay.d.ts +31 -0
  43. package/types/replayio.d.ts +12 -1
  44. package/types/run.d.ts +26 -1
  45. package/types/runlist.d.ts +1 -0
  46. package/types/userrun.d.ts +22 -0
@@ -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,6 +137,18 @@
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);}
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);}
135
152
  /* Panel P2 defect 1 (hamr-watched run mujjtrvd, 2026-09-27): the [▶] glyph
136
153
  itself pulses wherever a LIVE run shows it (runs list rows, the
137
154
  Workflows job row when its represented run is live, the run header) —
@@ -165,6 +182,8 @@
165
182
  .badge.cyan{color:var(--cyan);}
166
183
 
167
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;}
168
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;}
169
188
  .btn::before{content:"[ ";color:var(--text-faint);}
170
189
  .btn::after{content:" ]";color:var(--text-faint);}
@@ -243,6 +262,7 @@
243
262
  .attempt-row{margin-bottom:6px;}
244
263
  .attempt-toggle{margin-bottom:4px;}
245
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);}
246
266
  /* item 1 (2026-09-26): a round's own model call renders as this table's
247
267
  header ROW (visually distinct from its tool-call rows below it). */
248
268
  tr.round-header-row td{background:var(--panel2);font-weight:700;}
@@ -290,14 +310,96 @@
290
310
  .job-card h4::before{content:"┤ ";color:var(--text-faint);}
291
311
  .job-card h4::after{content:" ├";color:var(--text-faint);}
292
312
  .field{margin-bottom:10px;}
313
+ .field input,.field select{width:100%;}
293
314
  label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;text-transform:uppercase;letter-spacing:0.04em;}
294
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);}
295
316
  .ro-value.scroll{max-height:160px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;}
296
317
  .field-row{display:flex;flex-wrap:wrap;gap:10px;}
297
318
  .field-row .field{flex:1 1 140px;margin-bottom:10px;}
298
319
 
299
- .chat-thread{padding:20px 8px;color:var(--text-faint);font-size:13px;}
300
-
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:minmax(0,1fr) minmax(0,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
+ .btn.primary{background:var(--primary-bg);color:var(--primary-text);border-color:var(--primary-border);font-weight:700;}
337
+ .btn.primary::before,.btn.primary::after{color:var(--primary-text);opacity:0.6;}
338
+ .btn:disabled{opacity:0.5;cursor:not-allowed;}
339
+ /* a disabled .btn.primary must read as disabled, not just a dimmed version
340
+ of the enabled primary fill — .btn.primary's own background/color
341
+ (declared above, equal specificity) would otherwise win over the plain
342
+ .btn:disabled rule and still look clickable at 50% opacity. */
343
+ .btn.primary:disabled{background:var(--btn-bg);color:var(--text-faint);border-color:var(--border);}
344
+ .btn.primary:disabled::before,.btn.primary:disabled::after{color:var(--text-faint);opacity:0.4;}
345
+
346
+ /* P3 build item 3 — the single live progress line: an amber cycling
347
+ [progress.]/[progress..]/[progress...] glyph while a phase is running,
348
+ [done] in green once it finishes, [stopped] in red on a refusal. A
349
+ static [progress...] under prefers-reduced-motion (no animation). */
350
+ .chat-progress-row{display:flex;align-items:center;gap:8px;margin:2px 0 10px;font-size:12px;}
351
+ .chat-progress-glyph{font-weight:700;color:var(--amber);}
352
+ .chat-progress-glyph.done{color:var(--green);}
353
+ .chat-progress-glyph.stopped{color:var(--red);}
354
+ .chat-progress-label{color:var(--text-faint);}
355
+ /* build item 7 — the two $0 readiness lines under the Model field. */
356
+ .jf-readiness{font-size:11px;margin-top:2px;}
357
+ .jf-readiness.ok{color:var(--green);}
358
+ .jf-readiness.bad{color:var(--red);}
359
+ .jf-readiness.unknown{color:var(--text-faint);}
360
+
361
+ .chat-thread{display:flex;flex-direction:column;gap:10px;margin-bottom:10px;}
362
+ .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;}
363
+ .msg.bot{border-left:3px solid var(--border-strong);}
364
+ .msg.bot .who::before{content:"$ ";}
365
+ .msg.you{background:var(--bg);margin-left:20px;border-left:3px solid var(--accent);}
366
+ .msg.you .who::before{content:"> ";}
367
+ .msg.system{color:var(--text-faint);border-left:3px dotted var(--text-faint);font-size:12px;}
368
+ .msg .who{font-size:11px;color:var(--text-faint);margin-bottom:3px;text-transform:none;font-weight:400;}
369
+ .chat-input-row{margin-top:10px;border-top:1px dotted var(--border-strong);padding-top:10px;}
370
+ .chat-input-row input{width:100%;box-sizing:border-box;}
371
+ /* chat message box reads as an unmistakable text box: ~2x the default
372
+ input height, thicker 2px border (ported verbatim from the mockup). */
373
+ #chat-msg{min-height:64px;padding:8px 12px;border:2px solid var(--border-strong);}
374
+ .actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
375
+ .actions-row > .btn{flex:1 1 auto;}
376
+
377
+ /* Settings view (P4a) — same tokens as the rest of the page; the mockup's settings styles */
378
+ .settings-view{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
379
+ .settings-header{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);}
380
+ .settings-header h2{margin:0;font-size:15px;display:inline;}
381
+ .settings-header h2::before{content:"┤ ";color:var(--text-faint);}
382
+ .settings-header h2::after{content:" ├";color:var(--text-faint);}
383
+ .money-strip{display:grid;grid-template-columns:repeat(3, 1fr);gap:12px;align-items:end;padding:10px;border:1px solid var(--border-strong);background:var(--panel2);}
384
+ .money-item{min-width:0;}
385
+ .money-item label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;}
386
+ .money-value{font-size:16px;font-weight:700;padding:6px 0;overflow-wrap:anywhere;}
387
+ .money-item input{width:100%;height:44px;box-sizing:border-box;}
388
+ .keyfile-strip{position:relative;border:1px solid var(--border-strong);padding:12px 12px 8px;background:var(--panel2);margin:14px 0;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
389
+ .keyfile-strip::before{content:"┤ KEYS ├";position:absolute;top:-9px;left:10px;background:var(--panel2);padding:0 4px;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;}
390
+ .keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:220px;}
391
+ .keyfile-strip-actions{display:flex;gap:6px;flex:none;}
392
+ .keyfile-strip code{background:var(--bg);border:1px solid var(--border-strong);padding:1px 5px;font-size:11px;color:var(--amber);overflow-wrap:anywhere;}
393
+ .pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
394
+ .pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
395
+ .pv-table .hint{display:block;margin-top:3px;}
396
+ /* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
397
+ .pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
398
+ .pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
399
+ .table-wrap{overflow-x:auto;border:1px solid var(--border-strong);background:var(--panel2);max-width:100%;}
400
+ @media (max-width: 899px){
401
+ .money-strip{grid-template-columns:1fr 1fr;}
402
+ }
301
403
  @media (max-width: 899px){
302
404
  .main{flex-direction:column;}
303
405
  .left-pane{width:100%;border-right:none;border-bottom:1px solid var(--border-strong);}
@@ -323,6 +425,7 @@
323
425
  </div>
324
426
  <div class="topbar-right">
325
427
  <span class="hint" id="host-line"></span>
428
+ <button class="btn small" type="button" id="btn-settings" data-testid="btn-settings">&#9881; Settings</button>
326
429
  <button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light/dark theme">&#9789;</button>
327
430
  </div>
328
431
  </header>
@@ -338,12 +441,100 @@
338
441
  <div class="pane-body" id="left-pane-body">
339
442
 
340
443
  <section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
341
- <div class="chat-thread">
342
- Authoring a new job from this page comes later (PANEL-BUILD.md P3). This build
343
- (P1) is read-only: it shows runs already started from the command line
344
- (<code>bareloop run</code> / <code>bareloop run-u</code>). Use the Runs tab to
345
- look at one.
444
+ <button class="btn" type="button" id="chat-new-btn" data-testid="chat-new">+ New</button>
445
+
446
+ <div class="job-card compact" id="job-card" data-testid="job-card" hidden>
447
+ <h4 id="job-card-title">Job card — draft</h4>
448
+
449
+ <div class="field">
450
+ <label id="jf-verdict-label">Check type</label>
451
+ <div class="radio-row" role="radiogroup" aria-labelledby="jf-verdict-label">
452
+ <label><input type="radio" name="jf-verdict" value="deterministic" checked> Deterministic</label>
453
+ <label><input type="radio" name="jf-verdict" value="rubric"> Rubric</label>
454
+ </div>
455
+ </div>
456
+ <div class="field">
457
+ <label for="jf-model">Model</label>
458
+ <select id="jf-model" data-testid="jf-model"></select>
459
+ <!-- build item 5 (2026-09-28) — ONE line under Model, not two:
460
+ key + reachability collapsed into a single readiness read. -->
461
+ <div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
462
+ </div>
463
+ <div class="field">
464
+ <label for="jf-name">Job name</label>
465
+ <input id="jf-name" type="text" placeholder="kebab-case, unique">
466
+ </div>
467
+ <div class="field">
468
+ <label for="jf-goal">Goal</label>
469
+ <input class="expand-field" id="jf-goal" type="text">
470
+ </div>
471
+ <div class="field">
472
+ <label for="jf-source">Source</label>
473
+ <input id="jf-source" type="text" placeholder="absolute path to a local repo">
474
+ </div>
475
+ <div class="field">
476
+ <label for="jf-dest">Destination</label>
477
+ <input id="jf-dest" type="text" placeholder="the write fence — where in the repo this run may change files">
478
+ </div>
479
+ <div class="field">
480
+ <label for="jf-success">Success</label>
481
+ <input class="expand-field" id="jf-success" type="text">
482
+ </div>
483
+ <div class="field">
484
+ <label for="jf-guardrails">Guardrails</label>
485
+ <input id="jf-guardrails" type="text">
486
+ </div>
487
+ <div class="field">
488
+ <label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
489
+ <input class="expand-field" id="jf-judge" type="text" disabled>
490
+ </div>
491
+
492
+ <div class="cap-row" data-testid="cap-row">
493
+ <div class="cap-field">
494
+ <label for="jf-cap-money">Cap $</label>
495
+ <input id="jf-cap-money" type="text">
496
+ <div class="hint" id="jf-cap-note" data-testid="cap-note"></div>
497
+ </div>
498
+ <div class="cap-field">
499
+ <label for="jf-cap-time">Time cap (min)</label>
500
+ <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
501
+ </div>
502
+ </div>
503
+ <button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
504
+ <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
505
+ <div class="hint" id="chat-hash-line" data-testid="chat-hash-line" hidden></div>
506
+ </div>
507
+
508
+ <!-- build item 3 — the label comes FIRST, then the animated
509
+ indicator, so the cycling dots ([progress.] .. [progress....])
510
+ never push the label around. -->
511
+ <div class="chat-progress-row" id="chat-progress-row" data-testid="chat-progress-row" hidden>
512
+ <span class="chat-progress-label" id="chat-progress-label" data-testid="chat-progress-label"></span>
513
+ <span class="chat-progress-glyph" id="chat-progress-glyph" data-testid="chat-progress-glyph"></span>
514
+ </div>
515
+
516
+ <div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
517
+
518
+ <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
519
+ sign or check-deps POST shows here and STAYS shown: it is set
520
+ only by this tab's own action handlers below and is never
521
+ touched by renderActions()/poll() (which own #chat-card-error
522
+ instead), so it can never be wiped by the next 2s poll tick
523
+ before a person reads it. -->
524
+ <div class="hint" id="chat-action-error" data-testid="chat-action-error"></div>
525
+
526
+ <div class="chat-input-row">
527
+ <label for="chat-msg" style="display:none">Message</label>
528
+ <input id="chat-msg" type="text" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg">
346
529
  </div>
530
+ <button class="btn" type="button" id="chat-check-deps-btn" data-testid="chat-check-deps" hidden>Check again</button>
531
+
532
+ <div class="actions-row">
533
+ <button class="btn" type="button" id="chat-send-btn" data-testid="chat-send" disabled>Send</button>
534
+ <button class="btn primary" type="button" id="chat-sign-btn" data-testid="btn-sign-run" disabled>Sign &amp; run</button>
535
+ <button class="btn" type="button" id="chat-revise-btn" data-testid="btn-revise" disabled>Revise (2 left)</button>
536
+ </div>
537
+ <div class="hint">Only your click signs. The chat can't.</div>
347
538
  </section>
348
539
 
349
540
  <section id="panel-runs" class="tabpanel" role="tabpanel" aria-labelledby="tab-runs" hidden>
@@ -423,7 +614,7 @@
423
614
 
424
615
  <section id="panel-details" class="tabpanel" role="tabpanel" aria-labelledby="tab-details" hidden data-testid="panel-details">
425
616
  <div id="job-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
426
- <div class="job-card" id="job-card" hidden data-testid="job-card-readonly">
617
+ <div class="job-card" id="job-card-readonly" hidden data-testid="job-card-readonly">
427
618
  <h4 id="details-title">Job card — signed</h4>
428
619
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
429
620
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
@@ -447,6 +638,74 @@
447
638
 
448
639
  </div>
449
640
 
641
+ <!-- SETTINGS VIEW (P4a) — reads/writes ~/.config/bareloop/config.json through
642
+ /api/settings/*; every call carries the per-start token. -->
643
+ <section class="settings-view" id="settings-view" aria-label="Settings" hidden>
644
+ <div class="settings-header">
645
+ <button class="btn small" type="button" id="settings-back" data-testid="settings-back">&larr; Back</button>
646
+ <h2>Settings</h2>
647
+ </div>
648
+
649
+ <div class="tabrow" role="tablist" aria-label="Settings views">
650
+ <button role="tab" id="tab-providers" aria-controls="panel-providers" aria-selected="true" data-testid="settings-tab-providers">Providers</button>
651
+ <button role="tab" id="tab-money" aria-controls="panel-money" aria-selected="false" data-testid="settings-tab-money">Money &amp; limits</button>
652
+ </div>
653
+
654
+ <div class="pane-body">
655
+ <!-- PROVIDERS TAB (P4b: one row per key in the keys file that has a value) -->
656
+ <section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
657
+ <div class="keyfile-strip" data-testid="keyfile-box">
658
+ <div class="keyfile-strip-main">
659
+ <span>Put <code>NAME=key</code> lines in <code id="pv-keyfile-path">~/.config/bareloop/.env</code>, then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
660
+ <span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
661
+ </div>
662
+ <div class="keyfile-strip-actions">
663
+ <button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
664
+ </div>
665
+ </div>
666
+ <div class="table-wrap" data-testid="provider-table-wrap">
667
+ <table class="pv-table" data-testid="provider-table">
668
+ <thead>
669
+ <tr><th>Key</th><th>Name</th><th>API shape</th><th>Base URL</th><th>Test</th><th>Tokens used</th><th>Balance</th></tr>
670
+ </thead>
671
+ <tbody id="pv-rows"></tbody>
672
+ </table>
673
+ </div>
674
+ <div id="pv-shell" data-testid="pv-shell"></div>
675
+ <div class="hint" id="pv-note" data-testid="pv-note" style="margin-top:6px;">Name is the model id Chat uses. Base URL blank = the API shape's own host. Test asks the provider for its model list — it costs nothing.</div>
676
+ </section>
677
+
678
+ <!-- MONEY & LIMITS TAB -->
679
+ <section id="panel-money" class="tabpanel" role="tabpanel" aria-labelledby="tab-money" hidden>
680
+ <div class="money-strip" data-testid="money-strip">
681
+ <div class="money-item">
682
+ <label>$ month / to date</label>
683
+ <div class="money-value" id="ml-usd" data-testid="ml-usd">…</div>
684
+ </div>
685
+ <div class="money-item">
686
+ <label>Tokens month / to date</label>
687
+ <div class="money-value" id="ml-tokens" data-testid="ml-tokens">…</div>
688
+ </div>
689
+ <div class="money-item">
690
+ <label for="ml-money">Monthly money limit ($)</label>
691
+ <input id="ml-money" type="text" placeholder="blank = no limit" data-testid="ml-money">
692
+ </div>
693
+ </div>
694
+ <div class="hint" id="ml-note" data-testid="ml-note" style="margin-top:6px;">Limits are yours. The agent can never change them. A run whose cap is more than what is left this month does not start.</div>
695
+
696
+ <h4 style="margin-top:20px;font-size:13px;">Per-provider breakdown</h4>
697
+ <div class="table-wrap">
698
+ <table data-testid="provider-breakdown-table">
699
+ <thead>
700
+ <tr><th>Provider</th><th>$ month / to date</th><th>minutes month / to date</th><th>tokens</th></tr>
701
+ </thead>
702
+ <tbody id="ml-breakdown"></tbody>
703
+ </table>
704
+ </div>
705
+ </section>
706
+ </div>
707
+ </section>
708
+
450
709
  </div>
451
710
 
452
711
  <script>
@@ -454,8 +713,24 @@
454
713
  "use strict";
455
714
 
456
715
  var PORT = "__BARELOOP_PANEL_PORT__";
716
+ // P3 human-click guard: minted once per server start, sent on every POST
717
+ // this page ever makes (`x-bareloop-token`) — never logged, never put in
718
+ // a URL, and it never signs anything on its own (the sign route also
719
+ // requires the exact spec hash, checked server-side).
720
+ var TOKEN = "__BARELOOP_PANEL_TOKEN__";
457
721
  document.getElementById("host-line").textContent = "127.0.0.1:" + PORT;
458
722
 
723
+ function authorPost(path, body){
724
+ return fetch(path, {
725
+ method: "POST",
726
+ headers: {"content-type": "application/json", "x-bareloop-token": TOKEN},
727
+ body: JSON.stringify(body || {}),
728
+ }).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: j}; }); });
729
+ }
730
+ function authorGet(path){
731
+ return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json(); });
732
+ }
733
+
459
734
  function wireTabs(tabButtons){
460
735
  tabButtons.forEach(function(btn){
461
736
  btn.addEventListener("click", function(){
@@ -524,6 +799,20 @@
524
799
  if(outcome === null || outcome === undefined) return boxIsLive ? "▶" : "?";
525
800
  return "✗";
526
801
  }
802
+ // P3 addition: colour ONLY [✓]/[✗] (the standing wording ruling's own
803
+ // words/glyph vocabulary is unchanged — this wraps the same glyph
804
+ // character in a plain inline colour span for the two result-carrying
805
+ // cases; [▶]/[?] pass through unwrapped, exactly as before this addition).
806
+ function glyphSpan(g){
807
+ if(g === "✓") return '<span class="glyph-green">' + g + '</span>';
808
+ if(g === "✗") return '<span class="glyph-red">' + g + '</span>';
809
+ return g;
810
+ }
811
+ // PLAIN TEXT ONLY — feeds the SVG step map's title/width calculation
812
+ // (`stepTitleText`, an SVG <text> node), never innerHTML; a colour span
813
+ // here would render as literal "<span>" characters inside the map.
814
+ // {@link glyphSpan} (the P3 colour addition) is used only by the DOM
815
+ // part-card renderer below, never by this function.
527
816
  function attemptsInlineText(attempts, boxIsLive){
528
817
  if(!attempts || !attempts.length) return "";
529
818
  return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome, boxIsLive); }).join(" · ");
@@ -578,9 +867,9 @@
578
867
  function partResultGlyph(part, box){
579
868
  if(partHasNoVerdict(part)) return null;
580
869
  var boxIsLive = box.state === "running";
581
- if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome, boxIsLive); }).join("");
870
+ if(box.attempts.length > 1) return box.attempts.map(function(a){ return glyphSpan(attemptGlyph(a.outcome, boxIsLive)); }).join("");
582
871
  var outcome = (part.outcome !== null && part.outcome !== undefined) ? part.outcome : (box.attempts[0] ? box.attempts[0].outcome : null);
583
- return attemptGlyph(outcome, boxIsLive);
872
+ return glyphSpan(attemptGlyph(outcome, boxIsLive));
584
873
  }
585
874
  function buildOrderedBoxes(parts, isLive){
586
875
  var list = Array.isArray(parts) ? parts : [];
@@ -871,7 +1160,7 @@
871
1160
  '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
872
1161
  '<span class="wf-meta-line">' +
873
1162
  '<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
874
- '<span class="wf-meta">' + escapeXml(r.spend) + '</span>' +
1163
+ '<span class="wf-meta">' + escapeXml(rowSpendText(r)) + '</span>' +
875
1164
  '<span class="wf-meta">' + escapeXml(r.wall) + '</span>' +
876
1165
  '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
877
1166
  '</span>';
@@ -918,7 +1207,7 @@
918
1207
  lastGlyph: last.glyph || "▶",
919
1208
  lastCheckType: last.checkType || "unknown",
920
1209
  lastModel: last.model || null,
921
- lastSpend: last.spend || "unknown",
1210
+ lastSpend: rowSpendText(last),
922
1211
  lastWall: last.wall || "unknown",
923
1212
  lastDate: last.date,
924
1213
  };
@@ -1334,7 +1623,78 @@
1334
1623
  }
1335
1624
 
1336
1625
  // ---- Run tab ----
1337
- function money(n){ return (n === null || n === undefined) ? "unknown" : "$" + Number(n).toFixed(4); }
1626
+ // panelMoney — hamr's ruling 2026-09-28 ("panel money 2-decimals"): every
1627
+ // money display in THIS page (Run tab summary/counters, Runs list rows,
1628
+ // Audit header/part cards, Job tab, chat cost lines) renders through this
1629
+ // ONE function — no per-site rounding. The CLI's own 4-decimal `money()`
1630
+ // (src/replay.js) is UNCHANGED; this is the panel-only render, the
1631
+ // browser-script sibling of `src/panel/authorroutes.js`'s own
1632
+ // `panelMoney2` (same rule, can't share code — one runs in Node, one
1633
+ // inlined in the page).
1634
+ // - null/undefined/non-finite -> "unknown" (never a fabricated $0).
1635
+ // - a REAL positive amount below a cent -> "<$0.01", never "$0.00" (a
1636
+ // tiny real cost must never read as free).
1637
+ // - `floor` (this is a FLOOR/"at least" figure, never exact) rounds DOWN
1638
+ // to 2 decimals — never overstates a floor as exact-higher.
1639
+ // - otherwise rounds half-up to 2 decimals (an exact zero prints
1640
+ // "$0.00" — nothing spent is a real number, not an unknown).
1641
+ // Two-step rounding guards against float-representation noise on BOTH
1642
+ // sides: dollar figures here are derived from at most 6-decimal-precision
1643
+ // money (the same precision `src/text.js`'s `tallyCalls`/`priceOf` use),
1644
+ // so rounding to 6 decimals first erases that noise (e.g. 0.815 really
1645
+ // stored as 0.81499999999999995), and ONLY THEN is the value rounded/
1646
+ // floored to cents — a value already exactly on a cent boundary (e.g.
1647
+ // 0.82) can be stored as 81.99999999999999 cents post-multiply, and a
1648
+ // floor there would wrongly drop a whole cent; the tiny `+1e-6` nudge (at
1649
+ // dollar-cents scale, far below anything a real half-cent tie could be)
1650
+ // corrects that without ever crossing a true half-cent boundary the wrong
1651
+ // way.
1652
+ function panelMoney(n, floor){
1653
+ if(n === null || n === undefined) return "unknown";
1654
+ var v = Number(n);
1655
+ if(!isFinite(v)) return "unknown";
1656
+ if(v > 0 && v < 0.01) return "<$0.01";
1657
+ var clean = Math.round(v * 1e6) / 1e6;
1658
+ var cents = floor ? Math.floor(clean * 100 + 1e-6) : Math.round(clean * 100 + 1e-6);
1659
+ if(cents < 0) cents = 0;
1660
+ return "$" + (cents / 100).toFixed(2);
1661
+ }
1662
+ /**
1663
+ * The panel's one composed "spend (drafting) of cap" renderer — the
1664
+ * browser-script sibling of `src/replay.js`'s own `moneyWithDraft`, same
1665
+ * rules, 2-decimal via {@link panelMoney} instead of 4. `spendCoreFloor`
1666
+ * marks the LEADING figure a floor (a died run's own spend-floor, OR a
1667
+ * complete run whose drafting share is itself incomplete — hamr's ruling:
1668
+ * a reader summing the two must never read the leading number as exact
1669
+ * when the bracket next to it isn't). `draftSpendComplete === false` marks
1670
+ * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX".
1671
+ */
1672
+ function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd){
1673
+ var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
1674
+ var draftFloor = hasDraft && draftSpendComplete === false;
1675
+ var core = (spendCoreVal === null || spendCoreVal === undefined)
1676
+ ? "unknown"
1677
+ : ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
1678
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
1679
+ var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
1680
+ return core + draftPart + capPart;
1681
+ }
1682
+ /**
1683
+ * The Runs list row / Workflows grouping's own spend text — off the
1684
+ * numeric fields `/api/runs`' row now carries (never `r.spend`, which is
1685
+ * the LIBRARY's own 4-decimal string kept unchanged for `--all`'s CLI
1686
+ * listing to share; `src/panel/server.js`'s `summarizeRow` doc says why).
1687
+ * Same floor rules as {@link panelMoneyWithDraft}/`renderRun`'s own
1688
+ * `spendText`: a died row's spend is a floor by construction, a live row's
1689
+ * is a floor only when its own drafting share is incomplete.
1690
+ * @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
1691
+ */
1692
+ function rowSpendText(r){
1693
+ var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
1694
+ var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
1695
+ var coreFloor = !!r.died || draftIncomplete;
1696
+ return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete);
1697
+ }
1338
1698
  function duration(ms){
1339
1699
  if(ms === null || ms === undefined) return "unknown";
1340
1700
  if(ms < 0) return "unknown";
@@ -1345,6 +1705,31 @@
1345
1705
  return m + "m" + String(rem).padStart(2, "0") + "s";
1346
1706
  }
1347
1707
 
1708
+ /**
1709
+ * build item 6 (2026-09-28, session mul5fofw) — while a run is LIVE (no
1710
+ * job-end reached yet: `detail.spentUsd === null` and not died), the Run
1711
+ * tab's summary must show a running FLOOR from the spine's own metered
1712
+ * rounds instead of "unknown" — never presented as exact ("so far", never
1713
+ * "at least" — this is a live, still-growing number, not a died run's
1714
+ * fixed floor). Reuses {@link panelMoney} for the actual digits; never a
1715
+ * second hand-rolled money format.
1716
+ * @param {number|null} spendFloorUsd @param {number|null} draftSpentUsd
1717
+ * @param {boolean|null} draftSpendComplete @returns {string}
1718
+ */
1719
+ function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete){
1720
+ if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
1721
+ var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
1722
+ var draftFloor = hasDraft && draftSpendComplete === false;
1723
+ var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
1724
+ return panelMoney(spendFloorUsd) + " so far" + draftPart;
1725
+ }
1726
+ /** build item 6 — the matching wall-clock phrase: "running <duration>",
1727
+ * never "unknown" once the spine has at least one timestamped record.
1728
+ * @param {number|null} wallFloorMs @returns {string} */
1729
+ function liveWallPhrase(wallFloorMs){
1730
+ return (wallFloorMs === null || wallFloorMs === undefined) ? "elapsed unknown" : ("running " + duration(wallFloorMs));
1731
+ }
1732
+
1348
1733
  // ---- Audit row cell builders — ONE shared renderer for a gate-audit row,
1349
1734
  // used by both the Flat table (renderAudit) and the Grouped view's
1350
1735
  // per-attempt rounds table (renderRoundsTable) so the two views can never
@@ -1371,7 +1756,7 @@
1371
1756
  function auditDecisionCellHtml(r){
1372
1757
  if(auditIsModelCall(r)){
1373
1758
  var bits = [];
1374
- bits.push(typeof r.costUsd === "number" ? money(r.costUsd) : "unknown");
1759
+ bits.push(typeof r.costUsd === "number" ? panelMoney(r.costUsd) : "unknown");
1375
1760
  bits.push((typeof r.tokens === "number" ? r.tokens : "unknown") + " tokens");
1376
1761
  bits.push(duration(r.durationMs));
1377
1762
  return escapeXml(bits.join(" · "));
@@ -1385,6 +1770,25 @@
1385
1770
  if(auditIsBlocked(r)) cls = (cls ? cls + " " : "") + "row-blocked";
1386
1771
  return cls;
1387
1772
  }
1773
+ // fix (2026-09-28): the Time cell showed the full ISO timestamp
1774
+ // (`2026-09-27T08:20:58.924Z`), overflowing every Audit-tab table sideways
1775
+ // at 390px. Shows time-only `HH:MM:SS` taken from the ISO string AS-IS
1776
+ // (UTC, no timezone conversion), full ISO kept in the cell's `title` for
1777
+ // hover — ONE helper, shared by the Flat table and the Grouped view's
1778
+ // per-attempt rounds table (both the header row and the tool-call rows),
1779
+ // so the three call sites can never drift on how a Time cell reads.
1780
+ function auditShortTime(iso){
1781
+ if(!iso) return null;
1782
+ var m = /T(\d\d:\d\d:\d\d)/.exec(String(iso));
1783
+ return m ? m[1] : null;
1784
+ }
1785
+ function auditTimeCellHtml(r){
1786
+ var iso = r.time;
1787
+ if(!iso) return "unknown";
1788
+ var short = auditShortTime(iso);
1789
+ if(!short) return escapeXml(iso);
1790
+ return '<span title="' + escapeXml(iso) + '">' + escapeXml(short) + '</span>';
1791
+ }
1388
1792
 
1389
1793
  // item 7: tools/cache summary rows — built ONLY from replay's own already-
1390
1794
  // computed `behaviour`/`memoryCache` fields (never recomputed a second way).
@@ -1489,17 +1893,38 @@
1489
1893
  // no job-end means no final totals were ever recorded) are replaced with
1490
1894
  // the priced-rounds/first-to-last-record FLOORS the server derived —
1491
1895
  // never "unknown" when a priced round actually exists.
1492
- var spendText = detail.died ? (detail.spendFloorUsd === null ? "unknown" : "at least " + money(detail.spendFloorUsd)) : money(detail.spentUsd);
1493
- var wallText = detail.died ? (detail.wallFloorMs === null ? "unknown" : "at least " + duration(detail.wallFloorMs)) : duration(detail.wallMs);
1494
- // item 6 (2026-09-25): "took" for a finished (job-end reached) OR died
1495
- // run — both are OVER, so the wall figure is a completed duration, never
1496
- // an in-progress one. "elapsed" is reserved for the one state that IS
1497
- // still in progress: a live [▶] run (glyph "▶" and not died).
1498
- var isLive = detail.glyph === "▶" && !detail.died;
1499
- var wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
1896
+ // hamr's ruling 2026-09-28 ("one cap covers drafting + run", 2nd
1897
+ // addendum) — the leading figure reads as a floor ("at least $X") on a
1898
+ // died run OR when the drafting share itself is incomplete (a reader
1899
+ // summing the two must never take the leading number as exact when the
1900
+ // bracket beside it isn't); the drafting bracket reads its own floor
1901
+ // independently. {@link panelMoneyWithDraft} is the one place that
1902
+ // decides all of this — see its own doc.
1903
+ // build item 6 (2026-09-28): a LIVE run (no job-end reached: died is
1904
+ // false and spentUsd is still null) shows the RUNNING floor instead of
1905
+ // "unknown" — the server now derives the same spendFloorUsd/wallFloorMs
1906
+ // for a still-running spine that it already derived for a died one.
1907
+ var isLiveNoEnd = !detail.died && detail.spentUsd === null;
1908
+ var spendText, wallPhrase;
1909
+ if(isLiveNoEnd){
1910
+ spendText = liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete);
1911
+ wallPhrase = liveWallPhrase(detail.wallFloorMs);
1912
+ } else {
1913
+ var draftIncomplete = typeof detail.draftSpentUsd === "number" && detail.draftSpentUsd !== null && detail.draftSpendComplete === false;
1914
+ var spendCoreVal = detail.died ? detail.spendFloorUsd : detail.spentUsd;
1915
+ var spendCoreFloor = detail.died || draftIncomplete;
1916
+ spendText = panelMoneyWithDraft(spendCoreVal, spendCoreFloor, detail.draftSpentUsd, detail.draftSpendComplete);
1917
+ var wallText = detail.died ? (detail.wallFloorMs === null ? "unknown" : "at least " + duration(detail.wallFloorMs)) : duration(detail.wallMs);
1918
+ // item 6 (2026-09-25): "took" for a finished (job-end reached) OR died
1919
+ // run — both are OVER, so the wall figure is a completed duration, never
1920
+ // an in-progress one. "elapsed" is reserved for the one state that IS
1921
+ // still in progress: a live [▶] run (glyph "▶" and not died).
1922
+ var isLive = detail.glyph === "▶" && !detail.died;
1923
+ wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
1924
+ }
1500
1925
 
1501
1926
  document.getElementById("run-counters").innerHTML =
1502
- spendText + " of $" + (detail.budgetUsd === null ? "unknown" : Number(detail.budgetUsd).toFixed(2)) +
1927
+ spendText + " of " + panelMoney(detail.budgetUsd) +
1503
1928
  " &middot; " + wallPhrase;
1504
1929
 
1505
1930
  var box = document.getElementById("run-summary");
@@ -1546,12 +1971,10 @@
1546
1971
  var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
1547
1972
  var titleText = box.kind === "step" ? ((cardStepNumbers[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
1548
1973
  var resultGlyph = partResultGlyph(part, box);
1549
- var line1Bits = [
1550
- (typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
1551
- typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
1552
- duration(part.wallMs),
1553
- part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
1554
- ];
1974
+ // build item 4: ONE owner (partLine1Text, shared with the Audit tab's
1975
+ // Grouped headers) for the wall/money bits, so a live run's card never
1976
+ // reads a bare "unknown" a second reader independently failed to float.
1977
+ var line1Bits = [partLine1Text(part, detail)];
1555
1978
  if(resultGlyph !== null) line1Bits.push(resultGlyph);
1556
1979
  var card = document.createElement("div");
1557
1980
  card.className = "step-card part-card" + (box.state === "running" ? " running" : "");
@@ -1590,19 +2013,30 @@
1590
2013
  var row = document.createElement("div");
1591
2014
  row.className = "attempt-row";
1592
2015
  row.setAttribute("data-testid", "audit-attempt-" + partIndex + "-" + a.n);
2016
+ // plain-checks build (2026-09-28): the withdrawn "checks N/M" headline
2017
+ // is replaced by attemptChecksLine's plain words (including the
2018
+ // opening outer-close grade, which is attempt 1 on this SAME list —
2019
+ // src/replay.js's fixLoop.attempts) — "" for a rubric/command close or
2020
+ // a still-running attempt with no stages yet.
2021
+ var checksText = attemptChecksLine(a);
2022
+ var checksList = fullStageList(a).map(stageLineHtml).join("<br>");
1593
2023
  row.innerHTML =
1594
2024
  '<button class="btn small attempt-toggle" type="button" aria-expanded="false">' +
1595
2025
  'attempt ' + a.n + ' ' + attemptGlyph(a.outcome, boxIsLive) +
2026
+ (checksText ? ' &middot; ' + checksText : '') +
1596
2027
  '</button>' +
2028
+ (checksList ? '<div class="checks-list" data-testid="audit-attempt-checks-' + partIndex + '-' + a.n + '" hidden>' + checksList + '</div>' : '') +
1597
2029
  '<div class="rounds-list" hidden></div>';
1598
2030
  container.appendChild(row);
1599
2031
  var btn = row.querySelector(".attempt-toggle");
1600
2032
  var roundsEl = row.querySelector(".rounds-list");
2033
+ var checksEl = row.querySelector(".checks-list");
1601
2034
  var built = false;
1602
2035
  btn.addEventListener("click", function(){
1603
2036
  var expanded = btn.getAttribute("aria-expanded") === "true";
1604
2037
  btn.setAttribute("aria-expanded", expanded ? "false" : "true");
1605
2038
  roundsEl.hidden = expanded;
2039
+ if(checksEl) checksEl.hidden = expanded;
1606
2040
  if(!expanded && !built){
1607
2041
  built = true;
1608
2042
  loadRounds(roundsEl, partIndex, a, 0);
@@ -1635,7 +2069,7 @@
1635
2069
  '<td>' + auditActionCellHtml(pseudo) + '</td>' +
1636
2070
  '<td>' + auditPathCellHtml(pseudo) + '</td>' +
1637
2071
  '<td>' + auditDecisionCellHtml(pseudo) + '</td>' +
1638
- '<td>' + escapeXml(pseudo.time || "unknown") + '</td>' +
2072
+ '<td>' + auditTimeCellHtml(pseudo) + '</td>' +
1639
2073
  '</tr>';
1640
2074
  }
1641
2075
  function renderRoundToolRow(tc){
@@ -1645,7 +2079,7 @@
1645
2079
  '<td>' + auditActionCellHtml(tc) + '</td>' +
1646
2080
  '<td>' + auditPathCellHtml(tc) + '</td>' +
1647
2081
  '<td>' + auditDecisionCellHtml(tc) + '</td>' +
1648
- '<td>' + escapeXml(tc.time || "unknown") + '</td>' +
2082
+ '<td>' + auditTimeCellHtml(tc) + '</td>' +
1649
2083
  '</tr>';
1650
2084
  }
1651
2085
  function renderRoundRows(r, toolLogSaved){
@@ -1713,13 +2147,188 @@
1713
2147
  if(!bt) return false;
1714
2148
  return !!(bt.edit || bt.write);
1715
2149
  }
1716
- function partLine1Text(part){
1717
- return [
2150
+ /**
2151
+ * build item 4 (2026-09-28, session mul5fofw's own live run): the wall/money
2152
+ * bits of a part card — ONE owner, used by both the Run tab's cards
2153
+ * ({@link renderRun}) and the Audit tab's Grouped headers
2154
+ * ({@link renderAuditGroups}), which used to compute these independently (a
2155
+ * second wall/spend reader — exactly the class of bug F198 already named).
2156
+ *
2157
+ * A part's `wallMs`/`spentUsd` are `null` while it is genuinely still being
2158
+ * measured (`replay.js`'s own `windowWallMs`/`thisFileSpend`, unresolved
2159
+ * until a later record — or, for the `kind:"run"` synthetic whole-run part,
2160
+ * unresolved until `job-end`). `duration(null)`/`panelMoney(null)` both read
2161
+ * "unknown", which is honest for a DEAD run but wrong for a LIVE one — the
2162
+ * same distinction {@link liveSpendText}/{@link liveWallPhrase} already draw
2163
+ * for the Summary box. Only the run's OWN card (`box.kind === "run"`, the
2164
+ * one shown when no step has started yet) can be substituted this way: its
2165
+ * `wallMs`/`spentUsd` (`replay.js`'s top-level `wallMs`/`thisFileSpend`) ARE
2166
+ * the run's own totals-so-far, so `detail.wallFloorMs`/`spendFloorUsd` (the
2167
+ * server's floor over the SAME spine) is the right number, not a guess. A
2168
+ * step/scout/plan/fix/judge part's own wall/spend has no run-level floor to
2169
+ * borrow, so it is left exactly as before.
2170
+ * @param {any} part @param {any} [detail] the run detail, when available
2171
+ * @returns {string}
2172
+ */
2173
+ function partLine1Text(part, detail){
2174
+ var isLiveRunPart = part && part.kind === "run" && detail && !detail.died && detail.spentUsd === null;
2175
+ var wallText = (isLiveRunPart && part.wallMs === null) ? liveWallPhrase(detail.wallFloorMs) : duration(part.wallMs);
2176
+ var spendText = (isLiveRunPart && part.spentUsd === null)
2177
+ ? liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete)
2178
+ : (part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : panelMoney(part.spentUsd));
2179
+ var bits = [
1718
2180
  (typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
1719
2181
  typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
1720
- duration(part.wallMs),
1721
- part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
1722
- ].join(" &middot; ");
2182
+ wallText,
2183
+ spendText,
2184
+ ];
2185
+ // plain-checks build (2026-09-28): the part's headline check LINE — its
2186
+ // MOST RECENT attempt's own declared-close grading (the opening
2187
+ // outer-close grade counts as attempt 1, same list `buildAttemptsList`
2188
+ // reads), in the withdrawn headline's place.
2189
+ var latestAttempt = Array.isArray(part.attempts) && part.attempts.length ? part.attempts[part.attempts.length - 1] : null;
2190
+ var headline = latestAttempt ? attemptChecksLine(latestAttempt) : "";
2191
+ if(headline) bits.push(headline);
2192
+ return bits.join(" &middot; ");
2193
+ }
2194
+ /**
2195
+ * plain-checks build (2026-09-28, hamr's ruling): the "checks N/M" headline
2196
+ * is WITHDRAWN — "confusing, reads like 6 failed when 5 never ran".
2197
+ * Replaced everywhere by this plain-words line:
2198
+ * - all declared stages passed: "all N checks passed"
2199
+ * - a stage failed: "check #N <plain question> — <number>", where `#N`
2200
+ * is the failing stage's 1-based position in the DECLARED close
2201
+ * ({@link declaredPosition}), the question is `stage.question` (the
2202
+ * server's one owner, `src/panel/server.js`'s `stageQuestionText` —
2203
+ * falls back to the stage's own name when the catalogue has no honest
2204
+ * plain question for it) and the number is {@link stageNumberText}'s
2205
+ * reworded shape, omitted entirely for a pass/fail stage with no
2206
+ * number.
2207
+ * "" (never shown) for a rubric/command close or an unresolved attempt
2208
+ * (still running, no stages yet). `attempt.declaredStagesTotal` is the
2209
+ * DECLARED stage count (never `stages.length`, the RAN count — first-red-
2210
+ * wins means a later attempt's own `stages` array can be shorter than the
2211
+ * close it belongs to; hamr's live catch on run mulbz0ny, checks-count fix
2212
+ * 2026-09-28), falling back to `stages.length` only when no spec resolved.
2213
+ * The caller (buildAttemptsList) prefixes "attempt N ✓/✗ · " for the
2214
+ * attempt header, and the Run tab card / Audit Grouped header show this
2215
+ * SAME text bare (hamr: "same text as its attempt header, minus 'attempt
2216
+ * N'").
2217
+ * @param {any} attempt @returns {string}
2218
+ */
2219
+ function attemptChecksLine(attempt){
2220
+ var stages = attempt && attempt.stages;
2221
+ if(!Array.isArray(stages) || stages.length === 0) return "";
2222
+ var total = (typeof attempt.declaredStagesTotal === "number" && attempt.declaredStagesTotal > 0) ? attempt.declaredStagesTotal : stages.length;
2223
+ var failing = null;
2224
+ for(var i = 0; i < stages.length; i++){
2225
+ if(stages[i] && stages[i].verdict !== "satisfied"){ failing = stages[i]; break; }
2226
+ }
2227
+ if(failing === null) return "all " + total + " checks passed";
2228
+ var pos = declaredPosition(attempt, failing.name);
2229
+ if(pos === null) pos = stages.indexOf(failing) + 1;
2230
+ var numberText = stageNumberText(failing);
2231
+ return "check #" + pos + " " + escapeXml(stageQuestionText(failing)) + (numberText ? " — " + numberText : "");
2232
+ }
2233
+ /** a stage's DECLARED 1-based position, from `attempt.declaredStages` (the
2234
+ * server's full declared-order list, {@link enrichPartsWithStageKind} in
2235
+ * src/panel/server.js) — matched by NAME, never by array index (robust to
2236
+ * any reordering between the resolved spec and the stages that actually
2237
+ * ran). `null` when no `declaredStages` travelled (no spec resolvable) or
2238
+ * the name isn't found there; the caller falls back to the stage's
2239
+ * position within whatever ran.
2240
+ * @param {any} attempt @param {string|null|undefined} stageName @returns {number|null} */
2241
+ function declaredPosition(attempt, stageName){
2242
+ if(!Array.isArray(attempt.declaredStages)) return null;
2243
+ var idx = attempt.declaredStages.findIndex(function(ds){ return ds && ds.name === stageName; });
2244
+ return idx === -1 ? null : idx + 1;
2245
+ }
2246
+ /** a stage's plain-English question — `stage.question` (the server's one
2247
+ * owner, `src/panel/server.js`'s `stageQuestionText`) when the catalogue
2248
+ * has an honest one for this stage's kind+params, else the stage's OWN
2249
+ * name (a command-close's hand-written stage name, or a kind/genre shape
2250
+ * the owner has no wording for — never invented here).
2251
+ * @param {any} stage @returns {string} */
2252
+ function stageQuestionText(stage){
2253
+ if(stage && typeof stage.question === "string" && stage.question) return stage.question;
2254
+ return (stage && stage.name) ? stage.name : "unknown";
2255
+ }
2256
+ /**
2257
+ * §3a's closed numeric wording, one owner (checks-wording build spec,
2258
+ * 2026-09-28, reworded by the plain-checks build's own separators): a
2259
+ * numeric stage's own DECLARED `direction`/`baselineKind` (server-sent,
2260
+ * {@link stageKindMetaFromSpec} on the panel server — the signed close
2261
+ * declaration's own per-stage facts, never guessed from the runtime number
2262
+ * alone) picks one of hamr's 3 shapes:
2263
+ * - lower-is-better toward a DECLARED-ZERO goal (`baselineKind === 0`):
2264
+ * "N left, need T"
2265
+ * - higher-is-better (a floor): "N of total"
2266
+ * - lower-is-better toward a SEED-MEASURED baseline (`baselineKind ===
2267
+ * "seed"`): "N, limit M"
2268
+ * Only `count-not-worse` ever carries a numeric value/baseline at all
2269
+ * (src/kinds.js — every other kind's StageResult defaults both to null),
2270
+ * so a value-less stage always renders "" here (pass/fail, glyph only).
2271
+ * Every shipped kind fits one of the 3 shapes above; a stage that somehow
2272
+ * carries a number with no resolvable direction/baselineKind (no spec
2273
+ * resolved server-side, or an old archived run) falls back to the spec's
2274
+ * own explicit "N (baseline M)" wording verbatim, never a 4th invented
2275
+ * shape.
2276
+ * @param {any} stage @returns {string}
2277
+ */
2278
+ function stageNumberText(stage){
2279
+ var hasValue = typeof stage.value === "number";
2280
+ var hasBaseline = typeof stage.baseline === "number";
2281
+ if(!hasValue) return "";
2282
+ if(stage.direction === "higher-is-better" && hasBaseline) return stage.value + " of " + stage.baseline;
2283
+ if(stage.direction === "lower-is-better" && stage.baselineKind === 0 && hasBaseline) return stage.value + " left, need " + stage.baseline;
2284
+ if(stage.direction === "lower-is-better" && stage.baselineKind === "seed" && hasBaseline) return stage.value + ", limit " + stage.baseline;
2285
+ return hasBaseline ? (stage.value + " (baseline " + stage.baseline + ")") : String(stage.value);
2286
+ }
2287
+ /**
2288
+ * plain-checks build (2026-09-28): the expanded attempt row's FULL declared
2289
+ * stage list, in declared order, including stages that never ran (first-
2290
+ * red-wins stopped the close early) — hamr's ruling: "one line per DECLARED
2291
+ * stage, in order, including stages that never ran". Zips
2292
+ * `attempt.declaredStages` (the server's full ordered `{name, question}`
2293
+ * list) against `attempt.stages` (the shorter, possibly-partial RAN list)
2294
+ * BY NAME: a declared stage with a matching ran entry renders that entry
2295
+ * (`ran: true`); one with no match renders a synthetic not-run stub
2296
+ * (`ran: false`, `verdict: null`). When no `declaredStages` travelled at
2297
+ * all (no spec resolvable), falls back to whatever ran, unpadded — exactly
2298
+ * today's behaviour for a rubric/command close or an unresolvable spec.
2299
+ * @param {any} attempt @returns {{n: number, ran: boolean, stage: any}[]}
2300
+ */
2301
+ function fullStageList(attempt){
2302
+ var ran = Array.isArray(attempt.stages) ? attempt.stages : [];
2303
+ if(!Array.isArray(attempt.declaredStages) || attempt.declaredStages.length === 0){
2304
+ return ran.map(function(s, i){ return { n: i + 1, ran: true, stage: s }; });
2305
+ }
2306
+ var byName = {};
2307
+ ran.forEach(function(s){ if(s && typeof s.name === "string") byName[s.name] = s; });
2308
+ return attempt.declaredStages.map(function(ds, i){
2309
+ var s = ds && Object.prototype.hasOwnProperty.call(byName, ds.name) ? byName[ds.name] : null;
2310
+ if(s) return { n: i + 1, ran: true, stage: s };
2311
+ return { n: i + 1, ran: false, stage: { name: ds ? ds.name : null, question: ds ? ds.question : null, verdict: null } };
2312
+ });
2313
+ }
2314
+ /**
2315
+ * one stage's expand-view line — "#N <question> <mark> <number>", hamr's
2316
+ * closed illustration:
2317
+ * #1 did it change any file? ✓
2318
+ * #2 src/checks.js has 0 type errors? ✗ 4 left, need 0
2319
+ * #3 other files didn't get more type errors? · not run
2320
+ * A never-run declared stage (`entry.ran === false`) always reads "· not
2321
+ * run", no number. A ran stage with no recorded number (a guard, a
2322
+ * pass/fail command stage) renders the mark alone.
2323
+ * @param {{n: number, ran: boolean, stage: any}} entry @returns {string}
2324
+ */
2325
+ function stageLineHtml(entry){
2326
+ var s = entry.stage;
2327
+ var q = escapeXml(stageQuestionText(s));
2328
+ if(!entry.ran) return "#" + entry.n + " " + q + " &middot; not run";
2329
+ var mark = s.verdict === "satisfied" ? glyphSpan("✓") : (s.verdict ? glyphSpan("✗") : "&ndash;");
2330
+ var numberText = stageNumberText(s);
2331
+ return "#" + entry.n + " " + q + " " + mark + (numberText ? " " + numberText : "");
1723
2332
  }
1724
2333
  var currentAuditFilter = "all";
1725
2334
  function auditPartMatchesFilter(rowWrap, filter){
@@ -1763,7 +2372,7 @@
1763
2372
  headBtn.setAttribute("data-testid", "audit-part-toggle-" + idx);
1764
2373
  var groupResultGlyph = partResultGlyph(part, box);
1765
2374
  headBtn.innerHTML =
1766
- '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part) +
2375
+ '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part, detail) +
1767
2376
  (groupResultGlyph !== null ? ' &middot; ' + groupResultGlyph : '') +
1768
2377
  (box.blocked > 0 ? ' <span class="badge red" data-testid="audit-part-blocked-' + idx + '">' + box.blocked + ' blocked</span>' : '');
1769
2378
  var body = document.createElement("div");
@@ -1874,7 +2483,7 @@
1874
2483
  "<td>" + auditActionCellHtml(r) + "</td>" +
1875
2484
  "<td>" + pathCell + "</td>" +
1876
2485
  "<td>" + decisionCell + "</td>" +
1877
- "<td>" + escapeXml(r.time || "unknown") + "</td>";
2486
+ "<td>" + auditTimeCellHtml(r) + "</td>";
1878
2487
  tbody.appendChild(tr);
1879
2488
  });
1880
2489
  document.getElementById("audit-rawlog").textContent = result.raw || "";
@@ -1907,7 +2516,7 @@
1907
2516
  // ---- Job tab ----
1908
2517
  function renderJob(job){
1909
2518
  var empty = document.getElementById("job-empty");
1910
- var card = document.getElementById("job-card");
2519
+ var card = document.getElementById("job-card-readonly");
1911
2520
  if(!job){ empty.hidden = false; card.hidden = true; return; }
1912
2521
  empty.hidden = true;
1913
2522
  card.hidden = false;
@@ -1925,7 +2534,7 @@
1925
2534
  document.getElementById("details-source").textContent = job.source;
1926
2535
  document.getElementById("details-dest").textContent = job.destination;
1927
2536
  document.getElementById("details-tools").textContent = job.tools;
1928
- document.getElementById("details-cap-money").textContent = job.budgetUsd === null ? "unknown" : ("$" + Number(job.budgetUsd).toFixed(2));
2537
+ document.getElementById("details-cap-money").textContent = panelMoney(job.budgetUsd);
1929
2538
  document.getElementById("details-cap-time").textContent = job.maxWallMs === null ? "unknown" : duration(job.maxWallMs);
1930
2539
  document.getElementById("job-note").textContent = job.note || "";
1931
2540
  lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
@@ -2094,11 +2703,6 @@
2094
2703
  if(pollTimer) return;
2095
2704
  pollTimer = setInterval(pollTick, POLL_MS);
2096
2705
  }
2097
- function stopPolling(){
2098
- if(!pollTimer) return;
2099
- clearInterval(pollTimer);
2100
- pollTimer = null;
2101
- }
2102
2706
  // Pause while the tab is hidden (cheap, standard); resume — with an
2103
2707
  // immediate catch-up tick, forcing BOTH fetches regardless of the list's
2104
2708
  // 10s throttle — on return, rather than waiting a full 2s (or up to 10s
@@ -2112,11 +2716,15 @@
2112
2716
  lastRunsSig = sig(runs);
2113
2717
  runsFilterBar.setItems(runs);
2114
2718
  if(runs.length && !runs[0].fileMissing){
2115
- document.getElementById("tab-runs").click();
2116
- // newest run overall, opened directly — the Workflows view starts
2117
- // collapsed, but its job row still renders "selected" (build item,
2118
- // 2026-09-26: a job row shows selected state like a child row) since
2119
- // `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is set.
2719
+ // build item 5 — the LEFT pane opens on Chat by default; this no
2720
+ // longer force-clicks "tab-runs" (it used to switch the left pane
2721
+ // away from Chat on every load that had a run). The RIGHT pane still
2722
+ // loads the newest run's details so they're ready the moment a
2723
+ // person switches to the Runs tab themselves — the Workflows view
2724
+ // starts collapsed, but its job row still renders "selected" (build
2725
+ // item, 2026-09-26: a job row shows selected state like a child row)
2726
+ // since `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is
2727
+ // set.
2120
2728
  selectRun(runs[0].runid, null, ".hist-row");
2121
2729
  document.getElementById("tab-run").click();
2122
2730
  runsFilterBar.refresh();
@@ -2150,6 +2758,595 @@
2150
2758
  });
2151
2759
  })();
2152
2760
 
2761
+ // ---- Settings view (PANEL-BUILD.md P4a) ----
2762
+ (function(){
2763
+ var mainView = document.getElementById("main-view");
2764
+ var settingsView = document.getElementById("settings-view");
2765
+ var limitInput = document.getElementById("ml-money");
2766
+ var noteEl = document.getElementById("ml-note");
2767
+ var NOTE_DEFAULT = noteEl.textContent;
2768
+ // table cells: an "at least" figure keeps its marker as a "≥" prefix on that number — never dropped
2769
+ function moneyCell(n, atLeast){ return (atLeast ? "≥" : "") + panelMoney(n); }
2770
+ // wall minutes: "unknown" when no run had a known wall (never a clean 0); 1 decimal under 100, else a whole number with a thousands separator
2771
+ function minutesCell(ms, atLeast){
2772
+ if(ms === null || ms === undefined) return "unknown";
2773
+ var m = ms / 60000;
2774
+ var txt = m < 100 ? m.toFixed(1) : Math.round(m).toLocaleString("en-US");
2775
+ return (atLeast ? "≥" : "") + txt + " min";
2776
+ }
2777
+ var keepNote = false;
2778
+ function loadMoney(){
2779
+ authorGet("/api/settings/money").then(function(r){
2780
+ if(!r || !r.ok){ noteEl.textContent = "could not read your spend"; return; }
2781
+ document.getElementById("ml-usd").textContent = moneyCell(r.monthUsd, r.monthAtLeast) + " / " + moneyCell(r.totalUsd, r.totalAtLeast);
2782
+ document.getElementById("ml-tokens").textContent = tokensText(r.monthTokens) + " / " + tokensText(r.totalTokens);
2783
+ if(document.activeElement !== limitInput){
2784
+ limitInput.value = r.monthlyLimitUsd === null ? "" : r.monthlyLimitUsd.toFixed(2);
2785
+ }
2786
+ var rows = r.byProvider.map(function(p){
2787
+ return "<tr><td>" + escapeXml(p.label)
2788
+ + "</td><td>" + escapeXml(moneyCell(p.monthUsd, p.monthAtLeast) + " / " + moneyCell(p.totalUsd, p.totalAtLeast))
2789
+ + "</td><td>" + escapeXml(minutesCell(p.monthWallMs, p.monthWallAtLeast) + " / " + minutesCell(p.totalWallMs, p.totalWallAtLeast))
2790
+ + "</td><td>" + escapeXml(tokensText(p.tokens)) + "</td></tr>";
2791
+ }).join("");
2792
+ document.getElementById("ml-breakdown").innerHTML = rows || '<tr><td colspan="4" class="hint">no runs yet</td></tr>';
2793
+ if(keepNote){ keepNote = false; return; }
2794
+ noteEl.textContent = r.configProblem ? r.configProblem : NOTE_DEFAULT;
2795
+ }).catch(function(){ noteEl.textContent = "could not read your spend"; });
2796
+ }
2797
+ document.getElementById("btn-settings").addEventListener("click", function(){
2798
+ mainView.hidden = true;
2799
+ settingsView.hidden = false;
2800
+ loadProviders();
2801
+ loadMoney();
2802
+ });
2803
+ document.getElementById("settings-back").addEventListener("click", function(){
2804
+ settingsView.hidden = true;
2805
+ mainView.hidden = false;
2806
+ });
2807
+ document.getElementById("tab-money").addEventListener("click", loadMoney);
2808
+
2809
+ // ---- Providers tab (P4b): one row per key in the keys file that has a value ----
2810
+ var pvRows = document.getElementById("pv-rows");
2811
+ var pvNote = document.getElementById("pv-note");
2812
+ var PV_NOTE_DEFAULT = pvNote.textContent;
2813
+ function tokensText(n){
2814
+ if(!n) return "0";
2815
+ if(n >= 1e6) return (n / 1e6).toFixed(1) + "M";
2816
+ if(n >= 1e3) return (n / 1e3).toFixed(1) + "k";
2817
+ return String(n);
2818
+ }
2819
+ function loadProviders(){
2820
+ authorGet("/api/settings/providers").then(function(r){
2821
+ if(!r || !r.ok){ pvNote.textContent = "could not read providers"; return; }
2822
+ var kf = r.keysFile;
2823
+ document.getElementById("pv-keyfile-path").textContent = kf.path;
2824
+ document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
2825
+ pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
2826
+ var e = escapeXml(row.envName);
2827
+ var shapeOpts = r.shapes.map(function(s){
2828
+ return '<option value="' + escapeXml(s.id) + '"' + (s.id === row.shape ? " selected" : "") + ">" + escapeXml(s.label) + "</option>";
2829
+ }).join("");
2830
+ var bal = row.balance.kind === "note"
2831
+ ? '<input type="text" class="pv-balance" placeholder="type by hand" value="' + (row.balance.usd === null ? "" : escapeXml(row.balance.usd.toFixed(2))) + '"><div class="hint">a note, never checked</div>'
2832
+ : (row.balance.kind === "fetch" ? '<span class="pv-balance-fetch" data-env="' + e + '">…</span>' : "—");
2833
+ return '<tr data-testid="provider-row-' + e + '" data-env="' + e + '">'
2834
+ + "<td><code>" + e + "</code>"
2835
+ + '<div class="hint" data-testid="pv-status-' + e + '">' + escapeXml(row.keyStatus) + "</div></td>"
2836
+ + '<td><input type="text" class="pv-name" data-testid="pv-name-' + e + '" placeholder="model id" value="' + escapeXml(row.name) + '"></td>'
2837
+ + '<td><select class="pv-shape" data-testid="pv-shape-' + e + '">' + shapeOpts + "</select></td>"
2838
+ + '<td><input type="text" class="pv-url" data-testid="pv-url-' + e + '" placeholder="' + escapeXml(row.placeholder || "the shape\'s own host") + '" value="' + escapeXml(row.baseUrl) + '"></td>'
2839
+ + '<td class="pv-test-cell">' + (row.canTest
2840
+ ? '<button class="btn small pv-test" type="button" data-testid="test-connection-' + e + '">Test</button><div class="hint pv-test-result" data-testid="test-result-' + e + '"></div>'
2841
+ : '<span class="hint">not available</span>') + "</td>"
2842
+ + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
2843
+ + "<td>" + bal + "</td></tr>";
2844
+ }).join("") : '<tr><td colspan="7" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
2845
+ pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
2846
+ authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
2847
+ fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
2848
+ }).catch(function(){ fetchEl.textContent = "— unavailable"; });
2849
+ });
2850
+ // a key exported only in the shell has no row: say so, by NAME, once per name
2851
+ document.getElementById("pv-shell").innerHTML = (r.shellOnly || []).map(function(n){
2852
+ return '<div class="hint" data-testid="pv-shell-' + escapeXml(n) + '">' + escapeXml(n) + " is set in your shell but not in ~/.config/bareloop/.env — add it there to use it here and in Chat.</div>";
2853
+ }).join("");
2854
+ pvNote.textContent = r.configProblem ? r.configProblem : PV_NOTE_DEFAULT;
2855
+ document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
2856
+ }).catch(function(){ pvNote.textContent = "could not read providers"; });
2857
+ }
2858
+ pvRows.addEventListener("change", function(e){
2859
+ var t = e.target;
2860
+ var tr = t.closest("tr");
2861
+ if(!tr) return;
2862
+ if(t.classList.contains("pv-balance")){
2863
+ authorPost("/api/settings/providers/balance-note", { usd: t.value.trim() === "" ? null : t.value.trim() }).then(function(r){
2864
+ pvNote.textContent = r.body && r.body.ok ? "Saved — balance note." : ((r.body && r.body.error) || "could not save");
2865
+ }).catch(function(){ pvNote.textContent = "could not save"; });
2866
+ return;
2867
+ }
2868
+ if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url"))) return;
2869
+ var env = tr.getAttribute("data-env");
2870
+ authorPost("/api/settings/providers/row", {
2871
+ envName: env,
2872
+ name: tr.querySelector(".pv-name").value,
2873
+ shape: tr.querySelector(".pv-shape").value,
2874
+ baseUrl: tr.querySelector(".pv-url").value,
2875
+ }).then(function(r){
2876
+ pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
2877
+ // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
2878
+ document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
2879
+ }).catch(function(){ pvNote.textContent = "could not save"; });
2880
+ });
2881
+ pvRows.addEventListener("click", function(e){
2882
+ var t = e.target;
2883
+ if(!t.classList || !t.classList.contains("pv-test")) return;
2884
+ var out = t.parentNode.querySelector(".pv-test-result");
2885
+ out.textContent = "testing…"; t.disabled = true;
2886
+ authorPost("/api/settings/providers/test", { envName: t.closest("tr").getAttribute("data-env") }).then(function(r){
2887
+ var b = r.body || {};
2888
+ out.textContent = b.reachable ? ("OK" + (b.ms !== null && b.ms !== undefined ? " · " + b.ms + " ms" : "")) : ("failed — " + (b.note || b.status || b.error || "no answer"));
2889
+ }).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
2890
+ });
2891
+ document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
2892
+ wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
2893
+ document.getElementById("tab-providers").addEventListener("click", loadProviders);
2894
+ // Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
2895
+ limitInput.addEventListener("change", function(){
2896
+ var text = limitInput.value.trim();
2897
+ authorPost("/api/settings/money", { monthlyLimitUsd: text === "" ? null : text }).then(function(r){
2898
+ var msg = r.body && r.body.ok
2899
+ ? (r.body.monthlyLimitUsd === null ? "Saved — no monthly limit." : "Saved — monthly limit " + panelMoney(r.body.monthlyLimitUsd) + ".")
2900
+ : ((r.body && r.body.error) || "could not save");
2901
+ keepNote = true;
2902
+ loadMoney();
2903
+ noteEl.textContent = msg;
2904
+ document.dispatchEvent(new CustomEvent("bareloop-limit-changed"));
2905
+ }).catch(function(){ noteEl.textContent = "could not save"; });
2906
+ });
2907
+ // verification convenience, like the mockup: ?settings=1 opens Settings on load
2908
+ if(location.search.indexOf("settings=1") !== -1){ document.getElementById("btn-settings").click(); }
2909
+ })();
2910
+
2911
+ // ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
2912
+ (function(){
2913
+ var newBtn = document.getElementById("chat-new-btn");
2914
+ var card = document.getElementById("job-card");
2915
+ var thread = document.getElementById("chat-thread");
2916
+ var msgInput = document.getElementById("chat-msg");
2917
+ var sendBtn = document.getElementById("chat-send-btn");
2918
+ var signBtn = document.getElementById("chat-sign-btn");
2919
+ var reviseBtn = document.getElementById("chat-revise-btn");
2920
+ var startBtn = document.getElementById("chat-start-btn");
2921
+ var checkDepsBtn = document.getElementById("chat-check-deps-btn");
2922
+ var errEl = document.getElementById("chat-card-error");
2923
+ var actionErrEl = document.getElementById("chat-action-error");
2924
+ var hashLine = document.getElementById("chat-hash-line");
2925
+ var moneyCapInput = document.getElementById("jf-cap-money");
2926
+ var modelSelect = document.getElementById("jf-model");
2927
+ var modelStatusEl = document.getElementById("jf-model-status");
2928
+ var progressRow = document.getElementById("chat-progress-row");
2929
+ var progressGlyph = document.getElementById("chat-progress-glyph");
2930
+ var progressLabelEl = document.getElementById("chat-progress-label");
2931
+
2932
+ var sessionId = null;
2933
+ var pollTimer = null;
2934
+ var lastPhase = null;
2935
+ var signClickedOnce = false;
2936
+ // build item 4 — Start disables the moment it's clicked and stays
2937
+ // disabled for the whole life of a non-terminal session (the server's
2938
+ // own one-at-a-time rule, `TERMINAL_PHASES` in
2939
+ // src/panel/authorroutes.js, mirrored here so Start never even offers a
2940
+ // click the server would 409 anyway).
2941
+ var CLIENT_TERMINAL_PHASES = ["refused", "abandoned", "error", "signed", "signing-failed"];
2942
+ var DONE_PHASES = ["prepared", "signed"];
2943
+ var sessionLive = false;
2944
+ // build item 7 — Start also stays disabled while the chosen model's key
2945
+ // isn't usable; updated by refreshModelStatus() below.
2946
+ var keyOk = false;
2947
+
2948
+ newBtn.addEventListener("click", function(){
2949
+ card.hidden = false;
2950
+ newBtn.hidden = true;
2951
+ thread.innerHTML = "";
2952
+ errEl.textContent = "";
2953
+ actionErrEl.textContent = "";
2954
+ sessionId = null;
2955
+ sessionLive = false;
2956
+ signClickedOnce = false;
2957
+ if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
2958
+ stopProgressDots();
2959
+ progressRow.hidden = true;
2960
+ checkDepsBtn.hidden = true;
2961
+ refreshStartEnabled();
2962
+ });
2963
+
2964
+ function currentCard(){
2965
+ var moneyCap = Number(document.getElementById("jf-cap-money").value);
2966
+ var timeCapMin = document.getElementById("jf-cap-time").value.trim();
2967
+ return {
2968
+ checkType: document.querySelector('input[name="jf-verdict"]:checked').value === "rubric" ? "rubric" : "deterministic",
2969
+ model: document.getElementById("jf-model").value,
2970
+ jobName: document.getElementById("jf-name").value.trim(),
2971
+ goal: document.getElementById("jf-goal").value.trim(),
2972
+ source: document.getElementById("jf-source").value.trim(),
2973
+ destination: document.getElementById("jf-dest").value.trim(),
2974
+ success: document.getElementById("jf-success").value.trim(),
2975
+ guardrails: document.getElementById("jf-guardrails").value.trim(),
2976
+ judgeExamples: document.getElementById("jf-judge").value.trim(),
2977
+ capUsd: moneyCap,
2978
+ maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
2979
+ };
2980
+ }
2981
+
2982
+ // hamr's ruling 2026-09-28 ("one cap covers drafting + run") — the
2983
+ // separate Drafting cap field is GONE; drafting runs under the same
2984
+ // `Cap $` field every run does. Start stays disabled until Cap $ parses
2985
+ // as a positive number, checked client-side as a UX convenience only;
2986
+ // the server (`validateJobCard`) is the real gate. Also disabled while a
2987
+ // session is already live (build item 4) or the chosen model's key
2988
+ // isn't usable (build item 7).
2989
+ function refreshStartEnabled(){
2990
+ var v = Number(moneyCapInput.value);
2991
+ var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
2992
+ startBtn.disabled = !capOk || sessionLive || !keyOk;
2993
+ }
2994
+ moneyCapInput.addEventListener("input", refreshStartEnabled);
2995
+ // P4a — the monthly-limit note sits right under the cap, as the person types.
2996
+ // Plain text, no button; the wording comes from the server (one library check).
2997
+ var capNoteEl = document.getElementById("jf-cap-note");
2998
+ var capNoteTimer = null;
2999
+ function refreshCapNote(){
3000
+ var v = Number(moneyCapInput.value);
3001
+ if(moneyCapInput.value.trim() === "" || !Number.isFinite(v) || v <= 0){ capNoteEl.textContent = ""; return; }
3002
+ authorGet("/api/author/monthly-check?cap=" + encodeURIComponent(String(v))).then(function(r){
3003
+ capNoteEl.textContent = (r && r.refusal) ? r.refusal : "";
3004
+ }).catch(function(){ capNoteEl.textContent = ""; });
3005
+ }
3006
+ moneyCapInput.addEventListener("input", function(){
3007
+ if(capNoteTimer){ clearTimeout(capNoteTimer); }
3008
+ capNoteTimer = setTimeout(refreshCapNote, 250);
3009
+ });
3010
+ // the limit changed in Settings: re-read the note for whatever cap is typed
3011
+ document.addEventListener("bareloop-limit-changed", refreshCapNote);
3012
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
3013
+ r.addEventListener("change", function(){
3014
+ var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
3015
+ document.getElementById("jf-judge").disabled = !isRubric;
3016
+ });
3017
+ });
3018
+
3019
+ // build item 7 / build item 5 (2026-09-28) — key + reachability, $0,
3020
+ // shown right under Model, on load and whenever the selection changes.
3021
+ // Start stays disabled while the key isn't usable; a reachability
3022
+ // failure shows red but never blocks (an endpoint can be flaky).
3023
+ // fix (2026-09-28): a 401/403 from the reachability probe is not
3024
+ // flakiness — it means the key itself was REJECTED by the provider.
3025
+ // Neither blocks Start (unchanged rule); only the WORDING differs.
3026
+ // Every other status (timeout, network error, 5xx, 429,
3027
+ // unsupported-provider) keeps the original "may be flaky" wording.
3028
+ function reachStatusText(status){
3029
+ var rejectedMatch = /^HTTP (401|403)$/.exec(status);
3030
+ return rejectedMatch
3031
+ ? "[✗] key rejected (HTTP " + rejectedMatch[1] + ")"
3032
+ : "[✗] " + status + " — may be flaky, not blocking";
3033
+ }
3034
+ // build item 5 — ONE line, not two: pure decision from the model-check
3035
+ // response (`r`) + the selected model's display id, so the whole
3036
+ // key/reachability read can be unit-tested without any DOM at all.
3037
+ // All good: "[✓] <ENV_KEY> found · <modelId> reachable". Otherwise: one
3038
+ // red line with the FIRST problem — a missing/bad key short-circuits
3039
+ // before reachability is even mentioned (reachability was never
3040
+ // checked in that case anyway — see the server route).
3041
+ // `keyOk` (Start-gating) and `ok` (display color) are DIFFERENT axes: a
3042
+ // reachability failure shows red (`ok:false`) but never blocks Start
3043
+ // (`keyOk` stays true) — an endpoint can be flaky; a missing/bad KEY
3044
+ // blocks Start and always shows red.
3045
+ function modelStatusLine(r, modelId){
3046
+ if(!r || !r.ok) return { keyOk: false, ok: false, text: "[✗] could not check the key" };
3047
+ var keyFound = r.keyStatus === "found";
3048
+ if(!keyFound){
3049
+ var problem = r.keyStatus === "missing" ? " not set" : (" " + r.keyProblem);
3050
+ return { keyOk: false, ok: false, text: "[✗] " + r.envKey + problem };
3051
+ }
3052
+ var reach = r.reachability;
3053
+ if(!reach || !reach.checked) return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found" };
3054
+ if(reach.reachable){
3055
+ var suffix = reach.modelListed === false ? " (model not listed)" : "";
3056
+ return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found · " + modelId + " reachable" + suffix };
3057
+ }
3058
+ return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
3059
+ }
3060
+ function refreshModelStatus(){
3061
+ var modelId = modelSelect.value;
3062
+ if(modelId === ""){
3063
+ keyOk = false;
3064
+ modelStatusEl.className = "jf-readiness bad";
3065
+ modelStatusEl.textContent = "[✗] no model — give a key a Name in Settings > Providers";
3066
+ refreshStartEnabled();
3067
+ return;
3068
+ }
3069
+ authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
3070
+ var line = modelStatusLine(r, modelId);
3071
+ keyOk = line.keyOk;
3072
+ modelStatusEl.className = "jf-readiness " + (line.ok ? "ok" : "bad");
3073
+ modelStatusEl.textContent = line.text;
3074
+ refreshStartEnabled();
3075
+ }).catch(function(){
3076
+ keyOk = false;
3077
+ modelStatusEl.className = "jf-readiness bad";
3078
+ modelStatusEl.textContent = "[✗] could not check the key";
3079
+ refreshStartEnabled();
3080
+ });
3081
+ }
3082
+ modelSelect.addEventListener("change", refreshModelStatus);
3083
+ // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
3084
+ // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
3085
+ function loadModels(){
3086
+ authorGet("/api/author/models").then(function(r){
3087
+ var models = r && r.ok ? r.models : [];
3088
+ var was = modelSelect.value;
3089
+ modelSelect.innerHTML = models.length ? models.map(function(m){
3090
+ return '<option value="' + escapeXml(m.id) + '">' + escapeXml(m.id) + " · " + escapeXml(m.envName) + "</option>";
3091
+ }).join("") : '<option value="">no model — add a Name in Settings</option>';
3092
+ if(models.some(function(m){ return m.id === was; })) modelSelect.value = was;
3093
+ refreshModelStatus();
3094
+ }).catch(function(){ refreshModelStatus(); });
3095
+ }
3096
+ document.addEventListener("bareloop-providers-changed", loadModels);
3097
+ loadModels();
3098
+
3099
+ // build item 2 — one plain line per event, no "(progress)" jargon
3100
+ // suffix: `onPhase` no longer posts its own bubble (see
3101
+ // src/panel/authorsession.js), so every remaining message is already
3102
+ // something a person needs to read.
3103
+ // build item 4 — a system line (a step notice: "Copying source ($0)",
3104
+ // "Packages found", …) carries no who-label at all — it is not a
3105
+ // message FROM anyone, just a plain step line. Only "you" and the
3106
+ // model's own bot replies get one.
3107
+ function chatWhoLabel(role){
3108
+ if(role === "you") return "you";
3109
+ if(role === "system") return "";
3110
+ return "bareloop";
3111
+ }
3112
+ function renderMessages(state){
3113
+ thread.innerHTML = state.messages.map(function(m){
3114
+ var cls = m.role === "you" ? "you" : (m.role === "system" ? "system" : "bot");
3115
+ var who = chatWhoLabel(m.role);
3116
+ var whoHtml = who ? ('<div class="who">' + escapeXml(who) + '</div>') : "";
3117
+ return '<div class="msg ' + cls + '">' + whoHtml + escapeXml(m.text) + '</div>';
3118
+ }).join("");
3119
+ thread.scrollTop = thread.scrollHeight;
3120
+ }
3121
+
3122
+ // build item 3 — the single live progress line: the LABEL sits first in
3123
+ // the markup, then the animated indicator (never the other way — the
3124
+ // dots must not push the label around as they cycle 1..4).
3125
+ var progressDotTimer = null;
3126
+ var progressDotCount = 1;
3127
+ var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
3128
+ function stopProgressDots(){
3129
+ if(progressDotTimer){ clearInterval(progressDotTimer); progressDotTimer = null; }
3130
+ }
3131
+ function startProgressDots(){
3132
+ if(reducedMotion){ progressGlyph.textContent = "[progress....]"; return; }
3133
+ if(progressDotTimer) return;
3134
+ progressDotCount = 1;
3135
+ progressGlyph.textContent = "[progress.]";
3136
+ progressDotTimer = setInterval(function(){
3137
+ progressDotCount = (progressDotCount % 4) + 1;
3138
+ progressGlyph.textContent = "[progress" + new Array(progressDotCount + 1).join(".") + "]";
3139
+ }, 500);
3140
+ }
3141
+ // build item 3 — pure: what the label must say for a given state. A
3142
+ // person waiting on their OWN next action (an ask is pending) must never
3143
+ // see a stale machine step name (observed: "checking prior behaviour"
3144
+ // shown while waiting on hamr; "plan confirmed" shown while the plan was
3145
+ // awaiting his confirm) — a pending ask always wins over progressLabel.
3146
+ function progressLabelFor(state){
3147
+ var pending = state && state.pendingAsk;
3148
+ if(pending && pending.kind === "menu") return "waiting for your OK";
3149
+ if(pending && pending.kind === "install-needed") return "install needed";
3150
+ if(pending) return "waiting for your answer";
3151
+ return (state && state.progressLabel) || "working";
3152
+ }
3153
+ function renderProgress(state){
3154
+ progressRow.hidden = false;
3155
+ if(DONE_PHASES.indexOf(state.phase) !== -1){
3156
+ stopProgressDots();
3157
+ progressGlyph.className = "chat-progress-glyph done";
3158
+ progressGlyph.textContent = "[done]";
3159
+ progressLabelEl.textContent = "";
3160
+ } else if(CLIENT_TERMINAL_PHASES.indexOf(state.phase) !== -1){
3161
+ stopProgressDots();
3162
+ progressGlyph.className = "chat-progress-glyph stopped";
3163
+ progressGlyph.textContent = "[stopped]";
3164
+ progressLabelEl.textContent = state.error || "";
3165
+ } else {
3166
+ progressGlyph.className = "chat-progress-glyph";
3167
+ progressLabelEl.textContent = progressLabelFor(state);
3168
+ startProgressDots();
3169
+ }
3170
+ }
3171
+
3172
+ function renderActions(state){
3173
+ var pending = state.pendingAsk;
3174
+ var atMenu = !!pending && pending.kind === "menu";
3175
+ var atDepsWait = !!pending && pending.kind === "install-needed";
3176
+ sendBtn.disabled = !pending || atMenu || atDepsWait;
3177
+ reviseBtn.disabled = !atMenu || state.revisesLeft <= 0;
3178
+ reviseBtn.textContent = "Revise (" + state.revisesLeft + " left)";
3179
+ checkDepsBtn.hidden = !atDepsWait;
3180
+ checkDepsBtn.disabled = !atDepsWait;
3181
+ if(state.phase === "prepared" && !signClickedOnce){
3182
+ signBtn.disabled = false;
3183
+ signBtn.textContent = "Sign & run";
3184
+ } else if(state.phase === "prepared" && signClickedOnce){
3185
+ signBtn.disabled = false;
3186
+ signBtn.textContent = "Sign " + (state.specHash || "").slice(0, 8) + " & run";
3187
+ } else {
3188
+ signBtn.disabled = !atMenu;
3189
+ signBtn.textContent = "Sign & run";
3190
+ }
3191
+ if(state.specHash){
3192
+ hashLine.hidden = false;
3193
+ hashLine.textContent = "spec hash: " + state.specHash;
3194
+ }
3195
+ if(state.phase === "refused" || state.phase === "error" || state.phase === "abandoned"){
3196
+ errEl.textContent = state.error || ("stopped: " + state.phase);
3197
+ } else {
3198
+ errEl.textContent = "";
3199
+ }
3200
+ renderProgress(state);
3201
+ // build item 4 — re-enable Start only once the session has settled
3202
+ // into a terminal phase (a fresh Start becomes possible again).
3203
+ sessionLive = CLIENT_TERMINAL_PHASES.indexOf(state.phase) === -1;
3204
+ refreshStartEnabled();
3205
+ }
3206
+
3207
+ function poll(){
3208
+ if(!sessionId) return;
3209
+ authorGet("/api/author/" + sessionId).then(function(j){
3210
+ if(!j || !j.state) return;
3211
+ renderMessages(j.state);
3212
+ renderActions(j.state);
3213
+ if(j.state.phase === "signed"){
3214
+ clearInterval(pollTimer);
3215
+ document.getElementById("tab-runs").click();
3216
+ }
3217
+ lastPhase = j.state.phase;
3218
+ }).catch(function(){ /* transient — next poll tries again */ });
3219
+ }
3220
+
3221
+ startBtn.addEventListener("click", function(){
3222
+ errEl.textContent = "";
3223
+ actionErrEl.textContent = "";
3224
+ // build item 4 — disable the moment it's clicked, not just once the
3225
+ // response comes back; re-enabled below only if the start is refused.
3226
+ sessionLive = true;
3227
+ refreshStartEnabled();
3228
+ var body = currentCard();
3229
+ authorPost("/api/author/start", body).then(function(r){
3230
+ if(r.status !== 200 || !r.body.ok){
3231
+ errEl.textContent = (r.body && r.body.error) || "refused";
3232
+ sessionLive = false;
3233
+ refreshStartEnabled();
3234
+ return;
3235
+ }
3236
+ sessionId = r.body.sessionId;
3237
+ signClickedOnce = false;
3238
+ renderMessages(r.body.state);
3239
+ renderActions(r.body.state);
3240
+ if(pollTimer) clearInterval(pollTimer);
3241
+ pollTimer = setInterval(poll, 2000);
3242
+ });
3243
+ });
3244
+
3245
+ // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
3246
+ // (fed by the every-2s `poll()`) unconditionally overwrites
3247
+ // `#chat-card-error` on every tick (`errEl.textContent = state.error ||
3248
+ // ""`), including the SAME tick the send/revise/sign handlers below
3249
+ // used to call `poll()` in their own `.then` — so a refused/failed POST's
3250
+ // error was wiped before a person could read it (often on the very next
3251
+ // line of the same callback). Compounding it, `send`/`revise` cleared
3252
+ // `msgInput.value` unconditionally, even on failure — so a rejected
3253
+ // answer vanished from the box with no trace at all. Fixed by: (1) a
3254
+ // dedicated `#chat-action-error` element only these handlers touch,
3255
+ // never `poll()`/`renderActions`; (2) only clearing the input on a
3256
+ // confirmed 200+ok response; (3) an explicit `.catch` so a rejected
3257
+ // fetch (network drop, server restart) shows an error too, instead of
3258
+ // silently doing nothing.
3259
+ // build item 1 — the ONE place that decides "did this chat POST
3260
+ // succeed" — pure, no DOM, unit-tested directly
3261
+ // (tests/panel-page.test.js). Before this existed, each handler decided
3262
+ // ad hoc via `r.body && r.body.error && !r.body.ok`, which is true only
3263
+ // when the server bothered to hand back BOTH `ok:false` AND a non-empty
3264
+ // `error` string — a malformed/short-circuited response (e.g. a 403 the
3265
+ // guard returns with `{ok:false, error:'refused — ...'}` reaching a
3266
+ // caller that never checked `r.status`, or any response missing
3267
+ // `.error`) read as neither branch and was silently treated as fine.
3268
+ function chatPostOutcome(r){
3269
+ var ok = !!(r && r.status === 200 && r.body && r.body.ok === true);
3270
+ return { ok: ok, error: ok ? "" : ((r && r.body && r.body.error) || "the request failed — nothing was sent") };
3271
+ }
3272
+ function chatActionFailed(message){
3273
+ actionErrEl.textContent = message || "the request failed — nothing was sent";
3274
+ }
3275
+ function chatActionOk(){
3276
+ actionErrEl.textContent = "";
3277
+ }
3278
+
3279
+ checkDepsBtn.addEventListener("click", function(){
3280
+ if(!sessionId) return;
3281
+ checkDepsBtn.disabled = true;
3282
+ authorPost("/api/author/" + sessionId + "/check-deps", {}).then(function(r){
3283
+ var o = chatPostOutcome(r);
3284
+ if(o.ok) chatActionOk(); else chatActionFailed(o.error);
3285
+ poll();
3286
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, nothing was sent"); });
3287
+ });
3288
+
3289
+ sendBtn.addEventListener("click", function(){
3290
+ if(!sessionId) return;
3291
+ var text = msgInput.value;
3292
+ if(!text.trim()) return;
3293
+ authorPost("/api/author/" + sessionId + "/send", {text: text}).then(function(r){
3294
+ var o = chatPostOutcome(r);
3295
+ if(o.ok){
3296
+ chatActionOk();
3297
+ msgInput.value = "";
3298
+ } else {
3299
+ // failure — the text stays in the box so nothing is lost; the
3300
+ // person can just click Send again once the error is fixed.
3301
+ chatActionFailed(o.error);
3302
+ }
3303
+ poll();
3304
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3305
+ });
3306
+
3307
+ reviseBtn.addEventListener("click", function(){
3308
+ if(!sessionId) return;
3309
+ var text = msgInput.value;
3310
+ authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
3311
+ var o = chatPostOutcome(r);
3312
+ if(o.ok){
3313
+ chatActionOk();
3314
+ msgInput.value = "";
3315
+ } else {
3316
+ chatActionFailed(o.error);
3317
+ }
3318
+ poll();
3319
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
3320
+ });
3321
+
3322
+ signBtn.addEventListener("click", function(){
3323
+ if(!sessionId) return;
3324
+ if(!signClickedOnce){
3325
+ // click 1 — the confirm turn's own "confirm" pick. Never signs.
3326
+ authorPost("/api/author/" + sessionId + "/sign-prepare", {}).then(function(r){
3327
+ var o = chatPostOutcome(r);
3328
+ if(o.ok) chatActionOk(); else chatActionFailed(o.error);
3329
+ signClickedOnce = true;
3330
+ poll();
3331
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server"); });
3332
+ return;
3333
+ }
3334
+ // click 2 — the ONLY route that signs. Carries the hash shown on the
3335
+ // card; the server re-checks it against signing.json regardless.
3336
+ authorGet("/api/author/" + sessionId).then(function(j){
3337
+ var hash = j.state && j.state.specHash;
3338
+ if(!hash) return;
3339
+ authorPost("/api/author/" + sessionId + "/sign", {specHash: hash}).then(function(r){
3340
+ var o = chatPostOutcome(r);
3341
+ if(o.ok){ chatActionOk(); } else { chatActionFailed(o.error); return; }
3342
+ poll();
3343
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not signed"); });
3344
+ });
3345
+ });
3346
+
3347
+ refreshStartEnabled();
3348
+ })();
3349
+
2153
3350
  })();
2154
3351
  </script>
2155
3352
  </body>