@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 +405 -0
- package/cli.js +151 -0
- package/connection.js +391 -0
- package/context.js +26 -0
- package/conversation.js +452 -0
- package/encryption.js +105 -0
- package/history.js +283 -0
- package/index.js +159 -0
- package/package.json +75 -0
- package/proxy.js +169 -0
- package/sdk/index.js +9 -0
- package/sdk/query.js +396 -0
- package/sdk/stream.js +112 -0
- package/sdk/types.js +13 -0
- package/sdk/utils.js +194 -0
- package/service.js +587 -0
- package/terminal.js +176 -0
- package/workbench.js +907 -0
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
|
+
}
|