@yeaft/webchat-agent 1.0.190 → 1.0.191

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.
@@ -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
  }
@@ -53,7 +53,7 @@ import {
53
53
  resolveSessionYeaftDir,
54
54
  } from './sessions/session-crud.js';
55
55
  import { openSession, loadSessionMeta } from './sessions/session-store.js';
56
- import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
56
+ import { loadSessionConfig, normalizeSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
57
57
  import { updateSessionConfig } from './sessions/session-crud.js';
58
58
  import { createCoordinator } from './sessions/coordinator.js';
59
59
  import { seedDefaultSession } from './sessions/seed-default.js';
@@ -131,49 +131,93 @@ function applyLiveLanguage(language) {
131
131
  try { session?.engine?.setLanguage?.(language); } catch { /* best-effort */ }
132
132
  }
133
133
 
134
- function refreshLiveSessionConfig() {
135
- if (!session) return;
136
- try {
137
- const freshConfig = loadConfig({ dir: liveConfigRoot() });
138
- const freshModels = Array.isArray(freshConfig.availableModels) ? freshConfig.availableModels : [];
139
- session.config.availableModels = freshModels;
140
- if (freshConfig.language) {
141
- applyLiveLanguage(freshConfig.language);
142
- }
143
- if (freshConfig.model && !freshModels.some(m => modelRefMatchesAvailable(m, session.config.model))) {
144
- session.config.model = freshConfig.primaryModel || freshConfig.model;
145
- }
146
- if (freshConfig.providers) {
147
- session.config.providers = freshConfig.providers;
148
- if (typeof session.adapter?.refreshProviders === 'function') {
149
- session.adapter.refreshProviders(freshConfig.providers);
150
- }
151
- }
152
- } catch (err) {
153
- console.warn('[Yeaft] refresh live session config failed:', err?.message || err);
134
+ function modelRefIdentity(value) {
135
+ const text = String(value || '');
136
+ const slash = text.indexOf('/');
137
+ return slash < 0
138
+ ? { provider: '', modelId: text }
139
+ : { provider: text.slice(0, slash), modelId: text.slice(slash + 1) };
140
+ }
141
+
142
+ function modelRefsEquivalent(left, right) {
143
+ if (!left || !right) return false;
144
+ if (left === right) return true;
145
+ const a = modelRefIdentity(left);
146
+ const b = modelRefIdentity(right);
147
+ if (a.modelId !== b.modelId) return false;
148
+ return !a.provider || !b.provider || a.provider === b.provider;
149
+ }
150
+
151
+ function resolveLiveSessionConfig(baseConfig, sessionId, options = {}) {
152
+ const configRoot = baseConfig?.dir || liveConfigRoot();
153
+ const sessionConfig = normalizeSessionConfig(configRoot, sessionId, baseConfig, options);
154
+ return resolveSessionConfig(baseConfig, sessionConfig);
155
+ }
156
+
157
+ let sessionConfigRefreshRevision = 0;
158
+
159
+ /** Reload the Agent-owned config and install it into every live Engine. */
160
+ export async function refreshLiveSessionConfig(options = {}) {
161
+ sessionConfigRefreshRevision += 1;
162
+ if (!session && sessionLoadPromise) {
163
+ // The loader may run its own catch-up refresh, but it does not know this
164
+ // config transaction's pre-save default. Wait for it, then continue once
165
+ // with the original normalization input.
166
+ await sessionLoadPromise;
167
+ }
168
+
169
+ const configRoot = liveConfigRoot();
170
+ const freshConfig = loadConfig({ dir: configRoot });
171
+ const previousDefaultModel = options.previousDefaultModel
172
+ || session?.config?.primaryModel
173
+ || session?.config?.model
174
+ || null;
175
+ // Normalize disk state even before the runtime is loaded. The config-save
176
+ // transaction supplies the pre-write default so legacy automatic seeds can
177
+ // become inheritance without depending on page/session lifecycle.
178
+ for (const row of snapshotSessions(configRoot)) {
179
+ normalizeSessionConfig(configRoot, row.id, freshConfig, { previousDefaultModel });
180
+ }
181
+ if (!session) return freshConfig;
182
+
183
+ const liveSession = session;
184
+ const currentConfig = liveSession.config || {};
185
+ const runtimeOnly = {};
186
+ for (const key of ['serverMode', '_readOnly', 'modelEffort']) {
187
+ if (Object.prototype.hasOwnProperty.call(currentConfig, key)) runtimeOnly[key] = currentConfig[key];
188
+ }
189
+ const inheritedAgentDefault = !currentConfig.model
190
+ || modelRefsEquivalent(currentConfig.model, currentConfig.primaryModel);
191
+ const nextModel = inheritedAgentDefault
192
+ ? (freshConfig.primaryModel || freshConfig.model)
193
+ : currentConfig.model;
194
+ const nextConfig = { ...freshConfig, ...runtimeOnly, model: nextModel };
195
+ const vpConfigSnapshots = [];
196
+ for (const [key, engine] of vpEngines) {
197
+ const separator = key.indexOf('::');
198
+ if (separator < 1) continue;
199
+ const sessionId = key.slice(0, separator);
200
+ vpConfigSnapshots.push({
201
+ key,
202
+ engine,
203
+ config: resolveLiveSessionConfig(nextConfig, sessionId, { previousDefaultModel }),
204
+ });
154
205
  }
155
- }
156
206
 
157
- function defaultSessionModelConfig(baseConfig) {
158
- const config = baseConfig || session?.config || {};
159
- const out = {};
160
- const model = typeof config.primaryModel === 'string' && config.primaryModel.trim()
161
- ? config.primaryModel.trim()
162
- : (typeof config.model === 'string' && config.model.trim() ? config.model.trim() : '');
163
- if (model) out.model = model;
164
- if (typeof config.modelEffort === 'string' && config.modelEffort.trim()) {
165
- out.modelEffort = config.modelEffort.trim();
207
+ if (typeof liveSession.adapter?.refreshProviders === 'function') {
208
+ liveSession.adapter.refreshProviders(freshConfig.providers || []);
166
209
  }
167
- return out;
210
+ for (const key of Object.keys(currentConfig)) delete currentConfig[key];
211
+ Object.assign(currentConfig, nextConfig);
212
+ liveSession.engine?.refreshConfig?.(currentConfig);
213
+ for (const { key, engine, config } of vpConfigSnapshots) {
214
+ engine.refreshConfig?.(config);
215
+ vpEngineConfigKeys.set(key, engineConfigKey(config));
216
+ }
217
+ if (freshConfig.language) applyLiveLanguage(freshConfig.language);
218
+ return currentConfig;
168
219
  }
169
220
 
170
- function withDefaultSessionConfig(payload) {
171
- const next = payload && typeof payload === 'object' ? { ...payload } : {};
172
- const existing = next.config && typeof next.config === 'object' ? next.config : {};
173
- const seeded = { ...defaultSessionModelConfig(), ...existing };
174
- if (Object.keys(seeded).length > 0) next.config = seeded;
175
- return next;
176
- }
177
221
 
178
222
  /** Test-only: replace the lightweight VP thread classifier. */
179
223
  export function __testSetThreadClassifier(fn) {
@@ -210,7 +254,11 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
210
254
  setTimeout(async () => {
211
255
  try {
212
256
  if (!replaySession) return;
213
- refreshLiveSessionConfig();
257
+ try {
258
+ await refreshLiveSessionConfig();
259
+ } catch (err) {
260
+ console.warn('[Yeaft] load-history config refresh failed:', err?.message || err);
261
+ }
214
262
  let projectRuntime = null;
215
263
  if (sessionId) {
216
264
  try {
@@ -517,6 +565,7 @@ function engineConfigKey(config) {
517
565
  fastModel: config?.fastModel || '',
518
566
  fastModelId: config?.fastModelId || '',
519
567
  fallbackModel: config?.fallbackModel || '',
568
+ providers: Array.isArray(config?.providers) ? config.providers : [],
520
569
  });
521
570
  }
522
571
 
@@ -833,7 +882,7 @@ function queryTimeoutMsForSessionConfig(config = null) {
833
882
  function queryTimeoutMsForSession(sessionId = null) {
834
883
  if (!sessionId || !session) return queryTimeoutMsForSessionConfig(session?.config);
835
884
  try {
836
- return queryTimeoutMsForSessionConfig(resolveSessionConfig(session.config, loadSessionConfig(liveConfigRoot(), sessionId)));
885
+ return queryTimeoutMsForSessionConfig(resolveLiveSessionConfig(session.config, sessionId));
837
886
  } catch {
838
887
  return queryTimeoutMsForSessionConfig(session?.config);
839
888
  }
@@ -1337,8 +1386,7 @@ export function __testGroupHistory(sessionId) {
1337
1386
 
1338
1387
  export function __testResolveVpEffectiveConfig(sessionId) {
1339
1388
  if (!session) return null;
1340
- const sessionConfigRoot = liveConfigRoot();
1341
- return resolveSessionConfig(session.config, loadSessionConfig(sessionConfigRoot, sessionId));
1389
+ return resolveLiveSessionConfig(session.config, sessionId);
1342
1390
  }
1343
1391
 
1344
1392
  /**
@@ -1466,9 +1514,7 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1466
1514
  // Per-session config overlay (v1: model only). Falls back to the
1467
1515
  // session's user-level config when no override is set. Session config is
1468
1516
  // always resolved from the agent-local root; project `.yeaft` is ignored.
1469
- const sessionConfigRoot = liveConfigRoot();
1470
- const groupCfg = loadSessionConfig(sessionConfigRoot, sessionId);
1471
- const effectiveConfig = resolveSessionConfig(session.config, groupCfg);
1517
+ const effectiveConfig = resolveLiveSessionConfig(session.config, sessionId);
1472
1518
  const configKey = engineConfigKey(effectiveConfig);
1473
1519
  let eng = vpEngines.get(key);
1474
1520
  if (eng && vpEngineConfigKeys.get(key) === configKey) return eng;
@@ -2684,7 +2730,7 @@ export function handleYeaftCreateSession(msg) {
2684
2730
  const payload = (msg && msg.payload) || {};
2685
2731
  try {
2686
2732
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2687
- const group = createSessionFromSpec(yeaftDir, withDefaultSessionConfig(payload));
2733
+ const group = createSessionFromSpec(yeaftDir, payload);
2688
2734
  recordAgentSessionCreated();
2689
2735
  group.config = loadSessionConfig(yeaftDir, group.id);
2690
2736
  sendSessionCrudResult({ op: 'create', requestId, ok: true, session: group });
@@ -4212,6 +4258,7 @@ export async function ensureSessionLoaded(opts = {}) {
4212
4258
  if (sessionLoadPromise) return sessionLoadPromise;
4213
4259
 
4214
4260
  sessionLoadPromise = (async () => {
4261
+ const bootConfigRevision = sessionConfigRefreshRevision;
4215
4262
  const yeaftDir = ctx.CONFIG?.yeaftDir;
4216
4263
  const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
4217
4264
  session = await loadSession({
@@ -4223,6 +4270,11 @@ export async function ensureSessionLoaded(opts = {}) {
4223
4270
  });
4224
4271
 
4225
4272
  installYeaftRuntimeBridge(session);
4273
+ // A save may complete after loadSession read config.json but before this
4274
+ // runtime became visible. Refresh before any status/session_ready emit.
4275
+ if (sessionConfigRefreshRevision !== bootConfigRevision) {
4276
+ await refreshLiveSessionConfig();
4277
+ }
4226
4278
 
4227
4279
  try {
4228
4280
  if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {