Repository Public
1/**2 * Claude Code introspection (runtime-fs): /claude/info and /claude/conversations.3 * Read-only. Never starts a conversation, never returns account identifiers or tokens.4 *5 * Observed formats (claude 2.1.x on macOS):6 * - `claude auth status --json` → {"loggedIn":bool,"authMethod":"api_key"|"none"|…,7 * "apiProvider":…,"email":…,"orgId":…,"orgName":…,…}; exit code 0 in both states.8 * Only `loggedIn`, `authMethod` and `subscriptionType` are ever read.9 * - transcripts live in `<configDir>/projects/<cwd with every non-alphanumeric char → "-">/<uuid>.jsonl`.10 */11import { promises as fsp } from 'node:fs';12import path from 'node:path';13import { z } from 'zod';14import type { ClaudeConversation } from '@cr/shared';15import type { RuntimeConfig } from './config.js';16import { RtError, type RuntimePlugin } from './plugins.js';17import { assertRootAllowed } from './paths.js';18import { defaultClaudeEnv, resolveClaudeBinary, runClaude, type ClaudeEnv } from './fsx-claude-env.js';19import { listCodexSessions, parseCodexLoginStatus, parseCodexVersion } from './codex.js';2021export const MAX_TRANSCRIPT_READ = 4 * 1024 * 1024;22const MAX_CONVERSATIONS = 50;23const SUMMARY_CHARS = 160;24const INFO_TTL_MS = 30_000;2526export interface ClaudeOptions {27 env?: ClaudeEnv;28}2930export interface ClaudeInfo {31 path: string | null;32 version: string | null;33 authenticated: boolean | null;34 /** Non-sensitive label such as "api_key" or "claude.ai"; never an account identifier. */35 method: string | null;36 subscription: string | null;37 /** Absolute configuration directory of the agent (`~/.claude` / `$CLAUDE_CONFIG_DIR`, or `$CODEX_HOME`). */38 configDir: string;39 /** Set to `codex` by a Codex runtime; absent for Claude (unchanged payload). */40 agent?: 'claude' | 'codex';41}4243/** `/a/b_c.d e` → `-a-b-c-d-e` (every non-alphanumeric character becomes a dash). */44export function encodeProjectDir(cwd: string): string {45 return cwd.replace(/[^A-Za-z0-9]/g, '-');46}4748const LABEL = /^[A-Za-z0-9 ._-]{1,40}$/;4950/** Map `claude auth status` output to a boolean + non-sensitive labels. */51export function parseAuthStatus(stdout: string): Pick<ClaudeInfo, 'authenticated' | 'method' | 'subscription'> {52 const unknown = { authenticated: null, method: null, subscription: null };53 const start = stdout.indexOf('{');54 const end = stdout.lastIndexOf('}');55 if (start >= 0 && end > start) {56 try {57 const obj = JSON.parse(stdout.slice(start, end + 1)) as Record<string, unknown>;58 if (typeof obj.loggedIn !== 'boolean') return unknown;59 const label = (v: unknown): string | null => (typeof v === 'string' && LABEL.test(v) ? v : null);60 return { authenticated: obj.loggedIn, method: label(obj.authMethod), subscription: label(obj.subscriptionType) };61 } catch {62 return unknown;63 }64 }65 // Human-readable fallback (older CLIs): only unambiguous phrases are trusted.66 if (/\bnot logged in\b/i.test(stdout)) return { authenticated: false, method: null, subscription: null };67 if (/\blogged in\b/i.test(stdout)) return { authenticated: true, method: null, subscription: null };68 return unknown;69}7071/** Codex profile: `codex --version` + `codex login status` (which prints on stderr). */72async function collectCodexInfo(config: RuntimeConfig, env: ClaudeEnv): Promise<ClaudeInfo> {73 const info: ClaudeInfo = {74 path: (await resolveClaudeBinary(config, env)).path,75 version: null,76 authenticated: null,77 method: null,78 subscription: null,79 configDir: env.codexHome,80 agent: 'codex',81 };82 if (!info.path) return info;83 const [ver, auth] = await Promise.all([84 runClaude(config, env, ['--version'], { timeoutMs: 15_000, maxStdout: 4096 }),85 runClaude(config, env, ['login', 'status'], { timeoutMs: 20_000, maxStdout: 16 * 1024 }),86 ]);87 if (ver && ver.code === 0) info.version = parseCodexVersion(ver.stdout);88 // The status line may end with a masked API key: only the parsed boolean and label are kept.89 if (auth && !auth.timedOut && !auth.spawnError) Object.assign(info, parseCodexLoginStatus(`${auth.stdout}\n${auth.stderr}`));90 return info;91}9293async function collectInfo(config: RuntimeConfig, env: ClaudeEnv): Promise<ClaudeInfo> {94 if (config.agent === 'codex') return collectCodexInfo(config, env);95 const info: ClaudeInfo = {96 path: (await resolveClaudeBinary(config, env)).path,97 version: null,98 authenticated: null,99 method: null,100 subscription: null,101 configDir: env.configDir,102 };103 if (!info.path) return info;104 const [ver, auth] = await Promise.all([105 runClaude(config, env, ['--version'], { timeoutMs: 15_000, maxStdout: 4096 }),106 runClaude(config, env, ['auth', 'status', '--json'], { timeoutMs: 20_000, maxStdout: 64 * 1024 }),107 ]);108 if (ver && ver.code === 0) {109 const m = /\d+\.\d+\.\d+[\w.-]*/.exec(ver.stdout);110 info.version = m ? m[0] : null;111 }112 if (auth && !auth.timedOut && !auth.spawnError) Object.assign(info, parseAuthStatus(auth.stdout));113 return info;114}115116/** Is this transcript text a real prompt typed by the user (not a command echo / injected block)? */117function cleanPrompt(text: string): string | null {118 let t = text.trim();119 // Strip leading injected blocks, then refuse command/meta wrappers entirely.120 for (;;) {121 const m = /^<system-reminder>[\s\S]*?<\/system-reminder>\s*/.exec(t);122 if (!m) break;123 t = t.slice(m[0].length);124 }125 if (t === '') return null;126 if (/^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|system-reminder|bash-input|bash-stdout|bash-stderr|user-memory-input|task-notification)\b/.test(t)) return null;127 if (t.startsWith('Caveat: The messages below were generated by the user while running local commands')) return null;128 if (t.startsWith('[Request interrupted')) return null;129 const flat = t.replace(/\s+/g, ' ');130 return flat.length > SUMMARY_CHARS ? `${flat.slice(0, SUMMARY_CHARS - 1)}…` : flat;131}132133function userPrompt(entry: unknown): string | null {134 if (typeof entry !== 'object' || entry === null) return null;135 const e = entry as Record<string, unknown>;136 if (e.type !== 'user' || e.isMeta === true || e.isSidechain === true || e.isCompactSummary === true) return null;137 const msg = e.message as Record<string, unknown> | undefined;138 if (!msg || msg.role !== 'user') return null;139 if (typeof msg.content === 'string') return cleanPrompt(msg.content);140 if (Array.isArray(msg.content)) {141 for (const block of msg.content as unknown[]) {142 const b = block as Record<string, unknown> | null;143 if (b && b.type === 'text' && typeof b.text === 'string') {144 const p = cleanPrompt(b.text);145 if (p) return p;146 }147 }148 }149 return null;150}151152/** Read at most MAX_TRANSCRIPT_READ bytes; count the lines read and find the first user prompt. */153export async function readTranscriptMeta(file: string, cap = MAX_TRANSCRIPT_READ): Promise<{ summary: string; messageCount: number }> {154 const fh = await fsp.open(file, 'r');155 try {156 const size = (await fh.stat()).size;157 const want = Math.min(size, cap);158 const buf = Buffer.alloc(want);159 let got = 0;160 while (got < want) {161 const { bytesRead } = await fh.read(buf, got, want - got, got);162 if (bytesRead === 0) break;163 got += bytesRead;164 }165 const text = buf.subarray(0, got).toString('utf8');166 const lines = text.split('\n');167 // When the read stopped at the cap, the last piece is an incomplete line: not counted.168 if (got < size) lines.pop();169 let summary = '';170 let count = 0;171 for (const line of lines) {172 if (line.trim() === '') continue;173 count++;174 if (summary !== '' || !line.includes('"user"')) continue;175 try {176 summary = userPrompt(JSON.parse(line)) ?? '';177 } catch {178 /* malformed line: counted, not summarised */179 }180 }181 return { summary, messageCount: count };182 } finally {183 await fh.close();184 }185}186187export async function listConversations(projectsDir: string, cwds: string[]): Promise<ClaudeConversation[]> {188 const found = new Map<string, { file: string; mtimeMs: number }>();189 for (const cwd of new Set(cwds)) {190 const dir = path.join(projectsDir, encodeProjectDir(cwd));191 let names: string[];192 try {193 names = await fsp.readdir(dir);194 } catch {195 continue;196 }197 for (const name of names) {198 if (!name.endsWith('.jsonl')) continue;199 const file = path.join(dir, name);200 const st = await fsp.lstat(file).catch(() => null);201 if (!st?.isFile()) continue; // symlinks and directories are ignored202 const id = name.slice(0, -'.jsonl'.length);203 const prev = found.get(id);204 if (!prev || prev.mtimeMs < st.mtimeMs) found.set(id, { file, mtimeMs: st.mtimeMs });205 }206 }207 const newest = [...found.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs).slice(0, MAX_CONVERSATIONS);208 const out: ClaudeConversation[] = [];209 for (const [id, f] of newest) {210 const meta = await readTranscriptMeta(f.file).catch(() => null);211 if (!meta) continue;212 out.push({ id, summary: meta.summary, updatedAt: new Date(f.mtimeMs).toISOString(), messageCount: meta.messageCount });213 }214 return out;215}216217const ConversationsBody = z.object({ cwd: z.string().min(1).max(4096) });218219export function createClaudePlugin(opts: ClaudeOptions = {}): RuntimePlugin {220 return async (app, { config }) => {221 const env = opts.env ?? defaultClaudeEnv();222 let cached: { at: number; value: Promise<ClaudeInfo> } | null = null;223224 app.get('/claude/info', async () => {225 if (!cached || Date.now() - cached.at > INFO_TTL_MS) {226 cached = { at: Date.now(), value: collectInfo(config, env) };227 cached.value.catch(() => {228 cached = null;229 });230 }231 return cached.value;232 });233234 app.post('/claude/conversations', async (req) => {235 const parsed = ConversationsBody.safeParse(req.body);236 if (!parsed.success) throw new RtError(400, 'bad_request', 'cwd is required');237 const cwd = parsed.data.cwd;238 if (!path.isAbsolute(cwd)) throw new RtError(400, 'bad_request', 'cwd must be absolute');239 const real = await assertRootAllowed(config, cwd);240 // Both CLIs record the process cwd (a real path); the lexical form is tried as well.241 if (config.agent === 'codex') return listCodexSessions(path.join(env.codexHome, 'sessions'), [real, path.resolve(cwd)]);242 return listConversations(path.join(env.configDir, 'projects'), [real, path.resolve(cwd)]);243 });244 };245}246247export default createClaudePlugin();248