@yeaft/webchat-agent 0.1.1031 → 0.1.1033

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": "0.1.1031",
3
+ "version": "0.1.1033",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -498,31 +498,128 @@ export function loadConfig(overrides = {}) {
498
498
  /**
499
499
  * Load MCP server configuration.
500
500
  *
501
- * Reads from config.json "mcpServers" field first, then falls back to
502
- * standalone ~/.yeaft/mcp.json for backward compatibility.
501
+ * Merges two tiers, in priority order (highest first):
502
+ * 1. global — ~/.yeaft authoritative config: config.json "mcpServers"
503
+ * array, else standalone ~/.yeaft/mcp.json ({ servers: [...] }).
504
+ * 2. project — `<workDir>/.mcp.json` (Claude Code standard location), so a
505
+ * project that integrates Claude Code works out of the box. Supplementary:
506
+ * a project server whose name collides with a global one is dropped (the
507
+ * explicit ~/.yeaft config wins).
503
508
  *
504
509
  * @param {string} yeaftDir
505
510
  * @param {object} [jsonConfig] — Already-parsed config.json (optional, avoids re-read)
506
- * @returns {{ servers: object[] }}
511
+ * @param {string} [workDir] — optional project working directory (project tier root)
512
+ * @returns {{ servers: object[], skipped: { name: string, reason: string, source: string }[] }}
513
+ */
514
+ export function loadMCPConfig(yeaftDir, jsonConfig, workDir) {
515
+ // ── Global tier: ~/.yeaft authoritative config ──
516
+ const globalServers = loadGlobalMCPServers(yeaftDir, jsonConfig);
517
+
518
+ // ── Project tier: <workDir>/.mcp.json (Claude Code standard) ──
519
+ const project = workDir
520
+ ? loadProjectMCPServers(workDir)
521
+ : { servers: [], skipped: [] };
522
+
523
+ // Dedup: global (~/.yeaft explicit config) wins over project supplements.
524
+ const seen = new Set(globalServers.map(s => s.name));
525
+ const servers = [...globalServers];
526
+ for (const s of project.servers) {
527
+ if (seen.has(s.name)) continue;
528
+ seen.add(s.name);
529
+ servers.push(s);
530
+ }
531
+
532
+ return { servers, skipped: project.skipped };
533
+ }
534
+
535
+ /**
536
+ * Load the global (~/.yeaft) MCP server list.
537
+ *
538
+ * Reads from config.json "mcpServers" field first, then falls back to
539
+ * standalone ~/.yeaft/mcp.json for backward compatibility. Returns a plain
540
+ * array (not wrapped) so `loadMCPConfig` can merge tiers.
541
+ *
542
+ * @param {string} yeaftDir
543
+ * @param {object} [jsonConfig]
544
+ * @returns {object[]}
507
545
  */
508
- export function loadMCPConfig(yeaftDir, jsonConfig) {
546
+ function loadGlobalMCPServers(yeaftDir, jsonConfig) {
509
547
  // Check config.json mcpServers field
510
548
  if (jsonConfig && Array.isArray(jsonConfig.mcpServers)) {
511
549
  const valid = jsonConfig.mcpServers.filter(s => s.name && s.command);
512
- if (valid.length > 0) return { servers: valid };
550
+ if (valid.length > 0) return valid;
513
551
  }
514
552
 
515
553
  // Fallback: standalone mcp.json
516
554
  const mcpPath = join(yeaftDir, 'mcp.json');
517
- if (!existsSync(mcpPath)) return { servers: [] };
555
+ if (!existsSync(mcpPath)) return [];
518
556
 
519
557
  try {
520
558
  const raw = readFileSync(mcpPath, 'utf8');
521
559
  const parsed = JSON.parse(raw);
522
- if (!parsed.servers || !Array.isArray(parsed.servers)) return { servers: [] };
523
- const valid = parsed.servers.filter(s => s.name && s.command);
524
- return { servers: valid };
560
+ if (!parsed.servers || !Array.isArray(parsed.servers)) return [];
561
+ return parsed.servers.filter(s => s.name && s.command);
525
562
  } catch {
526
- return { servers: [] };
563
+ return [];
527
564
  }
528
565
  }
566
+
567
+ /**
568
+ * Parse a project's Claude Code MCP config at `<workDir>/.mcp.json`.
569
+ *
570
+ * Format (Claude Code standard):
571
+ * { "mcpServers": { "<name>": { command, args?, env?, url?, type? } } }
572
+ *
573
+ * Only stdio servers (those with a `command`) are adapted into yeaft's
574
+ * { name, command, args?, env? } shape. SSE/HTTP servers (url/type, no
575
+ * command) cannot be spawned by the current MCPManager, so they're reported
576
+ * in `skipped` with reason 'unsupported-transport' rather than silently
577
+ * dropped or surfaced later as spawn failures.
578
+ *
579
+ * Robust by design: a missing file, malformed JSON, or a non-object
580
+ * `mcpServers` field all return gracefully with empty arrays — a broken
581
+ * project `.mcp.json` must never fail session creation.
582
+ *
583
+ * @param {string} workDir — project working directory
584
+ * @returns {{ servers: object[], skipped: { name: string, reason: string, source: string }[] }}
585
+ */
586
+ export function loadProjectMCPServers(workDir) {
587
+ const empty = { servers: [], skipped: [] };
588
+ if (!workDir || typeof workDir !== 'string') return empty;
589
+
590
+ const mcpPath = join(workDir, '.mcp.json');
591
+ if (!existsSync(mcpPath)) return empty;
592
+
593
+ let parsed;
594
+ try {
595
+ parsed = JSON.parse(readFileSync(mcpPath, 'utf8'));
596
+ } catch {
597
+ return empty;
598
+ }
599
+
600
+ const mcpServers = parsed && parsed.mcpServers;
601
+ if (!mcpServers || typeof mcpServers !== 'object' || Array.isArray(mcpServers)) {
602
+ return empty;
603
+ }
604
+
605
+ const servers = [];
606
+ const skipped = [];
607
+ for (const [name, raw] of Object.entries(mcpServers)) {
608
+ if (!name || !raw || typeof raw !== 'object') continue;
609
+ if (typeof raw.command === 'string' && raw.command.length > 0) {
610
+ // stdio server → adapt to yeaft shape
611
+ const server = { name, command: raw.command };
612
+ if (Array.isArray(raw.args)) server.args = raw.args;
613
+ if (raw.env && typeof raw.env === 'object') server.env = raw.env;
614
+ servers.push(server);
615
+ } else if (typeof raw.url === 'string' || typeof raw.type === 'string') {
616
+ // SSE/HTTP transport — not spawnable by current MCPManager.
617
+ skipped.push({ name, reason: 'unsupported-transport', source: '.mcp.json' });
618
+ } else {
619
+ // No command and no url/type — malformed entry.
620
+ skipped.push({ name, reason: 'invalid-config', source: '.mcp.json' });
621
+ }
622
+ }
623
+
624
+ return { servers, skipped };
625
+ }
package/yeaft/engine.js CHANGED
@@ -2051,6 +2051,20 @@ export class Engine {
2051
2051
  signal,
2052
2052
  onRawExchange: captureRawExchange,
2053
2053
  })) {
2054
+ // task-325a (abort-stop fix): per-event abort short-circuit.
2055
+ // The adapter is expected to throw AbortError when fetch's
2056
+ // signal fires, but in practice undici/HTTP-2/proxy layers
2057
+ // can hand us a batch of SSE chunks that were already buffered
2058
+ // when abort was requested. Those chunks would otherwise be
2059
+ // forwarded to the caller (web-bridge → WS → browser) for
2060
+ // 1–2s after the user pressed Stop, producing the exact
2061
+ // symptom "Stop button doesn't stop the turn". Drop every
2062
+ // post-abort event by throwing into the outer catch, which
2063
+ // converges on the same `aborted` + `turn_end` terminal pair
2064
+ // as the adapter-throws-AbortError path.
2065
+ if (signal?.aborted) {
2066
+ throw new LLMAbortError();
2067
+ }
2054
2068
  switch (event.type) {
2055
2069
  case 'text_delta':
2056
2070
  if (ttfbMs === null) ttfbMs = Date.now() - startTime;
package/yeaft/session.js CHANGED
@@ -370,6 +370,14 @@ export async function loadSession(options = {}) {
370
370
  }
371
371
 
372
372
  // ─── 6. Load skills ────────────────────────────────────
373
+ // Project tier root for skills + MCP project assets. Per-session/per-group
374
+ // workdirs override at tool-execution time via ToolContext.cwd; for the
375
+ // SYSTEM-PROMPT skill set and the project MCP servers we use the agent
376
+ // process cwd, the common case when an agent is launched inside a project
377
+ // the user wants project-tier assets for. Shared so skills (.claude/skills,
378
+ // .yeaft/skills) and MCP (.mcp.json) resolve from the same root.
379
+ const projectTierRoot = process.cwd();
380
+
373
381
  let skillManager;
374
382
  if (skipSkills) {
375
383
  // Pass the literal user-tier dir (matches the normal branch's tier 2)
@@ -378,17 +386,11 @@ export async function loadSession(options = {}) {
378
386
  skillManager = new SkillManager(join(yeaftDir, 'skills'));
379
387
  // Don't call .load() — empty skill manager
380
388
  } else {
381
- // Pass the agent's current working directory as the project tier root.
382
- // Per-session/per-group workdirs override at tool-execution time via
383
- // ToolContext.cwd; for the SYSTEM-PROMPT skill set we just use the
384
- // agent process cwd, which is the common case when an agent is
385
- // launched inside a project the user wants project-tier skills for.
386
- const projectTierRoot = process.cwd();
387
389
  skillManager = createSkillManager(yeaftDir, projectTierRoot);
388
390
  }
389
391
 
390
392
  // ─── 7. Connect MCP servers ────────────────────────────
391
- const mcpConfig = loadMCPConfig(yeaftDir);
393
+ const mcpConfig = loadMCPConfig(yeaftDir, undefined, projectTierRoot);
392
394
  const mcpManager = new MCPManager();
393
395
  let mcpStatus = { connected: [], failed: [] };
394
396
 
@@ -548,6 +550,10 @@ export async function loadSession(options = {}) {
548
550
  skills: skillManager.size,
549
551
  mcpServers: mcpStatus.connected,
550
552
  mcpFailed: mcpStatus.failed,
553
+ // Project `.mcp.json` servers we couldn't spawn (e.g. SSE/HTTP transport).
554
+ // Surfaced (not silently dropped) so the UI can explain why a configured
555
+ // server isn't available.
556
+ mcpSkipped: mcpConfig.skipped || [],
551
557
  tools: toolRegistry.size,
552
558
  };
553
559
 
package/yeaft/skills.js CHANGED
@@ -34,9 +34,14 @@
34
34
  * tier 2 (user): <yeaftDir>/skills (e.g. ~/.yeaft/skills). User edits
35
35
  * land here. `save()` writes here. `init.js` seeds it from tier 1
36
36
  * on first boot so users start with the full bundled set.
37
- * tier 3 (project): <workDir>/.yeaft/skills (if provided). Highest
37
+ * tier 3 (project-claude): <workDir>/.claude/skills (if provided). Claude
38
+ * Code project assets, loaded so a Claude-Code-integrated project
39
+ * works out of the box. Higher than user (project-local beats
40
+ * user-global), lower than the yeaft-native project tier.
41
+ * tier 4 (project): <workDir>/.yeaft/skills (if provided). Highest
38
42
  * priority — a project can pin a skill version without affecting
39
- * the user's other projects.
43
+ * the user's other projects, and overrides a borrowed
44
+ * `.claude/skills` skill of the same name.
40
45
  *
41
46
  * Reference: yeaft-yeaft-design.md §8, yeaft-yeaft-core-systems.md
42
47
  */
@@ -694,10 +699,16 @@ export class SkillManager {
694
699
  * Create a SkillManager wired with the standard layered tier list and load it.
695
700
  *
696
701
  * Tier order (lowest → highest priority):
697
- * 1. bundled — the yeaft-skills package on disk, located via
702
+ * 1. bundled — the yeaft-skills package on disk, located via
698
703
  * `bundledYeaftSkillsDir()` (typically ~/.claude/skills/yeaft-skills/skills/).
699
- * 2. user — `<yeaftDir>/skills` (e.g. ~/.yeaft/skills). User edits + saves.
700
- * 3. project — `<workDir>/.yeaft/skills` when a workDir is provided.
704
+ * 2. user — `<yeaftDir>/skills` (e.g. ~/.yeaft/skills). User edits + saves.
705
+ * 3. project-claude — `<workDir>/.claude/skills` when a workDir is provided.
706
+ * Claude Code project assets, loaded so a project that integrates Claude
707
+ * Code works out of the box. Ranks above `user` (project-local beats
708
+ * user-global) but below the yeaft-native project tier.
709
+ * 4. project — `<workDir>/.yeaft/skills` when a workDir is provided.
710
+ * Highest priority: a yeaft-native skill pinned in the project overrides
711
+ * a borrowed `.claude/skills` skill of the same name.
701
712
  *
702
713
  * `save()` / `remove()` always target the USER tier, matching Claude Code.
703
714
  *
@@ -708,12 +719,14 @@ export class SkillManager {
708
719
  export function createSkillManager(yeaftDir, workDir) {
709
720
  const bundled = bundledYeaftSkillsDir();
710
721
  const userDir = join(yeaftDir, 'skills');
722
+ const claudeProjectDir = workDir ? join(workDir, '.claude', 'skills') : null;
711
723
  const projectDir = workDir ? join(workDir, '.yeaft', 'skills') : null;
712
724
 
713
- const dirs = [bundled, userDir, projectDir].filter(Boolean);
725
+ const dirs = [bundled, userDir, claudeProjectDir, projectDir].filter(Boolean);
714
726
  const tierByDir = {};
715
727
  if (bundled) tierByDir[bundled] = 'bundled';
716
728
  tierByDir[userDir] = 'user';
729
+ if (claudeProjectDir) tierByDir[claudeProjectDir] = 'project-claude';
717
730
  if (projectDir) tierByDir[projectDir] = 'project';
718
731
 
719
732
  const manager = new SkillManager(dirs, { userDir, tierByDir });