fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input
- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug - Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable - Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat - Guard xterm dispose against unloaded addons to prevent errors on agent switch - Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
This commit is contained in:
@@ -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<string, AgentTerminalState>()
|
||||
|
||||
const AGENT_COMMANDS: Record<string, string> = {
|
||||
'main': 'claude',
|
||||
'ejecutor': 'ejecutor',
|
||||
'nucleo000': 'nucleo000'
|
||||
}
|
||||
|
||||
// Store active terminal sessions by ID (persistent across reconnections)
|
||||
const sessions = new Map<string, TerminalSession>()
|
||||
|
||||
@@ -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<AgentTerminalState> {
|
||||
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<string, any> = {}
|
||||
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<string, unknown>)
|
||||
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<string, unknown>) {
|
||||
|
||||
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<string, unknown>) {
|
||||
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`)
|
||||
}
|
||||
|
||||
@@ -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<string, number>
|
||||
} | null
|
||||
modelUsage: Record<string, {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
costUSD: number
|
||||
}>
|
||||
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<string, { windowMessages: number; dailyEstimate: number; weeklyEstimate: number }> = {
|
||||
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<string, {
|
||||
@@ -90,6 +134,16 @@ const cache = new Map<string, {
|
||||
lastModified: number
|
||||
}>()
|
||||
|
||||
// ── Active session tracking (for real-time chat) ──
|
||||
|
||||
const activeAgentSessions = new Map<string, {
|
||||
sessionId: string
|
||||
transcriptPath: string
|
||||
}>()
|
||||
|
||||
// Byte offset per session (how far we've read)
|
||||
const readOffsets = new Map<string, number>()
|
||||
|
||||
// ── 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<string, {
|
||||
uuid: string
|
||||
timestamp: string
|
||||
textParts: string[]
|
||||
toolNames: string[]
|
||||
hasThinking: boolean
|
||||
}>()
|
||||
|
||||
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<string, any>)) {
|
||||
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<string, number>)
|
||||
.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 []
|
||||
|
||||
Reference in New Issue
Block a user