onbuzz 6.3.0 → 6.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onbuzz",
3
- "version": "6.3.0",
3
+ "version": "6.3.3",
4
4
  "description": "Loxia OnBuzz - Your AI Fleet",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -441,6 +441,11 @@ class WebServer {
441
441
  }
442
442
  }
443
443
 
444
+ // Per-process identity, echoed by /api/health so a caller (the Electron
445
+ // shell above all) can tell OUR server from another program that happens
446
+ // to answer on the same port. See startServer() → _verifyOwnPort().
447
+ this.instanceId = crypto.randomUUID();
448
+
444
449
  // Express app
445
450
  this.app = express();
446
451
  this.server = createServer(this.app);
@@ -1066,12 +1071,14 @@ class WebServer {
1066
1071
  res.json({
1067
1072
  status: 'healthy',
1068
1073
  version: packageJson.version || '1.0.0',
1074
+ instance: this.instanceId,
1069
1075
  timestamp: new Date().toISOString()
1070
1076
  });
1071
1077
  } catch {
1072
1078
  res.json({
1073
1079
  status: 'healthy',
1074
1080
  version: '1.0.0',
1081
+ instance: this.instanceId,
1075
1082
  timestamp: new Date().toISOString()
1076
1083
  });
1077
1084
  }
@@ -7834,6 +7841,47 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
7834
7841
  }
7835
7842
  }
7836
7843
 
7844
+ // A successful bind is not proof we OWN the port: on Windows a 0.0.0.0
7845
+ // bind succeeds while another program holds 127.0.0.1 on the same port,
7846
+ // and loopback traffic goes to that program. Listen, then ask
7847
+ // 127.0.0.1:port/api/health for our own instance id; a foreign answer
7848
+ // means close and move on (dynamic port) or fail loudly (forced port).
7849
+ const forced = !!(envPort && !isNaN(envPort));
7850
+ const MAX_PORT_HOPS = 20;
7851
+ for (let hop = 0; hop < MAX_PORT_HOPS; hop++) {
7852
+ const owned = await this._listenAndVerify();
7853
+ if (owned) return;
7854
+ const foreign = this.port;
7855
+ if (forced) {
7856
+ throw new Error(`Port ${foreign} is answered by another program (it is in use on 127.0.0.1). Stop that program or start with a different LOXIA_PORT.`);
7857
+ }
7858
+ this.logger.warn('Port is answered by another program — moving to the next free port', { port: foreign, host: this.host });
7859
+ this.port = await findFreePort(foreign + 1, 100, this.host);
7860
+ }
7861
+ throw new Error(`Could not find a port that is not answered by another program (tried ${MAX_PORT_HOPS} from ${this.config.port || 8080})`);
7862
+ }
7863
+
7864
+ /**
7865
+ * GET our own /api/health over loopback and compare the instance id.
7866
+ * @private
7867
+ */
7868
+ async _verifyOwnPort() {
7869
+ try {
7870
+ const res = await fetch(`http://127.0.0.1:${this.port}/api/health`, { signal: AbortSignal.timeout(3000) });
7871
+ const data = await res.json();
7872
+ return data?.instance === this.instanceId;
7873
+ } catch {
7874
+ return false;
7875
+ }
7876
+ }
7877
+
7878
+ /**
7879
+ * Listen on this.port; resolve true when the port is verified ours, false
7880
+ * when another program answers on it (the server is closed again in that
7881
+ * case). Rejects on a real bind error.
7882
+ * @private
7883
+ */
7884
+ _listenAndVerify() {
7837
7885
  return new Promise((resolve, reject) => {
7838
7886
  // Handle bind errors (EADDRINUSE, EACCES, etc.) — must be set BEFORE listen()
7839
7887
  const onError = (error) => {
@@ -7852,6 +7900,13 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
7852
7900
  this.server.listen({ port: this.port, host: this.host, exclusive: false }, async () => {
7853
7901
  // Remove the startup error handler; replace with runtime error handler
7854
7902
  this.server.removeListener('error', onError);
7903
+
7904
+ if (!(await this._verifyOwnPort())) {
7905
+ this.logger.error('Another program answers on the port we bound — not ours', { port: this.port, host: this.host });
7906
+ await new Promise((done) => this.server.close(() => done()));
7907
+ resolve(false);
7908
+ return;
7909
+ }
7855
7910
  this.server.on('error', (error) => {
7856
7911
  this.logger.error('HTTP server runtime error:', {
7857
7912
  error: error.message,
@@ -7922,7 +7977,7 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
7922
7977
  await this.testWebSocketServer();
7923
7978
  }, 1000);
7924
7979
 
7925
- resolve();
7980
+ resolve(true);
7926
7981
  });
7927
7982
  });
7928
7983
  }
@@ -8056,6 +8111,7 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
8056
8111
  isRunning: this.isRunning,
8057
8112
  port: this.port,
8058
8113
  host: this.host,
8114
+ instance: this.instanceId,
8059
8115
  connections: this.connections.size,
8060
8116
  sessions: this.sessions.size,
8061
8117
  url: `http://${this.host}:${this.port}`
@@ -252,11 +252,37 @@ describe('param-quirk ladder (backend path)', () => {
252
252
  expect.stringContaining('MODEL QUIRK'), expect.anything());
253
253
  });
254
254
 
255
- test('retry also failing on ANOTHER param error → exactly two attempts, then throw', async () => {
255
+ test('the SAME rejection after the strip → exactly two attempts, then throw (never an infinite ladder)', async () => {
256
256
  const svc = makeService();
257
- const calls = mockBackendRejecting(GPT56, () => true); // always rejects
257
+ const calls = mockBackendRejecting(GPT56, () => true); // always rejects on temperature
258
258
  await expect(svc.sendMessage('gpt-5.6-sol', [{ role: 'user', content: 'hi' }], { ...KEYED }))
259
259
  .rejects.toThrow();
260
- expect(calls).toHaveLength(2); // one learn-retry, never an infinite ladder
260
+ expect(calls).toHaveLength(2); // temperature stripped once; a second identical rejection learns nothing → throw
261
+ });
262
+
263
+ test('gpt-6-astra fixture: two DIFFERENT rejections in one request (forced reasoning_effort, then temperature) → third attempt succeeds', async () => {
264
+ const svc = makeService();
265
+ const ASTRA = "400 Function tools with reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.";
266
+ const calls = [];
267
+ global.fetch = jest.fn(async (url, init) => {
268
+ const body = JSON.parse(init.body);
269
+ calls.push(body);
270
+ const reject = (message, param) => ({
271
+ ok: false, status: 400,
272
+ json: async () => ({ error: { message, param, code: 'unsupported_value' } }),
273
+ text: async () => JSON.stringify({ error: { message, param, code: 'unsupported_value' } }),
274
+ headers: { get: () => 'application/json' },
275
+ });
276
+ if (body.options?.reasoning_effort !== 'none') return reject(ASTRA, null);
277
+ if (body.options?.temperature !== undefined) return reject(GPT56, 'temperature');
278
+ return { ok: true, status: 200, json: async () => ({ content: 'ok' }), text: async () => 'ok', headers: { get: () => 'application/json' } };
279
+ });
280
+ const r = await svc.sendMessage('gpt-6-astra', [{ role: 'user', content: 'hi' }], { ...KEYED, temperature: 0.7 });
281
+ expect(r.content).toBe('ok');
282
+ expect(calls).toHaveLength(3);
283
+ expect(calls[2].options.reasoning_effort).toBe('none');
284
+ expect(calls[2].options.temperature).toBeUndefined();
285
+ const { getModelQuirkStore } = await import('../modelQuirks.js');
286
+ expect([...getModelQuirkStore().get('gpt-6-astra')].sort()).toEqual(['reasoning_effort=none', 'temperature']);
261
287
  });
262
288
  });
@@ -11,11 +11,32 @@ import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import {
14
- classifyParamError, ModelQuirkStore, STRIPPABLE_PARAMS,
14
+ classifyParamError, classifyForcedValue, parseQuirk, ModelQuirkStore, STRIPPABLE_PARAMS, FORCEABLE_PARAMS,
15
15
  getModelQuirkStore, _resetModelQuirkStore,
16
16
  } from '../modelQuirks.js';
17
17
 
18
18
  const GPT56_MESSAGE = "400 Unsupported value: 'temperature' does not support 0.7 with this model. Only the default (1) value is supported.";
19
+ // Fixture #2 is the VERBATIM gpt-6-astra rejection (2026-09-06, Azure gateway /llm/chat, function tools on).
20
+ const ASTRA_MESSAGE = "HTTP 400: Bad Request - 400 Function tools with reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.";
21
+
22
+ describe('classifyForcedValue (fixture #2: gpt-6-astra)', () => {
23
+ test('the astra message names the value to SEND, not a key to strip', () => {
24
+ expect(classifyForcedValue(new Error(ASTRA_MESSAGE))).toEqual({ param: 'reasoning_effort', value: 'none' });
25
+ expect(classifyParamError(new Error(ASTRA_MESSAGE))).toBeNull(); // no strippable param in it
26
+ expect(classifyForcedValue(ASTRA_MESSAGE)).toEqual({ param: 'reasoning_effort', value: 'none' });
27
+ });
28
+ test('only forceable params, only rejection messages; strip-class errors stay null', () => {
29
+ expect(classifyForcedValue(new Error("not supported: set temperature to '1'"))).toBeNull(); // temperature is strippable, not forceable
30
+ expect(classifyForcedValue(new Error("please set reasoning_effort to 'low'"))).toBeNull(); // no rejection wording
31
+ expect(classifyForcedValue(new Error(GPT56_MESSAGE))).toBeNull();
32
+ expect(classifyForcedValue(null)).toBeNull();
33
+ expect(FORCEABLE_PARAMS).toEqual(['reasoning_effort']);
34
+ });
35
+ test('parseQuirk splits forced entries and leaves bare ones', () => {
36
+ expect(parseQuirk('reasoning_effort=none')).toEqual({ param: 'reasoning_effort', value: 'none' });
37
+ expect(parseQuirk('temperature')).toEqual({ param: 'temperature', value: undefined });
38
+ });
39
+ });
19
40
 
20
41
  describe('classifyParamError', () => {
21
42
  test('fixture #1: the gpt-5.6-sol production error (message form)', () => {
@@ -136,6 +157,39 @@ describe('ModelQuirkStore', () => {
136
157
  await store.flush(); // in-flight persist must settle before dir cleanup
137
158
  });
138
159
 
160
+ test('a forced-value quirk (key=value) is SET by sanitize, persists, and re-applies after reload', async () => {
161
+ const store = new ModelQuirkStore({});
162
+ await store.load();
163
+ expect(store.add('gpt-6-astra', 'reasoning_effort=none')).toBe(true);
164
+ const payload = { temperature: 0.7, tools: [{}] };
165
+ expect(store.sanitize('gpt-6-astra', payload)).toEqual(['reasoning_effort']);
166
+ expect(payload).toEqual({ temperature: 0.7, tools: [{}], reasoning_effort: 'none' });
167
+ expect(store.sanitize('gpt-6-astra', payload)).toEqual([]); // already in place → nothing touched
168
+ await store.flush();
169
+ const again = new ModelQuirkStore({});
170
+ await again.load();
171
+ const p2 = {};
172
+ again.sanitize('gpt-6-astra', p2);
173
+ expect(p2).toEqual({ reasoning_effort: 'none' });
174
+ await again.flush();
175
+ });
176
+
177
+ test('a forced quirk can be retired: forcedEntry finds it, remove forgets it and persists', async () => {
178
+ const store = new ModelQuirkStore({});
179
+ await store.load();
180
+ store.add('gpt-6-astra', 'reasoning_effort=none');
181
+ store.add('gpt-6-astra', 'temperature');
182
+ expect(store.forcedEntry('gpt-6-astra', 'reasoning_effort')).toBe('reasoning_effort=none');
183
+ expect(store.forcedEntry('gpt-6-astra', 'temperature')).toBeNull(); // bare strip entry is not a forced value
184
+ expect(store.remove('gpt-6-astra', 'reasoning_effort=none')).toBe(true);
185
+ expect(store.remove('gpt-6-astra', 'reasoning_effort=none')).toBe(false);
186
+ expect([...store.get('gpt-6-astra')]).toEqual(['temperature']);
187
+ await store.flush();
188
+ const again = new ModelQuirkStore({});
189
+ await again.load();
190
+ expect([...again.get('gpt-6-astra')]).toEqual(['temperature']);
191
+ });
192
+
139
193
  test('persist failure warns but the quirk stays active in memory', async () => {
140
194
  const store = new ModelQuirkStore({ logger: { warn: jest.fn() } });
141
195
  await store.load();
@@ -11,7 +11,29 @@ jest.unstable_mockModule('../portRegistry.js', () => ({
11
11
  }))
12
12
  }));
13
13
 
14
- const { ServiceRegistry, ServiceStatus, registry } = await import('../serviceRegistry.js');
14
+ const { ServiceRegistry, ServiceStatus, registry, isPortFree, isWildcardHost } = await import('../serviceRegistry.js');
15
+ const net = await import('node:net');
16
+
17
+ describe('isPortFree — a wildcard port is free only if loopback is free too (Windows collision)', () => {
18
+ test('a port held on 127.0.0.1 is NOT free for 0.0.0.0 (the shell connects over loopback); it is free again once released', async () => {
19
+ const holder = net.createServer();
20
+ await new Promise((r) => holder.listen(0, '127.0.0.1', r));
21
+ const port = holder.address().port;
22
+ try {
23
+ expect(await isPortFree(port, '127.0.0.1')).toBe(false);
24
+ expect(await isPortFree(port, '0.0.0.0')).toBe(false); // the bind may succeed on Windows — still not ours
25
+ } finally {
26
+ await new Promise((r) => holder.close(r));
27
+ }
28
+ expect(await isPortFree(port, '0.0.0.0')).toBe(true);
29
+ expect(await isPortFree(port, '127.0.0.1')).toBe(true);
30
+ });
31
+
32
+ test('isWildcardHost', () => {
33
+ for (const h of ['0.0.0.0', '::', '', undefined, null]) expect(isWildcardHost(h)).toBe(true);
34
+ for (const h of ['127.0.0.1', 'localhost', '::1', '192.168.1.5']) expect(isWildcardHost(h)).toBe(false);
35
+ });
36
+ });
15
37
 
16
38
  describe('ServiceRegistry', () => {
17
39
  beforeEach(() => {
@@ -16,7 +16,10 @@ import {
16
16
  HTTP_STATUS,
17
17
  COMPACTION_CONFIG
18
18
  } from '../utilities/constants.js';
19
- import { getModelQuirkStore, classifyParamError, STRIPPABLE_PARAMS } from './modelQuirks.js';
19
+ import { getModelQuirkStore, classifyParamError, classifyForcedValue, STRIPPABLE_PARAMS } from './modelQuirks.js';
20
+
21
+ /** Distinct parameter rejections a single request may learn from before giving up. */
22
+ const MAX_QUIRK_LEARNS_PER_REQUEST = 4;
20
23
  import { getOllamaService, OLLAMA_MODEL_PREFIX } from './ollamaService.js';
21
24
  import { getDaniService, DANI_MODEL_PREFIX } from './daniService.js';
22
25
  import { SHARED_MEMORY_SCOPE } from './memoryService.js';
@@ -613,7 +616,11 @@ class AIService {
613
616
  });
614
617
  break;
615
618
  } catch (err) {
616
- if (quirkAttempt === 0 && this._learnParamQuirk(model, err, payload.options)) continue;
619
+ // Each learned quirk changes the payload, so several distinct rejections
620
+ // may be resolved within ONE request (gpt-6-astra on the Responses API
621
+ // rejected `temperature`, then `reasoning_effort`: one learn per request
622
+ // cost two failed turns before the third succeeded).
623
+ if (quirkAttempt < MAX_QUIRK_LEARNS_PER_REQUEST && this._learnParamQuirk(model, err, payload.options)) continue;
617
624
  throw err;
618
625
  }
619
626
  }
@@ -776,7 +783,11 @@ class AIService {
776
783
  });
777
784
  break;
778
785
  } catch (err) {
779
- if (quirkAttempt === 0 && this._learnParamQuirk(model, err, payload.options)) continue;
786
+ // Each learned quirk changes the payload, so several distinct rejections
787
+ // may be resolved within ONE request (gpt-6-astra on the Responses API
788
+ // rejected `temperature`, then `reasoning_effort`: one learn per request
789
+ // cost two failed turns before the third succeeded).
790
+ if (quirkAttempt < MAX_QUIRK_LEARNS_PER_REQUEST && this._learnParamQuirk(model, err, payload.options)) continue;
780
791
  throw err;
781
792
  }
782
793
  }
@@ -2334,9 +2345,35 @@ class AIService {
2334
2345
  */
2335
2346
  _learnParamQuirk(model, error, payloadOptions) {
2336
2347
  try {
2348
+ if (!payloadOptions) return false;
2349
+ // Forced-value quirk: the provider names the value to send ("set
2350
+ // reasoning_effort to 'none'" — gpt-6-astra with function tools on
2351
+ // chat completions, 2026-09-06). Nothing to strip; set it and retry.
2352
+ const forced = classifyForcedValue(error);
2353
+ if (forced && payloadOptions[forced.param] !== forced.value) {
2354
+ payloadOptions[forced.param] = forced.value;
2355
+ const isNew = this._quirkStore().add(model, `${forced.param}=${forced.value}`);
2356
+ this.logger.warn(
2357
+ `MODEL QUIRK ${isNew ? 'LEARNED' : 'RE-APPLIED'}: ${model} needs '${forced.param}=${forced.value}' — set and retrying once. ` +
2358
+ `Future requests send it (config/model-quirks.json); declare parameters.fixed.${forced.param} on the catalog entry to avoid the first failure.`,
2359
+ { model, param: forced.param, value: forced.value }
2360
+ );
2361
+ return true;
2362
+ }
2337
2363
  const param = classifyParamError(error);
2338
- if (!param || !STRIPPABLE_PARAMS.includes(param)) return false;
2339
- if (!payloadOptions || !(param in payloadOptions)) return false;
2364
+ if (!param) return false;
2365
+ // A forced value WE added is now rejected (the catalog re-routed the
2366
+ // model, e.g. gpt-6-astra to the Responses API, where reasoning_effort
2367
+ // is refused): retire the quirk, drop the key, retry once.
2368
+ const forcedEntry = this._quirkStore().forcedEntry(model, param);
2369
+ if (forcedEntry && param in payloadOptions) {
2370
+ delete payloadOptions[param];
2371
+ this._quirkStore().remove(model, forcedEntry);
2372
+ this.logger.warn(`MODEL QUIRK RETIRED: ${model} now rejects '${param}' — forgot '${forcedEntry}', stripped and retrying once.`, { model, param });
2373
+ return true;
2374
+ }
2375
+ if (!STRIPPABLE_PARAMS.includes(param)) return false;
2376
+ if (!(param in payloadOptions)) return false;
2340
2377
  delete payloadOptions[param];
2341
2378
  const isNew = this._quirkStore().add(model, param);
2342
2379
  this.logger.warn(
@@ -70,6 +70,36 @@ function canonicalize(param) {
70
70
  return PARAM_ALIASES[p] ?? p;
71
71
  }
72
72
 
73
+ /** Payload keys a provider may ask us to SET to a specific value (forced quirks). */
74
+ export const FORCEABLE_PARAMS = Object.freeze(['reasoning_effort']);
75
+
76
+ /**
77
+ * A rejection that names the value to send instead of stripping a key —
78
+ * live-caught (2026-09-06, gpt-6-astra via the Azure gateway):
79
+ * 400 "Function tools with reasoning_effort are not supported for gpt-6-astra
80
+ * in /v1/chat/completions. To use function tools, use /v1/responses or
81
+ * set reasoning_effort to 'none'."
82
+ * Azure applies its own non-'none' reasoning default, so there is nothing to
83
+ * strip; the fix is to SEND the value the provider names. Returns
84
+ * { param, value } (canonical param) or null.
85
+ */
86
+ export function classifyForcedValue(error) {
87
+ if (!error) return null;
88
+ const msg = String(error.message ?? error);
89
+ if (!/not supported|unsupported/i.test(msg)) return null;
90
+ const m = /\bset\s+["']?([\w.]+)["']?\s+to\s+["']([\w.-]+)["']/i.exec(msg) || /\bset\s+["']?([\w.]+)["']?\s+to\s+([\w.-]+)\b/i.exec(msg);
91
+ if (!m) return null;
92
+ const param = canonicalize(m[1]);
93
+ return FORCEABLE_PARAMS.includes(param) ? { param, value: m[2] } : null;
94
+ }
95
+
96
+ /** Quirk entry encoding: a bare key = strip it; `key=value` = force that value. */
97
+ export function parseQuirk(entry) {
98
+ const s = String(entry);
99
+ const i = s.indexOf('=');
100
+ return i < 0 ? { param: s, value: undefined } : { param: s.slice(0, i), value: s.slice(i + 1) };
101
+ }
102
+
73
103
  /**
74
104
  * Per-model quirk memory. `{ models: { "<model>": ["temperature", …] } }`
75
105
  * persisted atomically to <dataDir>/config/model-quirks.json.
@@ -117,11 +147,43 @@ export class ModelQuirkStore {
117
147
  const set = this._quirks.get(model);
118
148
  if (set.has(param)) return false;
119
149
  set.add(param);
120
- this._persistPromise = this._persist().catch((err) =>
121
- this.logger?.warn?.(`[quirks] persist failed (quirk still active this session): ${err.message}`));
150
+ this._schedulePersist('quirk still active this session');
122
151
  return true;
123
152
  }
124
153
 
154
+ /**
155
+ * Persists are chained, never concurrent: two writes racing through the
156
+ * same temp file + rename could land the OLDER snapshot last (add, add,
157
+ * remove → reload showed nothing).
158
+ */
159
+ _schedulePersist(stillActiveNote) {
160
+ this._persistPromise = (this._persistPromise || Promise.resolve())
161
+ .then(() => this._persist())
162
+ .catch((err) => this.logger?.warn?.(`[quirks] persist failed (${stillActiveNote}): ${err.message}`));
163
+ }
164
+
165
+ /**
166
+ * Forget a learned quirk (e.g. a forced value the provider later rejects
167
+ * once the catalog routes the model elsewhere). Returns true when removed.
168
+ */
169
+ remove(model, entry) {
170
+ const set = this._quirks.get(model);
171
+ if (!set || !set.has(entry)) return false;
172
+ set.delete(entry);
173
+ if (!set.size) this._quirks.delete(model);
174
+ this._schedulePersist('removal still active this session');
175
+ return true;
176
+ }
177
+
178
+ /** The forced entry (`param=value`) this model carries for `param`, or null. */
179
+ forcedEntry(model, param) {
180
+ for (const entry of this.get(model)) {
181
+ const q = parseQuirk(entry);
182
+ if (q.param === param && q.value !== undefined) return entry;
183
+ }
184
+ return null;
185
+ }
186
+
125
187
  /** Await any in-flight persist (graceful shutdown / tests). */
126
188
  async flush() {
127
189
  await this._persistPromise;
@@ -138,19 +200,23 @@ export class ModelQuirkStore {
138
200
  }
139
201
 
140
202
  /**
141
- * Remove known-rejected keys from a payload options object IN PLACE
142
- * (payloads are request-local). Returns the list of stripped keys.
203
+ * Apply this model's quirks to a payload options object IN PLACE
204
+ * (payloads are request-local): bare entries are removed, `key=value`
205
+ * entries are set. Returns the list of touched keys.
143
206
  */
144
207
  sanitize(model, payloadOptions) {
145
- const stripped = [];
146
- if (!payloadOptions || typeof payloadOptions !== 'object') return stripped;
147
- for (const param of this.get(model)) {
148
- if (param in payloadOptions) {
208
+ const touched = [];
209
+ if (!payloadOptions || typeof payloadOptions !== 'object') return touched;
210
+ for (const entry of this.get(model)) {
211
+ const { param, value } = parseQuirk(entry);
212
+ if (value !== undefined) {
213
+ if (payloadOptions[param] !== value) { payloadOptions[param] = value; touched.push(param); }
214
+ } else if (param in payloadOptions) {
149
215
  delete payloadOptions[param];
150
- stripped.push(param);
216
+ touched.push(param);
151
217
  }
152
218
  }
153
- return stripped;
219
+ return touched;
154
220
  }
155
221
  }
156
222
 
@@ -318,7 +318,27 @@ export async function findFreePort(preferredPort, maxAttempts = 100, host = '127
318
318
  * @param {string} host - Host to check (default: '127.0.0.1')
319
319
  * @returns {Promise<boolean>}
320
320
  */
321
+ /** '0.0.0.0' / '::' / empty = bind every interface. */
322
+ export function isWildcardHost(host) {
323
+ return host === undefined || host === null || host === '' || host === '0.0.0.0' || host === '::';
324
+ }
325
+
321
326
  export async function isPortFree(port, host = '127.0.0.1') {
327
+ // A wildcard bind is NOT proof the port is free on Windows: binding 0.0.0.0:P
328
+ // succeeds while another program holds 127.0.0.1:P, and every connection to
329
+ // localhost:P then reaches THAT program (measured 2026-09-06: llama-server on
330
+ // 127.0.0.1:8080, our 0.0.0.0:8080 bind "worked", the Electron shell polled
331
+ // /api/health against llama-server for 30 attempts → "Backend did not become
332
+ // healthy"). The shell, the UI and the relay all connect via loopback, so a
333
+ // wildcard port is free only if loopback is free too.
334
+ if (isWildcardHost(host)) {
335
+ const loopbackFree = await bindProbe(port, '127.0.0.1');
336
+ if (!loopbackFree) return false;
337
+ }
338
+ return bindProbe(port, host);
339
+ }
340
+
341
+ async function bindProbe(port, host) {
322
342
  return new Promise((resolve) => {
323
343
  const server = net.createServer();
324
344