UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

1,459 lines (1,443 loc) 267 kB
import axios, { isAxiosError } from 'axios'; import chalk from 'chalk'; import fs, { appendFileSync } from 'fs'; import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, constants, renameSync, unlinkSync, writeFileSync, chmodSync, promises, openSync, writeSync, fsyncSync, closeSync } from 'node:fs'; import os__default, { homedir, tmpdir } from 'node:os'; import path__default, { join, basename, dirname as dirname$1 } from 'node:path'; import { EventEmitter } from 'node:events'; import { io } from 'socket.io-client'; import * as z from 'zod'; import { z as z$1 } from 'zod'; import { randomBytes, createCipheriv, createDecipheriv, randomUUID } from 'node:crypto'; import tweetnacl from 'tweetnacl'; import { spawn, exec, execFile as execFile$3 } from 'child_process'; import { promisify } from 'util'; import { mkdir, readFile, appendFile, stat, writeFile, readdir } from 'fs/promises'; import { createHash } from 'crypto'; import path__default$1, { dirname, resolve, join as join$1 } from 'path'; import { fileURLToPath } from 'url'; import os__default$1, { platform } from 'os'; import { execFile as execFile$2, spawn as spawn$1 } from 'node:child_process'; import fs__default, { readFile as readFile$1, open, stat as stat$1, unlink, mkdir as mkdir$1, writeFile as writeFile$1, rename, mkdtemp, rmdir, constants as constants$1 } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { promisify as promisify$1 } from 'node:util'; import { Expo } from 'expo-server-sdk'; var name = "consortium"; var version = "0.8.11"; 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-local": "./bin/consortium-local.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" } }, "./pi-ext": { require: { types: "./dist/pi/ext/consortium-permission/index.d.cts", "default": "./dist/pi/ext/consortium-permission/index.cjs" }, "import": { types: "./dist/pi/ext/consortium-permission/index.d.mts", "default": "./dist/pi/ext/consortium-permission/index.mjs" } } }; var files = [ "dist", "bin", "scripts", "tools/archives", "tools/licenses", "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: "npx shx rm -rf dist && node -e \"process.argv=process.argv.concat(['--noEmit']);require('typescript/lib/tsc.js')\" && npx pkgroll && node scripts/copy-pi-ext-pkg.cjs", "check:no-placeholder-key": "node -e \"const fs=require('fs');const s=fs.readFileSync('src/harness/verifySignature.ts','utf8');const m=s.match(/-----BEGIN PUBLIC KEY-----[\\s\\S]*?-----END PUBLIC KEY-----/);if(!m||m[0].includes('PLACEHOLDER')){console.error('placeholder pubkey in verifySignature.ts');process.exit(1)}\"", test: "$npm_execpath run check:no-placeholder-key && $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:cluster": "$npm_execpath run build && tsx --env-file .env.dev-cluster 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 && node scripts/check-dist-version.cjs", release: "$npm_execpath install && release-it", postinstall: "node scripts/unpack-tools.cjs && node scripts/fix-node-pty-perms.cjs && node scripts/fix-libsodium-esm.cjs && node scripts/verify-platform.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", "@consortium/extension-sdk": "*", "@modelcontextprotocol/sdk": "^1.25.3", "@sentry/node": "^8.46.0", "@stablelib/base64": "^2.0.1", "@stablelib/hex": "^2.0.1", ai: "^5.0.107", axios: "^1.13.2", "better-sqlite3": "^12.9.0", 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", "libsodium-wrappers": "^0.7.13", 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/better-sqlite3": "^7.6.13", "@types/cross-spawn": "^6.0.6", "@types/http-proxy": "^1.17.17", "@types/libsodium-wrappers": "^0.7.14", "@types/node": ">=20", "@types/ps-list": "^6.2.1", "@types/qrcode-terminal": "^0.12.2", "@types/react": "^19.2.7", "@types/tmp": "^0.2.6", "cross-env": "^10.1.0", dotenv: "^16.6.1", eslint: "^9", "eslint-config-prettier": "^10", "ink-testing-library": "^4.0.0", 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", "@consortium/crypto": "*" }; var resolutions = { "whatwg-url": "14.2.0", "parse-path": "7.0.3", "@types/parse-path": "7.0.3" }; var optionalDependencies = { "node-pty": "^1.0.0", "consortium-code-darwin-arm64": "0.2.0-canary.20260417210700", "consortium-code-darwin-x64": "0.2.0-canary.20260417210700", "consortium-code-linux-arm64": "0.2.0-canary.20260417210700", "consortium-code-linux-x64": "0.2.0-canary.20260417210700" }; var publishConfig = { registry: "https://registry.npmjs.org" }; var packageManager = "yarn@1.22.22"; var engines = { node: ">=20" }; 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, optionalDependencies: optionalDependencies, publishConfig: publishConfig, packageManager: packageManager, engines: engines }; function isLocalhostUrl(url) { try { const host = new URL(url).hostname; return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1"; } catch { return false; } } class Configuration { serverUrl; webappUrl; isDaemonProcess; // Directories and paths (from persistence) consortiumHomeDir; logsDir; settingsFile; privateKeyFile; daemonStateFile; daemonLockFile; currentCliVersion; isExperimentalEnabled; disableCaffeinate; authToken = process.env.CONSORTIUM_TOKEN; constructor() { this.serverUrl = process.env.CONSORTIUM_SERVER_URL || "https://api.consortium.dev"; this.webappUrl = process.env.CONSORTIUM_WEBAPP_URL || (isLocalhostUrl(this.serverUrl) ? `http://localhost:${process.env.CONSORTIUM_WEBAPP_PORT || "8081"}` : "https://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, mode: 448 }); } if (!existsSync(this.logsDir)) { mkdirSync(this.logsDir, { recursive: true, mode: 448 }); } } } 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 : arg instanceof Error ? JSON.stringify(arg, Object.getOwnPropertyNames(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 Promise.resolve().then(function () { return persistence; }); 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$1.object({ c: z$1.string(), // Base64 encoded encrypted content t: z$1.literal("encrypted") }); const UpdateBodySchema = z$1.object({ message: z$1.object({ id: z$1.string(), seq: z$1.number(), content: SessionMessageContentSchema }), sid: z$1.string(), // Session ID t: z$1.literal("new-message") }); const UpdateSessionBodySchema = z$1.object({ t: z$1.literal("update-session"), sid: z$1.string(), metadata: z$1.object({ version: z$1.number(), value: z$1.string() }).nullish(), agentState: z$1.object({ version: z$1.number(), value: z$1.string() }).nullish() }); const UpdateMachineBodySchema = z$1.object({ t: z$1.literal("update-machine"), machineId: z$1.string(), metadata: z$1.object({ version: z$1.number(), value: z$1.string() }).nullish(), daemonState: z$1.object({ version: z$1.number(), value: z$1.string() }).nullish() }); z$1.object({ id: z$1.string(), seq: z$1.number(), body: z$1.union([ UpdateBodySchema, UpdateSessionBodySchema, UpdateMachineBodySchema ]), createdAt: z$1.number() }); z$1.object({ host: z$1.string(), platform: z$1.string(), consortiumCliVersion: z$1.string(), homeDir: z$1.string(), consortiumHomeDir: z$1.string(), consortiumLibDir: z$1.string(), // Hardware specs — collected once at daemon startup from os module // (no syscall, libuv-cached). Optional so older daemons stay valid. cpuCount: z$1.number().optional(), memoryTotalMb: z$1.number().optional(), // OpenClaw detection fields (populated by daemon heartbeat) openclawInstalled: z$1.boolean().optional(), openclawVersion: z$1.string().nullable().optional(), openclawWorkspaceExists: z$1.boolean().optional(), openclawGatewayRunning: z$1.boolean().optional(), openclawGatewayPort: z$1.number().optional(), openclawChannels: z$1.array(z$1.string()).optional(), openclawAgentCount: z$1.number().optional(), openclawAgents: z$1.array(z$1.object({ name: z$1.string(), workspace: z$1.string(), agentDir: z$1.string(), model: z$1.string(), routingRules: z$1.number(), isDefault: z$1.boolean() })).optional(), // Terminal multiplexer capabilities — populated at daemon startup. // Tells the mobile app whether "New persistent terminal" should be enabled. terminalCapabilities: z$1.object({ tmux: z$1.boolean(), zellij: z$1.boolean() }).optional() }); z$1.object({ status: z$1.union([ z$1.enum(["running", "shutting-down"]), z$1.string() // Forward compatibility ]), pid: z$1.number().optional(), httpPort: z$1.number().optional(), startedAt: z$1.number().optional(), shutdownRequestedAt: z$1.number().optional(), shutdownSource: z$1.union([ z$1.enum(["mobile-app", "cli", "os-signal", "unknown"]), z$1.string() // Forward compatibility ]).optional() }); z$1.object({ content: SessionMessageContentSchema, createdAt: z$1.number(), id: z$1.string(), seq: z$1.number(), updatedAt: z$1.number() }); const MessageMetaSchema = z$1.object({ sentFrom: z$1.string().optional(), // Source identifier permissionMode: z$1.enum(["default", "acceptEdits", "bypassPermissions", "plan", "read-only", "safe-yolo", "yolo"]).optional(), // Permission mode for this message model: z$1.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z$1.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z$1.string().nullable().optional(), // Custom system prompt for this message (null = reset) appendSystemPrompt: z$1.string().nullable().optional(), // Append to system prompt for this message (null = reset) allowedTools: z$1.array(z$1.string()).nullable().optional(), // Allowed tools for this message (null = reset) disallowedTools: z$1.array(z$1.string()).nullable().optional() // Disallowed tools for this message (null = reset) }); z$1.object({ session: z$1.object({ id: z$1.string(), tag: z$1.string(), seq: z$1.number(), createdAt: z$1.number(), updatedAt: z$1.number(), metadata: z$1.string(), metadataVersion: z$1.number(), agentState: z$1.string().nullable(), agentStateVersion: z$1.number() }) }); const UserMessageSchema = z$1.object({ role: z$1.literal("user"), content: z$1.object({ type: z$1.literal("text"), text: z$1.string(), // Session-attachments (PR 5): Drive node ids and the client-generated // local id ride alongside `text` inside the encrypted body. Optional / // additive — older messages without these fields must continue to // parse. PR 6 will consume them when forwarding to ACP. attachmentIds: z$1.array(z$1.string()).optional(), localId: z$1.string().optional(), // PR 5 inline-envelope path: when the message carries its own // driveId + per-message DEK, the resolver can hit Drive directly // without consulting the server's binding rows. driveId: z$1.string().optional(), driveNodeIds: z$1.array(z$1.string()).optional(), driveDek: z$1.string().optional() }), localKey: z$1.string().optional(), // Mobile messages include this meta: MessageMetaSchema.optional(), // Convenience copy — apiSession also surfaces attachmentIds at the top // level (mirroring `content.attachmentIds`) so consumers don't have to // reach through `content`. PR 6 owns the forwarding path that reads // this; PR 5 just makes sure the field is preserved end-to-end. attachmentIds: z$1.array(z$1.string()).optional(), localId: z$1.string().optional(), driveId: z$1.string().optional(), driveDek: z$1.string().optional() }); const AgentMessageSchema = z$1.object({ role: z$1.literal("agent"), content: z$1.object({ type: z$1.literal("output"), data: z$1.any() }), meta: MessageMetaSchema.optional() }); z$1.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); } } function authChallenge(secret) { const keypair = tweetnacl.sign.keyPair.fromSeed(secret); const challenge = getRandomBytes(32); const signature = tweetnacl.sign.detached(challenge, keypair.secretKey); return { challenge, publicKey: keypair.publicKey, signature }; } class MessageDecodeError extends Error { constructor(message) { super(message); this.name = "MessageDecodeError"; } } new TextEncoder(); const strictTextDecoder = new TextDecoder("utf-8", { fatal: true }); function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function coerceAttachmentIds(raw) { if (!Array.isArray(raw)) return []; const out = []; for (const entry of raw) { if (typeof entry !== "string") return []; out.push(entry); } return out; } function decodeMessageBody(plaintext) { let text; try { text = strictTextDecoder.decode(plaintext); } catch (e) { throw new MessageDecodeError( `Message body is not valid UTF-8: ${e.message}` ); } const trimmed = text.trimStart(); if (!trimmed.startsWith("{")) { return { v: 1, text }; } let parsed; try { parsed = JSON.parse(text); } catch { return { v: 1, text }; } if (!isPlainObject(parsed)) { return { v: 1, text }; } if (typeof parsed.v !== "number" || parsed.v !== 2) { return { v: 1, text }; } const innerText = typeof parsed.text === "string" ? parsed.text : ""; const attachmentIds = coerceAttachmentIds(parsed.attachmentIds); const result = { v: 2, text: innerText, attachmentIds }; if (typeof parsed.localId === "string") { result.localId = parsed.localId; } return result; } 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.emitWithAck("rpc-register", { method: prefixedMethod }).catch((err) => { this.logger(`[RPC] Failed to register ${prefixedMethod}: ${err}`); }); } } /** * 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)); } } async onSocketConnect(socket) { this.socket = socket; const registrations = Array.from(this.handlers.keys()).map( (prefixedMethod) => socket.emitWithAck("rpc-register", { method: prefixedMethod }).catch((err) => { this.logger(`[RPC] Failed to register ${prefixedMethod}: ${err}`); }) ); await Promise.all(registrations); } 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); } /** * Invoke a registered handler in-process with already-decrypted params. * Used by the generic harness dispatcher to fall through to a legacy * per-harness RPC (e.g. `openclaw-agent-add`) when the harness provider * doesn't expose the dispatcher-facing method directly. */ async callLocal(method, params) { const prefixedMethod = this.getPrefixedMethod(method); const handler = this.handlers.get(prefixedMethod); if (!handler) return void 0; return await handler(params); } /** * 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(process.execPath, [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$1 = promisify(exec); async function gitExec(command, cwd, timeout = 3e4) { const options = { cwd, timeout }; const result = await execAsync$1(command, options); return { stdout: result.stdout?.toString() || "", stderr: result.stderr?.toString() || "" }; } async function ensureDevGitignore(repoPath) { const gitignorePath = join$1(repoPath, ".gitignore"); try { const content = await readFile(gitignorePath, "utf8"); if (!content.includes(".dev/") && !content.includes(".dev\n")) { await appendFile(gitignorePath, "\n# Worktree directory\n.dev/\n"); } } catch { await appendFile(gitignorePath, "# Worktree directory\n.dev/\n"); } } function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").substring(0, 40); } async function createCardWorktree(repoPath, cardId, cardSlug, baseBranch = "main") { const slug = slugify(cardSlug); const cardIdShort = cardId.substring(0, 7); const branchName = `card/${cardIdShort}/${slug}`; const worktreeDir = `.dev/worktrees/${slug}`; const worktreePath = join$1(repoPath, worktreeDir); await ensureDevGitignore(repoPath); await mkdir(join$1(repoPath, ".dev/worktrees"), { recursive: true }); try { await gitExec("git fetch origin", repoPath, 6e4); } catch (e) { logger.debug(`[Worktree] Fetch failed (may be offline): ${e}`); } const startPoint = `origin/${baseBranch}`; try { await gitExec(`git worktree add -b "${branchName}" "${worktreeDir}" "${startPoint}"`, repoPath); } catch (error) { if (error.stderr?.includes("already exists")) { try { await gitExec(`git worktree add "${worktreeDir}" "${branchName}"`, repoPath); } catch (e2) { if (e2.stderr?.includes("already registered")) { return { worktreePath, branchName }; } throw new Error(`Failed to create worktree: ${e2.stderr || e2.message}`); } } else { throw new Error(`Failed to create worktree: ${error.stderr || error.message}`); } } return { worktreePath, branchName }; } async function listWorktrees(repoPath) { const { stdout } = await gitExec("git worktree list --porcelain", repoPath); const worktrees = []; let current = {}; for (const line of stdout.split("\n")) { if (line.startsWith("worktree ")) { if (current.path) { worktrees.push(current); } current = { path: line.substring(9), changedFiles: [] }; } else if (line.startsWith("HEAD ")) { current.head = line.substring(5); } else if (line.startsWith("branch ")) { const branch = line.substring(7).replace("refs/heads/", ""); current.branch = branch; const cardMatch = branch.match(/^card\/([^/]+)\//); if (cardMatch) { current.cardId = cardMatch[1]; } } } if (current.path) { worktrees.push(current); } const cardWorktrees = worktrees.filter((w) => w.path.includes(".dev/worktrees")); for (const wt of cardWorktrees) { wt.changedFiles = await getWorktreeChangedFiles(wt.path); } return cardWorktrees; } async function getWorktreeChangedFiles(worktreePath, repoPath) { try { const { stdout } = await gitExec("git diff --name-only origin/main...HEAD", worktreePath); return stdout.trim().split("\n").filter(Boolean); } catch { try { const { stdout } = await gitExec("git diff --name-only HEAD", worktreePath); return stdout.trim().split("\n").filter(Boolean); } catch { return []; } } } async function cleanupWorktree(repoPath, worktreePath, deleteBranch = false) { let branchName; if (deleteBranch) { try { const { stdout } = await gitExec("git rev-parse --abbrev-ref HEAD", worktreePath); branchName = stdout.trim(); } catch { } } try { await gitExec(`git worktree remove "${worktreePath}" --force`, repoPath); } catch (error) { await gitExec("git worktree prune", repoPath); } if (deleteBranch && branchName && branchName !== "main" && branchName !== "master") { try { await gitExec(`git branch -D "${branchName}"`, repoPath); } catch { } } } async function createPRFromWorktree(worktreePath, title, body, baseBranch = "main") { try { await gitExec("git add -A", worktreePath); await gitExec(`git commit -m "${title.replace(/"/g, '\\"')}" --allow-empty`, worktreePath); } catch { } const { stdout: branchStdout } = await gitExec("git rev-parse --abbrev-ref HEAD", worktreePath); const branchName = branchStdout.trim(); await gitExec(`git push -u origin "${branchName}"`, worktreePath, 6e4); const escapedTitle = title.replace(/'/g, "'\\''"); const escapedBody = body.replace(/'/g, "'\\''"); const { stdout: prStdout } = await gitExec( `gh pr create --title '${escapedTitle}' --body '${escapedBody}' --base '${baseBranch}' --json number,url`, worktreePath, 6e4 ); const prData = JSON.parse(prStdout.trim()); return { prNumber: prData.number, prUrl: prData.url }; } const ALWAYS_CONFLICT_FILES = [ "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "Gemfile.lock", "go.sum", "poetry.lock", "composer.lock" ]; function detectConflicts(activeWorktrees, proposedFiles) { const fileToWorktrees = /* @__PURE__ */ new Map(); for (const wt of activeWorktrees) { for (const file of wt.changedFiles) { const existing = fileToWorktrees.get(file) || []; existing.push(wt.branch); fileToWorktrees.set(file, existing); } } const conflicts = []; for (const [file, worktrees] of fileToWorktrees) { if (worktrees.length > 1) { const basename = file.split("/").pop() || file; conflicts.push({ file, worktrees, isLockFile: ALWAYS_CONFLICT_FILES.includes(basename) }); } } return { hasConflicts: conflicts.length > 0, hasLockFileConflicts: conflicts.some((c) => c.isLockFile), conflicts }; } function formatConflictReport(report) { if (!report.hasConflicts) { return "No file conflicts detected between active worktrees."; } const lines = ["## File Conflict Warning\n"]; if (report.hasLockFileConflicts) { lines.push("**CRITICAL: Lock file conflicts detected.** These cards should NOT run in parallel.\n"); } lines.push("Conflicting files:"); for (const conflict of report.conflicts) { const marker = conflict.isLockFile ? " \u26A0\uFE0F LOCK FILE" : ""; lines.push(`- \`${conflict.file}\`${marker} \u2192 modified by: ${conflict.worktrees.join(", ")}`); } return lines.join("\n"); } 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 (e