UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

351 lines (330 loc) • 14.5 kB
import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { codexHome } from "./host-paths.js"; const DEFAULT_LIMIT = 40; const MAX_LIMIT = 200; const DEFAULT_ITEM_CHARS = 12_000; const MAX_ITEM_CHARS = 40_000; const MAX_SCAN_BYTES = 64 * 1024 * 1024; const MAX_RESULT_CHARS = 700_000; const CHUNK_BYTES = 256 * 1024; const MAX_LINE_BYTES = 8 * 1024 * 1024; function opaqueAgentId(provider, nativeId) { return `aiagent_${createHash("sha256").update(`${provider}\0${nativeId}`).digest("hex").slice(0, 24)}`; } function truncate(value, maxChars) { const text = String(value ?? ""); return text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n… [truncated ${text.length - maxChars} characters]`; } function redactObject(value, maxChars) { const seen = new WeakSet(); const scrub = (item, key = "") => { if (/token|secret|password|authorization|cookie|api[-_]?key/i.test(key)) return "[redacted]"; if (!item || typeof item !== "object") return item; if (seen.has(item)) return "[circular]"; seen.add(item); if (Array.isArray(item)) return item.slice(0, 100).map((child) => scrub(child)); return Object.fromEntries(Object.entries(item).slice(0, 100).map(([childKey, child]) => [childKey, scrub(child, childKey)])); }; try { return truncate(JSON.stringify(scrub(value)), maxChars); } catch { return "[unavailable]"; } } function textFromContent(content, maxChars) { if (typeof content === "string") return truncate(content, maxChars); if (!Array.isArray(content)) return ""; return truncate(content.flatMap((block) => { if (!block || typeof block !== "object") return []; if (["text", "input_text", "output_text"].includes(block.type) && typeof block.text === "string") return [block.text]; return []; }).join("\n"), maxChars); } function timestamp(row) { const value = row?.timestamp || row?.payload?.timestamp || row?.payload?.created_at || null; return typeof value === "string" ? value : null; } function normalizeClaude(row, maxChars) { if (!row || !["user", "assistant"].includes(row.type) || !row.message) return []; const role = row.message.role || row.type; const content = row.message.content; const records = []; const plain = textFromContent(content, maxChars); if (plain) records.push({ type: "message", role, text: plain, at: timestamp(row) }); if (!Array.isArray(content)) return records; for (const block of content) { if (!block || typeof block !== "object") continue; if (block.type === "tool_use") { records.push({ type: "tool_call", tool: String(block.name || "tool"), input: redactObject(block.input ?? {}, maxChars), at: timestamp(row), }); } else if (block.type === "tool_result") { const output = textFromContent(block.content, maxChars) || (typeof block.content === "string" ? truncate(block.content, maxChars) : redactObject(block.content, maxChars)); records.push({ type: "tool_result", output, isError: Boolean(block.is_error), at: timestamp(row) }); } // Deliberately exclude thinking/signature blocks. They are provider-private // reasoning artifacts, not user-visible transcript content. } return records; } function normalizeCodex(row, maxChars) { if (!row || typeof row !== "object") return []; if (row.type === "response_item") { const item = row.payload || {}; if (item.type === "message" && ["user", "assistant"].includes(item.role)) { const text = textFromContent(item.content, maxChars); const attachments = Array.isArray(item.content) ? item.content.filter((block) => ["input_image", "image", "localImage"].includes(block?.type)) .map(() => ({ name: "Image", image: true })) : []; return text || attachments.length ? [{ type: "message", role: item.role, text, attachments, at: timestamp(row) }] : []; } if (["function_call", "custom_tool_call", "tool_call"].includes(item.type)) { return [{ type: "tool_call", tool: String(item.name || item.tool_name || item.call_id || "tool"), input: truncate(typeof item.arguments === "string" ? item.arguments : redactObject(item.arguments ?? item.input ?? {}, maxChars), maxChars), at: timestamp(row), }]; } if (["function_call_output", "custom_tool_call_output", "tool_result"].includes(item.type)) { return [{ type: "tool_result", output: truncate(item.output ?? item.content ?? "", maxChars), at: timestamp(row) }]; } // Reasoning summaries are intentionally not returned. Codex progress below // is the supported user-visible surface; encrypted reasoning never leaves. return []; } if (row.type === "event_msg") { const event = row.payload || {}; if (["agent_reasoning", "task_started", "task_complete", "turn_aborted", "sub_agent_activity"].includes(event.type)) { const text = event.text || event.message || event.status || event.type.replaceAll("_", " "); return [{ type: "progress", text: truncate(text, maxChars), at: timestamp(row) }]; } } return []; } function collectFiles(root, accept, maxFiles = 2000) { const found = []; const visit = (dir) => { if (found.length >= maxFiles) return; let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (found.length >= maxFiles) break; const full = path.join(dir, entry.name); if (entry.isDirectory()) visit(full); else if (entry.isFile() && accept(entry.name, full)) found.push(full); } }; visit(root); return found; } function readFirstJsonLine(filePath, maxBytes = 512 * 1024) { let fd; try { fd = fs.openSync(filePath, "r"); const size = Math.min(fs.fstatSync(fd).size, maxBytes); const buffer = Buffer.alloc(size); fs.readSync(fd, buffer, 0, size, 0); for (const line of buffer.toString("utf8").split("\n")) { try { return JSON.parse(line); } catch {} } } catch {} finally { if (fd !== undefined) fs.closeSync(fd); } return null; } function claudeAgents(target) { const mainPath = String(target?.nativeRef?.transcriptPath || ""); if (!mainPath || !fs.existsSync(mainPath)) throw new Error("Claude transcript is not available on this computer"); const agents = [{ id: "main", provider: "claude", role: "orchestrator", parentId: null, status: target.state, filePath: mainPath }]; const root = path.join(path.dirname(mainPath), path.basename(mainPath, ".jsonl")); for (const filePath of collectFiles(root, (name) => /^agent-.*\.jsonl$/.test(name))) { const nativeId = path.basename(filePath, ".jsonl").replace(/^agent-/, ""); const metaPath = filePath.replace(/\.jsonl$/, ".meta.json"); let meta = {}; try { meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); } catch {} agents.push({ id: opaqueAgentId("claude", nativeId), provider: "claude", role: String(meta.agentType || "subagent"), parentId: "main", depth: Number(meta.spawnDepth || 1), status: "recorded", filePath, }); } return agents; } function codexMeta(filePath) { const row = readFirstJsonLine(filePath); const payload = row?.type === "session_meta" ? row.payload || {} : {}; const source = payload.source?.subagent?.thread_spawn || payload.source?.subagent || {}; return { nativeId: String(payload.id || payload.session_id || ""), parentNativeId: String(payload.parent_thread_id || source.parent_thread_id || ""), subagent: payload.thread_source === "subagent" || Boolean(payload.source?.subagent), role: String(payload.agent_nickname || payload.agent_path || "subagent"), depth: Number(source.depth || 1), }; } function codexAgents(target) { const mainPath = String(target?.nativeRef?.sessionPath || ""); if (!mainPath || !fs.existsSync(mainPath)) throw new Error("Codex transcript is not available on this computer"); const mainNativeId = String(target.nativeId || target.nativeRef?.threadId || codexMeta(mainPath).nativeId); const found = collectFiles(path.join(codexHome(), "sessions"), (name) => name.endsWith(".jsonl"), 10_000); const metas = found.map((filePath) => ({ filePath, ...codexMeta(filePath) })).filter((row) => row.nativeId); const descendants = new Set([mainNativeId]); let changed = true; while (changed) { changed = false; for (const row of metas) { if (row.subagent && descendants.has(row.parentNativeId) && !descendants.has(row.nativeId)) { descendants.add(row.nativeId); changed = true; } } } const idFor = (nativeId) => nativeId === mainNativeId ? "main" : opaqueAgentId("codex", nativeId); const agents = [{ id: "main", provider: "codex", role: "orchestrator", parentId: null, status: target.state, filePath: mainPath }]; for (const row of metas.filter((item) => item.subagent && descendants.has(item.nativeId))) { agents.push({ id: idFor(row.nativeId), provider: "codex", role: row.role, parentId: idFor(row.parentNativeId), depth: row.depth, status: "recorded", filePath: row.filePath, }); } return agents; } function sessionAgents(target) { if (target.provider === "claude") return claudeAgents(target); if (target.provider === "codex") return codexAgents(target); throw new Error(`Unsupported AI-session provider: ${target.provider}`); } function encodeCursor(agentId, before) { return Buffer.from(JSON.stringify({ v: 1, agentId, before })).toString("base64url"); } function decodeCursor(cursor, agentId, size) { if (!cursor) return size; try { const parsed = JSON.parse(Buffer.from(String(cursor), "base64url").toString("utf8")); if (parsed.v !== 1 || parsed.agentId !== agentId) throw new Error(); return Math.max(0, Math.min(size, Number(parsed.before) || 0)); } catch { throw new Error("Invalid transcript cursor"); } } async function scanBackwards(filePath, { provider, agentId, cursor, limit, maxChars, query = "" }) { const fd = await fs.promises.open(filePath, "r"); try { const size = (await fd.stat()).size; let end = decodeCursor(cursor, agentId, size); let carry = Buffer.alloc(0); let droppingOversize = false; let scanned = 0; let skippedOversize = 0; let resultChars = 0; const records = []; const needle = query.toLocaleLowerCase(); const rowNormalizer = provider === "claude" ? (row) => normalizeClaude(row, maxChars) : (row) => normalizeCodex(row, maxChars); while (end > 0 && scanned < MAX_SCAN_BYTES && records.length < limit) { const start = Math.max(0, end - CHUNK_BYTES); const chunk = Buffer.alloc(end - start); await fd.read(chunk, 0, chunk.length, start); scanned += chunk.length; let data = chunk; if (droppingOversize) { const boundary = data.lastIndexOf(0x0a); if (boundary < 0) { end = start; continue; } data = data.subarray(0, boundary + 1); droppingOversize = false; } else { data = Buffer.concat([data, carry]); } const newlines = []; for (let i = 0; i < data.length; i += 1) if (data[i] === 0x0a) newlines.push(i); if (!newlines.length && start > 0) { if (data.length > MAX_LINE_BYTES) { carry = Buffer.alloc(0); droppingOversize = true; skippedOversize += 1; } else carry = data; end = start; continue; } const starts = [0, ...newlines.map((index) => index + 1)]; const ends = [...newlines, data.length]; const firstComplete = start === 0 ? 0 : 1; for (let i = starts.length - 1; i >= firstComplete && records.length < limit && resultChars < MAX_RESULT_CHARS; i -= 1) { const bytes = data.subarray(starts[i], ends[i]); if (!bytes.length) continue; if (bytes.length > MAX_LINE_BYTES) { skippedOversize += 1; continue; } let row; try { row = JSON.parse(bytes.toString("utf8")); } catch { continue; } const normalized = rowNormalizer(row, maxChars); for (let j = normalized.length - 1; j >= 0; j -= 1) { const record = normalized[j]; const serialized = JSON.stringify(record); if (!needle || serialized.toLocaleLowerCase().includes(needle)) { if (records.length && resultChars + serialized.length > MAX_RESULT_CHARS) break; records.push(record); resultChars += serialized.length; } } if (records.length >= limit || resultChars >= MAX_RESULT_CHARS) { const before = start + starts[i]; return { records, nextCursor: before > 0 ? encodeCursor(agentId, before) : null, scannedBytes: scanned, skippedOversize }; } } carry = start === 0 ? Buffer.alloc(0) : data.subarray(0, newlines[0]); end = start; } return { records, nextCursor: end > 0 ? encodeCursor(agentId, end) : null, scannedBytes: scanned, skippedOversize, scanLimitReached: end > 0 && scanned >= MAX_SCAN_BYTES, }; } finally { await fd.close(); } } function publicAgent(agent) { const { filePath, ...safe } = agent; return safe; } /** Inspect one user-owned native Claude Code or Codex AI session. */ export async function inspectAiSession(target, input = {}) { const action = String(input.action || "read"); const agents = sessionAgents(target); if (action === "agents") { return { aiSessionId: target.id, provider: target.provider, agents: agents.map(publicAgent) }; } const agentId = String(input.agentId || "main"); const agent = agents.find((row) => row.id === agentId); if (!agent) throw new Error("AI-session agent not found"); const limit = Math.min(MAX_LIMIT, Math.max(1, Number(input.limit) || DEFAULT_LIMIT)); const maxChars = Math.min(MAX_ITEM_CHARS, Math.max(500, Number(input.maxCharsPerItem) || DEFAULT_ITEM_CHARS)); const query = action === "search" ? String(input.query || "").trim() : ""; if (action === "search" && !query) throw new Error("query is required for AI-session search"); const page = await scanBackwards(agent.filePath, { provider: target.provider, agentId, cursor: input.cursor, limit, maxChars, query }); return { aiSessionId: target.id, provider: target.provider, agent: publicAgent(agent), order: "newest_first", ...(query ? { query } : {}), ...page, }; }