@yeaft/webchat-agent 1.0.443 → 1.0.445

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.443",
3
+ "version": "1.0.445",
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",
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { promisify } from 'node:util';
4
+ import { resolveWindowsNpmCliPath } from './upgrade-command.js';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export const YEAFT_SKILLS_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
9
+
10
+ export function execHiddenFileAsync(command, args = [], options = {}) {
11
+ return execFileAsync(command, args, {
12
+ ...options,
13
+ shell: false,
14
+ windowsHide: true,
15
+ });
16
+ }
17
+
18
+ export function resolveNpmInvocation({
19
+ platform = process.platform,
20
+ nodePath = process.execPath,
21
+ resolveWindowsNpmCliPathFn = resolveWindowsNpmCliPath,
22
+ } = {}) {
23
+ if (platform === 'win32') {
24
+ const npmCliPath = resolveWindowsNpmCliPathFn(nodePath);
25
+ if (!npmCliPath) {
26
+ throw new Error('npm JavaScript CLI entry point could not be resolved');
27
+ }
28
+ return { command: nodePath, argsPrefix: [npmCliPath] };
29
+ }
30
+ return { command: 'npm', argsPrefix: [] };
31
+ }
32
+
33
+ export function shouldRunPeriodicCheck(
34
+ markerPath,
35
+ intervalMs = YEAFT_SKILLS_UPDATE_CHECK_INTERVAL_MS,
36
+ now = Date.now,
37
+ ) {
38
+ try {
39
+ const checkedAt = JSON.parse(readFileSync(markerPath, 'utf8'))?.checkedAt;
40
+ return !Number.isFinite(checkedAt) || now() - checkedAt >= intervalMs;
41
+ } catch {
42
+ return true;
43
+ }
44
+ }
45
+
46
+ export function writePeriodicCheckMarker(markerPath, now = Date.now) {
47
+ const checkedAt = now();
48
+ writeFileSync(markerPath, JSON.stringify({
49
+ checkedAt,
50
+ checkedAtIso: new Date(checkedAt).toISOString(),
51
+ }, null, 2));
52
+ }
package/yeaft/plugins.js CHANGED
@@ -133,16 +133,30 @@ export function buildPluginCatalog({ toolRegistry, skillManager, mcpConfig, mcpM
133
133
  .sort((a, b) => a.label.localeCompare(b.label))
134
134
  : [];
135
135
 
136
- const skills = typeof skillManager?.list === 'function'
137
- ? skillManager.list()
138
- .map(skill => ({
139
- id: skill.name,
140
- label: skill.name,
141
- description: skill.description || '',
142
- category: skill.category || null,
143
- }))
144
- .sort((a, b) => a.label.localeCompare(b.label))
145
- : [];
136
+ // Runtime authorization is keyed by Skill name, so this catalog must expose
137
+ // only the effective layered entry per name. Source-level rows belong to the
138
+ // management surface below; otherwise a project override would render two
139
+ // allowlist toggles that both mutate the same name.
140
+ const skills = (typeof skillManager?.list === 'function' ? skillManager.list() : [])
141
+ .map(skill => ({
142
+ id: skill.name,
143
+ label: skill.name,
144
+ description: skill.description || '',
145
+ category: skill.category || null,
146
+ tier: skill.tier || null,
147
+ }))
148
+ .sort((a, b) => a.label.localeCompare(b.label));
149
+
150
+ const skillSources = (typeof skillManager?.listSources === 'function' ? skillManager.listSources() : [])
151
+ .map(skill => ({
152
+ id: skill.id,
153
+ name: skill.name,
154
+ label: skill.name,
155
+ description: skill.description || '',
156
+ tier: skill.tier || null,
157
+ managed: skill.managed === true,
158
+ }))
159
+ .sort((a, b) => a.label.localeCompare(b.label) || String(a.id).localeCompare(String(b.id)));
146
160
 
147
161
  const statusByName = new Map((mcpManager?.status?.() || [])
148
162
  .map(status => [status.name, status]));
@@ -166,5 +180,5 @@ export function buildPluginCatalog({ toolRegistry, skillManager, mcpConfig, mcpM
166
180
  })
167
181
  .sort((a, b) => a.label.localeCompare(b.label));
168
182
 
169
- return { tools, skills, mcpServers };
183
+ return { tools, skills, skillSources, mcpServers };
170
184
  }
package/yeaft/skills.js CHANGED
@@ -55,7 +55,7 @@
55
55
  * Reference: yeaft-yeaft-design.md §8, yeaft-yeaft-core-systems.md
56
56
  */
57
57
 
58
- import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync, realpathSync } from 'fs';
58
+ import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync, lstatSync, realpathSync } from 'fs';
59
59
  import { join, basename, sep, dirname, resolve, delimiter } from 'path';
60
60
  import { platform, homedir } from 'os';
61
61
  import { fileURLToPath } from 'url';
@@ -247,6 +247,189 @@ function pathIsInside(childPath, parentPath) {
247
247
  return child === parent || child.startsWith(parent + sep);
248
248
  }
249
249
 
250
+ const MANAGED_SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
251
+ const MAX_MANAGED_SKILL_TEXT_LENGTH = 64 * 1024;
252
+
253
+ function normalizeManagedSkillInput(input = {}) {
254
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
255
+ if (!MANAGED_SKILL_NAME_RE.test(name)) {
256
+ throw new Error('skill name must start with a letter or digit and use only letters, digits, dots, dashes, or underscores');
257
+ }
258
+ const description = typeof input.description === 'string' ? input.description.trim() : '';
259
+ const trigger = typeof input.trigger === 'string' ? input.trigger.trim() : '';
260
+ const content = typeof input.content === 'string' ? input.content.trim() : '';
261
+ if (!description) throw new Error('skill description is required');
262
+ if (!content) throw new Error('skill instructions are required');
263
+ if (/[\r\n]/.test(description) || /[\r\n]/.test(trigger)) {
264
+ throw new Error('skill description and trigger must be single-line text');
265
+ }
266
+ if ([description, trigger, content].some(value => value.length > MAX_MANAGED_SKILL_TEXT_LENGTH)) {
267
+ throw new Error('skill fields exceed the maximum allowed length');
268
+ }
269
+ return { name, description, trigger, content, mode: 'both' };
270
+ }
271
+
272
+ function assertNativeSkillRoot(rootDir, { create = true } = {}) {
273
+ const root = resolve(rootDir || '');
274
+ if (!root || root === resolve('.')) throw new Error('skill root is required');
275
+ if (existsSync(root)) {
276
+ const stat = lstatSync(root);
277
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('skill root must be a non-symbolic-link directory');
278
+ } else if (create) {
279
+ mkdirSync(root, { recursive: true });
280
+ } else {
281
+ return null;
282
+ }
283
+ try {
284
+ return realpathSync(root);
285
+ } catch {
286
+ throw new Error('skill root could not be resolved safely');
287
+ }
288
+ }
289
+
290
+ function managedSkillFilePath(rootDir, name, options) {
291
+ if (!MANAGED_SKILL_NAME_RE.test(name)) throw new Error('invalid skill name');
292
+ const root = assertNativeSkillRoot(rootDir, options);
293
+ if (!root) return null;
294
+ const filePath = resolve(root, `${name}.md`);
295
+ if (!pathIsInside(filePath, root) || filePath === root) throw new Error('invalid skill path');
296
+ if (existsSync(filePath)) {
297
+ let stat;
298
+ try {
299
+ stat = lstatSync(filePath);
300
+ } catch {
301
+ throw new Error('skill path could not be inspected safely');
302
+ }
303
+ if (stat.isSymbolicLink()) throw new Error('skill path must not be a symbolic link');
304
+ }
305
+ return filePath;
306
+ }
307
+
308
+ function isRemovableNativeSkill(skillPath, rootDir) {
309
+ if (!skillPath || !rootDir || !pathIsInside(skillPath, rootDir)) return false;
310
+ try {
311
+ const stat = lstatSync(skillPath);
312
+ return stat.isFile() && !stat.isSymbolicLink() && skillPath.toLowerCase().endsWith('.md');
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Create a native Yeaft single-file skill within an explicitly selected scope.
320
+ * This deliberately does not edit bundled or borrowed Claude/Codex sources.
321
+ *
322
+ * @param {string} rootDir
323
+ * @param {{ name: string, description: string, trigger?: string, content: string }} input
324
+ * @returns {{ name: string, path: string }}
325
+ */
326
+ export function createManagedSkill(rootDir, input) {
327
+ const skill = normalizeManagedSkillInput(input);
328
+ const filePath = managedSkillFilePath(rootDir, skill.name);
329
+ if (existsSync(filePath)) throw new Error(`skill "${skill.name}" already exists in this scope`);
330
+ writeFileSync(filePath, serializeSkill(skill), { encoding: 'utf8', flag: 'wx' });
331
+ return { name: skill.name, path: filePath };
332
+ }
333
+
334
+ /**
335
+ * Remove only a native single-file skill from the selected Yeaft scope.
336
+ * Directory skills and borrowed/project compatibility roots are intentionally
337
+ * left untouched because they may own references and unrelated project files.
338
+ *
339
+ * @param {string} rootDir
340
+ * @param {string} rawName
341
+ * @returns {{ name: string, removed: boolean }}
342
+ */
343
+ export function removeManagedSkill(rootDir, rawName) {
344
+ const name = typeof rawName === 'string' ? rawName.trim() : '';
345
+ const root = assertNativeSkillRoot(rootDir, { create: false });
346
+ if (!root) return { name, removed: false };
347
+ const filePath = managedSkillFilePath(root, name, { create: false });
348
+ if (!filePath || !existsSync(filePath)) return { name, removed: false };
349
+ if (!isRemovableNativeSkill(filePath, root)) {
350
+ throw new Error('only native single-file skills can be removed here');
351
+ }
352
+ unlinkSync(filePath);
353
+ return { name, removed: true };
354
+ }
355
+
356
+ function managedProjectDirectory(parent, name, { create }) {
357
+ const candidate = join(parent, name);
358
+ if (!pathIsInside(candidate, parent)) throw new Error('invalid managed project Skill path');
359
+ if (!existsSync(candidate)) {
360
+ if (!create) return null;
361
+ // Create a single verified segment at a time. Recursive mkdir would follow
362
+ // a repository-controlled `.yeaft` link before we could inspect it.
363
+ mkdirSync(candidate);
364
+ }
365
+ let stat;
366
+ try {
367
+ stat = lstatSync(candidate);
368
+ } catch {
369
+ throw new Error('managed project Skill path could not be inspected safely');
370
+ }
371
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
372
+ throw new Error('managed project Skill path must not contain a symbolic link');
373
+ }
374
+ let canonical;
375
+ try {
376
+ canonical = realpathSync(candidate);
377
+ } catch {
378
+ throw new Error('managed project Skill path could not be resolved safely');
379
+ }
380
+ if (!pathIsInside(canonical, parent) || canonical === parent) {
381
+ throw new Error('managed project Skill path escapes the project directory');
382
+ }
383
+ return canonical;
384
+ }
385
+
386
+ /**
387
+ * Resolve `<canonical workDir>/.yeaft/skills` without following any
388
+ * repository-controlled path segment. `workDir` itself is canonicalized first,
389
+ * so a configured workspace alias still identifies its real project root.
390
+ *
391
+ * @param {string} workDir
392
+ * @param {{ create?: boolean }} [options]
393
+ * @returns {string|null}
394
+ */
395
+ export function resolveManagedProjectSkillRoot(workDir, { create = true } = {}) {
396
+ const lexicalWorkDir = resolve(workDir || '');
397
+ if (!workDir || lexicalWorkDir === resolve('.')) {
398
+ throw new Error('project directory is required for project-level skills');
399
+ }
400
+ let projectRoot;
401
+ try {
402
+ projectRoot = realpathSync(lexicalWorkDir);
403
+ } catch {
404
+ throw new Error('project directory could not be resolved safely');
405
+ }
406
+ let projectStat;
407
+ try {
408
+ projectStat = lstatSync(projectRoot);
409
+ } catch {
410
+ throw new Error('project directory could not be inspected safely');
411
+ }
412
+ if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) {
413
+ throw new Error('project directory must be a non-symbolic-link directory');
414
+ }
415
+ const yeaftRoot = managedProjectDirectory(projectRoot, '.yeaft', { create });
416
+ if (!yeaftRoot) return null;
417
+ return managedProjectDirectory(yeaftRoot, 'skills', { create });
418
+ }
419
+
420
+ /** Create a native Skill in a verified project-level Yeaft root. */
421
+ export function createManagedProjectSkill(workDir, input) {
422
+ const root = resolveManagedProjectSkillRoot(workDir, { create: true });
423
+ return createManagedSkill(root, input);
424
+ }
425
+
426
+ /** Remove a native Skill from a verified project-level Yeaft root. */
427
+ export function removeManagedProjectSkill(workDir, name) {
428
+ const root = resolveManagedProjectSkillRoot(workDir, { create: false });
429
+ if (!root) return { name: typeof name === 'string' ? name.trim() : '', removed: false };
430
+ return removeManagedSkill(root, name);
431
+ }
432
+
250
433
  function shouldIgnorePath(candidatePath, ignorePaths) {
251
434
  return ignorePaths.some(ignorePath => pathIsInside(candidatePath, ignorePath));
252
435
  }
@@ -333,8 +516,83 @@ function discoverSkills(rootDir, subPath = '', opts = {}) {
333
516
  return { skills, errors };
334
517
  }
335
518
 
519
+ function secureWorkspacePath(workspaceRoot, relativePath, type) {
520
+ const lexicalRoot = resolve(workspaceRoot);
521
+ const lexicalTarget = resolve(lexicalRoot, relativePath);
522
+ if (!pathIsInside(lexicalTarget, lexicalRoot) || lexicalTarget === lexicalRoot) return null;
523
+
524
+ let canonicalCurrent;
525
+ let expectedCurrent;
526
+ let current = lexicalRoot;
527
+ try {
528
+ const rootStat = lstatSync(current);
529
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
530
+ canonicalCurrent = realpathSync(current);
531
+ expectedCurrent = canonicalCurrent;
532
+ for (const component of relativePath.split(/[\\/]+/)) {
533
+ if (!component || component === '.' || component === '..') return null;
534
+ current = join(current, component);
535
+ expectedCurrent = join(expectedCurrent, component);
536
+ const stat = lstatSync(current);
537
+ if (stat.isSymbolicLink() || realpathSync(current) !== expectedCurrent) return null;
538
+ if (current !== lexicalTarget && !stat.isDirectory()) return null;
539
+ if (current === lexicalTarget && ((type === 'directory' && !stat.isDirectory()) || (type === 'file' && !stat.isFile()))) {
540
+ return null;
541
+ }
542
+ }
543
+ return current;
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+
549
+ function listSecureWorkspaceDirectory(workspaceRoot, relativePath) {
550
+ const dir = secureWorkspacePath(workspaceRoot, relativePath, 'directory');
551
+ if (!dir) return null;
552
+ try {
553
+ return readdirSync(dir, { withFileTypes: true }).map(entry => ({
554
+ name: entry.name,
555
+ isFile: entry.isFile(),
556
+ isDirectory: entry.isDirectory(),
557
+ isSymbolicLink: entry.isSymbolicLink() || entry.isBlockDevice() || entry.isCharacterDevice()
558
+ || entry.isFIFO() || entry.isSocket(),
559
+ }));
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ function readSecureWorkspaceFile(workspaceRoot, relativePath) {
566
+ const file = secureWorkspacePath(workspaceRoot, relativePath, 'file');
567
+ if (!file) return null;
568
+ try {
569
+ if (lstatSync(file).size > MAX_PROJECT_SKILL_BYTES) {
570
+ return { truncated: true, buffer: Buffer.alloc(0) };
571
+ }
572
+ const buffer = readFileSync(file);
573
+ if (secureWorkspacePath(workspaceRoot, relativePath, 'file') !== file) return null;
574
+ return buffer.length > MAX_PROJECT_SKILL_BYTES
575
+ ? { truncated: true, buffer: Buffer.alloc(0) }
576
+ : { truncated: false, buffer };
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+
582
+ function listSecureWorkspaceEntries(workspaceRoot, relativePath) {
583
+ const entries = listWorkspaceDirectory(workspaceRoot, relativePath);
584
+ if (entries || process.platform === 'linux') return entries;
585
+ return listSecureWorkspaceDirectory(workspaceRoot, relativePath);
586
+ }
587
+
588
+ function readSecureWorkspaceSkillFile(workspaceRoot, relativePath) {
589
+ const read = readWorkspaceFile(workspaceRoot, relativePath, { maxBytes: MAX_PROJECT_SKILL_BYTES });
590
+ if (read || process.platform === 'linux') return read;
591
+ return readSecureWorkspaceFile(workspaceRoot, relativePath);
592
+ }
593
+
336
594
  function listWorkspaceSkillFiles(workspaceRoot, relativeDir) {
337
- return (listWorkspaceDirectory(workspaceRoot, relativeDir) || [])
595
+ return (listSecureWorkspaceEntries(workspaceRoot, relativeDir) || [])
338
596
  .filter(entry => entry.isFile && !entry.isSymbolicLink)
339
597
  .map(entry => entry.name);
340
598
  }
@@ -343,15 +601,16 @@ function discoverWorkspaceSkills(workspaceRoot, skillsRelativeRoot, subPath = ''
343
601
  const skills = [];
344
602
  const errors = [];
345
603
  const relativeDir = subPath ? join(skillsRelativeRoot, subPath) : skillsRelativeRoot;
346
- const entries = listWorkspaceDirectory(workspaceRoot, relativeDir);
604
+ const entries = listSecureWorkspaceEntries(workspaceRoot, relativeDir);
347
605
  if (!entries) return { skills, errors };
606
+ const readFile = relativePath => readSecureWorkspaceSkillFile(workspaceRoot, relativePath);
348
607
 
349
608
  for (const entry of entries) {
350
609
  if (entry.isSymbolicLink) continue;
351
610
  const relPath = subPath ? join(subPath, entry.name) : entry.name;
352
611
  const workspacePath = join(skillsRelativeRoot, relPath);
353
612
  if (entry.isFile && entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
354
- const read = readWorkspaceFile(workspaceRoot, workspacePath, { maxBytes: MAX_PROJECT_SKILL_BYTES });
613
+ const read = readFile(workspacePath);
355
614
  if (!read || read.truncated) continue;
356
615
  const skill = parseSkill(read.buffer.toString('utf8'), entry.name);
357
616
  if (!skill?.name) {
@@ -370,13 +629,13 @@ function discoverWorkspaceSkills(workspaceRoot, skillsRelativeRoot, subPath = ''
370
629
  }
371
630
  if (!entry.isDirectory) continue;
372
631
 
373
- const childEntries = listWorkspaceDirectory(workspaceRoot, workspacePath);
632
+ const childEntries = listSecureWorkspaceEntries(workspaceRoot, workspacePath);
374
633
  if (!childEntries) continue;
375
634
  const skillFile = childEntries.find(child => child.name === 'SKILL.md');
376
635
  if (skillFile) {
377
636
  if (!skillFile.isFile || skillFile.isSymbolicLink) continue;
378
637
  const skillRelativePath = join(workspacePath, 'SKILL.md');
379
- const read = readWorkspaceFile(workspaceRoot, skillRelativePath, { maxBytes: MAX_PROJECT_SKILL_BYTES });
638
+ const read = readFile(skillRelativePath);
380
639
  if (!read || read.truncated) continue;
381
640
  const skill = parseSkill(read.buffer.toString('utf8'), entry.name);
382
641
  if (!skill?.name) {
@@ -646,7 +905,7 @@ export class SkillManager {
646
905
  * surfaced for historic YAML compatibility.
647
906
  *
648
907
  * @param {string} [_mode] — deprecated, ignored
649
- * @returns {Array<{ name: string, description: string, trigger: string, mode: string, category?: string, platforms?: string[], keywords?: string[], source: string, tier?: string, hasReferences: boolean, hasTemplates: boolean }>}
908
+ * @returns {Array<{ name: string, description: string, trigger: string, mode: string, category?: string, platforms?: string[], keywords?: string[], source: string, tier?: string, managed: boolean, hasReferences: boolean, hasTemplates: boolean }>}
650
909
  */
651
910
  list(_mode) {
652
911
  const skills = [...this.#skills.values()];
@@ -661,11 +920,52 @@ export class SkillManager {
661
920
  keywords: s.keywords || undefined,
662
921
  source: s._source,
663
922
  tier: s._tier || undefined,
923
+ managed: (s._tier === 'user' || s._tier === 'project')
924
+ && this.#nativeRootsForTier(s._tier).some(root => isRemovableNativeSkill(s._path, root)),
664
925
  hasReferences: (s._references && s._references.length > 0) || false,
665
926
  hasTemplates: (s._templates && s._templates.length > 0) || false,
666
927
  }));
667
928
  }
668
929
 
930
+ #nativeRootsForTier(tier) {
931
+ return [...this.#tierByDir.entries()]
932
+ .filter(([, label]) => label === tier)
933
+ .map(([dir]) => dir);
934
+ }
935
+
936
+ /**
937
+ * Return every discovered Skill source, including lower-priority sources
938
+ * shadowed by an effective user or project override. Runtime callers keep
939
+ * using `list()`; this is strictly for management UIs that must not make a
940
+ * removable user Skill disappear just because a project overrides its name.
941
+ *
942
+ * @returns {Array<{ id: string, name: string, description: string, source: string, tier?: string, managed: boolean }>}
943
+ */
944
+ listSources() {
945
+ const sources = [];
946
+ for (const dir of this.#skillsDirs) {
947
+ const secure = this.#secureWorkspaceByDir.get(dir);
948
+ if (!secure && !existsSync(dir)) continue;
949
+ const { skills } = secure
950
+ ? discoverWorkspaceSkills(secure.workspaceRoot, secure.relativeRoot)
951
+ : discoverSkills(dir, '', { ignorePaths: this.#ignorePathsByDir.get(dir) || [] });
952
+ const tier = this.#tierByDir.get(dir) || basename(dir);
953
+ for (const skill of skills) {
954
+ if (!matchesPlatform(skill.platforms)) continue;
955
+ const path = skill._path || '';
956
+ sources.push({
957
+ id: `${tier}:${displaySkillPath(path)}`,
958
+ name: skill.name,
959
+ description: skill.description || '',
960
+ source: skill._source,
961
+ tier,
962
+ managed: (tier === 'user' || tier === 'project') && isRemovableNativeSkill(path, dir),
963
+ });
964
+ }
965
+ }
966
+ return sources;
967
+ }
968
+
669
969
  /**
670
970
  * View a skill's full content + linked files (progressive disclosure view tier).
671
971
  *
@@ -691,7 +991,7 @@ export class SkillManager {
691
991
  result.linkedContent = 'Error: path traversal not allowed';
692
992
  } else {
693
993
  const relativePath = join(skill._secureRelativePath, filePath);
694
- const read = readWorkspaceFile(skill._secureWorkspaceRoot, relativePath, { maxBytes: MAX_PROJECT_SKILL_BYTES });
994
+ const read = readSecureWorkspaceSkillFile(skill._secureWorkspaceRoot, relativePath);
695
995
  result.linkedContent = !read
696
996
  ? 'Error reading file: secure workspace read failed'
697
997
  : (read.truncated ? 'Error reading file: linked file exceeds size limit' : read.buffer.toString('utf8'));
@@ -17,14 +17,20 @@
17
17
 
18
18
  import { delimiter, join } from 'node:path';
19
19
  import { COLLAB_TOOL_POLICY } from './tools/registry.js';
20
- import { existsSync } from 'node:fs';
20
+ import { existsSync, lstatSync } from 'node:fs';
21
21
  import { randomUUID } from 'node:crypto';
22
22
  import { DEFAULT_YEAFT_DIR } from './init.js';
23
23
  import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
24
24
  import { Engine } from './engine.js';
25
25
  import { loadSession } from './session.js';
26
26
  import { loadAgentMCPConfig, loadConfig, loadMCPConfig } from './config.js';
27
- import { createSkillManager } from './skills.js';
27
+ import {
28
+ createManagedProjectSkill,
29
+ createManagedSkill,
30
+ createSkillManager,
31
+ removeManagedProjectSkill,
32
+ removeManagedSkill,
33
+ } from './skills.js';
28
34
  import { buildPluginCatalog, createPluginSkillManager, resolveMcpPluginPolicy } from './plugins.js';
29
35
  import { MCPManager } from './mcp.js';
30
36
  import { sendToServer } from '../connection/buffer.js';
@@ -53,7 +59,8 @@ import {
53
59
  migrateRegisteredWorkDirSessions,
54
60
  resolveSessionYeaftDir,
55
61
  } from './sessions/session-crud.js';
56
- import { openSession, loadSessionMeta } from './sessions/session-store.js';
62
+ import { openSession, loadSessionMeta, SESSION_META_FILE } from './sessions/session-store.js';
63
+ import { validateSessionId } from './sessions/ids.js';
57
64
  import { loadSessionConfig, normalizeSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
58
65
  import { updateSessionConfig } from './sessions/session-crud.js';
59
66
  import { createCoordinator } from './sessions/coordinator.js';
@@ -3261,6 +3268,118 @@ function loadPluginCatalogMcpConfig(yeaftDir) {
3261
3268
  return loadAgentMCPConfig(yeaftDir);
3262
3269
  }
3263
3270
 
3271
+ const MANAGED_SKILL_SCOPES = new Set(['user', 'project']);
3272
+
3273
+ function normalizedManagedSkillScope(value) {
3274
+ const scope = typeof value === 'string' ? value.trim() : '';
3275
+ return MANAGED_SKILL_SCOPES.has(scope) ? scope : '';
3276
+ }
3277
+
3278
+ function resolveManagedSkillSession(yeaftDir, rawSessionId) {
3279
+ const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : '';
3280
+ const validation = validateSessionId(sessionId);
3281
+ if (!validation.ok) throw new Error('invalid Session id');
3282
+
3283
+ // Skill mutation is a filesystem write. Its authority root is strictly the
3284
+ // agent-local canonical store, not the read-compatible resolver: that one
3285
+ // can consult bootstrap-only group-workdirs.json and project-local legacy
3286
+ // Session directories. Those sources are not valid write authorization.
3287
+ const sessionRoot = sessionsRoot(yeaftDir);
3288
+ try {
3289
+ const sessionRootStat = lstatSync(sessionRoot);
3290
+ if (!sessionRootStat.isDirectory() || sessionRootStat.isSymbolicLink()) {
3291
+ throw new Error('the selected Session was not found');
3292
+ }
3293
+ } catch (err) {
3294
+ if (err?.message === 'the selected Session was not found') throw err;
3295
+ throw new Error('the selected Session was not found');
3296
+ }
3297
+ const sessionDir = join(sessionRoot, sessionId);
3298
+ // `loadSessionMeta()` deliberately supports legacy group.json for readers.
3299
+ // This mutation path needs the canonical agent-local session.json instead.
3300
+ const metaPath = join(sessionDir, SESSION_META_FILE);
3301
+ if (!existsSync(metaPath)) throw new Error('the selected Session was not found');
3302
+ try {
3303
+ const sessionDirStat = lstatSync(sessionDir);
3304
+ const metaStat = lstatSync(metaPath);
3305
+ if (!sessionDirStat.isDirectory() || sessionDirStat.isSymbolicLink()
3306
+ || !metaStat.isFile() || metaStat.isSymbolicLink()) {
3307
+ throw new Error('the selected Session was not found');
3308
+ }
3309
+ } catch (err) {
3310
+ if (err?.message === 'the selected Session was not found') throw err;
3311
+ throw new Error('the selected Session was not found');
3312
+ }
3313
+ const meta = loadSessionMeta(sessionDir);
3314
+ if (!meta || meta.id !== sessionId) throw new Error('the selected Session was not found');
3315
+
3316
+ const workDir = normalizeSessionWorkDir(meta.workDir);
3317
+ if (!workDir) throw new Error('the selected Session has no project directory');
3318
+ return { sessionId, workDir };
3319
+ }
3320
+
3321
+ function currentRuntimeMatchesWorkDir(workDir) {
3322
+ const runtime = activeSkillRuntime(captureRuntimeOwner());
3323
+ return runtime && normalizeSessionWorkDir(runtime.workDir) === normalizeSessionWorkDir(workDir);
3324
+ }
3325
+
3326
+ function reloadManagedSkillRuntime(scope, workDir = '') {
3327
+ const owner = captureRuntimeOwner();
3328
+ if (!owner) return;
3329
+ if (scope === 'project' && currentRuntimeMatchesWorkDir(workDir)) {
3330
+ reloadActiveSkills(owner);
3331
+ return;
3332
+ }
3333
+ if (scope === 'user') reloadActiveSkills(owner);
3334
+ }
3335
+
3336
+ function managedSkillCatalog(yeaftDir, workDir = '') {
3337
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir);
3338
+ const runtime = resolvePluginCatalogRuntime(normalizedWorkDir);
3339
+ const manager = createSkillManager(yeaftDir, normalizedWorkDir || process.cwd());
3340
+ return buildPluginCatalog({ ...runtime, skillManager: manager });
3341
+ }
3342
+
3343
+ /**
3344
+ * Create or remove a native Yeaft Skill in an explicit user/project scope.
3345
+ * The Session lookup is agent-local; browser-supplied filesystem paths are
3346
+ * never trusted as a write target.
3347
+ */
3348
+ export function handleYeaftManagedSkill(msg = {}) {
3349
+ const requestId = msg.requestId || null;
3350
+ const action = msg.action === 'remove' ? 'remove' : (msg.action === 'create' ? 'create' : '');
3351
+ const scope = normalizedManagedSkillScope(msg.scope);
3352
+ const sessionId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : '';
3353
+ const yeaftDir = ctx.CONFIG?.yeaftDir || session?.yeaftDir || DEFAULT_YEAFT_DIR;
3354
+ const respond = (payload) => sendToServer({
3355
+ type: 'yeaft_managed_skill_result',
3356
+ requestId,
3357
+ ...(msg._requestClientId ? { _requestClientId: msg._requestClientId } : {}),
3358
+ ...payload,
3359
+ });
3360
+ if (!action || !scope) {
3361
+ respond({ catalog: { tools: [], skills: [], skillSources: [], mcpServers: [] }, error: 'skill action must be create or remove and scope must be user or project' });
3362
+ return;
3363
+ }
3364
+ try {
3365
+ const managedSession = scope === 'project'
3366
+ ? resolveManagedSkillSession(yeaftDir, sessionId)
3367
+ : null;
3368
+ const result = scope === 'project'
3369
+ ? (action === 'create'
3370
+ ? createManagedProjectSkill(managedSession.workDir, msg.skill || {})
3371
+ : removeManagedProjectSkill(managedSession.workDir, msg.name))
3372
+ : (action === 'create'
3373
+ ? createManagedSkill(join(yeaftDir, 'skills'), msg.skill || {})
3374
+ : removeManagedSkill(join(yeaftDir, 'skills'), msg.name));
3375
+ reloadManagedSkillRuntime(scope, managedSession?.workDir || '');
3376
+ const catalog = managedSkillCatalog(yeaftDir, managedSession?.workDir || '');
3377
+ respond({ scope, sessionId: managedSession?.sessionId || null, result, catalog, error: null });
3378
+ } catch (err) {
3379
+ respond({ scope, sessionId: null, catalog: { tools: [], skills: [], skillSources: [], mcpServers: [] }, error: err?.message || String(err) });
3380
+ }
3381
+ }
3382
+
3264
3383
  /** Test-only: read the MCP catalog source without sending a bridge response. */
3265
3384
  export function __testLoadPluginCatalogMcpConfig(yeaftDir) {
3266
3385
  return loadPluginCatalogMcpConfig(yeaftDir);