@yeaft/webchat-agent 0.1.250 → 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 +13 -1
- package/connection/buffer.js +1 -0
- package/context.js +2 -0
- package/conversation.js +117 -1
- 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
|
}
|
|
@@ -310,7 +320,8 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
310
320
|
ctx.sendToServer({
|
|
311
321
|
type: 'slash_commands_update',
|
|
312
322
|
conversationId,
|
|
313
|
-
slashCommands: state.slashCommands
|
|
323
|
+
slashCommands: state.slashCommands,
|
|
324
|
+
slashCommandDescriptions: ctx.slashCommandDescriptions
|
|
314
325
|
});
|
|
315
326
|
}
|
|
316
327
|
}
|
|
@@ -563,6 +574,7 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
563
574
|
console.log(`[SDK] Stale processClaudeOutput for ${conversationId}, skipping cleanup`);
|
|
564
575
|
} else if (!wasCancelled && (wasTurnActive || !resultHandled)) {
|
|
565
576
|
// 进程异常退出:要么 turn 正在进行中,要么从未成功完成过任何 turn
|
|
577
|
+
cleanupSubagentWatchers(conversationId);
|
|
566
578
|
ctx.sendToServer({
|
|
567
579
|
type: 'conversation_closed',
|
|
568
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',
|
package/context.js
CHANGED
|
@@ -14,6 +14,8 @@ export default {
|
|
|
14
14
|
agentCapabilities: [],
|
|
15
15
|
// Agent 级别的 slash commands 缓存(所有 conversation 共用)
|
|
16
16
|
slashCommands: [],
|
|
17
|
+
// Slash command 描述映射: { commandName: description } — 从 plugin commands/*.md 提取
|
|
18
|
+
slashCommandDescriptions: {},
|
|
17
19
|
// MCP servers 列表 (从 ~/.claude.json 读取): [{ name, enabled, source }]
|
|
18
20
|
mcpServers: [],
|
|
19
21
|
// 连接相关
|
package/conversation.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
1
4
|
import ctx from './context.js';
|
|
2
5
|
import { query } from './sdk/index.js';
|
|
3
6
|
import { loadSessionHistory } from './history.js';
|
|
@@ -7,6 +10,114 @@ import { crewSessions, loadCrewIndex } from './crew.js';
|
|
|
7
10
|
// 不支持的斜杠命令(真正需要交互式 CLI 的命令)
|
|
8
11
|
const UNSUPPORTED_SLASH_COMMANDS = ['/help', '/bug', '/login', '/logout', '/terminal-setup', '/vim', '/config'];
|
|
9
12
|
|
|
13
|
+
// 内置命令的描述(作为 fallback,这些命令 CLI 不提供 frontmatter 描述)
|
|
14
|
+
const BUILTIN_COMMAND_DESCRIPTIONS = {
|
|
15
|
+
compact: 'Compact conversation context',
|
|
16
|
+
context: 'Show context usage',
|
|
17
|
+
cost: 'Show token costs',
|
|
18
|
+
init: 'Reinitialize session',
|
|
19
|
+
review: 'Code review',
|
|
20
|
+
insights: 'Session insights',
|
|
21
|
+
'pr-comments': 'PR comment review',
|
|
22
|
+
'release-notes': 'Generate release notes',
|
|
23
|
+
'security-review': 'Security review',
|
|
24
|
+
heapdump: 'Heap dump (debug)',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load command descriptions from installed plugin commands/*.md files.
|
|
29
|
+
* Parses YAML frontmatter to extract name and description fields.
|
|
30
|
+
* Results are cached in ctx.slashCommandDescriptions.
|
|
31
|
+
*/
|
|
32
|
+
export function loadPluginCommandDescriptions() {
|
|
33
|
+
if (Object.keys(ctx.slashCommandDescriptions).length > 0) return; // already loaded
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const installedPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
37
|
+
const installed = JSON.parse(readFileSync(installedPath, 'utf-8'));
|
|
38
|
+
|
|
39
|
+
for (const [pluginKey, entries] of Object.entries(installed.plugins || {})) {
|
|
40
|
+
// pluginKey: "yeaft-skills@yeaft-skills-dev"
|
|
41
|
+
const pluginName = pluginKey.split('@')[0]; // "yeaft-skills"
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const commandsDir = join(entry.installPath, 'commands');
|
|
44
|
+
let files;
|
|
45
|
+
try {
|
|
46
|
+
files = readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
|
47
|
+
} catch { continue; }
|
|
48
|
+
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
try {
|
|
51
|
+
const content = readFileSync(join(commandsDir, file), 'utf-8');
|
|
52
|
+
const fm = parseFrontmatter(content);
|
|
53
|
+
if (fm.name && fm.description) {
|
|
54
|
+
// CLI uses pluginName:commandName format (e.g. "yeaft-skills:sprint")
|
|
55
|
+
const cliName = `${pluginName}:${fm.name}`;
|
|
56
|
+
// Take first line of description only
|
|
57
|
+
const desc = fm.description.split('\n')[0].trim();
|
|
58
|
+
ctx.slashCommandDescriptions[cliName] = desc;
|
|
59
|
+
}
|
|
60
|
+
} catch { /* skip unparseable files */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add builtin descriptions
|
|
66
|
+
for (const [name, desc] of Object.entries(BUILTIN_COMMAND_DESCRIPTIONS)) {
|
|
67
|
+
if (!ctx.slashCommandDescriptions[name]) {
|
|
68
|
+
ctx.slashCommandDescriptions[name] = desc;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`[Preload] Loaded ${Object.keys(ctx.slashCommandDescriptions).length} command descriptions`);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.warn('[Preload] Failed to load plugin command descriptions:', err.message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Minimal YAML frontmatter parser for commands/*.md files.
|
|
80
|
+
* Extracts name and description from --- delimited frontmatter.
|
|
81
|
+
*/
|
|
82
|
+
function parseFrontmatter(content) {
|
|
83
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
84
|
+
if (!match) return {};
|
|
85
|
+
|
|
86
|
+
const fm = {};
|
|
87
|
+
const lines = match[1].split('\n');
|
|
88
|
+
let currentKey = null;
|
|
89
|
+
let multilineValue = '';
|
|
90
|
+
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
// New key: value pair
|
|
93
|
+
const kvMatch = line.match(/^(\w+):\s*(.*)/);
|
|
94
|
+
if (kvMatch) {
|
|
95
|
+
// Save previous multiline value
|
|
96
|
+
if (currentKey && multilineValue) {
|
|
97
|
+
fm[currentKey] = multilineValue.trim();
|
|
98
|
+
}
|
|
99
|
+
currentKey = kvMatch[1];
|
|
100
|
+
const val = kvMatch[2].trim();
|
|
101
|
+
if (val === '|' || val === '>') {
|
|
102
|
+
multilineValue = '';
|
|
103
|
+
} else {
|
|
104
|
+
fm[currentKey] = val;
|
|
105
|
+
currentKey = null;
|
|
106
|
+
multilineValue = '';
|
|
107
|
+
}
|
|
108
|
+
} else if (currentKey && (line.startsWith(' ') || line.trim() === '')) {
|
|
109
|
+
// Continuation of multiline value
|
|
110
|
+
multilineValue += (multilineValue ? '\n' : '') + line.trimStart();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Save last multiline value
|
|
114
|
+
if (currentKey && multilineValue) {
|
|
115
|
+
fm[currentKey] = multilineValue.trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return fm;
|
|
119
|
+
}
|
|
120
|
+
|
|
10
121
|
/**
|
|
11
122
|
* Prestart Claude CLI process in background (fire-and-forget).
|
|
12
123
|
* When the query starts, processClaudeOutput will receive the system init message
|
|
@@ -33,6 +144,10 @@ function prestartClaude(conversationId, workDir, resumeSessionId) {
|
|
|
33
144
|
*/
|
|
34
145
|
export async function preloadSlashCommands(workDir, targetId = '__preload__') {
|
|
35
146
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
147
|
+
|
|
148
|
+
// Eagerly load plugin command descriptions (cached, runs once)
|
|
149
|
+
loadPluginCommandDescriptions();
|
|
150
|
+
|
|
36
151
|
try {
|
|
37
152
|
const abortController = new AbortController();
|
|
38
153
|
// Use --print with a cheap built-in command to trigger system init.
|
|
@@ -58,7 +173,8 @@ export async function preloadSlashCommands(workDir, targetId = '__preload__') {
|
|
|
58
173
|
ctx.sendToServer({
|
|
59
174
|
type: 'slash_commands_update',
|
|
60
175
|
conversationId: targetId,
|
|
61
|
-
slashCommands
|
|
176
|
+
slashCommands,
|
|
177
|
+
slashCommandDescriptions: ctx.slashCommandDescriptions
|
|
62
178
|
});
|
|
63
179
|
console.log(`[Preload] ${targetId}: loaded ${slashCommands.length} slash commands from ${effectiveWorkDir}`);
|
|
64
180
|
}
|
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
|
+
}
|