@yeaft/webchat-agent 1.0.408 → 1.0.410

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.408",
3
+ "version": "1.0.410",
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",
@@ -6,7 +6,7 @@ import ctx from '../context.js';
6
6
  import { resolveAndValidatePath, BINARY_EXTENSIONS } from './utils.js';
7
7
 
8
8
  export async function handleReadFile(msg) {
9
- const { conversationId, filePath, _requestUserId } = msg;
9
+ const { conversationId, filePath, requestId, _requestUserId, _requestClientId } = msg;
10
10
  console.log('[Agent] handleReadFile received:', { filePath, conversationId, workDir: msg.workDir });
11
11
  const conv = ctx.conversations.get(conversationId);
12
12
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
@@ -23,7 +23,9 @@ export async function handleReadFile(msg) {
23
23
  ctx.sendToServer({
24
24
  type: 'file_content',
25
25
  conversationId,
26
+ requestId,
26
27
  _requestUserId,
28
+ _requestClientId,
27
29
  filePath: resolved,
28
30
  requestedFilePath: filePath,
29
31
  content: buffer.toString('base64'),
@@ -57,7 +59,9 @@ export async function handleReadFile(msg) {
57
59
  ctx.sendToServer({
58
60
  type: 'file_content',
59
61
  conversationId,
62
+ requestId,
60
63
  _requestUserId,
64
+ _requestClientId,
61
65
  filePath: resolved,
62
66
  requestedFilePath: filePath,
63
67
  content,
@@ -68,7 +72,9 @@ export async function handleReadFile(msg) {
68
72
  ctx.sendToServer({
69
73
  type: 'file_content',
70
74
  conversationId,
75
+ requestId,
71
76
  _requestUserId,
77
+ _requestClientId,
72
78
  filePath,
73
79
  requestedFilePath: filePath,
74
80
  content: '',
@@ -78,7 +84,7 @@ export async function handleReadFile(msg) {
78
84
  }
79
85
 
80
86
  export async function handleWriteFile(msg) {
81
- const { conversationId, filePath, content, _requestUserId } = msg;
87
+ const { conversationId, filePath, content, requestId, _requestUserId, _requestClientId } = msg;
82
88
  const conv = ctx.conversations.get(conversationId);
83
89
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
84
90
 
@@ -89,16 +95,22 @@ export async function handleWriteFile(msg) {
89
95
  ctx.sendToServer({
90
96
  type: 'file_saved',
91
97
  conversationId,
98
+ requestId,
92
99
  _requestUserId,
100
+ _requestClientId,
93
101
  filePath: resolved,
102
+ requestedFilePath: filePath,
94
103
  success: true
95
104
  });
96
105
  } catch (e) {
97
106
  ctx.sendToServer({
98
107
  type: 'file_saved',
99
108
  conversationId,
109
+ requestId,
100
110
  _requestUserId,
111
+ _requestClientId,
101
112
  filePath,
113
+ requestedFilePath: filePath,
102
114
  success: false,
103
115
  error: e.message
104
116
  });
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
3
  import { Engine } from './engine.js';
4
4
  import { createRouter } from './routing/router.js';
5
+ import { createLoopGuard } from './routing/loop-guard.js';
5
6
  import { createCoordinator } from './sessions/coordinator.js';
6
7
  import { resolveMemberId } from './sessions/roster.js';
7
8
  import { sessionsRoot } from './sessions/session-crud.js';
@@ -10,6 +11,86 @@ import { loadSessionConfig, resolveSessionConfig } from './sessions/session-conf
10
11
  import { readVp } from './vp/vp-crud.js';
11
12
  import { COLLAB_TOOL_POLICY } from './tools/registry.js';
12
13
 
14
+ const MAX_ROUTE_FORWARD_RESULT_CHARS = 12_000;
15
+ const MAX_ROUTE_FORWARD_PROMPT_CHARS = 60_000;
16
+
17
+ function cleanString(value) {
18
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
19
+ }
20
+
21
+ function routeForwardCompletionFor(row) {
22
+ const envelope = row?.envelope;
23
+ const meta = envelope?.msg?.meta;
24
+ if (!meta || row?.stopReason === 'tool_handoff' || row?.stopReason === 'aborted') return null;
25
+ const parent = meta.injectedBy === 'route_forward_result'
26
+ && meta.routeForwardParent
27
+ && typeof meta.routeForwardParent === 'object'
28
+ ? meta.routeForwardParent
29
+ : null;
30
+ if (meta.injectedBy !== 'route_forward' && !parent) return null;
31
+ const sourceVpId = cleanString(parent?.sourceVpId ?? meta.senderVpId);
32
+ const forwardId = cleanString(parent?.forwardId ?? envelope?.msg?.id);
33
+ if (!sourceVpId || !forwardId || sourceVpId === row.vpId) return null;
34
+ const rawExpectedVpIds = parent?.expectedVpIds ?? meta.routeForwardExpectedTargets;
35
+ const expectedVpIds = Array.isArray(rawExpectedVpIds)
36
+ ? [...new Set(rawExpectedVpIds.map(cleanString).filter(Boolean))]
37
+ : [row.vpId];
38
+ if (!expectedVpIds.includes(row.vpId)) return null;
39
+ return {
40
+ forwardId,
41
+ sourceVpId,
42
+ sourceThreadId: cleanString(parent?.sourceThreadId ?? meta.sourceThreadId) || 'main',
43
+ causedBy: Array.isArray(parent?.causedBy ?? meta.causedBy)
44
+ ? (parent?.causedBy ?? meta.causedBy).slice()
45
+ : [],
46
+ dispatchErrors: [
47
+ ...(Array.isArray(parent?.dispatchErrors) ? parent.dispatchErrors : []),
48
+ ...(Array.isArray(meta.routeForwardDispatchErrors) ? meta.routeForwardDispatchErrors : []),
49
+ ],
50
+ truncatedAtFanOutCap: Boolean(parent?.truncatedAtFanOutCap)
51
+ || Boolean(meta.routeForwardTruncatedAtFanOutCap),
52
+ parentRouteForward: parent?.parentRouteForward
53
+ || (meta.injectedBy === 'route_forward' ? (meta.routeForwardParent || null) : null),
54
+ expectedVpIds,
55
+ vpId: row.vpId,
56
+ result: row.result || '',
57
+ error: row.error || null,
58
+ stopReason: row.stopReason || 'end_turn',
59
+ envelope,
60
+ };
61
+ }
62
+
63
+ function formatRouteForwardResult(aggregate, results) {
64
+ const sections = results.map((entry) => {
65
+ const rawBody = entry.error
66
+ ? `Error: ${entry.error.message || String(entry.error)}`
67
+ : (entry.result || '(no text returned)');
68
+ const body = rawBody.length > MAX_ROUTE_FORWARD_RESULT_CHARS
69
+ ? `${rawBody.slice(0, MAX_ROUTE_FORWARD_RESULT_CHARS)}\n[Result truncated]`
70
+ : rawBody;
71
+ return `[${entry.vpId} — ${entry.stopReason}]\n${body}`;
72
+ });
73
+ const notices = [];
74
+ if (aggregate.truncatedAtFanOutCap) {
75
+ notices.push('Warning: the requested fan-out was truncated at the Session limit.');
76
+ }
77
+ if (aggregate.dispatchErrors.length > 0) {
78
+ notices.push(`Dispatch errors: ${JSON.stringify(aggregate.dispatchErrors)}`);
79
+ }
80
+ const prompt = [
81
+ '[RouteForward result]',
82
+ notices.length > 0
83
+ ? 'The accepted delegated VP work has finished, but dispatch was partial. Continue the same user request using the results and warnings below.'
84
+ : 'The delegated VP work has finished. Continue the same user request using the result below.',
85
+ ...notices,
86
+ '',
87
+ ...sections,
88
+ ].join('\n');
89
+ return prompt.length > MAX_ROUTE_FORWARD_PROMPT_CHARS
90
+ ? `${prompt.slice(0, MAX_ROUTE_FORWARD_PROMPT_CHARS)}\n[Combined RouteForward results truncated]`
91
+ : prompt;
92
+ }
93
+
13
94
  function buildVpPersona(vpId, loaded) {
14
95
  const vp = readVp(vpId, { libDir: join(loaded.yeaftDir, 'virtual-persons') });
15
96
  if (!vp) return null;
@@ -79,6 +160,8 @@ export function createCliSessionRunner({
79
160
  // new row carries one durable causalRootId; the legacy ids remain fallbacks
80
161
  // for rows produced before that field existed.
81
162
  const rootOrderByIdentity = new Map();
163
+ const activeTurnContexts = new Map();
164
+ const routeForwardGuard = createLoopGuard();
82
165
  let nextRootOrder = 0;
83
166
  let closed = false;
84
167
 
@@ -107,6 +190,8 @@ export function createCliSessionRunner({
107
190
  const rootOrder = Number.isInteger(envelope?._cliRootOrder)
108
191
  ? envelope._cliRootOrder
109
192
  : null;
193
+ const inboundMessageId = cleanString(envelope?.msg?.id);
194
+ const includeCurrentRoot = envelope?.msg?.meta?.injectedBy === 'route_forward_result';
110
195
  // Engine.query() appends `prompt` itself. Exclude this root's durable user
111
196
  // row and every later root turn, regardless of where their assistant/tool
112
197
  // rows landed in the globally sequenced transcript. This preserves rows
@@ -115,6 +200,10 @@ export function createCliSessionRunner({
115
200
  const messages = loaded.conversationStore
116
201
  .loadSessionHistoryForVp(sessionId, vpId)
117
202
  .filter((message) => {
203
+ if (inboundMessageId
204
+ && (message?.id === inboundMessageId || message?.messageId === inboundMessageId)) {
205
+ return false;
206
+ }
118
207
  if (persistedUserClientMessageId
119
208
  && message?.role === 'user'
120
209
  && message.clientMessageId === persistedUserClientMessageId) return false;
@@ -129,11 +218,14 @@ export function createCliSessionRunner({
129
218
  } else if (typeof message?.turnId === 'string') {
130
219
  messageRootOrder = rootOrderByIdentity.get(message.turnId);
131
220
  }
132
- return !Number.isInteger(messageRootOrder) || messageRootOrder < rootOrder;
221
+ return !Number.isInteger(messageRootOrder)
222
+ || messageRootOrder < rootOrder
223
+ || (includeCurrentRoot && messageRootOrder === rootOrder);
133
224
  });
134
225
  const todos = [];
135
226
  let resultText = '';
136
227
  let failed = null;
228
+ let stopReason = null;
137
229
  const scopedCoordinator = {
138
230
  group: coordinator.group,
139
231
  ingest(input, opts) {
@@ -158,7 +250,7 @@ export function createCliSessionRunner({
158
250
  sessionMembers: meta.roster.slice(),
159
251
  sessionAnnouncement: meta.announcement || '',
160
252
  vpPersona: personaFactory(vpId, loaded),
161
- router: createRouter({ coordinator: scopedCoordinator }),
253
+ router: createRouter({ coordinator: scopedCoordinator, guard: routeForwardGuard }),
162
254
  inboundEnvelope: envelope,
163
255
  userAlreadyPersisted: true,
164
256
  causalRootId,
@@ -177,6 +269,8 @@ export function createCliSessionRunner({
177
269
  userEffort: options.modelEffort || null,
178
270
  };
179
271
 
272
+ const turnContext = envelope?._cliTurnContext;
273
+ turnContext?.activeEngines.add(engine);
180
274
  try {
181
275
  for await (const event of engine.query(queryOptions)) {
182
276
  if (event.type === 'text_delta') resultText += event.text || '';
@@ -185,6 +279,11 @@ export function createCliSessionRunner({
185
279
  ? event.error
186
280
  : new Error(String(event.error?.message || event.error || 'Unknown Engine error'));
187
281
  }
282
+ if (event.type === 'turn_end' && event.terminal && event.stopReason) {
283
+ stopReason = event.stopReason;
284
+ } else if (event.type === 'stop' && event.stopReason) {
285
+ stopReason = event.stopReason;
286
+ }
188
287
  await options.onEvent?.({ vpId, event, sessionId, turnId: queryOptions.vpTurnId });
189
288
  }
190
289
  } catch (error) {
@@ -195,20 +294,53 @@ export function createCliSessionRunner({
195
294
  turnId: queryOptions.vpTurnId,
196
295
  event: { type: 'error', error, retryable: false },
197
296
  });
297
+ } finally {
298
+ turnContext?.activeEngines.delete(engine);
198
299
  }
199
- return { vpId, result: resultText, error: failed };
300
+ return {
301
+ vpId,
302
+ result: resultText,
303
+ error: failed,
304
+ stopReason: failed ? 'error' : (stopReason || 'end_turn'),
305
+ envelope,
306
+ };
200
307
  };
201
308
 
202
309
  const enqueue = (vpId, envelope) => {
203
310
  if (closed) throw new Error('CLI Session runner is closed');
204
311
  const turnContext = envelope?._cliTurnContext;
205
312
  if (!turnContext) throw new Error('CLI Session envelope is missing its turn context');
206
- if (turnContext.claimedVpIds.has(vpId)) {
313
+ if (turnContext.cancellation.cancelled) {
314
+ return Promise.resolve({
315
+ vpId,
316
+ result: '',
317
+ error: null,
318
+ stopReason: 'aborted',
319
+ envelope,
320
+ });
321
+ }
322
+ const routeForwardResultId = cleanString(envelope?.msg?.meta?.routeForwardId);
323
+ const isRouteForwardResult = envelope?.msg?.meta?.injectedBy === 'route_forward_result'
324
+ && cleanString(envelope?.msg?.meta?.routeTargetVpId) === vpId
325
+ && routeForwardResultId
326
+ && turnContext.routeForwardReturnIds.has(routeForwardResultId);
327
+ if (turnContext.claimedVpIds.has(vpId) && !isRouteForwardResult) {
207
328
  return { ok: false, error: 'target_already_claimed' };
208
329
  }
209
- turnContext.claimedVpIds.add(vpId);
330
+ if (!isRouteForwardResult) turnContext.claimedVpIds.add(vpId);
210
331
  const previous = tails.get(vpId) || Promise.resolve();
211
- const task = previous.catch(() => {}).then(() => runEnvelope(vpId, envelope, turnContext.options));
332
+ const task = previous.catch(() => {}).then(() => {
333
+ if (turnContext.cancellation.cancelled) {
334
+ return {
335
+ vpId,
336
+ result: '',
337
+ error: null,
338
+ stopReason: 'aborted',
339
+ envelope,
340
+ };
341
+ }
342
+ return runEnvelope(vpId, envelope, turnContext.options);
343
+ });
212
344
  tails.set(vpId, task);
213
345
  pending.add(task);
214
346
  turnContext.tasks.push(task);
@@ -221,6 +353,56 @@ export function createCliSessionRunner({
221
353
 
222
354
  coordinator = createCoordinator(handle, { deliver: enqueue });
223
355
 
356
+ const enqueueRouteForwardReturns = (completed, turnContext) => {
357
+ if (turnContext.cancellation.cancelled) return;
358
+ for (const row of completed) {
359
+ const completion = routeForwardCompletionFor(row);
360
+ if (!completion) continue;
361
+ let aggregate = turnContext.routeForwardReturns.get(completion.forwardId);
362
+ if (!aggregate) {
363
+ aggregate = {
364
+ ...completion,
365
+ results: new Map(),
366
+ };
367
+ turnContext.routeForwardReturns.set(completion.forwardId, aggregate);
368
+ }
369
+ aggregate.results.set(completion.vpId, completion);
370
+ if (turnContext.routeForwardReturnIds.has(completion.forwardId)
371
+ || !aggregate.expectedVpIds.every(vpId => aggregate.results.has(vpId))) continue;
372
+
373
+ // A RouteForward can return to its source exactly once. The source
374
+ // remains claimed for the root turn, so this explicit, internally
375
+ // marked continuation is the only allowed re-entry path.
376
+ turnContext.routeForwardReturnIds.add(completion.forwardId);
377
+ const results = aggregate.expectedVpIds.map(vpId => aggregate.results.get(vpId));
378
+ const representative = results[0];
379
+ coordinator.ingest({
380
+ id: randomUUID(),
381
+ from: representative.vpId,
382
+ role: 'assistant',
383
+ text: formatRouteForwardResult(aggregate, results),
384
+ internal: true,
385
+ meta: {
386
+ synthetic: true,
387
+ injectedBy: 'route_forward_result',
388
+ routeTargetVpId: aggregate.sourceVpId,
389
+ senderVpId: representative.vpId,
390
+ sourceThreadId: aggregate.sourceThreadId,
391
+ routeForwardId: aggregate.forwardId,
392
+ ...(aggregate.parentRouteForward
393
+ ? { routeForwardParent: aggregate.parentRouteForward }
394
+ : {}),
395
+ causedBy: aggregate.causedBy,
396
+ routeForwardDispatchErrors: aggregate.dispatchErrors,
397
+ routeForwardTruncatedAtFanOutCap: aggregate.truncatedAtFanOutCap,
398
+ },
399
+ _cliRootOrder: representative.envelope?._cliRootOrder,
400
+ _cliCausalRootId: representative.envelope?._cliCausalRootId,
401
+ _cliTurnContext: turnContext,
402
+ });
403
+ }
404
+ };
405
+
224
406
  async function drain(tasks = pending) {
225
407
  const results = [];
226
408
  while (tasks.size > 0) {
@@ -263,12 +445,24 @@ export function createCliSessionRunner({
263
445
  explicit: routingIntent.explicit === true,
264
446
  });
265
447
  }
448
+ const messageId = randomUUID();
449
+ const cancellationId = cleanString(options.cancellationId) || messageId;
450
+ if (activeTurnContexts.has(cancellationId)) {
451
+ throw new Error(`CLI Session cancellation id ${cancellationId} is already active`);
452
+ }
266
453
  const turnContext = Object.freeze({
454
+ rootId: messageId,
455
+ cancellationId,
267
456
  options: Object.freeze({ ...options }),
268
457
  tasks: [],
269
458
  claimedVpIds: new Set(),
459
+ routeForwardReturns: new Map(),
460
+ routeForwardReturnIds: new Set(),
461
+ cancellation: { cancelled: false, reason: null },
462
+ activeEngines: new Set(),
270
463
  });
271
- const messageId = randomUUID();
464
+ activeTurnContexts.set(cancellationId, turnContext);
465
+ try {
272
466
  const rootOrder = nextRootOrder++;
273
467
  rootOrderByIdentity.set(messageId, rootOrder);
274
468
  // The shared user row is the durability boundary. Validate structured
@@ -314,14 +508,29 @@ export function createCliSessionRunner({
314
508
  while (cursor < turnContext.tasks.length) {
315
509
  const batch = turnContext.tasks.slice(cursor);
316
510
  cursor += batch.length;
317
- results.push(...await Promise.all(batch));
511
+ const completed = await Promise.all(batch);
512
+ results.push(...completed);
513
+ enqueueRouteForwardReturns(completed, turnContext);
318
514
  await Promise.resolve();
319
515
  }
320
516
  return { report, results };
517
+ } finally {
518
+ activeTurnContexts.delete(cancellationId);
519
+ }
321
520
  },
322
- abort(reason = 'user') {
521
+ abort(reason = 'user', options = {}) {
522
+ const cancellationId = cleanString(options?.cancellationId);
523
+ const contexts = cancellationId
524
+ ? [activeTurnContexts.get(cancellationId)].filter(Boolean)
525
+ : Array.from(activeTurnContexts.values());
526
+ const enginesToAbort = new Set();
527
+ for (const turnContext of contexts) {
528
+ turnContext.cancellation.cancelled = true;
529
+ turnContext.cancellation.reason = reason;
530
+ for (const engine of turnContext.activeEngines) enginesToAbort.add(engine);
531
+ }
323
532
  let count = 0;
324
- for (const engine of engines.values()) {
533
+ for (const engine of enginesToAbort) {
325
534
  if (engine.abort?.(reason)) count += 1;
326
535
  }
327
536
  return count;
@@ -13,8 +13,33 @@ 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 { normalizePluginConfig } from './plugins.js';
16
17
  import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
17
18
 
19
+ /**
20
+ * Read config.json before any public mutation. A missing file is a valid
21
+ * first-run state, but an existing malformed file, non-object root, or invalid
22
+ * Plugins schema must never be replaced by an unrelated Settings/MCP write.
23
+ * Otherwise the runtime's fail-closed policy could silently become inheritance
24
+ * (all capabilities enabled) on the next config reload.
25
+ *
26
+ * @param {string} configPath
27
+ * @returns {Record<string, unknown>}
28
+ * @throws {Error} when an existing config cannot be safely preserved
29
+ */
30
+ 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;
41
+ }
42
+
18
43
  /**
19
44
  * Read the LLM-relevant portion of config.json.
20
45
  *
@@ -119,15 +144,13 @@ export function updateLlmConfig(update, dir) {
119
144
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
120
145
  const configPath = join(root, 'config.json');
121
146
 
122
- // Read existing config (preserve non-LLM fields)
123
- let existing = {};
124
- if (existsSync(configPath)) {
125
- try {
126
- existing = JSON.parse(readFileSync(configPath, 'utf8'));
127
- } catch {
128
- // Start fresh if corrupt
129
- existing = {};
130
- }
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}` };
131
154
  }
132
155
 
133
156
  // Validate providers structure
@@ -265,14 +288,14 @@ export function updateYeaftSettings(update, dir) {
265
288
  }
266
289
  }
267
290
 
268
- // Read existing config (preserve LLM and other top-level fields).
269
- let existing = {};
270
- if (existsSync(configPath)) {
271
- try {
272
- existing = JSON.parse(readFileSync(configPath, 'utf8'));
273
- } catch {
274
- existing = {};
275
- }
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}` };
276
299
  }
277
300
 
278
301
  const prev = normaliseYeaftSection(existing.yeaft);
@@ -336,7 +359,12 @@ export function updateTelemetrySettings(update, dir) {
336
359
  }
337
360
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
338
361
  const configPath = join(root, 'config.json');
339
- const existing = readConfigJson(configPath);
362
+ let existing;
363
+ try {
364
+ existing = readConfigForWrite(configPath);
365
+ } catch (err) {
366
+ return { error: `Failed to read config.json: ${err?.message || err}` };
367
+ }
340
368
  const merged = normaliseTelemetrySection({
341
369
  ...(existing.telemetry && typeof existing.telemetry === 'object' ? existing.telemetry : {}),
342
370
  ...update,
@@ -427,13 +455,11 @@ export function updateSearchSettings(update, dir) {
427
455
  return { error: 'tavilyApiKey must be a string' };
428
456
  }
429
457
 
430
- let existing = {};
431
- if (existsSync(configPath)) {
432
- try {
433
- existing = JSON.parse(readFileSync(configPath, 'utf8'));
434
- } catch {
435
- existing = {};
436
- }
458
+ let existing;
459
+ try {
460
+ existing = readConfigForWrite(configPath);
461
+ } catch (err) {
462
+ return { error: `Failed to read config.json: ${err?.message || err}` };
437
463
  }
438
464
  const prev = (existing && typeof existing.search === 'object' && existing.search) || {};
439
465
  const merged = { ...prev };
@@ -500,6 +526,49 @@ export async function fetchTavilyUsage(dir) {
500
526
  }
501
527
  }
502
528
 
529
+ // ─── Agent plugin selection ────────────────────────────────────
530
+
531
+ /**
532
+ * Read the Agent-local plugin allowlists. Missing category fields mean
533
+ * inheritance (all discovered capabilities remain available).
534
+ */
535
+ export function getPluginConfig(dir) {
536
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
537
+ const configPath = join(root, 'config.json');
538
+ try {
539
+ const json = readConfigForWrite(configPath);
540
+ return { plugins: normalizePluginConfig(json.plugins) };
541
+ } catch (err) {
542
+ return { error: `Failed to read plugin config: ${err?.message || err}` };
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Persist Agent-local plugin allowlists without touching providers, MCP server
548
+ * definitions, or any other config.json field.
549
+ */
550
+ export function updatePluginConfig(plugins, dir) {
551
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
552
+ const configPath = join(root, 'config.json');
553
+ let normalized;
554
+ let existing;
555
+ try {
556
+ existing = readConfigForWrite(configPath);
557
+ 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}` };
568
+ }
569
+ return { plugins: normalized };
570
+ }
571
+
503
572
  // ─── MCP server config (mcpServers array in config.json) ──
504
573
 
505
574
  /**
@@ -579,24 +648,6 @@ function validateMcpServer(entry) {
579
648
  return null;
580
649
  }
581
650
 
582
- /**
583
- * Read existing config.json (silently start fresh on missing / corrupt).
584
- * Internal helper used by the MCP CRUD trio to share one parse path.
585
- *
586
- * @param {string} configPath
587
- * @returns {object}
588
- */
589
- function readConfigJson(configPath) {
590
- if (!existsSync(configPath)) return {};
591
- try {
592
- const raw = readFileSync(configPath, 'utf8');
593
- const json = JSON.parse(raw);
594
- return (json && typeof json === 'object') ? json : {};
595
- } catch {
596
- return {};
597
- }
598
- }
599
-
600
651
  /**
601
652
  * List MCP servers currently saved in config.json. Returns an array — empty
602
653
  * when none configured. Each entry is the normalised on-disk shape, NOT
@@ -609,12 +660,12 @@ export function listMcpServers(dir) {
609
660
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
610
661
  const configPath = join(root, 'config.json');
611
662
  try {
612
- const json = readConfigJson(configPath);
663
+ const json = readConfigForWrite(configPath);
613
664
  const raw = Array.isArray(json.mcpServers) ? json.mcpServers : [];
614
665
  const servers = raw.map(normaliseMcpServer).filter(Boolean);
615
666
  return { servers };
616
- } catch (e) {
617
- return { error: `Failed to read config.json: ${e.message}` };
667
+ } catch (err) {
668
+ return { error: `Failed to read config.json: ${err?.message || err}` };
618
669
  }
619
670
  }
620
671
 
@@ -634,7 +685,12 @@ export function upsertMcpServer(server, dir) {
634
685
 
635
686
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
636
687
  const configPath = join(root, 'config.json');
637
- const existing = readConfigJson(configPath);
688
+ let existing;
689
+ try {
690
+ existing = readConfigForWrite(configPath);
691
+ } catch (err) {
692
+ return { error: `Failed to read config.json: ${err?.message || err}` };
693
+ }
638
694
  const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
639
695
 
640
696
  const normalised = normaliseMcpServer(server);
@@ -677,7 +733,12 @@ export function removeMcpServer(name, dir) {
677
733
  const target = name.trim();
678
734
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
679
735
  const configPath = join(root, 'config.json');
680
- const existing = readConfigJson(configPath);
736
+ let existing;
737
+ try {
738
+ existing = readConfigForWrite(configPath);
739
+ } catch (err) {
740
+ return { error: `Failed to read config.json: ${err?.message || err}` };
741
+ }
681
742
  const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
682
743
  const next = list.filter(s => !(s && typeof s === 'object' && s.name === target));
683
744
  const removed = next.length !== list.length;