bareloop 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/bareloop.context.md +125 -4
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +28 -12
- package/src/panel/authorroutes.js +283 -0
- package/src/panel/authorsession.js +557 -0
- package/src/panel/index.html +972 -49
- package/src/panel/server.js +425 -34
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providers.js +82 -0
- package/src/replay.js +80 -2
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/userrun.js +75 -8
- package/types/authorflow.d.ts +4 -21
- package/types/panel/authorroutes.d.ts +65 -0
- package/types/panel/authorsession.d.ts +61 -0
- package/types/panel/server.d.ts +33 -3
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/userrun.d.ts +17 -0
package/src/panel/index.html
CHANGED
|
@@ -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,13 +310,72 @@
|
|
|
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
|
-
|
|
320
|
+
/* P3 build (Chat/authoring): job-card compact mode, radio row, the $/time/
|
|
321
|
+
drafting cap row, and the chat thread's message bubbles — ported from
|
|
322
|
+
design/panel-mockup.html (the visual contract), re-themed onto this
|
|
323
|
+
page's own existing colour tokens rather than the mockup's own. */
|
|
324
|
+
.job-card.compact{padding:14px 8px 8px;}
|
|
325
|
+
.job-card.compact .field{margin-bottom:6px;}
|
|
326
|
+
.job-card.compact label{margin-bottom:1px;font-size:11px;}
|
|
327
|
+
.job-card.compact input,
|
|
328
|
+
.job-card.compact select{padding:5px 7px;}
|
|
329
|
+
.expand-field{text-overflow:ellipsis;transition:height 0.12s ease;}
|
|
330
|
+
.expand-field:focus{height:52px;white-space:normal;}
|
|
331
|
+
.radio-row{display:flex;gap:14px;align-items:center;margin-bottom:10px;}
|
|
332
|
+
.radio-row label{display:flex;align-items:center;gap:5px;font-size:13px;color:var(--text);margin:0;text-transform:none;}
|
|
333
|
+
.cap-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-bottom:6px;}
|
|
334
|
+
.cap-field label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:1px;}
|
|
335
|
+
.cap-field input{width:100%;padding:5px 6px;box-sizing:border-box;}
|
|
336
|
+
@media (max-width: 899px){
|
|
337
|
+
.cap-row{grid-template-columns:1fr;}
|
|
338
|
+
}
|
|
339
|
+
.btn.primary{background:var(--primary-bg);color:var(--primary-text);border-color:var(--primary-border);font-weight:700;}
|
|
340
|
+
.btn.primary::before,.btn.primary::after{color:var(--primary-text);opacity:0.6;}
|
|
341
|
+
.btn:disabled{opacity:0.5;cursor:not-allowed;}
|
|
342
|
+
/* a disabled .btn.primary must read as disabled, not just a dimmed version
|
|
343
|
+
of the enabled primary fill — .btn.primary's own background/color
|
|
344
|
+
(declared above, equal specificity) would otherwise win over the plain
|
|
345
|
+
.btn:disabled rule and still look clickable at 50% opacity. */
|
|
346
|
+
.btn.primary:disabled{background:var(--btn-bg);color:var(--text-faint);border-color:var(--border);}
|
|
347
|
+
.btn.primary:disabled::before,.btn.primary:disabled::after{color:var(--text-faint);opacity:0.4;}
|
|
348
|
+
|
|
349
|
+
/* P3 build item 3 — the single live progress line: an amber cycling
|
|
350
|
+
[progress.]/[progress..]/[progress...] glyph while a phase is running,
|
|
351
|
+
[done] in green once it finishes, [stopped] in red on a refusal. A
|
|
352
|
+
static [progress...] under prefers-reduced-motion (no animation). */
|
|
353
|
+
.chat-progress-row{display:flex;align-items:center;gap:8px;margin:2px 0 10px;font-size:12px;}
|
|
354
|
+
.chat-progress-glyph{font-weight:700;color:var(--amber);}
|
|
355
|
+
.chat-progress-glyph.done{color:var(--green);}
|
|
356
|
+
.chat-progress-glyph.stopped{color:var(--red);}
|
|
357
|
+
.chat-progress-label{color:var(--text-faint);}
|
|
358
|
+
/* build item 7 — the two $0 readiness lines under the Model field. */
|
|
359
|
+
.jf-readiness{font-size:11px;margin-top:2px;}
|
|
360
|
+
.jf-readiness.ok{color:var(--green);}
|
|
361
|
+
.jf-readiness.bad{color:var(--red);}
|
|
362
|
+
.jf-readiness.unknown{color:var(--text-faint);}
|
|
363
|
+
|
|
364
|
+
.chat-thread{display:flex;flex-direction:column;gap:10px;margin-bottom:10px;}
|
|
365
|
+
.msg{padding:8px 10px;border:1px solid var(--border);max-width:100%;background:var(--panel2);font-size:13px;word-break:break-word;white-space:pre-wrap;}
|
|
366
|
+
.msg.bot{border-left:3px solid var(--border-strong);}
|
|
367
|
+
.msg.bot .who::before{content:"$ ";}
|
|
368
|
+
.msg.you{background:var(--bg);margin-left:20px;border-left:3px solid var(--accent);}
|
|
369
|
+
.msg.you .who::before{content:"> ";}
|
|
370
|
+
.msg.system{color:var(--text-faint);border-left:3px dotted var(--text-faint);font-size:12px;}
|
|
371
|
+
.msg .who{font-size:11px;color:var(--text-faint);margin-bottom:3px;text-transform:none;font-weight:400;}
|
|
372
|
+
.chat-input-row{margin-top:10px;border-top:1px dotted var(--border-strong);padding-top:10px;}
|
|
373
|
+
.chat-input-row input{width:100%;box-sizing:border-box;}
|
|
374
|
+
/* chat message box reads as an unmistakable text box: ~2x the default
|
|
375
|
+
input height, thicker 2px border (ported verbatim from the mockup). */
|
|
376
|
+
#chat-msg{min-height:64px;padding:8px 12px;border:2px solid var(--border-strong);}
|
|
377
|
+
.actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
|
|
378
|
+
.actions-row > .btn{flex:1 1 auto;}
|
|
300
379
|
|
|
301
380
|
@media (max-width: 899px){
|
|
302
381
|
.main{flex-direction:column;}
|
|
@@ -338,12 +417,106 @@
|
|
|
338
417
|
<div class="pane-body" id="left-pane-body">
|
|
339
418
|
|
|
340
419
|
<section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
420
|
+
<button class="btn" type="button" id="chat-new-btn" data-testid="chat-new">+ New</button>
|
|
421
|
+
|
|
422
|
+
<div class="job-card compact" id="job-card" data-testid="job-card" hidden>
|
|
423
|
+
<h4 id="job-card-title">Job card — draft</h4>
|
|
424
|
+
|
|
425
|
+
<div class="field">
|
|
426
|
+
<label id="jf-verdict-label">Check type</label>
|
|
427
|
+
<div class="radio-row" role="radiogroup" aria-labelledby="jf-verdict-label">
|
|
428
|
+
<label><input type="radio" name="jf-verdict" value="deterministic" checked> Deterministic</label>
|
|
429
|
+
<label><input type="radio" name="jf-verdict" value="rubric"> Rubric</label>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
<div class="field">
|
|
433
|
+
<label for="jf-model">Model</label>
|
|
434
|
+
<select id="jf-model">
|
|
435
|
+
<option value="claude-sonnet-5">claude-sonnet-5 · Anthropic</option>
|
|
436
|
+
<option value="deepseek-flash">deepseek-flash · DeepSeek</option>
|
|
437
|
+
</select>
|
|
438
|
+
<!-- build item 5 (2026-09-28) — ONE line under Model, not two:
|
|
439
|
+
key + reachability collapsed into a single readiness read. -->
|
|
440
|
+
<div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="field">
|
|
443
|
+
<label for="jf-name">Job name</label>
|
|
444
|
+
<input id="jf-name" type="text" placeholder="kebab-case, unique">
|
|
445
|
+
</div>
|
|
446
|
+
<div class="field">
|
|
447
|
+
<label for="jf-goal">Goal</label>
|
|
448
|
+
<input class="expand-field" id="jf-goal" type="text">
|
|
449
|
+
</div>
|
|
450
|
+
<div class="field">
|
|
451
|
+
<label for="jf-source">Source</label>
|
|
452
|
+
<input id="jf-source" type="text" placeholder="absolute path to a local repo">
|
|
453
|
+
</div>
|
|
454
|
+
<div class="field">
|
|
455
|
+
<label for="jf-dest">Destination</label>
|
|
456
|
+
<input id="jf-dest" type="text" placeholder="the write fence — where in the repo this run may change files">
|
|
457
|
+
</div>
|
|
458
|
+
<div class="field">
|
|
459
|
+
<label for="jf-success">Success</label>
|
|
460
|
+
<input class="expand-field" id="jf-success" type="text">
|
|
461
|
+
</div>
|
|
462
|
+
<div class="field">
|
|
463
|
+
<label for="jf-guardrails">Guardrails</label>
|
|
464
|
+
<input id="jf-guardrails" type="text">
|
|
465
|
+
</div>
|
|
466
|
+
<div class="field">
|
|
467
|
+
<label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
|
|
468
|
+
<input class="expand-field" id="jf-judge" type="text" disabled>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
471
|
+
<div class="cap-row" data-testid="cap-row">
|
|
472
|
+
<div class="cap-field">
|
|
473
|
+
<label for="jf-cap-money">Cap $</label>
|
|
474
|
+
<input id="jf-cap-money" type="text">
|
|
475
|
+
</div>
|
|
476
|
+
<div class="cap-field">
|
|
477
|
+
<label for="jf-cap-time">Time cap (min)</label>
|
|
478
|
+
<input id="jf-cap-time" type="text" placeholder="or blank = no wall">
|
|
479
|
+
</div>
|
|
480
|
+
<div class="cap-field">
|
|
481
|
+
<label for="jf-price">Token price $</label>
|
|
482
|
+
<input id="jf-price" type="text" placeholder="est." disabled>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
<button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
|
|
486
|
+
<div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
|
|
487
|
+
<div class="hint" id="chat-hash-line" data-testid="chat-hash-line" hidden></div>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
<!-- build item 3 — the label comes FIRST, then the animated
|
|
491
|
+
indicator, so the cycling dots ([progress.] .. [progress....])
|
|
492
|
+
never push the label around. -->
|
|
493
|
+
<div class="chat-progress-row" id="chat-progress-row" data-testid="chat-progress-row" hidden>
|
|
494
|
+
<span class="chat-progress-label" id="chat-progress-label" data-testid="chat-progress-label"></span>
|
|
495
|
+
<span class="chat-progress-glyph" id="chat-progress-glyph" data-testid="chat-progress-glyph"></span>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
|
|
499
|
+
|
|
500
|
+
<!-- build item 1 — a failed/rejected send, revise, sign-prepare,
|
|
501
|
+
sign or check-deps POST shows here and STAYS shown: it is set
|
|
502
|
+
only by this tab's own action handlers below and is never
|
|
503
|
+
touched by renderActions()/poll() (which own #chat-card-error
|
|
504
|
+
instead), so it can never be wiped by the next 2s poll tick
|
|
505
|
+
before a person reads it. -->
|
|
506
|
+
<div class="hint" id="chat-action-error" data-testid="chat-action-error"></div>
|
|
507
|
+
|
|
508
|
+
<div class="chat-input-row">
|
|
509
|
+
<label for="chat-msg" style="display:none">Message</label>
|
|
510
|
+
<input id="chat-msg" type="text" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg">
|
|
346
511
|
</div>
|
|
512
|
+
<button class="btn" type="button" id="chat-check-deps-btn" data-testid="chat-check-deps" hidden>Check again</button>
|
|
513
|
+
|
|
514
|
+
<div class="actions-row">
|
|
515
|
+
<button class="btn" type="button" id="chat-send-btn" data-testid="chat-send" disabled>Send</button>
|
|
516
|
+
<button class="btn primary" type="button" id="chat-sign-btn" data-testid="btn-sign-run" disabled>Sign & run</button>
|
|
517
|
+
<button class="btn" type="button" id="chat-revise-btn" data-testid="btn-revise" disabled>Revise (2 left)</button>
|
|
518
|
+
</div>
|
|
519
|
+
<div class="hint">Only your click signs. The chat can't.</div>
|
|
347
520
|
</section>
|
|
348
521
|
|
|
349
522
|
<section id="panel-runs" class="tabpanel" role="tabpanel" aria-labelledby="tab-runs" hidden>
|
|
@@ -423,7 +596,7 @@
|
|
|
423
596
|
|
|
424
597
|
<section id="panel-details" class="tabpanel" role="tabpanel" aria-labelledby="tab-details" hidden data-testid="panel-details">
|
|
425
598
|
<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">
|
|
599
|
+
<div class="job-card" id="job-card-readonly" hidden data-testid="job-card-readonly">
|
|
427
600
|
<h4 id="details-title">Job card — signed</h4>
|
|
428
601
|
<div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
|
|
429
602
|
<div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
|
|
@@ -454,8 +627,24 @@
|
|
|
454
627
|
"use strict";
|
|
455
628
|
|
|
456
629
|
var PORT = "__BARELOOP_PANEL_PORT__";
|
|
630
|
+
// P3 human-click guard: minted once per server start, sent on every POST
|
|
631
|
+
// this page ever makes (`x-bareloop-token`) — never logged, never put in
|
|
632
|
+
// a URL, and it never signs anything on its own (the sign route also
|
|
633
|
+
// requires the exact spec hash, checked server-side).
|
|
634
|
+
var TOKEN = "__BARELOOP_PANEL_TOKEN__";
|
|
457
635
|
document.getElementById("host-line").textContent = "127.0.0.1:" + PORT;
|
|
458
636
|
|
|
637
|
+
function authorPost(path, body){
|
|
638
|
+
return fetch(path, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
headers: {"content-type": "application/json", "x-bareloop-token": TOKEN},
|
|
641
|
+
body: JSON.stringify(body || {}),
|
|
642
|
+
}).then(function(r){ return r.json().then(function(j){ return {status: r.status, body: j}; }); });
|
|
643
|
+
}
|
|
644
|
+
function authorGet(path){
|
|
645
|
+
return fetch(path, {headers: {"x-bareloop-token": TOKEN}}).then(function(r){ return r.json(); });
|
|
646
|
+
}
|
|
647
|
+
|
|
459
648
|
function wireTabs(tabButtons){
|
|
460
649
|
tabButtons.forEach(function(btn){
|
|
461
650
|
btn.addEventListener("click", function(){
|
|
@@ -524,6 +713,20 @@
|
|
|
524
713
|
if(outcome === null || outcome === undefined) return boxIsLive ? "▶" : "?";
|
|
525
714
|
return "✗";
|
|
526
715
|
}
|
|
716
|
+
// P3 addition: colour ONLY [✓]/[✗] (the standing wording ruling's own
|
|
717
|
+
// words/glyph vocabulary is unchanged — this wraps the same glyph
|
|
718
|
+
// character in a plain inline colour span for the two result-carrying
|
|
719
|
+
// cases; [▶]/[?] pass through unwrapped, exactly as before this addition).
|
|
720
|
+
function glyphSpan(g){
|
|
721
|
+
if(g === "✓") return '<span class="glyph-green">' + g + '</span>';
|
|
722
|
+
if(g === "✗") return '<span class="glyph-red">' + g + '</span>';
|
|
723
|
+
return g;
|
|
724
|
+
}
|
|
725
|
+
// PLAIN TEXT ONLY — feeds the SVG step map's title/width calculation
|
|
726
|
+
// (`stepTitleText`, an SVG <text> node), never innerHTML; a colour span
|
|
727
|
+
// here would render as literal "<span>" characters inside the map.
|
|
728
|
+
// {@link glyphSpan} (the P3 colour addition) is used only by the DOM
|
|
729
|
+
// part-card renderer below, never by this function.
|
|
527
730
|
function attemptsInlineText(attempts, boxIsLive){
|
|
528
731
|
if(!attempts || !attempts.length) return "";
|
|
529
732
|
return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome, boxIsLive); }).join(" · ");
|
|
@@ -578,9 +781,9 @@
|
|
|
578
781
|
function partResultGlyph(part, box){
|
|
579
782
|
if(partHasNoVerdict(part)) return null;
|
|
580
783
|
var boxIsLive = box.state === "running";
|
|
581
|
-
if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome, boxIsLive); }).join("");
|
|
784
|
+
if(box.attempts.length > 1) return box.attempts.map(function(a){ return glyphSpan(attemptGlyph(a.outcome, boxIsLive)); }).join("");
|
|
582
785
|
var outcome = (part.outcome !== null && part.outcome !== undefined) ? part.outcome : (box.attempts[0] ? box.attempts[0].outcome : null);
|
|
583
|
-
return attemptGlyph(outcome, boxIsLive);
|
|
786
|
+
return glyphSpan(attemptGlyph(outcome, boxIsLive));
|
|
584
787
|
}
|
|
585
788
|
function buildOrderedBoxes(parts, isLive){
|
|
586
789
|
var list = Array.isArray(parts) ? parts : [];
|
|
@@ -871,7 +1074,7 @@
|
|
|
871
1074
|
'<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
|
|
872
1075
|
'<span class="wf-meta-line">' +
|
|
873
1076
|
'<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
|
|
874
|
-
'<span class="wf-meta">' + escapeXml(r
|
|
1077
|
+
'<span class="wf-meta">' + escapeXml(rowSpendText(r)) + '</span>' +
|
|
875
1078
|
'<span class="wf-meta">' + escapeXml(r.wall) + '</span>' +
|
|
876
1079
|
'<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
|
|
877
1080
|
'</span>';
|
|
@@ -918,7 +1121,7 @@
|
|
|
918
1121
|
lastGlyph: last.glyph || "▶",
|
|
919
1122
|
lastCheckType: last.checkType || "unknown",
|
|
920
1123
|
lastModel: last.model || null,
|
|
921
|
-
lastSpend: last
|
|
1124
|
+
lastSpend: rowSpendText(last),
|
|
922
1125
|
lastWall: last.wall || "unknown",
|
|
923
1126
|
lastDate: last.date,
|
|
924
1127
|
};
|
|
@@ -1334,7 +1537,78 @@
|
|
|
1334
1537
|
}
|
|
1335
1538
|
|
|
1336
1539
|
// ---- Run tab ----
|
|
1337
|
-
|
|
1540
|
+
// panelMoney — hamr's ruling 2026-09-28 ("panel money 2-decimals"): every
|
|
1541
|
+
// money display in THIS page (Run tab summary/counters, Runs list rows,
|
|
1542
|
+
// Audit header/part cards, Job tab, chat cost lines) renders through this
|
|
1543
|
+
// ONE function — no per-site rounding. The CLI's own 4-decimal `money()`
|
|
1544
|
+
// (src/replay.js) is UNCHANGED; this is the panel-only render, the
|
|
1545
|
+
// browser-script sibling of `src/panel/authorroutes.js`'s own
|
|
1546
|
+
// `panelMoney2` (same rule, can't share code — one runs in Node, one
|
|
1547
|
+
// inlined in the page).
|
|
1548
|
+
// - null/undefined/non-finite -> "unknown" (never a fabricated $0).
|
|
1549
|
+
// - a REAL positive amount below a cent -> "<$0.01", never "$0.00" (a
|
|
1550
|
+
// tiny real cost must never read as free).
|
|
1551
|
+
// - `floor` (this is a FLOOR/"at least" figure, never exact) rounds DOWN
|
|
1552
|
+
// to 2 decimals — never overstates a floor as exact-higher.
|
|
1553
|
+
// - otherwise rounds half-up to 2 decimals (an exact zero prints
|
|
1554
|
+
// "$0.00" — nothing spent is a real number, not an unknown).
|
|
1555
|
+
// Two-step rounding guards against float-representation noise on BOTH
|
|
1556
|
+
// sides: dollar figures here are derived from at most 6-decimal-precision
|
|
1557
|
+
// money (the same precision `src/text.js`'s `tallyCalls`/`priceOf` use),
|
|
1558
|
+
// so rounding to 6 decimals first erases that noise (e.g. 0.815 really
|
|
1559
|
+
// stored as 0.81499999999999995), and ONLY THEN is the value rounded/
|
|
1560
|
+
// floored to cents — a value already exactly on a cent boundary (e.g.
|
|
1561
|
+
// 0.82) can be stored as 81.99999999999999 cents post-multiply, and a
|
|
1562
|
+
// floor there would wrongly drop a whole cent; the tiny `+1e-6` nudge (at
|
|
1563
|
+
// dollar-cents scale, far below anything a real half-cent tie could be)
|
|
1564
|
+
// corrects that without ever crossing a true half-cent boundary the wrong
|
|
1565
|
+
// way.
|
|
1566
|
+
function panelMoney(n, floor){
|
|
1567
|
+
if(n === null || n === undefined) return "unknown";
|
|
1568
|
+
var v = Number(n);
|
|
1569
|
+
if(!isFinite(v)) return "unknown";
|
|
1570
|
+
if(v > 0 && v < 0.01) return "<$0.01";
|
|
1571
|
+
var clean = Math.round(v * 1e6) / 1e6;
|
|
1572
|
+
var cents = floor ? Math.floor(clean * 100 + 1e-6) : Math.round(clean * 100 + 1e-6);
|
|
1573
|
+
if(cents < 0) cents = 0;
|
|
1574
|
+
return "$" + (cents / 100).toFixed(2);
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* The panel's one composed "spend (drafting) of cap" renderer — the
|
|
1578
|
+
* browser-script sibling of `src/replay.js`'s own `moneyWithDraft`, same
|
|
1579
|
+
* rules, 2-decimal via {@link panelMoney} instead of 4. `spendCoreFloor`
|
|
1580
|
+
* marks the LEADING figure a floor (a died run's own spend-floor, OR a
|
|
1581
|
+
* complete run whose drafting share is itself incomplete — hamr's ruling:
|
|
1582
|
+
* a reader summing the two must never read the leading number as exact
|
|
1583
|
+
* when the bracket next to it isn't). `draftSpendComplete === false` marks
|
|
1584
|
+
* the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX".
|
|
1585
|
+
*/
|
|
1586
|
+
function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd){
|
|
1587
|
+
var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
|
|
1588
|
+
var draftFloor = hasDraft && draftSpendComplete === false;
|
|
1589
|
+
var core = (spendCoreVal === null || spendCoreVal === undefined)
|
|
1590
|
+
? "unknown"
|
|
1591
|
+
: ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
|
|
1592
|
+
var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
|
|
1593
|
+
var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
|
|
1594
|
+
return core + draftPart + capPart;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* The Runs list row / Workflows grouping's own spend text — off the
|
|
1598
|
+
* numeric fields `/api/runs`' row now carries (never `r.spend`, which is
|
|
1599
|
+
* the LIBRARY's own 4-decimal string kept unchanged for `--all`'s CLI
|
|
1600
|
+
* listing to share; `src/panel/server.js`'s `summarizeRow` doc says why).
|
|
1601
|
+
* Same floor rules as {@link panelMoneyWithDraft}/`renderRun`'s own
|
|
1602
|
+
* `spendText`: a died row's spend is a floor by construction, a live row's
|
|
1603
|
+
* is a floor only when its own drafting share is incomplete.
|
|
1604
|
+
* @param {any} r a `/api/runs` row (or a Workflows group's `last` row)
|
|
1605
|
+
*/
|
|
1606
|
+
function rowSpendText(r){
|
|
1607
|
+
var draftIncomplete = typeof r.draftSpentUsd === "number" && r.draftSpentUsd !== null && r.draftSpendComplete === false;
|
|
1608
|
+
var coreVal = r.died ? r.spendFloorUsd : r.spentUsd;
|
|
1609
|
+
var coreFloor = !!r.died || draftIncomplete;
|
|
1610
|
+
return panelMoneyWithDraft(coreVal, coreFloor, r.draftSpentUsd, r.draftSpendComplete);
|
|
1611
|
+
}
|
|
1338
1612
|
function duration(ms){
|
|
1339
1613
|
if(ms === null || ms === undefined) return "unknown";
|
|
1340
1614
|
if(ms < 0) return "unknown";
|
|
@@ -1345,6 +1619,31 @@
|
|
|
1345
1619
|
return m + "m" + String(rem).padStart(2, "0") + "s";
|
|
1346
1620
|
}
|
|
1347
1621
|
|
|
1622
|
+
/**
|
|
1623
|
+
* build item 6 (2026-09-28, session mul5fofw) — while a run is LIVE (no
|
|
1624
|
+
* job-end reached yet: `detail.spentUsd === null` and not died), the Run
|
|
1625
|
+
* tab's summary must show a running FLOOR from the spine's own metered
|
|
1626
|
+
* rounds instead of "unknown" — never presented as exact ("so far", never
|
|
1627
|
+
* "at least" — this is a live, still-growing number, not a died run's
|
|
1628
|
+
* fixed floor). Reuses {@link panelMoney} for the actual digits; never a
|
|
1629
|
+
* second hand-rolled money format.
|
|
1630
|
+
* @param {number|null} spendFloorUsd @param {number|null} draftSpentUsd
|
|
1631
|
+
* @param {boolean|null} draftSpendComplete @returns {string}
|
|
1632
|
+
*/
|
|
1633
|
+
function liveSpendText(spendFloorUsd, draftSpentUsd, draftSpendComplete){
|
|
1634
|
+
if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
|
|
1635
|
+
var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
|
|
1636
|
+
var draftFloor = hasDraft && draftSpendComplete === false;
|
|
1637
|
+
var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + " drafting)" : "";
|
|
1638
|
+
return panelMoney(spendFloorUsd) + " so far" + draftPart;
|
|
1639
|
+
}
|
|
1640
|
+
/** build item 6 — the matching wall-clock phrase: "running <duration>",
|
|
1641
|
+
* never "unknown" once the spine has at least one timestamped record.
|
|
1642
|
+
* @param {number|null} wallFloorMs @returns {string} */
|
|
1643
|
+
function liveWallPhrase(wallFloorMs){
|
|
1644
|
+
return (wallFloorMs === null || wallFloorMs === undefined) ? "elapsed unknown" : ("running " + duration(wallFloorMs));
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1348
1647
|
// ---- Audit row cell builders — ONE shared renderer for a gate-audit row,
|
|
1349
1648
|
// used by both the Flat table (renderAudit) and the Grouped view's
|
|
1350
1649
|
// per-attempt rounds table (renderRoundsTable) so the two views can never
|
|
@@ -1371,7 +1670,7 @@
|
|
|
1371
1670
|
function auditDecisionCellHtml(r){
|
|
1372
1671
|
if(auditIsModelCall(r)){
|
|
1373
1672
|
var bits = [];
|
|
1374
|
-
bits.push(typeof r.costUsd === "number" ?
|
|
1673
|
+
bits.push(typeof r.costUsd === "number" ? panelMoney(r.costUsd) : "unknown");
|
|
1375
1674
|
bits.push((typeof r.tokens === "number" ? r.tokens : "unknown") + " tokens");
|
|
1376
1675
|
bits.push(duration(r.durationMs));
|
|
1377
1676
|
return escapeXml(bits.join(" · "));
|
|
@@ -1385,6 +1684,25 @@
|
|
|
1385
1684
|
if(auditIsBlocked(r)) cls = (cls ? cls + " " : "") + "row-blocked";
|
|
1386
1685
|
return cls;
|
|
1387
1686
|
}
|
|
1687
|
+
// fix (2026-09-28): the Time cell showed the full ISO timestamp
|
|
1688
|
+
// (`2026-09-27T08:20:58.924Z`), overflowing every Audit-tab table sideways
|
|
1689
|
+
// at 390px. Shows time-only `HH:MM:SS` taken from the ISO string AS-IS
|
|
1690
|
+
// (UTC, no timezone conversion), full ISO kept in the cell's `title` for
|
|
1691
|
+
// hover — ONE helper, shared by the Flat table and the Grouped view's
|
|
1692
|
+
// per-attempt rounds table (both the header row and the tool-call rows),
|
|
1693
|
+
// so the three call sites can never drift on how a Time cell reads.
|
|
1694
|
+
function auditShortTime(iso){
|
|
1695
|
+
if(!iso) return null;
|
|
1696
|
+
var m = /T(\d\d:\d\d:\d\d)/.exec(String(iso));
|
|
1697
|
+
return m ? m[1] : null;
|
|
1698
|
+
}
|
|
1699
|
+
function auditTimeCellHtml(r){
|
|
1700
|
+
var iso = r.time;
|
|
1701
|
+
if(!iso) return "unknown";
|
|
1702
|
+
var short = auditShortTime(iso);
|
|
1703
|
+
if(!short) return escapeXml(iso);
|
|
1704
|
+
return '<span title="' + escapeXml(iso) + '">' + escapeXml(short) + '</span>';
|
|
1705
|
+
}
|
|
1388
1706
|
|
|
1389
1707
|
// item 7: tools/cache summary rows — built ONLY from replay's own already-
|
|
1390
1708
|
// computed `behaviour`/`memoryCache` fields (never recomputed a second way).
|
|
@@ -1489,17 +1807,38 @@
|
|
|
1489
1807
|
// no job-end means no final totals were ever recorded) are replaced with
|
|
1490
1808
|
// the priced-rounds/first-to-last-record FLOORS the server derived —
|
|
1491
1809
|
// never "unknown" when a priced round actually exists.
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
|
|
1810
|
+
// hamr's ruling 2026-09-28 ("one cap covers drafting + run", 2nd
|
|
1811
|
+
// addendum) — the leading figure reads as a floor ("at least $X") on a
|
|
1812
|
+
// died run OR when the drafting share itself is incomplete (a reader
|
|
1813
|
+
// summing the two must never take the leading number as exact when the
|
|
1814
|
+
// bracket beside it isn't); the drafting bracket reads its own floor
|
|
1815
|
+
// independently. {@link panelMoneyWithDraft} is the one place that
|
|
1816
|
+
// decides all of this — see its own doc.
|
|
1817
|
+
// build item 6 (2026-09-28): a LIVE run (no job-end reached: died is
|
|
1818
|
+
// false and spentUsd is still null) shows the RUNNING floor instead of
|
|
1819
|
+
// "unknown" — the server now derives the same spendFloorUsd/wallFloorMs
|
|
1820
|
+
// for a still-running spine that it already derived for a died one.
|
|
1821
|
+
var isLiveNoEnd = !detail.died && detail.spentUsd === null;
|
|
1822
|
+
var spendText, wallPhrase;
|
|
1823
|
+
if(isLiveNoEnd){
|
|
1824
|
+
spendText = liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete);
|
|
1825
|
+
wallPhrase = liveWallPhrase(detail.wallFloorMs);
|
|
1826
|
+
} else {
|
|
1827
|
+
var draftIncomplete = typeof detail.draftSpentUsd === "number" && detail.draftSpentUsd !== null && detail.draftSpendComplete === false;
|
|
1828
|
+
var spendCoreVal = detail.died ? detail.spendFloorUsd : detail.spentUsd;
|
|
1829
|
+
var spendCoreFloor = detail.died || draftIncomplete;
|
|
1830
|
+
spendText = panelMoneyWithDraft(spendCoreVal, spendCoreFloor, detail.draftSpentUsd, detail.draftSpendComplete);
|
|
1831
|
+
var wallText = detail.died ? (detail.wallFloorMs === null ? "unknown" : "at least " + duration(detail.wallFloorMs)) : duration(detail.wallMs);
|
|
1832
|
+
// item 6 (2026-09-25): "took" for a finished (job-end reached) OR died
|
|
1833
|
+
// run — both are OVER, so the wall figure is a completed duration, never
|
|
1834
|
+
// an in-progress one. "elapsed" is reserved for the one state that IS
|
|
1835
|
+
// still in progress: a live [▶] run (glyph "▶" and not died).
|
|
1836
|
+
var isLive = detail.glyph === "▶" && !detail.died;
|
|
1837
|
+
wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
|
|
1838
|
+
}
|
|
1500
1839
|
|
|
1501
1840
|
document.getElementById("run-counters").innerHTML =
|
|
1502
|
-
spendText + " of
|
|
1841
|
+
spendText + " of " + panelMoney(detail.budgetUsd) +
|
|
1503
1842
|
" · " + wallPhrase;
|
|
1504
1843
|
|
|
1505
1844
|
var box = document.getElementById("run-summary");
|
|
@@ -1546,12 +1885,10 @@
|
|
|
1546
1885
|
var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
|
|
1547
1886
|
var titleText = box.kind === "step" ? ((cardStepNumbers[idx] + 1) + ' · ' + escapeXml(box.title)) : escapeXml(box.title);
|
|
1548
1887
|
var resultGlyph = partResultGlyph(part, box);
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
|
|
1554
|
-
];
|
|
1888
|
+
// build item 4: ONE owner (partLine1Text, shared with the Audit tab's
|
|
1889
|
+
// Grouped headers) for the wall/money bits, so a live run's card never
|
|
1890
|
+
// reads a bare "unknown" a second reader independently failed to float.
|
|
1891
|
+
var line1Bits = [partLine1Text(part, detail)];
|
|
1555
1892
|
if(resultGlyph !== null) line1Bits.push(resultGlyph);
|
|
1556
1893
|
var card = document.createElement("div");
|
|
1557
1894
|
card.className = "step-card part-card" + (box.state === "running" ? " running" : "");
|
|
@@ -1590,19 +1927,30 @@
|
|
|
1590
1927
|
var row = document.createElement("div");
|
|
1591
1928
|
row.className = "attempt-row";
|
|
1592
1929
|
row.setAttribute("data-testid", "audit-attempt-" + partIndex + "-" + a.n);
|
|
1930
|
+
// plain-checks build (2026-09-28): the withdrawn "checks N/M" headline
|
|
1931
|
+
// is replaced by attemptChecksLine's plain words (including the
|
|
1932
|
+
// opening outer-close grade, which is attempt 1 on this SAME list —
|
|
1933
|
+
// src/replay.js's fixLoop.attempts) — "" for a rubric/command close or
|
|
1934
|
+
// a still-running attempt with no stages yet.
|
|
1935
|
+
var checksText = attemptChecksLine(a);
|
|
1936
|
+
var checksList = fullStageList(a).map(stageLineHtml).join("<br>");
|
|
1593
1937
|
row.innerHTML =
|
|
1594
1938
|
'<button class="btn small attempt-toggle" type="button" aria-expanded="false">' +
|
|
1595
1939
|
'attempt ' + a.n + ' ' + attemptGlyph(a.outcome, boxIsLive) +
|
|
1940
|
+
(checksText ? ' · ' + checksText : '') +
|
|
1596
1941
|
'</button>' +
|
|
1942
|
+
(checksList ? '<div class="checks-list" data-testid="audit-attempt-checks-' + partIndex + '-' + a.n + '" hidden>' + checksList + '</div>' : '') +
|
|
1597
1943
|
'<div class="rounds-list" hidden></div>';
|
|
1598
1944
|
container.appendChild(row);
|
|
1599
1945
|
var btn = row.querySelector(".attempt-toggle");
|
|
1600
1946
|
var roundsEl = row.querySelector(".rounds-list");
|
|
1947
|
+
var checksEl = row.querySelector(".checks-list");
|
|
1601
1948
|
var built = false;
|
|
1602
1949
|
btn.addEventListener("click", function(){
|
|
1603
1950
|
var expanded = btn.getAttribute("aria-expanded") === "true";
|
|
1604
1951
|
btn.setAttribute("aria-expanded", expanded ? "false" : "true");
|
|
1605
1952
|
roundsEl.hidden = expanded;
|
|
1953
|
+
if(checksEl) checksEl.hidden = expanded;
|
|
1606
1954
|
if(!expanded && !built){
|
|
1607
1955
|
built = true;
|
|
1608
1956
|
loadRounds(roundsEl, partIndex, a, 0);
|
|
@@ -1635,7 +1983,7 @@
|
|
|
1635
1983
|
'<td>' + auditActionCellHtml(pseudo) + '</td>' +
|
|
1636
1984
|
'<td>' + auditPathCellHtml(pseudo) + '</td>' +
|
|
1637
1985
|
'<td>' + auditDecisionCellHtml(pseudo) + '</td>' +
|
|
1638
|
-
'<td>' +
|
|
1986
|
+
'<td>' + auditTimeCellHtml(pseudo) + '</td>' +
|
|
1639
1987
|
'</tr>';
|
|
1640
1988
|
}
|
|
1641
1989
|
function renderRoundToolRow(tc){
|
|
@@ -1645,7 +1993,7 @@
|
|
|
1645
1993
|
'<td>' + auditActionCellHtml(tc) + '</td>' +
|
|
1646
1994
|
'<td>' + auditPathCellHtml(tc) + '</td>' +
|
|
1647
1995
|
'<td>' + auditDecisionCellHtml(tc) + '</td>' +
|
|
1648
|
-
'<td>' +
|
|
1996
|
+
'<td>' + auditTimeCellHtml(tc) + '</td>' +
|
|
1649
1997
|
'</tr>';
|
|
1650
1998
|
}
|
|
1651
1999
|
function renderRoundRows(r, toolLogSaved){
|
|
@@ -1713,13 +2061,188 @@
|
|
|
1713
2061
|
if(!bt) return false;
|
|
1714
2062
|
return !!(bt.edit || bt.write);
|
|
1715
2063
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
2064
|
+
/**
|
|
2065
|
+
* build item 4 (2026-09-28, session mul5fofw's own live run): the wall/money
|
|
2066
|
+
* bits of a part card — ONE owner, used by both the Run tab's cards
|
|
2067
|
+
* ({@link renderRun}) and the Audit tab's Grouped headers
|
|
2068
|
+
* ({@link renderAuditGroups}), which used to compute these independently (a
|
|
2069
|
+
* second wall/spend reader — exactly the class of bug F198 already named).
|
|
2070
|
+
*
|
|
2071
|
+
* A part's `wallMs`/`spentUsd` are `null` while it is genuinely still being
|
|
2072
|
+
* measured (`replay.js`'s own `windowWallMs`/`thisFileSpend`, unresolved
|
|
2073
|
+
* until a later record — or, for the `kind:"run"` synthetic whole-run part,
|
|
2074
|
+
* unresolved until `job-end`). `duration(null)`/`panelMoney(null)` both read
|
|
2075
|
+
* "unknown", which is honest for a DEAD run but wrong for a LIVE one — the
|
|
2076
|
+
* same distinction {@link liveSpendText}/{@link liveWallPhrase} already draw
|
|
2077
|
+
* for the Summary box. Only the run's OWN card (`box.kind === "run"`, the
|
|
2078
|
+
* one shown when no step has started yet) can be substituted this way: its
|
|
2079
|
+
* `wallMs`/`spentUsd` (`replay.js`'s top-level `wallMs`/`thisFileSpend`) ARE
|
|
2080
|
+
* the run's own totals-so-far, so `detail.wallFloorMs`/`spendFloorUsd` (the
|
|
2081
|
+
* server's floor over the SAME spine) is the right number, not a guess. A
|
|
2082
|
+
* step/scout/plan/fix/judge part's own wall/spend has no run-level floor to
|
|
2083
|
+
* borrow, so it is left exactly as before.
|
|
2084
|
+
* @param {any} part @param {any} [detail] the run detail, when available
|
|
2085
|
+
* @returns {string}
|
|
2086
|
+
*/
|
|
2087
|
+
function partLine1Text(part, detail){
|
|
2088
|
+
var isLiveRunPart = part && part.kind === "run" && detail && !detail.died && detail.spentUsd === null;
|
|
2089
|
+
var wallText = (isLiveRunPart && part.wallMs === null) ? liveWallPhrase(detail.wallFloorMs) : duration(part.wallMs);
|
|
2090
|
+
var spendText = (isLiveRunPart && part.spentUsd === null)
|
|
2091
|
+
? liveSpendText(detail.spendFloorUsd, detail.draftSpentUsd, detail.draftSpendComplete)
|
|
2092
|
+
: (part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : panelMoney(part.spentUsd));
|
|
2093
|
+
var bits = [
|
|
1718
2094
|
(typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
|
|
1719
2095
|
typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
]
|
|
2096
|
+
wallText,
|
|
2097
|
+
spendText,
|
|
2098
|
+
];
|
|
2099
|
+
// plain-checks build (2026-09-28): the part's headline check LINE — its
|
|
2100
|
+
// MOST RECENT attempt's own declared-close grading (the opening
|
|
2101
|
+
// outer-close grade counts as attempt 1, same list `buildAttemptsList`
|
|
2102
|
+
// reads), in the withdrawn headline's place.
|
|
2103
|
+
var latestAttempt = Array.isArray(part.attempts) && part.attempts.length ? part.attempts[part.attempts.length - 1] : null;
|
|
2104
|
+
var headline = latestAttempt ? attemptChecksLine(latestAttempt) : "";
|
|
2105
|
+
if(headline) bits.push(headline);
|
|
2106
|
+
return bits.join(" · ");
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* plain-checks build (2026-09-28, hamr's ruling): the "checks N/M" headline
|
|
2110
|
+
* is WITHDRAWN — "confusing, reads like 6 failed when 5 never ran".
|
|
2111
|
+
* Replaced everywhere by this plain-words line:
|
|
2112
|
+
* - all declared stages passed: "all N checks passed"
|
|
2113
|
+
* - a stage failed: "check #N <plain question> — <number>", where `#N`
|
|
2114
|
+
* is the failing stage's 1-based position in the DECLARED close
|
|
2115
|
+
* ({@link declaredPosition}), the question is `stage.question` (the
|
|
2116
|
+
* server's one owner, `src/panel/server.js`'s `stageQuestionText` —
|
|
2117
|
+
* falls back to the stage's own name when the catalogue has no honest
|
|
2118
|
+
* plain question for it) and the number is {@link stageNumberText}'s
|
|
2119
|
+
* reworded shape, omitted entirely for a pass/fail stage with no
|
|
2120
|
+
* number.
|
|
2121
|
+
* "" (never shown) for a rubric/command close or an unresolved attempt
|
|
2122
|
+
* (still running, no stages yet). `attempt.declaredStagesTotal` is the
|
|
2123
|
+
* DECLARED stage count (never `stages.length`, the RAN count — first-red-
|
|
2124
|
+
* wins means a later attempt's own `stages` array can be shorter than the
|
|
2125
|
+
* close it belongs to; hamr's live catch on run mulbz0ny, checks-count fix
|
|
2126
|
+
* 2026-09-28), falling back to `stages.length` only when no spec resolved.
|
|
2127
|
+
* The caller (buildAttemptsList) prefixes "attempt N ✓/✗ · " for the
|
|
2128
|
+
* attempt header, and the Run tab card / Audit Grouped header show this
|
|
2129
|
+
* SAME text bare (hamr: "same text as its attempt header, minus 'attempt
|
|
2130
|
+
* N'").
|
|
2131
|
+
* @param {any} attempt @returns {string}
|
|
2132
|
+
*/
|
|
2133
|
+
function attemptChecksLine(attempt){
|
|
2134
|
+
var stages = attempt && attempt.stages;
|
|
2135
|
+
if(!Array.isArray(stages) || stages.length === 0) return "";
|
|
2136
|
+
var total = (typeof attempt.declaredStagesTotal === "number" && attempt.declaredStagesTotal > 0) ? attempt.declaredStagesTotal : stages.length;
|
|
2137
|
+
var failing = null;
|
|
2138
|
+
for(var i = 0; i < stages.length; i++){
|
|
2139
|
+
if(stages[i] && stages[i].verdict !== "satisfied"){ failing = stages[i]; break; }
|
|
2140
|
+
}
|
|
2141
|
+
if(failing === null) return "all " + total + " checks passed";
|
|
2142
|
+
var pos = declaredPosition(attempt, failing.name);
|
|
2143
|
+
if(pos === null) pos = stages.indexOf(failing) + 1;
|
|
2144
|
+
var numberText = stageNumberText(failing);
|
|
2145
|
+
return "check #" + pos + " " + escapeXml(stageQuestionText(failing)) + (numberText ? " — " + numberText : "");
|
|
2146
|
+
}
|
|
2147
|
+
/** a stage's DECLARED 1-based position, from `attempt.declaredStages` (the
|
|
2148
|
+
* server's full declared-order list, {@link enrichPartsWithStageKind} in
|
|
2149
|
+
* src/panel/server.js) — matched by NAME, never by array index (robust to
|
|
2150
|
+
* any reordering between the resolved spec and the stages that actually
|
|
2151
|
+
* ran). `null` when no `declaredStages` travelled (no spec resolvable) or
|
|
2152
|
+
* the name isn't found there; the caller falls back to the stage's
|
|
2153
|
+
* position within whatever ran.
|
|
2154
|
+
* @param {any} attempt @param {string|null|undefined} stageName @returns {number|null} */
|
|
2155
|
+
function declaredPosition(attempt, stageName){
|
|
2156
|
+
if(!Array.isArray(attempt.declaredStages)) return null;
|
|
2157
|
+
var idx = attempt.declaredStages.findIndex(function(ds){ return ds && ds.name === stageName; });
|
|
2158
|
+
return idx === -1 ? null : idx + 1;
|
|
2159
|
+
}
|
|
2160
|
+
/** a stage's plain-English question — `stage.question` (the server's one
|
|
2161
|
+
* owner, `src/panel/server.js`'s `stageQuestionText`) when the catalogue
|
|
2162
|
+
* has an honest one for this stage's kind+params, else the stage's OWN
|
|
2163
|
+
* name (a command-close's hand-written stage name, or a kind/genre shape
|
|
2164
|
+
* the owner has no wording for — never invented here).
|
|
2165
|
+
* @param {any} stage @returns {string} */
|
|
2166
|
+
function stageQuestionText(stage){
|
|
2167
|
+
if(stage && typeof stage.question === "string" && stage.question) return stage.question;
|
|
2168
|
+
return (stage && stage.name) ? stage.name : "unknown";
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* §3a's closed numeric wording, one owner (checks-wording build spec,
|
|
2172
|
+
* 2026-09-28, reworded by the plain-checks build's own separators): a
|
|
2173
|
+
* numeric stage's own DECLARED `direction`/`baselineKind` (server-sent,
|
|
2174
|
+
* {@link stageKindMetaFromSpec} on the panel server — the signed close
|
|
2175
|
+
* declaration's own per-stage facts, never guessed from the runtime number
|
|
2176
|
+
* alone) picks one of hamr's 3 shapes:
|
|
2177
|
+
* - lower-is-better toward a DECLARED-ZERO goal (`baselineKind === 0`):
|
|
2178
|
+
* "N left, need T"
|
|
2179
|
+
* - higher-is-better (a floor): "N of total"
|
|
2180
|
+
* - lower-is-better toward a SEED-MEASURED baseline (`baselineKind ===
|
|
2181
|
+
* "seed"`): "N, limit M"
|
|
2182
|
+
* Only `count-not-worse` ever carries a numeric value/baseline at all
|
|
2183
|
+
* (src/kinds.js — every other kind's StageResult defaults both to null),
|
|
2184
|
+
* so a value-less stage always renders "" here (pass/fail, glyph only).
|
|
2185
|
+
* Every shipped kind fits one of the 3 shapes above; a stage that somehow
|
|
2186
|
+
* carries a number with no resolvable direction/baselineKind (no spec
|
|
2187
|
+
* resolved server-side, or an old archived run) falls back to the spec's
|
|
2188
|
+
* own explicit "N (baseline M)" wording verbatim, never a 4th invented
|
|
2189
|
+
* shape.
|
|
2190
|
+
* @param {any} stage @returns {string}
|
|
2191
|
+
*/
|
|
2192
|
+
function stageNumberText(stage){
|
|
2193
|
+
var hasValue = typeof stage.value === "number";
|
|
2194
|
+
var hasBaseline = typeof stage.baseline === "number";
|
|
2195
|
+
if(!hasValue) return "";
|
|
2196
|
+
if(stage.direction === "higher-is-better" && hasBaseline) return stage.value + " of " + stage.baseline;
|
|
2197
|
+
if(stage.direction === "lower-is-better" && stage.baselineKind === 0 && hasBaseline) return stage.value + " left, need " + stage.baseline;
|
|
2198
|
+
if(stage.direction === "lower-is-better" && stage.baselineKind === "seed" && hasBaseline) return stage.value + ", limit " + stage.baseline;
|
|
2199
|
+
return hasBaseline ? (stage.value + " (baseline " + stage.baseline + ")") : String(stage.value);
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* plain-checks build (2026-09-28): the expanded attempt row's FULL declared
|
|
2203
|
+
* stage list, in declared order, including stages that never ran (first-
|
|
2204
|
+
* red-wins stopped the close early) — hamr's ruling: "one line per DECLARED
|
|
2205
|
+
* stage, in order, including stages that never ran". Zips
|
|
2206
|
+
* `attempt.declaredStages` (the server's full ordered `{name, question}`
|
|
2207
|
+
* list) against `attempt.stages` (the shorter, possibly-partial RAN list)
|
|
2208
|
+
* BY NAME: a declared stage with a matching ran entry renders that entry
|
|
2209
|
+
* (`ran: true`); one with no match renders a synthetic not-run stub
|
|
2210
|
+
* (`ran: false`, `verdict: null`). When no `declaredStages` travelled at
|
|
2211
|
+
* all (no spec resolvable), falls back to whatever ran, unpadded — exactly
|
|
2212
|
+
* today's behaviour for a rubric/command close or an unresolvable spec.
|
|
2213
|
+
* @param {any} attempt @returns {{n: number, ran: boolean, stage: any}[]}
|
|
2214
|
+
*/
|
|
2215
|
+
function fullStageList(attempt){
|
|
2216
|
+
var ran = Array.isArray(attempt.stages) ? attempt.stages : [];
|
|
2217
|
+
if(!Array.isArray(attempt.declaredStages) || attempt.declaredStages.length === 0){
|
|
2218
|
+
return ran.map(function(s, i){ return { n: i + 1, ran: true, stage: s }; });
|
|
2219
|
+
}
|
|
2220
|
+
var byName = {};
|
|
2221
|
+
ran.forEach(function(s){ if(s && typeof s.name === "string") byName[s.name] = s; });
|
|
2222
|
+
return attempt.declaredStages.map(function(ds, i){
|
|
2223
|
+
var s = ds && Object.prototype.hasOwnProperty.call(byName, ds.name) ? byName[ds.name] : null;
|
|
2224
|
+
if(s) return { n: i + 1, ran: true, stage: s };
|
|
2225
|
+
return { n: i + 1, ran: false, stage: { name: ds ? ds.name : null, question: ds ? ds.question : null, verdict: null } };
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* one stage's expand-view line — "#N <question> <mark> <number>", hamr's
|
|
2230
|
+
* closed illustration:
|
|
2231
|
+
* #1 did it change any file? ✓
|
|
2232
|
+
* #2 src/checks.js has 0 type errors? ✗ 4 left, need 0
|
|
2233
|
+
* #3 other files didn't get more type errors? · not run
|
|
2234
|
+
* A never-run declared stage (`entry.ran === false`) always reads "· not
|
|
2235
|
+
* run", no number. A ran stage with no recorded number (a guard, a
|
|
2236
|
+
* pass/fail command stage) renders the mark alone.
|
|
2237
|
+
* @param {{n: number, ran: boolean, stage: any}} entry @returns {string}
|
|
2238
|
+
*/
|
|
2239
|
+
function stageLineHtml(entry){
|
|
2240
|
+
var s = entry.stage;
|
|
2241
|
+
var q = escapeXml(stageQuestionText(s));
|
|
2242
|
+
if(!entry.ran) return "#" + entry.n + " " + q + " · not run";
|
|
2243
|
+
var mark = s.verdict === "satisfied" ? glyphSpan("✓") : (s.verdict ? glyphSpan("✗") : "–");
|
|
2244
|
+
var numberText = stageNumberText(s);
|
|
2245
|
+
return "#" + entry.n + " " + q + " " + mark + (numberText ? " " + numberText : "");
|
|
1723
2246
|
}
|
|
1724
2247
|
var currentAuditFilter = "all";
|
|
1725
2248
|
function auditPartMatchesFilter(rowWrap, filter){
|
|
@@ -1763,7 +2286,7 @@
|
|
|
1763
2286
|
headBtn.setAttribute("data-testid", "audit-part-toggle-" + idx);
|
|
1764
2287
|
var groupResultGlyph = partResultGlyph(part, box);
|
|
1765
2288
|
headBtn.innerHTML =
|
|
1766
|
-
'<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' · ' + partLine1Text(part) +
|
|
2289
|
+
'<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' · ' + partLine1Text(part, detail) +
|
|
1767
2290
|
(groupResultGlyph !== null ? ' · ' + groupResultGlyph : '') +
|
|
1768
2291
|
(box.blocked > 0 ? ' <span class="badge red" data-testid="audit-part-blocked-' + idx + '">' + box.blocked + ' blocked</span>' : '');
|
|
1769
2292
|
var body = document.createElement("div");
|
|
@@ -1874,7 +2397,7 @@
|
|
|
1874
2397
|
"<td>" + auditActionCellHtml(r) + "</td>" +
|
|
1875
2398
|
"<td>" + pathCell + "</td>" +
|
|
1876
2399
|
"<td>" + decisionCell + "</td>" +
|
|
1877
|
-
"<td>" +
|
|
2400
|
+
"<td>" + auditTimeCellHtml(r) + "</td>";
|
|
1878
2401
|
tbody.appendChild(tr);
|
|
1879
2402
|
});
|
|
1880
2403
|
document.getElementById("audit-rawlog").textContent = result.raw || "";
|
|
@@ -1907,7 +2430,7 @@
|
|
|
1907
2430
|
// ---- Job tab ----
|
|
1908
2431
|
function renderJob(job){
|
|
1909
2432
|
var empty = document.getElementById("job-empty");
|
|
1910
|
-
var card = document.getElementById("job-card");
|
|
2433
|
+
var card = document.getElementById("job-card-readonly");
|
|
1911
2434
|
if(!job){ empty.hidden = false; card.hidden = true; return; }
|
|
1912
2435
|
empty.hidden = true;
|
|
1913
2436
|
card.hidden = false;
|
|
@@ -1925,7 +2448,7 @@
|
|
|
1925
2448
|
document.getElementById("details-source").textContent = job.source;
|
|
1926
2449
|
document.getElementById("details-dest").textContent = job.destination;
|
|
1927
2450
|
document.getElementById("details-tools").textContent = job.tools;
|
|
1928
|
-
document.getElementById("details-cap-money").textContent =
|
|
2451
|
+
document.getElementById("details-cap-money").textContent = panelMoney(job.budgetUsd);
|
|
1929
2452
|
document.getElementById("details-cap-time").textContent = job.maxWallMs === null ? "unknown" : duration(job.maxWallMs);
|
|
1930
2453
|
document.getElementById("job-note").textContent = job.note || "";
|
|
1931
2454
|
lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
|
|
@@ -2094,11 +2617,6 @@
|
|
|
2094
2617
|
if(pollTimer) return;
|
|
2095
2618
|
pollTimer = setInterval(pollTick, POLL_MS);
|
|
2096
2619
|
}
|
|
2097
|
-
function stopPolling(){
|
|
2098
|
-
if(!pollTimer) return;
|
|
2099
|
-
clearInterval(pollTimer);
|
|
2100
|
-
pollTimer = null;
|
|
2101
|
-
}
|
|
2102
2620
|
// Pause while the tab is hidden (cheap, standard); resume — with an
|
|
2103
2621
|
// immediate catch-up tick, forcing BOTH fetches regardless of the list's
|
|
2104
2622
|
// 10s throttle — on return, rather than waiting a full 2s (or up to 10s
|
|
@@ -2112,11 +2630,15 @@
|
|
|
2112
2630
|
lastRunsSig = sig(runs);
|
|
2113
2631
|
runsFilterBar.setItems(runs);
|
|
2114
2632
|
if(runs.length && !runs[0].fileMissing){
|
|
2115
|
-
|
|
2116
|
-
//
|
|
2117
|
-
//
|
|
2118
|
-
//
|
|
2119
|
-
//
|
|
2633
|
+
// build item 5 — the LEFT pane opens on Chat by default; this no
|
|
2634
|
+
// longer force-clicks "tab-runs" (it used to switch the left pane
|
|
2635
|
+
// away from Chat on every load that had a run). The RIGHT pane still
|
|
2636
|
+
// loads the newest run's details so they're ready the moment a
|
|
2637
|
+
// person switches to the Runs tab themselves — the Workflows view
|
|
2638
|
+
// starts collapsed, but its job row still renders "selected" (build
|
|
2639
|
+
// item, 2026-09-26: a job row shows selected state like a child row)
|
|
2640
|
+
// since `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is
|
|
2641
|
+
// set.
|
|
2120
2642
|
selectRun(runs[0].runid, null, ".hist-row");
|
|
2121
2643
|
document.getElementById("tab-run").click();
|
|
2122
2644
|
runsFilterBar.refresh();
|
|
@@ -2150,6 +2672,407 @@
|
|
|
2150
2672
|
});
|
|
2151
2673
|
})();
|
|
2152
2674
|
|
|
2675
|
+
// ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
|
|
2676
|
+
(function(){
|
|
2677
|
+
var newBtn = document.getElementById("chat-new-btn");
|
|
2678
|
+
var card = document.getElementById("job-card");
|
|
2679
|
+
var thread = document.getElementById("chat-thread");
|
|
2680
|
+
var msgInput = document.getElementById("chat-msg");
|
|
2681
|
+
var sendBtn = document.getElementById("chat-send-btn");
|
|
2682
|
+
var signBtn = document.getElementById("chat-sign-btn");
|
|
2683
|
+
var reviseBtn = document.getElementById("chat-revise-btn");
|
|
2684
|
+
var startBtn = document.getElementById("chat-start-btn");
|
|
2685
|
+
var checkDepsBtn = document.getElementById("chat-check-deps-btn");
|
|
2686
|
+
var errEl = document.getElementById("chat-card-error");
|
|
2687
|
+
var actionErrEl = document.getElementById("chat-action-error");
|
|
2688
|
+
var hashLine = document.getElementById("chat-hash-line");
|
|
2689
|
+
var moneyCapInput = document.getElementById("jf-cap-money");
|
|
2690
|
+
var modelSelect = document.getElementById("jf-model");
|
|
2691
|
+
var modelStatusEl = document.getElementById("jf-model-status");
|
|
2692
|
+
var progressRow = document.getElementById("chat-progress-row");
|
|
2693
|
+
var progressGlyph = document.getElementById("chat-progress-glyph");
|
|
2694
|
+
var progressLabelEl = document.getElementById("chat-progress-label");
|
|
2695
|
+
|
|
2696
|
+
var sessionId = null;
|
|
2697
|
+
var pollTimer = null;
|
|
2698
|
+
var lastPhase = null;
|
|
2699
|
+
var signClickedOnce = false;
|
|
2700
|
+
// build item 4 — Start disables the moment it's clicked and stays
|
|
2701
|
+
// disabled for the whole life of a non-terminal session (the server's
|
|
2702
|
+
// own one-at-a-time rule, `TERMINAL_PHASES` in
|
|
2703
|
+
// src/panel/authorroutes.js, mirrored here so Start never even offers a
|
|
2704
|
+
// click the server would 409 anyway).
|
|
2705
|
+
var CLIENT_TERMINAL_PHASES = ["refused", "abandoned", "error", "signed", "signing-failed"];
|
|
2706
|
+
var DONE_PHASES = ["prepared", "signed"];
|
|
2707
|
+
var sessionLive = false;
|
|
2708
|
+
// build item 7 — Start also stays disabled while the chosen model's key
|
|
2709
|
+
// isn't usable; updated by refreshModelStatus() below.
|
|
2710
|
+
var keyOk = false;
|
|
2711
|
+
|
|
2712
|
+
newBtn.addEventListener("click", function(){
|
|
2713
|
+
card.hidden = false;
|
|
2714
|
+
newBtn.hidden = true;
|
|
2715
|
+
thread.innerHTML = "";
|
|
2716
|
+
errEl.textContent = "";
|
|
2717
|
+
actionErrEl.textContent = "";
|
|
2718
|
+
sessionId = null;
|
|
2719
|
+
sessionLive = false;
|
|
2720
|
+
signClickedOnce = false;
|
|
2721
|
+
if(pollTimer){ clearInterval(pollTimer); pollTimer = null; }
|
|
2722
|
+
stopProgressDots();
|
|
2723
|
+
progressRow.hidden = true;
|
|
2724
|
+
checkDepsBtn.hidden = true;
|
|
2725
|
+
refreshStartEnabled();
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
function currentCard(){
|
|
2729
|
+
var moneyCap = Number(document.getElementById("jf-cap-money").value);
|
|
2730
|
+
var timeCapMin = document.getElementById("jf-cap-time").value.trim();
|
|
2731
|
+
return {
|
|
2732
|
+
checkType: document.querySelector('input[name="jf-verdict"]:checked').value === "rubric" ? "rubric" : "deterministic",
|
|
2733
|
+
model: document.getElementById("jf-model").value,
|
|
2734
|
+
jobName: document.getElementById("jf-name").value.trim(),
|
|
2735
|
+
goal: document.getElementById("jf-goal").value.trim(),
|
|
2736
|
+
source: document.getElementById("jf-source").value.trim(),
|
|
2737
|
+
destination: document.getElementById("jf-dest").value.trim(),
|
|
2738
|
+
success: document.getElementById("jf-success").value.trim(),
|
|
2739
|
+
guardrails: document.getElementById("jf-guardrails").value.trim(),
|
|
2740
|
+
judgeExamples: document.getElementById("jf-judge").value.trim(),
|
|
2741
|
+
capUsd: moneyCap,
|
|
2742
|
+
maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// hamr's ruling 2026-09-28 ("one cap covers drafting + run") — the
|
|
2747
|
+
// separate Drafting cap field is GONE; drafting runs under the same
|
|
2748
|
+
// `Cap $` field every run does. Start stays disabled until Cap $ parses
|
|
2749
|
+
// as a positive number, checked client-side as a UX convenience only;
|
|
2750
|
+
// the server (`validateJobCard`) is the real gate. Also disabled while a
|
|
2751
|
+
// session is already live (build item 4) or the chosen model's key
|
|
2752
|
+
// isn't usable (build item 7).
|
|
2753
|
+
function refreshStartEnabled(){
|
|
2754
|
+
var v = Number(moneyCapInput.value);
|
|
2755
|
+
var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
|
|
2756
|
+
startBtn.disabled = !capOk || sessionLive || !keyOk;
|
|
2757
|
+
}
|
|
2758
|
+
moneyCapInput.addEventListener("input", refreshStartEnabled);
|
|
2759
|
+
document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
|
|
2760
|
+
r.addEventListener("change", function(){
|
|
2761
|
+
var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
|
|
2762
|
+
document.getElementById("jf-judge").disabled = !isRubric;
|
|
2763
|
+
});
|
|
2764
|
+
});
|
|
2765
|
+
|
|
2766
|
+
// build item 7 / build item 5 (2026-09-28) — key + reachability, $0,
|
|
2767
|
+
// shown right under Model, on load and whenever the selection changes.
|
|
2768
|
+
// Start stays disabled while the key isn't usable; a reachability
|
|
2769
|
+
// failure shows red but never blocks (an endpoint can be flaky).
|
|
2770
|
+
// fix (2026-09-28): a 401/403 from the reachability probe is not
|
|
2771
|
+
// flakiness — it means the key itself was REJECTED by the provider.
|
|
2772
|
+
// Neither blocks Start (unchanged rule); only the WORDING differs.
|
|
2773
|
+
// Every other status (timeout, network error, 5xx, 429,
|
|
2774
|
+
// unsupported-provider) keeps the original "may be flaky" wording.
|
|
2775
|
+
function reachStatusText(status){
|
|
2776
|
+
var rejectedMatch = /^HTTP (401|403)$/.exec(status);
|
|
2777
|
+
return rejectedMatch
|
|
2778
|
+
? "[✗] key rejected (HTTP " + rejectedMatch[1] + ")"
|
|
2779
|
+
: "[✗] " + status + " — may be flaky, not blocking";
|
|
2780
|
+
}
|
|
2781
|
+
// build item 5 — ONE line, not two: pure decision from the model-check
|
|
2782
|
+
// response (`r`) + the selected model's display id, so the whole
|
|
2783
|
+
// key/reachability read can be unit-tested without any DOM at all.
|
|
2784
|
+
// All good: "[✓] <ENV_KEY> found · <modelId> reachable". Otherwise: one
|
|
2785
|
+
// red line with the FIRST problem — a missing/bad key short-circuits
|
|
2786
|
+
// before reachability is even mentioned (reachability was never
|
|
2787
|
+
// checked in that case anyway — see the server route).
|
|
2788
|
+
// `keyOk` (Start-gating) and `ok` (display color) are DIFFERENT axes: a
|
|
2789
|
+
// reachability failure shows red (`ok:false`) but never blocks Start
|
|
2790
|
+
// (`keyOk` stays true) — an endpoint can be flaky; a missing/bad KEY
|
|
2791
|
+
// blocks Start and always shows red.
|
|
2792
|
+
function modelStatusLine(r, modelId){
|
|
2793
|
+
if(!r || !r.ok) return { keyOk: false, ok: false, text: "[✗] could not check the key" };
|
|
2794
|
+
var keyFound = r.keyStatus === "found";
|
|
2795
|
+
if(!keyFound){
|
|
2796
|
+
var problem = r.keyStatus === "missing" ? " not set" : (" " + r.keyProblem);
|
|
2797
|
+
return { keyOk: false, ok: false, text: "[✗] " + r.envKey + problem };
|
|
2798
|
+
}
|
|
2799
|
+
var reach = r.reachability;
|
|
2800
|
+
if(!reach || !reach.checked) return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found" };
|
|
2801
|
+
if(reach.reachable){
|
|
2802
|
+
var suffix = reach.modelListed === false ? " (model not listed)" : "";
|
|
2803
|
+
return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found · " + modelId + " reachable" + suffix };
|
|
2804
|
+
}
|
|
2805
|
+
return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
|
|
2806
|
+
}
|
|
2807
|
+
function refreshModelStatus(){
|
|
2808
|
+
var modelId = modelSelect.value;
|
|
2809
|
+
authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
|
|
2810
|
+
var line = modelStatusLine(r, modelId);
|
|
2811
|
+
keyOk = line.keyOk;
|
|
2812
|
+
modelStatusEl.className = "jf-readiness " + (line.ok ? "ok" : "bad");
|
|
2813
|
+
modelStatusEl.textContent = line.text;
|
|
2814
|
+
refreshStartEnabled();
|
|
2815
|
+
}).catch(function(){
|
|
2816
|
+
keyOk = false;
|
|
2817
|
+
modelStatusEl.className = "jf-readiness bad";
|
|
2818
|
+
modelStatusEl.textContent = "[✗] could not check the key";
|
|
2819
|
+
refreshStartEnabled();
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
modelSelect.addEventListener("change", refreshModelStatus);
|
|
2823
|
+
refreshModelStatus();
|
|
2824
|
+
|
|
2825
|
+
// build item 2 — one plain line per event, no "(progress)" jargon
|
|
2826
|
+
// suffix: `onPhase` no longer posts its own bubble (see
|
|
2827
|
+
// src/panel/authorsession.js), so every remaining message is already
|
|
2828
|
+
// something a person needs to read.
|
|
2829
|
+
// build item 4 — a system line (a step notice: "Copying source ($0)",
|
|
2830
|
+
// "Packages found", …) carries no who-label at all — it is not a
|
|
2831
|
+
// message FROM anyone, just a plain step line. Only "you" and the
|
|
2832
|
+
// model's own bot replies get one.
|
|
2833
|
+
function chatWhoLabel(role){
|
|
2834
|
+
if(role === "you") return "you";
|
|
2835
|
+
if(role === "system") return "";
|
|
2836
|
+
return "bareloop";
|
|
2837
|
+
}
|
|
2838
|
+
function renderMessages(state){
|
|
2839
|
+
thread.innerHTML = state.messages.map(function(m){
|
|
2840
|
+
var cls = m.role === "you" ? "you" : (m.role === "system" ? "system" : "bot");
|
|
2841
|
+
var who = chatWhoLabel(m.role);
|
|
2842
|
+
var whoHtml = who ? ('<div class="who">' + escapeXml(who) + '</div>') : "";
|
|
2843
|
+
return '<div class="msg ' + cls + '">' + whoHtml + escapeXml(m.text) + '</div>';
|
|
2844
|
+
}).join("");
|
|
2845
|
+
thread.scrollTop = thread.scrollHeight;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// build item 3 — the single live progress line: the LABEL sits first in
|
|
2849
|
+
// the markup, then the animated indicator (never the other way — the
|
|
2850
|
+
// dots must not push the label around as they cycle 1..4).
|
|
2851
|
+
var progressDotTimer = null;
|
|
2852
|
+
var progressDotCount = 1;
|
|
2853
|
+
var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
2854
|
+
function stopProgressDots(){
|
|
2855
|
+
if(progressDotTimer){ clearInterval(progressDotTimer); progressDotTimer = null; }
|
|
2856
|
+
}
|
|
2857
|
+
function startProgressDots(){
|
|
2858
|
+
if(reducedMotion){ progressGlyph.textContent = "[progress....]"; return; }
|
|
2859
|
+
if(progressDotTimer) return;
|
|
2860
|
+
progressDotCount = 1;
|
|
2861
|
+
progressGlyph.textContent = "[progress.]";
|
|
2862
|
+
progressDotTimer = setInterval(function(){
|
|
2863
|
+
progressDotCount = (progressDotCount % 4) + 1;
|
|
2864
|
+
progressGlyph.textContent = "[progress" + new Array(progressDotCount + 1).join(".") + "]";
|
|
2865
|
+
}, 500);
|
|
2866
|
+
}
|
|
2867
|
+
// build item 3 — pure: what the label must say for a given state. A
|
|
2868
|
+
// person waiting on their OWN next action (an ask is pending) must never
|
|
2869
|
+
// see a stale machine step name (observed: "checking prior behaviour"
|
|
2870
|
+
// shown while waiting on hamr; "plan confirmed" shown while the plan was
|
|
2871
|
+
// awaiting his confirm) — a pending ask always wins over progressLabel.
|
|
2872
|
+
function progressLabelFor(state){
|
|
2873
|
+
var pending = state && state.pendingAsk;
|
|
2874
|
+
if(pending && pending.kind === "menu") return "waiting for your OK";
|
|
2875
|
+
if(pending && pending.kind === "install-needed") return "install needed";
|
|
2876
|
+
if(pending) return "waiting for your answer";
|
|
2877
|
+
return (state && state.progressLabel) || "working";
|
|
2878
|
+
}
|
|
2879
|
+
function renderProgress(state){
|
|
2880
|
+
progressRow.hidden = false;
|
|
2881
|
+
if(DONE_PHASES.indexOf(state.phase) !== -1){
|
|
2882
|
+
stopProgressDots();
|
|
2883
|
+
progressGlyph.className = "chat-progress-glyph done";
|
|
2884
|
+
progressGlyph.textContent = "[done]";
|
|
2885
|
+
progressLabelEl.textContent = "";
|
|
2886
|
+
} else if(CLIENT_TERMINAL_PHASES.indexOf(state.phase) !== -1){
|
|
2887
|
+
stopProgressDots();
|
|
2888
|
+
progressGlyph.className = "chat-progress-glyph stopped";
|
|
2889
|
+
progressGlyph.textContent = "[stopped]";
|
|
2890
|
+
progressLabelEl.textContent = state.error || "";
|
|
2891
|
+
} else {
|
|
2892
|
+
progressGlyph.className = "chat-progress-glyph";
|
|
2893
|
+
progressLabelEl.textContent = progressLabelFor(state);
|
|
2894
|
+
startProgressDots();
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function renderActions(state){
|
|
2899
|
+
var pending = state.pendingAsk;
|
|
2900
|
+
var atMenu = !!pending && pending.kind === "menu";
|
|
2901
|
+
var atDepsWait = !!pending && pending.kind === "install-needed";
|
|
2902
|
+
sendBtn.disabled = !pending || atMenu || atDepsWait;
|
|
2903
|
+
reviseBtn.disabled = !atMenu || state.revisesLeft <= 0;
|
|
2904
|
+
reviseBtn.textContent = "Revise (" + state.revisesLeft + " left)";
|
|
2905
|
+
checkDepsBtn.hidden = !atDepsWait;
|
|
2906
|
+
checkDepsBtn.disabled = !atDepsWait;
|
|
2907
|
+
if(state.phase === "prepared" && !signClickedOnce){
|
|
2908
|
+
signBtn.disabled = false;
|
|
2909
|
+
signBtn.textContent = "Sign & run";
|
|
2910
|
+
} else if(state.phase === "prepared" && signClickedOnce){
|
|
2911
|
+
signBtn.disabled = false;
|
|
2912
|
+
signBtn.textContent = "Sign " + (state.specHash || "").slice(0, 8) + " & run";
|
|
2913
|
+
} else {
|
|
2914
|
+
signBtn.disabled = !atMenu;
|
|
2915
|
+
signBtn.textContent = "Sign & run";
|
|
2916
|
+
}
|
|
2917
|
+
if(state.specHash){
|
|
2918
|
+
hashLine.hidden = false;
|
|
2919
|
+
hashLine.textContent = "spec hash: " + state.specHash;
|
|
2920
|
+
}
|
|
2921
|
+
if(state.phase === "refused" || state.phase === "error" || state.phase === "abandoned"){
|
|
2922
|
+
errEl.textContent = state.error || ("stopped: " + state.phase);
|
|
2923
|
+
} else {
|
|
2924
|
+
errEl.textContent = "";
|
|
2925
|
+
}
|
|
2926
|
+
renderProgress(state);
|
|
2927
|
+
// build item 4 — re-enable Start only once the session has settled
|
|
2928
|
+
// into a terminal phase (a fresh Start becomes possible again).
|
|
2929
|
+
sessionLive = CLIENT_TERMINAL_PHASES.indexOf(state.phase) === -1;
|
|
2930
|
+
refreshStartEnabled();
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
function poll(){
|
|
2934
|
+
if(!sessionId) return;
|
|
2935
|
+
authorGet("/api/author/" + sessionId).then(function(j){
|
|
2936
|
+
if(!j || !j.state) return;
|
|
2937
|
+
renderMessages(j.state);
|
|
2938
|
+
renderActions(j.state);
|
|
2939
|
+
if(j.state.phase === "signed"){
|
|
2940
|
+
clearInterval(pollTimer);
|
|
2941
|
+
document.getElementById("tab-runs").click();
|
|
2942
|
+
}
|
|
2943
|
+
lastPhase = j.state.phase;
|
|
2944
|
+
}).catch(function(){ /* transient — next poll tries again */ });
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
startBtn.addEventListener("click", function(){
|
|
2948
|
+
errEl.textContent = "";
|
|
2949
|
+
actionErrEl.textContent = "";
|
|
2950
|
+
// build item 4 — disable the moment it's clicked, not just once the
|
|
2951
|
+
// response comes back; re-enabled below only if the start is refused.
|
|
2952
|
+
sessionLive = true;
|
|
2953
|
+
refreshStartEnabled();
|
|
2954
|
+
var body = currentCard();
|
|
2955
|
+
authorPost("/api/author/start", body).then(function(r){
|
|
2956
|
+
if(r.status !== 200 || !r.body.ok){
|
|
2957
|
+
errEl.textContent = (r.body && r.body.error) || "refused";
|
|
2958
|
+
sessionLive = false;
|
|
2959
|
+
refreshStartEnabled();
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
sessionId = r.body.sessionId;
|
|
2963
|
+
signClickedOnce = false;
|
|
2964
|
+
renderMessages(r.body.state);
|
|
2965
|
+
renderActions(r.body.state);
|
|
2966
|
+
if(pollTimer) clearInterval(pollTimer);
|
|
2967
|
+
pollTimer = setInterval(poll, 2000);
|
|
2968
|
+
});
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
// build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
|
|
2972
|
+
// (fed by the every-2s `poll()`) unconditionally overwrites
|
|
2973
|
+
// `#chat-card-error` on every tick (`errEl.textContent = state.error ||
|
|
2974
|
+
// ""`), including the SAME tick the send/revise/sign handlers below
|
|
2975
|
+
// used to call `poll()` in their own `.then` — so a refused/failed POST's
|
|
2976
|
+
// error was wiped before a person could read it (often on the very next
|
|
2977
|
+
// line of the same callback). Compounding it, `send`/`revise` cleared
|
|
2978
|
+
// `msgInput.value` unconditionally, even on failure — so a rejected
|
|
2979
|
+
// answer vanished from the box with no trace at all. Fixed by: (1) a
|
|
2980
|
+
// dedicated `#chat-action-error` element only these handlers touch,
|
|
2981
|
+
// never `poll()`/`renderActions`; (2) only clearing the input on a
|
|
2982
|
+
// confirmed 200+ok response; (3) an explicit `.catch` so a rejected
|
|
2983
|
+
// fetch (network drop, server restart) shows an error too, instead of
|
|
2984
|
+
// silently doing nothing.
|
|
2985
|
+
// build item 1 — the ONE place that decides "did this chat POST
|
|
2986
|
+
// succeed" — pure, no DOM, unit-tested directly
|
|
2987
|
+
// (tests/panel-page.test.js). Before this existed, each handler decided
|
|
2988
|
+
// ad hoc via `r.body && r.body.error && !r.body.ok`, which is true only
|
|
2989
|
+
// when the server bothered to hand back BOTH `ok:false` AND a non-empty
|
|
2990
|
+
// `error` string — a malformed/short-circuited response (e.g. a 403 the
|
|
2991
|
+
// guard returns with `{ok:false, error:'refused — ...'}` reaching a
|
|
2992
|
+
// caller that never checked `r.status`, or any response missing
|
|
2993
|
+
// `.error`) read as neither branch and was silently treated as fine.
|
|
2994
|
+
function chatPostOutcome(r){
|
|
2995
|
+
var ok = !!(r && r.status === 200 && r.body && r.body.ok === true);
|
|
2996
|
+
return { ok: ok, error: ok ? "" : ((r && r.body && r.body.error) || "the request failed — nothing was sent") };
|
|
2997
|
+
}
|
|
2998
|
+
function chatActionFailed(message){
|
|
2999
|
+
actionErrEl.textContent = message || "the request failed — nothing was sent";
|
|
3000
|
+
}
|
|
3001
|
+
function chatActionOk(){
|
|
3002
|
+
actionErrEl.textContent = "";
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
checkDepsBtn.addEventListener("click", function(){
|
|
3006
|
+
if(!sessionId) return;
|
|
3007
|
+
checkDepsBtn.disabled = true;
|
|
3008
|
+
authorPost("/api/author/" + sessionId + "/check-deps", {}).then(function(r){
|
|
3009
|
+
var o = chatPostOutcome(r);
|
|
3010
|
+
if(o.ok) chatActionOk(); else chatActionFailed(o.error);
|
|
3011
|
+
poll();
|
|
3012
|
+
}).catch(function(){ chatActionFailed("network error — could not reach the panel server, nothing was sent"); });
|
|
3013
|
+
});
|
|
3014
|
+
|
|
3015
|
+
sendBtn.addEventListener("click", function(){
|
|
3016
|
+
if(!sessionId) return;
|
|
3017
|
+
var text = msgInput.value;
|
|
3018
|
+
if(!text.trim()) return;
|
|
3019
|
+
authorPost("/api/author/" + sessionId + "/send", {text: text}).then(function(r){
|
|
3020
|
+
var o = chatPostOutcome(r);
|
|
3021
|
+
if(o.ok){
|
|
3022
|
+
chatActionOk();
|
|
3023
|
+
msgInput.value = "";
|
|
3024
|
+
} else {
|
|
3025
|
+
// failure — the text stays in the box so nothing is lost; the
|
|
3026
|
+
// person can just click Send again once the error is fixed.
|
|
3027
|
+
chatActionFailed(o.error);
|
|
3028
|
+
}
|
|
3029
|
+
poll();
|
|
3030
|
+
}).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
reviseBtn.addEventListener("click", function(){
|
|
3034
|
+
if(!sessionId) return;
|
|
3035
|
+
var text = msgInput.value;
|
|
3036
|
+
authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
|
|
3037
|
+
var o = chatPostOutcome(r);
|
|
3038
|
+
if(o.ok){
|
|
3039
|
+
chatActionOk();
|
|
3040
|
+
msgInput.value = "";
|
|
3041
|
+
} else {
|
|
3042
|
+
chatActionFailed(o.error);
|
|
3043
|
+
}
|
|
3044
|
+
poll();
|
|
3045
|
+
}).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
|
|
3046
|
+
});
|
|
3047
|
+
|
|
3048
|
+
signBtn.addEventListener("click", function(){
|
|
3049
|
+
if(!sessionId) return;
|
|
3050
|
+
if(!signClickedOnce){
|
|
3051
|
+
// click 1 — the confirm turn's own "confirm" pick. Never signs.
|
|
3052
|
+
authorPost("/api/author/" + sessionId + "/sign-prepare", {}).then(function(r){
|
|
3053
|
+
var o = chatPostOutcome(r);
|
|
3054
|
+
if(o.ok) chatActionOk(); else chatActionFailed(o.error);
|
|
3055
|
+
signClickedOnce = true;
|
|
3056
|
+
poll();
|
|
3057
|
+
}).catch(function(){ chatActionFailed("network error — could not reach the panel server"); });
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
// click 2 — the ONLY route that signs. Carries the hash shown on the
|
|
3061
|
+
// card; the server re-checks it against signing.json regardless.
|
|
3062
|
+
authorGet("/api/author/" + sessionId).then(function(j){
|
|
3063
|
+
var hash = j.state && j.state.specHash;
|
|
3064
|
+
if(!hash) return;
|
|
3065
|
+
authorPost("/api/author/" + sessionId + "/sign", {specHash: hash}).then(function(r){
|
|
3066
|
+
var o = chatPostOutcome(r);
|
|
3067
|
+
if(o.ok){ chatActionOk(); } else { chatActionFailed(o.error); return; }
|
|
3068
|
+
poll();
|
|
3069
|
+
}).catch(function(){ chatActionFailed("network error — could not reach the panel server, not signed"); });
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
refreshStartEnabled();
|
|
3074
|
+
})();
|
|
3075
|
+
|
|
2153
3076
|
})();
|
|
2154
3077
|
</script>
|
|
2155
3078
|
</body>
|