tide-commander 0.67.3 โ†’ 0.69.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.
@@ -3,9 +3,12 @@
3
3
  * Handles spawn, kill, stop, remove, rename, and update operations for agents
4
4
  */
5
5
  import * as fs from 'fs';
6
+ import { spawn } from 'child_process';
6
7
  import { agentService, runtimeService, skillService, customClassService, bossService, permissionService } from '../../services/index.js';
7
8
  import { createLogger } from '../../utils/index.js';
9
+ import { ClaudeBackend, parseContextOutput } from '../../claude/backend.js';
8
10
  const log = createLogger('AgentHandler');
11
+ const claudeBackend = new ClaudeBackend();
9
12
  // Test change: Server restart validation - if you see this log, the server restarted successfully
10
13
  log.log('๐Ÿ”„ AgentHandler loaded - server restart test');
11
14
  /**
@@ -183,37 +186,298 @@ export async function handleCollapseContext(ctx, payload) {
183
186
  }
184
187
  }
185
188
  /**
186
- * Handle request_context_stats message - requests detailed context breakdown
189
+ * Parse the visual terminal format from /context command output.
190
+ * Example: "claude-opus-4-6 ยท 46k/200k tokens (23%)"
191
+ * "โ› System prompt: 6.7k tokens (3.4%)"
187
192
  */
188
- export async function handleRequestContextStats(ctx, payload) {
189
- const agent = agentService.getAgent(payload.agentId);
190
- if (agent?.provider === 'codex') {
191
- const contextUsed = Math.max(0, Math.round(agent.contextUsed || 0));
192
- const contextLimit = Math.max(1, Math.round(agent.contextLimit || 200000));
193
- const usedPercent = Math.min(100, Math.round((contextUsed / contextLimit) * 100));
194
- const freePercent = 100 - usedPercent;
195
- ctx.broadcast({
196
- type: 'output',
197
- payload: {
198
- agentId: payload.agentId,
199
- text: `Context (estimated from Codex turn usage): ${(contextUsed / 1000).toFixed(1)}k/${(contextLimit / 1000).toFixed(1)}k (${freePercent}% free)`,
200
- isStreaming: false,
201
- timestamp: Date.now(),
193
+ function parseVisualContextOutput(content) {
194
+ try {
195
+ // Match: model-name ยท 46k/200k tokens (23%)
196
+ const headerMatch = content.match(/([\w.-]+)\s*[ยทโ€ข]\s*([\d.]+k?)\s*\/\s*([\d.]+k?)\s*tokens\s*\((\d+)%\)/);
197
+ if (!headerMatch) {
198
+ return null;
199
+ }
200
+ const parseTokenVal = (str) => {
201
+ if (str.endsWith('k') || str.endsWith('K')) {
202
+ return parseFloat(str) * 1000;
203
+ }
204
+ return parseFloat(str);
205
+ };
206
+ const model = headerMatch[1];
207
+ const totalTokens = parseTokenVal(headerMatch[2]);
208
+ const contextWindow = parseTokenVal(headerMatch[3]);
209
+ const usedPercent = parseInt(headerMatch[4], 10);
210
+ // Parse categories from visual format: "โ› Category Name: 6.7k tokens (3.4%)" or "โ› Category Name: 479 tokens (0.2%)"
211
+ const parseVisualCategory = (name) => {
212
+ const regex = new RegExp(`${name}:\\s*([\\.\\d]+k?)\\s*(?:tokens)?\\s*\\(([\\.\\d]+)%\\)`, 'i');
213
+ const match = content.match(regex);
214
+ if (match) {
215
+ return { tokens: parseTokenVal(match[1]), percent: parseFloat(match[2]) };
216
+ }
217
+ return { tokens: 0, percent: 0 };
218
+ };
219
+ return {
220
+ model,
221
+ contextWindow,
222
+ totalTokens,
223
+ usedPercent,
224
+ categories: {
225
+ systemPrompt: parseVisualCategory('System prompt'),
226
+ systemTools: parseVisualCategory('System tools'),
227
+ messages: parseVisualCategory('Messages'),
228
+ freeSpace: parseVisualCategory('Free space'),
229
+ autocompactBuffer: parseVisualCategory('Autocompact buffer'),
202
230
  },
231
+ lastUpdated: Date.now(),
232
+ };
233
+ }
234
+ catch (err) {
235
+ log.error('parseVisualContextOutput error:', err);
236
+ return null;
237
+ }
238
+ }
239
+ /**
240
+ * Spawn a short-lived Claude CLI process to fetch real context stats for a session.
241
+ *
242
+ * Strategy: Two attempts.
243
+ * 1. stream-json mode (--print --input/output-format stream-json)
244
+ * The /context slash command IS recognised (0 tokens, no API call) but
245
+ * its output may come as a `user` event with <local-command-stdout> tags
246
+ * or may be completely absent from the JSON stream.
247
+ * 2. Plain pipe mode (no --print, no format flags)
248
+ * Pipe `/context\n` as plain text. The CLI should run the local command
249
+ * and write the visual bar-chart output to stdout/stderr.
250
+ */
251
+ function fetchContextFromCLI(sessionId, cwd) {
252
+ return new Promise((resolve) => {
253
+ // Attempt 1: stream-json mode (fast, preferred if output is available)
254
+ tryStreamJson(sessionId, cwd).then((stats) => {
255
+ if (stats) {
256
+ resolve(stats);
257
+ return;
258
+ }
259
+ // Attempt 2: plain pipe mode (interactive-like, captures visual output)
260
+ tryPlainPipe(sessionId, cwd).then((stats2) => {
261
+ resolve(stats2);
262
+ });
263
+ });
264
+ });
265
+ }
266
+ function tryStreamJson(sessionId, cwd) {
267
+ return new Promise((resolve) => {
268
+ const executable = claudeBackend.getExecutablePath();
269
+ const args = [
270
+ '--resume', sessionId,
271
+ '--print',
272
+ '--output-format', 'stream-json',
273
+ '--input-format', 'stream-json',
274
+ ];
275
+ log.log(`[fetchContext:stream-json] Spawning: ${executable} ${args.join(' ')}`);
276
+ const child = spawn(executable, args, {
277
+ cwd,
278
+ stdio: ['pipe', 'pipe', 'pipe'],
279
+ });
280
+ let stdout = '';
281
+ let stderr = '';
282
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
283
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
284
+ const input = JSON.stringify({
285
+ type: 'user',
286
+ message: { role: 'user', content: '/context' },
287
+ });
288
+ child.stdin.write(input + '\n');
289
+ child.stdin.end();
290
+ const timer = setTimeout(() => {
291
+ log.warn('[fetchContext:stream-json] Timed out');
292
+ child.kill();
293
+ resolve(null);
294
+ }, 10000);
295
+ child.on('close', () => {
296
+ clearTimeout(timer);
297
+ log.log(`[fetchContext:stream-json] exited, stdout=${stdout.length}, stderr=${stderr.length}`);
298
+ const combined = stdout + '\n' + stderr;
299
+ // Look for <local-command-stdout> in raw text or inside JSON user events
300
+ const localCmdMatch = combined.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
301
+ if (localCmdMatch) {
302
+ const stats = parseContextOutput(localCmdMatch[1]);
303
+ if (stats) {
304
+ log.log(`[fetchContext:stream-json] Parsed from tags: ${stats.totalTokens}/${stats.contextWindow}`);
305
+ resolve(stats);
306
+ return;
307
+ }
308
+ }
309
+ for (const line of combined.split('\n')) {
310
+ if (!line.trim())
311
+ continue;
312
+ try {
313
+ const event = JSON.parse(line);
314
+ if (event.type === 'user' && typeof event.message?.content === 'string') {
315
+ const tagMatch = event.message.content.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
316
+ if (tagMatch) {
317
+ const stats = parseContextOutput(tagMatch[1]);
318
+ if (stats) {
319
+ log.log(`[fetchContext:stream-json] Parsed from user event: ${stats.totalTokens}/${stats.contextWindow}`);
320
+ resolve(stats);
321
+ return;
322
+ }
323
+ }
324
+ }
325
+ }
326
+ catch { /* not JSON */ }
327
+ }
328
+ log.log('[fetchContext:stream-json] No context data found, will try plain pipe');
329
+ resolve(null);
330
+ });
331
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
332
+ });
333
+ }
334
+ function tryPlainPipe(sessionId, cwd) {
335
+ return new Promise((resolve) => {
336
+ const executable = claudeBackend.getExecutablePath();
337
+ // No --print, no format flags. Pipe /context as plain text.
338
+ // The CLI should recognise it as a slash command in interactive-like mode.
339
+ const args = ['--resume', sessionId];
340
+ log.log(`[fetchContext:plain] Spawning: ${executable} ${args.join(' ')}`);
341
+ const child = spawn(executable, args, {
342
+ cwd,
343
+ stdio: ['pipe', 'pipe', 'pipe'],
344
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' }, // suppress ANSI codes
203
345
  });
346
+ let stdout = '';
347
+ let stderr = '';
348
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
349
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
350
+ // Send the slash command and close stdin so CLI exits
351
+ child.stdin.write('/context\n');
352
+ child.stdin.end();
353
+ const timer = setTimeout(() => {
354
+ log.warn('[fetchContext:plain] Timed out');
355
+ child.kill('SIGTERM');
356
+ // Even on timeout, try to parse what we have
357
+ const stats = parseAllFormats(stdout + '\n' + stderr);
358
+ resolve(stats);
359
+ }, 10000);
360
+ child.on('close', () => {
361
+ clearTimeout(timer);
362
+ log.log(`[fetchContext:plain] exited, stdout=${stdout.length}, stderr=${stderr.length}`);
363
+ const stats = parseAllFormats(stdout + '\n' + stderr);
364
+ if (stats) {
365
+ log.log(`[fetchContext:plain] Parsed: ${stats.totalTokens}/${stats.contextWindow} (${stats.model})`);
366
+ }
367
+ else {
368
+ log.warn('[fetchContext:plain] Could not parse context');
369
+ if (stdout.length < 2000)
370
+ log.log(`[fetchContext:plain] stdout: ${stdout}`);
371
+ if (stderr.length < 2000)
372
+ log.log(`[fetchContext:plain] stderr: ${stderr}`);
373
+ }
374
+ resolve(stats);
375
+ });
376
+ child.on('error', (err) => {
377
+ clearTimeout(timer);
378
+ log.error(`[fetchContext:plain] error: ${err}`);
379
+ resolve(null);
380
+ });
381
+ });
382
+ }
383
+ /** Try every known format parser on the combined output. */
384
+ function parseAllFormats(raw) {
385
+ // Strip ANSI escape codes
386
+ const stripped = raw.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
387
+ // 1. Markdown table format (from <local-command-stdout> or raw)
388
+ const localCmd = stripped.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
389
+ if (localCmd) {
390
+ const stats = parseContextOutput(localCmd[1]);
391
+ if (stats)
392
+ return stats;
393
+ }
394
+ const mdStats = parseContextOutput(stripped);
395
+ if (mdStats)
396
+ return mdStats;
397
+ // 2. Visual terminal format (โ› bar chart)
398
+ const vizStats = parseVisualContextOutput(stripped);
399
+ if (vizStats)
400
+ return vizStats;
401
+ return null;
402
+ }
403
+ /**
404
+ * Build context stats from tracked agent data (fallback when CLI fetch isn't possible).
405
+ */
406
+ function buildStatsFromTrackedData(agent) {
407
+ const contextUsed = Math.max(0, Math.round(agent.contextUsed || 0));
408
+ const contextLimit = Math.max(1, Math.round(agent.contextLimit || 200000));
409
+ const usedPercent = Math.min(100, Math.round((contextUsed / contextLimit) * 100));
410
+ const freeTokens = Math.max(0, contextLimit - contextUsed);
411
+ const model = agent.model || agent.codexModel || 'unknown';
412
+ return {
413
+ model,
414
+ contextWindow: contextLimit,
415
+ totalTokens: contextUsed,
416
+ usedPercent,
417
+ categories: {
418
+ systemPrompt: { tokens: 0, percent: 0 },
419
+ systemTools: { tokens: 0, percent: 0 },
420
+ messages: { tokens: contextUsed, percent: Number(((contextUsed / contextLimit) * 100).toFixed(1)) },
421
+ freeSpace: { tokens: freeTokens, percent: Number(((freeTokens / contextLimit) * 100).toFixed(1)) },
422
+ autocompactBuffer: { tokens: 0, percent: 0 },
423
+ },
424
+ lastUpdated: Date.now(),
425
+ };
426
+ }
427
+ /**
428
+ * Broadcast context stats to UI (both the modal and the context bar).
429
+ */
430
+ function broadcastContextStats(ctx, agentId, stats, label) {
431
+ const freePercent = stats.categories?.freeSpace?.percent ?? (100 - stats.usedPercent);
432
+ agentService.updateAgent(agentId, {
433
+ contextStats: stats,
434
+ contextUsed: stats.totalTokens,
435
+ contextLimit: stats.contextWindow,
436
+ }, false);
437
+ ctx.broadcast({ type: 'context_stats', payload: { agentId, stats } });
438
+ ctx.broadcast({ type: 'context_update', payload: { agentId, contextUsed: stats.totalTokens, contextLimit: stats.contextWindow } });
439
+ ctx.broadcast({
440
+ type: 'output',
441
+ payload: {
442
+ agentId,
443
+ text: `Context (${label}): ${(stats.totalTokens / 1000).toFixed(1)}k/${(stats.contextWindow / 1000).toFixed(1)}k (${freePercent}% free)`,
444
+ isStreaming: false,
445
+ timestamp: Date.now(),
446
+ },
447
+ });
448
+ }
449
+ /**
450
+ * Handle request_context_stats message.
451
+ * For Claude agents with a session, fetches REAL context stats from the CLI.
452
+ * Falls back to tracked data if CLI fetch fails or agent has no session.
453
+ */
454
+ export async function handleRequestContextStats(ctx, payload) {
455
+ const agent = agentService.getAgent(payload.agentId);
456
+ if (!agent) {
457
+ log.error(` Agent not found for context stats: ${payload.agentId}`);
204
458
  return;
205
459
  }
206
- if (agent && agent.status === 'idle') {
460
+ const isClaudeProvider = (agent.provider ?? 'claude') === 'claude';
461
+ // For Claude agents with an active session, fetch real context stats from the CLI
462
+ if (isClaudeProvider && agent.sessionId) {
463
+ log.log(`[contextStats] Fetching real context from CLI for ${agent.name} (session=${agent.sessionId})`);
207
464
  try {
208
- await runtimeService.sendCommand(payload.agentId, '/context');
465
+ const stats = await fetchContextFromCLI(agent.sessionId, agent.cwd || process.cwd());
466
+ if (stats) {
467
+ log.log(`[contextStats] Got real stats: ${stats.totalTokens}/${stats.contextWindow} (${stats.model})`);
468
+ broadcastContextStats(ctx, payload.agentId, stats, 'from CLI');
469
+ return;
470
+ }
471
+ log.warn(`[contextStats] CLI fetch returned null, falling back to tracked data`);
209
472
  }
210
473
  catch (err) {
211
- log.error(` Failed to request context stats: ${err}`);
474
+ log.error(`[contextStats] CLI fetch failed: ${err}`);
212
475
  }
213
476
  }
214
- else {
215
- log.log(` Cannot request context stats while agent ${payload.agentId} is busy`);
216
- }
477
+ // Fallback: generate from tracked data
478
+ const stats = buildStatsFromTrackedData(agent);
479
+ const label = agent.provider === 'codex' ? 'estimated from turn usage' : 'tracked from token usage';
480
+ broadcastContextStats(ctx, payload.agentId, stats, label);
217
481
  }
218
482
  /**
219
483
  * Handle move_agent message
@@ -5,6 +5,7 @@
5
5
  import { agentService, runtimeService, skillService, customClassService } from '../../services/index.js';
6
6
  import { createLogger } from '../../utils/index.js';
7
7
  import { getAuthToken } from '../../auth/index.js';
8
+ import { handleRequestContextStats } from './agent-handler.js';
8
9
  const log = createLogger('CommandHandler');
9
10
  /**
10
11
  * Track last boss commands for delegation parsing
@@ -95,8 +96,17 @@ export async function handleSendCommand(ctx, payload, buildBossMessage) {
95
96
  log.error(` Agent not found: ${agentId}`);
96
97
  return;
97
98
  }
99
+ const trimmedCmd = command.trim();
100
+ // Intercept /context and /cost for ALL agents (boss or regular) BEFORE routing.
101
+ // The CLI /context slash command does NOT work via stdin in --print mode
102
+ // (it gets treated as a user message). We generate stats from tracked data instead.
103
+ if (trimmedCmd === '/context' || trimmedCmd === '/cost') {
104
+ log.log(`Agent ${agent.name}: Intercepting ${trimmedCmd} - generating stats from tracked data`);
105
+ await handleRequestContextStats(ctx, { agentId });
106
+ return;
107
+ }
98
108
  // Handle /clear command - clear session and start fresh
99
- if (command.trim() === '/clear') {
109
+ if (trimmedCmd === '/clear') {
100
110
  log.log(`Agent ${agent.name}: /clear command - clearing session`);
101
111
  await runtimeService.stopAgent(agentId);
102
112
  agentService.updateAgent(agentId, {
@@ -153,23 +163,8 @@ async function handleBossCommand(ctx, agentId, command, agentName, buildBossMess
153
163
  * Regular agents get custom class instructions and skills combined into a prompt
154
164
  */
155
165
  async function handleRegularAgentCommand(ctx, agentId, command, agent) {
156
- const trimmedCommand = command.trim();
157
- if (agent.provider === 'codex' && (trimmedCommand === '/context' || trimmedCommand === '/cost' || trimmedCommand === '/compact')) {
158
- const contextUsed = Math.max(0, Math.round(agent.contextUsed || 0));
159
- const contextLimit = Math.max(1, Math.round(agent.contextLimit || 200000));
160
- const usedPercent = Math.min(100, Math.round((contextUsed / contextLimit) * 100));
161
- const freePercent = 100 - usedPercent;
162
- ctx.broadcast({
163
- type: 'output',
164
- payload: {
165
- agentId,
166
- text: `Context (estimated from Codex turn usage): ${(contextUsed / 1000).toFixed(1)}k/${(contextLimit / 1000).toFixed(1)}k (${freePercent}% free)`,
167
- isStreaming: false,
168
- timestamp: Date.now(),
169
- },
170
- });
171
- return;
172
- }
166
+ // Note: /context, /cost, /compact are intercepted at the handleSendCommand level
167
+ // so they never reach here. This function only handles actual commands to send to the agent.
173
168
  const customAgentConfig = buildCustomAgentConfig(agentId, agent.class);
174
169
  if (customAgentConfig) {
175
170
  log.log(` Agent ${agent.name} customAgentConfig: name=${customAgentConfig.name}, promptLen=${customAgentConfig.definition.prompt.length}`);
@@ -58,15 +58,14 @@ export function setupRuntimeListeners(ctx) {
58
58
  cleanPreview = parsed
59
59
  .filter((b) => b.type === 'text' && b.text)
60
60
  .map((b) => b.text)
61
- .join(' ')
62
- .slice(0, 200);
61
+ .join(' ');
63
62
  }
64
63
  else {
65
- cleanPreview = event.toolOutput.slice(0, 200);
64
+ cleanPreview = event.toolOutput;
66
65
  }
67
66
  }
68
67
  catch {
69
- cleanPreview = event.toolOutput.slice(0, 200);
68
+ cleanPreview = event.toolOutput;
70
69
  }
71
70
  }
72
71
  ctx.broadcast({
@@ -77,11 +76,29 @@ export function setupRuntimeListeners(ctx) {
77
76
  success: true,
78
77
  resultPreview: cleanPreview,
79
78
  subagentName: event.subagentName,
79
+ // Completion stats from Task tool metadata
80
+ durationMs: event.subagentStats?.durationMs,
81
+ tokensUsed: event.subagentStats?.tokensUsed,
82
+ toolUseCount: event.subagentStats?.toolUseCount,
80
83
  },
81
84
  });
82
- log.log(`[Subagent] Broadcast subagent_completed for toolUseId=${event.toolUseId}, name=${event.subagentName || 'unknown'}`);
85
+ 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'}`);
83
86
  }
84
- else if (event.type === 'error') {
87
+ // Forward subagent internal tool activity to client (events with parentToolUseId)
88
+ if (event.parentToolUseId && event.type === 'tool_start' && event.toolName !== 'Task') {
89
+ const toolDesc = formatToolActivity(event.toolName, event.toolInput);
90
+ ctx.broadcast({
91
+ type: 'subagent_output',
92
+ payload: {
93
+ subagentId: event.parentToolUseId,
94
+ parentAgentId: agentId,
95
+ text: toolDesc,
96
+ isStreaming: false,
97
+ timestamp: Date.now(),
98
+ },
99
+ });
100
+ }
101
+ if (event.type === 'error') {
85
102
  ctx.sendActivity(agentId, `Error: ${event.errorMessage}`);
86
103
  }
87
104
  else if (event.type === 'tool_result' && event.toolName === 'Bash') {
@@ -125,6 +142,20 @@ export function setupRuntimeListeners(ctx) {
125
142
  parseBossSpawn(agentId, agent.name, event.resultText, ctx.broadcast, ctx.sendActivity);
126
143
  }
127
144
  }
145
+ // Real-time context tracking: broadcast lightweight context_update on usage_snapshot and step_complete
146
+ if ((event.type === 'usage_snapshot' && event.tokens) || event.type === 'step_complete') {
147
+ const agent = agentService.getAgent(agentId);
148
+ if (agent) {
149
+ ctx.broadcast({
150
+ type: 'context_update',
151
+ payload: {
152
+ agentId,
153
+ contextUsed: agent.contextUsed,
154
+ contextLimit: agent.contextLimit,
155
+ },
156
+ });
157
+ }
158
+ }
128
159
  if (event.type === 'context_stats' && event.contextStatsRaw) {
129
160
  log.log(`[context_stats] Received for agent ${agentId}, raw length: ${event.contextStatsRaw.length}`);
130
161
  const stats = parseContextOutput(event.contextStatsRaw);
@@ -139,6 +170,15 @@ export function setupRuntimeListeners(ctx) {
139
170
  type: 'context_stats',
140
171
  payload: { agentId, stats },
141
172
  });
173
+ // Also send lightweight context_update so the context bar updates immediately
174
+ ctx.broadcast({
175
+ type: 'context_update',
176
+ payload: {
177
+ agentId,
178
+ contextUsed: stats.totalTokens,
179
+ contextLimit: stats.contextWindow,
180
+ },
181
+ });
142
182
  }
143
183
  else {
144
184
  log.log(`[context_stats] Failed to parse context output for agent ${agentId}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "0.67.3",
3
+ "version": "0.69.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,8 @@
32
32
  "test:ci": "vitest run --exclude src/packages/client/hooks/__tests__/useSnapshots.test.ts --exclude src/packages/client/hooks/__tests__/useKeyboardShortcuts.test.ts",
33
33
  "test:watch": "vitest",
34
34
  "test:coverage": "vitest run --coverage",
35
- "prepack": "npm run build"
35
+ "prepack": "npm run build",
36
+ "tc": "tsx tools/tc.ts"
36
37
  },
37
38
  "dependencies": {
38
39
  "@anthropic-ai/sdk": "^0.71.2",