@yeaft/webchat-agent 1.0.50 → 1.0.52

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.
@@ -32,7 +32,7 @@ const isWin = platform() === 'win32';
32
32
  // macOS/Linux: use absolute path — launchd/systemd have minimal PATH.
33
33
  const npmPath = isWin ? 'npm' : join(nodeBinDir, 'npm');
34
34
  const pm2Path = isWin ? 'pm2' : join(nodeBinDir, 'pm2');
35
- const shellOpt = isWin ? { shell: true } : {};
35
+ const shellOpt = isWin ? { shell: true, windowsHide: true } : {};
36
36
 
37
37
  // Ensure PATH includes nodeBinDir so that `#!/usr/bin/env node` shebangs
38
38
  // can locate node in launchd/systemd environments with minimal PATH.
package/crew/worktree.js CHANGED
@@ -27,7 +27,7 @@ export async function initWorktrees(projectDir, roles) {
27
27
  // 获取 git 已知的 worktree 列表
28
28
  let knownWorktrees = new Set();
29
29
  try {
30
- const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], { cwd: projectDir });
30
+ const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], { cwd: projectDir, windowsHide: true });
31
31
  for (const line of stdout.split('\n')) {
32
32
  if (line.startsWith('worktree ')) {
33
33
  knownWorktrees.add(line.slice('worktree '.length).trim());
@@ -66,13 +66,13 @@ export async function initWorktrees(projectDir, roles) {
66
66
  try {
67
67
  // 创建分支(如果不存在)
68
68
  try {
69
- await execFile('git', ['branch', branch], { cwd: projectDir });
69
+ await execFile('git', ['branch', branch], { cwd: projectDir, windowsHide: true });
70
70
  } catch {
71
71
  // 分支已存在,忽略
72
72
  }
73
73
 
74
74
  // 创建 worktree
75
- await execFile('git', ['worktree', 'add', wtDir, branch], { cwd: projectDir });
75
+ await execFile('git', ['worktree', 'add', wtDir, branch], { cwd: projectDir, windowsHide: true });
76
76
  console.log(`[Crew] Created worktree: ${wtDir} on branch ${branch}`);
77
77
  worktreeMap.set(idx, wtDir);
78
78
  } catch (e) {
@@ -104,14 +104,14 @@ export async function cleanupWorktrees(projectDir) {
104
104
  const branch = `crew/${entry}`;
105
105
 
106
106
  try {
107
- await execFile('git', ['worktree', 'remove', wtDir, '--force'], { cwd: projectDir });
107
+ await execFile('git', ['worktree', 'remove', wtDir, '--force'], { cwd: projectDir, windowsHide: true });
108
108
  console.log(`[Crew] Removed worktree: ${wtDir}`);
109
109
  } catch (e) {
110
110
  console.warn(`[Crew] Failed to remove worktree ${wtDir}:`, e.message);
111
111
  }
112
112
 
113
113
  try {
114
- await execFile('git', ['branch', '-D', branch], { cwd: projectDir });
114
+ await execFile('git', ['branch', '-D', branch], { cwd: projectDir, windowsHide: true });
115
115
  console.log(`[Crew] Deleted branch: ${branch}`);
116
116
  } catch (e) {
117
117
  console.warn(`[Crew] Failed to delete branch ${branch}:`, e.message);
package/history.js CHANGED
@@ -2,6 +2,7 @@ import { homedir } from 'os';
2
2
  import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import ctx from './context.js';
5
+ import { normalizeClaudeMessage } from './sdk/message-normalize.js';
5
6
  import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
6
7
 
7
8
  // Claude 项目目录
@@ -227,7 +228,7 @@ function readTailMessages(filePath, limit) {
227
228
  const line = lines[i];
228
229
  if (!line || !line.trim()) continue;
229
230
  try {
230
- const data = JSON.parse(line);
231
+ const data = normalizeClaudeMessage(JSON.parse(line));
231
232
  if (data.type === 'user' || data.type === 'assistant') {
232
233
  collected.push(data);
233
234
  if (collected.length >= limit) break;
@@ -241,7 +242,7 @@ function readTailMessages(filePath, limit) {
241
242
  const headLine = carry.toString('utf-8').trim();
242
243
  if (headLine) {
243
244
  try {
244
- const data = JSON.parse(headLine);
245
+ const data = normalizeClaudeMessage(JSON.parse(headLine));
245
246
  if (data.type === 'user' || data.type === 'assistant') {
246
247
  collected.push(data);
247
248
  }
@@ -287,7 +288,7 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
287
288
 
288
289
  for (const line of lines) {
289
290
  try {
290
- const data = JSON.parse(line);
291
+ const data = normalizeClaudeMessage(JSON.parse(line));
291
292
  if (data.type === 'user' || data.type === 'assistant') {
292
293
  messages.push(data);
293
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.50",
3
+ "version": "1.0.52",
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",
@@ -99,7 +99,7 @@ function _probeAcpModels() {
99
99
  if (err) reject(err); else resolve(models || []);
100
100
  };
101
101
  try {
102
- child = spawn('copilot', ['--acp'], { stdio: ['pipe', 'pipe', 'pipe'] });
102
+ child = spawn('copilot', ['--acp'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
103
103
  } catch (e) { return reject(e); }
104
104
  const timer = setTimeout(() => finish(new Error('ACP probe timeout')), PROBE_TIMEOUT_MS);
105
105
  child.on('error', (e) => finish(e));
@@ -0,0 +1,129 @@
1
+ const TOOL_LIKE_BLOCK_TYPES = new Set([
2
+ 'tool_use',
3
+ 'tool_call',
4
+ 'function_call',
5
+ 'server_tool_use',
6
+ 'call',
7
+ ]);
8
+
9
+ /**
10
+ * Claude Code stream-json has changed its tool-call block spelling more than
11
+ * once. Normalize every known tool-like assistant content block to the
12
+ * Anthropic-compatible `tool_use` shape used by the rest of the app.
13
+ *
14
+ * @param {unknown} block
15
+ * @returns {unknown}
16
+ */
17
+ export function normalizeAssistantContentBlock(block) {
18
+ if (!block || typeof block !== 'object') return block;
19
+ if (block.type === 'tool_use') return block;
20
+ if (!TOOL_LIKE_BLOCK_TYPES.has(block.type)) return block;
21
+
22
+ const input = normalizeToolInput(block);
23
+ const name = block.name
24
+ || block.tool_name
25
+ || block.toolName
26
+ || block.function?.name
27
+ || block.action?.name
28
+ || input?.name
29
+ || 'tool';
30
+ const id = block.id
31
+ || block.tool_use_id
32
+ || block.toolUseId
33
+ || block.tool_call_id
34
+ || block.toolCallId
35
+ || block.call_id
36
+ || block.callId
37
+ || stableFallbackToolId(name, block);
38
+
39
+ return {
40
+ type: 'tool_use',
41
+ id,
42
+ name,
43
+ input,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} content
49
+ * @returns {unknown}
50
+ */
51
+ export function normalizeAssistantContent(content) {
52
+ if (!Array.isArray(content)) return content;
53
+ return content.map(normalizeAssistantContentBlock);
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} message
58
+ * @returns {unknown}
59
+ */
60
+ export function normalizeClaudeMessage(message) {
61
+ if (!message || typeof message !== 'object') return message;
62
+ if (message.type !== 'assistant' || !message.message) return message;
63
+ const content = message.message.content;
64
+ const normalized = normalizeAssistantContent(content);
65
+ if (normalized === content) return message;
66
+ return {
67
+ ...message,
68
+ message: {
69
+ ...message.message,
70
+ content: normalized,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Stream events should only emit text deltas from actual text blocks. Newer
77
+ * Claude Code builds may emit text-looking deltas while a tool-call block is
78
+ * being assembled (`call`, transient id, JSON argument text). Those bytes are
79
+ * transport detail, not assistant prose; the final complete assistant message
80
+ * carries the real tool_use block.
81
+ *
82
+ * @param {string|undefined|null} blockType
83
+ * @returns {boolean}
84
+ */
85
+ export function shouldForwardTextDeltaForBlockType(blockType) {
86
+ if (!blockType) return true; // Back-compat for older CLIs without start events.
87
+ return blockType === 'text';
88
+ }
89
+
90
+ function stableFallbackToolId(name, block) {
91
+ const raw = stableStringify(block);
92
+ let hash = 2166136261;
93
+ for (let i = 0; i < raw.length; i++) {
94
+ hash ^= raw.charCodeAt(i);
95
+ hash = Math.imul(hash, 16777619) >>> 0;
96
+ }
97
+ return `${name}-${hash.toString(36)}`;
98
+ }
99
+
100
+ function stableStringify(value) {
101
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
102
+ if (value && typeof value === 'object') {
103
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
104
+ }
105
+ return JSON.stringify(value);
106
+ }
107
+
108
+ function normalizeToolInput(block) {
109
+ const raw = block.input
110
+ ?? block.arguments
111
+ ?? block.args
112
+ ?? block.parameters
113
+ ?? block.function?.arguments
114
+ ?? block.action?.arguments
115
+ ?? {};
116
+
117
+ if (typeof raw === 'string') {
118
+ const trimmed = raw.trim();
119
+ if (!trimmed) return {};
120
+ try {
121
+ const parsed = JSON.parse(trimmed);
122
+ return parsed && typeof parsed === 'object' ? parsed : { value: parsed };
123
+ } catch {
124
+ return { arguments: raw };
125
+ }
126
+ }
127
+ if (raw && typeof raw === 'object') return raw;
128
+ return {};
129
+ }
package/sdk/query.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from 'child_process';
7
7
  import { createInterface } from 'readline';
8
8
  import { Stream } from './stream.js';
9
9
  import { AbortError } from './types.js';
10
+ import { normalizeClaudeMessage, shouldForwardTextDeltaForBlockType } from './message-normalize.js';
10
11
  import { getCleanEnv, logDebug, streamToStdin, resolveClaudeCommand } from './utils.js';
11
12
 
12
13
 
@@ -82,6 +83,7 @@ export class Query {
82
83
  // Track whether we've forwarded text deltas for the current assistant turn.
83
84
  // When true, the next complete `assistant` message's text blocks are redundant.
84
85
  let hasStreamedTextDeltas = false;
86
+ const streamBlockTypes = new Map();
85
87
 
86
88
  try {
87
89
  for await (const line of rl) {
@@ -114,36 +116,52 @@ export class Query {
114
116
  const event = message.event;
115
117
  if (!event) continue;
116
118
 
117
- // content_block_delta with text_delta → convert to assistant message for streaming
119
+ if (event.type === 'content_block_start') {
120
+ streamBlockTypes.set(event.index, event.content_block?.type || null);
121
+ continue;
122
+ }
123
+ if (event.type === 'content_block_stop') {
124
+ streamBlockTypes.delete(event.index);
125
+ continue;
126
+ }
127
+ // content_block_delta with text_delta → convert to assistant message for streaming,
128
+ // but only while the active block is actually text. Some Claude Code builds
129
+ // expose tool-call assembly as text-looking deltas; forwarding those leaks
130
+ // raw `call`, transient ids, and JSON arguments into the chat transcript.
118
131
  if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
119
- hasStreamedTextDeltas = true;
120
- this.inputStream.enqueue({
121
- type: 'assistant',
122
- message: {
123
- role: 'assistant',
124
- content: [{ type: 'text', text: event.delta.text }]
125
- }
126
- });
132
+ const blockType = streamBlockTypes.get(event.index);
133
+ if (shouldForwardTextDeltaForBlockType(blockType)) {
134
+ hasStreamedTextDeltas = true;
135
+ this.inputStream.enqueue({
136
+ type: 'assistant',
137
+ message: {
138
+ role: 'assistant',
139
+ content: [{ type: 'text', text: event.delta.text }]
140
+ }
141
+ });
142
+ }
127
143
  }
128
- // All other stream events (message_start, content_block_start,
129
- // input_json_delta, content_block_stop, message_stop) are ignored.
130
- // tool_use is handled via the complete assistant message.
144
+ // All other stream events (message_start, input_json_delta,
145
+ // message_stop) are ignored. Tool use is handled via the complete
146
+ // assistant message after block normalization below.
131
147
  continue;
132
148
  }
133
149
 
150
+ const normalizedMessage = normalizeClaudeMessage(message);
151
+
134
152
  // Deduplicate: when a complete assistant message arrives after we've
135
153
  // already streamed text deltas, strip the text blocks (already sent).
136
154
  // Keep tool_use blocks which are NOT sent incrementally.
137
- if (message.type === 'assistant' && hasStreamedTextDeltas) {
155
+ if (normalizedMessage.type === 'assistant' && hasStreamedTextDeltas) {
138
156
  hasStreamedTextDeltas = false; // Reset for next assistant turn
139
157
 
140
- const content = message.message?.content;
158
+ const content = normalizedMessage.message?.content;
141
159
  if (Array.isArray(content)) {
142
160
  const nonTextBlocks = content.filter(b => b.type !== 'text');
143
161
  if (nonTextBlocks.length > 0) {
144
162
  // Forward only tool_use blocks (text already sent via deltas)
145
- message.message.content = nonTextBlocks;
146
- this.inputStream.enqueue(message);
163
+ normalizedMessage.message.content = nonTextBlocks;
164
+ this.inputStream.enqueue(normalizedMessage);
147
165
  } else {
148
166
  // Pure text message fully streamed — send finish-streaming signal
149
167
  // so frontend clears isStreaming and typing dots can reappear
@@ -165,11 +183,12 @@ export class Query {
165
183
 
166
184
  // Reset delta tracking on non-assistant messages
167
185
  // (e.g., user, result, system — a new turn boundary)
168
- if (message.type !== 'assistant') {
186
+ if (normalizedMessage.type !== 'assistant') {
169
187
  hasStreamedTextDeltas = false;
188
+ streamBlockTypes.clear();
170
189
  }
171
190
 
172
- this.inputStream.enqueue(message);
191
+ this.inputStream.enqueue(normalizedMessage);
173
192
  } catch (e) {
174
193
  logDebug(`Non-JSON line: ${line.substring(0, 100)}`);
175
194
  }
package/yeaft/mcp.js CHANGED
@@ -81,6 +81,7 @@ class MCPServerConnection extends EventEmitter {
81
81
  this.#process = spawn(this.config.command, this.config.args || [], {
82
82
  stdio: ['pipe', 'pipe', 'pipe'],
83
83
  env: { ...process.env, ...this.config.env },
84
+ windowsHide: true,
84
85
  });
85
86
 
86
87
  this.#process.stdout.on('data', (data) => {
@@ -45,6 +45,7 @@ function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
45
45
  env,
46
46
  stdio: ['ignore', 'pipe', 'pipe'],
47
47
  detached: !platform.isWindows,
48
+ windowsHide: true,
48
49
  });
49
50
 
50
51
  let stdout = '';
@@ -14,6 +14,12 @@ import { existsSync, mkdirSync } from 'fs';
14
14
  import { join, resolve } from 'path';
15
15
  import { randomUUID } from 'crypto';
16
16
 
17
+ const HIDDEN_PROCESS_OPTIONS = { windowsHide: true };
18
+
19
+ function gitExecFileSync(args, options = {}) {
20
+ return execFileSync('git', args, { ...options, ...HIDDEN_PROCESS_OPTIONS });
21
+ }
22
+
17
23
  export default defineTool({
18
24
  name: 'EnterWorktree',
19
25
  description: {
@@ -61,7 +67,7 @@ Worktree 创建在 .yeaft/worktrees/ 中,基于 HEAD 创建新分支。返回
61
67
 
62
68
  // Verify we're in a git repo
63
69
  try {
64
- execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
70
+ gitExecFileSync(['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
65
71
  } catch {
66
72
  return JSON.stringify({ error: 'Not in a git repository' });
67
73
  }
@@ -93,7 +99,7 @@ Worktree 创建在 .yeaft/worktrees/ 中,基于 HEAD 创建新分支。返回
93
99
  try {
94
100
  // Create worktree with new branch. Use execFileSync args instead of
95
101
  // shell quoting so Windows drive letters and spaces in paths survive.
96
- execFileSync('git', ['worktree', 'add', '-b', branchName, worktreeDir, baseRef], { cwd, stdio: 'pipe' });
102
+ gitExecFileSync(['worktree', 'add', '-b', branchName, worktreeDir, baseRef], { cwd, stdio: 'pipe' });
97
103
 
98
104
  return JSON.stringify({
99
105
  success: true,
@@ -12,6 +12,12 @@ import { execFileSync } from 'child_process';
12
12
  import { existsSync } from 'fs';
13
13
  import { resolve } from 'path';
14
14
 
15
+ const HIDDEN_PROCESS_OPTIONS = { windowsHide: true };
16
+
17
+ function gitExecFileSync(args, options = {}) {
18
+ return execFileSync('git', args, { ...options, ...HIDDEN_PROCESS_OPTIONS });
19
+ }
20
+
15
21
  export default defineTool({
16
22
  name: 'ExitWorktree',
17
23
  description: {
@@ -84,7 +90,7 @@ unless discard_changes is set to true.`,
84
90
  // Check for uncommitted changes
85
91
  if (!input.discard_changes) {
86
92
  try {
87
- const status = execFileSync('git', ['status', '--porcelain'], {
93
+ const status = gitExecFileSync(['status', '--porcelain'], {
88
94
  cwd: worktreePath,
89
95
  encoding: 'utf8',
90
96
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -104,7 +110,7 @@ unless discard_changes is set to true.`,
104
110
  // Get branch name before removal
105
111
  let branchName = null;
106
112
  try {
107
- branchName = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
113
+ branchName = gitExecFileSync(['rev-parse', '--abbrev-ref', 'HEAD'], {
108
114
  cwd: worktreePath,
109
115
  encoding: 'utf8',
110
116
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -115,7 +121,7 @@ unless discard_changes is set to true.`,
115
121
 
116
122
  // Remove worktree. Use argv form so Windows paths with spaces or drive
117
123
  // letters are passed to git unchanged.
118
- execFileSync('git', ['worktree', 'remove', ...(input.discard_changes ? ['--force'] : []), worktreePath], {
124
+ gitExecFileSync(['worktree', 'remove', ...(input.discard_changes ? ['--force'] : []), worktreePath], {
119
125
  cwd: mainCwd,
120
126
  stdio: 'pipe',
121
127
  });
@@ -123,7 +129,7 @@ unless discard_changes is set to true.`,
123
129
  // Remove the branch if it was a yeaft worktree branch
124
130
  if (branchName && branchName.startsWith('yeaft-wt/')) {
125
131
  try {
126
- execFileSync('git', ['branch', '-D', branchName], {
132
+ gitExecFileSync(['branch', '-D', branchName], {
127
133
  cwd: mainCwd,
128
134
  stdio: 'pipe',
129
135
  });
@@ -30,7 +30,7 @@ const BINARY_EXTS = new Set([
30
30
  */
31
31
  function hasRipgrep() {
32
32
  return new Promise((resolve) => {
33
- const proc = spawn('rg', ['--version'], { stdio: 'pipe' });
33
+ const proc = spawn('rg', ['--version'], { stdio: 'pipe', windowsHide: true });
34
34
  proc.on('close', (code) => resolve(code === 0));
35
35
  proc.on('error', () => resolve(false));
36
36
  });
@@ -60,7 +60,7 @@ function runRipgrep(pattern, searchPath, options) {
60
60
  if (options.multiline) args.push('-U', '--multiline-dotall');
61
61
  args.push('--max-count', String(options.maxResults || 500));
62
62
 
63
- const proc = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
63
+ const proc = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
64
64
  let stdout = '';
65
65
  let stderr = '';
66
66