Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions bin/iak-claude-tail.mjs
Original file line number Diff line number Diff line change
@@ -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/<handle> 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),
Comment on lines +136 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send the daemon bearer token from tailer

In authenticated daemon configs (mcp.confirmations.auth_token set), startConfirmationsServer rejects every request before route dispatch unless an Authorization: Bearer ... header is present, but the new tailer only sends Content-Type. Those deployments will just log 401s and never populate /ide-chat/<handle>, so the CodeWatch mirror is unusable unless the auth token can be supplied and forwarded here.

Useful? React with 👍 / 👎.

});
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);
66 changes: 66 additions & 0 deletions src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,79 @@ export function startConfirmationsServer({
});
return;
}
// POST /ide-chat/<handle> body { role, text, ts, session_id, tool_calls? }
// GET /ide-chat/<handle>?since=<iso> → { 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-object IDE chat payloads

When a client sends syntactically valid but non-object JSON such as null to POST /ide-chat/<handle>, this call passes it into appendIdeChatEvent, which immediately dereferences raw.role and throws an uncaught TypeError; that terminates the daemon instead of returning a 400 like the other HTTP handlers. Validate that the parsed payload is an object, or catch errors from appending, before storing the event.

Useful? React with 👍 / 👎.

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' }));
});
server.listen(port, host);
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() {
Expand Down
Loading