bareloop 0.40.0 → 0.41.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 (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bareloop.context.md +97 -124
  3. package/package.json +2 -2
  4. package/src/authorflow.js +9 -9
  5. package/src/authorjob.js +4 -4
  6. package/src/authorrun.js +25 -25
  7. package/src/authorscout.js +4 -4
  8. package/src/bundle.js +7 -4
  9. package/src/bundlerun.js +4 -1
  10. package/src/cli.js +1 -1
  11. package/src/config.js +129 -29
  12. package/src/draftspend.js +4 -4
  13. package/src/index.js +2 -4
  14. package/src/interviewrun.js +17 -25
  15. package/src/job.js +13 -7
  16. package/src/judged.js +11 -43
  17. package/src/keysfile.js +15 -7
  18. package/src/kinds.js +2 -2
  19. package/src/modellist.js +84 -0
  20. package/src/monthly.js +23 -43
  21. package/src/panel/authorroutes.js +19 -14
  22. package/src/panel/authorsession.js +62 -58
  23. package/src/panel/index.html +520 -145
  24. package/src/panel/runroutes.js +3 -0
  25. package/src/panel/server.js +35 -15
  26. package/src/panel/settingsroutes.js +232 -173
  27. package/src/planrun.js +2 -2
  28. package/src/providerrows.js +226 -141
  29. package/src/providers.js +58 -332
  30. package/src/providershapes.js +18 -0
  31. package/src/replay.js +1 -1
  32. package/src/thinking.js +130 -0
  33. package/src/userrun.js +50 -73
  34. package/types/authorflow.d.ts +5 -5
  35. package/types/authorjob.d.ts +2 -2
  36. package/types/authorscout.d.ts +2 -2
  37. package/types/config.d.ts +23 -6
  38. package/types/draftspend.d.ts +8 -8
  39. package/types/index.d.ts +1 -1
  40. package/types/job.d.ts +5 -5
  41. package/types/judged.d.ts +5 -36
  42. package/types/keysfile.d.ts +15 -4
  43. package/types/modellist.d.ts +50 -0
  44. package/types/monthly.d.ts +4 -3
  45. package/types/panel/authorsession.d.ts +2 -9
  46. package/types/panel/server.d.ts +14 -4
  47. package/types/providerrows.d.ts +192 -99
  48. package/types/providers.d.ts +34 -159
  49. package/types/providershapes.d.ts +10 -0
  50. package/types/thinking.d.ts +90 -0
  51. package/types/userrun.d.ts +6 -1
@@ -175,6 +175,9 @@ export function createRunRoutes(opts) {
175
175
  '--wait', 'systemd-inhibit', '--why=bareloop panel resume',
176
176
  process.execPath, bareloopBin,
177
177
  'run-u', '--spec', specPath, '--resume', m[1], '--approve', specHash,
178
+ // an older spec that names no model runs on the Settings line the person picks at resume (hash untouched, run-u's own rule)
179
+ ...(typeof body?.model === 'string' && body.model !== '' ? ['--model', body.model] : []),
180
+ ...(typeof body?.provider === 'string' && body.provider !== '' ? ['--provider', body.provider] : []),
178
181
  // the drafting fold rides every leg of the chain (a leg that dropped it would widen the ceiling)
179
182
  ...(plan.draftSpentUsd !== null ? ['--draft-spent-usd', String(plan.draftSpentUsd), ...(plan.draftSpendComplete === false ? ['--draft-spend-incomplete'] : [])] : []),
180
183
  ];
@@ -49,7 +49,8 @@ import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.j
49
49
  import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
50
50
  import { checkCloseByteSignature } from '../close-integrity.js';
51
51
  import { keysHome } from '../keysfile.js';
52
- import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
52
+ import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '../providerrows.js';
53
+ import { SHAPES } from '../providershapes.js';
53
54
  import { resolveProvider } from '../providers.js';
54
55
  import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
55
56
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
@@ -2374,28 +2375,38 @@ function signedSpecForRun(row, records) {
2374
2375
  }
2375
2376
 
2376
2377
  /**
2377
- * The worker a signed spec ran: provider, baseUrl and model, the model falling back to the provider's default tier
2378
- * when the spec names none (the same fallback `resolveWorkerModel` makes at run time).
2378
+ * The worker a signed spec ran: provider, baseUrl and model ('' when an older spec names none: bareloop keeps no default).
2379
2379
  * @param {any} spec
2380
2380
  * @returns {{provider: string, baseUrl?: string, model: string}}
2381
2381
  */
2382
2382
  function workerOfSpec(spec) {
2383
2383
  const provider = typeof spec?.provider === 'string' ? spec.provider : '';
2384
- let model = typeof spec?.model === 'string' ? spec.model : '';
2385
- if (!model) { try { model = resolveProvider(provider).tiers.sonnet; } catch { model = ''; } }
2384
+ const model = typeof spec?.model === 'string' ? spec.model : '';
2386
2385
  return { provider, ...(typeof spec?.baseUrl === 'string' && spec.baseUrl ? { baseUrl: spec.baseUrl } : {}), model };
2387
2386
  }
2388
2387
  /** @param {{provider: string, baseUrl?: string, model: string}} w @returns {string} one spelling of "the same worker" */
2389
2388
  const workerId = (w) => `${w.provider}\u0000${w.baseUrl ?? ''}\u0000${w.model}`;
2389
+ /**
2390
+ * The model id a reuse card's Model box shows for a row: the spec's own model when the row has a line for it, else the
2391
+ * row's first ticked line; `undefined` when there is no row or no such line.
2392
+ * @param {import('../providerrows.js').KeyRow|null} row
2393
+ * @param {string|null|undefined} specModel
2394
+ * @returns {string|undefined}
2395
+ */
2396
+ function rowModelOf(row, specModel) {
2397
+ if (!row) return undefined;
2398
+ if (specModel && row.models.some((l) => l.model === specModel)) return specModel;
2399
+ return tickedLines([row])[0]?.line.model;
2400
+ }
2390
2401
  /**
2391
2402
  * The worker the reuse card's CURRENT Model box names: the Settings row's provider, baseUrl and Name. `null` when the
2392
2403
  * box is blank or names no row.
2393
2404
  * @param {string} model a Settings Name
2394
- * @param {{ home?: string }} opts
2405
+ * @param {{ home?: string, provider?: string }} opts `provider` is the key's env name the card picked
2395
2406
  * @returns {{provider: string, baseUrl?: string, model: string}|null}
2396
2407
  */
2397
2408
  function workerOfChoice(model, opts) {
2398
- const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), model);
2409
+ const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), { provider: opts.provider, model });
2399
2410
  return c ? { provider: c.provider, ...(c.baseUrl ? { baseUrl: c.baseUrl } : {}), model: c.name } : null;
2400
2411
  }
2401
2412
 
@@ -2478,10 +2489,11 @@ export function reuseLine(record) {
2478
2489
  * $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
2479
2490
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
2480
2491
  * @param {string} runid
2481
- * @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
2492
+ * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
2482
2493
  * @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
2483
2494
  * locked: readonly string[], open: readonly string[], note: string|null,
2484
- * specHash: string, workflowKey: string, spec: any, specPath: string,
2495
+ * specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
2496
+ * specWorker: {provider: string, endpoint: string, label: string},
2485
2497
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2486
2498
  */
2487
2499
  export function getStartFrom(runid, opts = {}) {
@@ -2507,7 +2519,7 @@ export function getStartFrom(runid, opts = {}) {
2507
2519
  const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
2508
2520
  const card = reuseCardFromSpec(spec, {
2509
2521
  model: typeof saved.model === 'string' && saved.model ? saved.model
2510
- : (findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name
2522
+ : (rowModelOf(findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model }), spec.model)
2511
2523
  ?? (typeof jobStart?.model === 'string' ? jobStart.model : '')),
2512
2524
  source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2513
2525
  // a card saved by P7 or later keeps the Inputs text the person typed
@@ -2515,7 +2527,7 @@ export function getStartFrom(runid, opts = {}) {
2515
2527
  jobName: row.job,
2516
2528
  });
2517
2529
  const key = workflowKey(spec);
2518
- const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2530
+ const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, { ...opts, provider: opts.provider ?? card.provider }) });
2519
2531
  return {
2520
2532
  ok: true,
2521
2533
  origin: { runid, job: row.job },
@@ -2530,6 +2542,14 @@ export function getStartFrom(runid, opts = {}) {
2530
2542
  workflowKey: key,
2531
2543
  spec,
2532
2544
  specPath: signed.specPath,
2545
+ // an older spec signed before models were required names none: the Resume card then opens its Model box (run-u takes `--model`)
2546
+ specNamesModel: typeof spec.model === 'string' && spec.model !== '',
2547
+ // what resolveWorker will hold a picked line to: the spec's shape, and its endpoint when it names one ('' = any endpoint)
2548
+ specWorker: {
2549
+ provider: String(spec.provider),
2550
+ endpoint: typeof spec.baseUrl === 'string' && spec.baseUrl.trim() !== '' ? endpointOf(String(spec.provider), spec.baseUrl) : '',
2551
+ label: SHAPES.find((x) => x.id === spec.provider)?.label ?? String(spec.provider),
2552
+ },
2533
2553
  trackRecord,
2534
2554
  line: reuseLine(trackRecord),
2535
2555
  };
@@ -2540,7 +2560,7 @@ export function getStartFrom(runid, opts = {}) {
2540
2560
  * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
2541
2561
  * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
2542
2562
  * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
2543
- * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
2563
+ * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` passes through to the estimate line, as start-from's does
2544
2564
  * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
2545
2565
  */
2546
2566
  export function listReuseJobs(opts = {}) {
@@ -2621,7 +2641,7 @@ function describeSpec(spec) {
2621
2641
  * signs it on this machine at [Sign & run]. Same card, same locked/open boxes as a run's reuse. `null` when no such
2622
2642
  * imported job.
2623
2643
  * @param {string} id
2624
- * @param {{ home?: string, model?: string }} [opts] `model` as for {@link getStartFrom}
2644
+ * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
2625
2645
  * @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
2626
2646
  * locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
2627
2647
  * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -2640,10 +2660,10 @@ export function getStartFromImport(id, opts = {}) {
2640
2660
  // the bundle's provider is no longer forced: Model is open on a reuse, so the person's chosen Settings row is the worker.
2641
2661
  // The card opens on the row matching the bundle's own provider when there is one, else blank (the page's menu picks).
2642
2662
  const rows = rowsForHome(keysHome(opts.home));
2643
- const named = findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name ?? '';
2663
+ const named = rowModelOf(findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model }), spec.model) ?? '';
2644
2664
  const card = reuseCardFromSpec(spec, { model: named, source: '', jobName: row.job });
2645
2665
  const key = workflowKey(spec);
2646
- const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2666
+ const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, { ...opts, provider: opts.provider ?? card.provider }) });
2647
2667
  return {
2648
2668
  ok: true,
2649
2669
  origin: { runid: null, job: row.job, importId: id },
@@ -1,26 +1,35 @@
1
- // PANEL-BUILD.md P4a — Settings routes (`/api/settings/*`). A separate module from the
1
+ // rewritten-from: fwdloop src/panel/settings.js@f9449e8 (the Providers routes; the Money routes are bareloop's own)
2
+ // PANEL-BUILD.md P4a / P8 — Settings routes (`/api/settings/*`). A separate module from the
2
3
  // authoring routes on purpose: the chat/authoring session can reach NO route here (it has
3
4
  // no path to this file's handlers), and every route here is behind the same human-click
4
5
  // guard (per-server-start token + Origin/Host, `checkHumanGuard`) — GET too, because the
5
6
  // pages these serve name key variables and spend.
6
7
  //
7
- // Providers (P4b): the rows are the keys file's filled lines; Name / API shape / Base URL save to
8
- // config.json `keys.<ENV NAME>` (src/providerrows.js is the one owner of the rows).
8
+ // Providers (P8): the rows are the keys file's filled lines (plus a shell-only key that config
9
+ // already names); API shape / Base URL / old-max_tokens save to config.json `keys.<ENV NAME>`
10
+ // and the model lines to `models.<ENV NAME>` (src/providerrows.js is the one owner of the rows).
11
+ // Test and Refresh are ONE free GET of `<Base URL>/models` (src/modellist.js), never a completion,
12
+ // and write no spend row. Every refusal is a plain sentence in `say`; the page shows `say`, never
13
+ // a code or an HTTP status.
9
14
  //
10
15
  // Every write goes to `~/.config/bareloop/config.json` through `src/config.js` (the one
11
16
  // writer); the monthly limit is a person's own number, set by hand — nothing here (and
12
17
  // nothing the agent can reach) raises it on their behalf. Key VALUES never appear in any
13
18
  // response: names and found / not set only.
14
19
  import { checkHumanGuard } from './authorroutes.js';
15
- import { readConfig, updateConfig, ConfigError } from '../config.js';
20
+ import {
21
+ readConfig, updateConfig, ConfigError, providerFieldProblem, cleanBaseUrl, modelsProblem,
22
+ } from '../config.js';
16
23
  import { spendSummary, monthlyLimitOf } from '../monthly.js';
17
24
  import { spawn as realSpawn } from 'node:child_process';
18
25
  import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
19
- import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
20
- import { apiKeyProblem, checkProviderReachable } from '../providers.js';
26
+ import { SHAPES } from '../providershapes.js';
27
+ import { keyRows, effectiveModels, usableKey, rowNamesOf } from '../providerrows.js';
28
+ import { apiKeyProblem } from '../providers.js';
29
+ import { listModels } from '../modellist.js';
21
30
 
22
- /** DeepSeek's own balance endpoint (same host as its models list). */
23
- const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
31
+ const BROKEN_CONFIG = 'Your settings file (config.json) cannot be read. Fix or remove it, then look again.';
32
+ const BAD_KEY = 'The key has a space or a line break in it. Fix it in your keys file, then reload keys.';
24
33
 
25
34
  /**
26
35
  * `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
@@ -36,11 +45,209 @@ export function createSettingsRoutes(opts) {
36
45
  * module; values never do.
37
46
  * @returns {ReturnType<typeof loadKeysEnv>} */
38
47
  const keys = () => loadKeysEnv({ env: opts.env, home: opts.home });
39
- /** the Providers rows: keys with a value in the file, over the saved settings
40
- * @param {Record<string, any>} config */
41
- const currentRows = (config) => keyRows({ filled: filledKeyNames(opts.home), config });
42
- /** @param {string} u @returns {string} */
43
- const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
48
+
49
+ /**
50
+ * The row names: every key in the keys file with a value, plus a key only the SHELL holds when
51
+ * config already names it (`keys.<ENV>` or `models.<ENV>`).
52
+ * @param {Record<string, any>} config
53
+ * @returns {{ filled: string[], shellOnly: string[], all: string[] }}
54
+ */
55
+ function rowNames(config) {
56
+ const filled = filledKeyNames(opts.home);
57
+ const all = rowNamesOf(filled, config, opts.env ?? process.env);
58
+ return { filled, shellOnly: all.filter((n) => !filled.includes(n)), all };
59
+ }
60
+ /** config.json, or a plain-words problem (never read as "nothing set"). @returns {{config: Record<string, any>, problem: string|null}} */
61
+ function config() {
62
+ const c = readConfig({ home: opts.home });
63
+ return c.problem ? { config: {}, problem: BROKEN_CONFIG } : { config: c.config, problem: null };
64
+ }
65
+ /** @param {string} refused @param {string} say @param {number} [status] */
66
+ const refuse = (refused, say, status = 400) => ({ status, body: { ok: false, refused, say } });
67
+ /** @param {number} status @param {any} body */
68
+ const reply = (status, body) => ({ status, body });
69
+ /** The row a request names, or null: only a key that is a row is a provider. @param {unknown} slot @param {Record<string, any>} cfg */
70
+ const slotOf = (slot, cfg) => (typeof slot === 'string' && rowNames(cfg).all.includes(slot) ? slot : null);
71
+
72
+ /** One row's key for a header only; or `{why}` in plain words. @param {string} slot @param {ReturnType<typeof loadKeysEnv>} k @returns {{ key?: string, why?: string }} */
73
+ function keyFor(slot, k) {
74
+ if (k.unreadable) return { why: String(k.warning) };
75
+ const value = k.env[slot];
76
+ if (value === undefined || value === '') return { why: 'No key set for this provider.' };
77
+ if (value !== 'null' && apiKeyProblem(value)) return { why: BAD_KEY };
78
+ return { key: /** @type {string} */ (usableKey(value)) };
79
+ }
80
+
81
+ /**
82
+ * Test and Refresh: the ONE free GET (src/modellist.js) for a row. `{unknown:true}` when the slot is not a row.
83
+ * @param {unknown} rawSlot
84
+ * @returns {Promise<{unknown: true}|{ok: true, models: string[], ms: number}|{ok: false, why: string}>}
85
+ */
86
+ async function listFor(rawSlot) {
87
+ const c = config();
88
+ const k = keys();
89
+ const slot = slotOf(rawSlot, c.config);
90
+ if (!slot) return { unknown: true };
91
+ if (c.problem) return { ok: false, why: c.problem };
92
+ const row = keyRows({ filled: [slot], config: c.config })[0];
93
+ // the address comes first: a row with no address has nothing to send yet
94
+ if (row.baseUrl.trim() === '') return { ok: false, why: `Type a Base URL for ${slot} in Settings.` };
95
+ const kf = keyFor(slot, k);
96
+ if (kf.why) return { ok: false, why: kf.why };
97
+ const r = await listModels({ shape: row.provider, baseUrl: row.baseUrl, key: /** @type {string} */ (kf.key), fetchImpl });
98
+ return r.ok ? r : { ok: false, why: r.why };
99
+ }
100
+
101
+ /** The Providers GET. */
102
+ function providers() {
103
+ const k = keys();
104
+ const c = config();
105
+ const names = rowNames(c.config);
106
+ const rows = keyRows({ filled: names.all, config: c.config });
107
+ const spend = spendSummary({ home: opts.home, now: opts.now, rows });
108
+ return reply(200, {
109
+ ok: true,
110
+ keysFile: { path: keysFilePath(opts.home), exists: k.exists, refusal: k.warning },
111
+ configProblem: c.problem,
112
+ shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
113
+ rows: rows.map((r) => {
114
+ const raw = k.env[r.envName];
115
+ let keyStatus = 'not set';
116
+ if (k.unreadable) keyStatus = 'refused — see above';
117
+ else if (raw && raw !== 'null' && apiKeyProblem(raw)) keyStatus = BAD_KEY;
118
+ else if (names.filled.includes(r.envName)) keyStatus = 'set in file';
119
+ else if (names.shellOnly.includes(r.envName)) keyStatus = 'found in shell';
120
+ const own = spend.tokensByModel[r.envName] ?? {};
121
+ return {
122
+ slot: r.envName, label: r.envName, envVar: r.envName, keyStatus,
123
+ shape: r.provider, baseUrl: r.baseUrl, legacyMaxTokens: r.legacyMaxTokens,
124
+ tokens: spend.tokensByRow[r.envName] ?? 0,
125
+ models: r.models.map(({ model, use, inPerM, outPerM }) => ({
126
+ model, use, ...(inPerM === undefined ? {} : { inPerM }), ...(outPerM === undefined ? {} : { outPerM }),
127
+ // this model's own tokens from the spend rows that name it; absent = no row names it (never 0)
128
+ ...(Object.hasOwn(own, model) ? { tokens: own[model] } : {}),
129
+ })),
130
+ };
131
+ }),
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Save one model line: {slot, model, use?, inPerM?, outPerM?}. A field left out keeps the line's value; a price
137
+ * `null` clears it. The WHOLE list is judged by `modelsProblem` (the one rule) before anything is written.
138
+ * @param {any} body
139
+ */
140
+ function modelRoute(body) {
141
+ const c = config();
142
+ const k = keys();
143
+ const slot = slotOf(body?.slot, c.config);
144
+ if (!slot) return refuse('unknown-provider', 'That provider is not known.');
145
+ if (c.problem) return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
146
+ if (Object.hasOwn(body, 'cachedInPerM')) {
147
+ return refuse('bad-price', 'Cached input has no price of its own: it is priced as input. Nothing was saved.');
148
+ }
149
+ if (typeof body.model !== 'string') return refuse('bad-model', 'Pick a model line. Nothing was saved.');
150
+ const list = effectiveModels(slot, c.config).map((m) => ({ ...m }));
151
+ /** @type {Record<string, any>|undefined} */
152
+ let line = list.find((m) => m.model === body.model);
153
+ if (!line) { line = { model: body.model, use: false }; list.push(/** @type {any} */ (line)); }
154
+ if (Object.hasOwn(body, 'use')) line.use = body.use;
155
+ for (const f of /** @type {const} */ (['inPerM', 'outPerM'])) {
156
+ if (!Object.hasOwn(body, f)) continue;
157
+ if (body[f] === null) delete line[f];
158
+ else line[f] = body[f];
159
+ }
160
+ const problem = modelsProblem(list);
161
+ if (problem) return refuse('bad-model', problem);
162
+ try {
163
+ updateConfig({ models: { [slot]: list } }, { home: opts.home });
164
+ } catch (e) {
165
+ if (!(e instanceof ConfigError)) throw e;
166
+ return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
167
+ }
168
+ return reply(200, { ok: true, slot });
169
+ }
170
+
171
+ /** Save a row's shape, Base URL and old-max_tokens tick. Every field is judged first; write only if all pass. @param {any} body */
172
+ function providerRoute(body) {
173
+ const c = config();
174
+ const slot = slotOf(body?.slot, c.config);
175
+ if (!slot) return refuse('unknown-provider', 'That provider is not known.');
176
+ if (c.problem) return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
177
+ /** @type {Record<string, string>} */
178
+ const say = { shape: 'Pick an API shape from the list.', baseUrl: 'The Base URL must start with http:// or https://.', legacyMaxTokens: 'The legacy max_tokens tick must be on or off.' };
179
+ /** @type {Record<string, string|boolean|null>} */
180
+ const patch = {};
181
+ for (const key of Object.keys(say)) {
182
+ if (!Object.hasOwn(body, key)) continue;
183
+ const v = body[key];
184
+ const clearUrl = key === 'baseUrl' && v === '';
185
+ if (!clearUrl && providerFieldProblem(key, v) !== null) return refuse('bad-provider', `${say[key]} Nothing was saved.`);
186
+ patch[key] = clearUrl ? null : (key === 'baseUrl' ? cleanBaseUrl(v) : v);
187
+ }
188
+ if (Object.keys(patch).length === 0) return refuse('bad-provider', 'Nothing to save.');
189
+ try {
190
+ updateConfig({ keys: { [slot]: patch } }, { home: opts.home });
191
+ } catch (e) {
192
+ if (!(e instanceof ConfigError)) throw e;
193
+ return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
194
+ }
195
+ return reply(200, { ok: true, slot });
196
+ }
197
+
198
+ /**
199
+ * Open the FOLDER of the server's own keys file (never a file, never an editor, never a path from the request) in the OS file manager:
200
+ * argv array, no shell, detached. Another OS, a failed spawn, or a test process with no injected opener: `opened:false` + the path,
201
+ * which the page shows as text.
202
+ * @param {any} body
203
+ * @returns {Promise<{status: number, body: any}>}
204
+ */
205
+ function openKeysFolder(body) {
206
+ if (body !== null && typeof body === 'object' && Object.keys(body).length > 0) {
207
+ return Promise.resolve(refuse('no-path-allowed', 'The keys folder is opened from the panel\'s own settings; a path cannot be sent. Nothing was opened.'));
208
+ }
209
+ const dir = keysHome(opts.home);
210
+ const cannot = () => reply(200, { ok: true, opened: false, path: dir });
211
+ const platform = opts.platform ?? process.platform;
212
+ const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
213
+ // under a test process (NODE_ENV=test or `node --test`) nothing is spawned unless a test injected an opener
214
+ const underTest = process.env.NODE_ENV === 'test' || process.env.NODE_TEST_CONTEXT !== undefined;
215
+ const opener = opts.openFolderSpawn ?? (underTest ? null : realSpawn);
216
+ if (!cmd || !opener) return Promise.resolve(cannot());
217
+ return new Promise((resolve) => {
218
+ let child;
219
+ try { child = opener(cmd, [dir], { detached: true, stdio: 'ignore' }); } catch { resolve(cannot()); return; }
220
+ let settled = false;
221
+ child.once('error', () => { if (!settled) { settled = true; resolve(cannot()); } });
222
+ child.once('spawn', () => {
223
+ if (settled) return;
224
+ settled = true;
225
+ if (typeof child.unref === 'function') child.unref();
226
+ resolve(reply(200, { ok: true, opened: true, path: dir }));
227
+ });
228
+ });
229
+ }
230
+
231
+ /**
232
+ * The POST routes that answer asynchronously.
233
+ * @param {string} pathname
234
+ * @param {any} body
235
+ * @returns {Promise<{status: number, body: any}>|{status: number, body: any}}
236
+ */
237
+ function postRoute(pathname, body) {
238
+ if (pathname === '/api/settings/provider') return providerRoute(body);
239
+ if (pathname === '/api/settings/model') return modelRoute(body);
240
+ if (pathname === '/api/settings/open-keys-folder') return openKeysFolder(body);
241
+ return listFor(body?.slot).then((r) => {
242
+ if ('unknown' in r) return refuse('unknown-provider', 'That provider is not known.');
243
+ if (pathname === '/api/settings/test') return reply(200, r.ok ? { ok: true, ms: r.ms } : r);
244
+ // /api/settings/models
245
+ if (!r.ok) return reply(200, r);
246
+ const ids = r.models;
247
+ return reply(200, ids.length === 0 ? { ok: false, why: 'The provider did not list any models.' } : { ok: true, models: ids });
248
+ });
249
+ }
250
+
44
251
  /**
45
252
  * @param {import('node:http').IncomingMessage} req
46
253
  * @param {import('node:http').ServerResponse} res
@@ -61,7 +268,8 @@ export function createSettingsRoutes(opts) {
61
268
  if (pathname === '/api/settings/money') {
62
269
  if (req.method === 'GET') {
63
270
  const cfg = readConfig({ home: opts.home });
64
- const s = spendSummary({ home: opts.home, now: opts.now });
271
+ const rows = keyRows({ filled: rowNames(cfg.config).all, config: cfg.config });
272
+ const s = spendSummary({ home: opts.home, now: opts.now, rows });
65
273
  /** @type {number|null} */
66
274
  let limit = null;
67
275
  let configProblem = cfg.problem;
@@ -109,171 +317,22 @@ export function createSettingsRoutes(opts) {
109
317
  return true;
110
318
  }
111
319
 
112
- // ── Providers (P4b): one row per key in the keys file that has a value. ──
320
+ // ── Providers (P8) ──
113
321
  if (pathname === '/api/settings/providers' && req.method === 'GET') {
114
- // a missing keys file is created with the five empty preset lines (never edited if present)
115
- ensureKeysFile(PRESET_KEY_NAMES, opts.home);
116
- const cfg = readConfig({ home: opts.home });
117
- const k = keys();
118
- const rows = currentRows(cfg.config);
119
- const s = spendSummary({ home: opts.home, now: opts.now, rows });
120
- const note = cfg.config.anthropicBalanceNote;
121
- // a preset key exported only in the shell gets no row (the rows are the keys file): NAMES the
122
- // person can act on, read off the RAW shell env — never a value, never a length
123
- const shellEnv = opts.env ?? process.env;
124
- const filled = new Set(filledKeyNames(opts.home));
125
- const shellOnly = PRESET_KEY_NAMES.filter((n) => typeof shellEnv[n] === 'string' && shellEnv[n] !== '' && !filled.has(n));
126
- send(200, {
127
- ok: true,
128
- shellOnly,
129
- keysFile: { path: keysFilePath(opts.home), exists: k.exists, names: k.names, warning: k.warning },
130
- configProblem: cfg.problem,
131
- shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
132
- rows: rows.map((r) => {
133
- const raw = k.env[r.envName];
134
- const problem = raw ? apiKeyProblem(raw) : null;
135
- const isDeepseek = r.provider === 'openai-api' && hostOf(endpointOf(r.provider, r.baseUrl)) === 'api.deepseek.com';
136
- return {
137
- envName: r.envName, name: r.name, shape: r.provider, baseUrl: r.baseUrl,
138
- placeholder: defaultUrlOf(r.provider),
139
- keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
140
- canTest: r.provider !== 'gemini-api',
141
- priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
142
- priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
143
- tokens: s.tokensByRow[r.envName] ?? 0,
144
- balance: r.provider === 'anthropic-api'
145
- ? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
146
- : (isDeepseek ? { kind: 'fetch' } : { kind: 'none' }),
147
- };
148
- }),
149
- });
150
- return true;
151
- }
152
-
153
- if (pathname === '/api/settings/providers/row' && req.method === 'POST') {
154
- const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
155
- if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
156
- // only the three settings a row has; each is optional in the body, the whole triple is stored
157
- /** @type {Record<string, string|number|null>} */
158
- const patch = {
159
- name: typeof body?.name === 'string' ? body.name.trim() : row.name,
160
- shape: typeof body?.shape === 'string' ? body.shape : row.provider,
161
- baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
162
- };
163
- // the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
164
- // never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
165
- for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
166
- if (!Object.hasOwn(body ?? {}, f)) continue;
167
- const v = body[f];
168
- const label = f === 'priceInPerM' ? 'In' : 'Out';
169
- if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
170
- const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
171
- if (!(Number.isFinite(n) && n >= 0)) {
172
- send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
173
- return true;
174
- }
175
- patch[f] = n;
176
- }
177
- // a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
178
- const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
179
- if (after('priceInPerM') !== after('priceOutPerM')) {
180
- send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
181
- return true;
182
- }
183
- try {
184
- updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
185
- } catch (e) {
186
- if (!(e instanceof ConfigError)) throw e;
187
- send(400, { ok: false, error: e.message });
188
- return true;
189
- }
190
- send(200, { ok: true, envName: row.envName, ...patch });
191
- return true;
192
- }
193
-
194
- if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
195
- // opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
196
- // Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
197
- const dir = keysHome(opts.home);
198
- const platform = opts.platform ?? process.platform;
199
- const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
200
- const cannot = () => send(200, { ok: true, opened: false, path: dir });
201
- if (!cmd) { cannot(); return true; }
202
- let child;
203
- try {
204
- child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
205
- } catch { cannot(); return true; }
206
- let settled = false;
207
- child.once('error', () => { if (!settled) { settled = true; cannot(); } });
208
- child.once('spawn', () => {
209
- if (settled) return;
210
- settled = true;
211
- if (typeof child.unref === 'function') child.unref();
212
- send(200, { ok: true, opened: true, path: dir });
213
- });
214
- return true;
215
- }
216
-
217
- if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
218
- // the Anthropic balance is a NOTE the person types; no check ever reads it
219
- const raw = body?.usd;
220
- const usd = raw === null || raw === '' || raw === undefined ? null : Number(raw);
221
- if (usd !== null && !(Number.isFinite(usd) && usd >= 0)) { send(400, { ok: false, error: 'the balance must be a number, 0 or more' }); return true; }
222
- try {
223
- updateConfig({ anthropicBalanceNote: usd }, { home: opts.home });
224
- } catch (e) {
225
- if (!(e instanceof ConfigError)) throw e;
226
- send(400, { ok: false, error: e.message });
227
- return true;
228
- }
229
- send(200, { ok: true, usd });
322
+ // a missing keys file is created with its two comment lines (never edited if present)
323
+ ensureKeysFile(opts.home);
324
+ const r = providers();
325
+ send(r.status, r.body);
230
326
  return true;
231
327
  }
232
-
233
- if (pathname === '/api/settings/providers/test' && req.method === 'POST') {
234
- const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
235
- if (!row) { send(400, { ok: false, error: 'unknown key' }); return true; }
236
- const raw = keys().env[row.envName];
237
- if (!raw) { send(200, { ok: true, reachable: false, status: 'no key', note: `${row.envName} not set`, ms: null }); return true; }
238
- const problem = apiKeyProblem(raw);
239
- if (problem) { send(200, { ok: true, reachable: false, status: 'bad key', note: `${row.envName} ${problem}`, ms: null }); return true; }
240
- const started = Date.now();
241
- // $0: one GET of the models list with THIS row's shape + URL (never a completion), key only in a header
242
- checkProviderReachable({
243
- providerName: row.provider, apiKey: usableKey(raw), baseUrl: row.baseUrl.trim() !== '' ? row.baseUrl : undefined, fetchImpl, timeoutMs: 4000,
244
- }).then((r) => {
245
- send(200, { ok: true, reachable: r.reachable, status: r.status, note: r.note, ms: Date.now() - started });
246
- });
247
- return true;
248
- }
249
-
250
- if (pathname === '/api/settings/providers/balance' && req.method === 'GET') {
251
- // DeepSeek only: fetched server-side from the provider (key in a header, never returned)
252
- const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
253
- const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === q.get('env'));
254
- if (!row || row.provider !== 'openai-api' || hostOf(endpointOf(row.provider, row.baseUrl)) !== 'api.deepseek.com') { send(404, { ok: false, error: 'not found' }); return true; }
255
- const raw = keys().env[row.envName];
256
- if (!raw || apiKeyProblem(raw)) { send(200, { ok: true, text: null, note: 'no usable key' }); return true; }
257
- const controller = new AbortController();
258
- const timer = setTimeout(() => controller.abort(), 4000);
259
- fetchImpl(DEEPSEEK_BALANCE_URL, { method: 'GET', headers: { Authorization: `Bearer ${raw}` }, signal: controller.signal })
260
- .then(async (res) => {
261
- if (!res.ok) { send(200, { ok: true, text: null, note: `HTTP ${res.status}` }); return; }
262
- /** @type {any} */
263
- let j = null;
264
- try { j = await res.json(); } catch { j = null; }
265
- const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
266
- const parts = infos
267
- .filter((/** @type {any} */ b) => b && typeof b.currency === 'string' && b.total_balance !== undefined)
268
- .map((/** @type {any} */ b) => `${String(b.total_balance)} ${b.currency}`);
269
- send(200, { ok: true, text: parts.length ? parts.join(' + ') : null, note: parts.length ? null : 'balance not reported' });
270
- })
271
- .catch((/** @type {any} */ e) => { send(200, { ok: true, text: null, note: `request failed: ${e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'error')}` }); })
272
- .finally(() => clearTimeout(timer));
328
+ const postPaths = ['/api/settings/provider', '/api/settings/model', '/api/settings/models', '/api/settings/test', '/api/settings/open-keys-folder'];
329
+ if (req.method === 'POST' && postPaths.includes(pathname)) {
330
+ Promise.resolve(postRoute(pathname, body)).then((r) => send(r.status, r.body));
273
331
  return true;
274
332
  }
275
333
 
276
- send(404, { ok: false, error: 'not found' });
334
+ const r = refuse('no-such-settings-route', 'Settings cannot do that. Keys are typed into your keys file by hand.', 404);
335
+ send(r.status, r.body);
277
336
  return true;
278
337
  }
279
338
  return { handle };
package/src/planrun.js CHANGED
@@ -1685,7 +1685,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1685
1685
  // ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
1686
1686
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1687
1687
  // the same F6 pricing halt rather than being laundered into $0.
1688
- judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates }) : null,
1688
+ judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates, thinking: job.thinking === true }) : null,
1689
1689
  // the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
1690
1690
  // through untouched: this module resolves no judge, exactly as it constructs no
1691
1691
  // provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
@@ -2301,7 +2301,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2301
2301
  // chain, node_modules/bareguard/src/gate.js) — i.e. only the residual
2302
2302
  // case of an in-fence path that still carries one of these segments.
2303
2303
  tools: { denyArgPatterns: { write: [FORBIDDEN_WRITE_SEGMENT_PATTERN], edit: [FORBIDDEN_WRITE_SEGMENT_PATTERN] } },
2304
- budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
2304
+ budget: { maxCostUsd: Math.max(remainingUsd(), 1e-4) },
2305
2305
  limits: { maxTurns },
2306
2306
  audit: { path: auditPath },
2307
2307
  // The gate audit is the THIRD persistent, append-only channel (beside the