@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 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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.251",
3
+ "version": "0.1.252",
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/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
+ }