@yeaft/webchat-agent 0.0.2

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/claude.js ADDED
@@ -0,0 +1,405 @@
1
+ import { query, Stream } from './sdk/index.js';
2
+ import ctx from './context.js';
3
+ import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
4
+
5
+ /**
6
+ * Start a Claude SDK query for a conversation
7
+ * Uses the SDK with AsyncIterable input stream for bidirectional communication
8
+ */
9
+ export async function startClaudeQuery(conversationId, workDir, resumeSessionId) {
10
+ // 如果已存在,先保存 per-session 设置,再关闭
11
+ let savedDisallowedTools = null;
12
+ let savedUserId = undefined;
13
+ let savedUsername = undefined;
14
+ if (ctx.conversations.has(conversationId)) {
15
+ const existing = ctx.conversations.get(conversationId);
16
+ savedDisallowedTools = existing.disallowedTools ?? null;
17
+ savedUserId = existing.userId;
18
+ savedUsername = existing.username;
19
+ if (existing.abortController) {
20
+ existing.abortController.abort();
21
+ }
22
+ ctx.conversations.delete(conversationId);
23
+ }
24
+
25
+ // 创建输入流和 abort controller
26
+ const inputStream = new Stream();
27
+ const abortController = new AbortController();
28
+
29
+ const state = {
30
+ query: null,
31
+ inputStream,
32
+ workDir,
33
+ claudeSessionId: resumeSessionId || null,
34
+ createdAt: Date.now(),
35
+ abortController,
36
+ turnActive: false, // 是否有 turn 正在处理中
37
+ // Metadata from system init message
38
+ tools: [],
39
+ slashCommands: [],
40
+ model: null,
41
+ // Usage tracking
42
+ usage: {
43
+ inputTokens: 0,
44
+ outputTokens: 0,
45
+ cacheRead: 0,
46
+ cacheCreation: 0,
47
+ totalCostUsd: 0
48
+ },
49
+ // 后台任务追踪: taskId -> { command, status, output, startTime, endTime }
50
+ backgroundTasks: new Map(),
51
+ // Per-session 工具禁用设置
52
+ disallowedTools: savedDisallowedTools,
53
+ // 保留用户信息(从旧 state 恢复)
54
+ userId: savedUserId,
55
+ username: savedUsername,
56
+ };
57
+
58
+ // 配置 SDK query 选项
59
+ const options = {
60
+ cwd: workDir,
61
+ permissionMode: 'bypassPermissions', // Auto-approve all tool uses
62
+ abort: abortController.signal,
63
+ // 拦截 AskUserQuestion 工具调用,转发到 Web UI
64
+ canCallTool: async (toolName, input, toolCtx) => {
65
+ if (toolName === 'AskUserQuestion') {
66
+ return await handleAskUserQuestion(conversationId, input, toolCtx);
67
+ }
68
+ // 其他工具自动批准
69
+ return input;
70
+ }
71
+ };
72
+
73
+ // 禁用的工具:per-session 优先,否则使用全局默认
74
+ const effectiveDisallowedTools = savedDisallowedTools != null
75
+ ? savedDisallowedTools
76
+ : (ctx.CONFIG.disallowedTools || []);
77
+ if (effectiveDisallowedTools.length > 0) {
78
+ options.disallowedTools = effectiveDisallowedTools;
79
+ console.log(`[SDK] Disallowed tools: ${effectiveDisallowedTools.join(', ')}`);
80
+ }
81
+
82
+ // Validate session ID is a valid UUID before using it
83
+ const isValidUUID = (id) => {
84
+ if (!id) return false;
85
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
86
+ return uuidRegex.test(id);
87
+ };
88
+
89
+ const validResumeId = isValidUUID(resumeSessionId) ? resumeSessionId : null;
90
+ console.log(`[SDK] resumeSessionId: "${resumeSessionId}" (length: ${resumeSessionId?.length})`);
91
+ console.log(`[SDK] isValidUUID: ${isValidUUID(resumeSessionId)}`);
92
+ console.log(`[SDK] validResumeId: "${validResumeId}"`);
93
+ if (resumeSessionId && !validResumeId) {
94
+ console.warn(`[SDK] Invalid session ID (not UUID): ${resumeSessionId}, starting fresh`);
95
+ state.claudeSessionId = null; // Clear invalid ID
96
+ }
97
+
98
+ if (validResumeId) {
99
+ options.resume = validResumeId;
100
+ }
101
+
102
+ console.log(`[SDK] Starting Claude query for ${conversationId}, resume: ${validResumeId || 'none'}`);
103
+
104
+ // 使用 SDK 的 query 函数
105
+ const claudeQuery = query({
106
+ prompt: inputStream,
107
+ options
108
+ });
109
+
110
+ state.query = claudeQuery;
111
+ ctx.conversations.set(conversationId, state);
112
+
113
+ // 异步处理 Claude 输出
114
+ processClaudeOutput(conversationId, claudeQuery, state);
115
+
116
+ // 注意:不在这里调用 sendConversationList()
117
+ // 因为此时 turnActive 还是 false,会发送 processing: false 给 server
118
+ // 由调用方(handleUserInput)在设置 turnActive = true 后再调用
119
+ return state;
120
+ }
121
+
122
+ /**
123
+ * 检测并追踪后台任务(仅 Bash 和 Agent 任务)
124
+ * 普通工具调用(Read、Edit、Grep、Glob 等)不跟踪
125
+ */
126
+ function detectAndTrackBackgroundTask(conversationId, state, message) {
127
+ // 检测 assistant 消息中的 tool_use
128
+ if (message.type === 'assistant' && message.message?.content) {
129
+ const content = message.message.content;
130
+ if (!Array.isArray(content)) return;
131
+
132
+ for (const block of content) {
133
+ if (block.type === 'tool_use') {
134
+ const toolName = block.name;
135
+ const toolInput = block.input || {};
136
+ const toolUseId = block.id;
137
+
138
+ // 跟踪 Bash 工具调用
139
+ if (toolName === 'Bash') {
140
+ const taskInfo = {
141
+ id: toolUseId,
142
+ type: 'bash',
143
+ command: toolInput.command || '',
144
+ description: toolInput.description || '',
145
+ background: !!toolInput.run_in_background,
146
+ status: 'running',
147
+ output: '',
148
+ startTime: Date.now(),
149
+ endTime: null
150
+ };
151
+ state.backgroundTasks.set(toolUseId, taskInfo);
152
+ console.log(`[Tasks] Bash: ${toolUseId}, command: ${taskInfo.command.substring(0, 50)}...`);
153
+
154
+ ctx.sendToServer({
155
+ type: 'background_task_started',
156
+ conversationId,
157
+ task: taskInfo
158
+ });
159
+ }
160
+
161
+ // 跟踪 Task 工具(Agent 任务)
162
+ else if (toolName === 'Task') {
163
+ const taskInfo = {
164
+ id: toolUseId,
165
+ type: 'agent',
166
+ description: toolInput.description || toolInput.prompt?.substring(0, 100) || 'Agent Task',
167
+ prompt: toolInput.prompt || '',
168
+ background: !!toolInput.run_in_background,
169
+ status: 'running',
170
+ output: '',
171
+ startTime: Date.now(),
172
+ endTime: null
173
+ };
174
+ state.backgroundTasks.set(toolUseId, taskInfo);
175
+ console.log(`[Tasks] Agent: ${toolUseId}, desc: ${taskInfo.description}`);
176
+
177
+ ctx.sendToServer({
178
+ type: 'background_task_started',
179
+ conversationId,
180
+ task: taskInfo
181
+ });
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // 检测 tool_result 消息(任务完成或输出更新)
188
+ if (message.type === 'user' && message.tool_use_result) {
189
+ const result = message.tool_use_result;
190
+ // tool_use_result 格式可能是数组或单个对象
191
+ const results = Array.isArray(result) ? result : [result];
192
+
193
+ for (const r of results) {
194
+ const toolUseId = r.tool_use_id;
195
+ if (toolUseId && state.backgroundTasks.has(toolUseId)) {
196
+ const taskInfo = state.backgroundTasks.get(toolUseId);
197
+ const content = r.content || '';
198
+
199
+ // 更新任务输出
200
+ taskInfo.output += (typeof content === 'string' ? content : JSON.stringify(content)) + '\n';
201
+ taskInfo.status = 'completed';
202
+ taskInfo.endTime = Date.now();
203
+
204
+ console.log(`[Tasks] Completed: ${toolUseId}`);
205
+
206
+ ctx.sendToServer({
207
+ type: 'background_task_output',
208
+ conversationId,
209
+ taskId: toolUseId,
210
+ task: taskInfo
211
+ });
212
+ }
213
+ }
214
+ }
215
+
216
+ // 检测 system 消息中的任务输出(实时输出)
217
+ if (message.type === 'system' && message.subtype === 'task_output') {
218
+ const taskId = message.task_id;
219
+ if (taskId && state.backgroundTasks.has(taskId)) {
220
+ const taskInfo = state.backgroundTasks.get(taskId);
221
+ const output = message.output || message.content || '';
222
+
223
+ taskInfo.output += output;
224
+
225
+ ctx.sendToServer({
226
+ type: 'background_task_output',
227
+ conversationId,
228
+ taskId,
229
+ task: taskInfo,
230
+ newOutput: output
231
+ });
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Process Claude output messages asynchronously
238
+ */
239
+ async function processClaudeOutput(conversationId, claudeQuery, state) {
240
+ // 标记是否已在 result 消息中发送了 turn_completed
241
+ let resultHandled = false;
242
+
243
+ try {
244
+ for await (const message of claudeQuery) {
245
+ console.log('Output:', message.type, message.subtype || '');
246
+
247
+ // 捕获 system init 消息中的 metadata
248
+ if (message.type === 'system' && message.subtype === 'init') {
249
+ state.claudeSessionId = message.session_id;
250
+ state.tools = message.tools || [];
251
+ state.slashCommands = message.slash_commands || [];
252
+ state.model = message.model || null;
253
+ console.log(`Claude session ID: ${state.claudeSessionId}`);
254
+ console.log(`Model: ${state.model}`);
255
+ console.log(`Available tools: ${state.tools.length}`);
256
+ console.log(`Available slash commands: ${state.slashCommands.join(', ')}`);
257
+
258
+ // 通知服务器更新 claudeSessionId(用于历史会话恢复)
259
+ ctx.sendToServer({
260
+ type: 'session_id_update',
261
+ conversationId,
262
+ claudeSessionId: state.claudeSessionId,
263
+ workDir: state.workDir
264
+ });
265
+
266
+ // 通知 web 端可用的 slash commands 列表(用于自动补全)
267
+ // 缓存到 agent 级别,只在列表变化时才发送
268
+ if (state.slashCommands.length > 0) {
269
+ const changed = JSON.stringify(state.slashCommands) !== JSON.stringify(ctx.slashCommands);
270
+ if (changed) {
271
+ ctx.slashCommands = [...state.slashCommands];
272
+ ctx.sendToServer({
273
+ type: 'slash_commands_update',
274
+ conversationId,
275
+ slashCommands: state.slashCommands
276
+ });
277
+ }
278
+ }
279
+ }
280
+
281
+ // 捕获 compact 相关的 system 消息
282
+ // Claude Code 在 context 不足时会自动 compact
283
+ if (message.type === 'system') {
284
+ // 新格式: subtype: 'status', status: 'compacting'
285
+ if (message.subtype === 'status' && message.status === 'compacting') {
286
+ console.log(`[${conversationId}] Compact started (status)`);
287
+ ctx.sendToServer({
288
+ type: 'compact_status',
289
+ conversationId,
290
+ status: 'compacting',
291
+ message: 'Context compacting in progress...'
292
+ });
293
+ }
294
+ // compact 边界标记 — 表示 compact 完成
295
+ if (message.subtype === 'compact_boundary') {
296
+ console.log(`[${conversationId}] Compact completed (boundary)`);
297
+ ctx.sendToServer({
298
+ type: 'compact_status',
299
+ conversationId,
300
+ status: 'completed',
301
+ message: 'Context compacted successfully'
302
+ });
303
+ }
304
+ // 旧格式兼容
305
+ if (message.subtype === 'compact_start' || message.message?.includes?.('Compacting')) {
306
+ console.log(`[${conversationId}] Compact started`);
307
+ ctx.sendToServer({
308
+ type: 'compact_status',
309
+ conversationId,
310
+ status: 'compacting',
311
+ message: message.message || 'Context compacting in progress...'
312
+ });
313
+ }
314
+ if (message.subtype === 'compact_complete' || message.subtype === 'compact_end') {
315
+ console.log(`[${conversationId}] Compact completed`);
316
+ ctx.sendToServer({
317
+ type: 'compact_status',
318
+ conversationId,
319
+ status: 'completed',
320
+ message: message.message || 'Context compacted successfully'
321
+ });
322
+ }
323
+ }
324
+
325
+ // 捕获 result 消息中的 usage 信息
326
+ if (message.type === 'result') {
327
+ if (message.usage) {
328
+ state.usage.inputTokens += message.usage.input_tokens || 0;
329
+ state.usage.outputTokens += message.usage.output_tokens || 0;
330
+ state.usage.cacheRead += message.usage.cache_read_input_tokens || 0;
331
+ state.usage.cacheCreation += message.usage.cache_creation_input_tokens || 0;
332
+ }
333
+ state.usage.totalCostUsd += message.total_cost_usd || 0;
334
+ console.log(`[SDK] Query completed for ${conversationId}, cost: $${state.usage.totalCostUsd.toFixed(4)}`);
335
+
336
+ // ★ Turn 完成:发送 turn_completed,进程继续运行等待下一条消息
337
+ // stream-json 模式下 Claude 进程是持久运行的,for-await 在 result 后继续等待
338
+ // 不清空 state.query 和 state.inputStream,下次用户消息直接通过同一个 inputStream 发送
339
+ sendOutput(conversationId, message);
340
+
341
+ resultHandled = true;
342
+ state.turnActive = false;
343
+
344
+ ctx.sendToServer({
345
+ type: 'turn_completed',
346
+ conversationId,
347
+ claudeSessionId: state.claudeSessionId,
348
+ workDir: state.workDir
349
+ });
350
+ sendConversationList();
351
+ continue;
352
+ }
353
+
354
+ // 检测后台任务
355
+ detectAndTrackBackgroundTask(conversationId, state, message);
356
+
357
+ sendOutput(conversationId, message);
358
+ }
359
+ } catch (error) {
360
+ if (error.name === 'AbortError') {
361
+ console.log(`[SDK] Query aborted for ${conversationId}`);
362
+ } else {
363
+ console.error(`[SDK] Error for ${conversationId}:`, error.message);
364
+ sendError(conversationId, error.message);
365
+ }
366
+ } finally {
367
+ // 查询完成后清理
368
+ // 注意:必须用传入的 state 参数,不能用 conversations.get(conversationId)
369
+ // 因为在取消+重发的竞态场景下,conversations 中可能已经是新 state 了
370
+ const conv = state;
371
+ const currentConv = ctx.conversations.get(conversationId);
372
+ const isStale = currentConv !== conv; // 已被新 startClaudeQuery 替换
373
+
374
+ const claudeSessionId = conv?.claudeSessionId;
375
+ const wasCancelled = conv?.cancelled;
376
+ const wasTurnActive = conv?.turnActive; // 保存清理前的 turnActive 状态
377
+
378
+ // 只有当前 state 未被替换时才清理
379
+ if (!isStale && conv) {
380
+ conv.query = null;
381
+ conv.inputStream = null;
382
+ conv.turnActive = false;
383
+ conv.cancelled = false; // 重置取消标志
384
+ }
385
+
386
+ if (isStale) {
387
+ // state 已被新查询替换,不做任何清理操作
388
+ console.log(`[SDK] Stale processClaudeOutput for ${conversationId}, skipping cleanup`);
389
+ } else if (!wasCancelled && (wasTurnActive || !resultHandled)) {
390
+ // 进程异常退出:要么 turn 正在进行中,要么从未成功完成过任何 turn
391
+ ctx.sendToServer({
392
+ type: 'conversation_closed',
393
+ conversationId,
394
+ claudeSessionId,
395
+ workDir: conv?.workDir,
396
+ exitCode: 0,
397
+ processExited: true
398
+ });
399
+ }
400
+ // wasCancelled 时由 handleCancelExecution 已发送 execution_cancelled
401
+
402
+ sendConversationList();
403
+ }
404
+ }
405
+
package/cli.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point for @yeaft/webchat-agent
4
+ * Parses command-line arguments and starts the agent or runs subcommands
5
+ */
6
+ import { execSync } from 'child_process';
7
+ import { readFileSync } from 'fs';
8
+ import { dirname, join } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
13
+
14
+ const args = process.argv.slice(2);
15
+ const command = args[0];
16
+ const subArgs = args.slice(1);
17
+
18
+ // Service management subcommands
19
+ const SERVICE_COMMANDS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status', 'logs'];
20
+
21
+ if (command === 'upgrade') {
22
+ upgrade();
23
+ } else if (command === '--version' || command === '-v') {
24
+ console.log(pkg.version);
25
+ } else if (command === '--help' || command === '-h') {
26
+ printHelp();
27
+ } else if (SERVICE_COMMANDS.includes(command)) {
28
+ handleServiceCommand(command, subArgs);
29
+ } else {
30
+ // Normal agent startup — parse flags and set env vars
31
+ parseAndStart(args);
32
+ }
33
+
34
+ function printHelp() {
35
+ console.log(`
36
+ ${pkg.name} v${pkg.version}
37
+
38
+ Usage:
39
+ yeaft-agent [options] Run agent in foreground
40
+ yeaft-agent install [options] Install as system service
41
+ yeaft-agent uninstall Remove system service
42
+ yeaft-agent start Start installed service
43
+ yeaft-agent stop Stop installed service
44
+ yeaft-agent restart Restart installed service
45
+ yeaft-agent status Show service status
46
+ yeaft-agent logs View service logs (follow mode)
47
+ yeaft-agent upgrade Upgrade to latest version
48
+ yeaft-agent --version Show version
49
+
50
+ Options:
51
+ --server <url> WebSocket server URL (default: ws://localhost:3456)
52
+ --name <name> Agent display name (default: Worker-{platform}-{pid})
53
+ --secret <secret> Agent secret for authentication
54
+ --work-dir <dir> Default working directory (default: cwd)
55
+ --auto-upgrade Check for updates on startup
56
+
57
+ Environment variables (alternative to flags):
58
+ SERVER_URL WebSocket server URL
59
+ AGENT_NAME Agent display name
60
+ AGENT_SECRET Agent secret
61
+ WORK_DIR Working directory
62
+
63
+ Examples:
64
+ yeaft-agent --server wss://your-server.com --name my-worker --secret xxx
65
+ yeaft-agent install --server wss://your-server.com --name my-worker --secret xxx
66
+ yeaft-agent status
67
+ yeaft-agent logs
68
+ `);
69
+ }
70
+
71
+ async function handleServiceCommand(command, args) {
72
+ const service = await import('./service.js');
73
+ switch (command) {
74
+ case 'install': service.install(args); break;
75
+ case 'uninstall': service.uninstall(); break;
76
+ case 'start': service.start(); break;
77
+ case 'stop': service.stop(); break;
78
+ case 'restart': service.restart(); break;
79
+ case 'status': service.status(); break;
80
+ case 'logs': service.logs(); break;
81
+ }
82
+ }
83
+
84
+ function parseAndStart(args) {
85
+ // Parse CLI flags → set environment variables (env vars take precedence over flags)
86
+ for (let i = 0; i < args.length; i++) {
87
+ const arg = args[i];
88
+ const next = args[i + 1];
89
+
90
+ switch (arg) {
91
+ case '--server':
92
+ if (next) { process.env.SERVER_URL = process.env.SERVER_URL || next; i++; }
93
+ break;
94
+ case '--name':
95
+ if (next) { process.env.AGENT_NAME = process.env.AGENT_NAME || next; i++; }
96
+ break;
97
+ case '--secret':
98
+ if (next) { process.env.AGENT_SECRET = process.env.AGENT_SECRET || next; i++; }
99
+ break;
100
+ case '--work-dir':
101
+ if (next) { process.env.WORK_DIR = process.env.WORK_DIR || next; i++; }
102
+ break;
103
+ case '--auto-upgrade':
104
+ checkForUpdates();
105
+ break;
106
+ default:
107
+ if (arg.startsWith('-')) {
108
+ console.warn(`Unknown option: ${arg}`);
109
+ printHelp();
110
+ process.exit(1);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Import and start the agent
116
+ import('./index.js');
117
+ }
118
+
119
+ async function checkForUpdates() {
120
+ try {
121
+ const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`);
122
+ if (!res.ok) return;
123
+ const data = await res.json();
124
+ const latest = data.version;
125
+ if (latest && latest !== pkg.version) {
126
+ console.log(`\n Update available: ${pkg.version} → ${latest}`);
127
+ console.log(` Run "yeaft-agent upgrade" to update\n`);
128
+ }
129
+ } catch {
130
+ // Silently ignore — network may be unavailable
131
+ }
132
+ }
133
+
134
+ function upgrade() {
135
+ console.log(`Current version: ${pkg.version}`);
136
+ console.log('Checking for updates...');
137
+
138
+ try {
139
+ const res = execSync(`npm view ${pkg.name} version`, { encoding: 'utf-8' }).trim();
140
+ if (res === pkg.version) {
141
+ console.log('Already up to date.');
142
+ return;
143
+ }
144
+ console.log(`Upgrading to ${res}...`);
145
+ execSync(`npm install -g ${pkg.name}@latest`, { stdio: 'inherit' });
146
+ console.log(`Successfully upgraded to ${res}`);
147
+ } catch (e) {
148
+ console.error('Upgrade failed:', e.message);
149
+ process.exit(1);
150
+ }
151
+ }