@yeaft/webchat-agent 0.1.442 → 0.1.444

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.
@@ -25,7 +25,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
25
25
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
26
26
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
27
27
  import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
28
- import { handleUnifyChat, handleUnifyModeSwitch, resetUnifySession } from '../unify/web-bridge.js';
28
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession } from '../unify/web-bridge.js';
29
29
 
30
30
  export async function handleMessage(msg) {
31
31
  switch (msg.type) {
@@ -329,6 +329,10 @@ export async function handleMessage(msg) {
329
329
  handleUnifyModeSwitch(msg);
330
330
  break;
331
331
 
332
+ case 'unify_model_switch':
333
+ handleUnifyModelSwitch(msg);
334
+ break;
335
+
332
336
  case 'unify_reset':
333
337
  await resetUnifySession();
334
338
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.442",
3
+ "version": "0.1.444",
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/unify/config.js CHANGED
@@ -260,6 +260,24 @@ export function loadConfig(overrides = {}) {
260
260
  adapter: null,
261
261
  };
262
262
 
263
+ // Aggregate all available models from providers
264
+ config.availableModels = [];
265
+ if (providers) {
266
+ for (const p of providers) {
267
+ if (!Array.isArray(p.models)) continue;
268
+ for (const m of p.models) {
269
+ // Avoid duplicates (first provider wins)
270
+ if (!config.availableModels.some(am => am.id === m)) {
271
+ config.availableModels.push({
272
+ id: m,
273
+ provider: p.name,
274
+ label: m,
275
+ });
276
+ }
277
+ }
278
+ }
279
+ }
280
+
263
281
  return config;
264
282
  }
265
283
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * agent.js — Create a sub-agent for parallel task execution.
3
+ *
4
+ * Sub-agents run in isolated contexts and can be assigned
5
+ * independent tasks. They communicate via send-message/wait-agent.
6
+ */
7
+
8
+ import { defineTool } from './types.js';
9
+ import { randomUUID } from 'crypto';
10
+
11
+ /** In-memory sub-agent registry. */
12
+ const agents = new Map();
13
+
14
+ /** Get the global agents map for other tools to access. */
15
+ export function getAgentRegistry() {
16
+ return agents;
17
+ }
18
+
19
+ export default defineTool({
20
+ name: 'Agent',
21
+ description: `Create a sub-agent to work on an independent task in parallel.
22
+
23
+ Sub-agents run in their own context and can be given specific tasks.
24
+ Use for parallel execution of independent subtasks.
25
+
26
+ Guidelines:
27
+ - Give each agent a clear, focused task description
28
+ - Use unique, descriptive names
29
+ - Sub-agents share the same tools but have independent conversations
30
+ - Use SendMessage to communicate with agents, WaitAgent to collect results
31
+ - Close agents with CloseAgent when done`,
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ name: {
36
+ type: 'string',
37
+ description: 'A descriptive name for the sub-agent (e.g. "test-writer", "refactor-auth")',
38
+ },
39
+ task: {
40
+ type: 'string',
41
+ description: 'The task description for the sub-agent',
42
+ },
43
+ cwd: {
44
+ type: 'string',
45
+ description: 'Working directory for the sub-agent (optional, defaults to parent cwd)',
46
+ },
47
+ },
48
+ required: ['name', 'task'],
49
+ },
50
+ modes: ['work'],
51
+ isConcurrencySafe: () => false,
52
+ isReadOnly: () => false,
53
+ async execute(input, ctx) {
54
+ const { name, task, cwd } = input;
55
+ if (!name) return JSON.stringify({ error: 'name is required' });
56
+ if (!task) return JSON.stringify({ error: 'task is required' });
57
+
58
+ // Check for name collision
59
+ for (const [, agent] of agents) {
60
+ if (agent.name === name && agent.status !== 'closed') {
61
+ return JSON.stringify({
62
+ error: `Agent "${name}" already exists. Close it first or use a different name.`,
63
+ agentId: agent.id,
64
+ });
65
+ }
66
+ }
67
+
68
+ const agentId = `agent-${randomUUID().slice(0, 8)}`;
69
+ const agent = {
70
+ id: agentId,
71
+ name,
72
+ task,
73
+ cwd: cwd || ctx?.cwd || process.cwd(),
74
+ status: 'created',
75
+ messages: [],
76
+ result: null,
77
+ createdAt: Date.now(),
78
+ };
79
+
80
+ agents.set(agentId, agent);
81
+
82
+ return JSON.stringify({
83
+ success: true,
84
+ agentId,
85
+ name,
86
+ message: `Sub-agent "${name}" created (${agentId}). Use SendMessage to give it work.`,
87
+ });
88
+ },
89
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * apply-patch.js — Apply a unified diff patch to files.
3
+ *
4
+ * Parses unified diff format and applies changes to the target files.
5
+ * Supports multiple files in a single patch.
6
+ */
7
+
8
+ import { defineTool } from './types.js';
9
+ import { readFile, writeFile, mkdir } from 'fs/promises';
10
+ import { existsSync } from 'fs';
11
+ import { resolve, dirname } from 'path';
12
+
13
+ /**
14
+ * Parse a unified diff into hunks.
15
+ * @param {string} patch
16
+ * @returns {Array<{ file: string, hunks: Array }>}
17
+ */
18
+ function parsePatch(patch) {
19
+ const files = [];
20
+ const lines = patch.split('\n');
21
+ let currentFile = null;
22
+ let currentHunk = null;
23
+
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const line = lines[i];
26
+
27
+ // File header: --- a/path or +++ b/path
28
+ if (line.startsWith('--- ')) {
29
+ // Next line should be +++
30
+ continue;
31
+ }
32
+ if (line.startsWith('+++ ')) {
33
+ let filePath = line.slice(4).trim();
34
+ // Strip a/ or b/ prefix
35
+ if (filePath.startsWith('b/')) filePath = filePath.slice(2);
36
+ if (filePath === '/dev/null') continue;
37
+
38
+ currentFile = { file: filePath, hunks: [] };
39
+ files.push(currentFile);
40
+ continue;
41
+ }
42
+
43
+ // Hunk header: @@ -start,count +start,count @@
44
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
45
+ if (hunkMatch) {
46
+ if (!currentFile) continue;
47
+ currentHunk = {
48
+ oldStart: parseInt(hunkMatch[1], 10),
49
+ oldCount: hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1,
50
+ newStart: parseInt(hunkMatch[3], 10),
51
+ newCount: hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1,
52
+ lines: [],
53
+ };
54
+ currentFile.hunks.push(currentHunk);
55
+ continue;
56
+ }
57
+
58
+ // Diff content lines
59
+ if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') || line === '')) {
60
+ currentHunk.lines.push(line);
61
+ }
62
+ }
63
+
64
+ return files;
65
+ }
66
+
67
+ /**
68
+ * Apply hunks to file content.
69
+ */
70
+ function applyHunks(content, hunks) {
71
+ const lines = content.split('\n');
72
+ let offset = 0; // tracks line number shifts from previous hunks
73
+
74
+ for (const hunk of hunks) {
75
+ const startLine = hunk.oldStart - 1 + offset; // 0-based
76
+ const newLines = [];
77
+ let removedCount = 0;
78
+
79
+ for (const diffLine of hunk.lines) {
80
+ if (diffLine.startsWith('+')) {
81
+ newLines.push(diffLine.slice(1));
82
+ } else if (diffLine.startsWith('-')) {
83
+ removedCount++;
84
+ } else if (diffLine.startsWith(' ') || diffLine === '') {
85
+ newLines.push(diffLine.startsWith(' ') ? diffLine.slice(1) : diffLine);
86
+ }
87
+ }
88
+
89
+ // Replace the old lines with new lines
90
+ lines.splice(startLine, removedCount + (hunk.oldCount - removedCount > 0 ? hunk.oldCount - removedCount : 0), ...newLines);
91
+ offset += newLines.length - hunk.oldCount;
92
+ }
93
+
94
+ return lines.join('\n');
95
+ }
96
+
97
+ export default defineTool({
98
+ name: 'ApplyPatch',
99
+ description: `Apply a unified diff patch to files.
100
+
101
+ Parses unified diff format (like git diff output) and applies changes.
102
+ Supports patching multiple files in a single diff.
103
+
104
+ Guidelines:
105
+ - Provide standard unified diff format (--- a/file, +++ b/file, @@ hunks)
106
+ - Ensure the diff matches the current file content exactly
107
+ - New files are created automatically with parent directories`,
108
+ parameters: {
109
+ type: 'object',
110
+ properties: {
111
+ patch: {
112
+ type: 'string',
113
+ description: 'The unified diff patch content',
114
+ },
115
+ },
116
+ required: ['patch'],
117
+ },
118
+ modes: ['work'],
119
+ isConcurrencySafe: () => false,
120
+ isReadOnly: () => false,
121
+ isDestructive: () => false,
122
+ async execute(input, ctx) {
123
+ const { patch } = input;
124
+ if (!patch) return JSON.stringify({ error: 'patch is required' });
125
+
126
+ const cwd = ctx?.cwd || process.cwd();
127
+
128
+ try {
129
+ const fileDiffs = parsePatch(patch);
130
+
131
+ if (fileDiffs.length === 0) {
132
+ return JSON.stringify({ error: 'No valid file diffs found in patch' });
133
+ }
134
+
135
+ const results = [];
136
+
137
+ for (const fileDiff of fileDiffs) {
138
+ const absPath = resolve(cwd, fileDiff.file);
139
+
140
+ try {
141
+ let content;
142
+ if (existsSync(absPath)) {
143
+ content = await readFile(absPath, 'utf-8');
144
+ } else {
145
+ // New file
146
+ await mkdir(dirname(absPath), { recursive: true });
147
+ content = '';
148
+ }
149
+
150
+ const newContent = applyHunks(content, fileDiff.hunks);
151
+ await writeFile(absPath, newContent, 'utf-8');
152
+
153
+ results.push({
154
+ file: fileDiff.file,
155
+ success: true,
156
+ hunks: fileDiff.hunks.length,
157
+ });
158
+ } catch (err) {
159
+ results.push({
160
+ file: fileDiff.file,
161
+ success: false,
162
+ error: err.message,
163
+ });
164
+ }
165
+ }
166
+
167
+ const successCount = results.filter(r => r.success).length;
168
+ return JSON.stringify({
169
+ results,
170
+ summary: `Applied patch to ${successCount}/${results.length} files`,
171
+ }, null, 2);
172
+ } catch (err) {
173
+ return JSON.stringify({ error: `Failed to apply patch: ${err.message}` });
174
+ }
175
+ },
176
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * ask-user.js — Ask the user a question and wait for their response.
3
+ *
4
+ * In Unify mode this tool sends a question through the web-bridge
5
+ * and blocks (via Promise) until the user answers. The answer is
6
+ * returned as the tool result.
7
+ *
8
+ * Reference: yeaft-unify-design.md §8
9
+ */
10
+
11
+ import { defineTool } from './types.js';
12
+ import { randomUUID } from 'crypto';
13
+
14
+ export default defineTool({
15
+ name: 'AskUser',
16
+ description: `Ask the user a question and wait for their response.
17
+
18
+ Use this tool when you need additional information or clarification from the user.
19
+ The user will see your question in the chat interface and can type a response.
20
+
21
+ Guidelines:
22
+ - Ask specific, focused questions
23
+ - Provide context about why you need the information
24
+ - If presenting options, list them clearly
25
+ - Don't use this for rhetorical questions — only when you genuinely need user input`,
26
+ parameters: {
27
+ type: 'object',
28
+ properties: {
29
+ question: {
30
+ type: 'string',
31
+ description: 'The question to ask the user',
32
+ },
33
+ options: {
34
+ type: 'array',
35
+ items: { type: 'string' },
36
+ description: 'Optional list of choices for the user to pick from',
37
+ },
38
+ },
39
+ required: ['question'],
40
+ },
41
+ modes: ['chat', 'work'],
42
+ isConcurrencySafe: () => false,
43
+ isReadOnly: () => true,
44
+ async execute(input, ctx) {
45
+ const { question, options } = input;
46
+ if (!question) return JSON.stringify({ error: 'question is required' });
47
+
48
+ // Generate a unique request ID for this ask
49
+ const requestId = `ask_${randomUUID().slice(0, 8)}`;
50
+
51
+ // In a full web-bridge integration, this would send an ask_user event
52
+ // and await the answer. For now, return a formatted prompt that the
53
+ // LLM can see — the web-bridge handles the ask flow externally.
54
+ return JSON.stringify({
55
+ type: 'ask_user',
56
+ requestId,
57
+ question,
58
+ ...(options ? { options } : {}),
59
+ message: `Question sent to user: "${question}"${options ? ` [Options: ${options.join(', ')}]` : ''}`,
60
+ });
61
+ },
62
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * bash.js — Execute shell commands.
3
+ *
4
+ * Spawns a child process to run shell commands with timeout, output
5
+ * truncation, working directory support, and cancellation via AbortSignal.
6
+ *
7
+ * Modeled after Claude Code's Bash tool implementation.
8
+ */
9
+
10
+ import { defineTool } from './types.js';
11
+ import { spawn } from 'child_process';
12
+ import { existsSync } from 'fs';
13
+ import { resolve } from 'path';
14
+
15
+ /** Max output size in bytes before truncation (256 KB). */
16
+ const MAX_OUTPUT = 256 * 1024;
17
+
18
+ /** Default timeout in ms (2 minutes). */
19
+ const DEFAULT_TIMEOUT_MS = 120_000;
20
+
21
+ /** Max timeout in ms (10 minutes). */
22
+ const MAX_TIMEOUT_MS = 600_000;
23
+
24
+ /**
25
+ * Run a command in a child process.
26
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
27
+ */
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], {
32
+ cwd,
33
+ env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
34
+ stdio: ['ignore', 'pipe', 'pipe'],
35
+ timeout,
36
+ });
37
+
38
+ let stdout = '';
39
+ let stderr = '';
40
+ let stdoutTruncated = false;
41
+ let stderrTruncated = false;
42
+ let timedOut = false;
43
+
44
+ proc.stdout.on('data', (chunk) => {
45
+ if (stdout.length < MAX_OUTPUT) {
46
+ stdout += chunk.toString();
47
+ if (stdout.length > MAX_OUTPUT) {
48
+ stdout = stdout.slice(0, MAX_OUTPUT);
49
+ stdoutTruncated = true;
50
+ }
51
+ }
52
+ });
53
+
54
+ proc.stderr.on('data', (chunk) => {
55
+ if (stderr.length < MAX_OUTPUT) {
56
+ stderr += chunk.toString();
57
+ if (stderr.length > MAX_OUTPUT) {
58
+ stderr = stderr.slice(0, MAX_OUTPUT);
59
+ stderrTruncated = true;
60
+ }
61
+ }
62
+ });
63
+
64
+ // Handle abort signal
65
+ const onAbort = () => {
66
+ try { proc.kill('SIGTERM'); } catch {}
67
+ setTimeout(() => {
68
+ try { proc.kill('SIGKILL'); } catch {}
69
+ }, 2000);
70
+ };
71
+ if (signal) {
72
+ if (signal.aborted) { onAbort(); return; }
73
+ signal.addEventListener('abort', onAbort, { once: true });
74
+ }
75
+
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,
82
+ timedOut,
83
+ });
84
+ });
85
+
86
+ 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
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ export default defineTool({
109
+ name: 'Bash',
110
+ description: `Execute a shell command and return its output.
111
+
112
+ Use this tool to run CLI commands, scripts, and system operations.
113
+
114
+ Guidelines:
115
+ - Commands run in the working directory (cwd from context)
116
+ - Timeout defaults to 2 minutes (max 10 minutes)
117
+ - Large outputs are truncated at 256KB
118
+ - Use absolute paths when possible
119
+ - Avoid interactive commands (no stdin support)
120
+ - For long-running tasks, consider redirecting output to a file
121
+ - stderr is captured separately and included in the result`,
122
+ parameters: {
123
+ type: 'object',
124
+ properties: {
125
+ command: {
126
+ type: 'string',
127
+ description: 'The shell command to execute',
128
+ },
129
+ cwd: {
130
+ type: 'string',
131
+ description: 'Working directory for the command (default: engine cwd)',
132
+ },
133
+ timeout_ms: {
134
+ type: 'number',
135
+ description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS})`,
136
+ },
137
+ },
138
+ required: ['command'],
139
+ },
140
+ modes: ['work'],
141
+ isConcurrencySafe: () => false,
142
+ isReadOnly: () => false,
143
+ isDestructive: (input) => {
144
+ if (!input?.command) return false;
145
+ const cmd = input.command.toLowerCase();
146
+ return cmd.includes('rm ') || cmd.includes('rmdir') ||
147
+ cmd.includes('git reset --hard') || cmd.includes('git clean') ||
148
+ cmd.includes('dd ') || cmd.includes('mkfs') ||
149
+ cmd.includes('> /dev/') || cmd.includes('chmod 000');
150
+ },
151
+ async execute(input, ctx) {
152
+ const { command, cwd: inputCwd, timeout_ms } = input;
153
+ if (!command) return JSON.stringify({ error: 'command is required' });
154
+
155
+ // Resolve working directory
156
+ const cwd = inputCwd
157
+ ? resolve(inputCwd)
158
+ : (ctx?.cwd || process.cwd());
159
+
160
+ if (!existsSync(cwd)) {
161
+ return JSON.stringify({ error: `Working directory does not exist: ${cwd}` });
162
+ }
163
+
164
+ // Clamp timeout
165
+ const timeout = Math.min(Math.max(timeout_ms || DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
166
+
167
+ try {
168
+ const result = await runCommand(command, {
169
+ cwd,
170
+ timeout,
171
+ signal: ctx?.signal,
172
+ });
173
+
174
+ // Format output similar to Claude Code
175
+ const parts = [];
176
+ if (result.stdout) parts.push(result.stdout);
177
+ if (result.stderr) parts.push(`STDERR:\n${result.stderr}`);
178
+ if (result.timedOut) parts.push(`\n(Command timed out after ${timeout}ms)`);
179
+
180
+ const output = parts.join('\n') || '(no output)';
181
+
182
+ return result.exitCode === 0
183
+ ? output
184
+ : `Exit code: ${result.exitCode}\n${output}`;
185
+ } catch (err) {
186
+ return JSON.stringify({ error: `Bash execution failed: ${err.message}` });
187
+ }
188
+ },
189
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * close-agent.js — Close a sub-agent and clean up.
3
+ */
4
+
5
+ import { defineTool } from './types.js';
6
+ import { getAgentRegistry } from './agent.js';
7
+
8
+ export default defineTool({
9
+ name: 'CloseAgent',
10
+ description: `Close a sub-agent and release its resources.
11
+
12
+ Use when a sub-agent's task is complete or no longer needed.
13
+ The agent's result (if any) is returned before closing.`,
14
+ parameters: {
15
+ type: 'object',
16
+ properties: {
17
+ agent_id: {
18
+ type: 'string',
19
+ description: 'The sub-agent ID to close',
20
+ },
21
+ result: {
22
+ type: 'string',
23
+ description: 'Optional final result to set before closing',
24
+ },
25
+ },
26
+ required: ['agent_id'],
27
+ },
28
+ modes: ['work'],
29
+ isConcurrencySafe: () => false,
30
+ isReadOnly: () => false,
31
+ async execute(input, ctx) {
32
+ const { agent_id, result } = input;
33
+ if (!agent_id) return JSON.stringify({ error: 'agent_id is required' });
34
+
35
+ const agents = getAgentRegistry();
36
+ const agent = agents.get(agent_id);
37
+
38
+ if (!agent) {
39
+ return JSON.stringify({ error: `Agent not found: ${agent_id}` });
40
+ }
41
+
42
+ if (result) {
43
+ agent.result = result;
44
+ }
45
+
46
+ const finalResult = agent.result;
47
+ agent.status = 'closed';
48
+
49
+ return JSON.stringify({
50
+ success: true,
51
+ agentId: agent_id,
52
+ name: agent.name,
53
+ result: finalResult,
54
+ messages: agent.messages.length,
55
+ message: `Agent "${agent.name}" closed`,
56
+ });
57
+ },
58
+ });