@yeaft/webchat-agent 0.1.934 → 0.1.936

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.
@@ -37,7 +37,7 @@ import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../yeaft/config-api.js';
39
39
  import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
40
- import { handleYeaftSessionSend, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager } from '../yeaft/web-bridge.js';
40
+ import { handleYeaftSessionSend, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager } from '../yeaft/web-bridge.js';
41
41
  import { startYeaftStatusRefresh, refreshYeaftStatus } from '../yeaft/status-cache.js';
42
42
 
43
43
  export async function handleMessage(msg) {
@@ -441,6 +441,27 @@ export async function handleMessage(msg) {
441
441
  break;
442
442
  }
443
443
 
444
+ // Yeaft MCP CRUD (Claude-Code-style Settings → MCP tab).
445
+ // Each wire op mutates ~/.yeaft/config.json `mcpServers` AND, when
446
+ // the session is alive, mirrors the change into `mcpManager` + hot-
447
+ // swaps the live `toolRegistry`. See handlers in web-bridge.js for
448
+ // the broadcast contract (`yeaft_mcp_updated`).
449
+ case 'yeaft_mcp_list':
450
+ handleYeaftMcpList(msg);
451
+ break;
452
+
453
+ case 'yeaft_mcp_add':
454
+ await handleYeaftMcpAdd(msg);
455
+ break;
456
+
457
+ case 'yeaft_mcp_remove':
458
+ await handleYeaftMcpRemove(msg);
459
+ break;
460
+
461
+ case 'yeaft_mcp_reload':
462
+ await handleYeaftMcpReload(msg);
463
+ break;
464
+
444
465
  // Yeaft — single conversation backed by the default session.
445
466
  //
446
467
  // Wire-alias scope: the `yeaft_group_chat` op (and its envelope
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.934",
3
+ "version": "0.1.936",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -376,3 +376,195 @@ export async function fetchTavilyUsage(dir) {
376
376
  }
377
377
  }
378
378
 
379
+ // ─── MCP server config (mcpServers array in config.json) ──
380
+
381
+ /**
382
+ * Server-name regex: lowercase letters, digits, underscore, dash. Matches
383
+ * what Claude Code accepts so config files are portable. Single source of
384
+ * truth for both add/update/remove validation.
385
+ */
386
+ const MCP_NAME_RE = /^[a-z0-9_-]+$/;
387
+
388
+ /**
389
+ * Normalise one MCP server entry on read. The on-disk shape that the
390
+ * MCPManager understands is `{ name, command, args?, env? }`. This pass
391
+ * strips unknown / non-string keys, coerces args to an array of strings,
392
+ * and forces env to a plain {string→string} object so the UI doesn't
393
+ * crash on a malformed handwritten config.
394
+ *
395
+ * @param {unknown} entry
396
+ * @returns {{ name: string, command: string, args: string[], env: Record<string,string> } | null}
397
+ */
398
+ function normaliseMcpServer(entry) {
399
+ if (!entry || typeof entry !== 'object') return null;
400
+ const e = /** @type {any} */ (entry);
401
+ const name = typeof e.name === 'string' ? e.name.trim() : '';
402
+ const command = typeof e.command === 'string' ? e.command.trim() : '';
403
+ if (!name || !command) return null;
404
+ const args = Array.isArray(e.args)
405
+ ? e.args.filter(a => typeof a === 'string')
406
+ : [];
407
+ /** @type {Record<string,string>} */
408
+ // Use a null-prototype object so a malicious config entry can't poison
409
+ // future lookups via `__proto__` / `constructor` / `prototype` keys. Even
410
+ // with the explicit skip list below, the null-prototype is the right
411
+ // baseline: env maps are plain key/value bags, they have no business
412
+ // owning prototype methods.
413
+ const env = Object.create(null);
414
+ if (e.env && typeof e.env === 'object' && !Array.isArray(e.env)) {
415
+ for (const [k, v] of Object.entries(e.env)) {
416
+ // Skip dangerous keys that would let attacker-controlled config
417
+ // pollute Object.prototype if env were ever spread / merged
418
+ // somewhere that doesn't expect a null-proto map.
419
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
420
+ if (typeof k === 'string' && typeof v === 'string') env[k] = v;
421
+ }
422
+ }
423
+ return { name, command, args, env };
424
+ }
425
+
426
+ /**
427
+ * Validate a server config for add/update. Returns null on success or a
428
+ * string error suitable for forwarding to the UI.
429
+ *
430
+ * @param {unknown} entry
431
+ * @returns {string|null}
432
+ */
433
+ function validateMcpServer(entry) {
434
+ if (!entry || typeof entry !== 'object') return 'server payload required';
435
+ const e = /** @type {any} */ (entry);
436
+ if (typeof e.name !== 'string' || !MCP_NAME_RE.test(e.name)) {
437
+ return 'server name must match /^[a-z0-9_-]+$/';
438
+ }
439
+ if (typeof e.command !== 'string' || !e.command.trim()) {
440
+ return 'server command is required';
441
+ }
442
+ if (e.args !== undefined && (!Array.isArray(e.args) || !e.args.every(a => typeof a === 'string'))) {
443
+ return 'server args must be an array of strings';
444
+ }
445
+ if (e.env !== undefined && (typeof e.env !== 'object' || e.env === null || Array.isArray(e.env))) {
446
+ return 'server env must be an object of string→string';
447
+ }
448
+ if (e.env && typeof e.env === 'object') {
449
+ for (const [k, v] of Object.entries(e.env)) {
450
+ if (typeof k !== 'string' || typeof v !== 'string') {
451
+ return 'server env entries must all be strings';
452
+ }
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+
458
+ /**
459
+ * Read existing config.json (silently start fresh on missing / corrupt).
460
+ * Internal helper used by the MCP CRUD trio to share one parse path.
461
+ *
462
+ * @param {string} configPath
463
+ * @returns {object}
464
+ */
465
+ function readConfigJson(configPath) {
466
+ if (!existsSync(configPath)) return {};
467
+ try {
468
+ const raw = readFileSync(configPath, 'utf8');
469
+ const json = JSON.parse(raw);
470
+ return (json && typeof json === 'object') ? json : {};
471
+ } catch {
472
+ return {};
473
+ }
474
+ }
475
+
476
+ /**
477
+ * List MCP servers currently saved in config.json. Returns an array — empty
478
+ * when none configured. Each entry is the normalised on-disk shape, NOT
479
+ * the runtime status (which lives on `mcpManager.status()`).
480
+ *
481
+ * @param {string} [dir]
482
+ * @returns {{ servers: Array<{ name: string, command: string, args: string[], env: Record<string,string> }> } | { error: string }}
483
+ */
484
+ export function listMcpServers(dir) {
485
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
486
+ const configPath = join(root, 'config.json');
487
+ try {
488
+ const json = readConfigJson(configPath);
489
+ const raw = Array.isArray(json.mcpServers) ? json.mcpServers : [];
490
+ const servers = raw.map(normaliseMcpServer).filter(Boolean);
491
+ return { servers };
492
+ } catch (e) {
493
+ return { error: `Failed to read config.json: ${e.message}` };
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Add or update an MCP server config entry. Match is by `name`. Returns
499
+ * the post-update list of servers (same shape as `listMcpServers`) plus
500
+ * the entry that was just written, so callers can pass it directly into
501
+ * `mcpManager.connect()` without re-reading the file.
502
+ *
503
+ * @param {{ name: string, command: string, args?: string[], env?: Record<string,string> }} server
504
+ * @param {string} [dir]
505
+ * @returns {{ servers: Array<object>, server: object } | { error: string }}
506
+ */
507
+ export function upsertMcpServer(server, dir) {
508
+ const err = validateMcpServer(server);
509
+ if (err) return { error: err };
510
+
511
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
512
+ const configPath = join(root, 'config.json');
513
+ const existing = readConfigJson(configPath);
514
+ const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
515
+
516
+ const normalised = normaliseMcpServer(server);
517
+ if (!normalised) return { error: 'invalid server payload' };
518
+
519
+ const idx = list.findIndex(s => s && typeof s === 'object' && s.name === normalised.name);
520
+ if (idx >= 0) {
521
+ list[idx] = normalised;
522
+ } else {
523
+ list.push(normalised);
524
+ }
525
+ existing.mcpServers = list;
526
+
527
+ try {
528
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
529
+ } catch (e) {
530
+ return { error: `Failed to write config.json: ${e.message}` };
531
+ }
532
+
533
+ return { servers: list.map(normaliseMcpServer).filter(Boolean), server: normalised };
534
+ }
535
+
536
+ /**
537
+ * Remove the MCP server config entry with the given name. Idempotent —
538
+ * removing a non-existent name returns the unchanged list with
539
+ * `removed: false`. This lets the UI safely call delete after a
540
+ * concurrent change without surfacing a spurious error.
541
+ *
542
+ * @param {string} name
543
+ * @param {string} [dir]
544
+ * @returns {{ servers: Array<object>, removed: boolean } | { error: string }}
545
+ */
546
+ export function removeMcpServer(name, dir) {
547
+ if (typeof name !== 'string' || !name.trim()) {
548
+ return { error: 'name required' };
549
+ }
550
+ // Trim once for the comparison too — without this, " github " from the
551
+ // wire would silently fail to delete "github" on disk because we'd be
552
+ // matching against the padded string.
553
+ const target = name.trim();
554
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
555
+ const configPath = join(root, 'config.json');
556
+ const existing = readConfigJson(configPath);
557
+ const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
558
+ const next = list.filter(s => !(s && typeof s === 'object' && s.name === target));
559
+ const removed = next.length !== list.length;
560
+ existing.mcpServers = next;
561
+
562
+ try {
563
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
564
+ } catch (e) {
565
+ return { error: `Failed to write config.json: ${e.message}` };
566
+ }
567
+
568
+ return { servers: next.map(normaliseMcpServer).filter(Boolean), removed };
569
+ }
570
+
@@ -1064,7 +1064,18 @@ export class ConversationStore {
1064
1064
 
1065
1065
  const startIdx = indexOfNthTurnFromEnd(visible, turnsLimit);
1066
1066
  const start = startIdx === -1 ? 0 : startIdx;
1067
- const messages = pairSanitize(visible.slice(start));
1067
+ // Visible history is for UI replay, not LLM context. The visible loader
1068
+ // already excludes tool-result rows, so running pairSanitize here can
1069
+ // incorrectly treat tool-using assistant replies as orphaned tool arcs and
1070
+ // drop/trim VP messages. Strip tool-call metadata instead and keep the
1071
+ // user-visible assistant text for the conversation pane.
1072
+ const messages = visible.slice(start).map(m => {
1073
+ if (m && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
1074
+ const { toolCalls, ...rest } = m;
1075
+ return rest;
1076
+ }
1077
+ return m;
1078
+ });
1068
1079
  const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
1069
1080
  const firstVisibleSeq = parseSeqFromId(visible[0].id);
1070
1081
  const hasMore = messages.length > 0
package/yeaft/engine.js CHANGED
@@ -2219,6 +2219,7 @@ export class Engine {
2219
2219
  // history replay can re-stamp them on reload.
2220
2220
  sessionId,
2221
2221
  threadId,
2222
+ vpId: this.#vpId,
2222
2223
  // Multi-VP fan-out (history-dedup): skip the user-row append
2223
2224
  // in stop-hooks when the orchestrator already wrote it once
2224
2225
  // for this turn. The hook still persists assistant + tool
package/yeaft/init.js CHANGED
@@ -5,14 +5,16 @@
5
5
  * Creates default config.md, MEMORY.md, and chat/index.md if missing.
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
9
- import { join } from 'path';
8
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, accessSync, constants } from 'fs';
9
+ import { join, dirname } from 'path';
10
10
  import { homedir } from 'os';
11
+ import { createHash } from 'crypto';
11
12
  // NOTE: migrateSessions runs at the end of initYeaftDir(). It collapses
12
13
  // legacy groups/ + chats/ + memory/{group,chat}/ into the unified sessions/
13
14
  // layout AND rewrites pre-rename per-message frontmatter (groupId → sessionId).
14
15
  // Idempotent via the `.yeaft-migration.done` sentinel file.
15
16
  import { migrateSessions } from './migrate/sessions.js';
17
+ import { bundledYeaftSkillsDir } from './skills.js';
16
18
 
17
19
  /**
18
20
  * Check if an error is a permission error (EACCES or EPERM).
@@ -157,7 +159,7 @@ This file tracks one Yeaft message-history mode.
157
159
  * Initialize the Yeaft data directory structure.
158
160
  *
159
161
  * @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
160
- * @returns {{ dir: string, created: string[], writable: boolean, warnings: string[] }} — The root dir, list of created paths, writability status, and any warnings
162
+ * @returns {{ dir: string, created: string[], writable: boolean, warnings: string[], seededSkills?: number }} — The root dir, list of created paths, writability status, any warnings, and how many bundled skills were seeded
161
163
  */
162
164
  export function initYeaftDir(dir) {
163
165
  const root = dir || DEFAULT_YEAFT_DIR;
@@ -215,6 +217,22 @@ export function initYeaftDir(dir) {
215
217
  created.push(mcpExamplePath);
216
218
  }
217
219
 
220
+ // Seed bundled yeaft-skills into the user dir (Claude-Code-style). After
221
+ // every successful boot the user dir mirrors the bundled set (modulo files
222
+ // the user has hand-edited — those are detected via the manifest sha and
223
+ // left alone). New bundled skills appear automatically; bundled-version
224
+ // upgrades flow through; user edits are never clobbered.
225
+ let seededSkills = 0;
226
+ try {
227
+ const seedResult = seedBundledSkills(join(root, 'skills'), warnings);
228
+ seededSkills = seedResult.copied;
229
+ if (seedResult.copied > 0 || seedResult.updated > 0) {
230
+ console.log(`[yeaft] seeded ${seedResult.copied} new + ${seedResult.updated} updated bundled skills (${seedResult.preserved} user edits preserved)`);
231
+ }
232
+ } catch (err) {
233
+ warnings.push(`Failed to seed bundled skills: ${err?.message || err}`);
234
+ }
235
+
218
236
  // NOTE: sessions migration runs synchronously here. It MUST complete before
219
237
  // any LLM request fires, because step 7 (per-message frontmatter rewrite)
220
238
  // is what lets the persist.js parser drop the legacy `groupId:` row alias.
@@ -230,5 +248,191 @@ export function initYeaftDir(dir) {
230
248
  console.warn(`[yeaft] session migration failed (continuing): ${err?.message || err}`);
231
249
  }
232
250
 
233
- return { dir: root, created, writable, warnings };
251
+ return { dir: root, created, writable, warnings, seededSkills };
252
+ }
253
+
254
+ // ─── Bundled skills seeding ───────────────────────────────
255
+
256
+ /**
257
+ * Filename inside the user skills directory that tracks which files we
258
+ * previously installed from the bundled package, keyed by their sha256.
259
+ * On re-seed, files whose CURRENT on-disk content matches a manifest entry
260
+ * are considered "still bundled" and can be safely overwritten if the
261
+ * bundled version has changed. Files whose sha doesn't match the manifest
262
+ * are treated as user-modified and left alone — Claude Code uses the same
263
+ * "user edits win" rule.
264
+ */
265
+ const SEED_MANIFEST_FILE = '.bundled-manifest.json';
266
+
267
+ /**
268
+ * sha256 of a string, hex encoded.
269
+ * @param {string} content
270
+ * @returns {string}
271
+ */
272
+ function sha256(content) {
273
+ return createHash('sha256').update(content, 'utf8').digest('hex');
274
+ }
275
+
276
+ /**
277
+ * Recursively walk a directory and yield relative file paths.
278
+ * @param {string} root
279
+ * @param {string} [sub]
280
+ * @returns {string[]}
281
+ */
282
+ function walkFiles(root, sub = '') {
283
+ const dir = sub ? join(root, sub) : root;
284
+ if (!existsSync(dir)) return [];
285
+ let entries;
286
+ try {
287
+ entries = readdirSync(dir, { withFileTypes: true });
288
+ } catch {
289
+ return [];
290
+ }
291
+ const out = [];
292
+ for (const entry of entries) {
293
+ const rel = sub ? join(sub, entry.name) : entry.name;
294
+ if (entry.isFile()) {
295
+ out.push(rel);
296
+ } else if (entry.isDirectory()) {
297
+ out.push(...walkFiles(root, rel));
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /**
304
+ * Read a tiny JSON file and return parsed value, or `{}` on missing/broken.
305
+ * @param {string} filePath
306
+ * @returns {Record<string, string>}
307
+ */
308
+ function readManifest(filePath) {
309
+ try {
310
+ if (!existsSync(filePath)) return {};
311
+ const raw = readFileSync(filePath, 'utf8');
312
+ const parsed = JSON.parse(raw);
313
+ return (parsed && typeof parsed === 'object') ? parsed : {};
314
+ } catch {
315
+ return {};
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Seed bundled `yeaft-skills` into a user skills directory.
321
+ *
322
+ * Behavior (Claude-Code style):
323
+ * - Every file under the bundled dir is mirrored to the user dir at the
324
+ * same relative path.
325
+ * - If target doesn't exist → copy verbatim, record sha in manifest.
326
+ * - If target exists AND its current sha matches the manifest entry → the
327
+ * file is "still the version we installed", so overwrite with the new
328
+ * bundled version (= picks up a bundled upgrade) and refresh the sha.
329
+ * - If target exists AND its sha differs from manifest → the user edited
330
+ * it. Leave it alone. Do NOT touch the manifest (so future runs still
331
+ * see the divergence).
332
+ * - Manifest is per-relative-path → sha256 of the BUNDLED version we last
333
+ * installed at that path.
334
+ *
335
+ * Re-runs are idempotent: stable state on disk + manifest produces no
336
+ * writes on the second pass.
337
+ *
338
+ * @param {string} userSkillsDir — absolute path to user-tier skills dir (e.g. ~/.yeaft/skills)
339
+ * @param {string[]} warnings — array to push warnings into
340
+ * @returns {{ copied: number, updated: number, preserved: number, skipped: number }}
341
+ */
342
+ export function seedBundledSkills(userSkillsDir, warnings = []) {
343
+ const bundled = bundledYeaftSkillsDir();
344
+ if (!bundled) {
345
+ return { copied: 0, updated: 0, preserved: 0, skipped: 0 };
346
+ }
347
+
348
+ // Ensure user dir exists (no-op if already there from SUBDIRS).
349
+ if (!existsSync(userSkillsDir)) {
350
+ if (!safeMkdir(userSkillsDir, warnings)) {
351
+ return { copied: 0, updated: 0, preserved: 0, skipped: 0 };
352
+ }
353
+ }
354
+
355
+ const manifestPath = join(userSkillsDir, SEED_MANIFEST_FILE);
356
+ const manifest = readManifest(manifestPath);
357
+ const nextManifest = { ...manifest };
358
+
359
+ let copied = 0;
360
+ let updated = 0;
361
+ let preserved = 0;
362
+ let skipped = 0;
363
+
364
+ const files = walkFiles(bundled);
365
+ for (const rel of files) {
366
+ const sourcePath = join(bundled, rel);
367
+ const targetPath = join(userSkillsDir, rel);
368
+
369
+ let bundledContent;
370
+ try {
371
+ bundledContent = readFileSync(sourcePath, 'utf8');
372
+ } catch (err) {
373
+ warnings.push(`Cannot read bundled skill ${rel}: ${err.message}`);
374
+ skipped += 1;
375
+ continue;
376
+ }
377
+ const bundledSha = sha256(bundledContent);
378
+
379
+ if (!existsSync(targetPath)) {
380
+ // First-time install — copy.
381
+ if (!safeMkdir(dirname(targetPath), warnings)) {
382
+ skipped += 1;
383
+ continue;
384
+ }
385
+ safeWriteFile(targetPath, bundledContent, warnings);
386
+ if (existsSync(targetPath)) {
387
+ nextManifest[rel] = bundledSha;
388
+ copied += 1;
389
+ } else {
390
+ skipped += 1;
391
+ }
392
+ continue;
393
+ }
394
+
395
+ // Target exists. Read it, sha it, decide.
396
+ let currentContent;
397
+ try {
398
+ currentContent = readFileSync(targetPath, 'utf8');
399
+ } catch (err) {
400
+ warnings.push(`Cannot read existing skill ${rel}: ${err.message}`);
401
+ skipped += 1;
402
+ continue;
403
+ }
404
+ const currentSha = sha256(currentContent);
405
+
406
+ if (currentSha === bundledSha) {
407
+ // Already up to date — no write, but make sure the manifest knows.
408
+ nextManifest[rel] = bundledSha;
409
+ continue;
410
+ }
411
+
412
+ const manifestSha = manifest[rel];
413
+ if (manifestSha && manifestSha === currentSha) {
414
+ // The on-disk file is the version WE installed; user hasn't touched
415
+ // it. The bundled version has changed (we passed the previous
416
+ // currentSha === bundledSha check), so apply the upgrade.
417
+ safeWriteFile(targetPath, bundledContent, warnings);
418
+ nextManifest[rel] = bundledSha;
419
+ updated += 1;
420
+ continue;
421
+ }
422
+
423
+ // Either: (a) no manifest entry (= file pre-existed before we tracked
424
+ // it) or (b) manifest sha doesn't match current sha (= user edited).
425
+ // Either way the file is user-owned now — leave it alone, don't
426
+ // overwrite, don't touch the manifest (so divergence stays visible).
427
+ preserved += 1;
428
+ }
429
+
430
+ // Write manifest only if it actually changed (avoids needless disk
431
+ // writes when nothing new happened).
432
+ const manifestChanged = JSON.stringify(manifest) !== JSON.stringify(nextManifest);
433
+ if (manifestChanged) {
434
+ safeWriteFile(manifestPath, JSON.stringify(nextManifest, null, 2) + '\n', warnings);
435
+ }
436
+
437
+ return { copied, updated, preserved, skipped };
234
438
  }
package/yeaft/session.js CHANGED
@@ -21,6 +21,7 @@ import { ConversationStore, setDefaultRecentTurnsLimit } from './conversation/pe
21
21
  import { SkillManager, createSkillManager } from './skills.js';
22
22
  import { MCPManager } from './mcp.js';
23
23
  import { createFullRegistry } from './tools/index.js';
24
+ import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
24
25
  import { Engine } from './engine.js';
25
26
  import { Compactor } from './compact/compactor.js';
26
27
  import { resolveContextWindow } from './models.js';
@@ -369,10 +370,19 @@ export async function loadSession(options = {}) {
369
370
  // ─── 6. Load skills ────────────────────────────────────
370
371
  let skillManager;
371
372
  if (skipSkills) {
372
- skillManager = new SkillManager(yeaftDir);
373
+ // Pass the literal user-tier dir (matches the normal branch's tier 2)
374
+ // so any save/remove calls land in the same place users expect. New
375
+ // `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
376
+ skillManager = new SkillManager(join(yeaftDir, 'skills'));
373
377
  // Don't call .load() — empty skill manager
374
378
  } else {
375
- skillManager = createSkillManager(yeaftDir);
379
+ // Pass the agent's current working directory as the project tier root.
380
+ // Per-session/per-group workdirs override at tool-execution time via
381
+ // ToolContext.cwd; for the SYSTEM-PROMPT skill set we just use the
382
+ // agent process cwd, which is the common case when an agent is
383
+ // launched inside a project the user wants project-tier skills for.
384
+ const projectTierRoot = process.cwd();
385
+ skillManager = createSkillManager(yeaftDir, projectTierRoot);
376
386
  }
377
387
 
378
388
  // ─── 7. Connect MCP servers ────────────────────────────
@@ -392,6 +402,20 @@ export async function loadSession(options = {}) {
392
402
  toolRegistry.register(tool);
393
403
  }
394
404
 
405
+ // Register flattened MCP tools (one ToolDef per MCP tool, named
406
+ // `mcp__<server>__<tool>` per Claude Code's convention). This replaces
407
+ // the legacy mcp_list_tools / mcp_call_tool meta-tools — the LLM now
408
+ // calls MCP tools directly in a single turn, no discovery dance.
409
+ // Re-built and re-registered on every connect/disconnect via
410
+ // `toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools)`
411
+ // which is invoked from the MCP web-bridge handlers.
412
+ if (mcpManager.hasServers) {
413
+ const flattened = buildMcpFlattenedTools(mcpManager);
414
+ for (const tool of flattened) {
415
+ toolRegistry.register(tool);
416
+ }
417
+ }
418
+
395
419
  // ─── 9. Create engine (wires everything) ───────────────
396
420
  // Tool-call usage statistics: persisted to <yeaftDir>/stats/tool-usage.json.
397
421
  // Loaded synchronously at boot so the first turn already sees prior counts.