bareloop 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ // PANEL-BUILD.md P4a — Settings routes (`/api/settings/*`). A separate module from the
2
+ // authoring routes on purpose: the chat/authoring session can reach NO route here (it has
3
+ // no path to this file's handlers), and every route here is behind the same human-click
4
+ // guard (per-server-start token + Origin/Host, `checkHumanGuard`) — GET too, because the
5
+ // pages these serve name key variables and spend.
6
+ //
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).
9
+ //
10
+ // Every write goes to `~/.config/bareloop/config.json` through `src/config.js` (the one
11
+ // writer); the monthly limit is a person's own number, set by hand — nothing here (and
12
+ // nothing the agent can reach) raises it on their behalf. Key VALUES never appear in any
13
+ // response: names and found / not set only.
14
+ import { checkHumanGuard } from './authorroutes.js';
15
+ import { readConfig, updateConfig, ConfigError } from '../config.js';
16
+ import { spendSummary, monthlyLimitOf } from '../monthly.js';
17
+ import { loadKeysEnv, keysFilePath, filledKeyNames, ensureKeysFile } from '../keysfile.js';
18
+ import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
19
+ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
20
+
21
+ /** DeepSeek's own balance endpoint (same host as its models list). */
22
+ const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
23
+
24
+ /**
25
+ * @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
26
+ * fetchImpl?: typeof fetch, now?: () => number }} opts
27
+ */
28
+ export function createSettingsRoutes(opts) {
29
+ const fetchImpl = opts.fetchImpl ?? fetch;
30
+ /** the env keys are looked up in: the door's env (shell + keys file), re-read against the
31
+ * keys file on every call so "Reload keys" is just another request. NAMES leave this
32
+ * module; values never do.
33
+ * @returns {ReturnType<typeof loadKeysEnv>} */
34
+ const keys = () => loadKeysEnv({ env: opts.env, home: opts.home });
35
+ /** the Providers rows: keys with a value in the file, over the saved settings
36
+ * @param {Record<string, any>} config */
37
+ const currentRows = (config) => keyRows({ filled: filledKeyNames(opts.home), config });
38
+ /** @param {string} u @returns {string} */
39
+ const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
40
+ /**
41
+ * @param {import('node:http').IncomingMessage} req
42
+ * @param {import('node:http').ServerResponse} res
43
+ * @param {string} pathname
44
+ * @param {any} body already-parsed JSON body (or `null`)
45
+ * @returns {boolean} true if this module handled the request
46
+ */
47
+ function handle(req, res, pathname, body) {
48
+ if (!pathname.startsWith('/api/settings')) return false;
49
+ const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
50
+ const text = JSON.stringify(obj);
51
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
52
+ res.end(text);
53
+ };
54
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
55
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
56
+
57
+ if (pathname === '/api/settings/money') {
58
+ if (req.method === 'GET') {
59
+ const cfg = readConfig({ home: opts.home });
60
+ const s = spendSummary({ home: opts.home, now: opts.now });
61
+ /** @type {number|null} */
62
+ let limit = null;
63
+ let configProblem = cfg.problem;
64
+ // a limit the file names but that is not a number above 0 is a problem, never "no limit"
65
+ try { limit = monthlyLimitOf(cfg.config, opts.home); } catch (e) {
66
+ if (!(e instanceof ConfigError)) throw e;
67
+ configProblem = e.message;
68
+ }
69
+ send(200, {
70
+ ok: true,
71
+ configProblem,
72
+ totalUsd: s.total.usd, totalAtLeast: s.total.atLeast,
73
+ monthUsd: s.month.usd, monthAtLeast: s.month.atLeast,
74
+ totalTokens: s.total.tokens, monthTokens: s.month.tokens,
75
+ monthlyLimitUsd: limit,
76
+ byProvider: Object.values(s.byProvider).sort((a, b) => b.totalUsd - a.totalUsd),
77
+ });
78
+ return true;
79
+ }
80
+ if (req.method === 'POST') {
81
+ // an empty / unparseable body, or a JSON body without the key, is NOT "clear the limit" — only
82
+ // an explicit null / blank clears it (that is how the auto-saving field clears it)
83
+ if (body === null || typeof body !== 'object' || Array.isArray(body) || !Object.hasOwn(body, 'monthlyLimitUsd')) {
84
+ send(400, { ok: false, error: 'send {"monthlyLimitUsd": <number above 0>} to set the limit, or null to clear it' });
85
+ return true;
86
+ }
87
+ // a blank / null limit removes it (no limit); anything else must be a number > 0
88
+ const raw = body.monthlyLimitUsd;
89
+ const limit = raw === null || raw === '' ? null : Number(raw);
90
+ if (limit !== null && !(Number.isFinite(limit) && limit > 0)) {
91
+ send(400, { ok: false, error: 'the monthly limit must be a number above 0 (leave it blank for no limit)' });
92
+ return true;
93
+ }
94
+ try {
95
+ updateConfig({ monthlyLimitUsd: limit }, { home: opts.home });
96
+ } catch (e) {
97
+ if (!(e instanceof ConfigError)) throw e;
98
+ send(400, { ok: false, error: e.message });
99
+ return true;
100
+ }
101
+ send(200, { ok: true, monthlyLimitUsd: limit });
102
+ return true;
103
+ }
104
+ send(405, { ok: false, error: 'GET or POST only' });
105
+ return true;
106
+ }
107
+
108
+ // ── Providers (P4b): one row per key in the keys file that has a value. ──
109
+ if (pathname === '/api/settings/providers' && req.method === 'GET') {
110
+ // a missing keys file is created with the five empty preset lines (never edited if present)
111
+ ensureKeysFile(PRESET_KEY_NAMES, opts.home);
112
+ const cfg = readConfig({ home: opts.home });
113
+ const k = keys();
114
+ const rows = currentRows(cfg.config);
115
+ const s = spendSummary({ home: opts.home, now: opts.now, rows });
116
+ const note = cfg.config.anthropicBalanceNote;
117
+ // a preset key exported only in the shell gets no row (the rows are the keys file): NAMES the
118
+ // person can act on, read off the RAW shell env — never a value, never a length
119
+ const shellEnv = opts.env ?? process.env;
120
+ const filled = new Set(filledKeyNames(opts.home));
121
+ const shellOnly = PRESET_KEY_NAMES.filter((n) => typeof shellEnv[n] === 'string' && shellEnv[n] !== '' && !filled.has(n));
122
+ send(200, {
123
+ ok: true,
124
+ shellOnly,
125
+ keysFile: { path: keysFilePath(opts.home), exists: k.exists, names: k.names, warning: k.warning },
126
+ configProblem: cfg.problem,
127
+ shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
128
+ rows: rows.map((r) => {
129
+ const raw = k.env[r.envName];
130
+ const problem = raw ? apiKeyProblem(raw) : null;
131
+ const isDeepseek = r.provider === 'openai-api' && hostOf(endpointOf(r.provider, r.baseUrl)) === 'api.deepseek.com';
132
+ return {
133
+ envName: r.envName, name: r.name, shape: r.provider, baseUrl: r.baseUrl,
134
+ placeholder: defaultUrlOf(r.provider),
135
+ keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
136
+ canTest: r.provider !== 'gemini-api',
137
+ tokens: s.tokensByRow[r.envName] ?? 0,
138
+ balance: r.provider === 'anthropic-api'
139
+ ? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
140
+ : (isDeepseek ? { kind: 'fetch' } : { kind: 'none' }),
141
+ };
142
+ }),
143
+ });
144
+ return true;
145
+ }
146
+
147
+ if (pathname === '/api/settings/providers/row' && req.method === 'POST') {
148
+ const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
149
+ if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
150
+ // only the three settings a row has; each is optional in the body, the whole triple is stored
151
+ const patch = {
152
+ name: typeof body?.name === 'string' ? body.name.trim() : row.name,
153
+ shape: typeof body?.shape === 'string' ? body.shape : row.provider,
154
+ baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
155
+ };
156
+ try {
157
+ updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
158
+ } catch (e) {
159
+ if (!(e instanceof ConfigError)) throw e;
160
+ send(400, { ok: false, error: e.message });
161
+ return true;
162
+ }
163
+ send(200, { ok: true, envName: row.envName, ...patch });
164
+ return true;
165
+ }
166
+
167
+ if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
168
+ // the Anthropic balance is a NOTE the person types; no check ever reads it
169
+ const raw = body?.usd;
170
+ const usd = raw === null || raw === '' || raw === undefined ? null : Number(raw);
171
+ if (usd !== null && !(Number.isFinite(usd) && usd >= 0)) { send(400, { ok: false, error: 'the balance must be a number, 0 or more' }); return true; }
172
+ try {
173
+ updateConfig({ anthropicBalanceNote: usd }, { home: opts.home });
174
+ } catch (e) {
175
+ if (!(e instanceof ConfigError)) throw e;
176
+ send(400, { ok: false, error: e.message });
177
+ return true;
178
+ }
179
+ send(200, { ok: true, usd });
180
+ return true;
181
+ }
182
+
183
+ if (pathname === '/api/settings/providers/test' && req.method === 'POST') {
184
+ const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
185
+ if (!row) { send(400, { ok: false, error: 'unknown key' }); return true; }
186
+ const raw = keys().env[row.envName];
187
+ if (!raw) { send(200, { ok: true, reachable: false, status: 'no key', note: `${row.envName} not set`, ms: null }); return true; }
188
+ const problem = apiKeyProblem(raw);
189
+ if (problem) { send(200, { ok: true, reachable: false, status: 'bad key', note: `${row.envName} ${problem}`, ms: null }); return true; }
190
+ const started = Date.now();
191
+ // $0: one GET of the models list with THIS row's shape + URL (never a completion), key only in a header
192
+ checkProviderReachable({
193
+ providerName: row.provider, apiKey: usableKey(raw), baseUrl: row.baseUrl.trim() !== '' ? row.baseUrl : undefined, fetchImpl, timeoutMs: 4000,
194
+ }).then((r) => {
195
+ send(200, { ok: true, reachable: r.reachable, status: r.status, note: r.note, ms: Date.now() - started });
196
+ });
197
+ return true;
198
+ }
199
+
200
+ if (pathname === '/api/settings/providers/balance' && req.method === 'GET') {
201
+ // DeepSeek only: fetched server-side from the provider (key in a header, never returned)
202
+ const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
203
+ const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === q.get('env'));
204
+ if (!row || row.provider !== 'openai-api' || hostOf(endpointOf(row.provider, row.baseUrl)) !== 'api.deepseek.com') { send(404, { ok: false, error: 'not found' }); return true; }
205
+ const raw = keys().env[row.envName];
206
+ if (!raw || apiKeyProblem(raw)) { send(200, { ok: true, text: null, note: 'no usable key' }); return true; }
207
+ const controller = new AbortController();
208
+ const timer = setTimeout(() => controller.abort(), 4000);
209
+ fetchImpl(DEEPSEEK_BALANCE_URL, { method: 'GET', headers: { Authorization: `Bearer ${raw}` }, signal: controller.signal })
210
+ .then(async (res) => {
211
+ if (!res.ok) { send(200, { ok: true, text: null, note: `HTTP ${res.status}` }); return; }
212
+ /** @type {any} */
213
+ let j = null;
214
+ try { j = await res.json(); } catch { j = null; }
215
+ const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
216
+ const parts = infos
217
+ .filter((/** @type {any} */ b) => b && typeof b.currency === 'string' && b.total_balance !== undefined)
218
+ .map((/** @type {any} */ b) => `${String(b.total_balance)} ${b.currency}`);
219
+ send(200, { ok: true, text: parts.length ? parts.join(' + ') : null, note: parts.length ? null : 'balance not reported' });
220
+ })
221
+ .catch((/** @type {any} */ e) => { send(200, { ok: true, text: null, note: `request failed: ${e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'error')}` }); })
222
+ .finally(() => clearTimeout(timer));
223
+ return true;
224
+ }
225
+
226
+ send(404, { ok: false, error: 'not found' });
227
+ return true;
228
+ }
229
+ return { handle };
230
+ }
@@ -0,0 +1,217 @@
1
+ // PANEL-BUILD.md P4b — the Providers rows, driven by the keys file. NOTHING here is a
2
+ // hardcoded provider list: one row exists per key in `~/.config/bareloop/.env` that has a
3
+ // value; each row's Name / API shape / Base URL come from config.json `keys.<ENV NAME>`
4
+ // (src/config.js), else the per-name defaults below. This is NOT a new provider table:
5
+ // a row's API shape names an entry that already exists in `src/providers.js`
6
+ // (`PROVIDER_TABLE`), and a row's Base URL is that provider's `baseUrl` option.
7
+ //
8
+ // This module is also THE one owner of "model -> key + shape + URL": Chat's Model menu,
9
+ // the author doors and the run doors all resolve through `findRow` / `keyNameFor` /
10
+ // `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
11
+ // never pass through here — only names.
12
+ import { resolveProvider } from './providers.js';
13
+ import { readConfig } from './config.js';
14
+ import { filledKeyNames } from './keysfile.js';
15
+
16
+ /** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
17
+ export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
18
+
19
+ /**
20
+ * The API shapes the dropdown offers. `id` is the `src/providers.js` table name;
21
+ * `defaultUrl` is the host a blank Base URL means ('' = the vendor's own, unshown).
22
+ * @type {readonly {id: string, label: string, defaultUrl: string}[]}
23
+ */
24
+ export const SHAPES = Object.freeze([
25
+ Object.freeze({ id: 'anthropic-api', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com/v1' }),
26
+ Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible', defaultUrl: 'https://api.openai.com/v1' }),
27
+ Object.freeze({ id: 'gemini-api', label: 'Gemini', defaultUrl: '' }),
28
+ ]);
29
+
30
+ /** The five empty lines a fresh keys file is created with, in this order. */
31
+ export const PRESET_KEY_NAMES = Object.freeze(['ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'LOCAL_API_KEY']);
32
+
33
+ /** A local server's default endpoint: Ollama's OpenAI-compatible host (LM Studio / llama.cpp: change the URL). */
34
+ export const LOCAL_BASE_URL = 'http://127.0.0.1:11434/v1';
35
+
36
+ /**
37
+ * A key line whose value is the word `null` is SET and means "no real key": the row exists and
38
+ * the provider is handed this harmless stand-in (local servers ignore auth). It is not a
39
+ * secret and is never shown or logged as a key.
40
+ */
41
+ export const NO_KEY_PLACEHOLDER = 'no-key-needed';
42
+
43
+ /**
44
+ * The key value to hand a provider: the `null` marker becomes the placeholder, anything else
45
+ * passes through unchanged.
46
+ * @template {string|undefined} T
47
+ * @param {T} raw
48
+ * @returns {T|string}
49
+ */
50
+ export function usableKey(raw) {
51
+ return raw === 'null' ? NO_KEY_PLACEHOLDER : raw;
52
+ }
53
+
54
+ /**
55
+ * What a key with no saved entry shows. Any name not listed is OpenAI-compatible with a
56
+ * blank URL and a blank Name.
57
+ * @param {string} envName
58
+ * @returns {{ name: string, shape: string, baseUrl: string }}
59
+ */
60
+ export function defaultsFor(envName) {
61
+ switch (envName) {
62
+ case 'ANTHROPIC_API_KEY': return { name: 'claude-sonnet-5', shape: 'anthropic-api', baseUrl: '' };
63
+ case 'DEEPSEEK_API_KEY': return { name: 'deepseek-flash', shape: 'openai-api', baseUrl: DEEPSEEK_BASE_URL };
64
+ case 'GEMINI_API_KEY': return { name: '', shape: 'gemini-api', baseUrl: '' };
65
+ case 'LOCAL_API_KEY': return { name: '', shape: 'openai-api', baseUrl: LOCAL_BASE_URL };
66
+ default: return { name: '', shape: 'openai-api', baseUrl: '' };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @typedef {object} KeyRow
72
+ * @property {string} envName the key variable NAME (read-only label; never a value)
73
+ * @property {string} name the model id Chat uses ('' = not offered in Chat)
74
+ * @property {string} provider the `src/providers.js` table entry (the API shape's id)
75
+ * @property {string} baseUrl the saved Base URL ('' = the shape's default host)
76
+ */
77
+
78
+ /**
79
+ * The rows: one per name in `filled`, saved entry over defaults.
80
+ * @param {{ filled: readonly string[], config: Record<string, any> }} src
81
+ * @returns {KeyRow[]}
82
+ */
83
+ export function keyRows({ filled, config }) {
84
+ const saved = config?.keys && typeof config.keys === 'object' && !Array.isArray(config.keys) ? config.keys : {};
85
+ return filled.map((envName) => {
86
+ const d = defaultsFor(envName);
87
+ const s = saved[envName] && typeof saved[envName] === 'object' ? saved[envName] : {};
88
+ return {
89
+ envName,
90
+ name: typeof s.name === 'string' ? s.name : d.name,
91
+ provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
92
+ baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
93
+ };
94
+ });
95
+ }
96
+
97
+ /**
98
+ * The rows for a keys home. `undefined` home (a door that skipped the keys file) = no
99
+ * rows, so nothing resolves through the file and the provider's built-in variable stands.
100
+ * @param {string|undefined} home
101
+ * @returns {KeyRow[]}
102
+ */
103
+ export function rowsForHome(home) {
104
+ if (home === undefined) return [];
105
+ return keyRows({ filled: filledKeyNames(home), config: readConfig({ home }).config });
106
+ }
107
+
108
+ /**
109
+ * @param {string} provider
110
+ * @returns {string} the shape's default host ('' = the vendor's own)
111
+ */
112
+ export function defaultUrlOf(provider) {
113
+ return SHAPES.find((s) => s.id === provider)?.defaultUrl ?? '';
114
+ }
115
+
116
+ /**
117
+ * An endpoint in comparable form: blank = the shape's default host; scheme + host lower-cased,
118
+ * trailing slashes dropped.
119
+ * @param {string} provider
120
+ * @param {string|null|undefined} baseUrl
121
+ * @returns {string}
122
+ */
123
+ export function endpointOf(provider, baseUrl) {
124
+ const raw = (typeof baseUrl === 'string' && baseUrl.trim() !== '' ? baseUrl : defaultUrlOf(provider)).trim();
125
+ if (raw === '') return '';
126
+ try {
127
+ const u = new URL(raw);
128
+ return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}`;
129
+ } catch { return raw.replace(/\/+$/, ''); }
130
+ }
131
+
132
+ /**
133
+ * The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
134
+ * @param {KeyRow} row
135
+ * @returns {string}
136
+ */
137
+ export function shownUrl(row) {
138
+ return row.baseUrl.trim() !== '' ? row.baseUrl : defaultUrlOf(row.provider);
139
+ }
140
+
141
+ /**
142
+ * Which row a `(provider, baseUrl[, model])` identity belongs to: the rows on that shape and
143
+ * endpoint; when several share it, the one whose Name is the model, else the first.
144
+ * @param {readonly KeyRow[]} rows
145
+ * @param {{ provider?: string|null, baseUrl?: string|null, model?: string|null }} id
146
+ * @returns {KeyRow|null}
147
+ */
148
+ export function findRow(rows, { provider, baseUrl, model }) {
149
+ if (typeof provider !== 'string') return null;
150
+ const want = endpointOf(provider, baseUrl);
151
+ const same = rows.filter((r) => r.provider === provider && endpointOf(r.provider, r.baseUrl) === want);
152
+ if (same.length === 0) return null;
153
+ return (model ? same.find((r) => r.name === model) : undefined) ?? same[0];
154
+ }
155
+
156
+ /**
157
+ * What Chat's Model menu offers and how a pick resolves: the row whose Name is the model
158
+ * (a blank Name is never offered). `baseUrl` is absent when blank, exactly as a spec omits it.
159
+ * @param {readonly KeyRow[]} rows
160
+ * @param {string} model
161
+ * @returns {{ provider: string, baseUrl?: string, envName: string, name: string }|null}
162
+ */
163
+ export function modelChoiceFor(rows, model) {
164
+ const r = model === '' ? undefined : rows.find((x) => x.name === model);
165
+ if (!r) return null;
166
+ return { provider: r.provider, ...(r.baseUrl.trim() !== '' ? { baseUrl: r.baseUrl.trim() } : {}), envName: r.envName, name: r.name };
167
+ }
168
+
169
+ /**
170
+ * The models Chat offers, in keys-file order.
171
+ * @param {readonly KeyRow[]} rows
172
+ * @returns {{ id: string, envName: string, shape: string }[]}
173
+ */
174
+ export function chatModels(rows) {
175
+ return rows.filter((r) => r.name !== '').map((r) => ({
176
+ id: r.name, envName: r.envName, shape: SHAPES.find((s) => s.id === r.provider)?.label ?? r.provider,
177
+ }));
178
+ }
179
+
180
+ /**
181
+ * The key variable NAME a job on `(provider, baseUrl[, model])` reads: the matching row's
182
+ * variable, else the provider's built-in one (byte-identical to before for anything no row
183
+ * claims).
184
+ * @param {string|null|undefined} provider
185
+ * @param {string|null|undefined} baseUrl
186
+ * @param {readonly KeyRow[]} rows
187
+ * @param {string|null|undefined} [model]
188
+ * @returns {{ name: string, builtIn: string, chosen: boolean }}
189
+ */
190
+ export function keyNameFor(provider, baseUrl, rows, model) {
191
+ const builtIn = resolveProvider(/** @type {string} */ (provider)).envKey;
192
+ const row = findRow(rows, { provider, baseUrl, model });
193
+ return row && row.envName !== builtIn
194
+ ? { name: row.envName, builtIn, chosen: true }
195
+ : { name: builtIn, builtIn, chosen: false };
196
+ }
197
+
198
+ /**
199
+ * The env a door hands to provider construction, with the matching row's key variable
200
+ * standing in for the provider's built-in one: `env[builtIn] = env[row.envName]` (the `null`
201
+ * no-key marker becomes {@link NO_KEY_PLACEHOLDER}). A copy — never mutates the input.
202
+ * No matching row and no marker = the env unchanged.
203
+ * @param {Record<string,string|undefined>} env
204
+ * @param {string|null|undefined} provider
205
+ * @param {string|null|undefined} baseUrl
206
+ * @param {readonly KeyRow[]} rows
207
+ * @param {string|null|undefined} [model]
208
+ * @returns {Record<string,string|undefined>}
209
+ */
210
+ export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
211
+ if (typeof provider !== 'string') return env;
212
+ let k;
213
+ try { k = keyNameFor(provider, baseUrl, rows, model); } catch { return env; } // unknown provider: resolveProvider's own named throw fires at its own door
214
+ const value = env[k.name];
215
+ if (!k.chosen && value !== 'null') return env;
216
+ return { ...env, [k.builtIn]: usableKey(value) };
217
+ }
package/src/providers.js CHANGED
@@ -396,7 +396,9 @@ export async function checkProviderReachable({
396
396
  return { reachable: true, modelListed, status: 'ok', note: null };
397
397
  } catch (/** @type {any} */ e) {
398
398
  const cls = e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'network-error');
399
- return { reachable: false, modelListed: null, status: String(cls), note: `request failed: ${cls}` };
399
+ // connection refused surfaces as fetch's bare TypeError ("fetch failed") — say it plainly
400
+ const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
401
+ return { reachable: false, modelListed: null, status: String(cls), note: refused ? `nothing answering at ${req.url}` : `request failed: ${cls}` };
400
402
  } finally {
401
403
  clearTimeout(timer);
402
404
  }
package/src/runlist.js CHANGED
@@ -128,6 +128,19 @@ function runidForSpine(spinePath) {
128
128
  return resolveSiblings(spinePath).runId;
129
129
  }
130
130
 
131
+ // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never shares
132
+ // the failed glyph — [✗] stays reserved for a run whose close/arbiter
133
+ // actually rendered a "no" (a real result). A spine that just stops, with
134
+ // no ending ever recorded, is a DIFFERENT fact (killed, crashed, or the
135
+ // machine slept) and gets its own [?] glyph. The distinguishing signal is
136
+ // the spine FILE's own mtime, never wall-clock "now minus job-start" (a
137
+ // resumed/paused run can legitimately sit quiet for a long time without
138
+ // having died): still fresh (written to within this window) reads as the
139
+ // existing `running` [▶] state; older than this reads as died. Tighten-only,
140
+ // named so a future change is a deliberate, visible edit. Lives here (not in
141
+ // the panel) because the monthly limit reads the same rule (src/monthly.js).
142
+ export const DIED_MTIME_MS = 10 * 60 * 1000;
143
+
131
144
  // A backfill scan of a real patients directory can be arbitrarily deep
132
145
  // (person-path runs archive several levels down: `<dir>/<proj>/out/
133
146
  // source-<x>/<proj>-bareloop/u-<id>.jsonl`, 4 levels under the dir a person
package/src/userrun.js CHANGED
@@ -73,6 +73,10 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
73
73
  import { coldReset, moveStaleGateAudit } from './u-patient.js';
74
74
  // PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
75
75
  import { appendRun } from './runlist.js';
76
+ import { keysForDoor } from './keysfile.js';
77
+ import { checkMonthlyRoom, monthlyRefusalText } from './monthly.js';
78
+ import { ConfigError } from './config.js';
79
+ import { applyConfiguredKey, keyNameFor, rowsForHome } from './providerrows.js';
76
80
  // the banner's wall arithmetic, extracted so it is reachable by a test (F83): the
77
81
  // end-of-run readout sits past the approval gate, so nothing could ever drive it here
78
82
  import { wallLine, doomedResume, deathAtOf, evidencePackage, doorLines, resumeAtLines, reviewDoorPackage, runDoorLines, tokensLine, doorTimingRedLines } from './u-readout.js';
@@ -219,6 +223,8 @@ class ExitSignal extends Error {
219
223
  /**
220
224
  * @typedef {object} Deps
221
225
  * @property {Record<string,string|undefined>} [env]
226
+ * @property {string} [keysHome] P4a — a test seam naming the home whose `.env` keys file is loaded
227
+ * even when `env` is injected; production never sets it.
222
228
  * @property {(s: string) => void} [out]
223
229
  * @property {(s: string) => void} [err]
224
230
  * @property {any} [provider] the TEST SEAM: supplying this skips the real-key
@@ -306,9 +312,19 @@ class ExitSignal extends Error {
306
312
  */
307
313
  async function execute(ctx) {
308
314
  const deps = ctx.deps ?? {};
309
- const env = deps.env ?? process.env;
310
315
  const out = deps.out ?? ((/** @type {string} */ s) => { console.log(s); });
311
316
  const err = deps.err ?? ((/** @type {string} */ s) => { console.error(s); });
317
+ // P4a item 1 — the keys file (`~/.config/bareloop/.env`) fills what the shell leaves
318
+ // unset; the shell wins. An injected `deps.env` skips the file unless `deps.keysHome`
319
+ // names a home (a test never reads the real one). Values go only to provider
320
+ // construction below.
321
+ const keys = keysForDoor(deps);
322
+ const env = keys.env;
323
+ if (keys.warning && deps.env === undefined) err(`WARNING: ${keys.warning}`);
324
+ // config.json (monthly limit, chosen key variables) lives beside the run list. An injected
325
+ // `env` with no injected home means a test — the real ~/.config/bareloop is never read then.
326
+ const cfgHome = keys.home ?? deps.runlistHome;
327
+ const cfgOn = cfgHome !== undefined;
312
328
  /** every operator/config stop this engine makes, in one exit code — thrown,
313
329
  * never `process.exit()`'d, so this stays a library function (constraint:
314
330
  * "never process.exit() inside the library — return an exit code").
@@ -1342,22 +1358,32 @@ async function execute(ctx) {
1342
1358
  // the same shape src/cli.js's `doRun` already uses) — a caller that hands in
1343
1359
  // its own provider IS the run, so nothing here needs a real secret.
1344
1360
  if (!deps.provider) {
1361
+ // P4b — the Settings row (keys file line) whose API shape + Base URL match this spec
1362
+ // supplies the key VARIABLE, standing in for the provider's built-in one; no matching
1363
+ // row = the built-in. Names only — values come from the
1364
+ // env / keys file. The judge follows the worker's choice when it is the same provider
1365
+ // (the same rule that gives it the worker's baseUrl below).
1366
+ const keyCfg = cfgOn ? rowsForHome(cfgHome) : [];
1367
+ const workerEnv = applyConfiguredKey(env, spec.provider, spec.baseUrl, keyCfg, spec.model);
1368
+ const judgeEnv = judge.provider === spec.provider ? workerEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model);
1369
+ /** the variable NAME this worker's key is read from (the person's pick, else built-in) — for messages only */
1370
+ const workerKeyName = keyNameFor(spec.provider, spec.baseUrl, keyCfg, spec.model).name;
1345
1371
  // A green anthropic-api job's behaviour is byte-identical to before: its
1346
1372
  // worker key IS `ANTHROPIC_API_KEY`, demanded as ever.
1347
- workerApiKey = env[providerEntry.envKey];
1348
- if (!workerApiKey) { err(`${providerEntry.envKey} not set (secrets load from the environment — never the tree)`); throw new ExitSignal(2); }
1373
+ workerApiKey = workerEnv[providerEntry.envKey];
1374
+ if (!workerApiKey) { err(`${workerKeyName} not set (secrets load from the environment — never the tree)`); throw new ExitSignal(2); }
1349
1375
  // F181 — a key that reads as "set" above can still carry a line break/
1350
1376
  // control char/stray whitespace (a two-line secret-store entry, e.g.) and
1351
1377
  // crash Node's own header-encode inside the paid span. Refuse at the SAME
1352
1378
  // door, before any provider is constructed; never echo the value.
1353
1379
  const workerKeyProblem = apiKeyProblem(workerApiKey);
1354
- if (workerKeyProblem) { err(`${providerEntry.envKey} ${workerKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`); throw new ExitSignal(2); }
1380
+ if (workerKeyProblem) { err(`${workerKeyName} ${workerKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`); throw new ExitSignal(2); }
1355
1381
  // The judge's KEY follows the RESOLVED judge provider's own env var, with
1356
1382
  // `JUDGE_API_KEY` as a role-named override in front of it (PRD item 32.3). The
1357
1383
  // override matters most in the case this item exists for: a job whose worker and
1358
1384
  // judge are the SAME provider but different accounts, and the anthropic case
1359
1385
  // where one variable used to do two jobs.
1360
- judgeApiKey = env.JUDGE_API_KEY ?? env[judgeEntry.envKey];
1386
+ judgeApiKey = env.JUDGE_API_KEY ?? judgeEnv[judgeEntry.envKey];
1361
1387
  if (JUDGES && !judgeApiKey) {
1362
1388
  err(`This job's close JUDGES (${judgedStages(spec.closeDecl).length} judged stage(s)) and its judge is ${judge.model} on ${judge.provider}${spec.judge ? ' (signed override)' : " (this job's own worker model — no `judge` override signed)"}.`);
1363
1389
  err(`Neither JUDGE_API_KEY nor ${judgeEntry.envKey} is set — secrets load from the environment, never the tree.`);
@@ -1377,6 +1403,34 @@ async function execute(ctx) {
1377
1403
  }
1378
1404
  }
1379
1405
 
1406
+ // THE MONTHLY $ LIMIT (PANEL-BUILD.md P4a, hamr 2026-09-29: "drop time keep money") — the
1407
+ // ONE run-start seam. Every run this door starts (the CLI's `bareloop run-u` and the panel,
1408
+ // which spawns it) passes here after the signature and the key gates and BEFORE the run
1409
+ // list row, the patient reset or any token: a cap larger than what is left this month does
1410
+ // not start, and nothing is spent. This leg's exposure is the whole signed cap on a cold
1411
+ // start (it covers drafting + run) and only the REMAINDER on a resume/door-rerun, whose
1412
+ // earlier spend is already in the month's total (a resume's killed leg counts its real spend only,
1413
+ // never its unspent cap — `resumingSpine` — since the remainder asked for here covers that). No limit set = no check. An unreadable
1414
+ // config.json refuses too — a broken instrument never silently reads "no limit".
1415
+ {
1416
+ const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
1417
+ const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
1418
+ /** @type {string|null} */
1419
+ let refusal = null;
1420
+ try {
1421
+ if (cfgOn) refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: legCapUsd, home: cfgHome, resumingSpine: deadSpineFile }));
1422
+ } catch (e) {
1423
+ if (!(e instanceof ConfigError)) throw e;
1424
+ err(`${e.message} — refusing to start rather than guess the monthly limit. Nothing spent.`);
1425
+ throw new ExitSignal(2);
1426
+ }
1427
+ if (refusal !== null) {
1428
+ err(refusal);
1429
+ err('Nothing spent. Lower the cap, or raise the monthly limit in Settings.');
1430
+ throw new ExitSignal(2);
1431
+ }
1432
+ }
1433
+
1380
1434
  // `wd`/`spineDir` are derived once, above the resume reader that needs them; only the
1381
1435
  // directory's CREATION belongs here, after the preview/approval gates have exited.
1382
1436
  mkdirSync(spineDir, { recursive: true });
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @typedef {object} Deps
3
3
  * @property {Record<string,string|undefined>} [env]
4
+ * @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
4
5
  * @property {NodeJS.ReadStream} [stdin]
5
6
  * @property {NodeJS.WritableStream} [stdout]
6
7
  * @property {NodeJS.WritableStream} [stderr]
@@ -23,6 +24,10 @@
23
24
  export function main(argv: string[], deps?: Deps): Promise<number>;
24
25
  export type Deps = {
25
26
  env?: Record<string, string | undefined> | undefined;
27
+ /**
28
+ * P4a — test seam: load this home's `.env` keys file even when `env` is injected.
29
+ */
30
+ keysHome?: string | undefined;
26
31
  stdin?: NodeJS.ReadStream | undefined;
27
32
  stdout?: NodeJS.WritableStream | undefined;
28
33
  stderr?: NodeJS.WritableStream | undefined;
package/types/cli.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
4
4
  * queued stdout on its own (F-something: `process.exit()` can discard it).
5
5
  * @param {string[]} argv
6
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string }} deps
6
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string }} deps
7
7
  * @returns {Promise<number>}
8
8
  */
9
9
  export function main(argv: string[], deps?: {
@@ -18,4 +18,5 @@ export function main(argv: string[], deps?: {
18
18
  now?: () => number;
19
19
  stdin?: any;
20
20
  runlistHome?: string;
21
+ keysHome?: string;
21
22
  }): Promise<number>;