bareloop 0.31.0 → 0.33.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 (54) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -1
  3. package/bareloop.context.md +232 -85
  4. package/bin/bareloop.mjs +0 -1
  5. package/package.json +1 -1
  6. package/src/authorflow.js +5 -3
  7. package/src/authorjob.js +3 -1
  8. package/src/authorrun.js +49 -11
  9. package/src/authorscout.js +10 -6
  10. package/src/bundlerun.js +266 -0
  11. package/src/cli.js +35 -357
  12. package/src/config.js +135 -0
  13. package/src/interviewrun.js +13 -4
  14. package/src/invoke.js +22 -0
  15. package/src/judged.js +5 -3
  16. package/src/keysfile.js +156 -0
  17. package/src/ledger.js +26 -0
  18. package/src/monthly.js +422 -0
  19. package/src/panel/authorroutes.js +61 -16
  20. package/src/panel/authorsession.js +46 -28
  21. package/src/panel/index.html +287 -13
  22. package/src/panel/server.js +58 -51
  23. package/src/panel/settingsroutes.js +230 -0
  24. package/src/planrun.js +7 -3
  25. package/src/providerrows.js +301 -0
  26. package/src/providers.js +3 -1
  27. package/src/run.js +7 -2
  28. package/src/runlist.js +92 -7
  29. package/src/source.js +8 -3
  30. package/src/u-watchdog.mjs +296 -0
  31. package/src/userrun.js +230 -54
  32. package/types/authorflow.d.ts +8 -2
  33. package/types/authorjob.d.ts +6 -1
  34. package/types/authorrun.d.ts +5 -0
  35. package/types/authorscout.d.ts +11 -2
  36. package/types/bundlerun.d.ts +26 -0
  37. package/types/cli.d.ts +2 -1
  38. package/types/config.d.ts +48 -0
  39. package/types/interviewrun.d.ts +5 -0
  40. package/types/invoke.d.ts +8 -0
  41. package/types/judged.d.ts +5 -1
  42. package/types/keysfile.d.ts +90 -0
  43. package/types/ledger.d.ts +14 -0
  44. package/types/monthly.d.ts +227 -0
  45. package/types/panel/authorroutes.d.ts +4 -2
  46. package/types/panel/authorsession.d.ts +7 -15
  47. package/types/panel/server.d.ts +10 -3
  48. package/types/panel/settingsroutes.d.ts +14 -0
  49. package/types/planrun.d.ts +13 -1
  50. package/types/providerrows.d.ts +229 -0
  51. package/types/run.d.ts +14 -1
  52. package/types/runlist.d.ts +66 -3
  53. package/types/source.d.ts +8 -3
  54. package/types/userrun.d.ts +54 -0
@@ -20,10 +20,12 @@
20
20
  // `src/panel/server.js`'s POST routes). Nothing here changes what a step
21
21
  // means or what an answer does — only where the answer comes from.
22
22
 
23
+ import { applyConfiguredKey, judgeRatesFor, keyNameFor, modelChoiceFor, ratesFor, rowsForHome } from '../providerrows.js';
24
+ import { ConfigError } from '../config.js';
23
25
  import {
24
26
  mkdirSync, existsSync, writeFileSync, readdirSync,
25
27
  } from 'node:fs';
26
- import { join, resolve } from 'node:path';
28
+ import { join, resolve, dirname } from 'node:path';
27
29
  import {
28
30
  prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
29
31
  } from '../source.js';
@@ -46,20 +48,6 @@ import { closeJudges } from '../kinds.js';
46
48
  import { redactSecrets } from '../validate.js';
47
49
  import { tallyCalls } from '../text.js';
48
50
 
49
- /**
50
- * The job card's "Model" select — a fixed, small, source-grounded mapping
51
- * from a display id to the `{provider, baseUrl?}` pair `src/interviewrun.js`
52
- * itself documents (that script's own `--provider openai-api --base-url
53
- * https://api.deepseek.com/v1` usage line for DeepSeek): the panel builds no
54
- * provider table of its own, this is a UI convenience over the SAME two
55
- * provider identities the CLI already resolves through `resolveProvider`.
56
- * @type {Readonly<Record<string, {provider: string, baseUrl?: string}>>}
57
- */
58
- export const MODEL_OPTIONS = Object.freeze({
59
- 'claude-sonnet-5': Object.freeze({ provider: 'anthropic-api' }),
60
- 'deepseek-flash': Object.freeze({ provider: 'openai-api', baseUrl: 'https://api.deepseek.com/v1' }),
61
- });
62
-
63
51
  /**
64
52
  * Plain-language labels for the library's own `onPhase(name, …)` calls
65
53
  * (`src/authorjob.js`/`src/authorflow.js`) — build item 3's progress
@@ -126,17 +114,18 @@ export function jobNameTaken(name, opts = {}) {
126
114
  * creates a session or spends anything — never inside the async pipeline,
127
115
  * so a bad card refuses synchronously, in the same HTTP response.
128
116
  * @param {any} card
117
+ * @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
129
118
  * @returns {{ok: true}|{ok: false, error: string}}
130
119
  */
131
- export function validateJobCard(card) {
120
+ export function validateJobCard(card, opts = {}) {
132
121
  if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
133
122
  const { jobName, checkType, model, goal, source, destination, success, guardrails, capUsd } = card;
134
123
  if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
135
- if (jobNameTaken(jobName, card.jobsDirOverride ? { jobsDir: card.jobsDirOverride } : {})) {
124
+ if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
136
125
  return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
137
126
  }
138
127
  if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
139
- if (!MODEL_OPTIONS[model]) return { ok: false, error: `Model must be one of: ${Object.keys(MODEL_OPTIONS).join(', ')}` };
128
+ if (!modelChoiceFor(opts.rows ?? [], model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
140
129
  for (const [field, label] of [[goal, 'Goal'], [source, 'Source'], [destination, 'Destination'], [success, 'Success'], [guardrails, 'Guardrails']]) {
141
130
  if (typeof field !== 'string' || field.trim() === '') return { ok: false, error: `${label} is required` };
142
131
  }
@@ -155,7 +144,7 @@ export function validateJobCard(card) {
155
144
  * is the ONE thing a person answers next; `answer()`/`send()`/`revise()`/
156
145
  * `signPrepare()` are the only ways in.
157
146
  * @param {any} card the validated job card (see {@link validateJobCard})
158
- * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, timeoutMs?: number,
147
+ * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
159
148
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
160
149
  * prepareSigningFn?: Function}} [deps] the last five are TEST SEAMS ONLY — see the note
161
150
  * just above where each is read, below.
@@ -164,6 +153,8 @@ export function validateJobCard(card) {
164
153
  export function createSession(card, deps = {}) {
165
154
  const env = deps.env ?? process.env;
166
155
  const sessionsRoot = deps.sessionsRoot ?? join(process.env.HOME ?? '/tmp', '.config', 'bareloop', 'panel-sessions');
156
+ // config.json lives beside the sessions dir (~/.config/bareloop) unless a home is injected
157
+ const configHome = deps.home ?? dirname(sessionsRoot);
167
158
  const timeoutMs = deps.timeoutMs ?? 300_000;
168
159
  // TEST SEAMS ONLY (never set by `src/panel/authorroutes.js`'s real caller):
169
160
  // override the declaration composer and/or `prepareSigning` itself so a
@@ -309,13 +300,28 @@ export function createSession(card, deps = {}) {
309
300
 
310
301
  async function run() {
311
302
  // ── keys, at $0, before anything is prepared ────────────────────────
312
- const modelChoice = MODEL_OPTIONS[card.model];
303
+ const keyRows = rowsForHome(configHome);
304
+ const modelChoice = modelChoiceFor(keyRows, card.model);
305
+ if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
313
306
  let providerEntry;
314
307
  try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
315
- const apiKey = env[providerEntry.envKey];
316
- if (!apiKey) { refuse(`${providerEntry.envKey} not set. Stopped — nothing spent.`); return; }
308
+ // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
309
+ const keyCfg = keyRows;
310
+ const draftEnv = applyConfiguredKey(env, modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name);
311
+ const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
312
+ const apiKey = draftEnv[providerEntry.envKey];
313
+ if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
314
+ // the customer's own price on this row (USD per 1M in config.json), resolved once here at $0 and
315
+ // handed to every model call this session makes; a bad price refuses, never falls back to the guess
316
+ /** @type {ReturnType<typeof ratesFor>} */
317
+ let draftPrice = null;
318
+ try { draftPrice = ratesFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name); } catch (e) {
319
+ if (!(e instanceof ConfigError)) throw e;
320
+ refuse(`${e.message}. Stopped — nothing spent.`);
321
+ return;
322
+ }
317
323
  const keyProblem = apiKeyProblem(apiKey);
318
- if (keyProblem) { refuse(`${providerEntry.envKey} ${keyProblem}. Stopped — nothing spent.`); return; }
324
+ if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
319
325
 
320
326
  const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
321
327
  const into = join(outDir, 'source-seed');
@@ -389,6 +395,8 @@ export function createSession(card, deps = {}) {
389
395
  description: `${card.jobName} — authored through the bareloop panel (${verdictType}, ${LANG})`,
390
396
  provider: modelChoice.provider,
391
397
  ...(modelChoice.baseUrl ? { baseUrl: modelChoice.baseUrl } : {}),
398
+ // the Name is the model id: signed into the spec only when it is not the provider's own default
399
+ ...(modelChoice.name !== providerEntry.tiers.sonnet ? { model: modelChoice.name } : {}),
392
400
  cadence: { unit: 'day', every: 1 },
393
401
  budgetUsd: card.capUsd,
394
402
  ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
@@ -414,7 +422,7 @@ export function createSession(card, deps = {}) {
414
422
  const draftJudge = (() => {
415
423
  try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
416
424
  })();
417
- const MODEL = providerEntry.tiers.sonnet;
425
+ const MODEL = modelChoice.name;
418
426
  const { provider } = buildRunnerProviders({
419
427
  providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
420
428
  judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
@@ -424,8 +432,8 @@ export function createSession(card, deps = {}) {
424
432
  // `provider` for the model boundary) and/or `scout` (bypassing the real
425
433
  // paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
426
434
  // machinery below with a deterministic fake, never a live provider call.
427
- const generate = deps.generate ?? makeLoopGenerate(provider);
428
- const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
435
+ const generate = deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null });
436
+ const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null });
429
437
 
430
438
  state.phase = 'drafting';
431
439
  state.progressLabel = 'drafting';
@@ -439,6 +447,7 @@ export function createSession(card, deps = {}) {
439
447
  questions: questionsFor(verdictType),
440
448
  writeScope, provider, generate,
441
449
  ceilingUsd: card.capUsd,
450
+ rates: draftPrice?.rates ?? null,
442
451
  onPhase, onCall,
443
452
  ask, confirmGenerate, isRepo: true, langResult,
444
453
  ...(deps.scout ? { scout: deps.scout } : {}),
@@ -472,8 +481,17 @@ export function createSession(card, deps = {}) {
472
481
  const judges = closeJudges(spec.closeDecl);
473
482
  const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
474
483
  let judgeProvider = null;
484
+ /** @type {ReturnType<typeof ratesFor>} */
485
+ let judgePrice = null;
475
486
  if (judge) {
476
- const judgeKeyValue = env.JUDGE_API_KEY ?? env[resolveProvider(judge.provider).envKey];
487
+ try {
488
+ judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
489
+ } catch (e) {
490
+ if (!(e instanceof ConfigError)) throw e;
491
+ refuse(`${e.message} — refusing before gate 4 spends anything`);
492
+ return;
493
+ }
494
+ const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
477
495
  const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
478
496
  if (!judgeKeyValue || judgeKeyProblem) {
479
497
  refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
@@ -488,7 +506,7 @@ export function createSession(card, deps = {}) {
488
506
  const signing = await prepareSigningFn({
489
507
  spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
490
508
  shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
491
- judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
509
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
492
510
  judgeModel: judge?.model ?? null,
493
511
  onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
494
512
  });
@@ -330,12 +330,9 @@
330
330
  .expand-field:focus{height:52px;white-space:normal;}
331
331
  .radio-row{display:flex;gap:14px;align-items:center;margin-bottom:10px;}
332
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;}
333
+ .cap-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:6px;margin-bottom:6px;}
334
334
  .cap-field label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:1px;}
335
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
336
  .btn.primary{background:var(--primary-bg);color:var(--primary-text);border-color:var(--primary-border);font-weight:700;}
340
337
  .btn.primary::before,.btn.primary::after{color:var(--primary-text);opacity:0.6;}
341
338
  .btn:disabled{opacity:0.5;cursor:not-allowed;}
@@ -377,6 +374,32 @@
377
374
  .actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
378
375
  .actions-row > .btn{flex:1 1 auto;}
379
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
+ }
380
403
  @media (max-width: 899px){
381
404
  .main{flex-direction:column;}
382
405
  .left-pane{width:100%;border-right:none;border-bottom:1px solid var(--border-strong);}
@@ -402,6 +425,7 @@
402
425
  </div>
403
426
  <div class="topbar-right">
404
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>
405
429
  <button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light/dark theme">&#9789;</button>
406
430
  </div>
407
431
  </header>
@@ -431,10 +455,7 @@
431
455
  </div>
432
456
  <div class="field">
433
457
  <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>
458
+ <select id="jf-model" data-testid="jf-model"></select>
438
459
  <!-- build item 5 (2026-09-28) — ONE line under Model, not two:
439
460
  key + reachability collapsed into a single readiness read. -->
440
461
  <div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
@@ -472,15 +493,12 @@
472
493
  <div class="cap-field">
473
494
  <label for="jf-cap-money">Cap $</label>
474
495
  <input id="jf-cap-money" type="text">
496
+ <div class="hint" id="jf-cap-note" data-testid="cap-note"></div>
475
497
  </div>
476
498
  <div class="cap-field">
477
499
  <label for="jf-cap-time">Time cap (min)</label>
478
500
  <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
479
501
  </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
502
  </div>
485
503
  <button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
486
504
  <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
@@ -620,6 +638,74 @@
620
638
 
621
639
  </div>
622
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
+
623
709
  </div>
624
710
 
625
711
  <script>
@@ -2672,6 +2758,156 @@
2672
2758
  });
2673
2759
  })();
2674
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
+
2675
2911
  // ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
2676
2912
  (function(){
2677
2913
  var newBtn = document.getElementById("chat-new-btn");
@@ -2756,6 +2992,23 @@
2756
2992
  startBtn.disabled = !capOk || sessionLive || !keyOk;
2757
2993
  }
2758
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);
2759
3012
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
2760
3013
  r.addEventListener("change", function(){
2761
3014
  var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
@@ -2806,6 +3059,13 @@
2806
3059
  }
2807
3060
  function refreshModelStatus(){
2808
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
+ }
2809
3069
  authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
2810
3070
  var line = modelStatusLine(r, modelId);
2811
3071
  keyOk = line.keyOk;
@@ -2820,7 +3080,21 @@
2820
3080
  });
2821
3081
  }
2822
3082
  modelSelect.addEventListener("change", refreshModelStatus);
2823
- 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();
2824
3098
 
2825
3099
  // build item 2 — one plain line per event, no "(progress)" jargon
2826
3100
  // suffix: `onPhase` no longer posts its own bubble (see