tide-commander 0.69.3 → 0.70.0

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Subagent JSONL File Watcher
3
+ *
4
+ * Watches Claude Code's subagent JSONL files for real-time activity streaming.
5
+ * Files are located at: ~/.claude/projects/<encoded-project>/<sessionId>/subagents/agent-<id>.jsonl
6
+ *
7
+ * Each JSONL line contains a message entry (user prompt, assistant text, tool_use, tool_result).
8
+ * We parse these and broadcast them to the UI for real-time subagent visibility.
9
+ *
10
+ * Lifecycle: The watcher starts when a Task tool spawns a subagent. For team agents,
11
+ * the Task tool returns immediately but the subagent process keeps running and writing
12
+ * to the JSONL file. The watcher uses idle-based auto-stop: it keeps running as long as
13
+ * the file is growing, and stops after IDLE_TIMEOUT_MS of no new content.
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import * as os from 'os';
18
+ import { createLogger } from '../utils/logger.js';
19
+ import { encodeProjectPath } from '../claude/session-loader.js';
20
+ const log = createLogger('SubagentJSONL');
21
+ const MAX_ENTRIES_PER_BROADCAST = 20;
22
+ const IDLE_TIMEOUT_MS = 180_000; // Stop after 3 minutes of no file changes (subagents can run long builds)
23
+ const POLL_INTERVAL_MS = 2_000; // Poll file every 2s (fallback if fs.watch misses events)
24
+ const MAX_WATCH_DURATION_MS = 900_000; // Hard limit: 15 minutes max per watcher
25
+ // Key param extraction per tool name
26
+ const TOOL_KEY_PARAMS = {
27
+ Bash: 'command',
28
+ Read: 'file_path',
29
+ Edit: 'file_path',
30
+ Write: 'file_path',
31
+ Grep: 'pattern',
32
+ Glob: 'pattern',
33
+ WebSearch: 'query',
34
+ WebFetch: 'url',
35
+ Task: 'description',
36
+ NotebookEdit: 'notebook_path',
37
+ };
38
+ const activeWatchers = new Map();
39
+ /**
40
+ * Get the subagents directory for a given agent's session
41
+ */
42
+ export function getSubagentsDir(cwd, sessionId) {
43
+ const encoded = encodeProjectPath(cwd);
44
+ return path.join(os.homedir(), '.claude', 'projects', encoded, sessionId, 'subagents');
45
+ }
46
+ /**
47
+ * Start watching for a subagent's JSONL file
48
+ */
49
+ export function startWatching(toolUseId, parentAgentId, subagentsDir, onBroadcast) {
50
+ if (activeWatchers.has(toolUseId)) {
51
+ log.warn(`[Watcher] Already watching for toolUseId=${toolUseId}`);
52
+ return;
53
+ }
54
+ const watcher = {
55
+ toolUseId,
56
+ parentAgentId,
57
+ subagentsDir,
58
+ readPosition: 0,
59
+ lineBuffer: '',
60
+ pendingEntries: [],
61
+ onBroadcast,
62
+ stopped: false,
63
+ lastReadTime: Date.now(),
64
+ };
65
+ activeWatchers.set(toolUseId, watcher);
66
+ log.log(`[Watcher] Starting watch for toolUseId=${toolUseId}, dir=${subagentsDir}`);
67
+ // Set a hard max duration to prevent leaks
68
+ watcher.maxTimer = setTimeout(() => {
69
+ log.log(`[Watcher] Max duration reached for toolUseId=${toolUseId}, stopping`);
70
+ doStop(watcher);
71
+ }, MAX_WATCH_DURATION_MS);
72
+ // Try to find existing files first, then watch for new ones
73
+ tryFindAndWatchFile(watcher);
74
+ }
75
+ /**
76
+ * Signal that the subagent's Task tool has completed.
77
+ * For team agents, the Task returns immediately but the subagent keeps running.
78
+ * We don't stop the watcher - it will auto-stop when the file goes idle.
79
+ */
80
+ export function stopWatching(toolUseId) {
81
+ const watcher = activeWatchers.get(toolUseId);
82
+ if (!watcher)
83
+ return;
84
+ // Just do a read to catch any pending content, but DON'T stop.
85
+ // The idle timeout will handle actual cleanup.
86
+ if (watcher.jsonlPath) {
87
+ readNewLines(watcher);
88
+ }
89
+ log.log(`[Watcher] Task completed for toolUseId=${toolUseId}, watcher continues (idle-based stop)`);
90
+ }
91
+ /**
92
+ * Actually stop and cleanup a watcher
93
+ */
94
+ function doStop(watcher) {
95
+ if (watcher.stopped)
96
+ return;
97
+ watcher.stopped = true;
98
+ // Final read
99
+ if (watcher.jsonlPath) {
100
+ readNewLines(watcher);
101
+ }
102
+ flushEntries(watcher);
103
+ // Cleanup all timers and watchers
104
+ watcher.dirWatcher?.close();
105
+ watcher.fileWatcher?.close();
106
+ if (watcher.broadcastTimer)
107
+ clearTimeout(watcher.broadcastTimer);
108
+ if (watcher.pollTimer)
109
+ clearInterval(watcher.pollTimer);
110
+ if (watcher.idleTimer)
111
+ clearTimeout(watcher.idleTimer);
112
+ if (watcher.maxTimer)
113
+ clearTimeout(watcher.maxTimer);
114
+ activeWatchers.delete(watcher.toolUseId);
115
+ log.log(`[Watcher] Stopped watching toolUseId=${watcher.toolUseId}`);
116
+ }
117
+ /**
118
+ * Stop all watchers (server shutdown)
119
+ */
120
+ export function stopAll() {
121
+ for (const watcher of activeWatchers.values()) {
122
+ doStop(watcher);
123
+ }
124
+ }
125
+ /**
126
+ * Reset the idle timer - called whenever new content is read
127
+ */
128
+ function resetIdleTimer(watcher) {
129
+ if (watcher.idleTimer)
130
+ clearTimeout(watcher.idleTimer);
131
+ watcher.idleTimer = setTimeout(() => {
132
+ log.log(`[Watcher] Idle timeout for toolUseId=${watcher.toolUseId}, stopping`);
133
+ doStop(watcher);
134
+ }, IDLE_TIMEOUT_MS);
135
+ }
136
+ /**
137
+ * Try to find an existing JSONL file or watch the directory for new ones
138
+ */
139
+ function tryFindAndWatchFile(watcher) {
140
+ const { subagentsDir } = watcher;
141
+ // Check if directory exists yet
142
+ if (!fs.existsSync(subagentsDir)) {
143
+ // Directory doesn't exist yet - watch parent for it to appear
144
+ const parentDir = path.dirname(subagentsDir);
145
+ if (!fs.existsSync(parentDir)) {
146
+ // Session directory doesn't exist either - retry periodically
147
+ const retryInterval = setInterval(() => {
148
+ if (watcher.stopped) {
149
+ clearInterval(retryInterval);
150
+ return;
151
+ }
152
+ if (fs.existsSync(subagentsDir)) {
153
+ clearInterval(retryInterval);
154
+ watchDirectory(watcher);
155
+ }
156
+ }, 500);
157
+ // Give up after 30 seconds
158
+ setTimeout(() => clearInterval(retryInterval), 30000);
159
+ return;
160
+ }
161
+ // Watch parent directory for subagents/ to appear
162
+ try {
163
+ const parentWatcher = fs.watch(parentDir, (eventType, filename) => {
164
+ if (watcher.stopped)
165
+ return;
166
+ if (filename === 'subagents' && fs.existsSync(subagentsDir)) {
167
+ parentWatcher.close();
168
+ watchDirectory(watcher);
169
+ }
170
+ });
171
+ parentWatcher.on('error', () => parentWatcher.close());
172
+ }
173
+ catch {
174
+ log.warn(`[Watcher] Failed to watch parent dir: ${parentDir}`);
175
+ }
176
+ return;
177
+ }
178
+ // Directory exists - look for existing files or watch for new ones
179
+ watchDirectory(watcher);
180
+ }
181
+ /**
182
+ * Watch the subagents directory for JSONL files
183
+ */
184
+ function watchDirectory(watcher) {
185
+ if (watcher.stopped)
186
+ return;
187
+ const { subagentsDir } = watcher;
188
+ // Check for existing .jsonl files
189
+ try {
190
+ const files = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl'));
191
+ if (files.length > 0) {
192
+ // Pick the most recently modified file
193
+ let newest = files[0];
194
+ let newestMtime = 0;
195
+ for (const f of files) {
196
+ try {
197
+ const stat = fs.statSync(path.join(subagentsDir, f));
198
+ if (stat.mtimeMs > newestMtime) {
199
+ newestMtime = stat.mtimeMs;
200
+ newest = f;
201
+ }
202
+ }
203
+ catch { /* skip */ }
204
+ }
205
+ startFileWatch(watcher, path.join(subagentsDir, newest));
206
+ return;
207
+ }
208
+ }
209
+ catch { /* directory may have been removed */ }
210
+ // No files yet - watch for new ones
211
+ try {
212
+ watcher.dirWatcher = fs.watch(subagentsDir, (eventType, filename) => {
213
+ if (watcher.stopped)
214
+ return;
215
+ if (filename && filename.endsWith('.jsonl') && !watcher.jsonlPath) {
216
+ const filePath = path.join(subagentsDir, filename);
217
+ if (fs.existsSync(filePath)) {
218
+ watcher.dirWatcher?.close();
219
+ watcher.dirWatcher = undefined;
220
+ startFileWatch(watcher, filePath);
221
+ }
222
+ }
223
+ });
224
+ watcher.dirWatcher.on('error', () => {
225
+ watcher.dirWatcher?.close();
226
+ watcher.dirWatcher = undefined;
227
+ });
228
+ }
229
+ catch {
230
+ log.warn(`[Watcher] Failed to watch directory: ${subagentsDir}`);
231
+ }
232
+ }
233
+ /**
234
+ * Start watching a specific JSONL file for new content
235
+ */
236
+ function startFileWatch(watcher, filePath) {
237
+ if (watcher.stopped)
238
+ return;
239
+ watcher.jsonlPath = filePath;
240
+ log.log(`[Watcher] Found JSONL file: ${filePath} for toolUseId=${watcher.toolUseId}`);
241
+ // Read existing content
242
+ readNewLines(watcher);
243
+ // Start idle timer
244
+ resetIdleTimer(watcher);
245
+ // Watch for changes via fs.watch
246
+ try {
247
+ watcher.fileWatcher = fs.watch(filePath, (eventType) => {
248
+ if (watcher.stopped)
249
+ return;
250
+ if (eventType === 'change') {
251
+ readNewLines(watcher);
252
+ }
253
+ });
254
+ watcher.fileWatcher.on('error', () => {
255
+ watcher.fileWatcher?.close();
256
+ watcher.fileWatcher = undefined;
257
+ });
258
+ }
259
+ catch {
260
+ log.warn(`[Watcher] Failed to watch file: ${filePath}`);
261
+ }
262
+ // Also poll periodically as fallback (fs.watch can miss events on some systems)
263
+ watcher.pollTimer = setInterval(() => {
264
+ if (watcher.stopped)
265
+ return;
266
+ readNewLines(watcher);
267
+ }, POLL_INTERVAL_MS);
268
+ }
269
+ /**
270
+ * Read new lines from the JSONL file since last read position
271
+ */
272
+ function readNewLines(watcher) {
273
+ if (!watcher.jsonlPath)
274
+ return;
275
+ let fd;
276
+ try {
277
+ fd = fs.openSync(watcher.jsonlPath, 'r');
278
+ const stat = fs.fstatSync(fd);
279
+ if (stat.size <= watcher.readPosition) {
280
+ fs.closeSync(fd);
281
+ return;
282
+ }
283
+ const bytesToRead = stat.size - watcher.readPosition;
284
+ const buffer = Buffer.alloc(bytesToRead);
285
+ fs.readSync(fd, buffer, 0, bytesToRead, watcher.readPosition);
286
+ fs.closeSync(fd);
287
+ fd = undefined;
288
+ watcher.readPosition = stat.size;
289
+ watcher.lastReadTime = Date.now();
290
+ // Reset idle timer since we got new content
291
+ resetIdleTimer(watcher);
292
+ // Split into lines, handling partial lines
293
+ const text = watcher.lineBuffer + buffer.toString('utf8');
294
+ const lines = text.split('\n');
295
+ // Last element may be incomplete - save as buffer
296
+ watcher.lineBuffer = lines.pop() || '';
297
+ for (const line of lines) {
298
+ const trimmed = line.trim();
299
+ if (!trimmed)
300
+ continue;
301
+ const entries = parseLine(trimmed);
302
+ if (entries.length > 0) {
303
+ watcher.pendingEntries.push(...entries);
304
+ }
305
+ }
306
+ // Debounce broadcast
307
+ scheduleBroadcast(watcher);
308
+ }
309
+ catch {
310
+ if (fd !== undefined) {
311
+ try {
312
+ fs.closeSync(fd);
313
+ }
314
+ catch { /* ignore */ }
315
+ }
316
+ // File may have been removed or not ready
317
+ }
318
+ }
319
+ /**
320
+ * Schedule a debounced broadcast of pending entries
321
+ */
322
+ function scheduleBroadcast(watcher) {
323
+ if (watcher.broadcastTimer)
324
+ return; // Already scheduled
325
+ watcher.broadcastTimer = setTimeout(() => {
326
+ watcher.broadcastTimer = undefined;
327
+ flushEntries(watcher);
328
+ }, 300);
329
+ }
330
+ /**
331
+ * Flush pending entries to the broadcast callback
332
+ */
333
+ function flushEntries(watcher) {
334
+ if (watcher.pendingEntries.length === 0)
335
+ return;
336
+ const entries = watcher.pendingEntries.splice(0, MAX_ENTRIES_PER_BROADCAST);
337
+ // If there are still more, keep the rest for next flush
338
+ if (watcher.pendingEntries.length > 0) {
339
+ scheduleBroadcast(watcher);
340
+ }
341
+ watcher.onBroadcast(watcher.toolUseId, watcher.parentAgentId, entries);
342
+ }
343
+ /**
344
+ * Parse a single JSONL line into SubagentStreamEntry items
345
+ */
346
+ function parseLine(line) {
347
+ const entries = [];
348
+ try {
349
+ const data = JSON.parse(line);
350
+ const message = data.message;
351
+ if (!message || !message.content)
352
+ return entries;
353
+ const timestamp = data.timestamp || new Date().toISOString();
354
+ const contentArray = Array.isArray(message.content) ? message.content : [];
355
+ // Skip initial user prompts (the task delegation message)
356
+ if (data.type === 'user' && message.role === 'user') {
357
+ // Check if this is a tool_result
358
+ for (const block of contentArray) {
359
+ if (block.type === 'tool_result') {
360
+ const resultText = typeof block.content === 'string'
361
+ ? block.content
362
+ : Array.isArray(block.content)
363
+ ? block.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ')
364
+ : '';
365
+ if (resultText) {
366
+ entries.push({
367
+ type: 'tool_result',
368
+ timestamp,
369
+ resultPreview: resultText.slice(0, 200),
370
+ isError: block.is_error === true,
371
+ toolUseId: block.tool_use_id,
372
+ });
373
+ }
374
+ }
375
+ }
376
+ return entries;
377
+ }
378
+ // Parse assistant messages
379
+ if (data.type === 'assistant' && message.role === 'assistant') {
380
+ for (const block of contentArray) {
381
+ if (block.type === 'text' && block.text) {
382
+ const text = block.text.trim();
383
+ if (text) {
384
+ entries.push({
385
+ type: 'text',
386
+ timestamp,
387
+ text: text.slice(0, 200),
388
+ });
389
+ }
390
+ }
391
+ else if (block.type === 'tool_use') {
392
+ const toolName = block.name || 'Unknown';
393
+ const input = block.input || {};
394
+ const keyParamName = TOOL_KEY_PARAMS[toolName];
395
+ const keyParam = keyParamName && input[keyParamName]
396
+ ? String(input[keyParamName]).slice(0, 120)
397
+ : undefined;
398
+ entries.push({
399
+ type: 'tool_use',
400
+ timestamp,
401
+ toolName,
402
+ toolKeyParam: keyParam,
403
+ toolUseId: block.id,
404
+ });
405
+ }
406
+ }
407
+ }
408
+ }
409
+ catch {
410
+ // Invalid JSON line - skip
411
+ }
412
+ return entries;
413
+ }
@@ -1,4 +1,4 @@
1
- import { saveAreas, saveBuildings } from '../../data/index.js';
1
+ import { saveAreas, saveBuildings, loadAreas, deleteAreaLogo } from '../../data/index.js';
2
2
  import { buildingService } from '../../services/index.js';
3
3
  import { logger } from '../../utils/index.js';
4
4
  const log = logger.ws;
@@ -11,6 +11,33 @@ function handleSyncMessage(ctx, payload, entityName, saveFn, updateType) {
11
11
  });
12
12
  }
13
13
  export function handleSyncAreas(ctx, payload) {
14
+ // Clean up orphaned logo files before saving
15
+ try {
16
+ const previousAreas = loadAreas();
17
+ const newAreaIds = new Set(payload.map(a => a.id));
18
+ for (const prev of previousAreas) {
19
+ // Area was deleted and had a logo
20
+ if (!newAreaIds.has(prev.id) && prev.logo?.filename) {
21
+ deleteAreaLogo(prev.logo.filename);
22
+ }
23
+ }
24
+ for (const area of payload) {
25
+ const prev = previousAreas.find(p => p.id === area.id);
26
+ if (!prev?.logo?.filename)
27
+ continue;
28
+ // Logo was removed from area
29
+ if (!area.logo?.filename) {
30
+ deleteAreaLogo(prev.logo.filename);
31
+ }
32
+ // Logo was replaced with a different file
33
+ else if (area.logo.filename !== prev.logo.filename) {
34
+ deleteAreaLogo(prev.logo.filename);
35
+ }
36
+ }
37
+ }
38
+ catch (err) {
39
+ log.error(' Failed to clean up area logos:', err);
40
+ }
14
41
  handleSyncMessage(ctx, payload, 'areas', saveAreas, 'areas_update');
15
42
  }
16
43
  export async function handleSyncBuildings(ctx, payload) {
@@ -6,6 +6,7 @@ import { parseAllFormats } from '../handlers/agent-handler.js';
6
6
  import { agentService, runtimeService } from '../../services/index.js';
7
7
  import { logger, formatToolActivity } from '../../utils/index.js';
8
8
  import { parseBossDelegation, parseBossSpawn, getBossForSubordinate, clearDelegation } from '../handlers/boss-response-handler.js';
9
+ import { startWatching as startJsonlWatching, stopWatching as stopJsonlWatching, getSubagentsDir } from '../../services/subagent-jsonl-watcher.js';
9
10
  const log = logger.ws;
10
11
  const MAX_SYNTHETIC_DIFF_FILE_BYTES = 256 * 1024;
11
12
  export function setupRuntimeListeners(ctx) {
@@ -47,6 +48,16 @@ export function setupRuntimeListeners(ctx) {
47
48
  });
48
49
  ctx.sendActivity(agentId, `Spawned subagent: ${subagent.name} (${subagent.subagentType})`);
49
50
  log.log(`[Subagent] Broadcast subagent_started: ${subagent.name} (${subagent.id})`);
51
+ // Start streaming JSONL file for this subagent
52
+ if (parentAgent?.sessionId) {
53
+ const subagentsDir = getSubagentsDir(parentAgent.cwd, parentAgent.sessionId);
54
+ startJsonlWatching(event.toolUseId, agentId, subagentsDir, (toolUseId, parentAgentId, entries) => {
55
+ ctx.broadcast({
56
+ type: 'subagent_stream',
57
+ payload: { toolUseId, parentAgentId, entries },
58
+ });
59
+ });
60
+ }
50
61
  }
51
62
  }
52
63
  }
@@ -84,6 +95,8 @@ export function setupRuntimeListeners(ctx) {
84
95
  },
85
96
  });
86
97
  log.log(`[Subagent] Broadcast subagent_completed for toolUseId=${event.toolUseId}, name=${event.subagentName || 'unknown'}, stats=${event.subagentStats ? `${event.subagentStats.durationMs}ms/${event.subagentStats.tokensUsed}tok/${event.subagentStats.toolUseCount}tools` : 'none'}`);
98
+ // Stop streaming JSONL file for this subagent
99
+ stopJsonlWatching(event.toolUseId);
87
100
  }
88
101
  // Forward subagent internal tool activity to client (events with parentToolUseId)
89
102
  if (event.parentToolUseId && event.type === 'tool_start' && event.toolName !== 'Task') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "0.69.3",
3
+ "version": "0.70.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",