@yeaft/webchat-agent 1.0.190 → 1.0.192

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": "@yeaft/webchat-agent",
3
- "version": "1.0.190",
3
+ "version": "1.0.192",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
- import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
14
+ import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
15
15
  import { normaliseYeaftSection } from './config.js';
16
16
  import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
17
17
 
@@ -54,6 +54,58 @@ export function getLlmConfig(dir) {
54
54
  }
55
55
  }
56
56
 
57
+ function providerModelIds(provider) {
58
+ return new Set(normalizeProviderModels(provider).map(model => model.id));
59
+ }
60
+
61
+ function bareModelOwners(providers, modelId) {
62
+ const owners = new Set();
63
+ for (const provider of providers) {
64
+ if (provider?.name && providerModelIds(provider).has(modelId)) owners.add(provider.name);
65
+ }
66
+ return owners;
67
+ }
68
+
69
+ function modelSelectionIsValid(selection, providers, authoritativeManagedNames) {
70
+ if (!selection) return false;
71
+ const parsed = parseModelRef(selection);
72
+ if (!parsed.modelId) return false;
73
+ if (!parsed.providerName) return bareModelOwners(providers, parsed.modelId).size === 1;
74
+ if (!authoritativeManagedNames.has(parsed.providerName)) return true;
75
+ const provider = providers.find(item => item?.name === parsed.providerName);
76
+ return !!provider && providerModelIds(provider).has(parsed.modelId);
77
+ }
78
+
79
+ function normalizeManagedModelDefaults(config) {
80
+ const providers = Array.isArray(config.providers) ? config.providers : [];
81
+ const authoritativeManagedProviders = providers.filter(provider =>
82
+ isGitHubCopilotProvider(provider)
83
+ && Array.isArray(provider.models)
84
+ && provider.models.length > 0);
85
+ if (authoritativeManagedProviders.length === 0) return;
86
+
87
+ const authoritativeManagedNames = new Set(authoritativeManagedProviders.map(provider => provider.name));
88
+ const fallbackProvider = authoritativeManagedProviders.find(provider => providerModelIds(provider).size > 0);
89
+ const fallbackModelId = fallbackProvider
90
+ ? providerModelIds(fallbackProvider).values().next().value
91
+ : null;
92
+ const managedFallback = fallbackProvider && fallbackModelId
93
+ ? `${fallbackProvider.name}/${fallbackModelId}`
94
+ : null;
95
+
96
+ if (config.primaryModel
97
+ && !modelSelectionIsValid(config.primaryModel, providers, authoritativeManagedNames)) {
98
+ config.primaryModel = managedFallback;
99
+ }
100
+ const validPrimary = modelSelectionIsValid(config.primaryModel, providers, authoritativeManagedNames)
101
+ ? config.primaryModel
102
+ : managedFallback;
103
+ if (config.fastModel
104
+ && !modelSelectionIsValid(config.fastModel, providers, authoritativeManagedNames)) {
105
+ config.fastModel = validPrimary;
106
+ }
107
+ }
108
+
57
109
  /**
58
110
  * Update the LLM-relevant portion of config.json.
59
111
  * Merges into existing config — preserves fields like debug, maxContextTokens, etc.
@@ -109,13 +161,16 @@ export function updateLlmConfig(update, dir) {
109
161
  });
110
162
  }
111
163
 
112
- // Update model selections
164
+ // Update model selections. A managed provider catalog is authoritative:
165
+ // when it drops the old Agent default, keep no hidden reference to that
166
+ // model in primaryModel or fastModel.
113
167
  if (update.primaryModel !== undefined) {
114
168
  existing.primaryModel = update.primaryModel || null;
115
169
  }
116
170
  if (update.fastModel !== undefined) {
117
171
  existing.fastModel = update.fastModel || null;
118
172
  }
173
+ if (update.providers !== undefined) normalizeManagedModelDefaults(existing);
119
174
  if (update.language !== undefined) {
120
175
  existing.language = update.language;
121
176
  }
package/yeaft/engine.js CHANGED
@@ -683,14 +683,7 @@ export class Engine {
683
683
  conversationId: this.#traceId,
684
684
  });
685
685
 
686
- // Build fast config: uses fastModelId for internal tasks (recall, consolidation, dream)
687
- // Falls back to primary model if no fastModel configured
688
- const fastModelId = config.fastModelId || config.model;
689
- if (fastModelId !== config.model) {
690
- this.#fastConfig = { ...config, model: fastModelId };
691
- } else {
692
- this.#fastConfig = config;
693
- }
686
+ this.refreshConfig(config);
694
687
  }
695
688
 
696
689
  /**
@@ -759,6 +752,21 @@ export class Engine {
759
752
  this.#config.language = lang;
760
753
  }
761
754
 
755
+ /**
756
+ * Replace the effective runtime config for subsequent queries without
757
+ * interrupting a turn that already captured its model.
758
+ *
759
+ * @param {object} config
760
+ */
761
+ refreshConfig(config) {
762
+ if (!config || typeof config !== 'object') return;
763
+ this.#config = config;
764
+ const fastModelId = config.fastModelId || config.model;
765
+ this.#fastConfig = fastModelId !== config.model
766
+ ? { ...config, model: fastModelId }
767
+ : config;
768
+ }
769
+
762
770
  /**
763
771
  * Hot-swap runtime managers for a project-bound Session. The ToolRegistry is
764
772
  * shared at the bridge/session layer; these references only decide which
@@ -27,6 +27,7 @@ import {
27
27
  GITHUB_COPILOT_BASE_URL,
28
28
  GITHUB_COPILOT_CREDENTIAL_PROVIDER,
29
29
  GITHUB_COPILOT_PROVIDER_NAME,
30
+ isGitHubCopilotProvider,
30
31
  normalizeKnownProviderForRuntime,
31
32
  } from './known-providers.js';
32
33
  import { pairSanitize } from '../pair-sanitize.js';
@@ -238,9 +239,12 @@ export class AdapterRouter extends LLMAdapter {
238
239
  /** @type {Map<string, LLMAdapter>} providerName::protocol → cached adapter */
239
240
  #adapterCache;
240
241
 
241
- /** @type {object[]} raw providers array */
242
+ /** @type {object[]} normalized providers array */
242
243
  #providers;
243
244
 
245
+ /** @type {Set<string>} managed providers backed by an explicit model catalog */
246
+ #authoritativeManagedProviders;
247
+
244
248
  /** @type {number} per-SSE-chunk silence budget; <= 0 disables the guard */
245
249
  #streamIdleTimeoutMs;
246
250
 
@@ -267,8 +271,13 @@ export class AdapterRouter extends LLMAdapter {
267
271
  * @param {object[]} providers
268
272
  */
269
273
  refreshProviders(providers) {
270
- this.#providers = (Array.isArray(providers) ? providers : [])
271
- .map(provider => normalizeKnownProviderForRuntime(provider));
274
+ const rawProviders = Array.isArray(providers) ? providers : [];
275
+ this.#authoritativeManagedProviders = new Set(rawProviders
276
+ .filter(provider => isGitHubCopilotProvider(provider)
277
+ && Array.isArray(provider.models)
278
+ && provider.models.length > 0)
279
+ .map(provider => provider.name || GITHUB_COPILOT_PROVIDER_NAME));
280
+ this.#providers = rawProviders.map(provider => normalizeKnownProviderForRuntime(provider));
272
281
  this.#modelToProvider = new Map();
273
282
  this.#adapterCache = new Map();
274
283
 
@@ -306,6 +315,10 @@ export class AdapterRouter extends LLMAdapter {
306
315
 
307
316
  const candidates = this.#providers.filter(p => p && p.name === parsed.providerName);
308
317
  if (candidates.length === 0) return null;
318
+ // An explicit managed catalog is authoritative. A qualified ref is not
319
+ // permission to resurrect a model that catalog just removed. Legacy rows
320
+ // with no models still use the bundled fallback catalog above.
321
+ if (this.#authoritativeManagedProviders.has(parsed.providerName)) return null;
309
322
 
310
323
  const inferred = inferProtocolFromModelId(parsed.modelId);
311
324
  if (!inferred) return null;
@@ -7,30 +7,27 @@
7
7
  * Session config is user-level state. Project `.yeaft` directories may provide
8
8
  * project assets such as skills/MCP config, but must not own Session config.
9
9
  *
10
- * v1 schema (intentionally tiny — extend via additive keys only):
10
+ * Public v1 schema:
11
11
  * {
12
12
  * "model": "my-proxy/claude-sonnet-4-20250514", // optional
13
13
  * "modelEffort": "high" // optional
14
14
  * }
15
15
  *
16
- * Missing file or `{}` no session-level override. Missing field → fall back to user-level
17
- * config (`~/.yeaft/config.json` via loadConfig()). Resolution is a
18
- * shallow overlay for send-time effective config.
19
- *
20
- * Storage layer only — no engine wiring, no validation of model strings
21
- * against the provider registry (that's done lazily at resolve time by
22
- * the engine when it tries to dispatch to AdapterRouter).
16
+ * Missing file or `{}` means no Session-level override. Managed provider
17
+ * catalogs are authoritative: an override removed from such a catalog cannot
18
+ * reach the adapter and falls back to the current Agent default.
23
19
  */
24
20
 
25
21
  import { existsSync, readFileSync } from 'fs';
26
22
  import { join } from 'path';
27
23
  import { writeAtomic } from '../storage/index.js';
24
+ import { isGitHubCopilotProvider } from '../llm/known-providers.js';
28
25
  import { sessionsRoot } from './session-crud.js';
29
26
 
30
27
  const CONFIG_FILE = 'config.json';
31
-
32
- /** Whitelist of persisted session model-override fields. Reject everything else. */
33
- const ALLOWED_KEYS = new Set(['model', 'modelEffort']);
28
+ const MODEL_SOURCE_EXPLICIT = 'explicit';
29
+ const WRITABLE_KEYS = new Set(['model', 'modelEffort']);
30
+ const STORED_KEYS = new Set([...WRITABLE_KEYS, 'modelSource']);
34
31
  const ALLOWED_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
35
32
 
36
33
  export class SessionConfigError extends Error {
@@ -41,36 +38,30 @@ export class SessionConfigError extends Error {
41
38
  }
42
39
  }
43
40
 
44
- /**
45
- * Resolve the on-disk path for a session's config.json. Always uses the
46
- * agent-local Yeaft root (`~/.yeaft` in production), not a project `.yeaft`.
47
- */
41
+ /** Resolve the agent-local path for a Session's config.json. */
48
42
  export function sessionConfigPath(yeaftDir, sessionId) {
49
43
  if (!yeaftDir) return null;
50
44
  return join(sessionsRoot(yeaftDir), sessionId, CONFIG_FILE);
51
45
  }
52
46
 
53
- /**
54
- * Read a session's config.json. Returns `{}` when the file is missing or
55
- * corrupt callers fall back to user-level defaults via
56
- * `resolveSessionConfig`. We never auto-write on read.
57
- *
58
- * @param {string} yeaftDir
59
- * @param {string} sessionId
60
- * @returns {object}
61
- */
62
- export function loadSessionConfig(yeaftDir, sessionId) {
47
+ function publicConfig(stored) {
48
+ const out = {};
49
+ for (const key of WRITABLE_KEYS) {
50
+ if (Object.prototype.hasOwnProperty.call(stored || {}, key)) out[key] = stored[key];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function loadStoredSessionConfig(yeaftDir, sessionId) {
63
56
  if (!sessionId || !yeaftDir) return {};
64
57
  const path = sessionConfigPath(yeaftDir, sessionId);
65
58
  if (!path || !existsSync(path)) return {};
66
59
  try {
67
60
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
68
61
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
69
- // Strip unknown keys at read time too — defensive against
70
- // hand-edited files that predate a key removal.
71
62
  const out = {};
72
- for (const k of Object.keys(parsed)) {
73
- if (ALLOWED_KEYS.has(k)) out[k] = parsed[k];
63
+ for (const key of Object.keys(parsed)) {
64
+ if (STORED_KEYS.has(key)) out[key] = parsed[key];
74
65
  }
75
66
  return out;
76
67
  } catch {
@@ -78,21 +69,27 @@ export function loadSessionConfig(yeaftDir, sessionId) {
78
69
  }
79
70
  }
80
71
 
72
+ function persistStoredSessionConfig(yeaftDir, sessionId, stored) {
73
+ const path = sessionConfigPath(yeaftDir, sessionId);
74
+ writeAtomic(path, `${JSON.stringify(stored, null, 2)}\n`);
75
+ }
76
+
81
77
  /**
82
- * Validate a partial group-config object. Throws SessionConfigError on
83
- * any unknown key or malformed value. Empty / null values for known
84
- * keys are allowed — they signal "fall back to user default".
85
- *
86
- * @param {object} cfg
78
+ * Read public Session config. Internal provenance never crosses this boundary.
87
79
  */
80
+ export function loadSessionConfig(yeaftDir, sessionId) {
81
+ return publicConfig(loadStoredSessionConfig(yeaftDir, sessionId));
82
+ }
83
+
84
+ /** Validate a user/wire supplied partial Session config. */
88
85
  export function validateSessionConfig(cfg) {
89
86
  if (cfg === null || cfg === undefined) return;
90
87
  if (typeof cfg !== 'object' || Array.isArray(cfg)) {
91
88
  throw new SessionConfigError('invalid_shape', 'config must be an object');
92
89
  }
93
- for (const k of Object.keys(cfg)) {
94
- if (!ALLOWED_KEYS.has(k)) {
95
- throw new SessionConfigError('unknown_key', `unknown config key: ${k}`);
90
+ for (const key of Object.keys(cfg)) {
91
+ if (!WRITABLE_KEYS.has(key)) {
92
+ throw new SessionConfigError('unknown_key', `unknown config key: ${key}`);
96
93
  }
97
94
  }
98
95
  if ('model' in cfg && cfg.model !== null && cfg.model !== undefined && cfg.model !== '') {
@@ -108,68 +105,154 @@ export function validateSessionConfig(cfg) {
108
105
  }
109
106
 
110
107
  /**
111
- * Shallow-merge `partial` into the group's existing config.json and
112
- * persist atomically. Returns the resulting object. Passing `null` or
113
- * empty string for a known key removes that key (so the group falls
114
- * back to the user default).
115
- *
116
- * @param {string} yeaftDir
117
- * @param {string} sessionId
118
- * @param {object} partial
119
- * @returns {object}
108
+ * Persist an explicit Session config change. `modelSource` is internal and is
109
+ * set here rather than accepted from clients, so future migrations can
110
+ * distinguish a user choice from the retired automatic default seed.
120
111
  */
121
112
  export function saveSessionConfig(yeaftDir, sessionId, partial) {
122
113
  if (!sessionId) throw new SessionConfigError('missing_group_id', 'sessionId required');
123
114
  validateSessionConfig(partial);
124
- const current = loadSessionConfig(yeaftDir, sessionId);
125
- const next = { ...current };
126
- for (const [k, v] of Object.entries(partial || {})) {
127
- if (!ALLOWED_KEYS.has(k)) continue;
128
- if (v === null || v === undefined || v === '') {
129
- delete next[k];
130
- } else {
131
- next[k] = typeof v === 'string' ? v.trim() : v;
115
+ const next = { ...loadStoredSessionConfig(yeaftDir, sessionId) };
116
+ for (const [key, value] of Object.entries(partial || {})) {
117
+ if (!WRITABLE_KEYS.has(key)) continue;
118
+ if (value === null || value === undefined || value === '') {
119
+ delete next[key];
120
+ if (key === 'model') delete next.modelSource;
121
+ continue;
132
122
  }
123
+ next[key] = typeof value === 'string' ? value.trim() : value;
124
+ if (key === 'model') next.modelSource = MODEL_SOURCE_EXPLICIT;
133
125
  }
134
- const path = sessionConfigPath(yeaftDir, sessionId);
135
- writeAtomic(path, `${JSON.stringify(next, null, 2)}\n`);
136
- return next;
126
+ persistStoredSessionConfig(yeaftDir, sessionId, next);
127
+ return publicConfig(next);
137
128
  }
138
129
 
139
- /**
140
- * Initialise an empty config.json next to a brand-new group. Idempotent —
141
- * leaves an existing file untouched. Called by `createSessionFromSpec`.
142
- */
130
+ /** Create an empty Session config file without inventing an override. */
143
131
  export function ensureSessionConfigFile(yeaftDir, sessionId) {
144
132
  const path = sessionConfigPath(yeaftDir, sessionId);
145
133
  if (!path || existsSync(path)) return;
146
134
  try {
147
- writeAtomic(path, `${JSON.stringify({}, null, 2)}\n`);
135
+ persistStoredSessionConfig(yeaftDir, sessionId, {});
148
136
  } catch {
149
- // Best-effort — a permission failure here should never break group
150
- // create. Read path returns {} on missing file anyway.
137
+ // Best-effort — a permission failure must not break Session creation.
151
138
  }
152
139
  }
153
140
 
141
+ function modelRefIdentity(value) {
142
+ const text = String(value || '');
143
+ const slash = text.indexOf('/');
144
+ return slash < 0
145
+ ? { provider: '', modelId: text }
146
+ : { provider: text.slice(0, slash), modelId: text.slice(slash + 1) };
147
+ }
148
+
149
+ function modelRefsEquivalent(left, right) {
150
+ if (!left || !right) return false;
151
+ if (left === right) return true;
152
+ const a = modelRefIdentity(left);
153
+ const b = modelRefIdentity(right);
154
+ if (a.modelId !== b.modelId) return false;
155
+ return !a.provider || !b.provider || a.provider === b.provider;
156
+ }
157
+
158
+ function hasAuthoritativeCatalog(provider) {
159
+ if (!provider || typeof provider !== 'object') return false;
160
+ if (provider.managed === true) return true;
161
+ if (typeof provider.managed === 'string' && provider.managed.trim()) return true;
162
+ return isGitHubCopilotProvider(provider);
163
+ }
164
+
165
+ function resolveAllowedModelRef(config, modelRef) {
166
+ const identity = modelRefIdentity(modelRef);
167
+ const providers = Array.isArray(config?.providers) ? config.providers : [];
168
+ const availableModels = Array.isArray(config?.availableModels) ? config.availableModels : [];
169
+
170
+ if (identity.provider) {
171
+ const provider = providers.find(item => item?.name === identity.provider);
172
+ if (!hasAuthoritativeCatalog(provider)) return modelRef;
173
+ const match = availableModels.find(model => model?.ref === modelRef
174
+ || (model?.provider === identity.provider && model?.id === identity.modelId));
175
+ return match?.ref || (match ? `${identity.provider}/${identity.modelId}` : null);
176
+ }
177
+
178
+ const authoritativeProviderNames = new Set(providers
179
+ .filter(hasAuthoritativeCatalog)
180
+ .map(provider => provider.name)
181
+ .filter(Boolean));
182
+ if (authoritativeProviderNames.size === 0) return modelRef;
183
+
184
+ const candidates = new Map();
185
+ for (const model of availableModels) {
186
+ const candidateIdentity = modelRefIdentity(model?.ref || '');
187
+ const candidateId = model?.id || candidateIdentity.modelId;
188
+ const providerName = model?.provider || candidateIdentity.provider;
189
+ if (candidateId !== identity.modelId || !providerName) continue;
190
+ candidates.set(model?.ref || `${providerName}/${candidateId}`, providerName);
191
+ }
192
+ if (candidates.size !== 1) return null;
193
+ const [[ref]] = candidates;
194
+ return ref;
195
+ }
196
+
154
197
  /**
155
- * Resolve the effective config for a group by overlaying group-level
156
- * overrides on top of the user-level config.
157
- *
158
- * Only fields that the per-group schema knows about are overlaid;
159
- * everything else (providers, language, token budgets, ...) is taken
160
- * verbatim from the user config.
198
+ * Normalize persisted config at the Agent/Session boundary.
161
199
  *
162
- * @param {object} userConfig loadConfig() result
163
- * @param {object} sessionConfig loadSessionConfig() result
164
- * @returns {object} A new config object safe to hand to the engine.
200
+ * - Managed catalog misses are always removed: they are no longer valid.
201
+ * - Legacy rows have no source bit. An unmarked value equal to the Agent's
202
+ * previous default is the retired automatic seed and becomes inheritance.
203
+ * - Values written through the explicit update path carry `modelSource` and
204
+ * are preserved while they remain in the authoritative catalog.
165
205
  */
206
+ export function normalizeSessionConfig(
207
+ yeaftDir,
208
+ sessionId,
209
+ userConfig,
210
+ { previousDefaultModel = null } = {},
211
+ ) {
212
+ const stored = loadStoredSessionConfig(yeaftDir, sessionId);
213
+ if (!stored.model || typeof stored.model !== 'string') return publicConfig(stored);
214
+
215
+ const allowedModel = resolveAllowedModelRef(userConfig, stored.model);
216
+ // The retired create path wrote the then-current Agent default without a
217
+ // source bit. New explicit writes always carry modelSource, so this is the
218
+ // only deterministic compatibility rule available for legacy rows.
219
+ const legacyAutoSeed = stored.modelSource !== MODEL_SOURCE_EXPLICIT
220
+ && modelRefsEquivalent(stored.model, previousDefaultModel);
221
+ if (allowedModel && !legacyAutoSeed) {
222
+ const next = { ...stored };
223
+ let changed = false;
224
+ if (next.model !== allowedModel) {
225
+ next.model = allowedModel;
226
+ changed = true;
227
+ }
228
+ // Resolve every legacy row once while the old Agent default is known. A
229
+ // different value could only have come from an explicit Session choice;
230
+ // backfill provenance so a later default change cannot misclassify it.
231
+ if (previousDefaultModel && next.modelSource !== MODEL_SOURCE_EXPLICIT) {
232
+ next.modelSource = MODEL_SOURCE_EXPLICIT;
233
+ changed = true;
234
+ }
235
+ if (changed) persistStoredSessionConfig(yeaftDir, sessionId, next);
236
+ return publicConfig(next);
237
+ }
238
+
239
+ const next = { ...stored };
240
+ delete next.model;
241
+ delete next.modelSource;
242
+ persistStoredSessionConfig(yeaftDir, sessionId, next);
243
+ return publicConfig(next);
244
+ }
245
+
246
+ /** Overlay a valid Session override on the Agent config. */
166
247
  export function resolveSessionConfig(userConfig, sessionConfig) {
167
248
  const base = userConfig ? { ...userConfig } : {};
168
249
  const overrides = sessionConfig && typeof sessionConfig === 'object' ? sessionConfig : {};
169
250
  if (overrides.model && typeof overrides.model === 'string' && overrides.model.trim()) {
170
- const model = overrides.model.trim();
171
- base.model = model;
172
- base.primaryModel = model;
251
+ const model = resolveAllowedModelRef(base, overrides.model.trim());
252
+ if (model) {
253
+ base.model = model;
254
+ base.primaryModel = model;
255
+ }
173
256
  }
174
257
  if (overrides.modelEffort && typeof overrides.modelEffort === 'string' && ALLOWED_EFFORTS.has(overrides.modelEffort)) {
175
258
  base.modelEffort = overrides.modelEffort;
@@ -6,6 +6,7 @@
6
6
  * never clear the model list just because a refresh failed.
7
7
  */
8
8
 
9
+ import { createHash } from 'node:crypto';
9
10
  import ctx from '../context.js';
10
11
  import { sendToServer } from '../connection/buffer.js';
11
12
  import { loadConfig } from './config.js';
@@ -23,6 +24,16 @@ function normalizeAvailableModels(models) {
23
24
  .filter(Boolean);
24
25
  }
25
26
 
27
+ function catalogDigest(model, availableModels) {
28
+ return createHash('sha256')
29
+ .update(JSON.stringify({ model: model || null, availableModels: normalizeAvailableModels(availableModels) }))
30
+ .digest('hex');
31
+ }
32
+
33
+ function createCatalogEpoch(now) {
34
+ return `${process.pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
35
+ }
36
+
26
37
  function buildEvent(snapshot) {
27
38
  return {
28
39
  type: 'yeaft_status',
@@ -33,7 +44,12 @@ function buildEvent(snapshot) {
33
44
  tools: snapshot.tools,
34
45
  yeaftDir: snapshot.yeaftDir || null,
35
46
  refreshedAt: snapshot.refreshedAt || null,
47
+ catalogRefreshedAt: snapshot.catalogRefreshedAt || null,
48
+ catalogEpoch: snapshot.catalogEpoch || null,
49
+ catalogRevision: snapshot.catalogRevision || null,
50
+ catalogDigest: snapshot.catalogDigest || null,
36
51
  refreshStartedAt: snapshot.refreshStartedAt || null,
52
+ refreshReason: snapshot.refreshReason || null,
37
53
  refreshError: snapshot.refreshError || null,
38
54
  refreshing: !!snapshot.refreshing,
39
55
  };
@@ -54,6 +70,13 @@ export function createYeaftStatusCache(options = {}) {
54
70
  let timer = null;
55
71
  let inFlight = null;
56
72
  let generation = 0;
73
+ let forceTail = Promise.resolve();
74
+ let pendingForceRefreshes = 0;
75
+ let hasConfigCatalog = false;
76
+ let lastCatalogRefreshedAt = 0;
77
+ let catalogEpoch = createCatalogEpoch(now);
78
+ let catalogRevision = 0;
79
+ let lastCatalogDigest = null;
57
80
 
58
81
  function current() {
59
82
  return snapshot ? { ...snapshot, availableModels: normalizeAvailableModels(snapshot.availableModels) } : null;
@@ -80,15 +103,26 @@ export function createYeaftStatusCache(options = {}) {
80
103
  const config = await load({ ...(yeaftDir && { dir: yeaftDir }) });
81
104
  if (refreshGeneration !== generation) return current();
82
105
  const previous = snapshot || {};
106
+ const nextModel = config.primaryModel || config.model || previous.model || null;
107
+ const nextModels = normalizeAvailableModels(config.availableModels);
108
+ const nextDigest = catalogDigest(nextModel, nextModels);
109
+ hasConfigCatalog = true;
110
+ lastCatalogRefreshedAt = now();
111
+ if (nextDigest !== lastCatalogDigest) catalogRevision += 1;
112
+ lastCatalogDigest = nextDigest;
83
113
  snapshot = {
84
114
  ...previous,
85
- model: config.primaryModel || config.model || previous.model || null,
86
- availableModels: normalizeAvailableModels(config.availableModels),
115
+ model: nextModel,
116
+ availableModels: nextModels,
87
117
  yeaftDir: config.dir || yeaftDir || previous.yeaftDir || null,
88
118
  skills: sessionStatus?.skills ?? previous.skills,
89
119
  mcpServers: sessionStatus?.mcpServers ?? previous.mcpServers,
90
120
  tools: sessionStatus?.tools ?? previous.tools,
91
- refreshedAt: now(),
121
+ refreshedAt: lastCatalogRefreshedAt,
122
+ catalogRefreshedAt: lastCatalogRefreshedAt,
123
+ catalogEpoch,
124
+ catalogRevision,
125
+ catalogDigest: lastCatalogDigest,
92
126
  refreshStartedAt: startedAt,
93
127
  refreshReason: reason,
94
128
  refreshError: null,
@@ -104,6 +138,10 @@ export function createYeaftStatusCache(options = {}) {
104
138
  ...previous,
105
139
  availableModels: normalizeAvailableModels(previous.availableModels),
106
140
  refreshedAt: previous.refreshedAt || null,
141
+ catalogRefreshedAt: previous.catalogRefreshedAt || null,
142
+ catalogEpoch: previous.catalogEpoch || null,
143
+ catalogRevision: previous.catalogRevision || null,
144
+ catalogDigest: previous.catalogDigest || null,
107
145
  refreshStartedAt: startedAt,
108
146
  refreshReason: reason,
109
147
  refreshError: message,
@@ -118,35 +156,54 @@ export function createYeaftStatusCache(options = {}) {
118
156
  return refreshPromise;
119
157
  }
120
158
 
121
- async function forceRefresh(options = {}) {
122
- // Invalidate any disk read that started before the config write, wait for it
123
- // to drain, then start a new read. A plain refresh() intentionally dedupes
124
- // concurrent callers, which is wrong after a successful config update.
159
+ function forceRefresh(options = {}) {
160
+ // Serialize post-save reads. Every caller invalidates work that started
161
+ // before its config write, then reads only after earlier forced refreshes
162
+ // have drained. A counter keeps Session hydration from cancelling the
163
+ // newest refresh when an older caller finishes first.
125
164
  generation += 1;
126
- if (inFlight) await inFlight;
127
- return refresh({ ...options, emitRefreshing: options.emitRefreshing ?? false });
165
+ pendingForceRefreshes += 1;
166
+ const run = async () => {
167
+ try {
168
+ if (inFlight) await inFlight;
169
+ return await refresh({ ...options, emitRefreshing: options.emitRefreshing ?? false });
170
+ } finally {
171
+ pendingForceRefreshes -= 1;
172
+ }
173
+ };
174
+ const result = forceTail.then(run, run);
175
+ forceTail = result.catch(() => null);
176
+ return result;
128
177
  }
129
178
 
130
179
  function hydrateFromSession(sessionLike, { reason = 'session_ready', emitEvent = true } = {}) {
131
180
  if (!sessionLike) return null;
132
- // Session hydration is authoritative. Any refresh that started before this
133
- // point loaded an older disk snapshot and must not overwrite it when it
134
- // resolves later.
135
- generation += 1;
181
+ // Session hydration fills runtime status only. The provider/model catalog
182
+ // is owned by config.json, so hydration never invalidates a config read.
183
+ // A config write invalidates stale reads through forceRefresh() instead.
136
184
  const previous = snapshot || {};
185
+ const sessionModels = normalizeAvailableModels(sessionLike.config?.availableModels);
137
186
  snapshot = {
138
187
  ...previous,
139
- model: sessionLike.config?.model || previous.model || null,
140
- availableModels: normalizeAvailableModels(sessionLike.config?.availableModels || previous.availableModels),
188
+ model: hasConfigCatalog
189
+ ? (previous.model || sessionLike.config?.model || null)
190
+ : (sessionLike.config?.model || previous.model || null),
191
+ availableModels: hasConfigCatalog
192
+ ? normalizeAvailableModels(previous.availableModels)
193
+ : (sessionModels.length > 0 ? sessionModels : normalizeAvailableModels(previous.availableModels)),
141
194
  yeaftDir: sessionLike.yeaftDir || sessionLike.config?.dir || previous.yeaftDir || null,
142
195
  skills: sessionLike.status?.skills ?? previous.skills,
143
196
  mcpServers: sessionLike.status?.mcpServers ?? previous.mcpServers,
144
197
  tools: sessionLike.status?.tools ?? previous.tools,
145
198
  refreshedAt: now(),
199
+ catalogRefreshedAt: hasConfigCatalog ? lastCatalogRefreshedAt : null,
200
+ catalogEpoch: hasConfigCatalog ? catalogEpoch : null,
201
+ catalogRevision: hasConfigCatalog ? catalogRevision : null,
202
+ catalogDigest: hasConfigCatalog ? lastCatalogDigest : null,
146
203
  refreshStartedAt: previous.refreshStartedAt || null,
147
204
  refreshReason: reason,
148
- refreshError: null,
149
- refreshing: false,
205
+ refreshError: previous.refreshError || null,
206
+ refreshing: pendingForceRefreshes > 0 || !!previous.refreshing,
150
207
  };
151
208
  return emitEvent ? emitSnapshot() : buildEvent(snapshot);
152
209
  }