UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

1,456 lines (1,431 loc) 757 kB
'use strict'; var chalk = require('chalk'); var fs = require('node:fs'); var os = require('node:os'); var path = require('node:path'); var node_crypto = require('node:crypto'); var persistence = require('./types-B_i6lpTn.cjs'); var node_child_process = require('node:child_process'); var readline = require('node:readline'); var fs$3 = require('node:fs/promises'); var fs$1 = require('fs/promises'); var path$1 = require('path'); var fs$2 = require('fs'); var ink = require('ink'); var React = require('react'); var node_url = require('node:url'); var axios = require('axios'); var node_events = require('node:events'); var socket_ioClient = require('socket.io-client'); var tweetnacl = require('tweetnacl'); require('expo-server-sdk'); var node_util = require('node:util'); var crypto = require('crypto'); var child_process = require('child_process'); var psList = require('ps-list'); var spawn = require('cross-spawn'); var os$1 = require('os'); var tmp = require('tmp'); var qrcode = require('qrcode-terminal'); var open = require('open'); var fastify = require('fastify'); var z = require('zod'); var fastifyTypeProviderZod = require('fastify-type-provider-zod'); var node_module = require('node:module'); var http = require('http'); var util = require('util'); var index_js = require('@modelcontextprotocol/sdk/client/index.js'); var streamableHttp_js = require('@modelcontextprotocol/sdk/client/streamableHttp.js'); var readline$1 = require('readline'); var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js'); var node_http = require('node:http'); var streamableHttp_js$1 = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs); var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os); var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path); var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1); var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1); var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto); var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1); var tmp__namespace = /*#__PURE__*/_interopNamespaceDefault(tmp); var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline$1); const UNICODE = { success: "\u2713", failure: "\u2717", warning: "\u26A0", info: "\u2139", rocket: "\u{1F680}", user: "\u{1F464}", bot: "\u{1F916}", tool: "\u{1F527}", sparkles: "\u2728", chart: "\u{1F4CA}", desktop: "\u{1F4BB}", eyes: "\u{1F440}", pointRight: "\u{1F449}", stethoscope: "\u{1FA7A}", clipboard: "\u{1F4CB}", wrench: "\u{1F527}", globe: "\u{1F30D}", page: "\u{1F4C4}", lock: "\u{1F510}", gear: "\u2699", hLine: "\u2500", hLineDouble: "\u2550" }; const ASCII = { success: "[OK]", failure: "[X]", warning: "[!]", info: "[i]", rocket: ">>", user: "User:", bot: "Bot:", tool: "*", sparkles: "*", chart: "#", desktop: "[PC]", eyes: "?", pointRight: "->", stethoscope: "Rx", clipboard: "[=]", wrench: "*", globe: "@", page: "=", lock: "#", gear: "*", hLine: "-", hLineDouble: "=" }; let resolved = false; let active = UNICODE; let ascii = false; function initGlyphs() { if (resolved) return; resolved = true; const argv = process.argv; const flagIdx = argv.indexOf("--ascii"); if (flagIdx !== -1) { argv.splice(flagIdx, 1); ascii = true; } const env = process.env; if (!ascii && env.CONSORTIUM_ASCII === "1") ascii = true; if (!ascii) { const locale = (env.LC_ALL || env.LC_CTYPE || env.LANG || "").toLowerCase(); if (locale && !locale.includes("utf-8") && !locale.includes("utf8")) { ascii = true; } } if (!ascii && process.platform === "win32") { if (!env.WT_SESSION && !env.TERM_PROGRAM) ascii = true; } active = ascii ? ASCII : UNICODE; } const glyphs = new Proxy({}, { get(_t, key) { if (!resolved) initGlyphs(); return active[key]; } }); const SCHEMA_VERSION = 1; const STDERR_RING_BUFFER_BYTES = 8 * 1024; const MAX_FILE_BYTES = 64 * 1024; const MAX_STACK_TAIL_BYTES = 2 * 1024; let installed$1 = false; let startTimeNs = 0n; let stderrBuffer = Buffer.alloc(0); let latestError = null; const packageInfo = readPackageInfo(); function readPackageInfo() { try { const candidates = [ // When bundled into dist/, package.json sits one level up path__namespace.resolve(process.cwd(), "package.json") ]; try { const u = typeof ({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index-BMIckAk5.cjs', document.baseURI).href)) }) !== "undefined" ? (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index-BMIckAk5.cjs', document.baseURI).href)) : void 0; if (u && typeof u === "string" && u.startsWith("file://")) { const here = new URL(u).pathname; candidates.unshift(path__namespace.resolve(path__namespace.dirname(here), "..", "package.json")); candidates.unshift(path__namespace.resolve(path__namespace.dirname(here), "..", "..", "package.json")); } } catch { } for (const p of candidates) { try { const raw = fs__namespace.readFileSync(p, "utf8"); const j = JSON.parse(raw); if (j && typeof j.version === "string" && typeof j.name === "string") { return { version: j.version, name: j.name }; } } catch { } } } catch { } return { version: "unknown", name: "consortium" }; } function consortiumHomeDir() { const env = process.env.CONSORTIUM_HOME_DIR; if (env) { return env.startsWith("~") ? path__namespace.join(os__namespace.homedir(), env.slice(1)) : env; } return path__namespace.join(os__namespace.homedir(), ".consortium"); } function lastExitFilePath() { return path__namespace.join(consortiumHomeDir(), "logs", "last-exit.json"); } function ensureLogsDir() { try { const dir = path__namespace.join(consortiumHomeDir(), "logs"); fs__namespace.mkdirSync(dir, { recursive: true, mode: 448 }); } catch { } } function detectGlibcVersion$1() { if (process.platform !== "linux") return null; try { const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null; const v = report?.header?.glibcVersionRuntime; if (typeof v === "string" && v.length > 0) return v; } catch { } return null; } function tailString(s, maxBytes) { if (!s) return s; const buf = Buffer.from(s, "utf8"); if (buf.byteLength <= maxBytes) return s; return buf.subarray(buf.byteLength - maxBytes).toString("utf8"); } function appendStderrRing(chunk) { try { const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk); if (!buf || buf.byteLength === 0) return; if (buf.byteLength >= STDERR_RING_BUFFER_BYTES) { stderrBuffer = Buffer.from(buf.subarray(buf.byteLength - STDERR_RING_BUFFER_BYTES)); return; } const combined = Buffer.concat([stderrBuffer, buf]); stderrBuffer = combined.byteLength > STDERR_RING_BUFFER_BYTES ? Buffer.from(combined.subarray(combined.byteLength - STDERR_RING_BUFFER_BYTES)) : combined; } catch { } } function patchStderr() { try { const originalWrite = process.stderr.write.bind(process.stderr); process.stderr.write = function patched(chunk, encoding, cb) { try { if (typeof encoding === "string" && typeof chunk === "string") { appendStderrRing(Buffer.from(chunk, encoding)); } else if (typeof chunk === "string" || Buffer.isBuffer(chunk)) { appendStderrRing(chunk); } } catch { } return originalWrite(chunk, encoding, cb); }; } catch { } } function buildPayload(code, signal, err) { const durationMs = (() => { try { if (!startTimeNs) return 0; const elapsed = process.hrtime.bigint() - startTimeNs; return Number(elapsed / 1000000n); } catch { return 0; } })(); const merged = err ?? (latestError ? { name: latestError.name, message: latestError.message, stack: latestError.stack } : null); const payload = { schemaVersion: SCHEMA_VERSION, code: typeof code === "number" ? code : null, signal: signal ?? null, args: Array.isArray(process.argv) ? process.argv.slice(2) : [], subcommand: Array.isArray(process.argv) && process.argv.length >= 3 ? process.argv[2] : null, nodeVersion: process.version, platform: `${process.platform}-${process.arch}`, arch: process.arch, glibcVersion: detectGlibcVersion$1(), isTTY: Boolean(process.stdout && process.stdout.isTTY), durationMs, exitedAt: (/* @__PURE__ */ new Date()).toISOString(), cliVersion: packageInfo.version, cliPackageName: packageInfo.name, errorName: merged ? merged.name ?? "Error" : null, errorMessage: merged ? merged.message ?? "" : null, errorStack: merged ? tailString(String(merged.stack ?? ""), MAX_STACK_TAIL_BYTES) : null, stderrTail: stderrBuffer.length > 0 ? stderrBuffer.toString("utf8") : "" }; return payload; } function serializeWithCap(payload) { const dropOrder = ["stderrTail", "errorStack"]; let current = { ...payload }; for (let i = 0; i <= dropOrder.length; i++) { let json; try { json = JSON.stringify(current, null, 2); } catch { json = "{}"; } if (Buffer.byteLength(json, "utf8") <= MAX_FILE_BYTES) return json; const toDrop = dropOrder[i]; if (!toDrop) break; current = { ...current, [toDrop]: null }; } try { return JSON.stringify(current).slice(0, MAX_FILE_BYTES); } catch { return "{}"; } } function writeLastExit(code, err, signal = null) { try { ensureLogsDir(); const payload = buildPayload(code, signal, err); const json = serializeWithCap(payload); const target = lastExitFilePath(); fs__namespace.writeFileSync(target, json, { mode: 384 }); } catch { } } function installCrashReporter() { if (installed$1) return; installed$1 = true; try { startTimeNs = process.hrtime.bigint(); } catch { startTimeNs = 0n; } patchStderr(); const captureError = (err) => { try { if (err instanceof Error) { latestError = { name: err.name || "Error", message: err.message || "", stack: err.stack || "" }; } else { latestError = { name: "NonError", message: String(err), stack: "" }; } } catch { } }; process.on("uncaughtException", captureError); process.on("unhandledRejection", captureError); try { process.stdout.on("error", captureError); } catch { } try { process.stderr.on("error", captureError); } catch { } process.prependListener("exit", (code) => { writeLastExit(code ?? 0, null, null); }); } class Session { path; logPath; api; client; queue; claudeEnvVars; claudeArgs; // Made mutable to allow filtering mcpServers; allowedTools; _onModeChange; /** Path to temporary settings file with SessionStart hook (required for session tracking) */ hookSettingsPath; /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ jsRuntime; sessionId; mode = "local"; thinking = false; /** Callbacks to be notified when session ID is found/changed */ sessionFoundCallbacks = []; /** Keep alive interval reference for cleanup */ keepAliveInterval; constructor(opts) { this.path = opts.path; this.api = opts.api; this.client = opts.client; this.logPath = opts.logPath; this.sessionId = opts.sessionId; this.queue = opts.messageQueue; this.claudeEnvVars = opts.claudeEnvVars; this.claudeArgs = opts.claudeArgs; this.mcpServers = opts.mcpServers; this.allowedTools = opts.allowedTools; this._onModeChange = opts.onModeChange; this.hookSettingsPath = opts.hookSettingsPath; this.jsRuntime = opts.jsRuntime ?? "node"; this.client.keepAlive(this.thinking, this.mode); this.keepAliveInterval = setInterval(() => { this.client.keepAlive(this.thinking, this.mode); }, 2e3); } /** * Cleanup resources (call when session is no longer needed) */ cleanup = () => { clearInterval(this.keepAliveInterval); this.sessionFoundCallbacks = []; persistence.logger.debug("[Session] Cleaned up resources"); }; onThinkingChange = (thinking) => { this.thinking = thinking; this.client.keepAlive(thinking, this.mode); }; onModeChange = (mode) => { this.mode = mode; this.client.keepAlive(this.thinking, mode); this._onModeChange(mode); }; /** * Called when Claude session ID is discovered or changed. * * This is triggered by the SessionStart hook when: * - Claude starts a new session (fresh start) * - Claude resumes a session (--continue, --resume flags) * - Claude forks a session (/compact, double-escape fork) * * Updates internal state, syncs to API metadata, and notifies * all registered callbacks (e.g., SessionScanner) about the change. */ onSessionFound = (sessionId) => { this.sessionId = sessionId; this.client.updateMetadata((metadata) => ({ ...metadata, claudeSessionId: sessionId, agentSessionId: sessionId })); persistence.logger.debug(`[Session] Claude Code session ID ${sessionId} added to metadata`); for (const callback of this.sessionFoundCallbacks) { callback(sessionId); } }; /** * Register a callback to be notified when session ID is found/changed */ addSessionFoundCallback = (callback) => { this.sessionFoundCallbacks.push(callback); }; /** * Remove a session found callback */ removeSessionFoundCallback = (callback) => { const index = this.sessionFoundCallbacks.indexOf(callback); if (index !== -1) { this.sessionFoundCallbacks.splice(index, 1); } }; /** * Clear the current session ID (used by /clear command) */ clearSessionId = () => { this.sessionId = null; persistence.logger.debug("[Session] Session ID cleared"); }; /** * Consume one-time Claude flags from claudeArgs after Claude spawn * Handles: --resume (with or without session ID), --continue */ consumeOneTimeFlags = () => { if (!this.claudeArgs) return; const filteredArgs = []; for (let i = 0; i < this.claudeArgs.length; i++) { const arg = this.claudeArgs[i]; if (arg === "--continue") { persistence.logger.debug("[Session] Consumed --continue flag"); continue; } if (arg === "--resume") { if (i + 1 < this.claudeArgs.length) { const nextArg = this.claudeArgs[i + 1]; if (!nextArg.startsWith("-") && nextArg.includes("-")) { i++; persistence.logger.debug(`[Session] Consumed --resume flag with session ID: ${nextArg}`); } else { persistence.logger.debug("[Session] Consumed --resume flag (no session ID)"); } } else { persistence.logger.debug("[Session] Consumed --resume flag (no session ID)"); } continue; } filteredArgs.push(arg); } this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : void 0; persistence.logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); }; } function getProjectPath(workingDirectory) { const projectId = path.resolve(workingDirectory).replace(/[\\\/\.: _]/g, "-"); const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); return path.join(claudeConfigDir, "projects", projectId); } function claudeCheckSession(sessionId, path$1) { const projectDir = getProjectPath(path$1); const sessionFile = path.join(projectDir, `${sessionId}.jsonl`); const sessionExists = fs.existsSync(sessionFile); if (!sessionExists) { persistence.logger.debug(`[claudeCheckSession] Path ${sessionFile} does not exist`); return false; } const sessionData = fs.readFileSync(sessionFile, "utf-8").split("\n"); const hasGoodMessage = !!sessionData.find((v, index) => { if (!v.trim()) return false; try { const parsed = JSON.parse(v); return typeof parsed.uuid === "string" && parsed.uuid.length > 0 || // Claude Code 2.1.x typeof parsed.messageId === "string" && parsed.messageId.length > 0 || // Older Claude Code typeof parsed.leafUuid === "string" && parsed.leafUuid.length > 0; } catch (e) { persistence.logger.debug(`[claudeCheckSession] Malformed JSON at line ${index + 1}:`, e); return false; } }); persistence.logger.debug(`[claudeCheckSession] Session ${sessionId}: ${hasGoodMessage ? "valid" : "invalid"}`); return hasGoodMessage; } function claudeFindLastSession(workingDirectory) { try { const projectDir = getProjectPath(workingDirectory); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const files = fs.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => { const sessionId = f.replace(".jsonl", ""); if (!uuidPattern.test(sessionId)) { return null; } if (claudeCheckSession(sessionId, workingDirectory)) { return { name: f, sessionId, mtime: fs.statSync(path.join(projectDir, f)).mtime.getTime() }; } return null; }).filter((f) => f !== null).sort((a, b) => b.mtime - a.mtime); return files.length > 0 ? files[0].sessionId : null; } catch (e) { persistence.logger.debug("[claudeFindLastSession] Error finding sessions:", e); return null; } } function trimIdent(text) { const lines = text.split("\n"); while (lines.length > 0 && lines[0].trim() === "") { lines.shift(); } while (lines.length > 0 && lines[lines.length - 1].trim() === "") { lines.pop(); } const minSpaces = lines.reduce((min, line) => { if (line.trim() === "") { return min; } const leadingSpaces = line.match(/^\s*/)[0].length; return Math.min(min, leadingSpaces); }, Infinity); const trimmedLines = lines.map((line) => line.slice(minSpaces)); return trimmedLines.join("\n"); } function getClaudeSettingsPath() { const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); return path.join(claudeConfigDir, "settings.json"); } function readClaudeSettings() { try { const settingsPath = getClaudeSettingsPath(); if (!fs.existsSync(settingsPath)) { persistence.logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`); return null; } const settingsContent = fs.readFileSync(settingsPath, "utf-8"); const settings = JSON.parse(settingsContent); persistence.logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`); persistence.logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`); return settings; } catch (error) { persistence.logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`); return null; } } function shouldIncludeCoAuthoredBy() { const settings = readClaudeSettings(); if (!settings || settings.includeCoAuthoredBy === void 0) { return true; } return settings.includeCoAuthoredBy; } const CO_AUTHORED_CREDITS = (() => trimIdent(` When making commit messages, instead of just giving co-credit to Claude, also give credit to Consortium like so: <main commit message> Generated with [Claude Code](https://claude.ai/code) via [Consortium](https://consortium.dev) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Consortium <yesreply@consortium.dev> `))(); const systemPrompt = (() => { return shouldIncludeCoAuthoredBy() ? CO_AUTHORED_CREDITS : ""; })(); class ExitCodeError extends Error { exitCode; constructor(exitCode) { super(`Process exited with code: ${exitCode}`); this.name = "ExitCodeError"; this.exitCode = exitCode; } } const claudeCliPath = path.resolve(path.join(persistence.projectPath(), "scripts", "claude_local_launcher.cjs")); async function claudeLocal(opts) { const projectDir = getProjectPath(opts.path); fs.mkdirSync(projectDir, { recursive: true }); const hasContinueFlag = opts.claudeArgs?.includes("--continue"); const hasResumeFlag = opts.claudeArgs?.includes("--resume"); const hasUserSessionControl = hasContinueFlag || hasResumeFlag; let startFrom = opts.sessionId; const extractFlag = (flags, withValue = false) => { if (!opts.claudeArgs) return { found: false }; for (const flag of flags) { const index = opts.claudeArgs.indexOf(flag); if (index !== -1) { if (withValue && index + 1 < opts.claudeArgs.length) { const nextArg = opts.claudeArgs[index + 1]; if (!nextArg.startsWith("-")) { const value = nextArg; opts.claudeArgs = opts.claudeArgs.filter((_, i) => i !== index && i !== index + 1); return { found: true, value }; } } if (!withValue) { opts.claudeArgs = opts.claudeArgs.filter((_, i) => i !== index); return { found: true }; } return { found: false }; } } return { found: false }; }; const sessionIdFlag = extractFlag(["--session-id"], true); if (sessionIdFlag.found && sessionIdFlag.value) { startFrom = null; persistence.logger.debug(`[ClaudeLocal] Using explicit --session-id: ${sessionIdFlag.value}`); } if (!startFrom && !sessionIdFlag.value) { const resumeFlag = extractFlag(["--resume", "-r"], true); if (resumeFlag.found) { if (resumeFlag.value) { startFrom = resumeFlag.value; persistence.logger.debug(`[ClaudeLocal] Using provided session ID from --resume: ${startFrom}`); } else { const lastSession = claudeFindLastSession(opts.path); if (lastSession) { startFrom = lastSession; persistence.logger.debug(`[ClaudeLocal] --resume: Found last session: ${lastSession}`); } } } } if (!startFrom && !sessionIdFlag.value) { const continueFlag = extractFlag(["--continue", "-c"], false); if (continueFlag.found) { const lastSession = claudeFindLastSession(opts.path); if (lastSession) { startFrom = lastSession; persistence.logger.debug(`[ClaudeLocal] --continue: Found last session: ${lastSession}`); } } } const explicitSessionId = sessionIdFlag.value || null; let newSessionId = null; let effectiveSessionId = startFrom; if (!opts.hookSettingsPath) { newSessionId = startFrom ? null : explicitSessionId || node_crypto.randomUUID(); effectiveSessionId = startFrom || newSessionId; if (startFrom) { persistence.logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`); opts.onSessionFound(startFrom); } else if (explicitSessionId) { persistence.logger.debug(`[ClaudeLocal] Using explicit session ID: ${explicitSessionId}`); opts.onSessionFound(explicitSessionId); } else { persistence.logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); opts.onSessionFound(newSessionId); } } else { if (startFrom) { persistence.logger.debug(`[ClaudeLocal] Will resume existing session: ${startFrom}`); } else if (hasUserSessionControl) { persistence.logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? "--continue" : "--resume"} flag, session ID will be determined by hook`); } else { persistence.logger.debug(`[ClaudeLocal] Fresh start, session ID will be provided by hook`); } } let thinking = false; let stopThinkingTimeout = null; const updateThinking = (newThinking) => { if (thinking !== newThinking) { thinking = newThinking; persistence.logger.debug(`[ClaudeLocal] Thinking state changed to: ${thinking}`); if (opts.onThinkingChange) { opts.onThinkingChange(thinking); } } }; try { process.stdin.pause(); await new Promise((r, reject) => { const args = []; if (!opts.hookSettingsPath) { const hasResumeFlag2 = opts.claudeArgs?.includes("--resume") || opts.claudeArgs?.includes("-r"); if (startFrom) { args.push("--resume", startFrom); } else if (!hasResumeFlag2 && newSessionId) { args.push("--session-id", newSessionId); } } else { if (startFrom) { args.push("--resume", startFrom); } } args.push("--append-system-prompt", systemPrompt); if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) { args.push("--mcp-config", JSON.stringify({ mcpServers: opts.mcpServers })); } if (opts.allowedTools && opts.allowedTools.length > 0) { args.push("--allowedTools", opts.allowedTools.join(",")); } if (opts.claudeArgs) { args.push(...opts.claudeArgs); } if (opts.hookSettingsPath) { args.push("--settings", opts.hookSettingsPath); persistence.logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); } if (!claudeCliPath || !fs.existsSync(claudeCliPath)) { throw new Error("Claude local launcher not found. Please ensure CONSORTIUM_PROJECT_ROOT is set correctly for development."); } const env = { ...process.env, ...opts.claudeEnvVars }; persistence.logger.debug(`[ClaudeLocal] Spawning launcher: ${claudeCliPath}`); persistence.logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`); const child = node_child_process.spawn(process.execPath, [claudeCliPath, ...args], { stdio: ["inherit", "inherit", "inherit", "pipe"], signal: opts.abort, cwd: opts.path, env }); if (child.stdio[3]) { const rl = readline.createInterface({ input: child.stdio[3], crlfDelay: Infinity }); const activeFetches = /* @__PURE__ */ new Map(); rl.on("line", (line) => { try { const message = JSON.parse(line); switch (message.type) { case "fetch-start": activeFetches.set(message.id, { hostname: message.hostname, path: message.path, startTime: message.timestamp }); if (stopThinkingTimeout) { clearTimeout(stopThinkingTimeout); stopThinkingTimeout = null; } updateThinking(true); break; case "fetch-end": activeFetches.delete(message.id); if (activeFetches.size === 0 && thinking && !stopThinkingTimeout) { stopThinkingTimeout = setTimeout(() => { if (activeFetches.size === 0) { updateThinking(false); } stopThinkingTimeout = null; }, 500); } break; default: persistence.logger.debug(`[ClaudeLocal] Unknown message type: ${message.type}`); } } catch (e) { persistence.logger.debug(`[ClaudeLocal] Non-JSON line from fd3: ${line}`); } }); rl.on("error", (err) => { console.error("Error reading from fd 3:", err); }); child.on("exit", () => { if (stopThinkingTimeout) { clearTimeout(stopThinkingTimeout); } updateThinking(false); }); } child.on("error", (error) => { }); child.on("exit", (code, signal) => { if (signal === "SIGTERM" && opts.abort.aborted) { r(); } else if (signal) { reject(new Error(`Process terminated with signal: ${signal}`)); } else if (code !== 0 && code !== null) { reject(new ExitCodeError(code)); } else { r(); } }); }); } finally { process.stdin.resume(); if (stopThinkingTimeout) { clearTimeout(stopThinkingTimeout); stopThinkingTimeout = null; } updateThinking(false); } return effectiveSessionId; } class Future { _resolve; _reject; _promise; constructor() { this._promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } resolve(value) { this._resolve(value); } reject(reason) { this._reject(reason); } get promise() { return this._promise; } } class InvalidateSync { _invalidated = false; _invalidatedDouble = false; _stopped = false; _command; _pendings = []; constructor(command) { this._command = command; } invalidate() { if (this._stopped) { return; } if (!this._invalidated) { this._invalidated = true; this._invalidatedDouble = false; this._doSync(); } else { if (!this._invalidatedDouble) { this._invalidatedDouble = true; } } } async invalidateAndAwait() { if (this._stopped) { return; } await new Promise((resolve) => { this._pendings.push(resolve); this.invalidate(); }); } stop() { if (this._stopped) { return; } this._notifyPendings(); this._stopped = true; } _notifyPendings = () => { for (let pending of this._pendings) { pending(); } this._pendings = []; }; _doSync = async () => { await persistence.backoff(async () => { if (this._stopped) { return; } await this._command(); }); if (this._stopped) { this._notifyPendings(); return; } if (this._invalidatedDouble) { this._invalidatedDouble = false; this._doSync(); } else { this._invalidated = false; this._notifyPendings(); } }; } function startFileWatcher(file, onFileChange) { const abortController = new AbortController(); const parentDir = path$1.dirname(file); const fileBasename = path$1.basename(file); async function waitForFileToAppear() { if (fs$2.existsSync(file)) return true; if (!fs$2.existsSync(parentDir)) { persistence.logger.debug(`[FILE_WATCHER] Parent directory ${parentDir} does not exist, polling for it`); while (!abortController.signal.aborted && !fs$2.existsSync(parentDir)) { await persistence.delay(5e3); } if (abortController.signal.aborted) return false; if (fs$2.existsSync(file)) return true; } persistence.logger.debug(`[FILE_WATCHER] File ${file} does not exist yet, watching parent directory ${parentDir}`); try { const dirWatcher = fs$1.watch(parentDir, { persistent: true, signal: abortController.signal }); const pollInterval = setInterval(() => { if (fs$2.existsSync(file)) { } }, 2e3); try { for await (const event of dirWatcher) { if (abortController.signal.aborted) return false; if (event.filename === fileBasename || event.filename === null) { if (fs$2.existsSync(file)) { persistence.logger.debug(`[FILE_WATCHER] File ${file} appeared, switching to direct watch`); return true; } } } } finally { clearInterval(pollInterval); } } catch (e) { if (abortController.signal.aborted) return false; persistence.logger.debug(`[FILE_WATCHER] Failed to watch parent directory ${parentDir}: ${e.message}, falling back to polling`); while (!abortController.signal.aborted) { if (fs$2.existsSync(file)) return true; await persistence.delay(2e3); } } return false; } void (async () => { let consecutiveErrors = 0; while (!abortController.signal.aborted) { try { const appeared = await waitForFileToAppear(); if (!appeared || abortController.signal.aborted) return; persistence.logger.debug(`[FILE_WATCHER] Starting watcher for ${file}`); const watcher = fs$1.watch(file, { persistent: true, signal: abortController.signal }); for await (const event of watcher) { if (abortController.signal.aborted) return; persistence.logger.debug(`[FILE_WATCHER] File changed: ${file}`); consecutiveErrors = 0; onFileChange(file); } consecutiveErrors = 0; } catch (e) { if (abortController.signal.aborted) return; if (e.code === "ENOENT") { persistence.logger.debug(`[FILE_WATCHER] File ${file} disappeared, will wait for it to reappear`); continue; } consecutiveErrors++; const backoffMs = Math.min(1e3 * Math.pow(2, consecutiveErrors - 1), 3e4); persistence.logger.debug(`[FILE_WATCHER] Watch error: ${e.message}, retrying in ${backoffMs}ms (attempt ${consecutiveErrors})`); await persistence.delay(backoffMs); } } })(); return () => { abortController.abort(); }; } const INTERNAL_CLAUDE_EVENT_TYPES = /* @__PURE__ */ new Set([ "file-history-snapshot", "change", "queue-operation" ]); async function createSessionScanner(opts) { const projectDir = getProjectPath(opts.workingDirectory); let finishedSessions = /* @__PURE__ */ new Set(); let pendingSessions = /* @__PURE__ */ new Set(); let currentSessionId = null; let watchers = /* @__PURE__ */ new Map(); let processedMessageKeys = /* @__PURE__ */ new Set(); if (opts.sessionId) { let messages = await readSessionLog(projectDir, opts.sessionId); persistence.logger.debug(`[SESSION_SCANNER] Marking ${messages.length} existing messages as processed from session ${opts.sessionId}`); for (let m of messages) { processedMessageKeys.add(messageKey(m)); } currentSessionId = opts.sessionId; } const sync = new InvalidateSync(async () => { let sessions = []; for (let p of pendingSessions) { sessions.push(p); } if (currentSessionId && !pendingSessions.has(currentSessionId)) { sessions.push(currentSessionId); } for (let [sessionId] of watchers) { if (!sessions.includes(sessionId)) { sessions.push(sessionId); } } for (let session of sessions) { const sessionMessages = await readSessionLog(projectDir, session); let skipped = 0; let sent = 0; for (let file of sessionMessages) { let key = messageKey(file); if (processedMessageKeys.has(key)) { skipped++; continue; } processedMessageKeys.add(key); persistence.logger.debug(`[SESSION_SCANNER] Sending new message: type=${file.type}, uuid=${file.type === "summary" ? file.leafUuid : file.uuid}`); opts.onMessage(file); sent++; } if (sessionMessages.length > 0) { persistence.logger.debug(`[SESSION_SCANNER] Session ${session}: found=${sessionMessages.length}, skipped=${skipped}, sent=${sent}`); } } for (let p of sessions) { if (pendingSessions.has(p)) { pendingSessions.delete(p); finishedSessions.add(p); } } for (let p of sessions) { if (!watchers.has(p)) { persistence.logger.debug(`[SESSION_SCANNER] Starting watcher for session: ${p}`); watchers.set(p, startFileWatcher(path.join(projectDir, `${p}.jsonl`), () => { sync.invalidate(); })); } } }); await sync.invalidateAndAwait(); const intervalId = setInterval(() => { sync.invalidate(); }, 3e3); return { cleanup: async () => { clearInterval(intervalId); for (let w of watchers.values()) { w(); } watchers.clear(); await sync.invalidateAndAwait(); sync.stop(); }, onNewSession: (sessionId) => { if (currentSessionId === sessionId) { persistence.logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is the same as the current session, skipping`); return; } if (finishedSessions.has(sessionId)) { persistence.logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already finished, skipping`); return; } if (pendingSessions.has(sessionId)) { persistence.logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already pending, skipping`); return; } if (currentSessionId) { pendingSessions.add(currentSessionId); } persistence.logger.debug(`[SESSION_SCANNER] New session: ${sessionId}`); currentSessionId = sessionId; sync.invalidate(); } }; } function messageKey(message) { if (message.type === "user") { return message.uuid; } else if (message.type === "assistant") { return message.uuid; } else if (message.type === "summary") { return "summary: " + message.leafUuid + ": " + message.summary; } else if (message.type === "system") { return message.uuid; } else { throw Error(); } } async function readSessionLog(projectDir, sessionId) { const expectedSessionFile = path.join(projectDir, `${sessionId}.jsonl`); let file; try { file = await fs$3.readFile(expectedSessionFile, "utf-8"); } catch (error) { if (error?.code !== "ENOENT") { persistence.logger.debug(`[SESSION_SCANNER] Error reading session file ${expectedSessionFile}: ${error.message ?? error}`); } return []; } let lines = file.split("\n"); let messages = []; for (let l of lines) { try { if (l.trim() === "") { continue; } let message = JSON.parse(l); if (message.type && INTERNAL_CLAUDE_EVENT_TYPES.has(message.type)) { continue; } let parsed = persistence.RawJSONLinesSchema.safeParse(message); if (!parsed.success) { continue; } messages.push(parsed.data); } catch (e) { persistence.logger.debug(`[SESSION_SCANNER] Error processing message: ${e}`); continue; } } return messages; } async function claudeLocalLauncher(session) { const scanner = await createSessionScanner({ sessionId: session.sessionId, workingDirectory: session.path, onMessage: (message) => { if (message.type !== "summary") { session.client.sendClaudeSessionMessage(message); } } }); const scannerSessionCallback = (sessionId) => { scanner.onNewSession(sessionId); }; session.addSessionFoundCallback(scannerSessionCallback); let exitReason = null; const processAbortController = new AbortController(); let exutFuture = new Future(); try { async function abort() { if (!processAbortController.signal.aborted) { processAbortController.abort(); } await exutFuture.promise; } async function doAbort() { persistence.logger.debug("[local]: doAbort"); if (!exitReason) { exitReason = { type: "switch" }; } session.queue.reset(); await abort(); } async function doSwitch() { persistence.logger.debug("[local]: doSwitch"); if (!exitReason) { exitReason = { type: "switch" }; } await abort(); } session.client.rpcHandlerManager.registerHandler("abort", doAbort); session.client.rpcHandlerManager.registerHandler("switch", doSwitch); session.queue.setOnMessage((message, mode) => { doSwitch(); }); if (session.queue.size() > 0) { return { type: "switch" }; } const handleSessionStart = (sessionId) => { session.onSessionFound(sessionId); scanner.onNewSession(sessionId); }; while (true) { if (exitReason) { return exitReason; } persistence.logger.debug("[local]: launch"); try { await claudeLocal({ path: session.path, sessionId: session.sessionId, onSessionFound: handleSessionStart, onThinkingChange: session.onThinkingChange, abort: processAbortController.signal, claudeEnvVars: session.claudeEnvVars, claudeArgs: session.claudeArgs, mcpServers: session.mcpServers, allowedTools: session.allowedTools, hookSettingsPath: session.hookSettingsPath }); session.consumeOneTimeFlags(); if (!exitReason) { exitReason = { type: "exit", code: 0 }; break; } } catch (e) { persistence.logger.debug("[local]: launch error", e); if (e instanceof ExitCodeError) { exitReason = { type: "exit", code: e.exitCode }; break; } if (!exitReason) { session.client.sendSessionEvent({ type: "message", message: "Process exited unexpectedly" }); continue; } else { break; } } persistence.logger.debug("[local]: launch done"); } } finally { exutFuture.resolve(void 0); session.client.rpcHandlerManager.registerHandler("abort", async () => { }); session.client.rpcHandlerManager.registerHandler("switch", async () => { }); session.queue.setOnMessage(null); session.removeSessionFoundCallback(scannerSessionCallback); await scanner.cleanup(); } return exitReason || { type: "exit", code: 0 }; } class MessageBuffer { messages = []; listeners = []; nextId = 1; addMessage(content, type = "assistant") { const message = { id: `msg-${this.nextId++}`, timestamp: /* @__PURE__ */ new Date(), content, type }; this.messages.push(message); this.notifyListeners(); } /** * Update the last message of a specific type by appending content to it * Useful for streaming responses where deltas should accumulate in one message */ updateLastMessage(contentDelta, type = "assistant") { for (let i = this.messages.length - 1; i >= 0; i--) { if (this.messages[i].type === type) { const oldMessage = this.messages[i]; const updatedMessage = { ...oldMessage, content: oldMessage.content + contentDelta }; this.messages[i] = updatedMessage; this.notifyListeners(); return; } } this.addMessage(contentDelta, type); } /** * Remove the last message of a specific type * Useful for removing placeholder messages like "Thinking..." when actual response starts */ removeLastMessage(type) { for (let i = this.messages.length - 1; i >= 0; i--) { if (this.messages[i].type === type) { this.messages.splice(i, 1); this.notifyListeners(); return true; } } return false; } getMessages() { return [...this.messages]; } clear() { this.messages = []; this.nextId = 1; this.notifyListeners(); } onUpdate(listener) { this.listeners.push(listener); return () => { const index = this.listeners.indexOf(listener); if (index > -1) { this.listeners.splice(index, 1); } }; } notifyListeners() { const messages = this.getMessages(); this.listeners.forEach((listener) => listener(messages)); } } const RemoteModeDisplay = ({ messageBuffer, logPath, onExit, onSwitchToLocal }) => { const [messages, setMessages] = React.useState([]); const [confirmationMode, setConfirmationMode] = React.useState(null); const [actionInProgress, setActionInProgress] = React.useState(null); const confirmationTimeoutRef = React.useRef(null); const { stdout } = ink.useStdout(); const terminalWidth = stdout.columns || 80; const terminalHeight = stdout.rows || 24; React.useEffect(() => { setMessages(messageBuffer.getMessages()); const unsubscribe = messageBuffer.onUpdate((newMessages) => { setMessages(newMessages); }); return () => { unsubscribe(); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); } }; }, [messageBuffer]); const resetConfirmation = React.useCallback(() => { setConfirmationMode(null); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); confirmationTimeoutRef.current = null; } }, []); const setConfirmationWithTimeout = React.useCallback((mode) => { setConfirmationMode(mode); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); } confirmationTimeoutRef.current = setTimeout(() => { resetConfirmation(); }, 15e3); }, [resetConfirmation]); ink.useInput(React.useCallback(async (input, key) => { if (actionInProgress) return; if (key.ctrl && input === "c") { if (confirmationMode === "exit") { resetConfirmation(); setActionInProgress("exiting"); await new Promise((resolve) => setTimeout(resolve, 100)); onExit?.(); } else { setConfirmationWithTimeout("exit"); } return; } if (input === " ") { if (confirmationMode === "switch") { resetConfirmation(); setActionInProgress("switching"); await new Promise((resolve) => setTimeout(resolve, 100)); onSwitchToLocal?.(); } else { setConfirmationWithTimeout("switch"); } return; } if (confirmationMode) { resetConfirmation(); } }, [confirmationMode, actionInProgress, onExit, onSwitchToLocal, setConfirmationWithTimeout, resetConfirmation])); const getMessageColor = (type) => { switch (type) { case "user": return "magenta"; case "assistant": return "cyan"; case "system": return "blue"; case "tool": return "yellow"; case "result": return "green"; case "status": return "gray"; default: return "white"; } }; const formatMessage = (msg) => { const lines = msg.content.split("\n"); const maxLineLength = terminalWidth - 10; return lines.map((line) => { if (line.length <= maxLineLength) return line; const chunks = []; for (let i = 0; i < line.length; i += maxLineLength) { chunks.push(line.slice(i, i + maxLineLength)); } return chunks.join("\n"); }).join("\n"); }; return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", width: terminalWidth, height: terminalHeight }, /* @__PURE__ */ React.createElement( ink.Box, { flexDirection: "column", width: terminalWidth, height: terminalHeight - 4, borderStyle: "round", borderColor: "gray", paddingX: 1, overflow: "hidden" }, /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", bold: true }, "\u{1F4E1} Remote Mode - Claude Messages"), /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", dimColor: true }, "\u2500".repeat(Math.min(terminalWidth - 4, 60)))), /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", height: terminalHeight - 10, overflow: "hidden" }, messages.length === 0 ? /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", dimColor: true }, "Waiting for messages...") : ( // Show only the last messages that fit in the available space messages.slice(-Math.max(1, terminalHeight - 10)).map((msg) => /* @__PURE__ */ React.createElement(ink.Box, { key: msg.id, flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(ink.Text, { color: getMessageColor(msg.type), dimColor: true }, formatMessage(msg)))) )) ), /* @__PURE__ */ React.createElement( ink.Box, { width: terminalWidth, borderStyle: "round", borderColor: actionInProgress ? "gray" : confirmationMode === "exit" ? "red" : confirmationMode === "switch" ? "yellow" : "green", paddingX: 2, justifyContent: "center", alignItems: "center", flexDirection: "column" }, /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", alignItems: "center" }, actionInProgress === "exiting" ? /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", bold: true }, "Exiting...") : actionInProgress === "switching" ? /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", bold: true }, "Switching to local mode...") : confirmationMode === "exit" ? /* @__PURE__ */ React.createElement(ink.Text, { color: "red", bold: true }, "\u26A0\uFE0F Press Ctrl-C again to exit completely") : confirmationMode === "switch" ? /* @__PURE__ */ React.createElement(ink.Text, { color: "yellow", bold: true }, "\u23F8\uFE0F Press space again to switch to local mode") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(ink.Text, { color: "green", bold: true }, "\u{1F4F1} Press space to switch to local mode \u2022 Ctrl-C to exit")), process.env.DEBUG && logPath && /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", dimColor: true }, "Debug logs: ", logPath)) )); }; class Stream { constructor(returned) { this.returned = returned;