import fs from "node:fs"; import type { AgentActivityItem, TodoItem, ToolActivityItem, } from "./types.js"; interface ToolState { id: string; name: string; status: ToolActivityItem["status"]; detail?: string; lastTs: number; } /** * Parse Grok `updates.jsonl` for tool / agent activity. * Pure-ish: accepts optional pre-split lines for tests. */ export function parseUpdatesLines(lines: string[]): { tools: ToolActivityItem[]; agents: AgentActivityItem[]; todos: TodoItem[]; } { const tools = new Map(); const agents: AgentActivityItem[] = []; let todos: TodoItem[] = []; let lineNo = 0; for (const raw of lines) { lineNo += 1; const line = raw.trim(); if (!line) continue; let obj: Record; try { obj = JSON.parse(line) as Record; } catch { continue; } const params = (obj.params as Record | undefined) ?? {}; const update = (params.update as Record | undefined) ?? (obj.update as Record | undefined) ?? {}; const kind = (update.sessionUpdate as string | undefined) ?? (obj.sessionUpdate as string | undefined) ?? ""; const ts = typeof obj.timestamp === "number" ? obj.timestamp : typeof obj.ts === "string" ? Date.parse(obj.ts) / 1000 : lineNo; if (kind === "tool_call" || kind === "tool_call_update") { const id = (update.toolCallId as string | undefined) ?? (update.id as string | undefined) ?? `tool-${lineNo}`; const title = (update.title as string | undefined) ?? ""; const statusRaw = (update.status as string | undefined) ?? ""; const prev = tools.get(id); let name = prev?.name ?? "tool"; if (title && !title.includes(" ") && title.length < 64) { name = title; } else if (title && prev?.name === "tool") { // Prefer short tool name from earlier tool_call event name = prev.name; } // On initial tool_call, title is the tool name if (kind === "tool_call" && title) { name = title.split(/\s+/)[0] || title; } let status: ToolActivityItem["status"] = prev?.status ?? "running"; if (statusRaw === "completed" || statusRaw === "complete" || statusRaw === "success") { status = "completed"; } else if (statusRaw === "failed" || statusRaw === "error") { status = "failed"; } else if (kind === "tool_call") { status = "running"; } let detail = prev?.detail; if (kind === "tool_call_update" && title && title.length < 120) { detail = title; } const rawInput = update.rawInput; if (!detail && rawInput != null) { detail = summarizeRawInput(rawInput); } tools.set(id, { id, name, status, detail, lastTs: ts, }); // Capture latest todo list from todo_write if ( name === "todo_write" || name === "TodoWrite" || (typeof title === "string" && /todo/i.test(title)) ) { const extracted = extractTodos(rawInput ?? update); if (extracted.length) todos = extracted; } continue; } if ( kind === "agent_message_chunk" || kind === "agent_thought_chunk" || kind.includes("agent") ) { const title = (update.title as string | undefined) ?? (update.agentType as string | undefined) ?? (update.name as string | undefined); if (title || kind === "agent_message_chunk") { agents.push({ id: (update.agentId as string | undefined) ?? (update.toolCallId as string | undefined), title: title ?? kind, status: (update.status as string | undefined) ?? "active", detail: typeof update.content === "string" ? update.content.slice(0, 80) : undefined, }); } } } // Aggregate completed tools by name for a compact summary const sorted = [...tools.values()].sort((a, b) => b.lastTs - a.lastTs); const recent = sorted.slice(0, 12); const completedCounts = new Map(); const items: ToolActivityItem[] = []; for (const t of recent) { if (t.status === "running") { items.push({ id: t.id, name: t.name, status: "running", detail: t.detail, }); } else { completedCounts.set(t.name, (completedCounts.get(t.name) ?? 0) + 1); } } for (const [name, count] of completedCounts) { items.push({ id: `agg-${name}`, name, status: "completed", count, }); } // Keep running first, then completed aggregates items.sort((a, b) => { if (a.status === "running" && b.status !== "running") return -1; if (b.status === "running" && a.status !== "running") return 1; return 0; }); return { tools: items.slice(0, 8), agents: agents.slice(-3), todos, }; } export function parseUpdatesFile( filePath: string, options: { maxTailBytes?: number } = {}, ): { tools: ToolActivityItem[]; agents: AgentActivityItem[]; todos: TodoItem[]; } { if (!fs.existsSync(filePath)) { return { tools: [], agents: [], todos: [] }; } const maxTail = options.maxTailBytes ?? 256_000; try { const stat = fs.statSync(filePath); let content: string; if (stat.size <= maxTail) { content = fs.readFileSync(filePath, "utf8"); } else { const fd = fs.openSync(filePath, "r"); try { const buf = Buffer.alloc(maxTail); fs.readSync(fd, buf, 0, maxTail, stat.size - maxTail); content = buf.toString("utf8"); // Drop partial first line const nl = content.indexOf("\n"); if (nl >= 0) content = content.slice(nl + 1); } finally { fs.closeSync(fd); } } return parseUpdatesLines(content.split(/\r?\n/)); } catch { return { tools: [], agents: [], todos: [] }; } } function extractTodos(raw: unknown): TodoItem[] { if (raw == null) return []; let obj: unknown = raw; if (typeof raw === "string") { try { obj = JSON.parse(raw.replace(/'/g, '"')); } catch { return []; } } if (!obj || typeof obj !== "object") return []; const o = obj as Record; const list = (o.todos ?? o.TodoWrite ?? o.items) as unknown; if (!Array.isArray(list)) return []; const out: TodoItem[] = []; for (const item of list) { if (!item || typeof item !== "object") continue; const t = item as Record; const content = String(t.content ?? t.title ?? t.text ?? "").trim(); if (!content) continue; out.push({ content, status: String(t.status ?? "pending"), id: t.id != null ? String(t.id) : undefined, }); } return out; } function summarizeRawInput(raw: unknown): string | undefined { if (raw == null) return undefined; if (typeof raw === "string") { try { return summarizeRawInput(JSON.parse(raw.replace(/'/g, '"'))); } catch { return raw.slice(0, 60); } } if (typeof raw === "object") { const o = raw as Record; const command = o.command ?? o.target_file ?? o.path ?? o.query ?? o.pattern; if (typeof command === "string") return command.slice(0, 60); } return undefined; } /** Format tool line like Claude HUD: ◐ Edit: x | ✓ Read ×3 */ export function formatToolLine(tools: ToolActivityItem[]): string { if (!tools.length) return ""; const parts: string[] = []; for (const t of tools) { if (t.status === "running") { const d = t.detail ? `: ${truncate(t.detail, 40)}` : ""; parts.push(`◐ ${t.name}${d}`); } else if (t.status === "failed") { parts.push(`✗ ${t.name}`); } else { const n = t.count && t.count > 1 ? ` ×${t.count}` : ""; parts.push(`✓ ${t.name}${n}`); } } return parts.join(" | "); } function truncate(s: string, n: number): string { if (s.length <= n) return s; return s.slice(0, n - 1) + "…"; }