@yeaft/webchat-agent 0.1.251 → 0.1.252
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 +11 -0
- package/connection/buffer.js +1 -0
- package/connection/message-router.js +0 -4
- package/conversation.js +0 -30
- package/package.json +1 -1
- package/subagent.js +323 -0
package/claude.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { query, Stream } from './sdk/index.js';
|
|
2
2
|
import ctx from './context.js';
|
|
3
3
|
import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
|
|
4
|
+
import { startSubagentWatcher, stopSubagentWatcher, cleanupSubagentWatchers } from './subagent.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Determine maxContextTokens and autoCompactThreshold from model name.
|
|
@@ -40,6 +41,7 @@ export async function startClaudeQuery(conversationId, workDir, resumeSessionId)
|
|
|
40
41
|
if (existing.abortController) {
|
|
41
42
|
existing.abortController.abort();
|
|
42
43
|
}
|
|
44
|
+
cleanupSubagentWatchers(conversationId);
|
|
43
45
|
ctx.conversations.delete(conversationId);
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -211,6 +213,9 @@ function detectAndTrackBackgroundTask(conversationId, state, message) {
|
|
|
211
213
|
conversationId,
|
|
212
214
|
task: taskInfo
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
// Start watching subagent JSONL files for this Task
|
|
218
|
+
startSubagentWatcher(conversationId, state, toolUseId);
|
|
214
219
|
}
|
|
215
220
|
}
|
|
216
221
|
}
|
|
@@ -241,6 +246,11 @@ function detectAndTrackBackgroundTask(conversationId, state, message) {
|
|
|
241
246
|
taskId: toolUseId,
|
|
242
247
|
task: taskInfo
|
|
243
248
|
});
|
|
249
|
+
|
|
250
|
+
// Stop subagent watcher if this was a Task (Agent) tool_use
|
|
251
|
+
if (taskInfo.type === 'agent') {
|
|
252
|
+
stopSubagentWatcher(conversationId, toolUseId);
|
|
253
|
+
}
|
|
244
254
|
}
|
|
245
255
|
}
|
|
246
256
|
}
|
|
@@ -564,6 +574,7 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
564
574
|
console.log(`[SDK] Stale processClaudeOutput for ${conversationId}, skipping cleanup`);
|
|
565
575
|
} else if (!wasCancelled && (wasTurnActive || !resultHandled)) {
|
|
566
576
|
// 进程异常退出:要么 turn 正在进行中,要么从未成功完成过任何 turn
|
|
577
|
+
cleanupSubagentWatchers(conversationId);
|
|
567
578
|
ctx.sendToServer({
|
|
568
579
|
type: 'conversation_closed',
|
|
569
580
|
conversationId,
|
package/connection/buffer.js
CHANGED
|
@@ -7,6 +7,7 @@ export const BUFFERABLE_TYPES = new Set([
|
|
|
7
7
|
'claude_output', 'turn_completed', 'conversation_closed',
|
|
8
8
|
'session_id_update', 'compact_status', 'slash_commands_update',
|
|
9
9
|
'background_task_started', 'background_task_output',
|
|
10
|
+
'subagent_started', 'subagent_message', 'subagent_completed',
|
|
10
11
|
'crew_output', 'crew_status', 'crew_turn_completed',
|
|
11
12
|
'crew_session_created', 'crew_session_restored', 'crew_human_needed',
|
|
12
13
|
'crew_role_added', 'crew_role_removed',
|
|
@@ -105,10 +105,6 @@ export async function handleMessage(msg) {
|
|
|
105
105
|
await handleBtwQuestion(msg);
|
|
106
106
|
break;
|
|
107
107
|
|
|
108
|
-
case 'stop_background_task':
|
|
109
|
-
await (await import('../conversation.js')).handleStopBackgroundTask(msg);
|
|
110
|
-
break;
|
|
111
|
-
|
|
112
108
|
case 'cancel_execution':
|
|
113
109
|
await handleCancelExecution(msg);
|
|
114
110
|
break;
|
package/conversation.js
CHANGED
|
@@ -551,36 +551,6 @@ export async function handleCancelExecution(msg) {
|
|
|
551
551
|
sendConversationList();
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
-
// 停止单个后台任务
|
|
555
|
-
// NOTE: This is a "soft stop" — it marks the task as stopped in our tracking state
|
|
556
|
-
// and notifies the frontend, but does NOT actually kill the underlying subprocess.
|
|
557
|
-
// The Claude SDK's abortController.abort() kills the entire session (all sub-tasks),
|
|
558
|
-
// not individual ones. Per-sub-agent abort is not supported by the SDK.
|
|
559
|
-
// TODO [P2]: Investigate SDK changes that might enable per-task cancellation.
|
|
560
|
-
export async function handleStopBackgroundTask(msg) {
|
|
561
|
-
const { conversationId, taskId } = msg;
|
|
562
|
-
|
|
563
|
-
const state = ctx.conversations.get(conversationId);
|
|
564
|
-
if (!state) return;
|
|
565
|
-
|
|
566
|
-
const taskInfo = state.backgroundTasks?.get(taskId);
|
|
567
|
-
if (!taskInfo) return;
|
|
568
|
-
|
|
569
|
-
// Soft stop: mark as stopped in tracking state (subprocess continues in background)
|
|
570
|
-
taskInfo.status = 'stopped';
|
|
571
|
-
taskInfo.endTime = Date.now();
|
|
572
|
-
|
|
573
|
-
console.log(`[Tasks] Stopped: ${taskId} in conversation ${conversationId}`);
|
|
574
|
-
|
|
575
|
-
// Notify frontend
|
|
576
|
-
ctx.sendToServer({
|
|
577
|
-
type: 'background_task_output',
|
|
578
|
-
conversationId,
|
|
579
|
-
taskId,
|
|
580
|
-
task: taskInfo
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
|
|
584
554
|
// 清空排队消息 — 已移至 server 端管理 (Phase 3.6)
|
|
585
555
|
// handleClearQueue 和 handleCancelQueuedMessage 不再需要
|
|
586
556
|
|
package/package.json
CHANGED
package/subagent.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-Agent JSONL Watcher
|
|
3
|
+
*
|
|
4
|
+
* Watches the Claude CLI subagent JSONL session files for real-time messages.
|
|
5
|
+
* When a Task tool_use is detected in claude.js, this module starts watching
|
|
6
|
+
* the `{sessionId}/subagents/` directory. New JSONL files are tail-read line
|
|
7
|
+
* by line, extracting assistant text and tool_use summaries, then forwarded
|
|
8
|
+
* to the server as `subagent_message` events.
|
|
9
|
+
*
|
|
10
|
+
* JSONL location: ~/.claude/projects/{projectFolder}/{sessionId}/subagents/agent-{id}.jsonl
|
|
11
|
+
* Meta location: ~/.claude/projects/{projectFolder}/{sessionId}/subagents/agent-{id}.meta.json
|
|
12
|
+
*/
|
|
13
|
+
import { watch, readFileSync, openSync, readSync, closeSync, statSync, existsSync, readdirSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import ctx from './context.js';
|
|
16
|
+
import { getClaudeProjectsDir, pathToProjectFolder } from './history.js';
|
|
17
|
+
|
|
18
|
+
// Active watchers: conversationId -> { dirWatcher, fileWatchers: Map<filePath, { bytesRead, watcher }> }
|
|
19
|
+
const activeWatchers = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the subagents directory for a given conversation state.
|
|
23
|
+
*/
|
|
24
|
+
function getSubagentsDir(state) {
|
|
25
|
+
if (!state.claudeSessionId || !state.workDir) return null;
|
|
26
|
+
const projectFolder = pathToProjectFolder(state.workDir);
|
|
27
|
+
return join(getClaudeProjectsDir(), projectFolder, state.claudeSessionId, 'subagents');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse agent ID from filename (e.g. "agent-a12015b.jsonl" → "a12015b")
|
|
32
|
+
*/
|
|
33
|
+
function parseAgentId(filename) {
|
|
34
|
+
const match = filename.match(/^agent-([^.]+)\.jsonl$/);
|
|
35
|
+
return match ? match[1] : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read meta.json for a subagent to get its type (e.g. "Explore")
|
|
40
|
+
*/
|
|
41
|
+
function readAgentMeta(subagentsDir, agentId) {
|
|
42
|
+
try {
|
|
43
|
+
const metaPath = join(subagentsDir, `agent-${agentId}.meta.json`);
|
|
44
|
+
if (existsSync(metaPath)) {
|
|
45
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
46
|
+
return { agentType: meta.agentType || 'Task' };
|
|
47
|
+
}
|
|
48
|
+
} catch { /* ignore */ }
|
|
49
|
+
return { agentType: 'Task' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract displayable content from a JSONL message line.
|
|
54
|
+
* Returns null if the message should be skipped.
|
|
55
|
+
*/
|
|
56
|
+
function parseJsonlLine(line) {
|
|
57
|
+
try {
|
|
58
|
+
const data = JSON.parse(line);
|
|
59
|
+
|
|
60
|
+
// Only process assistant messages
|
|
61
|
+
if (data.type !== 'assistant' || !data.message?.content) return null;
|
|
62
|
+
|
|
63
|
+
const content = data.message.content;
|
|
64
|
+
const messages = [];
|
|
65
|
+
|
|
66
|
+
// Handle string content (simple text)
|
|
67
|
+
if (typeof content === 'string') {
|
|
68
|
+
if (content.trim()) {
|
|
69
|
+
messages.push({ type: 'text', content: content.trim(), timestamp: data.timestamp });
|
|
70
|
+
}
|
|
71
|
+
return messages.length > 0 ? messages : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle array content (text blocks + tool_use blocks)
|
|
75
|
+
if (Array.isArray(content)) {
|
|
76
|
+
for (const block of content) {
|
|
77
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
78
|
+
messages.push({ type: 'text', content: block.text.trim(), timestamp: data.timestamp });
|
|
79
|
+
} else if (block.type === 'tool_use') {
|
|
80
|
+
const toolName = block.name || 'Unknown';
|
|
81
|
+
const summary = formatToolSummary(toolName, block.input);
|
|
82
|
+
messages.push({ type: 'tool', content: summary, timestamp: data.timestamp, toolName });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return messages.length > 0 ? messages : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format a one-liner summary for a tool_use block.
|
|
96
|
+
*/
|
|
97
|
+
function formatToolSummary(toolName, input) {
|
|
98
|
+
if (!input) return toolName;
|
|
99
|
+
switch (toolName) {
|
|
100
|
+
case 'Read':
|
|
101
|
+
return `Read ${input.file_path || ''}`;
|
|
102
|
+
case 'Write':
|
|
103
|
+
return `Write ${input.file_path || ''}`;
|
|
104
|
+
case 'Edit':
|
|
105
|
+
return `Edit ${input.file_path || ''}`;
|
|
106
|
+
case 'Bash':
|
|
107
|
+
return `Bash: ${(input.command || '').substring(0, 80)}`;
|
|
108
|
+
case 'Glob':
|
|
109
|
+
return `Glob ${input.pattern || ''}`;
|
|
110
|
+
case 'Grep':
|
|
111
|
+
return `Grep ${input.pattern || ''}`;
|
|
112
|
+
case 'WebFetch':
|
|
113
|
+
return `WebFetch ${input.url || ''}`;
|
|
114
|
+
case 'WebSearch':
|
|
115
|
+
return `WebSearch: ${input.query || ''}`;
|
|
116
|
+
default:
|
|
117
|
+
return toolName;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Start tail-reading a JSONL file for new lines.
|
|
123
|
+
*/
|
|
124
|
+
function startFileWatcher(filePath, agentId, conversationId, watcherState) {
|
|
125
|
+
let bytesRead = 0;
|
|
126
|
+
|
|
127
|
+
// Read existing content first
|
|
128
|
+
try {
|
|
129
|
+
const stat = statSync(filePath);
|
|
130
|
+
if (stat.size > 0) {
|
|
131
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
132
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const parsed = parseJsonlLine(line);
|
|
135
|
+
if (parsed) {
|
|
136
|
+
for (const msg of parsed) {
|
|
137
|
+
ctx.sendToServer({
|
|
138
|
+
type: 'subagent_message',
|
|
139
|
+
conversationId,
|
|
140
|
+
subagentId: agentId,
|
|
141
|
+
message: msg
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
bytesRead = stat.size;
|
|
147
|
+
}
|
|
148
|
+
} catch { /* file might not be ready yet */ }
|
|
149
|
+
|
|
150
|
+
// Watch for new content
|
|
151
|
+
let pendingPartial = '';
|
|
152
|
+
const watcher = watch(filePath, () => {
|
|
153
|
+
try {
|
|
154
|
+
const stat = statSync(filePath);
|
|
155
|
+
if (stat.size <= bytesRead) return;
|
|
156
|
+
|
|
157
|
+
const fd = openSync(filePath, 'r');
|
|
158
|
+
const buffer = Buffer.alloc(stat.size - bytesRead);
|
|
159
|
+
readSync(fd, buffer, 0, buffer.length, bytesRead);
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
|
|
162
|
+
const newContent = pendingPartial + buffer.toString('utf-8');
|
|
163
|
+
|
|
164
|
+
// Split into lines; the last element may be a partial line
|
|
165
|
+
const parts = newContent.split('\n');
|
|
166
|
+
const maybePartial = parts.pop(); // last element ('' if content ended with \n, or partial line)
|
|
167
|
+
pendingPartial = maybePartial || '';
|
|
168
|
+
|
|
169
|
+
// Advance bytesRead by what we consumed (total read minus what we're holding back)
|
|
170
|
+
bytesRead = stat.size - Buffer.byteLength(pendingPartial, 'utf-8');
|
|
171
|
+
|
|
172
|
+
for (const line of parts) {
|
|
173
|
+
if (!line.trim()) continue;
|
|
174
|
+
const parsed = parseJsonlLine(line);
|
|
175
|
+
if (parsed) {
|
|
176
|
+
for (const msg of parsed) {
|
|
177
|
+
ctx.sendToServer({
|
|
178
|
+
type: 'subagent_message',
|
|
179
|
+
conversationId,
|
|
180
|
+
subagentId: agentId,
|
|
181
|
+
message: msg
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch { /* ignore read errors during active writes */ }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
watcherState.fileWatchers.set(filePath, { watcher });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Start watching the subagents directory for a conversation.
|
|
194
|
+
* Called when a Task tool_use is detected.
|
|
195
|
+
*/
|
|
196
|
+
export function startSubagentWatcher(conversationId, state, toolUseId) {
|
|
197
|
+
const subagentsDir = getSubagentsDir(state);
|
|
198
|
+
if (!subagentsDir) return;
|
|
199
|
+
|
|
200
|
+
// If already watching this conversation, just record the mapping
|
|
201
|
+
if (activeWatchers.has(conversationId)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const watcherState = {
|
|
206
|
+
dirWatcher: null,
|
|
207
|
+
fileWatchers: new Map(),
|
|
208
|
+
knownAgents: new Set(),
|
|
209
|
+
toolUseId,
|
|
210
|
+
subagentsDir
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Function to discover and start watching a new agent JSONL file
|
|
214
|
+
const discoverAgentFile = (filename) => {
|
|
215
|
+
const agentId = parseAgentId(filename);
|
|
216
|
+
if (!agentId || watcherState.knownAgents.has(agentId)) return;
|
|
217
|
+
watcherState.knownAgents.add(agentId);
|
|
218
|
+
|
|
219
|
+
const filePath = join(subagentsDir, filename);
|
|
220
|
+
const meta = readAgentMeta(subagentsDir, agentId);
|
|
221
|
+
|
|
222
|
+
// Read slug from first line of JSONL if available
|
|
223
|
+
let slug = agentId;
|
|
224
|
+
try {
|
|
225
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
226
|
+
const firstLine = content.split('\n')[0];
|
|
227
|
+
if (firstLine) {
|
|
228
|
+
const data = JSON.parse(firstLine);
|
|
229
|
+
if (data.slug) slug = data.slug;
|
|
230
|
+
}
|
|
231
|
+
} catch { /* use agentId as fallback slug */ }
|
|
232
|
+
|
|
233
|
+
// Send subagent_started
|
|
234
|
+
ctx.sendToServer({
|
|
235
|
+
type: 'subagent_started',
|
|
236
|
+
conversationId,
|
|
237
|
+
subagentId: agentId,
|
|
238
|
+
slug,
|
|
239
|
+
subagentType: meta.agentType,
|
|
240
|
+
parentToolUseId: toolUseId,
|
|
241
|
+
description: `${meta.agentType} agent`
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
startFileWatcher(filePath, agentId, conversationId, watcherState);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Check for existing files first
|
|
248
|
+
try {
|
|
249
|
+
if (existsSync(subagentsDir)) {
|
|
250
|
+
const files = readdirSync(subagentsDir);
|
|
251
|
+
for (const file of files) {
|
|
252
|
+
if (file.endsWith('.jsonl')) {
|
|
253
|
+
discoverAgentFile(file);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch { /* dir might not exist yet */ }
|
|
258
|
+
|
|
259
|
+
// Watch for new files in the subagents directory
|
|
260
|
+
// Use an interval-based check because fs.watch on directories can be unreliable
|
|
261
|
+
const pollInterval = setInterval(() => {
|
|
262
|
+
try {
|
|
263
|
+
if (!existsSync(subagentsDir)) return;
|
|
264
|
+
const files = readdirSync(subagentsDir);
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
if (file.endsWith('.jsonl')) {
|
|
267
|
+
discoverAgentFile(file);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch { /* ignore */ }
|
|
271
|
+
}, 500);
|
|
272
|
+
|
|
273
|
+
watcherState.dirWatcher = pollInterval;
|
|
274
|
+
activeWatchers.set(conversationId, watcherState);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Stop watching subagents for a conversation's Task tool_use.
|
|
279
|
+
* Called when the Task tool_result is received.
|
|
280
|
+
*/
|
|
281
|
+
export function stopSubagentWatcher(conversationId, toolUseId) {
|
|
282
|
+
const watcherState = activeWatchers.get(conversationId);
|
|
283
|
+
if (!watcherState) return;
|
|
284
|
+
|
|
285
|
+
// Only stop if this is the matching tool_use
|
|
286
|
+
if (watcherState.toolUseId !== toolUseId) return;
|
|
287
|
+
|
|
288
|
+
// Send completion for all known agents
|
|
289
|
+
for (const agentId of watcherState.knownAgents) {
|
|
290
|
+
ctx.sendToServer({
|
|
291
|
+
type: 'subagent_completed',
|
|
292
|
+
conversationId,
|
|
293
|
+
subagentId: agentId
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Clean up watchers
|
|
298
|
+
if (watcherState.dirWatcher) {
|
|
299
|
+
clearInterval(watcherState.dirWatcher);
|
|
300
|
+
}
|
|
301
|
+
for (const { watcher } of watcherState.fileWatchers.values()) {
|
|
302
|
+
try { watcher.close(); } catch { /* ignore */ }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
activeWatchers.delete(conversationId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Clean up all watchers for a conversation (e.g. on conversation close).
|
|
310
|
+
*/
|
|
311
|
+
export function cleanupSubagentWatchers(conversationId) {
|
|
312
|
+
const watcherState = activeWatchers.get(conversationId);
|
|
313
|
+
if (!watcherState) return;
|
|
314
|
+
|
|
315
|
+
if (watcherState.dirWatcher) {
|
|
316
|
+
clearInterval(watcherState.dirWatcher);
|
|
317
|
+
}
|
|
318
|
+
for (const { watcher } of watcherState.fileWatchers.values()) {
|
|
319
|
+
try { watcher.close(); } catch { /* ignore */ }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
activeWatchers.delete(conversationId);
|
|
323
|
+
}
|