/** * Claude Office live watcher. * * Tails the .jsonl session transcripts under ~/.claude/projects/ and * translates appended lines into office events, streamed to the frontend * over Server-Sent Events at http://localhost:5179/events. * * Zero dependencies — plain Node 18+. */ import { createServer } from "node:http"; import { createReadStream, promises as fs } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; const PORT = Number(process.env.PORT || 5179); const POLL_MS = 1500; const ACTIVE_WINDOW_DAYS = 7; const PROJECTS_ROOT = path.join(homedir(), ".claude", "projects"); /** slug → { slug, name, cwd, lastActivity } */ const projects = new Map(); /** absolute file path → { offset, remainder, slug } */ const tails = new Map(); /** tool_use_id → { slug, desc, kind, startedAt } — pending Agent (subagent) calls */ const pendingSubs = new Map(); const clients = new Set(); function broadcast(event) { const data = `data: ${JSON.stringify(event)}\n\n`; for (const res of clients) res.write(data); } function prettifyName(slug, cwd) { if (cwd) { const seg = cwd.split(/[\\/]/).filter(Boolean); if (seg.length > 0) return seg[seg.length - 1]; } const parts = slug.split("-").filter(Boolean); return parts[parts.length - 1] || slug; } /** Read the tail of the newest transcript to discover the project cwd. */ async function sniffCwd(file) { try { const stat = await fs.stat(file); const start = Math.max(0, stat.size - 16384); const buf = await readRange(file, start, stat.size); const lines = buf.toString("utf8").split("\n"); for (let i = lines.length - 1; i >= 0; i--) { const m = lines[i].match(/"cwd":"((?:[^"\\]|\\.)*)"/); if (m) return JSON.parse(`"${m[1]}"`); } } catch { /* ignore */ } return null; } function readRange(file, start, end) { return new Promise((resolve, reject) => { const chunks = []; createReadStream(file, { start, end: Math.max(start, end - 1) }) .on("data", (c) => chunks.push(c)) .on("end", () => resolve(Buffer.concat(chunks))) .on("error", reject); }); } function textOf(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter((c) => c && c.type === "text" && typeof c.text === "string") .map((c) => c.text) .join(" "); } function handleLine(slug, line) { let o; try { o = JSON.parse(line); } catch { return; } const proj = projects.get(slug); if (proj) { proj.lastActivity = Date.now(); if (!proj.cwd && typeof o.cwd === "string") { proj.cwd = o.cwd; proj.name = prettifyName(slug, o.cwd); } } const msg = o.message; const content = msg?.content; // Subagent completion: any tool_result whose id matches a pending Agent call. if (Array.isArray(content)) { for (const c of content) { if (c && c.type === "tool_result" && c.tool_use_id && pendingSubs.has(c.tool_use_id)) { const sub = pendingSubs.get(c.tool_use_id); pendingSubs.delete(c.tool_use_id); broadcast({ type: "subagent_end", slug: sub.slug, subId: c.tool_use_id }); } } } // Skip subagent-internal lines for the main pawn's status. if (o.isSidechain) return; if (o.type === "assistant" && msg?.role === "assistant") { if (Array.isArray(content)) { for (const c of content) { if (!c || c.type !== "tool_use") continue; if (c.name === "Agent" || c.name === "Task") { const input = c.input ?? {}; const sub = { slug, desc: input.description || input.prompt?.slice(0, 40) || "subagent", kind: input.subagent_type || "Task", startedAt: Date.now(), }; pendingSubs.set(c.id, sub); broadcast({ type: "subagent_spawn", slug, subId: c.id, desc: sub.desc, kind: sub.kind }); } else { broadcast({ type: "tool", slug, tool: c.name }); } } } const text = textOf(content).trim(); if (text) broadcast({ type: "speech", slug, text: text.slice(0, 80) }); return; } if (o.type === "user" && msg?.role === "user" && !o.isMeta) { const hasToolResult = Array.isArray(content) && content.some((c) => c && c.type === "tool_result"); if (!hasToolResult) { const text = textOf(content).trim(); if (text) broadcast({ type: "prompt", slug }); } } } async function pollOnce(initial) { let dirs; try { dirs = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true }); } catch { return; } for (const d of dirs) { if (!d.isDirectory()) continue; const slug = d.name; const dir = path.join(PROJECTS_ROOT, slug); let files; try { files = (await fs.readdir(dir)).filter((f) => f.endsWith(".jsonl")); } catch { continue; } let newestMtime = 0; let newestFile = null; for (const f of files) { const full = path.join(dir, f); let stat; try { stat = await fs.stat(full); } catch { continue; } if (stat.mtimeMs > newestMtime) { newestMtime = stat.mtimeMs; newestFile = full; } let tail = tails.get(full); if (!tail) { // First sighting: skip history, only follow new appends. tail = { offset: stat.size, remainder: "", slug }; tails.set(full, tail); continue; } if (stat.size < tail.offset) { tail.offset = stat.size; // truncated / rotated tail.remainder = ""; } if (stat.size > tail.offset) { const buf = await readRange(full, tail.offset, stat.size); tail.offset = stat.size; const chunk = tail.remainder + buf.toString("utf8"); const lines = chunk.split("\n"); tail.remainder = lines.pop() ?? ""; for (const line of lines) { if (line.trim()) handleLine(slug, line); } } } // Register the project if recently active. const ageDays = (Date.now() - newestMtime) / 86400000; if (newestFile && ageDays <= ACTIVE_WINDOW_DAYS && !projects.has(slug)) { const cwd = await sniffCwd(newestFile); projects.set(slug, { slug, name: prettifyName(slug, cwd), cwd, lastActivity: newestMtime, }); if (!initial) { broadcast({ type: "project", slug, name: projects.get(slug).name, lastActivity: newestMtime }); } } } } function snapshot() { return { type: "snapshot", projects: [...projects.values()] .sort((a, b) => b.lastActivity - a.lastActivity) .map((p) => ({ slug: p.slug, name: p.name, lastActivity: p.lastActivity })), subs: [...pendingSubs.entries()].map(([subId, s]) => ({ subId, slug: s.slug, desc: s.desc, kind: s.kind, })), }; } const server = createServer((req, res) => { if (req.url === "/events") { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "Access-Control-Allow-Origin": "*", }); res.write(`data: ${JSON.stringify(snapshot())}\n\n`); clients.add(res); req.on("close", () => clients.delete(res)); return; } res.writeHead(404, { "Access-Control-Allow-Origin": "*" }); res.end("not found"); }); await pollOnce(true); // Transcripts are sensitive — never expose beyond this machine. server.listen(PORT, "127.0.0.1", () => { console.log(`✳ Claude Office watcher`); console.log(` watching ${PROJECTS_ROOT}`); console.log(` projects ${projects.size} active in last ${ACTIVE_WINDOW_DAYS} days`); console.log(` SSE http://localhost:${PORT}/events`); }); setInterval(() => pollOnce(false).catch(() => {}), POLL_MS);