UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

1,518 lines (1,503 loc) 85.9 kB
import axios from 'axios'; import chalk from 'chalk'; import { appendFileSync } from 'fs'; import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, basename } from 'node:path'; import { EventEmitter } from 'node:events'; import { io } from 'socket.io-client'; import { z } from 'zod'; import { randomBytes, createCipheriv, createDecipheriv, randomUUID } from 'node:crypto'; import tweetnacl from 'tweetnacl'; import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import { readFile, stat, writeFile, readdir } from 'fs/promises'; import { createHash } from 'crypto'; import { dirname, resolve, join as join$1 } from 'path'; import { fileURLToPath } from 'url'; import { platform } from 'os'; import { Expo } from 'expo-server-sdk'; var name = "consortium"; var version = "0.1.0"; var description = "Remote control and session sharing CLI for AI coding agents"; var author = "ConsortiumAI"; var license = "MIT"; var type = "module"; var homepage = "https://github.com/ConsortiumAI/consortium-cli"; var bugs = "https://github.com/ConsortiumAI/consortium-cli/issues"; var repository = "ConsortiumAI/consortium-cli"; var bin = { consortium: "./bin/consortium.mjs", "consortium-mcp": "./bin/consortium-mcp.mjs" }; var main = "./dist/index.cjs"; var module$1 = "./dist/index.mjs"; var types = "./dist/index.d.cts"; var exports$1 = { ".": { require: { types: "./dist/index.d.cts", "default": "./dist/index.cjs" }, "import": { types: "./dist/index.d.mts", "default": "./dist/index.mjs" } }, "./lib": { require: { types: "./dist/lib.d.cts", "default": "./dist/lib.cjs" }, "import": { types: "./dist/lib.d.mts", "default": "./dist/lib.mjs" } }, "./codex/consortiumMcpStdioBridge": { require: { types: "./dist/codex/consortiumMcpStdioBridge.d.cts", "default": "./dist/codex/consortiumMcpStdioBridge.cjs" }, "import": { types: "./dist/codex/consortiumMcpStdioBridge.d.mts", "default": "./dist/codex/consortiumMcpStdioBridge.mjs" } } }; var files = [ "dist", "bin", "scripts", "tools", "package.json" ]; var scripts = { "why do we need to build before running tests / dev?": "We need the binary to be built so we run daemon commands which directly run the binary - we don't want them to go out of sync or have custom spawn logic depending how we started consortium", typecheck: "tsc --noEmit", build: "shx rm -rf dist && npx tsc --noEmit && pkgroll", test: "$npm_execpath run build && vitest run", start: "$npm_execpath run build && node ./bin/consortium.mjs", dev: "tsx src/index.ts", "dev:local-server": "$npm_execpath run build && tsx --env-file .env.dev-local-server src/index.ts", "dev:integration-test-env": "$npm_execpath run build && tsx --env-file .env.integration-test src/index.ts", prepublishOnly: "$npm_execpath run build && $npm_execpath test", release: "$npm_execpath install && release-it", postinstall: "node scripts/unpack-tools.cjs", "// ==== Dev/Stable Variant Management ====": "", stable: "node scripts/env-wrapper.cjs stable", "dev:variant": "node scripts/env-wrapper.cjs dev", "// ==== Stable Version Quick Commands ====": "", "stable:daemon:start": "node scripts/env-wrapper.cjs stable daemon start", "stable:daemon:stop": "node scripts/env-wrapper.cjs stable daemon stop", "stable:daemon:status": "node scripts/env-wrapper.cjs stable daemon status", "stable:auth": "node scripts/env-wrapper.cjs stable auth", "// ==== Development Version Quick Commands ====": "", "dev:daemon:start": "node scripts/env-wrapper.cjs dev daemon start", "dev:daemon:stop": "node scripts/env-wrapper.cjs dev daemon stop", "dev:daemon:status": "node scripts/env-wrapper.cjs dev daemon status", "dev:auth": "node scripts/env-wrapper.cjs dev auth", "// ==== Setup ====": "", "setup:dev": "node scripts/setup-dev.cjs", doctor: "node scripts/env-wrapper.cjs stable doctor", "// ==== Development Linking ====": "", "link:dev": "node scripts/link-dev.cjs", "unlink:dev": "node scripts/link-dev.cjs unlink" }; var dependencies = { "@agentclientprotocol/sdk": "^0.8.0", "@modelcontextprotocol/sdk": "^1.25.3", "@stablelib/base64": "^2.0.1", "@stablelib/hex": "^2.0.1", "@types/cross-spawn": "^6.0.6", "@types/http-proxy": "^1.17.17", "@types/ps-list": "^6.2.1", "@types/qrcode-terminal": "^0.12.2", "@types/react": "^19.2.7", "@types/tmp": "^0.2.6", ai: "^5.0.107", axios: "^1.13.2", chalk: "^5.6.2", "cross-spawn": "^7.0.6", "expo-server-sdk": "^3.15.0", fastify: "^5.6.2", "fastify-type-provider-zod": "4.0.2", "http-proxy": "^1.18.1", "http-proxy-middleware": "^3.0.5", ink: "^6.5.1", open: "^10.2.0", "ps-list": "^8.1.1", "qrcode-terminal": "^0.12.0", react: "^19.2.0", "socket.io-client": "^4.8.1", tar: "^7.5.2", tmp: "^0.2.5", tweetnacl: "^1.0.3", zod: "3.25.76" }; var devDependencies = { "@eslint/compat": "^1", "@types/node": ">=20", "cross-env": "^10.1.0", dotenv: "^16.6.1", eslint: "^9", "eslint-config-prettier": "^10", pkgroll: "^2.14.2", "release-it": "^19.0.6", shx: "^0.3.3", "ts-node": "^10", tsx: "^4.20.6", typescript: "5.9.3", vitest: "^3.2.4" }; var resolutions = { "whatwg-url": "14.2.0", "parse-path": "7.0.3", "@types/parse-path": "7.0.3" }; var publishConfig = { registry: "https://registry.npmjs.org" }; var packageManager = "yarn@1.22.22"; var packageJson = { name: name, version: version, description: description, author: author, license: license, type: type, homepage: homepage, bugs: bugs, repository: repository, bin: bin, main: main, module: module$1, types: types, exports: exports$1, files: files, scripts: scripts, dependencies: dependencies, devDependencies: devDependencies, resolutions: resolutions, publishConfig: publishConfig, packageManager: packageManager }; class Configuration { serverUrl; webappUrl; isDaemonProcess; // Directories and paths (from persistence) consortiumHomeDir; logsDir; settingsFile; privateKeyFile; daemonStateFile; daemonLockFile; currentCliVersion; isExperimentalEnabled; disableCaffeinate; constructor() { this.serverUrl = process.env.CONSORTIUM_SERVER_URL || "https://api.consortium.dev"; this.webappUrl = process.env.CONSORTIUM_WEBAPP_URL || "https://app.consortium.dev"; const args = process.argv.slice(2); this.isDaemonProcess = args.length >= 2 && args[0] === "daemon" && args[1] === "start-sync"; if (process.env.CONSORTIUM_HOME_DIR) { const expandedPath = process.env.CONSORTIUM_HOME_DIR.replace(/^~/, homedir()); this.consortiumHomeDir = expandedPath; } else { this.consortiumHomeDir = join(homedir(), ".consortium"); } this.logsDir = join(this.consortiumHomeDir, "logs"); this.settingsFile = join(this.consortiumHomeDir, "settings.json"); this.privateKeyFile = join(this.consortiumHomeDir, "access.key"); this.daemonStateFile = join(this.consortiumHomeDir, "daemon.state.json"); this.daemonLockFile = join(this.consortiumHomeDir, "daemon.state.json.lock"); this.isExperimentalEnabled = ["true", "1", "yes"].includes(process.env.CONSORTIUM_EXPERIMENTAL?.toLowerCase() || ""); this.disableCaffeinate = ["true", "1", "yes"].includes(process.env.CONSORTIUM_DISABLE_CAFFEINATE?.toLowerCase() || ""); this.currentCliVersion = packageJson.version; const variant = process.env.CONSORTIUM_VARIANT || "stable"; if (variant === "dev" && !this.consortiumHomeDir.includes("dev")) { console.warn('\u26A0\uFE0F WARNING: CONSORTIUM_VARIANT=dev but CONSORTIUM_HOME_DIR does not contain "dev"'); console.warn(` Current: ${this.consortiumHomeDir}`); console.warn(` Expected: Should contain "dev" (e.g., ~/.consortium-dev)`); } if (!this.isDaemonProcess && variant === "dev") { console.log("\x1B[33m\u{1F527} DEV MODE\x1B[0m - Data: " + this.consortiumHomeDir); } if (!existsSync(this.consortiumHomeDir)) { mkdirSync(this.consortiumHomeDir, { recursive: true }); } if (!existsSync(this.logsDir)) { mkdirSync(this.logsDir, { recursive: true }); } } } const configuration = new Configuration(); function createTimestampForFilename(date = /* @__PURE__ */ new Date()) { return date.toLocaleString("sv-SE", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).replace(/[: ]/g, "-").replace(/,/g, "") + "-pid-" + process.pid; } function createTimestampForLogEntry(date = /* @__PURE__ */ new Date()) { return date.toLocaleTimeString("en-US", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 }); } function getSessionLogPath() { const timestamp = createTimestampForFilename(); const filename = configuration.isDaemonProcess ? `${timestamp}-daemon.log` : `${timestamp}.log`; return join(configuration.logsDir, filename); } class Logger { constructor(logFilePath = getSessionLogPath()) { this.logFilePath = logFilePath; if (process.env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING && process.env.CONSORTIUM_SERVER_URL) { this.dangerouslyUnencryptedServerLoggingUrl = process.env.CONSORTIUM_SERVER_URL; console.log(chalk.yellow("[REMOTE LOGGING] Sending logs to server for AI debugging")); } } dangerouslyUnencryptedServerLoggingUrl; // Use local timezone for simplicity of locating the logs, // in practice you will not need absolute timestamps localTimezoneTimestamp() { return createTimestampForLogEntry(); } debug(message, ...args) { this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, ...args); } debugLargeJson(message, object, maxStringLength = 100, maxArrayLength = 10) { if (!process.env.DEBUG) { this.debug(`In production, skipping message inspection`); } const truncateStrings = (obj) => { if (typeof obj === "string") { return obj.length > maxStringLength ? obj.substring(0, maxStringLength) + "... [truncated for logs]" : obj; } if (Array.isArray(obj)) { const truncatedArray = obj.map((item) => truncateStrings(item)).slice(0, maxArrayLength); if (obj.length > maxArrayLength) { truncatedArray.push(`... [truncated array for logs up to ${maxArrayLength} items]`); } return truncatedArray; } if (obj && typeof obj === "object") { const result = {}; for (const [key, value] of Object.entries(obj)) { if (key === "usage") { continue; } result[key] = truncateStrings(value); } return result; } return obj; }; const truncatedObject = truncateStrings(object); const json = JSON.stringify(truncatedObject, null, 2); this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, "\n", json); } info(message, ...args) { this.logToConsole("info", "", message, ...args); this.debug(message, args); } infoDeveloper(message, ...args) { this.debug(message, ...args); if (process.env.DEBUG) { this.logToConsole("info", "[DEV]", message, ...args); } } warn(message, ...args) { this.logToConsole("warn", "", message, ...args); this.debug(`[WARN] ${message}`, ...args); } getLogPath() { return this.logFilePath; } logToConsole(level, prefix, message, ...args) { switch (level) { case "debug": { console.log(chalk.gray(prefix), message, ...args); break; } case "error": { console.error(chalk.red(prefix), message, ...args); break; } case "info": { console.log(chalk.blue(prefix), message, ...args); break; } case "warn": { console.log(chalk.yellow(prefix), message, ...args); break; } default: { this.debug("Unknown log level:", level); console.log(chalk.blue(prefix), message, ...args); break; } } } async sendToRemoteServer(level, message, ...args) { if (!this.dangerouslyUnencryptedServerLoggingUrl) return; try { await fetch(this.dangerouslyUnencryptedServerLoggingUrl + "/logs-combined-from-cli-and-mobile-for-simple-ai-debugging", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), level, message: `${message} ${args.map( (a) => typeof a === "object" ? JSON.stringify(a, null, 2) : String(a) ).join(" ")}`, source: "cli", platform: process.platform }) }); } catch (error) { } } logToFile(prefix, message, ...args) { const logLine = `${prefix} ${message} ${args.map( (arg) => typeof arg === "string" ? arg : JSON.stringify(arg) ).join(" ")} `; if (this.dangerouslyUnencryptedServerLoggingUrl) { let level = "info"; if (prefix.includes(this.localTimezoneTimestamp())) { level = "debug"; } this.sendToRemoteServer(level, message, ...args).catch(() => { }); } try { appendFileSync(this.logFilePath, logLine); } catch (appendError) { if (process.env.DEBUG) { console.error("[DEV MODE ONLY THROWING] Failed to append to log file:", appendError); throw appendError; } } } } let logger = new Logger(); async function listDaemonLogFiles(limit = 50) { try { const logsDir = configuration.logsDir; if (!existsSync(logsDir)) { return []; } const logs = readdirSync(logsDir).filter((file) => file.endsWith("-daemon.log")).map((file) => { const fullPath = join(logsDir, file); const stats = statSync(fullPath); return { file, path: fullPath, modified: stats.mtime }; }).sort((a, b) => b.modified.getTime() - a.modified.getTime()); try { const { readDaemonState } = await import('./persistence-CJJbK-4u.mjs'); const state = await readDaemonState(); if (!state) { return logs; } if (state.daemonLogPath && existsSync(state.daemonLogPath)) { const stats = statSync(state.daemonLogPath); const persisted = { file: basename(state.daemonLogPath), path: state.daemonLogPath, modified: stats.mtime }; const idx = logs.findIndex((l) => l.path === persisted.path); if (idx >= 0) { const [found] = logs.splice(idx, 1); logs.unshift(found); } else { logs.unshift(persisted); } } } catch { } return logs.slice(0, Math.max(0, limit)); } catch { return []; } } async function getLatestDaemonLog() { const [latest] = await listDaemonLogFiles(1); return latest || null; } const SessionMessageContentSchema = z.object({ c: z.string(), // Base64 encoded encrypted content t: z.literal("encrypted") }); const UpdateBodySchema = z.object({ message: z.object({ id: z.string(), seq: z.number(), content: SessionMessageContentSchema }), sid: z.string(), // Session ID t: z.literal("new-message") }); const UpdateSessionBodySchema = z.object({ t: z.literal("update-session"), sid: z.string(), metadata: z.object({ version: z.number(), value: z.string() }).nullish(), agentState: z.object({ version: z.number(), value: z.string() }).nullish() }); const UpdateMachineBodySchema = z.object({ t: z.literal("update-machine"), machineId: z.string(), metadata: z.object({ version: z.number(), value: z.string() }).nullish(), daemonState: z.object({ version: z.number(), value: z.string() }).nullish() }); z.object({ id: z.string(), seq: z.number(), body: z.union([ UpdateBodySchema, UpdateSessionBodySchema, UpdateMachineBodySchema ]), createdAt: z.number() }); z.object({ host: z.string(), platform: z.string(), consortiumCliVersion: z.string(), homeDir: z.string(), consortiumHomeDir: z.string(), consortiumLibDir: z.string() }); z.object({ status: z.union([ z.enum(["running", "shutting-down"]), z.string() // Forward compatibility ]), pid: z.number().optional(), httpPort: z.number().optional(), startedAt: z.number().optional(), shutdownRequestedAt: z.number().optional(), shutdownSource: z.union([ z.enum(["mobile-app", "cli", "os-signal", "unknown"]), z.string() // Forward compatibility ]).optional() }); z.object({ content: SessionMessageContentSchema, createdAt: z.number(), id: z.string(), seq: z.number(), updatedAt: z.number() }); const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), // Source identifier permissionMode: z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "read-only", "safe-yolo", "yolo"]).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) appendSystemPrompt: z.string().nullable().optional(), // Append to system prompt for this message (null = reset) allowedTools: z.array(z.string()).nullable().optional(), // Allowed tools for this message (null = reset) disallowedTools: z.array(z.string()).nullable().optional() // Disallowed tools for this message (null = reset) }); z.object({ session: z.object({ id: z.string(), tag: z.string(), seq: z.number(), createdAt: z.number(), updatedAt: z.number(), metadata: z.string(), metadataVersion: z.number(), agentState: z.string().nullable(), agentStateVersion: z.number() }) }); const UserMessageSchema = z.object({ role: z.literal("user"), content: z.object({ type: z.literal("text"), text: z.string() }), localKey: z.string().optional(), // Mobile messages include this meta: MessageMetaSchema.optional() }); const AgentMessageSchema = z.object({ role: z.literal("agent"), content: z.object({ type: z.literal("output"), data: z.any() }), meta: MessageMetaSchema.optional() }); z.union([UserMessageSchema, AgentMessageSchema]); function encodeBase64(buffer, variant = "base64") { if (variant === "base64url") { return encodeBase64Url(buffer); } return Buffer.from(buffer).toString("base64"); } function encodeBase64Url(buffer) { return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } function decodeBase64(base64, variant = "base64") { if (variant === "base64url") { const base64Standard = base64.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - base64.length % 4) % 4); return new Uint8Array(Buffer.from(base64Standard, "base64")); } return new Uint8Array(Buffer.from(base64, "base64")); } function getRandomBytes(size) { return new Uint8Array(randomBytes(size)); } function libsodiumEncryptForPublicKey(data, recipientPublicKey) { const ephemeralKeyPair = tweetnacl.box.keyPair(); const nonce = getRandomBytes(tweetnacl.box.nonceLength); const encrypted = tweetnacl.box(data, nonce, recipientPublicKey, ephemeralKeyPair.secretKey); const result = new Uint8Array(ephemeralKeyPair.publicKey.length + nonce.length + encrypted.length); result.set(ephemeralKeyPair.publicKey, 0); result.set(nonce, ephemeralKeyPair.publicKey.length); result.set(encrypted, ephemeralKeyPair.publicKey.length + nonce.length); return result; } function encryptLegacy(data, secret) { const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength); const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret); const result = new Uint8Array(nonce.length + encrypted.length); result.set(nonce); result.set(encrypted, nonce.length); return result; } function decryptLegacy(data, secret) { const nonce = data.slice(0, tweetnacl.secretbox.nonceLength); const encrypted = data.slice(tweetnacl.secretbox.nonceLength); const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret); if (!decrypted) { return null; } return JSON.parse(new TextDecoder().decode(decrypted)); } function encryptWithDataKey(data, dataKey) { const nonce = getRandomBytes(12); const cipher = createCipheriv("aes-256-gcm", dataKey, nonce); const plaintext = new TextEncoder().encode(JSON.stringify(data)); const encrypted = Buffer.concat([ cipher.update(plaintext), cipher.final() ]); const authTag = cipher.getAuthTag(); const bundle = new Uint8Array(12 + encrypted.length + 16 + 1); bundle.set([0], 0); bundle.set(nonce, 1); bundle.set(new Uint8Array(encrypted), 13); bundle.set(new Uint8Array(authTag), 13 + encrypted.length); return bundle; } function decryptWithDataKey(bundle, dataKey) { if (bundle.length < 1) { return null; } if (bundle[0] !== 0) { return null; } if (bundle.length < 12 + 16 + 1) { return null; } const nonce = bundle.slice(1, 13); const authTag = bundle.slice(bundle.length - 16); const ciphertext = bundle.slice(13, bundle.length - 16); try { const decipher = createDecipheriv("aes-256-gcm", dataKey, nonce); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([ decipher.update(ciphertext), decipher.final() ]); return JSON.parse(new TextDecoder().decode(decrypted)); } catch (error) { return null; } } function encrypt(key, variant, data) { if (variant === "legacy") { return encryptLegacy(data, key); } else { return encryptWithDataKey(data, key); } } function decrypt(key, variant, data) { if (variant === "legacy") { return decryptLegacy(data, key); } else { return decryptWithDataKey(data, key); } } async function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount) { let maxDelayRet = minDelay + (maxDelay - minDelay) / maxFailureCount * Math.min(currentFailureCount, maxFailureCount); return Math.round(Math.random() * maxDelayRet); } function createBackoff(opts) { return async (callback) => { let currentFailureCount = 0; const minDelay = 250; const maxDelay = 1e3; const maxFailureCount = 50; while (true) { try { return await callback(); } catch (e) { if (currentFailureCount < maxFailureCount) { currentFailureCount++; } let waitForRequest = exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount); await delay(waitForRequest); } } }; } let backoff = createBackoff(); class AsyncLock { permits = 1; promiseResolverQueue = []; async inLock(func) { try { await this.lock(); return await func(); } finally { this.unlock(); } } async lock() { if (this.permits > 0) { this.permits = this.permits - 1; return; } await new Promise((resolve) => this.promiseResolverQueue.push(resolve)); } unlock() { this.permits += 1; if (this.permits > 1 && this.promiseResolverQueue.length > 0) { throw new Error("this.permits should never be > 0 when there is someone waiting."); } else if (this.permits === 1 && this.promiseResolverQueue.length > 0) { this.permits -= 1; const nextResolver = this.promiseResolverQueue.shift(); if (nextResolver) { setTimeout(() => { nextResolver(true); }, 0); } } } } class RpcHandlerManager { handlers = /* @__PURE__ */ new Map(); scopePrefix; encryptionKey; encryptionVariant; logger; socket = null; constructor(config) { this.scopePrefix = config.scopePrefix; this.encryptionKey = config.encryptionKey; this.encryptionVariant = config.encryptionVariant; this.logger = config.logger || ((msg, data) => logger.debug(msg, data)); } /** * Register an RPC handler for a specific method * @param method - The method name (without prefix) * @param handler - The handler function */ registerHandler(method, handler) { const prefixedMethod = this.getPrefixedMethod(method); this.handlers.set(prefixedMethod, handler); if (this.socket) { this.socket.emit("rpc-register", { method: prefixedMethod }); } } /** * Handle an incoming RPC request * @param request - The RPC request data * @param callback - The response callback */ async handleRequest(request) { try { const handler = this.handlers.get(request.method); if (!handler) { this.logger("[RPC] [ERROR] Method not found", { method: request.method }); const errorResponse = { error: "Method not found" }; const encryptedError = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)); return encryptedError; } const decryptedParams = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(request.params)); this.logger("[RPC] Calling handler", { method: request.method }); const result = await handler(decryptedParams); this.logger("[RPC] Handler returned", { method: request.method, hasResult: result !== void 0 }); const encryptedResponse = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, result)); this.logger("[RPC] Sending encrypted response", { method: request.method, responseLength: encryptedResponse.length }); return encryptedResponse; } catch (error) { this.logger("[RPC] [ERROR] Error handling request", { error }); const errorResponse = { error: error instanceof Error ? error.message : "Unknown error" }; return encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)); } } onSocketConnect(socket) { this.socket = socket; for (const [prefixedMethod] of this.handlers) { socket.emit("rpc-register", { method: prefixedMethod }); } } onSocketDisconnect() { this.socket = null; } /** * Get the number of registered handlers */ getHandlerCount() { return this.handlers.size; } /** * Check if a handler is registered * @param method - The method name (without prefix) */ hasHandler(method) { const prefixedMethod = this.getPrefixedMethod(method); return this.handlers.has(prefixedMethod); } /** * Clear all handlers */ clearHandlers() { this.handlers.clear(); this.logger("Cleared all RPC handlers"); } /** * Get the prefixed method name * @param method - The method name */ getPrefixedMethod(method) { return `${this.scopePrefix}:${method}`; } } const __dirname$1 = dirname(fileURLToPath(import.meta.url)); function projectPath() { const path = resolve(__dirname$1, ".."); return path; } function run$1(args, options) { const RUNNER_PATH = resolve(join$1(projectPath(), "scripts", "ripgrep_launcher.cjs")); return new Promise((resolve2, reject) => { const child = spawn("node", [RUNNER_PATH, JSON.stringify(args)], { stdio: ["pipe", "pipe", "pipe"], cwd: options?.cwd }); let stdout = ""; let stderr = ""; child.stdout.on("data", (data) => { stdout += data.toString(); }); child.stderr.on("data", (data) => { stderr += data.toString(); }); child.on("close", (code) => { resolve2({ exitCode: code || 0, stdout, stderr }); }); child.on("error", (err) => { reject(err); }); }); } function getBinaryPath() { const platformName = platform(); const binaryName = platformName === "win32" ? "difft.exe" : "difft"; return resolve(join$1(projectPath(), "tools", "unpacked", binaryName)); } function run(args, options) { const binaryPath = getBinaryPath(); return new Promise((resolve2, reject) => { const child = spawn(binaryPath, args, { stdio: ["pipe", "pipe", "pipe"], cwd: options?.cwd, env: { ...process.env, // Force color output when needed FORCE_COLOR: "1" } }); let stdout = ""; let stderr = ""; child.stdout.on("data", (data) => { stdout += data.toString(); }); child.stderr.on("data", (data) => { stderr += data.toString(); }); child.on("close", (code) => { resolve2({ exitCode: code || 0, stdout, stderr }); }); child.on("error", (err) => { reject(err); }); }); } function validatePath(targetPath, workingDirectory) { const resolvedTarget = resolve(workingDirectory, targetPath); const resolvedWorkingDir = resolve(workingDirectory); if (!resolvedTarget.startsWith(resolvedWorkingDir + "/") && resolvedTarget !== resolvedWorkingDir) { return { valid: false, error: `Access denied: Path '${targetPath}' is outside the working directory` }; } return { valid: true }; } const execAsync = promisify(exec); function registerCommonHandlers(rpcHandlerManager, workingDirectory) { rpcHandlerManager.registerHandler("bash", async (data) => { logger.debug("Shell command request:", data.command); if (data.cwd && data.cwd !== "/") { const validation = validatePath(data.cwd, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } } try { const options = { cwd: data.cwd === "/" ? void 0 : data.cwd, timeout: data.timeout || 3e4 // Default 30 seconds timeout }; logger.debug("Shell command executing...", { cwd: options.cwd, timeout: options.timeout }); const { stdout, stderr } = await execAsync(data.command, options); logger.debug("Shell command executed, processing result..."); const result = { success: true, stdout: stdout ? stdout.toString() : "", stderr: stderr ? stderr.toString() : "", exitCode: 0 }; logger.debug("Shell command result:", { success: true, exitCode: 0, stdoutLen: result.stdout.length, stderrLen: result.stderr.length }); return result; } catch (error) { const execError = error; if (execError.code === "ETIMEDOUT" || execError.killed) { const result2 = { success: false, stdout: execError.stdout || "", stderr: execError.stderr || "", exitCode: typeof execError.code === "number" ? execError.code : -1, error: "Command timed out" }; logger.debug("Shell command timed out:", { success: false, exitCode: result2.exitCode, error: "Command timed out" }); return result2; } const result = { success: false, stdout: execError.stdout ? execError.stdout.toString() : "", stderr: execError.stderr ? execError.stderr.toString() : execError.message || "Command failed", exitCode: typeof execError.code === "number" ? execError.code : 1, error: execError.message || "Command failed" }; logger.debug("Shell command failed:", { success: false, exitCode: result.exitCode, error: result.error, stdoutLen: result.stdout.length, stderrLen: result.stderr.length }); return result; } }); rpcHandlerManager.registerHandler("readFile", async (data) => { logger.debug("Read file request:", data.path); const validation = validatePath(data.path, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } try { const buffer = await readFile(data.path); const content = buffer.toString("base64"); return { success: true, content }; } catch (error) { logger.debug("Failed to read file:", error); return { success: false, error: error instanceof Error ? error.message : "Failed to read file" }; } }); rpcHandlerManager.registerHandler("writeFile", async (data) => { logger.debug("Write file request:", data.path); const validation = validatePath(data.path, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } try { if (data.expectedHash !== null && data.expectedHash !== void 0) { try { const existingBuffer = await readFile(data.path); const existingHash = createHash("sha256").update(existingBuffer).digest("hex"); if (existingHash !== data.expectedHash) { return { success: false, error: `File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}` }; } } catch (error) { const nodeError = error; if (nodeError.code !== "ENOENT") { throw error; } return { success: false, error: "File does not exist but hash was provided" }; } } else { try { await stat(data.path); return { success: false, error: "File already exists but was expected to be new" }; } catch (error) { const nodeError = error; if (nodeError.code !== "ENOENT") { throw error; } } } const buffer = Buffer.from(data.content, "base64"); await writeFile(data.path, buffer); const hash = createHash("sha256").update(buffer).digest("hex"); return { success: true, hash }; } catch (error) { logger.debug("Failed to write file:", error); return { success: false, error: error instanceof Error ? error.message : "Failed to write file" }; } }); rpcHandlerManager.registerHandler("listDirectory", async (data) => { logger.debug("List directory request:", data.path); const validation = validatePath(data.path, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } try { const entries = await readdir(data.path, { withFileTypes: true }); const directoryEntries = await Promise.all( entries.map(async (entry) => { const fullPath = join$1(data.path, entry.name); let type = "other"; let size; let modified; if (entry.isDirectory()) { type = "directory"; } else if (entry.isFile()) { type = "file"; } try { const stats = await stat(fullPath); size = stats.size; modified = stats.mtime.getTime(); } catch (error) { logger.debug(`Failed to stat ${fullPath}:`, error); } return { name: entry.name, type, size, modified }; }) ); directoryEntries.sort((a, b) => { if (a.type === "directory" && b.type !== "directory") return -1; if (a.type !== "directory" && b.type === "directory") return 1; return a.name.localeCompare(b.name); }); return { success: true, entries: directoryEntries }; } catch (error) { logger.debug("Failed to list directory:", error); return { success: false, error: error instanceof Error ? error.message : "Failed to list directory" }; } }); rpcHandlerManager.registerHandler("getDirectoryTree", async (data) => { logger.debug("Get directory tree request:", data.path, "maxDepth:", data.maxDepth); const validation = validatePath(data.path, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } async function buildTree(path, name, currentDepth) { try { const stats = await stat(path); const node = { name, path, type: stats.isDirectory() ? "directory" : "file", size: stats.size, modified: stats.mtime.getTime() }; if (stats.isDirectory() && currentDepth < data.maxDepth) { const entries = await readdir(path, { withFileTypes: true }); const children = []; await Promise.all( entries.map(async (entry) => { if (entry.isSymbolicLink()) { logger.debug(`Skipping symlink: ${join$1(path, entry.name)}`); return; } const childPath = join$1(path, entry.name); const childNode = await buildTree(childPath, entry.name, currentDepth + 1); if (childNode) { children.push(childNode); } }) ); children.sort((a, b) => { if (a.type === "directory" && b.type !== "directory") return -1; if (a.type !== "directory" && b.type === "directory") return 1; return a.name.localeCompare(b.name); }); node.children = children; } return node; } catch (error) { logger.debug(`Failed to process ${path}:`, error instanceof Error ? error.message : String(error)); return null; } } try { if (data.maxDepth < 0) { return { success: false, error: "maxDepth must be non-negative" }; } const baseName = data.path === "/" ? "/" : data.path.split("/").pop() || data.path; const tree = await buildTree(data.path, baseName, 0); if (!tree) { return { success: false, error: "Failed to access the specified path" }; } return { success: true, tree }; } catch (error) { logger.debug("Failed to get directory tree:", error); return { success: false, error: error instanceof Error ? error.message : "Failed to get directory tree" }; } }); rpcHandlerManager.registerHandler("ripgrep", async (data) => { logger.debug("Ripgrep request with args:", data.args, "cwd:", data.cwd); if (data.cwd) { const validation = validatePath(data.cwd, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } } try { const result = await run$1(data.args, { cwd: data.cwd }); return { success: true, exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() }; } catch (error) { logger.debug("Failed to run ripgrep:", error); return { success: false, error: error instanceof Error ? error.message : "Failed to run ripgrep" }; } }); rpcHandlerManager.registerHandler("difftastic", async (data) => { logger.debug("Difftastic request with args:", data.args, "cwd:", data.cwd); if (data.cwd) { const validation = validatePath(data.cwd, workingDirectory); if (!validation.valid) { return { success: false, error: validation.error }; } } try { const result = await run(data.args, { cwd: data.cwd }); return { success: true, exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() }; } catch (error) { logger.debug("Failed to run difftastic:", error); return { success: false, error: error instanceof Error ? error.message : "Failed to run difftastic" }; } }); } const PRICING = { // --- Claude 4 & Future Models --- "claude-4.5-opus": { input: 5, output: 25, cache_write: 6.25, cache_read: 0.5 }, "claude-4.1-opus": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 }, "claude-4-opus": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 }, "claude-4.5-sonnet": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 }, "claude-4-sonnet": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 }, "claude-4.5-haiku": { input: 1, output: 5, cache_write: 1.25, cache_read: 0.1 }, // --- Legacy / Claude 3 --- "claude-3-opus-20240229": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 }, "claude-3-sonnet-20240229": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 }, "claude-3-5-sonnet-20240620": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 }, // New Sonnet 3.5 updated model "claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 }, "claude-3-haiku-20240307": { input: 0.25, output: 1.25, cache_write: 0.3125, cache_read: 0.025 }, "claude-3-5-haiku-20241022": { input: 0.8, output: 4, cache_write: 1, // Approx based on 1.25x rule usually or custom cache_read: 0.08 } }; const DEFAULT_MODEL = "claude-3-5-sonnet-20241022"; function calculateCost(usage, modelId) { let pricing = PRICING[modelId]; if (!pricing) { if (modelId?.includes("opus")) { if (modelId.includes("4.5")) pricing = PRICING["claude-4.5-opus"]; else if (modelId.includes("4.1")) pricing = PRICING["claude-4.1-opus"]; else if (modelId.includes("4")) pricing = PRICING["claude-4-opus"]; else pricing = PRICING["claude-3-opus-20240229"]; } else if (modelId?.includes("sonnet")) { if (modelId.includes("4.5")) pricing = PRICING["claude-4.5-sonnet"]; else if (modelId.includes("4")) pricing = PRICING["claude-4-sonnet"]; else pricing = PRICING["claude-3-5-sonnet-20241022"]; } else if (modelId?.includes("haiku")) { if (modelId.includes("4.5")) pricing = PRICING["claude-4.5-haiku"]; else if (modelId.includes("3.5")) pricing = PRICING["claude-3-5-haiku-20241022"]; else pricing = PRICING["claude-3-haiku-20240307"]; } else pricing = PRICING[DEFAULT_MODEL]; } const inputCost = usage.input_tokens / 1e6 * pricing.input; const outputCost = usage.output_tokens / 1e6 * pricing.output; const cacheWriteCost = (usage.cache_creation_input_tokens || 0) / 1e6 * pricing.cache_write; const cacheReadCost = (usage.cache_read_input_tokens || 0) / 1e6 * pricing.cache_read; const totalInputCost = inputCost + cacheWriteCost + cacheReadCost; return { total: totalInputCost + outputCost, input: totalInputCost, output: outputCost }; } class ApiSessionClient extends EventEmitter { token; sessionId; metadata; metadataVersion; agentState; agentStateVersion; socket; pendingMessages = []; pendingMessageCallback = null; rpcHandlerManager; agentStateLock = new AsyncLock(); metadataLock = new AsyncLock(); encryptionKey; encryptionVariant; constructor(token, session) { super(); this.token = token; this.sessionId = session.id; this.metadata = session.metadata; this.metadataVersion = session.metadataVersion; this.agentState = session.agentState; this.agentStateVersion = session.agentStateVersion; this.encryptionKey = session.encryptionKey; this.encryptionVariant = session.encryptionVariant; this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.sessionId, encryptionKey: this.encryptionKey, encryptionVariant: this.encryptionVariant, logger: (msg, data) => logger.debug(msg, data) }); registerCommonHandlers(this.rpcHandlerManager, this.metadata.path); this.socket = io(configuration.serverUrl, { auth: { token: this.token, clientType: "session-scoped", sessionId: this.sessionId }, path: "/v1/updates", reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1e3, reconnectionDelayMax: 5e3, transports: ["websocket"], withCredentials: true, autoConnect: false }); this.socket.on("connect", () => { logger.debug("Socket connected successfully"); this.rpcHandlerManager.onSocketConnect(this.socket); }); this.socket.on("rpc-request", async (data, callback) => { callback(await this.rpcHandlerManager.handleRequest(data)); }); this.socket.on("disconnect", (reason) => { logger.debug("[API] Socket disconnected:", reason); this.rpcHandlerManager.onSocketDisconnect(); }); this.socket.on("connect_error", (error) => { logger.debug("[API] Socket connection error:", error); this.rpcHandlerManager.onSocketDisconnect(); }); this.socket.on("update", (data) => { try { logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", data); if (!data.body) { logger.debug("[SOCKET] [UPDATE] [ERROR] No body in update!"); return; } if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") { const body = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.message.content.c)); logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", body); const userResult = UserMessageSchema.safeParse(body); if (userResult.success) { if (this.pendingMessageCallback) { this.pendingMessageCallback(userResult.data); } else { this.pendingMessages.push(userResult.data); } } else { this.emit("message", body); } } else if (data.body.t === "update-session") { if (data.body.metadata && data.body.metadata.version > this.metadataVersion) { this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.metadata.value)); this.metadataVersion = data.body.metadata.version; } if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) { this.agentState = data.body.agentState.value ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.agentState.value)) : null; this.agentStateVersion = data.body.agentState.version; } } else if (data.body.t === "update-machine") { logger.debug(`[SOCKET] WARNING: Session client received unexpected machine update - ignoring`); } else { this.emit("message", data.body); } } catch (error) { logger.debug("[SOCKET] [UPDATE] [ERROR] Error handling update", { error }); } }); this.socket.on("error", (error) => { logger.debug("[API] Socket error:", error); }); this.socket.connect(); } onUserMessage(callback) { this.pendingMessageCallback = callback; while (this.pendingMessages.length > 0) { callback(this.pendingMessages.shift()); } } /** * Send message to session * @param body - Message body (can be MessageContent or raw content for agent messages) */ sendClaudeSessionMessage(body) { let content; if (body.type === "user" && typeof body.message.content === "string" && body.isSidechain !== true && body.isMeta !== true) { content = { role: "user", content: { type: "text", text: body.message.content }, meta: { sentFrom: "cli" } }; } else { content = { role: "agent", content: { type: "output", data: body // This wraps the entire Claude message }, meta: { sentFrom: "cli" } }; } logger.debugLargeJson("[SOCKET] Sending message through socket:", content); if (!this.socket.connected) { logger.debug("[API] Socket not connected, cannot send Claude session message. Message will be lost:", { type: body.type }); return; } const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); this.socket.emit("message", { sid: this.sessionId, message: encrypted }); if (body.type === "assistant" && body.message?.usage) { try { this.sendUsageData(body.message.usage, body.message.model); } catch (error) { logger.debug("[SOCKET] Failed to send usage data:", error); } } if (body.type === "summary" && "summary" in body && "leafUuid" in body) { this.updateMetadata((metadata) => ({ ...metadata, summary: { text: body.summary, updatedAt: Date.now() } })); } } sendCodexMessage(body) { let content = { role: "agent", content: { type: "codex", data: body // This wraps the entire Claude message }, meta: { sentFrom: "cli" } }; const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); if (!this.socket.connected) { logger.debug("[API] Socket not connected, cannot send message. Message will be lost:", { type: body.type }); } this.socket.emit("message", { sid: this.sessionId, message: encrypted }); } /** * Send a generic agent message to the session using ACP (Agent Communication Protocol) format. * Works for any agent type (Gemini, Codex, Claude, etc.) - CLI normalizes to unified ACP format. * * @param provider - The agent provider sending the message (e.g., 'gemini', 'codex', 'claude') * @param body - The message payload (type: 'message' | 'reasoning' | 'tool-call' | 'tool-result') */ sendAgentMessage(provider, body) { let content = { role: "agent", content: { type: "acp", provider, data: body }, meta: { sentFrom: "cli" } }; logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: body.type, hasMessage: "message" in body }); con