diff --git a/.claude-ejecutor/plugins/known_marketplaces.json b/.claude-ejecutor/plugins/known_marketplaces.json index 6f04738..743c6b9 100644 --- a/.claude-ejecutor/plugins/known_marketplaces.json +++ b/.claude-ejecutor/plugins/known_marketplaces.json @@ -5,6 +5,6 @@ "repo": "anthropics/claude-plugins-official" }, "installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official", - "lastUpdated": "2026-02-15T19:50:55.853Z" + "lastUpdated": "2026-02-16T06:32:07.237Z" } } \ No newline at end of file diff --git a/.claude-ejecutor/settings.json b/.claude-ejecutor/settings.json index d7bfbbc..cb7be00 100644 --- a/.claude-ejecutor/settings.json +++ b/.claude-ejecutor/settings.json @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -50,7 +50,18 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "timeout": 5000 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -62,7 +73,19 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "timeout": 5000 + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -73,8 +96,8 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", - "timeout": 5000 + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "timeout": 10000 } ] } diff --git a/.claude/ui.json b/.claude/ui.json index dab23bc..6749019 100644 --- a/.claude/ui.json +++ b/.claude/ui.json @@ -5,5 +5,5 @@ "gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)", "terminalBg": "#0f0a1a", "terminalBorder": "#6366f1", - "enabled": false + "enabled": true } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8bd9732..3138fd2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,6 +8,7 @@ import FloatingResponse from './components/FloatingResponse.vue' import FloatingVoice from './components/FloatingVoice.vue' import AgentBar from './components/AgentBar.vue' import HookNotifications from './components/HookNotifications.vue' +import NotificationLog from './components/NotificationLog.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue' import { initWebMCP, getWebMCP } from './services/webmcp' import { initTorch, destroyTorch } from './services/torch' @@ -67,6 +68,7 @@ function clearDebugLogs() { } const terminalRef = ref | null>(null) const responseRef = ref | null>(null) +const notifLogRef = ref | null>(null) const voiceRef = ref | null>(null) const canvasStore = useCanvasStore() const projectCanvasStore = useProjectCanvasStore() @@ -330,6 +332,8 @@ onMounted(async () => { // Setup response controls for MCP tools setResponseControls({ addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => { + // Also log to notification log + notifLogRef.value?.addResponseEntry(message, type || 'info') if (responseRef.value) { return responseRef.value.addMessage(message, type) } @@ -544,6 +548,9 @@ watch(() => route.name, (newPage) => { + + + diff --git a/frontend/src/components/AgentBar.vue b/frontend/src/components/AgentBar.vue index 41de78a..d5bc4cd 100644 --- a/frontend/src/components/AgentBar.vue +++ b/frontend/src/components/AgentBar.vue @@ -345,6 +345,7 @@ onBeforeUnmount(() => { +import { ref, watch, onMounted } from 'vue' +import { useClaudeHooksStore } from '../stores/claude-hooks' +import { useCanvasStore } from '../stores/canvas' + +interface LogEntry { + id: string + source: 'hook' | 'canvas' | 'response' + type: 'info' | 'success' | 'warning' | 'error' + title: string + detail: string + timestamp: number +} + +const STORAGE_KEY = 'notification-log' +const MAX_ENTRIES = 500 + +const hooksStore = useClaudeHooksStore() +const canvasStore = useCanvasStore() + +const isOpen = ref(false) +const entries = ref(loadFromStorage()) +const filter = ref<'all' | 'hook' | 'canvas' | 'response'>('all') + +// Track what we've already logged to avoid duplicates +const seenHookIds = new Set() +const seenCanvasIds = new Set() + +// Seed seen IDs from existing entries on load +entries.value.forEach(e => { + if (e.source === 'hook') seenHookIds.add(e.id) +}) + +function loadFromStorage(): LogEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } +} + +function saveToStorage() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.value.slice(-MAX_ENTRIES))) + } catch { /* quota exceeded — silently ignore */ } +} + +function addEntry(entry: LogEntry) { + entries.value.push(entry) + if (entries.value.length > MAX_ENTRIES) { + entries.value = entries.value.slice(-MAX_ENTRIES) + } + saveToStorage() +} + +function clearAll() { + entries.value = [] + seenHookIds.clear() + seenCanvasIds.clear() + localStorage.removeItem(STORAGE_KEY) +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +function formatDate(ts: number): string { + return new Date(ts).toLocaleDateString('es', { day: '2-digit', month: 'short' }) +} + +// Group entries by date +function getFilteredEntries() { + if (filter.value === 'all') return entries.value + return entries.value.filter(e => e.source === filter.value) +} + +// Watch hook notifications +watch(() => hooksStore.notifications, (notifs) => { + for (const n of notifs) { + if (seenHookIds.has(n.id)) continue + seenHookIds.add(n.id) + addEntry({ + id: n.id, + source: 'hook', + type: n.type, + title: n.title, + detail: n.detail, + timestamp: n.timestamp + }) + } +}, { deep: true }) + +// Watch canvas notifications +watch(() => canvasStore.notifications, (notifs) => { + for (const n of notifs) { + if (seenCanvasIds.has(n.id)) continue + seenCanvasIds.add(n.id) + addEntry({ + id: `canvas_${n.id}`, + source: 'canvas', + type: n.type as LogEntry['type'], + title: 'App', + detail: n.message, + timestamp: Date.now() + }) + } +}, { deep: true }) + +// Expose addResponseEntry for external use (FloatingResponse) +function addResponseEntry(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') { + addEntry({ + id: `resp_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, + source: 'response', + type, + title: 'Agent Response', + detail: message.length > 300 ? message.slice(0, 300) + '...' : message, + timestamp: Date.now() + }) +} + +defineExpose({ addResponseEntry }) + +const count = ref(0) +watch(entries, (e) => { count.value = e.length }, { immediate: true }) + +const sourceLabels: Record = { + hook: 'Hook', + canvas: 'App', + response: 'Response' +} + +const typeColors: Record = { + info: '#6366f1', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444' +} + + + + + diff --git a/frontend/src/components/agent/AgentTerminal.vue b/frontend/src/components/agent/AgentTerminal.vue new file mode 100644 index 0000000..6a1364e --- /dev/null +++ b/frontend/src/components/agent/AgentTerminal.vue @@ -0,0 +1,436 @@ + + + + + diff --git a/frontend/src/components/agent/ChatInput.vue b/frontend/src/components/agent/ChatInput.vue index 4731288..dd959e2 100644 --- a/frontend/src/components/agent/ChatInput.vue +++ b/frontend/src/components/agent/ChatInput.vue @@ -6,7 +6,10 @@ const props = defineProps<{ recording?: boolean historyActive?: boolean settingsActive?: boolean + terminalActive?: boolean + disabled?: boolean autofocus?: boolean + statusDot?: 'green' | 'yellow' | 'red' | 'gray' | '' }>() const emit = defineEmits<{ @@ -14,6 +17,7 @@ const emit = defineEmits<{ mic: [] 'toggle-history': [] 'toggle-settings': [] + 'toggle-terminal': [] }>() const inputText = ref('') @@ -42,12 +46,14 @@ defineExpose({ focus }) @@ -162,11 +179,39 @@ defineExpose({ focus }) } .ci-history.active, -.ci-settings.active { +.ci-settings.active, +.ci-terminal.active { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.7); } +.ci-terminal:hover { + background: rgba(16, 185, 129, 0.15); + border-color: rgba(16, 185, 129, 0.25); + color: rgba(16, 185, 129, 0.9); +} + +.ci-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.ci-status-dot.green { background: #22c55e; box-shadow: 0 0 4px #22c55e; } +.ci-status-dot.yellow { background: #eab308; box-shadow: 0 0 4px #eab308; animation: ci-pulse 1s infinite; } +.ci-status-dot.red { background: #ef4444; box-shadow: 0 0 4px #ef4444; } +.ci-status-dot.gray { background: #6b7280; } + +.chat-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@keyframes ci-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + @keyframes mic-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); } 50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } diff --git a/frontend/src/components/agent/PromptBar.vue b/frontend/src/components/agent/PromptBar.vue index b0a529e..d568753 100644 --- a/frontend/src/components/agent/PromptBar.vue +++ b/frontend/src/components/agent/PromptBar.vue @@ -3,11 +3,13 @@ import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } import type { Agent } from '../../types/agent' import { endpoints } from '../../config/endpoints' import { useVoiceCapture } from '../../composables/useVoiceCapture' +import { useAgentTerminal } from '../../composables/useAgentTerminal' import { useCanvasStore } from '../../stores/canvas' import ChatInput from './ChatInput.vue' import TranscriptCard from './TranscriptCard.vue' import InputSettings from './InputSettings.vue' import ConversationHistory from './ConversationHistory.vue' +import AgentTerminal from './AgentTerminal.vue' interface ClaudeUsage { subscription: { type: string; tier: string; label: string; multiplier: number } @@ -23,6 +25,7 @@ interface ChatMessage { content: string status: 'sent' | 'thinking' | 'done' uuid?: string + toolCalls?: string[] intervention?: { type: 'permission' | 'question' | 'plan' requestId?: string @@ -60,6 +63,32 @@ const voice = useVoiceCapture({ onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration) }) +// Agent terminal composable +const agentTerminal = useAgentTerminal(props.agent.id) +const showTerminal = ref(false) + +const statusDot = computed<'green' | 'yellow' | 'red' | 'gray' | ''>(() => { + switch (agentTerminal.terminalState.value) { + case 'ready': return 'green' + case 'connecting': + case 'agent-starting': return 'yellow' + case 'crashed': return 'red' + case 'off': return 'gray' + default: return '' + } +}) + +const inputPlaceholder = computed(() => { + const label = props.agent.uiConfig?.label || props.agent.name + switch (agentTerminal.terminalState.value) { + case 'off': return `Agent offline - type to start ${label}` + case 'crashed': return `Agent crashed - type to restart ${label}` + case 'connecting': + case 'agent-starting': return `Starting ${label}...` + default: return `Mensaje a ${label}...` + } +}) + const contentEl = ref(null) const chatInputEl = ref | null>(null) const isRecording = ref(false) @@ -96,7 +125,7 @@ const panelStyle = computed(() => { const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2 const bottomOffset = window.innerHeight - props.anchorRect.top + 12 - const panelWidth = 360 + const panelWidth = 420 let left = bubbleCenterX - panelWidth / 2 left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12)) @@ -112,6 +141,95 @@ const hasContent = computed(() => messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value ) +// ── Tool chip registry ── + +interface ToolMeta { icon: string; color: string; label?: string } + +const TOOL_CATEGORIES: Record = { + // File ops — cyan + Read: { icon: '◉', color: '#22d3ee' }, + Edit: { icon: '✎', color: '#22d3ee' }, + Write: { icon: '✦', color: '#22d3ee' }, + Glob: { icon: '⊞', color: '#22d3ee' }, + Grep: { icon: '⊘', color: '#22d3ee' }, + // Terminal — green + Bash: { icon: '▸', color: '#34d399' }, + KillShell: { icon: '✕', color: '#34d399', label: 'Kill' }, + // Tasks — amber + Task: { icon: '◈', color: '#fbbf24' }, + TaskCreate: { icon: '+', color: '#fbbf24', label: 'Task+' }, + TaskUpdate: { icon: '↻', color: '#fbbf24', label: 'Task↻' }, + TaskList: { icon: '≡', color: '#fbbf24', label: 'Tasks' }, + TaskOutput: { icon: '◎', color: '#fbbf24', label: 'TaskOut' }, + TodoWrite: { icon: '☑', color: '#fbbf24', label: 'Todo' }, + // Web — purple + WebSearch: { icon: '⌕', color: '#a78bfa' }, + WebFetch: { icon: '↓', color: '#a78bfa' }, + // Interaction — rose + AskUserQuestion: { icon: '?', color: '#fb7185', label: 'Ask' }, + EnterPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan↵' }, + ExitPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan✓' }, + Skill: { icon: '⚡', color: '#fb7185' }, + // Resources — slate + ListMcpResourcesTool: { icon: '☰', color: '#94a3b8', label: 'Resources' }, +} + +function getToolMeta(name: string): ToolMeta { + if (TOOL_CATEGORIES[name]) return TOOL_CATEGORIES[name] + if (name.startsWith('mcp__')) { + const short = name.split('-').pop() || name + return { icon: '◆', color: '#818cf8', label: short } + } + return { icon: '•', color: '#94a3b8', label: name } +} + +// ── Display items with tool grouping ── + +interface DisplayItem { + type: 'message' | 'tool-group' + msg?: ChatMessage + tools?: { name: string; count: number; meta: ToolMeta }[] + ids?: number[] +} + +function buildToolGroup(tools: string[], ids: number[]): DisplayItem { + const counts = new Map() + for (const t of tools) counts.set(t, (counts.get(t) || 0) + 1) + return { + type: 'tool-group', + tools: [...counts.entries()].map(([name, count]) => ({ + name, count, meta: getToolMeta(name) + })), + ids + } +} + +const displayMessages = computed(() => { + const items: DisplayItem[] = [] + let pendingTools: string[] = [] + let pendingIds: number[] = [] + + for (const msg of messages) { + const isToolOnly = msg.toolCalls?.length && msg.content.startsWith('[Tool calls:') + + if (isToolOnly) { + pendingTools.push(...msg.toolCalls!) + pendingIds.push(msg.id) + } else { + if (pendingTools.length) { + items.push(buildToolGroup(pendingTools, pendingIds)) + pendingTools = [] + pendingIds = [] + } + items.push({ type: 'message', msg }) + } + } + if (pendingTools.length) { + items.push(buildToolGroup(pendingTools, pendingIds)) + } + return items +}) + // ── Formatting helpers ── function shortModel(model: string): string { @@ -241,7 +359,8 @@ function appendTranscriptMessages(newMsgs: any[]) { role: msg.role === 'user' ? 'user' : 'agent', content: msg.content || '', status: 'done', - uuid: msg.uuid + uuid: msg.uuid, + toolCalls: msg.toolCalls || undefined }) } @@ -383,7 +502,8 @@ async function loadHistory(sessionId?: string) { role: msg.role === 'user' ? 'user' : 'agent', content: msg.content || '', status: 'done', - uuid: msg.uuid + uuid: msg.uuid, + toolCalls: msg.toolCalls || undefined }) } @@ -437,6 +557,8 @@ function handleSessionChange(sessionId: string) { function handleSubmit(text: string) { messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' }) emit('submit', text) + // Send text as input to the agent's terminal PTY + agentTerminal.sendPrompt(text) // Real response will arrive via transcript-update WebSocket } @@ -455,6 +577,7 @@ function handleTranscriptDone(text: string) { messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' }) emit('submit', text) + agentTerminal.sendPrompt(text) // Real response will arrive via transcript-update WebSocket } @@ -467,6 +590,10 @@ function toggleHistory() { if (showHistory.value) scrollToBottom() } +function toggleTerminal() { + showTerminal.value = !showTerminal.value +} + function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') emit('close') } @@ -477,6 +604,7 @@ watch(() => props.visible, async (v) => { showTranscript.value = false showHistory.value = false showSettings.value = false + showTerminal.value = false messages.length = 0 idCounter = 0 sessionStats.value = null @@ -493,6 +621,10 @@ watch(() => props.visible, async (v) => { // Connect WebSocket for real-time updates connectWs() + // Connect agent terminal (lazy) + agentTerminal.connect() + agentTerminal.checkStatus() + await voice.init() await nextTick() if (props.startRecording) { @@ -502,6 +634,8 @@ watch(() => props.visible, async (v) => { } } else { disconnectWs() + agentTerminal.disconnect() + showTerminal.value = false if (voice.isRecording.value) { voice.stopRecording() } @@ -518,6 +652,7 @@ onMounted(() => { onBeforeUnmount(() => { document.removeEventListener('keydown', handleKeydown) disconnectWs() + agentTerminal.dispose() voice.cleanup() }) @@ -592,86 +727,109 @@ onBeforeUnmount(() => {
-
- + {
+ + +
@@ -1137,6 +1305,74 @@ onBeforeUnmount(() => { 50% { border-color: rgba(239, 68, 68, 0.4); } } +/* Tool chips row — consecutive tool calls share horizontal space */ +.tool-chips-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + animation: msg-in 0.2s ease-out; + max-width: 95%; +} + +.tool-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border-radius: 6px; + font-size: 10px; + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + background: color-mix(in srgb, var(--chip-color) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent); + color: var(--chip-color); + white-space: nowrap; + transition: background 0.15s; +} + +.tool-chip:hover { + background: color-mix(in srgb, var(--chip-color) 22%, transparent); +} + +.chip-icon { + font-size: 10px; + opacity: 0.8; +} + +.chip-label { + font-weight: 500; +} + +.chip-count { + font-size: 9px; + font-weight: 700; + background: color-mix(in srgb, var(--chip-color) 25%, transparent); + padding: 0 4px; + border-radius: 4px; + min-width: 14px; + text-align: center; +} + +/* Reduce gap between consecutive tool rows */ +.tool-chips-row + .tool-chips-row { + margin-top: -2px; +} + +/* Inline tool chips for mixed messages (text + tool calls) */ +.inline-tools { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.tool-chip-mini { + font-size: 9px; + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + color: var(--chip-color); + opacity: 0.7; + white-space: nowrap; +} + /* Mobile */ @media (max-width: 768px) { .prompt-bar-panel { diff --git a/frontend/src/composables/useAgentTerminal.ts b/frontend/src/composables/useAgentTerminal.ts new file mode 100644 index 0000000..25dbeae --- /dev/null +++ b/frontend/src/composables/useAgentTerminal.ts @@ -0,0 +1,401 @@ +/** + * useAgentTerminal + * + * Composable for managing a per-agent terminal session. + * Wraps useTerminalRenderer with agent-specific WebSocket connection, + * agent lifecycle (start/stop), and prompt sending. + */ + +import { ref, computed, type Ref } from 'vue' +import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer' +import { agentTerminalUrl, terminalApiUrl } from '../config/endpoints' + +export type AgentTerminalState = 'off' | 'connecting' | 'ready' | 'crashed' | 'agent-starting' + +export interface AgentTerminal { + // State + terminalState: Ref + connected: Ref + agentRunning: Ref + sessionId: Ref + + // Container ref - bind this to the xterm DOM element in the component + containerRef: Ref + + // Terminal renderer (for mounting xterm) + renderer: TerminalRenderer + + // Connection + connect: () => void + disconnect: () => void + + // Agent lifecycle + startAgent: (force?: boolean) => Promise + stopAgent: () => Promise + checkStatus: () => Promise + + // Prompt + sendPrompt: (text: string) => void + + // Cleanup + dispose: () => void +} + +export function useAgentTerminal(agentId: string): AgentTerminal { + const containerRef = ref(null) + const connected = ref(false) + const connecting = ref(false) + const agentRunning = ref(false) + const agentStarting = ref(false) + const crashed = ref(false) + const sessionId = ref(null) + + let socket: WebSocket | null = null + let reconnectTimeout: number | null = null + let reconnectAttempts = 0 + let pendingPrompt: string | null = null + const MAX_RECONNECT = 10 + const RECONNECT_DELAY = 2000 + + const terminalState = computed(() => { + if (agentStarting.value) return 'agent-starting' + if (crashed.value) return 'crashed' + if (connected.value && agentRunning.value) return 'ready' + if (connecting.value) return 'connecting' + return 'off' + }) + + // Terminal renderer + const renderer = useTerminalRenderer({ + container: containerRef, + onData: (data) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data })) + } + }, + onResize: (cols, rows) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'resize', cols, rows })) + } + }, + onKeyEvent: (e) => { + // Ctrl+V: Paste + if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') { + e.preventDefault() + navigator.clipboard.readText().then((text) => { + if (text && socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data: text })) + } + }).catch(console.error) + return false + } + // Ctrl+C: Copy selection + if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') { + const selection = renderer.getSelection() + if (selection) { + navigator.clipboard.writeText(selection).catch(console.error) + return false + } + } + return true + } + }) + + // ── WebSocket connection ── + + function connect() { + if (connecting.value || connected.value) return + connecting.value = true + crashed.value = false + + const wsUrl = agentTerminalUrl(agentId) + console.log(`[AgentTerminal:${agentId}] Connecting to ${wsUrl}`) + + const timeout = window.setTimeout(() => { + if (connecting.value && !connected.value) { + connecting.value = false + socket?.close() + socket = null + scheduleReconnect() + } + }, 10000) + + try { + socket = new WebSocket(wsUrl) + socket.addEventListener('open', () => clearTimeout(timeout), { once: true }) + + socket.onopen = () => { + connected.value = true + connecting.value = false + reconnectAttempts = 0 + + // Send initial resize + const term = renderer.terminal.value + if (term) { + socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + } + + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + handleMessage(msg) + } catch { /* ignore parse errors */ } + } + + socket.onclose = () => { + connected.value = false + connecting.value = false + socket = null + + if (reconnectAttempts < MAX_RECONNECT) { + scheduleReconnect() + } + } + + socket.onerror = () => { + connecting.value = false + } + } catch { + connecting.value = false + scheduleReconnect() + } + } + + function disconnect() { + cancelReconnect() + if (socket) { + socket.onclose = null + socket.close() + socket = null + } + connected.value = false + connecting.value = false + } + + function scheduleReconnect() { + if (reconnectTimeout) clearTimeout(reconnectTimeout) + reconnectAttempts++ + const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5) + reconnectTimeout = window.setTimeout(() => { + if (!connected.value && !connecting.value) { + connect() + } + }, delay) + } + + function cancelReconnect() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + reconnectTimeout = null + } + reconnectAttempts = 0 + } + + // ── Message handling ── + + function handleMessage(msg: any) { + switch (msg.type) { + case 'connected': + sessionId.value = msg.sessionId + if (msg.hasHistory) { + // Session has existing output, request replay + setTimeout(() => requestReplay(), 50) + // Check if agent is actually running + checkStatus() + } else if (msg.isNew) { + // Brand new session, auto-start agent + autoStartAgent() + } + break + + case 'replay': + renderer.handleReplay(msg.data || '') + break + + case 'output': + renderer.write(msg.data) + break + + case 'exit': + renderer.write(msg.data) + agentRunning.value = false + crashed.value = true + agentStarting.value = false + sessionId.value = null + break + + case 'error': + renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`) + break + + case 'session-restart': + renderer.reset() + renderer.writeln('\x1b[33m[Session restarting...]\x1b[0m') + break + + case 'claude-status': + if (matchesAgent(msg.agent)) { + if (msg.status === 'sessionStart') { + agentRunning.value = true + agentStarting.value = false + crashed.value = false + } + } + break + + case 'buffer-cleared': + break + } + } + + function matchesAgent(name: string): boolean { + if (!name) return agentId === 'main' + return name === agentId || name === 'main' && agentId === 'main' + } + + function requestReplay(tailOnly = true) { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'request-replay', tailOnly, chunks: 200 })) + } + } + + // ── Agent lifecycle ── + + async function autoStartAgent() { + agentStarting.value = true + try { + await startAgent() + } catch (e) { + console.error(`[AgentTerminal:${agentId}] Auto-start failed:`, e) + agentStarting.value = false + } + } + + async function startAgent(force = false) { + agentStarting.value = true + crashed.value = false + + try { + const res = await fetch(terminalApiUrl('/start-agent'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId, force }) + }) + if (!res.ok) { agentStarting.value = false; return } + const contentType = res.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { agentStarting.value = false; return } + const data = await res.json() + if (data.success) { + agentRunning.value = true + // agentStarting stays true until sessionStart is received + // Flush pending prompt + if (pendingPrompt) { + setTimeout(() => { + flushPendingPrompt() + }, 500) + } + } else { + agentStarting.value = false + } + } catch (e) { + console.error(`[AgentTerminal:${agentId}] Start failed:`, e) + agentStarting.value = false + } + } + + async function stopAgent() { + try { + const res = await fetch(terminalApiUrl('/stop-agent'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId }) + }) + if (res.ok) { + agentRunning.value = false + agentStarting.value = false + } + } catch (e) { + console.error(`[AgentTerminal:${agentId}] Stop failed:`, e) + } + } + + async function checkStatus() { + try { + const res = await fetch(terminalApiUrl('/agent-sessions')) + if (!res.ok) return + const contentType = res.headers.get('content-type') || '' + if (!contentType.includes('application/json')) return + const data = await res.json() + const state = data[agentId] + if (state) { + agentRunning.value = state.isAgentRunning + if (!state.isAgentRunning && state.sessionExists) { + // Session exists but agent exited + crashed.value = true + } + } + } catch (e) { + console.error(`[AgentTerminal:${agentId}] Status check failed:`, e) + } + } + + // ── Prompt sending ── + + function typeTextToSocket(text: string) { + const chars = (text + '\r').split('') + let i = 0 + const typeChar = () => { + if (i < chars.length && socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data: chars[i] })) + i++ + setTimeout(typeChar, 15) + } + } + typeChar() + } + + function sendPrompt(text: string) { + if (socket?.readyState === WebSocket.OPEN && (agentRunning.value || agentStarting.value)) { + typeTextToSocket(text) + } else if (!connected.value) { + // Queue prompt and auto-connect + start + pendingPrompt = text + connect() + } else if (connected.value && !agentRunning.value) { + // Connected but agent not running, start it and queue + pendingPrompt = text + startAgent() + } + } + + function flushPendingPrompt() { + if (pendingPrompt && socket?.readyState === WebSocket.OPEN) { + typeTextToSocket(pendingPrompt) + pendingPrompt = null + } + } + + // ── Cleanup ── + + function dispose() { + disconnect() + renderer.dispose() + } + + return { + terminalState, + connected, + agentRunning, + sessionId, + containerRef, + renderer, + connect, + disconnect, + startAgent, + stopAgent, + checkStatus, + sendPrompt, + dispose + } +} diff --git a/frontend/src/composables/useTerminalRenderer.ts b/frontend/src/composables/useTerminalRenderer.ts index 288acec..7b6e9bb 100644 --- a/frontend/src/composables/useTerminalRenderer.ts +++ b/frontend/src/composables/useTerminalRenderer.ts @@ -214,8 +214,10 @@ export function useTerminalRenderer(options: TerminalRendererOptions): TerminalR function dispose(): void { resizeObserver?.disconnect() resizeObserver = null - terminal.value?.dispose() - terminal.value = null + if (terminal.value) { + try { terminal.value.dispose() } catch { /* addon not loaded */ } + terminal.value = null + } fitAddon.value = null isReady.value = false } diff --git a/frontend/src/config/endpoints.ts b/frontend/src/config/endpoints.ts index 7968ff1..7040c99 100644 --- a/frontend/src/config/endpoints.ts +++ b/frontend/src/config/endpoints.ts @@ -57,5 +57,17 @@ export const endpoints = { api: '/api' } +// Agent terminal helpers +export function agentTerminalUrl(agentId: string): string { + const base = endpoints.terminal + const sep = base.includes('?') ? '&' : '?' + return `${base}${sep}session=agent-${agentId}` +} + +export function terminalApiUrl(path: string): string { + if (isSecure) return `https://${hostname}/ws/terminal${path}` + return `http://${hostname}:4103${path}` +} + // Debug logging console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints) diff --git a/frontend/src/types/agent.ts b/frontend/src/types/agent.ts index c15fb92..76217d4 100644 --- a/frontend/src/types/agent.ts +++ b/frontend/src/types/agent.ts @@ -30,6 +30,7 @@ export interface UiConfig { terminalBg: string terminalBorder: string enabled: boolean + command?: string } export interface Agent { diff --git a/server/routes/claude-hook.ts b/server/routes/claude-hook.ts index 78e37fc..c1969de 100644 --- a/server/routes/claude-hook.ts +++ b/server/routes/claude-hook.ts @@ -1,6 +1,7 @@ import { jsonResponse, errorResponse } from '../utils/cors' import { PORT_TERMINAL } from '../config' import { existsSync, readFileSync } from 'fs' +import { setActiveSession, getIncrementalMessages } from '../services/transcript-engine' type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking' @@ -118,6 +119,30 @@ export async function handleClaudeHook(req: Request): Promise { console.error('[claude-hook] Failed to forward status to terminal server:', e) } + // 3. Incremental transcript reading for real-time chat + if (body.session_id && body.transcript_path) { + const agentName = agent || 'main' + setActiveSession(agentName, body.session_id, body.transcript_path as string) + + const newMessages = getIncrementalMessages(body.session_id, body.transcript_path as string) + if (newMessages.length > 0) { + try { + await fetch(`http://localhost:${PORT_TERMINAL}/transcript-update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: body.session_id, + agent: agentName, + messages: newMessages, + hookEvent: body.hook_event_name + }) + }) + } catch (e) { + console.error('[claude-hook] Failed to forward transcript update:', e) + } + } + } + return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' }) } catch (e) { return errorResponse('Invalid JSON body', 400) diff --git a/server/routes/index.ts b/server/routes/index.ts index b487f20..b796800 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -20,7 +20,7 @@ import { handleAgentsPlugins, handleAgentsMcpJson, handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp } from './agents' -import { handleTranscript, handleTranscriptSessions } from './transcript' +import { handleTranscript, handleTranscriptSessions, handleTranscriptActive, handleClaudeStats, handleClaudeUsage } from './transcript' export async function handleRequest(req: Request): Promise { const url = new URL(req.url) @@ -278,7 +278,21 @@ export async function handleRequest(req: Request): Promise { return handleGitFile(url) } + // Claude usage limits (estimated) + if (path === '/api/claude-usage') { + return handleClaudeUsage() + } + + // Claude stats (global) + if (path === '/api/claude-stats') { + return handleClaudeStats() + } + // Transcript + if (path === '/api/transcript/active' && req.method === 'GET') { + return handleTranscriptActive(req, url) + } + if (path === '/api/transcript/sessions' && req.method === 'GET') { return handleTranscriptSessions() } diff --git a/server/routes/transcript.ts b/server/routes/transcript.ts index 1347caf..02330c1 100644 --- a/server/routes/transcript.ts +++ b/server/routes/transcript.ts @@ -1,12 +1,44 @@ import { jsonResponse, errorResponse } from '../utils/cors' -import { getTranscriptAnalysis, listSessions } from '../services/transcript-engine' +import { getTranscriptAnalysis, listSessions, getActiveSession, resetSessionOffset, getClaudeStats, getClaudeUsage } from '../services/transcript-engine' import type { TranscriptAnalysis } from '../services/transcript-engine' +export function handleClaudeUsage(): Response { + const usage = getClaudeUsage() + if (!usage) return errorResponse('Usage data not available', 404) + return jsonResponse(usage) +} + +export function handleClaudeStats(): Response { + const stats = getClaudeStats() + if (!stats) return errorResponse('Stats not available', 404) + return jsonResponse(stats) +} + export function handleTranscriptSessions(): Response { const sessions = listSessions() return jsonResponse(sessions) } +export function handleTranscriptActive(req: Request, url: URL): Response { + if (req.method !== 'GET') return errorResponse('Method not allowed', 405) + + const agent = url.searchParams.get('agent') || 'main' + const activeSession = getActiveSession(agent) + + const sessionId = activeSession?.sessionId + const analysis = getTranscriptAnalysis(sessionId, activeSession?.transcriptPath) + + if (!analysis) return errorResponse('No active session found', 404) + + // Reset offset so future incrementals start from here + resetSessionOffset(analysis.sessionId) + + return jsonResponse({ + ...analysis, + messages: analysis.messages.filter(m => !m.isMeta) + }) +} + export function handleTranscript(req: Request, url: URL, sessionId: string): Response { if (req.method !== 'GET') return errorResponse('Method not allowed', 405) @@ -63,7 +95,8 @@ function handleSection(analysis: TranscriptAnalysis, section: string): Response version: analysis.version, duration: analysis.duration, startTime: analysis.startTime, - endTime: analysis.endTime + endTime: analysis.endTime, + lastStopReason: analysis.lastStopReason }) case 'files': return jsonResponse({ diff --git a/server/services/terminal.ts b/server/services/terminal.ts index 3e35ba1..e69b8b0 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -10,6 +10,23 @@ interface TerminalSession { createdAt: Date } +// Agent terminal state tracking +interface AgentTerminalState { + agentId: string + sessionId: string + command: string + startedAt: Date | null + isAgentRunning: boolean +} + +export const agentSessions = new Map() + +const AGENT_COMMANDS: Record = { + 'main': 'claude', + 'ejecutor': 'ejecutor', + 'nucleo000': 'nucleo000' +} + // Store active terminal sessions by ID (persistent across reconnections) const sessions = new Map() @@ -65,6 +82,16 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes } catch { /* ignore */ } } sessions.delete(sessionId) + + // Mark agent as not running if this is an agent session + if (sessionId.startsWith('agent-')) { + const agentId = sessionId.replace('agent-', '') + const state = agentSessions.get(agentId) + if (state) { + state.isAgentRunning = false + console.log(`[Terminal] Agent ${agentId} marked as stopped (exit code ${exitCode})`) + } + } }) sessions.set(sessionId, session) @@ -74,6 +101,60 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes return session } +// Kill an existing session's PTY process +export function killSession(sessionId: string): boolean { + const session = sessions.get(sessionId) + if (!session) return false + + console.log(`[Terminal] Killing session: ${sessionId} (PID: ${session.pty.pid})`) + + // Notify clients before killing + for (const ws of session.clients) { + try { + ws.send(JSON.stringify({ type: 'session-restart', sessionId })) + } catch { /* ignore */ } + } + + try { + session.pty.kill() + } catch (e) { + console.error(`[Terminal] Error killing PTY for ${sessionId}:`, e) + } + + sessions.delete(sessionId) + return true +} + +// Start an agent command in its dedicated session +export async function startAgentInSession(agentId: string, force = false): Promise { + const sessionId = `agent-${agentId}` + const command = AGENT_COMMANDS[agentId] || agentId + + // If force restart, kill existing session first + if (force && sessions.has(sessionId)) { + killSession(sessionId) + await new Promise(r => setTimeout(r, 300)) + } + + const session = getOrCreateSession(sessionId) + + // Write the agent command to the PTY + session.pty.write(command + '\r') + + const state: AgentTerminalState = { + agentId, + sessionId, + command, + startedAt: new Date(), + isAgentRunning: true + } + + agentSessions.set(agentId, state) + console.log(`[Terminal] Agent ${agentId} started in session ${sessionId} with command: ${command}`) + + return state +} + export function startTerminalServer() { const server = Bun.serve({ port: PORT_TERMINAL, @@ -146,6 +227,66 @@ export function startTerminalServer() { } } + // Agent sessions info + if (url.pathname === '/agent-sessions' && req.method === 'GET') { + const result: Record = {} + for (const [id, state] of agentSessions) { + const session = sessions.get(state.sessionId) + result[id] = { + ...state, + pid: session?.pty.pid ?? null, + bufferSize: session?.outputBuffer.length ?? 0, + clientCount: session?.clients.size ?? 0, + sessionExists: !!session + } + } + return Response.json(result, { headers: corsHeaders }) + } + + // Start agent in session + if (url.pathname === '/start-agent' && req.method === 'POST') { + try { + const body = await req.json() as { agentId: string; force?: boolean } + if (!body.agentId) { + return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders }) + } + const state = await startAgentInSession(body.agentId, body.force) + return Response.json({ success: true, state }, { headers: corsHeaders }) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500, headers: corsHeaders }) + } + } + + // Stop agent session + if (url.pathname === '/stop-agent' && req.method === 'POST') { + try { + const body = await req.json() as { agentId: string } + if (!body.agentId) { + return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders }) + } + const sessionId = `agent-${body.agentId}` + const killed = killSession(sessionId) + if (killed) { + const state = agentSessions.get(body.agentId) + if (state) state.isAgentRunning = false + } + return Response.json({ success: true, killed }, { headers: corsHeaders }) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500, headers: corsHeaders }) + } + } + + // Transcript update broadcast endpoint + if (url.pathname === '/transcript-update' && req.method === 'POST') { + try { + const body = await req.json() + broadcastTranscriptUpdate(body as Record) + return Response.json({ success: true }, { headers: corsHeaders }) + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders }) + } + } + // Check if this is a WebSocket upgrade request const upgradeHeader = req.headers.get('upgrade') console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) @@ -269,11 +410,22 @@ type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | // Broadcast Claude status to ALL clients across ALL sessions export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) { + const agentName = agent || 'main' + + // Track agent running state from sessionStart + if (status === 'sessionStart') { + const state = agentSessions.get(agentName) + if (state) { + state.isAgentRunning = true + console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`) + } + } + const message = JSON.stringify({ type: 'claude-status', status, tool, - agent: agent || 'main', + agent: agentName, timestamp: Date.now() }) @@ -337,3 +489,24 @@ export function broadcastPermissionRequest(data: Record) { console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`) } + +// Broadcast transcript updates to ALL clients +export function broadcastTranscriptUpdate(data: Record) { + const message = JSON.stringify({ + type: 'transcript-update', + ...data, + timestamp: Date.now() + }) + + let clientCount = 0 + for (const [, session] of sessions) { + for (const ws of session.clients) { + try { + ws.send(message) + clientCount++ + } catch { /* skip */ } + } + } + + console.log(`[Terminal] Transcript update: ${data.hookEvent || 'fetch'} (${(data.messages as any[])?.length || 0} msgs) → ${clientCount} clients`) +} diff --git a/server/services/transcript-engine.ts b/server/services/transcript-engine.ts index c3ad802..e992045 100644 --- a/server/services/transcript-engine.ts +++ b/server/services/transcript-engine.ts @@ -40,6 +40,7 @@ export interface TranscriptAnalysis { thinkingBlocks: number errors: number } + lastStopReason: string } export interface TranscriptMessage { @@ -83,6 +84,49 @@ export interface SessionInfo { model: string } +export interface ClaudeStats { + today: { + date: string + messageCount: number + sessionCount: number + toolCallCount: number + tokensByModel: Record + } | null + modelUsage: Record + totalSessions: number + totalMessages: number + firstSessionDate: string +} + +export interface ClaudeUsage { + subscription: { type: string; tier: string; label: string; multiplier: number } + today: { messages: number; outputTokens: number; sessions: number } + daily: { used: number; limit: number; percent: number } + weekly: { used: number; limit: number; percent: number } + status: 'normal' | 'elevated' | 'extended' | 'limit_approaching' +} + +const TIER_LIMITS: Record = { + pro: { windowMessages: 45, dailyEstimate: 500, weeklyEstimate: 3500 }, + max_5x: { windowMessages: 225, dailyEstimate: 2500, weeklyEstimate: 17500 }, + max_20x: { windowMessages: 900, dailyEstimate: 10000, weeklyEstimate: 70000 }, +} + +function parseTier(rateLimitTier: string): { key: string; label: string; multiplier: number } { + const match = rateLimitTier.match(/max_(\d+)x/) + if (match) { + const n = parseInt(match[1]) + return { key: `max_${n}x`, label: `Max ${n}x`, multiplier: n } + } + return { key: 'pro', label: 'Pro', multiplier: 1 } +} + // ── Module-level cache ── const cache = new Map() +// ── Active session tracking (for real-time chat) ── + +const activeAgentSessions = new Map() + +// Byte offset per session (how far we've read) +const readOffsets = new Map() + // ── Project hash ── function getProjectHash(): string { @@ -214,6 +268,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn toolNames: string[] hasThinking: boolean usage: any + stopReason: string pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[] }>() @@ -293,6 +348,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn toolNames: [], hasThinking: false, usage: null, + stopReason: '', pendingToolCalls: [] } assistantChunks.set(msgId, chunk) @@ -300,6 +356,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn // Take latest usage (streaming chunks repeat usage, last is most accurate) if (msg.usage) chunk.usage = msg.usage + if (msg.stop_reason) chunk.stopReason = msg.stop_reason // Process content blocks (each JSONL line typically has one block) if (Array.isArray(msg.content)) { @@ -362,7 +419,9 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn // ── Second pass: assemble assistant messages and finalize tool calls ── let turnIndex = 0 + let lastStopReason = '' for (const [, chunk] of assistantChunks) { + if (chunk.stopReason) lastStopReason = chunk.stopReason const text = chunk.textParts.join('\n').trim() if (text || chunk.toolNames.length > 0) { @@ -477,14 +536,17 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn toolCallCount: toolCalls.length, thinkingBlocks, errors - } + }, + lastStopReason } } // ── Exported API ── -export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null { - const filePath = resolveTranscriptPath(sessionId) +export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null { + const filePath = transcriptPath && existsSync(transcriptPath) + ? transcriptPath + : resolveTranscriptPath(sessionId) if (!filePath) return null const sid = sessionIdFromPath(filePath) @@ -511,6 +573,283 @@ export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | } } +// ── Path normalization (Windows compat) ── + +function normalizeTranscriptPath(tp: string): string { + tp = tp.replace(/\\/g, '/') + if (/^\/[a-zA-Z]\//.test(tp)) { + tp = tp[1].toUpperCase() + ':' + tp.slice(2) + } + return tp +} + +// ── Active session API (for real-time chat) ── + +export function setActiveSession(agent: string, sessionId: string, transcriptPath: string): void { + const normalizedPath = normalizeTranscriptPath(transcriptPath) + activeAgentSessions.set(agent, { sessionId, transcriptPath: normalizedPath }) + + // Initialize offset to current file size (don't replay old messages as "new") + if (!readOffsets.has(sessionId)) { + try { + const stat = statSync(normalizedPath) + readOffsets.set(sessionId, stat.size) + } catch { + readOffsets.set(sessionId, 0) + } + } +} + +export function getActiveSession(agent: string): { sessionId: string; transcriptPath: string } | null { + return activeAgentSessions.get(agent) || null +} + +export function resetSessionOffset(sessionId: string): void { + readOffsets.set(sessionId, 0) +} + +export function getIncrementalMessages(sessionId: string, transcriptPath?: string): TranscriptMessage[] { + // Resolve path + let filePath = transcriptPath ? normalizeTranscriptPath(transcriptPath) : null + if (!filePath) { + const session = [...activeAgentSessions.values()].find(s => s.sessionId === sessionId) + filePath = session?.transcriptPath || null + } + if (!filePath) { + filePath = resolveTranscriptPath(sessionId) + } + if (!filePath || !existsSync(filePath)) return [] + + try { + const stat = statSync(filePath) + const fileSize = stat.size + const offset = readOffsets.get(sessionId) || 0 + + if (offset >= fileSize) return [] + + // Read only new bytes + const buffer = readFileSync(filePath) + const newContent = buffer.slice(offset, fileSize).toString('utf8') + + // Update offset + readOffsets.set(sessionId, fileSize) + + // Parse new lines + const rawLines = newContent.split('\n').filter(l => l.trim()) + const messages: TranscriptMessage[] = [] + + // Track assistant chunks by message.id for consolidation + const assistantChunks = new Map() + + for (const line of rawLines) { + try { + const obj = JSON.parse(line) + + if (obj.type === 'user') { + const msg = obj.message + if (!msg) continue + + const isMeta = !!obj.isMeta + const text = extractText(msg.content) + const hasToolResult = Array.isArray(msg.content) && + msg.content.some((c: any) => c.type === 'tool_result') + + if (text && !hasToolResult && !isMeta) { + messages.push({ + uuid: obj.uuid || crypto.randomUUID(), + role: 'user', + content: text, + timestamp: obj.timestamp || new Date().toISOString(), + isMeta: false, + hasThinking: false + }) + } + } else if (obj.type === 'assistant') { + const msg = obj.message + if (!msg || msg.role !== 'assistant') continue + + const msgId = msg.id || obj.uuid || 'unknown' + let chunk = assistantChunks.get(msgId) + if (!chunk) { + chunk = { + uuid: obj.uuid || crypto.randomUUID(), + timestamp: obj.timestamp || new Date().toISOString(), + textParts: [], + toolNames: [], + hasThinking: false + } + assistantChunks.set(msgId, chunk) + } + + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text?.trim()) { + chunk.textParts.push(block.text) + } else if (block.type === 'thinking') { + chunk.hasThinking = true + } else if (block.type === 'tool_use') { + chunk.toolNames.push(block.name) + } + } + } + } + // Ignore progress, file-history-snapshot, summary + } catch { + // Skip unparseable lines + } + } + + // Assemble assistant messages from consolidated chunks + for (const [, chunk] of assistantChunks) { + const text = chunk.textParts.join('\n').trim() + if (text || chunk.toolNames.length > 0) { + messages.push({ + uuid: chunk.uuid, + role: 'assistant', + content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`, + timestamp: chunk.timestamp, + isMeta: false, + toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined, + hasThinking: chunk.hasThinking + }) + } + } + + // Sort chronologically + messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + return messages + } catch (e) { + console.error('[transcript-engine] Incremental read error:', e) + return [] + } +} + +export function getClaudeStats(): ClaudeStats | null { + const statsPath = join(homedir(), '.claude', 'stats-cache.json') + if (!existsSync(statsPath)) return null + + try { + const raw = JSON.parse(readFileSync(statsPath, 'utf8')) + const todayStr = new Date().toISOString().slice(0, 10) + + // Find today's daily activity + const todayActivity = raw.dailyActivity?.find((d: any) => d.date === todayStr) || null + const todayTokens = raw.dailyModelTokens?.find((d: any) => d.date === todayStr) || null + + const today = todayActivity ? { + date: todayStr, + messageCount: todayActivity.messageCount || 0, + sessionCount: todayActivity.sessionCount || 0, + toolCallCount: todayActivity.toolCallCount || 0, + tokensByModel: todayTokens?.tokensByModel || {} + } : null + + // Model usage + const modelUsage: ClaudeStats['modelUsage'] = {} + if (raw.modelUsage) { + for (const [model, usage] of Object.entries(raw.modelUsage as Record)) { + modelUsage[model] = { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadInputTokens: usage.cacheReadInputTokens || 0, + cacheCreationInputTokens: usage.cacheCreationInputTokens || 0, + costUSD: usage.costUSD || 0 + } + } + } + + return { + today, + modelUsage, + totalSessions: raw.totalSessions || 0, + totalMessages: raw.totalMessages || 0, + firstSessionDate: raw.firstSessionDate || '' + } + } catch (e) { + console.error('[transcript-engine] Error reading stats-cache.json:', e) + return null + } +} + +export function getClaudeUsage(): ClaudeUsage | null { + const credentialsPath = join(homedir(), '.claude', '.credentials.json') + const statsPath = join(homedir(), '.claude', 'stats-cache.json') + + if (!existsSync(credentialsPath)) return null + + try { + // 1. Read credentials (never expose tokens) + const creds = JSON.parse(readFileSync(credentialsPath, 'utf8')) + const oauth = creds.claudeAiOauth || {} + const subType = oauth.subscriptionType || 'pro' + const rawTier = oauth.rateLimitTier || '' + const tier = parseTier(rawTier) + const limits = TIER_LIMITS[tier.key] || TIER_LIMITS.pro + + // 2. Read stats-cache for daily + weekly data + let todayMessages = 0 + let todayOutputTokens = 0 + let todaySessions = 0 + let weeklyMessages = 0 + + if (existsSync(statsPath)) { + const raw = JSON.parse(readFileSync(statsPath, 'utf8')) + const todayStr = new Date().toISOString().slice(0, 10) + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + for (const day of raw.dailyActivity || []) { + if (day.date === todayStr) { + todayMessages = day.messageCount || 0 + todaySessions = day.sessionCount || 0 + } + if (day.date >= sevenDaysAgo) { + weeklyMessages += day.messageCount || 0 + } + } + + // Output tokens for today + const todayTokens = (raw.dailyModelTokens || []).find((d: any) => d.date === todayStr) + if (todayTokens?.tokensByModel) { + todayOutputTokens = Object.values(todayTokens.tokensByModel as Record) + .reduce((sum: number, v: number) => sum + v, 0) + } + } + + // 3. Calculate percentages + const dailyPercent = limits.dailyEstimate > 0 + ? Math.round(todayMessages / limits.dailyEstimate * 100) + : 0 + const weeklyPercent = limits.weeklyEstimate > 0 + ? Math.round(weeklyMessages / limits.weeklyEstimate * 100) + : 0 + + // 4. Determine status based on highest usage (daily or weekly) + const maxPercent = Math.max(dailyPercent, weeklyPercent) + let status: ClaudeUsage['status'] = 'normal' + if (maxPercent >= 100) status = 'extended' + else if (maxPercent >= 80) status = 'limit_approaching' + else if (maxPercent >= 50) status = 'elevated' + + return { + subscription: { type: subType, tier: tier.key, label: tier.label, multiplier: tier.multiplier }, + today: { messages: todayMessages, outputTokens: todayOutputTokens, sessions: todaySessions }, + daily: { used: todayMessages, limit: limits.dailyEstimate, percent: dailyPercent }, + weekly: { used: weeklyMessages, limit: limits.weeklyEstimate, percent: weeklyPercent }, + status + } + } catch (e) { + console.error('[transcript-engine] Error reading claude usage:', e) + return null + } +} + export function listSessions(): SessionInfo[] { const projectDir = getProjectDir() if (!existsSync(projectDir)) return []