@yeaft/webchat-agent 0.1.1012 → 0.1.1015

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.1012",
3
+ "version": "0.1.1015",
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/engine.js CHANGED
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
21
21
  import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
24
+ import { getRuntimePlatformInfo } from './runtime-platform.js';
24
25
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
25
26
  import { runMemoryPreflow, buildRelevantScopes } from './sessions/pre-flow.js';
26
27
  import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './sessions/project-doc.js';
@@ -815,6 +816,7 @@ export class Engine {
815
816
  activeScope,
816
817
  sessionAnnouncement,
817
818
  projectDoc,
819
+ runtimePlatform: getRuntimePlatformInfo(),
818
820
  taskCtx,
819
821
  // Worker-shape harness is descriptive metadata for human inspection;
820
822
  // production prompts skip it to save tokens. Re-enable via env when
@@ -903,6 +905,7 @@ export class Engine {
903
905
  return {
904
906
  signal,
905
907
  yeaftDir: this.#yeaftDir,
908
+ runtimePlatform: getRuntimePlatformInfo(),
906
909
  // Group-scoped working directory. Threaded from #runQuery({ workDir })
907
910
  // → set by web-bridge runVpTurn from sessionMeta.workDir. Tools read
908
911
  // `ctx.cwd` and resolve relative paths against it. Always absolute
package/yeaft/prompts.js CHANGED
@@ -25,6 +25,7 @@
25
25
  import { readFileSync, existsSync } from 'fs';
26
26
  import { join, dirname } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
+ import { getRuntimePlatformInfo, renderRuntimePlatformPrompt } from './runtime-platform.js';
28
29
  import { DEFAULT_VPS } from './vp/seed-defaults.js';
29
30
 
30
31
  // ─── Template Loading (one-time at startup) ──────────────────────
@@ -196,8 +197,12 @@ const PROMPTS = {
196
197
  date: (d) => `Date: ${d}`,
197
198
  dream: 'You are in dream mode. Reflect on past conversations and consolidate memories.',
198
199
  tools: (names) => `Available tools: ${names}`,
199
- // DESIGN-PROMPT §3 ④ — Active Scope header
200
- activeScopeHeader: '## active_scope',
200
+ // DESIGN-PROMPT §3 ④ — current session context block.
201
+ activeScopeHeader: '## Current session context',
202
+ activeScopeSessionIdLabel: 'Session ID',
203
+ activeScopeMembersLabel: 'Session members',
204
+ activeScopeTopicsLabel: 'Current focus',
205
+ activeScopeEnvelopeLabel: 'Handoff',
201
206
  multiVpRoutingHeader: '## multi_vp_routing',
202
207
  sessionAnnouncementHeader: '[Session Announcement]',
203
208
  // Project-doc (CLAUDE.md / AGENTS.md) header + one-liner intro. Both
@@ -212,8 +217,12 @@ const PROMPTS = {
212
217
  date: (d) => `日期:${d}`,
213
218
  dream: '你处于梦境模式。回顾过去的对话,整理和巩固记忆。',
214
219
  tools: (names) => `可用工具:${names}`,
215
- // DESIGN-PROMPT §3 ④ — Active Scope header
216
- activeScopeHeader: '## active_scope',
220
+ // DESIGN-PROMPT §3 ④ — 当前会话上下文。
221
+ activeScopeHeader: '## 当前会话上下文',
222
+ activeScopeSessionIdLabel: '会话 ID',
223
+ activeScopeMembersLabel: '会话成员',
224
+ activeScopeTopicsLabel: '当前讨论',
225
+ activeScopeEnvelopeLabel: '转交消息',
217
226
  multiVpRoutingHeader: '## multi_vp_routing',
218
227
  sessionAnnouncementHeader: '[会话公告]',
219
228
  // 项目文档块:CLAUDE.md / AGENTS.md(与 Codex 通用命名兼容)。
@@ -299,6 +308,7 @@ export function buildSystemPrompt({
299
308
  vpPersona,
300
309
  sessionAnnouncement = '',
301
310
  projectDoc = '',
311
+ runtimePlatform,
302
312
  } = {}) {
303
313
  // Normalize app locales like `zh-CN` to prompt dictionary/template keys.
304
314
  const effectiveLang = normalizePromptLanguage(language);
@@ -362,7 +372,12 @@ export function buildSystemPrompt({
362
372
  parts.push(dreamTemplate || lang.dream);
363
373
  }
364
374
 
365
- // ─── 4. Tools + Tool Guidance ──────────────────────────
375
+ // ─── 4. Runtime Platform + Tools + Tool Guidance ──────
376
+ const runtimePlatformBlock = renderRuntimePlatformPrompt(runtimePlatform || getRuntimePlatformInfo(), effectiveLang);
377
+ if (runtimePlatformBlock) {
378
+ parts.push(runtimePlatformBlock);
379
+ }
380
+
366
381
  if (toolNames.length > 0) {
367
382
  parts.push(lang.tools(toolNames.join(', ')));
368
383
 
@@ -495,20 +510,22 @@ function selectVpPersonaBody(vpPersona, effectiveLang) {
495
510
  function renderActiveScope(activeScope, lang) {
496
511
  if (!activeScope || typeof activeScope !== 'object') return '';
497
512
 
513
+ const isZh = lang === PROMPTS.zh;
514
+ const separator = isZh ? ':' : ': ';
498
515
  const lines = [];
499
516
  const session = typeof activeScope.sessionId === 'string' && activeScope.sessionId.trim()
500
517
  ? activeScope.sessionId.trim()
501
518
  : '';
502
- if (session) lines.push(`session_id: ${session}`);
519
+ if (session) lines.push(`${lang.activeScopeSessionIdLabel}${separator}${session}`);
503
520
 
504
- const membersLine = renderSessionMembersLine(activeScope.sessionMembers || activeScope.members);
505
- if (membersLine) lines.push(`session_members: ${membersLine}`);
521
+ const membersLine = renderSessionMembersLine(activeScope.sessionMembers || activeScope.members, isZh);
522
+ if (membersLine) lines.push(`${lang.activeScopeMembersLabel}${separator}${membersLine}`);
506
523
 
507
- const topicsLine = renderSessionMembersLine(activeScope.sessionTopics);
508
- if (topicsLine) lines.push(`session_topics: ${topicsLine}`);
524
+ const topicsLine = renderSessionTopicsLine(activeScope.sessionTopics, isZh);
525
+ if (topicsLine) lines.push(`${lang.activeScopeTopicsLabel}${separator}${topicsLine}`);
509
526
 
510
- const envLine = renderEnvelopeLine(activeScope.envelope);
511
- if (envLine) lines.push(`envelope: ${envLine}`);
527
+ const envLine = renderEnvelopeLine(activeScope.envelope, isZh);
528
+ if (envLine) lines.push(`${lang.activeScopeEnvelopeLabel}${separator}${envLine}`);
512
529
 
513
530
  if (lines.length === 0) return '';
514
531
 
@@ -523,8 +540,149 @@ function firstNonEmptyString(...values) {
523
540
  return '';
524
541
  }
525
542
 
526
- function renderSessionMembersLine(members) {
527
- return normalizeSessionMemberIds(members).join(', ');
543
+ function renderSessionMembersLine(members, useChineseSeparator = false) {
544
+ return normalizeSessionMemberIds(members).join(useChineseSeparator ? '、' : ', ');
545
+ }
546
+
547
+ function renderSessionTopicsLine(topics, isZh = false) {
548
+ const descriptions = [];
549
+ const seen = new Set();
550
+ for (const topic of normalizeSessionTopicIds(topics)) {
551
+ const description = describeSessionTopic(topic, isZh);
552
+ if (!description || seen.has(description)) continue;
553
+ seen.add(description);
554
+ descriptions.push(description);
555
+ }
556
+ return descriptions.join(isZh ? ';' : '; ');
557
+ }
558
+
559
+ function normalizeSessionTopicIds(topics) {
560
+ if (!Array.isArray(topics)) return [];
561
+ const clean = [];
562
+ const seen = new Set();
563
+ for (const topic of topics) {
564
+ if (typeof topic !== 'string') continue;
565
+ const id = topic.trim();
566
+ if (!id || seen.has(id)) continue;
567
+ seen.add(id);
568
+ clean.push(id);
569
+ }
570
+ return clean;
571
+ }
572
+
573
+ function describeSessionTopic(topic, isZh = false) {
574
+ const normalized = topic.toLowerCase().replace(/[_\s]+/g, '-');
575
+
576
+ if (normalized.includes('dream') && normalized.includes('segments')) {
577
+ return isZh
578
+ ? '梦境记忆片段的抽取与整理'
579
+ : 'Dream memory segment extraction and organization';
580
+ }
581
+ if (normalized.includes('dream') && normalized.includes('session') && normalized.includes('extraction')) {
582
+ return isZh
583
+ ? '梦境会话记忆的抽取质量'
584
+ : 'Dream session-memory extraction quality';
585
+ }
586
+ if (normalized.includes('system-prompt') && normalized.includes('localization')) {
587
+ return isZh
588
+ ? '系统提示词的中英文一致性'
589
+ : 'system prompt language localization';
590
+ }
591
+ if (normalized.includes('default-character') && normalized.includes('prompt-soul')) {
592
+ return isZh
593
+ ? '默认角色灵魂提示词的表达方式'
594
+ : 'default persona soul prompt wording';
595
+ }
596
+ if (normalized.includes('prompt') && normalized.includes('soul')) {
597
+ return isZh
598
+ ? '角色灵魂提示词的表达方式'
599
+ : 'persona soul prompt wording';
600
+ }
601
+ if (normalized.includes('openai-responses')) {
602
+ return isZh
603
+ ? 'Yeaft 的 OpenAI Responses 适配与模型配置'
604
+ : 'Yeaft OpenAI Responses adapter and model configuration';
605
+ }
606
+ if (normalized.includes('model-config-isolation')) {
607
+ return isZh
608
+ ? 'Yeaft 的模型配置隔离'
609
+ : 'Yeaft model configuration isolation';
610
+ }
611
+ if (normalized.includes('route-forward') || normalized.includes('handoff')) {
612
+ return isZh
613
+ ? 'Yeaft 的会话路由交接与可见性'
614
+ : 'Yeaft session routing handoff and visibility';
615
+ }
616
+ if (normalized.includes('copilot-cli') || normalized.includes('chat-session')) {
617
+ return isZh
618
+ ? 'Copilot CLI 的聊天会话行为'
619
+ : 'Copilot CLI chat session behavior';
620
+ }
621
+ if (normalized.includes('claude-opus')) {
622
+ return isZh
623
+ ? 'Yeaft 的 Claude Opus 模型接入'
624
+ : 'Yeaft Claude Opus model integration';
625
+ }
626
+ if (normalized.includes('pr-workflow')) {
627
+ return isZh
628
+ ? '项目的 PR review、merge 和 tag 发布流程'
629
+ : 'project PR review, merge, and tag release workflow';
630
+ }
631
+ if (/\bpr[-/]?\d+\b/.test(normalized) || normalized.includes('release') || /v\d+\.\d+\.\d+/.test(normalized)) {
632
+ return isZh
633
+ ? '最近的 PR 修复、review、merge 和 tag 发布流程'
634
+ : 'recent PR fixes, review, merge, and tag release work';
635
+ }
636
+ if (normalized.includes('active-scope')) {
637
+ return isZh
638
+ ? '当前会话上下文的提示词呈现'
639
+ : 'current session context prompt rendering';
640
+ }
641
+ if (normalized.includes('prompt') || normalized.includes('system')) {
642
+ return isZh
643
+ ? '近期的系统提示词调整'
644
+ : humanizeTopicSlug(topic, false) || 'recent system prompt work';
645
+ }
646
+ if (normalized.includes('dream')) {
647
+ return isZh
648
+ ? '近期的 Dream 记忆维护工作'
649
+ : humanizeTopicSlug(topic, false) || 'recent Dream memory work';
650
+ }
651
+ if (normalized.includes('session')) {
652
+ return isZh
653
+ ? '近期的会话上下文调整'
654
+ : humanizeTopicSlug(topic, false) || 'recent session context work';
655
+ }
656
+
657
+ if (isZh) return '近期的项目协作事项';
658
+ return humanizeTopicSlug(topic, false) || 'recent session collaboration topics';
659
+ }
660
+
661
+ function humanizeTopicSlug(topic, isZh = false) {
662
+ if (isZh) return '';
663
+ const words = topic
664
+ .replace(/[\/_-]+/g, ' ')
665
+ .replace(/\bv\d+(?:\.\d+)+\b/gi, '')
666
+ .replace(/\bpr\s*\d+\b/gi, '')
667
+ .trim()
668
+ .split(/\s+/)
669
+ .filter(Boolean);
670
+ if (words.length === 0) return '';
671
+
672
+ const normalizedWords = words.map(word => {
673
+ const lower = word.toLowerCase();
674
+ if (lower === 'yeaft') return 'Yeaft';
675
+ if (lower === 'project') return 'project';
676
+ if (lower === 'config') return 'configuration';
677
+ if (lower === 'isolation') return 'isolation';
678
+ if (lower === 'rendering') return 'rendering';
679
+ if (lower === 'workflow') return 'workflow';
680
+ if (lower === 'session') return 'session';
681
+ if (lower === 'context') return 'context';
682
+ return word;
683
+ });
684
+
685
+ return normalizedWords.join(' ');
528
686
  }
529
687
 
530
688
  function normalizeSessionMemberIds(members) {
@@ -581,21 +739,21 @@ function renderMultiVpRouting(activeScope, lang) {
581
739
  * @param {object|null|undefined} envelope
582
740
  * @returns {string}
583
741
  */
584
- function renderEnvelopeLine(envelope) {
742
+ function renderEnvelopeLine(envelope, isZh = false) {
585
743
  if (!envelope || typeof envelope !== 'object') return '';
586
744
  const segments = [];
587
745
  const fromVp = typeof envelope.fromVpId === 'string' && envelope.fromVpId.trim()
588
746
  ? envelope.fromVpId.trim()
589
747
  : (typeof envelope.senderVpId === 'string' ? envelope.senderVpId.trim() : '');
590
- if (fromVp) segments.push(`from=${fromVp}`);
748
+ if (fromVp) segments.push(`${isZh ? '来自' : 'from'}=${fromVp}`);
591
749
  const fromUser = typeof envelope.fromUserId === 'string' && envelope.fromUserId.trim()
592
750
  ? envelope.fromUserId.trim()
593
751
  : '';
594
- if (fromUser) segments.push(`user=${fromUser}`);
752
+ if (fromUser) segments.push(`${isZh ? '用户' : 'user'}=${fromUser}`);
595
753
  const intent = typeof envelope.intent === 'string' && envelope.intent.trim()
596
754
  ? envelope.intent.trim()
597
755
  : '';
598
- if (intent) segments.push(`intent=${intent}`);
756
+ if (intent) segments.push(`${isZh ? '意图' : 'intent'}=${intent}`);
599
757
  return segments.join(' ');
600
758
  }
601
759
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * runtime-platform.js — Runtime OS/platform facts for prompts and tools.
3
+ *
4
+ * Keep OS detection in one place. Tools should read `ctx.runtimePlatform`
5
+ * instead of guessing from scattered `process.platform` checks.
6
+ */
7
+
8
+ const WINDOWS_PLATFORMS = new Set(['win32']);
9
+ const MAC_PLATFORMS = new Set(['darwin']);
10
+ const LINUX_PLATFORMS = new Set(['linux']);
11
+
12
+ /**
13
+ * @param {string | undefined | null} platform
14
+ * @returns {NodeJS.Platform | string}
15
+ */
16
+ export function normalizePlatform(platform) {
17
+ const raw = typeof platform === 'string' && platform.trim()
18
+ ? platform.trim().toLowerCase()
19
+ : process.platform;
20
+ if (raw === 'windows') return 'win32';
21
+ if (raw === 'mac' || raw === 'macos' || raw === 'osx') return 'darwin';
22
+ return raw;
23
+ }
24
+
25
+ /**
26
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [opts]
27
+ * @returns {{ command: string, argsPrefix: string[], family: 'powershell' | 'cmd' | 'posix' }}
28
+ */
29
+ export function resolveDefaultShell(opts = {}) {
30
+ const platform = normalizePlatform(opts.platform);
31
+ const env = opts.env || process.env;
32
+
33
+ if (WINDOWS_PLATFORMS.has(platform)) {
34
+ const configured = env.YEAFT_WINDOWS_SHELL || env.PWSH || env.POWERSHELL;
35
+ const shell = configured || 'powershell.exe';
36
+ const lower = shell.toLowerCase();
37
+ if (lower.includes('cmd.exe') || lower.endsWith('cmd')) {
38
+ return { command: shell, argsPrefix: ['/d', '/s', '/c'], family: 'cmd' };
39
+ }
40
+ return {
41
+ command: shell,
42
+ argsPrefix: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'],
43
+ family: 'powershell',
44
+ };
45
+ }
46
+
47
+ return {
48
+ command: env.SHELL || '/bin/bash',
49
+ argsPrefix: ['-c'],
50
+ family: 'posix',
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [opts]
56
+ */
57
+ export function getRuntimePlatformInfo(opts = {}) {
58
+ const platform = normalizePlatform(opts.platform);
59
+ const shell = resolveDefaultShell({ platform, env: opts.env });
60
+ const isWindows = WINDOWS_PLATFORMS.has(platform);
61
+ const isMacOS = MAC_PLATFORMS.has(platform);
62
+ const isLinux = LINUX_PLATFORMS.has(platform);
63
+
64
+ return Object.freeze({
65
+ platform,
66
+ os: isWindows ? 'Windows' : (isMacOS ? 'macOS' : (isLinux ? 'Linux' : platform)),
67
+ isWindows,
68
+ isMacOS,
69
+ isLinux,
70
+ pathSeparator: isWindows ? '\\' : '/',
71
+ defaultShell: shell.command,
72
+ shellFamily: shell.family,
73
+ shellArgsPrefix: shell.argsPrefix,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * @param {ReturnType<typeof getRuntimePlatformInfo>} info
79
+ * @param {string} [language]
80
+ */
81
+ export function renderRuntimePlatformPrompt(info = getRuntimePlatformInfo(), language = 'en') {
82
+ const shell = info.shellFamily === 'powershell'
83
+ ? `${info.defaultShell} (PowerShell syntax)`
84
+ : (info.shellFamily === 'cmd' ? `${info.defaultShell} (cmd.exe syntax)` : `${info.defaultShell} (POSIX shell syntax)`);
85
+
86
+ if ((language || '').toLowerCase().startsWith('zh')) {
87
+ return [
88
+ '## runtime_platform',
89
+ `当前 Agent 运行系统:${info.os} (${info.platform})`,
90
+ `默认命令 shell:${shell}`,
91
+ `路径分隔符:${info.pathSeparator}`,
92
+ '生成 Bash 工具命令时必须匹配当前系统;Windows 上优先使用 PowerShell/cmd 语法,不要默认输出 Linux-only 命令。',
93
+ ].join('\n');
94
+ }
95
+
96
+ return [
97
+ '## runtime_platform',
98
+ `Agent OS: ${info.os} (${info.platform})`,
99
+ `Default command shell: ${shell}`,
100
+ `Path separator: ${info.pathSeparator}`,
101
+ 'When generating Bash tool commands, match this OS. On Windows, prefer PowerShell/cmd syntax instead of Linux-only commands.',
102
+ ].join('\n');
103
+ }
@@ -76,16 +76,10 @@ You are participating in the current session. Keep the user's context, answer fr
76
76
 
77
77
  - 使用紧凑的 GitHub 风格 Markdown。
78
78
  - 先给结论;不要一句话一段。
79
- <<<<<<< HEAD
80
79
  - 列表用于并列信息,不要把每句话都拆成列表项。
81
- - 围栏代码块 只用于代码、命令、配置、diff 或日志,并写语言标识。
82
- - 文件路径用 行内代码,例如 `agent/yeaft/prompts.js`。
83
- =======
84
- - 列表用于并列信息,不要把每句话都拆成 bullet。
85
- - fenced code block 只用于代码、命令、配置、diff 或日志,并写语言标识。
80
+ - 围栏代码块只用于代码、命令、配置、diff 或日志,并写语言标识。
86
81
  - 不要把普通说明、摘要、标签、标题、列表或单个词包进 fenced code block。
87
82
  - 文件路径、命令、标识符、状态值或短文本用 inline code,不要用 fenced code block。
88
83
  - 文件路径用 inline code,例如 `agent/yeaft/prompts.js`。
89
- >>>>>>> origin/main
90
84
  - 开发总结用 `改动 / 验证 / 风险` 或等价的简洁结构。
91
85
  - 评审用 `结论 / Findings / 验证`。
@@ -89,15 +89,10 @@
89
89
  - 使用 GitHub 风格 Markdown。
90
90
  - 普通说明写成紧凑自然段,不要一句话一段。
91
91
  - 并列信息用扁平列表,避免深层嵌套。
92
- <<<<<<< HEAD
93
92
  - 围栏代码块只用于真正的代码、命令、配置、diff、日志或用户需要精确复制的文本,并始终带语言标识。
94
- - 文件路径用行内代码,例如 `agent/yeaft/prompts.js`。
95
- =======
96
- - fenced code block 只用于真正的代码、命令、配置、diff、日志或用户需要精确复制的文本,并始终带语言标识。
97
93
  - 不要把普通说明、摘要、标签、标题、列表或单个词包进 fenced code block。
98
94
  - 文件路径、命令、标识符、状态值或短文本用 inline code,不要用 fenced code block。
99
95
  - 文件路径用 inline code,例如 `agent/yeaft/prompts.js`。
100
- >>>>>>> origin/main
101
96
  - 开发完成汇报使用:`改动`、`验证`、`风险 / 下一步`。
102
97
  - 评审使用:`结论`、`发现项`、`验证`。
103
98
  - 排障在需要时使用:`现象`、`证据`、`修复`、`验证`;简单问题保持更短。
@@ -4,13 +4,15 @@
4
4
  * Spawns a child process to run shell commands with timeout, output
5
5
  * truncation, working directory support, and cancellation via AbortSignal.
6
6
  *
7
- * Modeled after Claude Code's Bash tool implementation.
7
+ * The tool name remains Bash for wire compatibility. Internally it uses the
8
+ * platform default shell: POSIX shell on Linux/macOS, PowerShell/cmd on Windows.
8
9
  */
9
10
 
10
11
  import { defineTool } from './types.js';
11
12
  import { spawn } from 'child_process';
12
13
  import { existsSync } from 'fs';
13
14
  import { resolve } from 'path';
15
+ import { getRuntimePlatformInfo, resolveDefaultShell } from '../runtime-platform.js';
14
16
 
15
17
  /** Max output size in bytes before truncation (256 KB). */
16
18
  const MAX_OUTPUT = 256 * 1024;
@@ -21,18 +23,44 @@ const DEFAULT_TIMEOUT_MS = 120_000;
21
23
  /** Max timeout in ms (10 minutes). */
22
24
  const MAX_TIMEOUT_MS = 600_000;
23
25
 
26
+ /**
27
+ * @param {string} command
28
+ * @param {{ runtimePlatform?: object }} opts
29
+ */
30
+ export function buildShellInvocation(command, opts = {}) {
31
+ const runtimePlatform = opts.runtimePlatform || getRuntimePlatformInfo();
32
+ const shell = runtimePlatform.defaultShell
33
+ ? {
34
+ command: runtimePlatform.defaultShell,
35
+ argsPrefix: Array.isArray(runtimePlatform.shellArgsPrefix) ? runtimePlatform.shellArgsPrefix : null,
36
+ family: runtimePlatform.shellFamily,
37
+ }
38
+ : resolveDefaultShell({ platform: runtimePlatform.platform });
39
+
40
+ const argsPrefix = Array.isArray(shell.argsPrefix)
41
+ ? shell.argsPrefix
42
+ : resolveDefaultShell({ platform: runtimePlatform.platform }).argsPrefix;
43
+
44
+ return {
45
+ command: shell.command,
46
+ args: [...argsPrefix, command],
47
+ family: shell.family || runtimePlatform.shellFamily || 'posix',
48
+ };
49
+ }
50
+
24
51
  /**
25
52
  * Run a command in a child process.
26
53
  * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
27
54
  */
28
- function runCommand(command, { cwd, timeout, signal }) {
29
- return new Promise((resolve, reject) => {
30
- const shell = process.env.SHELL || '/bin/bash';
31
- const proc = spawn(shell, ['-c', command], {
55
+ function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
56
+ return new Promise((resolve) => {
57
+ const platform = runtimePlatform || getRuntimePlatformInfo();
58
+ const invocation = buildShellInvocation(command, { runtimePlatform: platform });
59
+ const proc = spawn(invocation.command, invocation.args, {
32
60
  cwd,
33
61
  env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
34
62
  stdio: ['ignore', 'pipe', 'pipe'],
35
- timeout,
63
+ detached: !platform.isWindows,
36
64
  });
37
65
 
38
66
  let stdout = '';
@@ -40,6 +68,31 @@ function runCommand(command, { cwd, timeout, signal }) {
40
68
  let stdoutTruncated = false;
41
69
  let stderrTruncated = false;
42
70
  let timedOut = false;
71
+ let settled = false;
72
+ let timeoutId = null;
73
+
74
+ const finish = (result) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ if (timeoutId) clearTimeout(timeoutId);
78
+ resolve(result);
79
+ };
80
+
81
+ const killProcess = () => {
82
+ try {
83
+ if (!platform.isWindows && proc.pid) {
84
+ process.kill(-proc.pid, 'SIGTERM');
85
+ return;
86
+ }
87
+ } catch {
88
+ // Fall back to killing the shell process below.
89
+ }
90
+ try {
91
+ proc.kill('SIGTERM');
92
+ } catch {
93
+ // ignore
94
+ }
95
+ };
43
96
 
44
97
  proc.stdout.on('data', (chunk) => {
45
98
  if (stdout.length < MAX_OUTPUT) {
@@ -61,46 +114,41 @@ function runCommand(command, { cwd, timeout, signal }) {
61
114
  }
62
115
  });
63
116
 
64
- // Handle abort signal
65
- const onAbort = () => {
66
- try { proc.kill('SIGTERM'); } catch {}
67
- setTimeout(() => {
68
- try { proc.kill('SIGKILL'); } catch {}
69
- }, 2000);
70
- };
117
+ timeoutId = setTimeout(() => {
118
+ timedOut = true;
119
+ killProcess();
120
+ finish({
121
+ stdout,
122
+ stderr: stderr + `\nProcess timed out after ${timeout}ms`,
123
+ exitCode: 124,
124
+ timedOut: true,
125
+ });
126
+ }, timeout);
127
+
71
128
  if (signal) {
72
- if (signal.aborted) { onAbort(); return; }
73
- signal.addEventListener('abort', onAbort, { once: true });
129
+ signal.addEventListener('abort', () => {
130
+ killProcess();
131
+ }, { once: true });
74
132
  }
75
133
 
76
- proc.on('close', (code) => {
77
- if (signal) signal.removeEventListener('abort', onAbort);
78
- resolve({
79
- stdout: stdoutTruncated ? stdout + '\n... (output truncated)' : stdout,
80
- stderr: stderrTruncated ? stderr + '\n... (stderr truncated)' : stderr,
81
- exitCode: code ?? 1,
134
+ proc.on('close', (code, signalName) => {
135
+ if (stdoutTruncated) stdout += '\n[Output truncated]';
136
+ if (stderrTruncated) stderr += '\n[Output truncated]';
137
+ finish({
138
+ stdout,
139
+ stderr,
140
+ exitCode: timedOut ? 124 : (code ?? (signalName ? 128 : 1)),
82
141
  timedOut,
83
142
  });
84
143
  });
85
144
 
86
145
  proc.on('error', (err) => {
87
- if (signal) signal.removeEventListener('abort', onAbort);
88
- if (err.code === 'ETIMEDOUT' || err.killed) {
89
- timedOut = true;
90
- resolve({
91
- stdout,
92
- stderr: stderr + `\nProcess timed out after ${timeout}ms`,
93
- exitCode: 124,
94
- timedOut: true,
95
- });
96
- } else {
97
- resolve({
98
- stdout,
99
- stderr: `Error spawning process: ${err.message}`,
100
- exitCode: 1,
101
- timedOut: false,
102
- });
103
- }
146
+ finish({
147
+ stdout,
148
+ stderr: `Error spawning process: ${err.message}`,
149
+ exitCode: 1,
150
+ timedOut: false,
151
+ });
104
152
  });
105
153
  });
106
154
  }
@@ -109,10 +157,13 @@ export default defineTool({
109
157
  name: 'Bash',
110
158
  description: `Execute a shell command and return its output.
111
159
 
112
- Use this tool to run CLI commands, scripts, and system operations.
160
+ Use this tool to run CLI commands, scripts, and system operations. The tool name
161
+ is kept as Bash for compatibility; on Windows the command is executed through
162
+ the configured Windows shell (PowerShell by default, or cmd when configured).
113
163
 
114
164
  Guidelines:
115
165
  - Commands run in the working directory (cwd from context)
166
+ - Match command syntax to the Agent OS shown in the runtime_platform prompt
116
167
  - Timeout defaults to 2 minutes (max 10 minutes)
117
168
  - Large outputs are truncated at 256KB
118
169
  - Use absolute paths when possible
@@ -124,7 +175,7 @@ Guidelines:
124
175
  properties: {
125
176
  command: {
126
177
  type: 'string',
127
- description: 'The shell command to execute',
178
+ description: 'The shell command to execute using the Agent OS default shell',
128
179
  },
129
180
  cwd: {
130
181
  type: 'string',
@@ -143,8 +194,9 @@ Guidelines:
143
194
  if (!input?.command) return false;
144
195
  const cmd = input.command.toLowerCase();
145
196
  return cmd.includes('rm ') || cmd.includes('rmdir') ||
197
+ cmd.includes('remove-item') || cmd.startsWith('del ') || cmd.includes(' del ') ||
146
198
  cmd.includes('git reset --hard') || cmd.includes('git clean') ||
147
- cmd.includes('dd ') || cmd.includes('mkfs') ||
199
+ cmd.includes('dd ') || cmd.includes('mkfs') || cmd.includes('format ') ||
148
200
  cmd.includes('> /dev/') || cmd.includes('chmod 000');
149
201
  },
150
202
  async execute(input, ctx) {
@@ -162,12 +214,14 @@ Guidelines:
162
214
 
163
215
  // Clamp timeout
164
216
  const timeout = Math.min(Math.max(timeout_ms || DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
217
+ const runtimePlatform = ctx?.runtimePlatform || getRuntimePlatformInfo();
165
218
 
166
219
  try {
167
220
  const result = await runCommand(command, {
168
221
  cwd,
169
222
  timeout,
170
223
  signal: ctx?.signal,
224
+ runtimePlatform,
171
225
  });
172
226
 
173
227
  // Format output similar to Claude Code
@@ -176,13 +230,13 @@ Guidelines:
176
230
  if (result.stderr) parts.push(`STDERR:\n${result.stderr}`);
177
231
  if (result.timedOut) parts.push(`\n(Command timed out after ${timeout}ms)`);
178
232
 
179
- const output = parts.join('\n') || '(no output)';
180
-
181
- return result.exitCode === 0
182
- ? output
183
- : `Exit code: ${result.exitCode}\n${output}`;
233
+ const output = parts.join('\n');
234
+ if (result.exitCode !== 0) {
235
+ return `Exit code: ${result.exitCode}\n${output}`;
236
+ }
237
+ return output || '(no output)';
184
238
  } catch (err) {
185
- return JSON.stringify({ error: `Bash execution failed: ${err.message}` });
239
+ return JSON.stringify({ error: err.message });
186
240
  }
187
241
  },
188
242
  });
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { defineTool } from './types.js';
12
- import { execSync } from 'child_process';
12
+ import { execFileSync } from 'child_process';
13
13
  import { existsSync, mkdirSync } from 'fs';
14
14
  import { join, resolve } from 'path';
15
15
  import { randomUUID } from 'crypto';
@@ -45,7 +45,7 @@ Returns the worktree path and branch name.`,
45
45
 
46
46
  // Verify we're in a git repo
47
47
  try {
48
- execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
48
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
49
49
  } catch {
50
50
  return JSON.stringify({ error: 'Not in a git repository' });
51
51
  }
@@ -75,9 +75,9 @@ Returns the worktree path and branch name.`,
75
75
  }
76
76
 
77
77
  try {
78
- // Create worktree with new branch
79
- const cmd = `git worktree add -b "${branchName}" "${worktreeDir}" ${baseRef}`;
80
- execSync(cmd, { cwd, stdio: 'pipe' });
78
+ // Create worktree with new branch. Use execFileSync args instead of
79
+ // shell quoting so Windows drive letters and spaces in paths survive.
80
+ execFileSync('git', ['worktree', 'add', '-b', branchName, worktreeDir, baseRef], { cwd, stdio: 'pipe' });
81
81
 
82
82
  return JSON.stringify({
83
83
  success: true,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { defineTool } from './types.js';
11
- import { execSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
12
12
  import { existsSync } from 'fs';
13
13
  import { resolve } from 'path';
14
14
 
@@ -66,7 +66,7 @@ unless discard_changes is set to true.`,
66
66
  // Check for uncommitted changes
67
67
  if (!input.discard_changes) {
68
68
  try {
69
- const status = execSync('git status --porcelain', {
69
+ const status = execFileSync('git', ['status', '--porcelain'], {
70
70
  cwd: worktreePath,
71
71
  encoding: 'utf8',
72
72
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -86,7 +86,7 @@ unless discard_changes is set to true.`,
86
86
  // Get branch name before removal
87
87
  let branchName = null;
88
88
  try {
89
- branchName = execSync('git rev-parse --abbrev-ref HEAD', {
89
+ branchName = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
90
90
  cwd: worktreePath,
91
91
  encoding: 'utf8',
92
92
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -95,9 +95,9 @@ unless discard_changes is set to true.`,
95
95
  // ignore
96
96
  }
97
97
 
98
- // Remove worktree
99
- const forceFlag = input.discard_changes ? ' --force' : '';
100
- execSync(`git worktree remove "${worktreePath}"${forceFlag}`, {
98
+ // Remove worktree. Use argv form so Windows paths with spaces or drive
99
+ // letters are passed to git unchanged.
100
+ execFileSync('git', ['worktree', 'remove', ...(input.discard_changes ? ['--force'] : []), worktreePath], {
101
101
  cwd: mainCwd,
102
102
  stdio: 'pipe',
103
103
  });
@@ -105,7 +105,7 @@ unless discard_changes is set to true.`,
105
105
  // Remove the branch if it was a yeaft worktree branch
106
106
  if (branchName && branchName.startsWith('yeaft-wt/')) {
107
107
  try {
108
- execSync(`git branch -D "${branchName}"`, {
108
+ execFileSync('git', ['branch', '-D', branchName], {
109
109
  cwd: mainCwd,
110
110
  stdio: 'pipe',
111
111
  });
@@ -0,0 +1,237 @@
1
+ const zh = {
2
+ Skill: {
3
+ description: '加载、查看和搜索 Yeaft skill 库中的工作流说明。用于发现某类任务是否已有可复用 skill,或读取指定 skill 的完整内容;不要用它执行普通文件搜索。',
4
+ parameters: {
5
+ action: '操作类型:list 列出 skill 元数据,view/load 读取指定 skill,search 按查询词匹配相关 skill。',
6
+ name: '要读取的 skill 名称,仅在 view/load 时需要。',
7
+ query: '搜索 skill 时使用的查询词,描述你要解决的任务。',
8
+ filePath: '读取目录型 skill 的关联文件路径,例如 references/style-guide.md。',
9
+ category: 'list 时可选的分类过滤条件。',
10
+ },
11
+ },
12
+ EnterWorktree: {
13
+ description: '为代码开发创建隔离 git worktree 和专用分支。任何功能开发、修 bug、测试性改动都应先进入 worktree,避免污染 main checkout;不要用它做普通目录切换。',
14
+ parameters: {
15
+ name: 'worktree 名称,会用于目录名和分支名;应使用有语义的 feat-/fix- 前缀。',
16
+ base_ref: '新分支基于的 git ref;通常使用 HEAD 或 origin/main。',
17
+ },
18
+ },
19
+ ExitWorktree: {
20
+ description: '结束一个 git worktree 会话。用于开发完成后保留或删除隔离 worktree;删除有未提交改动的 worktree 前必须确认这些改动不再需要。',
21
+ parameters: {
22
+ path: '要退出的 worktree 路径。',
23
+ action: 'keep 表示保留目录和分支;remove 表示删除 worktree 目录并删除分支。',
24
+ discard_changes: 'remove 时是否丢弃未提交改动;只有确认改动已提交/合并或明确不需要时才设为 true。',
25
+ },
26
+ },
27
+ AskUser: {
28
+ description: '向用户提出一个真正阻塞继续推进的问题并等待回答。只在缺少关键信息会导致错误或不安全操作时使用;不要把它当作普通说明或反问。',
29
+ parameters: {
30
+ question: '要问用户的具体问题,应说明为什么需要这个信息。',
31
+ options: '可选答案列表;当用户只需从固定选项中选择时提供。',
32
+ },
33
+ },
34
+ WebSearch: {
35
+ description: '搜索 Web 获取最新信息。用于查当前文档、版本、新闻、API 变更等训练数据可能过期的内容;已知 URL 应直接用 WebFetch。',
36
+ parameters: {
37
+ query: '搜索查询词;对时效性问题应包含年份、产品名或版本号。',
38
+ limit: '最多返回多少条搜索结果。',
39
+ },
40
+ },
41
+ WebFetch: {
42
+ description: '抓取并读取指定 URL 的页面或 API 响应。用于阅读文档、文章、PR 页面或接口返回;不要用于本地文件,本地文件请用 FileRead。',
43
+ parameters: {
44
+ url: '完整 URL,必须包含 http:// 或 https://。',
45
+ max_length: '返回内容的最大字符数;页面很大时提高该值或分段读取。',
46
+ raw: '是否返回原始响应体;读取 API/JSON 时设为 true,普通网页通常设为 false。',
47
+ },
48
+ },
49
+ HistorySearch: {
50
+ description: '搜索已持久化的历史对话消息。用于找之前的决策、用户偏好、代码片段或上下文;不要用它搜索当前工作区文件。',
51
+ parameters: {
52
+ keyword: '大小写不敏感的搜索关键词。',
53
+ limit: '最多返回多少条结果。',
54
+ },
55
+ },
56
+ Bash: {
57
+ description: '在 shell 中执行非交互式命令。用于运行测试、git/gh 命令、脚本和确定性诊断;避免交互式程序,不要执行未经用户允许的破坏性命令。',
58
+ parameters: {
59
+ command: '要执行的 shell 命令;需要引用文件路径时正确加引号。',
60
+ cwd: '命令运行目录;代码改动和测试应在对应 worktree 中运行。',
61
+ timeout_ms: '超时时间毫秒;长测试或构建可适当提高但不能超过工具上限。',
62
+ },
63
+ },
64
+ FileRead: {
65
+ description: '读取文本文件并带行号返回内容。编辑前必须先读文件;已知路径时直接使用它,不要先用 shell cat。',
66
+ parameters: {
67
+ file_path: '要读取的文件路径,可为绝对路径或相对当前工作目录。',
68
+ offset: '从第几行开始读取,0 基;只有大文件或明确行段时才需要。',
69
+ limit: '最多读取多少行;普通文件默认整段读取即可。',
70
+ },
71
+ },
72
+ FileWrite: {
73
+ description: '写入完整文件内容,会创建父目录并覆盖已有文件。适合新建文件或完整重写;修改已有文件时优先用 FileEdit 做小范围替换。',
74
+ parameters: {
75
+ file_path: '要写入的文件路径。',
76
+ content: '完整文件内容,不是补丁或片段。',
77
+ },
78
+ },
79
+ FileEdit: {
80
+ description: '在已有文件中做精确文本替换。使用前必须读取文件;old_string 必须和文件内容完全一致,除非明确 replace_all,否则必须唯一。',
81
+ parameters: {
82
+ file_path: '要编辑的文件路径。',
83
+ old_string: '要查找并替换的精确文本,必须与文件内容完全一致,包含空格、缩进和换行。',
84
+ new_string: '替换后的文本。',
85
+ replace_all: '是否替换所有匹配项;默认只允许唯一匹配,避免误改。',
86
+ },
87
+ },
88
+ Glob: {
89
+ description: '按 glob 模式查找文件路径。用于只知道文件名模式或扩展名时定位文件;如果要搜内容请用 Grep。',
90
+ parameters: {
91
+ pattern: 'glob 模式,例如 **/*.js 或 src/**/*.ts。',
92
+ path: '搜索起始目录。',
93
+ limit: '最多返回多少个文件。',
94
+ },
95
+ },
96
+ Grep: {
97
+ description: '用正则搜索文件内容。用于快速定位符号、错误文本、调用点或配置项;应结合 path、glob 或 type 缩小范围。',
98
+ parameters: {
99
+ pattern: '要搜索的正则表达式;特殊字符需要转义。',
100
+ path: '要搜索的文件或目录。',
101
+ output_mode: '输出模式:content 显示匹配行,files_with_matches 只列文件,count 显示计数。',
102
+ glob: '文件名过滤 glob,例如 *.{js,css}。',
103
+ type: '文件类型过滤,例如 js、py、rust。',
104
+ case_insensitive: '是否忽略大小写。',
105
+ context: 'content 模式下每个匹配周围显示的上下文行数。',
106
+ before: 'content 模式下每个匹配前显示的行数。',
107
+ after: 'content 模式下每个匹配后显示的行数。',
108
+ multiline: '是否启用多行正则匹配。',
109
+ head_limit: '最多返回多少条匹配结果。',
110
+ },
111
+ },
112
+ ListDir: {
113
+ description: '列出目录内容和文件大小。用于了解目录结构;需要查找模式时用 Glob,需要搜内容时用 Grep。',
114
+ parameters: {
115
+ path: '要列出的目录路径。',
116
+ show_hidden: '是否显示以点开头的隐藏文件。',
117
+ },
118
+ },
119
+ ApplyPatch: {
120
+ description: '应用 unified diff 补丁到文件。适合一次修改多个位置或创建新文件;补丁必须和当前文件内容匹配,简单替换优先用 FileEdit。',
121
+ parameters: {
122
+ patch: '标准 unified diff 内容,包含 ---、+++ 和 @@ hunk。',
123
+ },
124
+ },
125
+ SpawnAgent: {
126
+ description: '启动一个后台子 Agent 处理独立任务。用于单 VP 场景下的并行调查、测试、评审或长任务;启动后不要阻塞等待,继续主任务,并用 ListAgents 非阻塞查看状态或等待通知回灌。',
127
+ parameters: {
128
+ name: '子 Agent 的简短名称,便于识别。',
129
+ task: '给子 Agent 的明确任务说明。',
130
+ mission: '任务目标、范围和成功标准。',
131
+ expected_output: '期望子 Agent 最终交付的结果格式。',
132
+ persona: '可选人格或工作风格说明。',
133
+ budget: '子 Agent 的资源预算。',
134
+ 'budget.max_tokens': '允许子 Agent 消耗的最大 token 数。',
135
+ 'budget.max_turns': '允许子 Agent 执行的最大 turn 数。',
136
+ 'budget.wall_time_ms': '最长运行时间毫秒;超时应被标记并终止,避免 running zombie。',
137
+ cwd: '子 Agent 的工作目录。',
138
+ },
139
+ },
140
+ PromptAgent: {
141
+ description: '向已存在的子 Agent 追加消息。仅用于继续或补充一个已启动的子 Agent;不要用它替代 SpawnAgent 创建新任务。',
142
+ parameters: {
143
+ agent_id: '目标子 Agent id。',
144
+ message: '发送给子 Agent 的消息。',
145
+ },
146
+ },
147
+ WaitAgent: {
148
+ description: '兼容用的短轮询工具,用于快速查看某个子 Agent 是否已有结果。不要把它当作主流程循环等待;需要状态时优先用 ListAgents,长任务应后台运行并等待通知。',
149
+ parameters: {
150
+ agent_id: '要检查的子 Agent id。',
151
+ timeout_ms: '最多等待多少毫秒;应保持很短,避免阻塞父 VP。',
152
+ },
153
+ },
154
+ CloseAgent: {
155
+ description: '关闭子 Agent 并可记录最终结果。用于用户要求停止、任务已被主流程接管、或子 Agent 不再需要时;不要关闭无关 Agent。',
156
+ parameters: {
157
+ agent_id: '要关闭的子 Agent id。',
158
+ result: '可选的最终结果或关闭原因。',
159
+ },
160
+ },
161
+ ListAgents: {
162
+ description: '非阻塞列出当前 VP 拥有的子 Agent 状态。用于查看后台任务是否 running、stale、completed 或 failed,以及读取结果摘要和输出文件路径。',
163
+ parameters: {
164
+ include_closed: '是否包含已关闭的子 Agent。',
165
+ include_terminal: '是否包含 completed、failed、abandoned 等终态子 Agent。',
166
+ },
167
+ },
168
+ RouteForward: {
169
+ description: '把当前任务或问题明确转交给同一 Session 中的其他 VP。多 VP 场景下,只要用户点名其他 VP、任务属于其他 VP 职责、需要并行协作、或你需要另一个 VP 继续处理,就必须调用这个工具;在聊天文本里写 @名字 不会真正路由。',
170
+ parameters: {
171
+ to: '目标 VP id,或使用 all 广播给其他成员;不能填自己。',
172
+ text: '要代表你转发给目标 VP 的完整任务内容,必须包含必要上下文和明确期望。',
173
+ reason: '可选的简短转发理由,用于审计和界面展示。',
174
+ },
175
+ },
176
+ TodoWrite: {
177
+ description: '维护用户可见的多步骤任务清单。任务包含 3 个以上有意义步骤、用户给了列表、或即将做复杂多文件改动时必须使用;不要为单个琐碎动作制造清单。',
178
+ parameters: {
179
+ todos: '完整的当前 todo 列表;每次调用都要发送全量列表,不是增量 diff。',
180
+ 'todos[].content': '命令式步骤描述,例如“运行测试”。',
181
+ 'todos[].status': '步骤状态;任意时刻最多只能有一个 in_progress。',
182
+ 'todos[].activeForm': '该步骤执行中展示的进行时文案,例如“正在运行测试”。',
183
+ },
184
+ },
185
+ StartPlan: {
186
+ description: '进入规划模式,为非琐碎任务先形成短计划再继续执行。用户要求计划/思考、任务多步骤、范围不清或大型改动前应使用;除非第一步被用户信息阻塞,否则计划后继续推进。',
187
+ parameters: {
188
+ topic: '一句话说明正在规划的主题。',
189
+ userProblem: '用户真正想解决的底层问题,可选。',
190
+ stuckAt: '当前阻塞点或必须先决策的未知,可选。',
191
+ expectedScale: '预估规模,例如文件数、代码量或时间范围,可选。',
192
+ additionalContext: '影响计划的其他事实、约束或背景,可选。',
193
+ },
194
+ },
195
+ JsRepl: {
196
+ description: '在持久 JavaScript REPL 中执行代码。用于计算、数据转换和快速实验;不能访问文件系统或网络,状态会在多次调用之间保留。',
197
+ parameters: {
198
+ code: '要执行的 JavaScript 代码;reset=true 且只清空状态时可省略。',
199
+ reset: '是否先重置 REPL 上下文再执行代码。',
200
+ },
201
+ },
202
+ JsReplReset: {
203
+ description: '已废弃的 JavaScript REPL 重置工具。新调用应使用 JsRepl 并设置 reset=true;仅为兼容旧调用保留。',
204
+ parameters: {},
205
+ },
206
+ NotebookEdit: {
207
+ description: '读取或编辑 Jupyter notebook 单元格。用于 .ipynb 文件的精确 cell 级修改;普通文本文件不要用它。',
208
+ parameters: {
209
+ notebook_path: '目标 .ipynb 文件路径。',
210
+ action: '操作类型:read、replace、insert 或 delete。',
211
+ cell_index: '单元格索引,0 基。',
212
+ cell_type: '插入或替换时的单元格类型:code 或 markdown。',
213
+ source: '插入或替换的单元格源码内容。',
214
+ },
215
+ },
216
+ ImageGeneration: {
217
+ description: '根据文本描述生成图片并保存到工作目录。用于明确要求生成图像的任务;分析已有图片应使用 ViewImage。',
218
+ parameters: {
219
+ prompt: '详细、具体的图片生成描述,包含风格、构图和氛围。',
220
+ output_path: '生成图片保存路径。',
221
+ size: '图片尺寸。',
222
+ },
223
+ },
224
+ ViewImage: {
225
+ description: '读取本地图片文件并附加到对话中供模型分析。用于用户引用本地截图、图表或设计稿时;远程图片 URL 不用它。',
226
+ parameters: {
227
+ file_path: '图片文件路径,必须在项目目录或允许访问的目录内。',
228
+ },
229
+ },
230
+ };
231
+
232
+ export const BUILTIN_TOOL_LOCALIZED_DESCRIPTIONS = Object.freeze({ zh: Object.freeze(zh) });
233
+
234
+ export function getBuiltinToolLocalization(toolName, language) {
235
+ if (!String(language || '').toLowerCase().startsWith('zh')) return null;
236
+ return BUILTIN_TOOL_LOCALIZED_DESCRIPTIONS.zh[toolName] || null;
237
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * path-safety.js — Cross-platform path containment helpers for tools.
3
+ */
4
+
5
+ import path from 'path';
6
+
7
+ /**
8
+ * Return true when child is equal to or inside parent for the supplied path
9
+ * implementation (`path` on the host, or `path.win32`/`path.posix` in tests).
10
+ *
11
+ * @param {string} parent
12
+ * @param {string} child
13
+ * @param {{ relative: Function, isAbsolute: Function, resolve: Function }} [pathImpl]
14
+ */
15
+ export function isPathInsideOrEqual(parent, child, pathImpl = path) {
16
+ if (!parent || !child) return false;
17
+ const base = pathImpl.resolve(parent);
18
+ const target = pathImpl.resolve(child);
19
+ const rel = pathImpl.relative(base, target);
20
+ return rel === '' || (!!rel && !rel.startsWith('..') && !pathImpl.isAbsolute(rel));
21
+ }
22
+
23
+ /**
24
+ * @param {string} absPath
25
+ * @param {string} cwd
26
+ * @param {string[]} [allowlist]
27
+ * @param {{ relative: Function, isAbsolute: Function, resolve: Function }} [pathImpl]
28
+ */
29
+ export function checkPathAllowed(absPath, cwd, allowlist = [], pathImpl = path) {
30
+ if (isPathInsideOrEqual(cwd, absPath, pathImpl)) return null;
31
+
32
+ if (Array.isArray(allowlist)) {
33
+ for (const dir of allowlist) {
34
+ if (typeof dir !== 'string' || !pathImpl.isAbsolute(dir)) continue;
35
+ if (isPathInsideOrEqual(dir, absPath, pathImpl)) return null;
36
+ }
37
+ }
38
+
39
+ const inputWasAbs = pathImpl.isAbsolute(absPath);
40
+ return inputWasAbs
41
+ ? {
42
+ kind: 'absolute_outside_allowlist',
43
+ message: 'Absolute image paths must be inside the project directory or a ctx.imageAllowlist directory.',
44
+ }
45
+ : {
46
+ kind: 'relative_escape',
47
+ message: 'Relative image paths may not escape the working directory.',
48
+ };
49
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { formatSize } from '../archive/tool-results.js';
13
+ import { getBuiltinToolLocalization } from './localized-descriptions.js';
13
14
 
14
15
  /**
15
16
  * Collaboration tools are mutually exclusive per Yeaft group shape:
@@ -93,7 +94,37 @@ function localizeVisibleText(value, language, toolName) {
93
94
  * is actually a string. Object/array values under a `description` key
94
95
  * are sub-schemas and must be recursed into normally.
95
96
  */
96
- function localizeParameters(parameters, language, toolName) {
97
+ function cloneSchema(value) {
98
+ if (!value || typeof value !== 'object') return value;
99
+ if (Array.isArray(value)) return value.map(cloneSchema);
100
+ const out = {};
101
+ for (const [key, child] of Object.entries(value)) out[key] = cloneSchema(child);
102
+ return out;
103
+ }
104
+
105
+ function schemaAtPath(schema, path) {
106
+ const parts = String(path || '').split('.').filter(Boolean);
107
+ let node = schema;
108
+ for (const rawPart of parts) {
109
+ const part = rawPart.endsWith('[]') ? rawPart.slice(0, -2) : rawPart;
110
+ node = node?.properties?.[part];
111
+ if (!node) return null;
112
+ if (rawPart.endsWith('[]')) node = node.items;
113
+ }
114
+ return node || null;
115
+ }
116
+
117
+ function applyParameterOverrides(parameters, overrides) {
118
+ if (!overrides || !parameters || typeof parameters !== 'object') return parameters;
119
+ const out = cloneSchema(parameters);
120
+ for (const [path, description] of Object.entries(overrides)) {
121
+ const schema = schemaAtPath(out, path);
122
+ if (schema && typeof description === 'string') schema.description = description;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ function localizeParameters(parameters, language, toolName, parameterOverrides = null) {
97
128
  const lang = normalizeLanguage(language);
98
129
  if (lang !== 'zh' || !parameters || typeof parameters !== 'object') return parameters;
99
130
  if (Array.isArray(parameters)) return parameters.map(v => localizeParameters(v, lang, toolName));
@@ -102,12 +133,12 @@ function localizeParameters(parameters, language, toolName) {
102
133
  if (key === 'description' && typeof value === 'string') {
103
134
  out[key] = localizeVisibleText(value, lang, toolName);
104
135
  } else if (value && typeof value === 'object') {
105
- out[key] = localizeParameters(value, lang, toolName);
136
+ out[key] = localizeParameters(value, lang, toolName, null);
106
137
  } else {
107
138
  out[key] = value;
108
139
  }
109
140
  }
110
- return out;
141
+ return applyParameterOverrides(out, parameterOverrides);
111
142
  }
112
143
 
113
144
  /**
@@ -342,11 +373,14 @@ export class ToolRegistry {
342
373
  const collabToolPolicy = normalizeCollabToolPolicy(opts?.collabToolPolicy);
343
374
  return this.getAllTools()
344
375
  .filter(t => !isToolHiddenByCollabPolicy(t.name, collabToolPolicy))
345
- .map(t => ({
346
- name: t.name,
347
- description: localizeVisibleText(t.description, lang, t.name),
348
- parameters: localizeParameters(t.parameters, lang, t.name),
349
- }));
376
+ .map(t => {
377
+ const localized = getBuiltinToolLocalization(t.name, lang);
378
+ return {
379
+ name: t.name,
380
+ description: localized?.description || localizeVisibleText(t.description, lang, t.name),
381
+ parameters: localizeParameters(t.parameters, lang, t.name, localized?.parameters),
382
+ };
383
+ });
350
384
  }
351
385
 
352
386
  /**
@@ -14,6 +14,8 @@
14
14
  * @typedef {Object} ToolContext
15
15
  * @property {AbortSignal} [signal] — cancellation signal
16
16
  * @property {string} [yeaftDir] — Yeaft data directory
17
+ * @property {ReturnType<import('../runtime-platform.js').getRuntimePlatformInfo>} [runtimePlatform]
18
+ * — runtime OS/shell facts for platform-aware tools
17
19
  * @property {string} [cwd] — working directory
18
20
  * @property {import('../mcp.js').MCPManager} [mcpManager] — MCP manager
19
21
  * @property {object} [skillManager] — Skill manager
@@ -27,7 +27,8 @@
27
27
  import { defineTool } from './types.js';
28
28
  import { stat, readFile } from 'fs/promises';
29
29
  import { existsSync } from 'fs';
30
- import { resolve, extname, isAbsolute, relative } from 'path';
30
+ import { resolve, extname, isAbsolute } from 'path';
31
+ import { checkPathAllowed } from './path-safety.js';
31
32
 
32
33
  /** Default max image size in bytes (20 MiB). Override via ctx.maxImageBytes. */
33
34
  const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
@@ -109,36 +110,6 @@ function parseImageDimensions(buffer, ext) {
109
110
  return null;
110
111
  }
111
112
 
112
- /**
113
- * Check whether `absPath` is allowed given a project `cwd` and an optional
114
- * allowlist of absolute directories. Returns `null` on success, or an object
115
- * `{ kind, message }` describing the failure. The `kind` field lets callers
116
- * tailor the error text (see prev-3 P2: distinguish "absolute path outside
117
- * project" from "relative path containing ..").
118
- */
119
- function checkPathAllowed(absPath, cwd, allowlist) {
120
- // Reject if the resolved path lives inside the project (good).
121
- const relToCwd = relative(cwd, absPath);
122
- const insideCwd = relToCwd && !relToCwd.startsWith('..') && !isAbsolute(relToCwd);
123
- if (insideCwd) return null;
124
-
125
- // Otherwise must match an allowlist entry.
126
- if (Array.isArray(allowlist) && allowlist.length > 0) {
127
- for (const dir of allowlist) {
128
- if (typeof dir !== 'string' || !isAbsolute(dir)) continue;
129
- const rel = relative(dir, absPath);
130
- if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return null;
131
- }
132
- }
133
-
134
- return {
135
- kind: 'path_outside',
136
- message:
137
- 'Path is outside the project directory and not on the image allowlist. ' +
138
- 'Either move the file into the project, or ask the user to add its parent ' +
139
- 'directory to ctx.imageAllowlist (set via ~/.yeaft/config.json imageAllowlist[]).',
140
- };
141
- }
142
113
 
143
114
  function formatBytes(n) {
144
115
  if (n < 1024) return `${n}B`;