tide-commander 0.69.2 → 0.69.4

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/dist/index.html CHANGED
@@ -22,11 +22,11 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-DkIjeEeR.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-D-4CYuC3.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-uS-d4TUT.js">
28
28
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-DJ4p3FLF.js">
29
- <link rel="stylesheet" crossorigin href="/assets/main-BOFXGJQm.css">
29
+ <link rel="stylesheet" crossorigin href="/assets/main-Zi2pcuPE.css">
30
30
  </head>
31
31
  <body>
32
32
  <div id="app"></div>
@@ -1243,7 +1243,7 @@ router.post('/git-pull', async (req, res) => {
1243
1243
  return;
1244
1244
  }
1245
1245
  try {
1246
- let cmd = 'git pull';
1246
+ let cmd = 'git pull --no-rebase';
1247
1247
  if (remote)
1248
1248
  cmd += ` "${remote}"`;
1249
1249
  if (branch)
@@ -1252,15 +1252,31 @@ router.post('/git-pull', async (req, res) => {
1252
1252
  res.json({ success: true, output: output.trim() });
1253
1253
  }
1254
1254
  catch (err) {
1255
- const stderr = err.stderr?.toString() || err.message || '';
1256
- if (stderr.includes('CONFLICT')) {
1257
- res.status(409).json({ success: false, error: 'Merge conflict detected during pull. Resolve conflicts manually.' });
1255
+ const stderr = err.stderr?.toString() || '';
1256
+ const stdout = err.stdout?.toString() || '';
1257
+ const combined = stdout + '\n' + stderr;
1258
+ if (combined.includes('CONFLICT') || combined.includes('Automatic merge failed')) {
1259
+ // Parse conflict file paths from output (same as merge endpoint)
1260
+ const conflicts = [];
1261
+ const conflictRegex = /CONFLICT \([^)]+\): Merge conflict in (.+)/g;
1262
+ let match;
1263
+ while ((match = conflictRegex.exec(combined)) !== null) {
1264
+ conflicts.push(path.join(gitRoot, match[1].trim()));
1265
+ }
1266
+ const bothRegex = /CONFLICT \([^)]+\):.+?(?:both modified|both added):\s*(.+)/g;
1267
+ while ((match = bothRegex.exec(combined)) !== null) {
1268
+ const conflictPath = path.join(gitRoot, match[1].trim());
1269
+ if (!conflicts.includes(conflictPath)) {
1270
+ conflicts.push(conflictPath);
1271
+ }
1272
+ }
1273
+ res.json({ success: false, output: combined.trim(), conflicts });
1258
1274
  }
1259
- else if (stderr.includes('ETIMEDOUT') || stderr.includes('Could not resolve')) {
1275
+ else if (combined.includes('ETIMEDOUT') || combined.includes('Could not resolve')) {
1260
1276
  res.status(504).json({ success: false, error: 'Network error. Check your connection.' });
1261
1277
  }
1262
1278
  else {
1263
- res.status(500).json({ success: false, error: stderr.trim() || err.message });
1279
+ res.status(500).json({ success: false, error: (stderr || stdout).trim() || err.message });
1264
1280
  }
1265
1281
  }
1266
1282
  }
@@ -79,24 +79,6 @@ function emit(event, ...args) {
79
79
  listeners.forEach((listener) => listener(...args));
80
80
  }
81
81
  }
82
- function scheduleSilentContextRefresh(agentId, reason) {
83
- setTimeout(() => {
84
- if (reason === 'step_complete') {
85
- log.log(`[step_complete] Sending silent /context for agent ${agentId}`);
86
- }
87
- if (reason === 'handle_complete') {
88
- log.log(`[handleComplete] Triggering fallback /context refresh for agent ${agentId}`);
89
- }
90
- sendSilentCommand(agentId, '/context').catch((err) => {
91
- if (reason === 'step_complete') {
92
- log.log(`[step_complete] Silent /context failed for ${agentId}: ${err}`);
93
- }
94
- else {
95
- log.log(`[handleComplete] Fallback /context failed for ${agentId}: ${err}`);
96
- }
97
- });
98
- }, 300);
99
- }
100
82
  const commandExecution = createRuntimeCommandExecution({
101
83
  log,
102
84
  getRunner,
@@ -117,7 +99,6 @@ const runtimeEvents = createRuntimeEventHandlers({
117
99
  emitError: (agentId, error) => emit('error', agentId, error),
118
100
  parseUsageOutput: (raw) => parseUsageOutput(raw),
119
101
  executeCommand: (agentId, command, systemPrompt, forceNewSession) => commandExecution.executeCommand(agentId, command, systemPrompt, forceNewSession),
120
- scheduleSilentContextRefresh,
121
102
  });
122
103
  const statusSync = createRuntimeStatusSync({
123
104
  log,
@@ -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
+ }
@@ -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.2",
3
+ "version": "0.69.4",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",