From 1fc23c3664d08aeab941eb99de4bbd937366d913 Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Mon, 6 Jul 2026 11:38:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(confirmations):=20IDE-chat=20mirror=20?= =?UTF-8?q?=E2=80=94=20tail=20Claude=20transcripts=20into=20CodeWatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an IDE-chat-in-CodeWatch channel: the running IDE conversation shows up in CodeWatch as a per-handle chat feed. - src/confirmations.mjs: new /ide-chat/ endpoint on the confirmations server. POST appends an event; GET (?since=&limit=) returns events. Backed by a bounded in-memory ring buffer (500/handle) — memory stays flat on long-running daemons, history is intentionally non-persistent (a restart drops it; the tailer repopulates). - bin/iak-claude-tail.mjs: tails ~/.claude/projects/*.jsonl and POSTs each user/assistant message to the endpoint. Fully env-driven (IAK_HANDLE, IAK_DAEMON_URL, IAK_PROJECTS_DIR, IAK_TAIL_INTERVAL, IAK_TAIL_INCLUDE_TOOLS, IAK_TAIL_MAX_TEXT) — no machine-specific paths baked in. Independent of the action-status/poller work; both only add code (no shared regions), so they merge in any order. Tests stay 15/15. Co-Authored-By: Claude Opus 4.8 --- bin/iak-claude-tail.mjs | 164 ++++++++++++++++++++++++++++++++++++++++ src/confirmations.mjs | 66 ++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100755 bin/iak-claude-tail.mjs diff --git a/bin/iak-claude-tail.mjs b/bin/iak-claude-tail.mjs new file mode 100755 index 0000000..a31ec7e --- /dev/null +++ b/bin/iak-claude-tail.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// +// Tails Claude Code transcript JSONL files and forwards user/assistant +// messages to the IAK daemon's /ide-chat/ endpoint, so CodeWatch +// can render the IDE conversation as a per-handle channel. +// +// Run: node bin/iak-claude-tail.mjs +// +// Env: +// IAK_HANDLE (default: @claudemm) — handle this tail represents +// IAK_DAEMON_URL (default: http://127.0.0.1:8788) +// IAK_TAIL_INTERVAL (default: 2000) — poll interval in ms +// IAK_PROJECTS_DIR (default: ~/.claude/projects) +// IAK_TAIL_INCLUDE_TOOLS (default: 0) — 1 to include tool_use / tool_result +// IAK_TAIL_MAX_TEXT (default: 4000) — clamp text length per event + +import { readdir, stat, open } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +const HANDLE = process.env.IAK_HANDLE || '@claudemm'; +const DAEMON_URL = (process.env.IAK_DAEMON_URL || 'http://127.0.0.1:8788').replace(/\/$/, ''); +const POLL_MS = parseInt(process.env.IAK_TAIL_INTERVAL || '2000', 10); +const PROJECTS_DIR = process.env.IAK_PROJECTS_DIR || join(homedir(), '.claude', 'projects'); +const INCLUDE_TOOLS = process.env.IAK_TAIL_INCLUDE_TOOLS === '1'; +const MAX_TEXT = parseInt(process.env.IAK_TAIL_MAX_TEXT || '4000', 10); +const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000; +const PRIME_BYTES = 64 * 1024; + +const positions = new Map(); + +async function findActiveSessions() { + const out = []; + let projects; + try { + projects = await readdir(PROJECTS_DIR, { withFileTypes: true }); + } catch { + return out; + } + const now = Date.now(); + for (const p of projects) { + if (!p.isDirectory()) continue; + const projDir = join(PROJECTS_DIR, p.name); + let files; + try { files = await readdir(projDir); } catch { continue; } + let latest = null; + let latestMtime = 0; + for (const f of files) { + if (!f.endsWith('.jsonl')) continue; + const full = join(projDir, f); + try { + const s = await stat(full); + if (s.mtimeMs > latestMtime) { + latestMtime = s.mtimeMs; + latest = full; + } + } catch {} + } + if (latest && now - latestMtime < ACTIVE_WINDOW_MS) { + out.push({ path: latest, project: p.name, mtimeMs: latestMtime }); + } + } + return out; +} + +async function readNewLines(path) { + let s; + try { s = await stat(path); } catch { return []; } + let startPos = positions.get(path); + if (startPos === undefined) { + // Prime: only read the tail of the file on first encounter so we + // don't replay history when the script (re)starts. + startPos = Math.max(0, s.size - PRIME_BYTES); + } + if (s.size <= startPos) return []; + let fh; + try { fh = await open(path, 'r'); } catch { return []; } + try { + const buf = Buffer.alloc(s.size - startPos); + await fh.read(buf, 0, buf.length, startPos); + positions.set(path, s.size); + let text = buf.toString('utf-8'); + // If we primed mid-line, drop the partial leading line. + if (startPos > 0 && !text.startsWith('{')) { + const nl = text.indexOf('\n'); + if (nl >= 0) text = text.slice(nl + 1); + } + return text.split('\n').filter(Boolean); + } finally { + await fh.close(); + } +} + +function extractEvent(obj, sessionId) { + const t = obj.type; + if (!['user', 'assistant'].includes(t)) return null; + const msg = obj.message || {}; + let content = msg.content; + let toolCalls = []; + if (Array.isArray(content)) { + const parts = []; + for (const c of content) { + if (typeof c !== 'object' || c === null) { + parts.push(String(c)); + } else if (c.type === 'text' && typeof c.text === 'string') { + parts.push(c.text); + } else if (c.type === 'tool_use') { + if (INCLUDE_TOOLS) { + toolCalls.push({ name: c.name, input: c.input }); + parts.push(`[tool_use ${c.name}]`); + } + } else if (c.type === 'tool_result') { + if (INCLUDE_TOOLS) { + parts.push(`[tool_result] ${typeof c.content === 'string' ? c.content : JSON.stringify(c.content).slice(0, 200)}`); + } + } + } + content = parts.join('\n').trim(); + } else if (typeof content !== 'string') { + content = String(content ?? ''); + } + if (!content && toolCalls.length === 0) return null; + return { + handle: HANDLE, + role: t, + text: content.slice(0, MAX_TEXT), + ts: obj.timestamp || new Date().toISOString(), + session_id: sessionId || obj.sessionId || null, + tool_calls: INCLUDE_TOOLS && toolCalls.length ? toolCalls : undefined, + }; +} + +async function postEvent(event) { + try { + const res = await fetch(`${DAEMON_URL}/ide-chat/${encodeURIComponent(HANDLE)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok && res.status !== 404) { + console.warn(`[iak-claude-tail] daemon ${res.status} on POST /ide-chat`); + } + } catch (e) { + console.warn(`[iak-claude-tail] post failed: ${e.message}`); + } +} + +async function poll() { + const sessions = await findActiveSessions(); + for (const sess of sessions) { + const lines = await readNewLines(sess.path); + for (const line of lines) { + let obj; + try { obj = JSON.parse(line); } catch { continue; } + const event = extractEvent(obj, obj.sessionId); + if (event) await postEvent(event); + } + } +} + +console.log(`[iak-claude-tail] handle=${HANDLE} daemon=${DAEMON_URL} dir=${PROJECTS_DIR} interval=${POLL_MS}ms tools=${INCLUDE_TOOLS}`); +poll(); +setInterval(poll, POLL_MS); diff --git a/src/confirmations.mjs b/src/confirmations.mjs index add0f02..72baaab 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -659,6 +659,41 @@ export function startConfirmationsServer({ }); return; } + // POST /ide-chat/ body { role, text, ts, session_id, tool_calls? } + // GET /ide-chat/?since= → { events: [...] } + // + // Per-handle in-memory ring buffer. Backs the IDE-chat-in-CodeWatch + // feature — `bin/iak-claude-tail.mjs` tails ~/.claude/projects/*.jsonl + // and POSTs each user/assistant message here; CodeWatch polls GET to + // render the conversation as an IDE channel. + const ideChatMatch = url.pathname.match(/^\/ide-chat\/([^/]+)$/); + if (ideChatMatch) { + const handle = decodeURIComponent(ideChatMatch[1]); + if (req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let payload; + try { payload = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'invalid json' })); + return; + } + const event = appendIdeChatEvent(handle, payload); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, ts: event.ts })); + }); + return; + } + if (req.method === 'GET') { + const since = url.searchParams.get('since'); + const limit = parseInt(url.searchParams.get('limit') || '200', 10); + const events = listIdeChatEvents(handle, since, limit); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ events })); + return; + } + } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'not found' })); }); @@ -666,6 +701,37 @@ export function startConfirmationsServer({ return server; } +// In-memory IDE-chat ring buffer per handle. Bounded to keep memory flat +// even on long-running daemons. Persisted nowhere — restart drops history, +// CodeWatch will repopulate on next tail run. +const IDE_CHAT_MAX_PER_HANDLE = 500; +const ideChat = new Map(); // handle → array of events (oldest first) + +export function appendIdeChatEvent(handle, raw) { + const event = { + role: raw.role || 'unknown', + text: typeof raw.text === 'string' ? raw.text : String(raw.text ?? ''), + ts: raw.ts || new Date().toISOString(), + session_id: raw.session_id || null, + tool_calls: Array.isArray(raw.tool_calls) ? raw.tool_calls : undefined, + }; + let buf = ideChat.get(handle); + if (!buf) { buf = []; ideChat.set(handle, buf); } + buf.push(event); + if (buf.length > IDE_CHAT_MAX_PER_HANDLE) buf.splice(0, buf.length - IDE_CHAT_MAX_PER_HANDLE); + return event; +} + +export function listIdeChatEvents(handle, since, limit = 200) { + const buf = ideChat.get(handle) || []; + let filtered = buf; + if (since) { + filtered = buf.filter((e) => e.ts > since); + } + if (filtered.length > limit) filtered = filtered.slice(filtered.length - limit); + return filtered; +} + // Tiny self-contained HTML UI for tap-to-approve. Inlined so the // confirmations server has no external assets / templates to ship. function renderIntentsHtml() {