@yeaft/webchat-agent 1.0.413 → 1.0.415

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/browser-runtime/browser-install.js +497 -0
  2. package/browser-runtime/cli.js +88 -0
  3. package/browser-runtime/config.js +116 -0
  4. package/browser-runtime/errors.js +8 -0
  5. package/browser-runtime/extension/manifest.json +18 -0
  6. package/browser-runtime/extension/offscreen.html +5 -0
  7. package/browser-runtime/extension/offscreen.js +101 -0
  8. package/browser-runtime/extension/popup.html +5 -0
  9. package/browser-runtime/extension/popup.js +1 -0
  10. package/browser-runtime/extension/service-worker.js +48 -0
  11. package/browser-runtime/extension.js +45 -0
  12. package/browser-runtime/index.js +5 -0
  13. package/browser-runtime/probe.js +427 -0
  14. package/browser-runtime/protocol.js +71 -0
  15. package/browser-runtime/service.js +132 -0
  16. package/browser-runtime/windows-version-job.ps1 +233 -0
  17. package/browser-runtime/windows-version-worker.js +75 -0
  18. package/browser-runtime/windows-version.js +85 -0
  19. package/cli.js +24 -7
  20. package/connection/index.js +12 -0
  21. package/context.js +1 -0
  22. package/index.js +19 -2
  23. package/llm-config-cli.js +24 -21
  24. package/local-runtime/server/client-protocol.js +14 -0
  25. package/local-runtime/server/context.js +3 -2
  26. package/local-runtime/server/handlers/agent-file-terminal.js +185 -115
  27. package/local-runtime/server/handlers/agent-output.js +3 -0
  28. package/local-runtime/server/handlers/client-misc.js +21 -4
  29. package/local-runtime/server/handlers/client-workbench.js +222 -41
  30. package/local-runtime/server/workbench-correlation.js +184 -0
  31. package/local-runtime/server/workbench-route.js +180 -0
  32. package/local-runtime/server/ws-agent.js +4 -0
  33. package/local-runtime/server/ws-client.js +25 -3
  34. package/local-runtime/version.json +1 -1
  35. package/local-runtime/web/app.bundle.js +191 -135
  36. package/local-runtime/web/app.bundle.js.gz +0 -0
  37. package/local-runtime/web/index.html +2 -2
  38. package/local-runtime/web/style.bundle.css +1 -1
  39. package/local-runtime/web/style.bundle.css.gz +0 -0
  40. package/package.json +5 -1
  41. package/service/config.js +23 -2
  42. package/service/index.js +1 -0
  43. package/service/linux.js +3 -2
  44. package/terminal.js +167 -30
  45. package/workbench/file-ops.js +21 -20
  46. package/workbench/file-search.js +4 -3
  47. package/workbench/git-ops.js +23 -22
  48. package/workbench/request-routing.js +16 -0
  49. package/yeaft/cli.js +57 -1
  50. package/yeaft/config-api.js +138 -192
  51. package/yeaft/config-store.js +192 -0
  52. package/yeaft/config.js +3 -0
  53. package/yeaft/init.js +20 -7
  54. package/yeaft/sessions/feature-flag.js +15 -33
  55. package/yeaft/sessions/session-manifest.js +114 -10
  56. package/yeaft/stdio-protocol.js +57 -0
  57. package/yeaft/storage/atomic.js +43 -17
  58. package/yeaft/tools/process-runner.js +86 -13
@@ -8,12 +8,14 @@
8
8
  * like maxContinueTurns or debug that don't belong in the UI.
9
9
  */
10
10
 
11
- import { existsSync, readFileSync, writeFileSync } from 'fs';
11
+ import { existsSync, readFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
15
15
  import { normaliseTelemetrySection, normaliseYeaftSection } from './config.js';
16
+ import { normaliseBrowserRuntimeSection, validateBrowserRuntimeUpdate } from '../browser-runtime/config.js';
16
17
  import { normalizePluginConfig } from './plugins.js';
18
+ import { mutateAgentConfig, readAgentConfigForWrite } from './config-store.js';
17
19
  import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
18
20
 
19
21
  /**
@@ -28,16 +30,7 @@ import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from '.
28
30
  * @throws {Error} when an existing config cannot be safely preserved
29
31
  */
30
32
  function readConfigForWrite(configPath) {
31
- if (!existsSync(configPath)) return {};
32
- const json = JSON.parse(readFileSync(configPath, 'utf8'));
33
- if (!json || typeof json !== 'object' || Array.isArray(json)
34
- || Object.getPrototypeOf(json) !== Object.prototype) {
35
- throw new Error('config.json must contain an object');
36
- }
37
- if (Object.prototype.hasOwnProperty.call(json, 'plugins')) {
38
- normalizePluginConfig(json.plugins);
39
- }
40
- return json;
33
+ return readAgentConfigForWrite(configPath);
41
34
  }
42
35
 
43
36
  /**
@@ -142,85 +135,57 @@ function normalizeManagedModelDefaults(config) {
142
135
  */
143
136
  export function updateLlmConfig(update, dir) {
144
137
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
145
- const configPath = join(root, 'config.json');
146
-
147
- // Preserve all existing fields only when the on-disk document and its
148
- // Plugins policy are valid. Never turn a failed read into a fresh config.
149
- let existing;
150
- try {
151
- existing = readConfigForWrite(configPath);
152
- } catch (err) {
153
- return { error: `Failed to read config.json: ${err?.message || err}` };
154
- }
155
138
 
156
- // Validate providers structure
157
139
  if (update.providers !== undefined) {
158
- if (!Array.isArray(update.providers)) {
159
- return { error: 'providers must be an array' };
160
- }
161
- for (const p of update.providers) {
162
- if (!p.name || typeof p.name !== 'string') {
140
+ if (!Array.isArray(update.providers)) return { error: 'providers must be an array' };
141
+ for (const provider of update.providers) {
142
+ if (!provider.name || typeof provider.name !== 'string') {
163
143
  return { error: 'Each provider must have a name' };
164
144
  }
165
- if (isGitHubCopilotProvider(p)) continue;
166
- if (!p.baseUrl || typeof p.baseUrl !== 'string') {
167
- return { error: `Provider "${p.name}" must have a baseUrl` };
145
+ if (isGitHubCopilotProvider(provider)) continue;
146
+ if (!provider.baseUrl || typeof provider.baseUrl !== 'string') {
147
+ return { error: `Provider "${provider.name}" must have a baseUrl` };
168
148
  }
169
- if (!Array.isArray(p.models) || p.models.length === 0) {
170
- return { error: `Provider "${p.name}" must have at least one model` };
149
+ if (!Array.isArray(provider.models) || provider.models.length === 0) {
150
+ return { error: `Provider "${provider.name}" must have at least one model` };
171
151
  }
172
152
  }
173
- // Normalize + re-serialize each provider's models so that:
174
- // - id-only entries are persisted as plain strings (back-compat)
175
- // - entries with ctx / maxOutput are persisted as objects
176
- // - empty / 0 / NaN values get stripped
177
- existing.providers = update.providers.map(p => {
178
- const managed = serializeKnownProviderForPersistence(p);
179
- if (managed) return managed;
180
- const normalized = normalizeProviderModels(p);
181
- return {
182
- ...p,
183
- models: normalized.map(serializeModelForPersistence),
184
- };
185
- });
186
153
  }
187
154
 
188
- // Update model selections. A managed provider catalog is authoritative:
189
- // when it drops the old Agent default, keep no hidden reference to that
190
- // model in primaryModel or fastModel.
191
- if (update.primaryModel !== undefined) {
192
- existing.primaryModel = update.primaryModel || null;
193
- }
194
- if (update.fastModel !== undefined) {
195
- existing.fastModel = update.fastModel || null;
196
- }
197
- if (update.providers !== undefined) normalizeManagedModelDefaults(existing);
198
- if (update.language !== undefined) {
199
- existing.language = update.language;
200
- }
201
- if (update.debug !== undefined) {
202
- existing.debug = update.debug === true;
203
- }
204
-
205
- // Write back
206
155
  try {
207
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
208
- } catch (e) {
209
- return { error: `Failed to write config.json: ${e.message}` };
156
+ return mutateAgentConfig(root, existing => {
157
+ if (update.providers !== undefined) {
158
+ existing.providers = update.providers.map(provider => {
159
+ const managed = serializeKnownProviderForPersistence(provider);
160
+ if (managed) return managed;
161
+ return {
162
+ ...provider,
163
+ models: normalizeProviderModels(provider).map(serializeModelForPersistence),
164
+ };
165
+ });
166
+ }
167
+ if (update.primaryModel !== undefined) existing.primaryModel = update.primaryModel || null;
168
+ if (update.fastModel !== undefined) existing.fastModel = update.fastModel || null;
169
+ if (update.providers !== undefined) normalizeManagedModelDefaults(existing);
170
+ if (update.language !== undefined) existing.language = update.language;
171
+ if (update.debug !== undefined) existing.debug = update.debug === true;
172
+
173
+ const agentConfig = {
174
+ providers: Array.isArray(existing.providers) ? existing.providers : [],
175
+ primaryModel: existing.primaryModel || null,
176
+ fastModel: existing.fastModel || null,
177
+ language: existing.language || 'en',
178
+ debug: existing.debug === true,
179
+ };
180
+ return {
181
+ ...agentConfig,
182
+ agentConfig,
183
+ effectiveConfig: agentConfig,
184
+ };
185
+ });
186
+ } catch (error) {
187
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
210
188
  }
211
-
212
- const agentConfig = {
213
- providers: Array.isArray(existing.providers) ? existing.providers : [],
214
- primaryModel: existing.primaryModel || null,
215
- fastModel: existing.fastModel || null,
216
- language: existing.language || 'en',
217
- debug: existing.debug === true,
218
- };
219
- return {
220
- ...agentConfig,
221
- agentConfig,
222
- effectiveConfig: agentConfig,
223
- };
224
189
  }
225
190
 
226
191
  // ─── Yeaft runtime settings (task-318) ────────────────────────────
@@ -261,7 +226,6 @@ export function getYeaftSettings(dir) {
261
226
  */
262
227
  export function updateYeaftSettings(update, dir) {
263
228
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
264
- const configPath = join(root, 'config.json');
265
229
 
266
230
  if (!update || typeof update !== 'object') {
267
231
  return { error: 'update payload required' };
@@ -288,37 +252,26 @@ export function updateYeaftSettings(update, dir) {
288
252
  }
289
253
  }
290
254
 
291
- // Preserve all existing fields only when the on-disk document and its
292
- // Plugins policy are valid. A Settings update must not repair bad JSON into
293
- // a config whose missing Plugins fields inherit all capabilities.
294
- let existing;
295
- try {
296
- existing = readConfigForWrite(configPath);
297
- } catch (err) {
298
- return { error: `Failed to read config.json: ${err?.message || err}` };
299
- }
300
-
301
- const prev = normaliseYeaftSection(existing.yeaft);
302
- const merged = {
303
- maxConcurrentThreads: update.maxConcurrentThreads !== undefined
304
- ? Math.floor(Number(update.maxConcurrentThreads))
305
- : prev.maxConcurrentThreads,
306
- autoArchiveIdleDays: update.autoArchiveIdleDays !== undefined
307
- ? Math.floor(Number(update.autoArchiveIdleDays))
308
- : prev.autoArchiveIdleDays,
309
- recentTurnsLimit: update.recentTurnsLimit !== undefined
310
- ? Math.floor(Number(update.recentTurnsLimit))
311
- : prev.recentTurnsLimit,
312
- };
313
- existing.yeaft = merged;
314
-
315
255
  try {
316
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
317
- } catch (e) {
318
- return { error: `Failed to write config.json: ${e.message}` };
256
+ return mutateAgentConfig(root, existing => {
257
+ const prev = normaliseYeaftSection(existing.yeaft);
258
+ const merged = {
259
+ maxConcurrentThreads: update.maxConcurrentThreads !== undefined
260
+ ? Math.floor(Number(update.maxConcurrentThreads))
261
+ : prev.maxConcurrentThreads,
262
+ autoArchiveIdleDays: update.autoArchiveIdleDays !== undefined
263
+ ? Math.floor(Number(update.autoArchiveIdleDays))
264
+ : prev.autoArchiveIdleDays,
265
+ recentTurnsLimit: update.recentTurnsLimit !== undefined
266
+ ? Math.floor(Number(update.recentTurnsLimit))
267
+ : prev.recentTurnsLimit,
268
+ };
269
+ existing.yeaft = merged;
270
+ return merged;
271
+ });
272
+ } catch (error) {
273
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
319
274
  }
320
-
321
- return merged;
322
275
  }
323
276
 
324
277
  /**
@@ -357,25 +310,50 @@ export function updateTelemetrySettings(update, dir) {
357
310
  if (Object.keys(update).some(key => !allowed.has(key))) {
358
311
  return { error: 'unknown telemetry setting' };
359
312
  }
313
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
314
+ try {
315
+ return mutateAgentConfig(root, existing => {
316
+ const merged = normaliseTelemetrySection({
317
+ ...(existing.telemetry && typeof existing.telemetry === 'object' ? existing.telemetry : {}),
318
+ ...update,
319
+ });
320
+ existing.telemetry = merged;
321
+ return merged;
322
+ });
323
+ } catch (error) {
324
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
325
+ }
326
+ }
327
+
328
+ // ─── Browser Runtime settings ───────────────────────────────────────
329
+
330
+ export function getBrowserRuntimeSettings(dir) {
360
331
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
361
332
  const configPath = join(root, 'config.json');
362
- let existing;
333
+ if (!existsSync(configPath)) return normaliseBrowserRuntimeSection(null);
363
334
  try {
364
- existing = readConfigForWrite(configPath);
365
- } catch (err) {
366
- return { error: `Failed to read config.json: ${err?.message || err}` };
335
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
336
+ return normaliseBrowserRuntimeSection(json.browserRuntime);
337
+ } catch (error) {
338
+ return { error: `Failed to read config.json: ${error.message}` };
367
339
  }
368
- const merged = normaliseTelemetrySection({
369
- ...(existing.telemetry && typeof existing.telemetry === 'object' ? existing.telemetry : {}),
370
- ...update,
371
- });
372
- existing.telemetry = merged;
340
+ }
341
+
342
+ export function updateBrowserRuntimeSettings(update, dir) {
343
+ const validationError = validateBrowserRuntimeUpdate(update);
344
+ if (validationError) return { error: validationError };
345
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
373
346
  try {
374
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
375
- } catch (e) {
376
- return { error: `Failed to write config.json: ${e.message}` };
347
+ return mutateAgentConfig(root, existing => {
348
+ const previous = existing.browserRuntime && typeof existing.browserRuntime === 'object'
349
+ ? existing.browserRuntime
350
+ : {};
351
+ existing.browserRuntime = normaliseBrowserRuntimeSection({ ...previous, ...update });
352
+ return existing.browserRuntime;
353
+ });
354
+ } catch (error) {
355
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
377
356
  }
378
- return merged;
379
357
  }
380
358
 
381
359
  // ─── Search settings (web-search backend selection + Tavily key) ────
@@ -443,7 +421,6 @@ export function getSearchSettings(dir) {
443
421
  */
444
422
  export function updateSearchSettings(update, dir) {
445
423
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
446
- const configPath = join(root, 'config.json');
447
424
 
448
425
  if (!update || typeof update !== 'object') {
449
426
  return { error: 'update payload required' };
@@ -455,23 +432,17 @@ export function updateSearchSettings(update, dir) {
455
432
  return { error: 'tavilyApiKey must be a string' };
456
433
  }
457
434
 
458
- let existing;
459
435
  try {
460
- existing = readConfigForWrite(configPath);
461
- } catch (err) {
462
- return { error: `Failed to read config.json: ${err?.message || err}` };
463
- }
464
- const prev = (existing && typeof existing.search === 'object' && existing.search) || {};
465
- const merged = { ...prev };
466
- if (update.backend !== undefined) merged.backend = update.backend;
467
- if (update.tavilyApiKey !== undefined) merged.tavilyApiKey = update.tavilyApiKey;
468
- if (update.disableHtmlFallback !== undefined) merged.disableHtmlFallback = !!update.disableHtmlFallback;
469
- existing.search = merged;
470
-
471
- try {
472
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
473
- } catch (e) {
474
- return { error: `Failed to write config.json: ${e.message}` };
436
+ mutateAgentConfig(root, existing => {
437
+ const prev = (existing && typeof existing.search === 'object' && existing.search) || {};
438
+ const merged = { ...prev };
439
+ if (update.backend !== undefined) merged.backend = update.backend;
440
+ if (update.tavilyApiKey !== undefined) merged.tavilyApiKey = update.tavilyApiKey;
441
+ if (update.disableHtmlFallback !== undefined) merged.disableHtmlFallback = !!update.disableHtmlFallback;
442
+ existing.search = merged;
443
+ });
444
+ } catch (error) {
445
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
475
446
  }
476
447
  return getSearchSettings(root);
477
448
  }
@@ -549,24 +520,17 @@ export function getPluginConfig(dir) {
549
520
  */
550
521
  export function updatePluginConfig(plugins, dir) {
551
522
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
552
- const configPath = join(root, 'config.json');
553
523
  let normalized;
554
- let existing;
555
524
  try {
556
- existing = readConfigForWrite(configPath);
557
525
  normalized = normalizePluginConfig(plugins);
558
- } catch (err) {
559
- return { error: `Failed to read plugin config: ${err?.message || err}` };
560
- }
561
-
562
- if (Object.keys(normalized).length === 0) delete existing.plugins;
563
- else existing.plugins = normalized;
564
- try {
565
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
566
- } catch (err) {
567
- return { error: `Failed to write plugin config: ${err?.message || err}` };
526
+ return mutateAgentConfig(root, existing => {
527
+ if (Object.keys(normalized).length === 0) delete existing.plugins;
528
+ else existing.plugins = normalized;
529
+ return { plugins: normalized };
530
+ });
531
+ } catch (error) {
532
+ return { error: `Failed to read plugin config or persist update: ${error?.message || error}` };
568
533
  }
569
- return { plugins: normalized };
570
534
  }
571
535
 
572
536
  // ─── MCP server config (mcpServers array in config.json) ──
@@ -684,33 +648,21 @@ export function upsertMcpServer(server, dir) {
684
648
  if (err) return { error: err };
685
649
 
686
650
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
687
- const configPath = join(root, 'config.json');
688
- let existing;
689
- try {
690
- existing = readConfigForWrite(configPath);
691
- } catch (err) {
692
- return { error: `Failed to read config.json: ${err?.message || err}` };
693
- }
694
- const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
695
-
696
651
  const normalised = normaliseMcpServer(server);
697
652
  if (!normalised) return { error: 'invalid server payload' };
698
653
 
699
- const idx = list.findIndex(s => s && typeof s === 'object' && s.name === normalised.name);
700
- if (idx >= 0) {
701
- list[idx] = normalised;
702
- } else {
703
- list.push(normalised);
704
- }
705
- existing.mcpServers = list;
706
-
707
654
  try {
708
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
709
- } catch (e) {
710
- return { error: `Failed to write config.json: ${e.message}` };
655
+ return mutateAgentConfig(root, existing => {
656
+ const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
657
+ const index = list.findIndex(entry => entry && typeof entry === 'object' && entry.name === normalised.name);
658
+ if (index >= 0) list[index] = normalised;
659
+ else list.push(normalised);
660
+ existing.mcpServers = list;
661
+ return { servers: list.map(normaliseMcpServer).filter(Boolean), server: normalised };
662
+ });
663
+ } catch (error) {
664
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
711
665
  }
712
-
713
- return { servers: list.map(normaliseMcpServer).filter(Boolean), server: normalised };
714
666
  }
715
667
 
716
668
  /**
@@ -732,24 +684,18 @@ export function removeMcpServer(name, dir) {
732
684
  // matching against the padded string.
733
685
  const target = name.trim();
734
686
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
735
- const configPath = join(root, 'config.json');
736
- let existing;
737
- try {
738
- existing = readConfigForWrite(configPath);
739
- } catch (err) {
740
- return { error: `Failed to read config.json: ${err?.message || err}` };
741
- }
742
- const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
743
- const next = list.filter(s => !(s && typeof s === 'object' && s.name === target));
744
- const removed = next.length !== list.length;
745
- existing.mcpServers = next;
746
-
747
687
  try {
748
- writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
749
- } catch (e) {
750
- return { error: `Failed to write config.json: ${e.message}` };
688
+ return mutateAgentConfig(root, existing => {
689
+ const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
690
+ const next = list.filter(entry => !(entry && typeof entry === 'object' && entry.name === target));
691
+ existing.mcpServers = next;
692
+ return {
693
+ servers: next.map(normaliseMcpServer).filter(Boolean),
694
+ removed: next.length !== list.length,
695
+ };
696
+ });
697
+ } catch (error) {
698
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
751
699
  }
752
-
753
- return { servers: next.map(normaliseMcpServer).filter(Boolean), removed };
754
700
  }
755
701
 
@@ -0,0 +1,192 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ writeFileSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ } from 'node:fs';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { dirname, join } from 'node:path';
13
+ import { hostname } from 'node:os';
14
+ import { normalizePluginConfig } from './plugins.js';
15
+ import { writeAtomic } from './storage/atomic.js';
16
+
17
+ const LOCK_WAIT_MS = 10_000;
18
+ const LOCK_STALE_MS = 5 * 60_000;
19
+ const LOCK_RETRY_MS = 10;
20
+
21
+ function sleepSync(ms) {
22
+ const buffer = new Int32Array(new SharedArrayBuffer(4));
23
+ Atomics.wait(buffer, 0, 0, ms);
24
+ }
25
+
26
+ function ensureOwnerDirectory(path) {
27
+ if (existsSync(path)) {
28
+ const details = lstatSync(path);
29
+ if (!details.isDirectory()) throw new Error('Yeaft data root is not a directory');
30
+ if (process.platform !== 'win32') {
31
+ const currentMode = details.mode & 0o777;
32
+ const restrictedMode = currentMode & 0o700;
33
+ if (currentMode !== restrictedMode) chmodSync(path, restrictedMode);
34
+ }
35
+ return;
36
+ }
37
+ mkdirSync(path, { recursive: true, mode: 0o700 });
38
+ if (process.platform !== 'win32') chmodSync(path, 0o700);
39
+ }
40
+
41
+ function readConfigForWrite(configPath) {
42
+ if (!existsSync(configPath)) return {};
43
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
44
+ if (!json || typeof json !== 'object' || Array.isArray(json)
45
+ || Object.getPrototypeOf(json) !== Object.prototype) {
46
+ throw new Error('config.json must contain an object');
47
+ }
48
+ if (Object.prototype.hasOwnProperty.call(json, 'plugins')) {
49
+ normalizePluginConfig(json.plugins);
50
+ }
51
+ return json;
52
+ }
53
+
54
+ function processIsAlive(pid) {
55
+ if (!Number.isInteger(pid) || pid <= 0) return false;
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch (error) {
60
+ return error?.code === 'EPERM';
61
+ }
62
+ }
63
+
64
+ function readConfigLockOwner(lockDir) {
65
+ const lockStat = lstatSync(lockDir);
66
+ if (!lockStat.isDirectory()) throw new Error('config.json lock path is not a directory');
67
+ try {
68
+ const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8'));
69
+ return { owner, lockStat };
70
+ } catch {
71
+ return { owner: null, lockStat };
72
+ }
73
+ }
74
+
75
+ function configLockCanBeTaken(lockDir) {
76
+ const { owner, lockStat } = readConfigLockOwner(lockDir);
77
+ if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
78
+ if (owner) return false;
79
+ return Date.now() - lockStat.mtimeMs > LOCK_STALE_MS;
80
+ }
81
+
82
+ function lockIsOwned(lockDir, token) {
83
+ try {
84
+ const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8'));
85
+ return owner?.token === token;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function removeConfigLockIfOwned(lockDir, token) {
92
+ if (!lockIsOwned(lockDir, token)) return false;
93
+ const claimed = `${lockDir}.release-${token}`;
94
+ try {
95
+ renameSync(lockDir, claimed);
96
+ } catch (error) {
97
+ if (error?.code === 'ENOENT') return false;
98
+ throw error;
99
+ }
100
+ if (!lockIsOwned(claimed, token)) {
101
+ try { renameSync(claimed, lockDir); } catch {}
102
+ return false;
103
+ }
104
+ rmSync(claimed, { recursive: true, force: true });
105
+ return true;
106
+ }
107
+
108
+ function lockOwnerIdentity(owner) {
109
+ if (!owner) return null;
110
+ if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
111
+ return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
112
+ }
113
+
114
+ function takeConfigLock(lockDir) {
115
+ const observed = readConfigLockOwner(lockDir).owner;
116
+ if (!configLockCanBeTaken(lockDir)) return false;
117
+ const observedIdentity = lockOwnerIdentity(observed);
118
+ const claimed = `${lockDir}.stale-${randomUUID()}`;
119
+ try {
120
+ renameSync(lockDir, claimed);
121
+ } catch (error) {
122
+ if (error?.code === 'ENOENT') return true;
123
+ return false;
124
+ }
125
+ const claimedOwner = readConfigLockOwner(claimed).owner;
126
+ const ownerChanged = lockOwnerIdentity(claimedOwner) !== observedIdentity;
127
+ const ownerRevived = claimedOwner?.host === hostname()
128
+ && processIsAlive(Number(claimedOwner.pid));
129
+ if (ownerChanged || ownerRevived) {
130
+ try { renameSync(claimed, lockDir); } catch {}
131
+ return false;
132
+ }
133
+ rmSync(claimed, { recursive: true, force: true });
134
+ return true;
135
+ }
136
+
137
+ function acquireConfigLock(root, { waitMs = LOCK_WAIT_MS } = {}) {
138
+ ensureOwnerDirectory(root);
139
+ const lockDir = join(root, '.config.json.lock');
140
+ const deadline = Date.now() + waitMs;
141
+ for (;;) {
142
+ const token = randomUUID();
143
+ try {
144
+ mkdirSync(lockDir, { mode: 0o700 });
145
+ writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({
146
+ pid: process.pid,
147
+ host: hostname(),
148
+ token,
149
+ startedAt: Date.now(),
150
+ }), { flag: 'wx', mode: 0o600 });
151
+ return () => removeConfigLockIfOwned(lockDir, token);
152
+ } catch (error) {
153
+ if (error?.code !== 'EEXIST') throw error;
154
+ try {
155
+ if (takeConfigLock(lockDir)) continue;
156
+ } catch (inspectionError) {
157
+ if (inspectionError?.code === 'ENOENT') continue;
158
+ throw inspectionError;
159
+ }
160
+ if (Date.now() >= deadline) throw new Error('config.json is busy');
161
+ sleepSync(Math.min(LOCK_RETRY_MS, Math.max(1, deadline - Date.now())));
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Mutate one Agent-owned config.json under a cross-process lock.
168
+ * The callback runs after the file is re-read and validated inside the lock.
169
+ */
170
+ export function mutateAgentConfig(root, mutate, options = {}) {
171
+ if (!root) throw new Error('Yeaft data root required');
172
+ if (typeof mutate !== 'function') throw new Error('config mutator required');
173
+ const release = acquireConfigLock(root, options);
174
+ const configPath = join(root, 'config.json');
175
+ try {
176
+ const exists = existsSync(configPath);
177
+ const current = readConfigForWrite(configPath);
178
+ const result = mutate(current, { exists, configPath });
179
+ writeAtomic(configPath, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
180
+ return result;
181
+ } finally {
182
+ release();
183
+ }
184
+ }
185
+
186
+ export function mutateAgentConfigPath(configPath, mutate, options = {}) {
187
+ return mutateAgentConfig(dirname(configPath), mutate, options);
188
+ }
189
+
190
+ export function readAgentConfigForWrite(configPath) {
191
+ return readConfigForWrite(configPath);
192
+ }
package/yeaft/config.js CHANGED
@@ -27,6 +27,7 @@ import { getModelEffortOptions, getThinkingCapability, modelSupportsEffort, reso
27
27
  import { inferProtocolFromModelId } from './llm/router.js';
28
28
  import { normalizeKnownProviderForRuntime } from './llm/known-providers.js';
29
29
  import { createDenyAllPluginConfig, normalizePluginConfig } from './plugins.js';
30
+ import { normaliseBrowserRuntimeSection } from '../browser-runtime/config.js';
30
31
  import { readWorkspaceFile } from './workspace-file.js';
31
32
 
32
33
  /** Default configuration values. */
@@ -368,6 +369,7 @@ function loadLegacyConfig(dir, overrides) {
368
369
  // task-318: legacy path never had the `yeaft` section — defaults.
369
370
  yeaft: normaliseYeaftSection(null),
370
371
  telemetry: normaliseTelemetrySection(null),
372
+ browserRuntime: normaliseBrowserRuntimeSection(null),
371
373
  plugins: {},
372
374
  providers: null,
373
375
  primaryModel: null,
@@ -524,6 +526,7 @@ export function loadConfig(overrides = {}) {
524
526
  // don't pollute the flat config namespace used by chat code.
525
527
  yeaft: normaliseYeaftSection(jsonConfig.yeaft),
526
528
  telemetry: normaliseTelemetrySection(jsonConfig.telemetry),
529
+ browserRuntime: normaliseBrowserRuntimeSection(jsonConfig.browserRuntime),
527
530
 
528
531
  // Agent-level tools / skills / MCP server allowlists. Missing fields mean
529
532
  // all currently discovered capabilities remain enabled. A persisted schema