| # Patch: live-mirror external Claude Code sessions in even-terminal | |
| @evenrealities/even-terminal only streams sessions it drives itself via the | |
| Agent SDK. This patch adds passive mirroring: when you open a session in the | |
| app/glasses, it tails that session's ~/.claude/projects/**/<id>.jsonl (which | |
| the claude process in your own terminal appends to live) and broadcasts each | |
| new entry over SSE in the same message shapes the UI already renders. | |
| Applies to dist/ of a global install ($(npm root -g)/@evenrealities/even-terminal/dist). | |
| Restart even-terminal after applying. Note: dist edits are wiped on reinstall. | |
| --- | |
| ## 1. NEW FILE β dist/claude/tail.js | |
| js | | | // Live-mirror tailer for *external* Claude Code sessions. | | | import { existsSync, readdirSync, statSync, openSync, readSync, closeSync, watch } from "node:fs"; | | | import { join } from "node:path"; | | | import { homedir } from "node:os"; | | | import { summarizeClaudeToolCall } from "./summarize.js"; | | | import { claudeSessionStatus } from "./provider.js"; | | | import { pushMessage, broadcast, sessionHasClients } from "../routes/events.js"; | | | // session ids Even Terminal is itself driving β tailer stays quiet for these | | | export const drivenSessions = new Set(); | | | const tails = new Map(); | | | function findSessionFile(sessionId) { | | | const claudeDir = join(homedir(), ".claude", "projects"); | | | if (!existsSync(claudeDir)) return null; | | | for (const dir of readdirSync(claudeDir)) { | | | const p = join(claudeDir, dir, `${sessionId}.jsonl`); | | | if (existsSync(p)) return p; | | | } | | | return null; | | | } | | | function emit(sessionId, msg) { | | | const id = pushMessage(sessionId, msg); | | | broadcast(sessionId, msg, id); | | | } | | | /** Convert one Claude Code jsonl entry into SSE display messages. */ | | | function entryToMessages(entry, t) { | | | const out = []; | | | if (!entry || typeof entry !== "object") return out; | | | if (entry.type === "assistant") { | | | const content = entry.message?.content; | | | if (!Array.isArray(content)) return out; | | | for (const block of content) { | | | if (block.type === "text" && block.text?.trim()) { | | | out.push({ type: "status", state: "text_start", sessionId: t.sessionId }); | | | out.push({ type: "text_delta", text: block.text }); | | | out.push({ type: "status", state: "text_end", sessionId: t.sessionId }); | | | } else if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim()) { | | | out.push({ type: "status", state: "think_start", sessionId: t.sessionId }); | | | out.push({ type: "text_delta", text: block.thinking }); | | | out.push({ type: "status", state: "think_end", sessionId: t.sessionId }); | | | } else if (block.type === "tool_use") { | | | t.pending.set(block.id, { name: block.name, input: block.input }); | | | out.push({ type: "tool_start", name: block.name, toolId: block.id }); | | | } | | | } | | | } else if (entry.type === "user") { | | | const content = entry.message?.content; | | | if (typeof content === "string") { | | | if (content.trim() && !content.includes("Request interrupted by user")) | | | out.push({ type: "user_prompt", text: content }); | | | } else if (Array.isArray(content)) { | | | for (const block of content) { | | | if (block.type === "tool_result") { | | | const toolId = block.tool_use_id; | | | const pending = t.pending.get(toolId); | | | let output; | | | if (typeof block.content === "string") output = block.content; | | | else if (Array.isArray(block.content)) | | | output = block.content.filter((b) => b.type === "text").map((b) => b.text).join("\n"); | | | out.push({ | | | type: "tool_end", | | | name: pending?.name ?? "", | | | toolId, | | | summary: pending ? summarizeClaudeToolCall(pending.name, pending.input) : "", | | | detail: { input: pending?.input, output }, | | | }); | | | t.pending.delete(toolId); | | | } else if (block.type === "text" && block.text?.trim()) { | | | out.push({ type: "user_prompt", text: block.text }); | | | } | | | } | | | } | | | } else if (entry.type === "result") { | | | out.push({ | | | type: "result", | | | success: entry.subtype === "success", | | | text: entry.result ?? "", | | | sessionId: entry.session_id ?? t.sessionId, | | | costUsd: entry.total_cost_usd ?? 0, | | | provider: "claude", | | | turns: entry.num_turns ?? 0, | | | durationMs: entry.duration_ms ?? 0, | | | inputTokens: 0, | | | outputTokens: 0, | | | }); | | | } | | | return out; | | | } | | | function processFile(sessionId) { | | | const t = tails.get(sessionId); | | | if (!t) return; | | | let size; | | | try { size = statSync(t.file).size; } catch { return; } | | | if (size < t.offset) { t.offset = 0; t.remainder = ""; } // truncated/rotated | | | if (size === t.offset) return; | | | let chunk; | | | try { | | | const fd = openSync(t.file, "r"); | | | try { | | | const buf = Buffer.alloc(size - t.offset); | | | readSync(fd, buf, 0, buf.length, t.offset); | | | chunk = buf.toString("utf8"); | | | } finally { closeSync(fd); } | | | } catch { return; } | | | t.offset = size; | | | const data = t.remainder + chunk; | | | const lines = data.split("\n"); | | | t.remainder = lines.pop() ?? ""; // keep trailing partial line | | | if (drivenSessions.has(sessionId)) return; // ET drives it β SDK already emits | | | for (const line of lines) { | | | const trimmed = line.trim(); | | | if (!trimmed) continue; | | | let entry; | | | try { entry = JSON.parse(trimmed); } catch { continue; } | | | if (entry.uuid) { | | | if (t.seen.has(entry.uuid)) continue; | | | t.seen.add(entry.uuid); | | | } | | | for (const m of entryToMessages(entry, t)) emit(sessionId, m); | | | } | | | const status = claudeSessionStatus(sessionId); | | | if (status !== t.lastStatus) { | | | t.lastStatus = status; | | | emit(sessionId, { type: "status", state: status, sessionId }); | | | } | | | } | | | export function startTail(sessionId) { | | | if (!sessionId || tails.has(sessionId)) return; | | | const file = findSessionFile(sessionId); | | | if (!file) return; | | | let size = 0; | | | try { size = statSync(file).size; } catch {} | | | const t = { | | | sessionId, file, | | | offset: size, // start at EOF: stream only NEW activity | | | remainder: "", pending: new Map(), seen: new Set(), | | | lastStatus: null, watcher: null, timer: null, | | | }; | | | tails.set(sessionId, t); | | | const status = claudeSessionStatus(sessionId); | | | t.lastStatus = status; | | | emit(sessionId, { type: "status", state: status, sessionId }); | | | try { t.watcher = watch(file, { persistent: false }, () => processFile(sessionId)); } catch {} | | | t.timer = setInterval(() => processFile(sessionId), 1000); // poll fallback | | | console.log(`[tail] Mirroring external session=${sessionId} from offset=${size}`); | | | } | | | export function stopTail(sessionId) { | | | const t = tails.get(sessionId); | | | if (!t) return; | | | if (t.watcher) { try { t.watcher.close(); } catch {} } | | | if (t.timer) clearInterval(t.timer); | | | tails.delete(sessionId); | | | console.log(`[tail] Stopped mirroring session=${sessionId}`); | | | } | | | export function stopTailIfIdle(sessionId) { | | | if (!sessionHasClients(sessionId)) stopTail(sessionId); | | | } | | | | |
| --- | |
| ## 2. dist/routes/events.js | |
| diff | | | import { Router } from "express"; | | | import { writeToLogFile } from "../logger.js"; | | | +import { startTail, stopTailIfIdle } from "../claude/tail.js"; | | | @@ in router.get("/events", ...) @@ | | | s.clients.add(res); | | | console.log(`[sse] Client connected session=${sessionId} (session clients: ${s.clients.size}, total: ${clientCount()})`); | | | + // Mirror an externally-running Claude session's live jsonl output over SSE. | | | + startTail(sessionId); | | | @@ req.on("close", ...) @@ | | | clearInterval(heartbeat); | | | s.clients.delete(res); | | | + stopTailIfIdle(sessionId); | | | console.log(`[sse] Client disconnected (total: ${clientCount()})`); | | | | |
| --- | |
| ## 3. dist/claude/session.js (dedup: stay quiet for ET-driven sessions) | |
| diff | | | import { query } from "@anthropic-ai/claude-agent-sdk"; | | | import { existsSync } from "node:fs"; | | | +import { drivenSessions } from "./tail.js"; | | | @@ async run(prompt) @@ | | | this._busy = true; | | | this.busyEmitted = false; | | | this.currentBlockType = null; | | | + this.onIdReady((sid) => drivenSessions.add(sid)); | | | this.turnStartMs = Date.now(); | | | @@ query finally block @@ | | | this._busy = false; | | | + if (this.sessionId) | | | + drivenSessions.delete(this.sessionId); | | | this.queryHandle = null; | | | | |
| --- | |
| ## 4. Re-apply script β ~/.even-terminal/apply-tail-patch.mjs | |
| Run after upgrading/reinstalling even-terminal (npm wipes dist/ edits), then | |
| restart even-terminal: | |
| | | | node ~/.even-terminal/apply-tail-patch.mjs | | | | |
| Idempotent β safe to run repeatedly. | |
| js | | | #!/usr/bin/env node | | | // Re-applies the "live-mirror external Claude sessions" patch to a globally | | | // installed @evenrealities/even-terminal. Safe to run repeatedly. | | | import { execSync } from "node:child_process"; | | | import { readFileSync, writeFileSync, existsSync } from "node:fs"; | | | import { join } from "node:path"; | | | const root = execSync("npm root -g").toString().trim(); | | | const pkg = join(root, "@evenrealities", "even-terminal"); | | | if (!existsSync(pkg)) { | | | console.error(`even-terminal not found under ${root}`); | | | process.exit(1); | | | } | | | const dist = join(pkg, "dist"); | | | // Full source of dist/claude/tail.js (see section 1 of this gist). | | | // Paste the tail.js body between the backticks below. | | | const TAIL_JS = `<<< PASTE dist/claude/tail.js SOURCE HERE >>>`; | | | function patchFile(path, edits, marker) { | | | let src = readFileSync(path, "utf8"); | | | if (src.includes(marker)) { | | | console.log(` already patched: ${path}`); | | | return; | | | } | | | for (const [find, replace] of edits) { | | | if (!src.includes(find)) | | | throw new Error(`anchor not found in ${path}:\n${find}`); | | | src = src.replace(find, replace); | | | } | | | writeFileSync(path, src); | | | console.log(` patched: ${path}`); | | | } | | | // 1. tail.js | | | writeFileSync(join(dist, "claude", "tail.js"), TAIL_JS); | | | console.log(` wrote: ${join(dist, "claude", "tail.js")}`); | | | // 2. events.js | | | patchFile(join(dist, "routes", "events.js"), [ | | | [ | | | `import { Router } from "express";\nimport { writeToLogFile } from "../logger.js";`, | | | `import { Router } from "express";\nimport { writeToLogFile } from "../logger.js";\nimport { startTail, stopTailIfIdle } from "../claude/tail.js";`, | | | ], | | | [ | | | ` s.clients.add(res);\n console.log(\`[sse] Client connected session=\${sessionId} (session clients: \${s.clients.size}, total: \${clientCount()})\`);`, | | | ` s.clients.add(res);\n console.log(\`[sse] Client connected session=\${sessionId} (session clients: \${s.clients.size}, total: \${clientCount()})\`);\n // Mirror an externally-running Claude session's live jsonl output over SSE.\n startTail(sessionId);`, | | | ], | | | [ | | | ` clearInterval(heartbeat);\n s.clients.delete(res);\n console.log(\`[sse] Client disconnected (total: \${clientCount()})\`);`, | | | ` clearInterval(heartbeat);\n s.clients.delete(res);\n stopTailIfIdle(sessionId);\n console.log(\`[sse] Client disconnected (total: \${clientCount()})\`);`, | | | ], | | | ], `../claude/tail.js`); | | | // 3. session.js | | | patchFile(join(dist, "claude", "session.js"), [ | | | [ | | | `import { query } from "@anthropic-ai/claude-agent-sdk";\nimport { existsSync } from "node:fs";`, | | | `import { query } from "@anthropic-ai/claude-agent-sdk";\nimport { existsSync } from "node:fs";\nimport { drivenSessions } from "./tail.js";`, | | | ], | | | [ | | | ` this._busy = true;\n this.busyEmitted = false;\n this.currentBlockType = null;\n this.turnStartMs = Date.now();`, | | | ` this._busy = true;\n this.busyEmitted = false;\n this.currentBlockType = null;\n this.onIdReady((sid) => drivenSessions.add(sid));\n this.turnStartMs = Date.now();`, | | | ], | | | [ | | | ` this._busy = false;\n this.queryHandle = null;`, | | | ` this._busy = false;\n if (this.sessionId)\n drivenSessions.delete(this.sessionId);\n this.queryHandle = null;`, | | | ], | | | ], `drivenSessions`); | | | console.log("Done. Restart even-terminal for changes to take effect."); | | | | |
| > Note: assign TAIL_JS to the section-1 tail.js source (e.g. a template | |
| > literal, or readFileSync it from a saved copy). In the working version on | |
| > disk it's embedded inline. |
Why Can't We Agree on a Plugin Format?