// // client.js — drives `claude` in streaming JSON mode. // // The CLI speaks newline-delimited JSON on stdio. Two kinds of traffic share // that channel: // // * session messages — system / assistant / user / result / stream_event // * control traffic — request/response pairs in both directions, used for // the initialize handshake, permission prompts // (`can_use_tool`), interrupts and live setting changes // // This class owns the process and the framing; everything above it deals in // parsed messages. // const { log, warn, processWriter } = require("./util.js"); const cli = require("./cli.js"); const CONTROL_TIMEOUT = 60000; function uuid() { const bytes = new Uint8Array(16); if (nova.crypto && typeof nova.crypto.getRandomValues === "function") { nova.crypto.getRandomValues(bytes); } else { for (let i = 0; i < bytes.length; i++) { bytes[i] = Math.floor(Math.random() * 256); } } bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 const hex = []; for (let i = 0; i < bytes.length; i++) { hex.push(bytes[i].toString(16).padStart(2, "0")); } const s = hex.join(""); return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`; } class ClaudeClient { /** * @param {object} handlers * onMessage(message) — a session message from the CLI * onPermission(request) — must resolve to a PermissionResult * onPermissionCancelled(id) — the CLI withdrew a pending prompt * onExit(status, stderr) — the process ended * onError(error) — transport-level failure */ constructor(handlers = {}) { this.handlers = handlers; this.process = null; this.writer = null; this.sessionId = null; this.running = false; this.stdoutBuffer = ""; this.stderrLines = []; this.pendingControl = new Map(); this.inFlightPermissions = new Map(); } get isRunning() { return this.running; } /** * Build the command line. Kept separate so it can be inspected and tested. */ buildArgs(options) { const args = [ "--print", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", // Route permission asks over the control channel to `onPermission` // instead of auto-denying them. "--permission-prompt-tool", "stdio", ]; if (options.streamPartial) args.push("--include-partial-messages"); if (options.resume) { args.push("--resume", options.resume); } else { args.push("--session-id", options.sessionId); } if (options.model) args.push("--model", options.model); if (options.effort) args.push("--effort", options.effort); if (options.permissionMode) args.push("--permission-mode", options.permissionMode); for (const dir of options.additionalDirectories || []) { args.push("--add-dir", dir); } for (const extra of options.extraArgs || []) { args.push(extra); } return args; } /** Launch the CLI and complete the initialize handshake. */ async start(options = {}) { if (this.running) { throw new Error("A Claude session is already running."); } const binary = await cli.resolveBinary(); this.sessionId = options.resume || options.sessionId || uuid(); const args = this.buildArgs( Object.assign({}, options, { sessionId: this.sessionId }) ); log("starting:", binary, args.join(" ")); this.stdoutBuffer = ""; this.stderrLines = []; this.process = new Process(binary, { args: args, cwd: options.cwd || nova.workspace.path || nova.environment["HOME"], env: cli.environment(), stdio: "pipe", }); this.process.onStdout((chunk) => this._onStdout(chunk)); this.process.onStderr((chunk) => this._onStderr(chunk)); this.process.onDidExit((status) => this._onExit(status)); this.process.start(); this.writer = processWriter(this.process); this.running = true; try { await this._control({ subtype: "initialize" }, 20000); } catch (err) { // A failed handshake is worth reporting, but older CLIs still work // for plain message traffic, so the session continues. warn("initialize handshake failed:", err.message || err); } return this.sessionId; } /** Send a user turn. `content` is a string or an array of content blocks. */ send(content) { this._write({ type: "user", message: { role: "user", content: content, }, parent_tool_use_id: null, }); } async interrupt() { return this._control({ subtype: "interrupt" }, 15000); } async setPermissionMode(mode) { return this._control({ subtype: "set_permission_mode", mode: mode }); } async setModel(model) { return this._control({ subtype: "set_model", model: model || undefined }); } /** Stop the session. Graceful by default; `force` sends SIGKILL. */ stop(force = false) { if (!this.process) return; for (const [, pending] of this.pendingControl) { pending.reject(new Error("The Claude session ended.")); } this.pendingControl.clear(); this.inFlightPermissions.clear(); try { if (this.writer) this.writer.close(); } catch (_) { /* already closed */ } try { if (force) this.process.kill(); else this.process.terminate(); } catch (err) { warn("could not stop the process:", err); } this.running = false; } // -- internals --------------------------------------------------------- _write(object) { if (!this.writer) { throw new Error("The Claude session is not running."); } this.writer.write(JSON.stringify(object) + "\n"); } /** Send a control request and wait for its matching response. */ _control(request, timeout = CONTROL_TIMEOUT) { return new Promise((resolve, reject) => { if (!this.running && request.subtype !== "initialize") { reject(new Error("The Claude session is not running.")); return; } const requestId = "nova-" + uuid(); const timer = setTimeout(() => { this.pendingControl.delete(requestId); reject(new Error(`Claude Code did not answer "${request.subtype}" in time.`)); }, timeout); this.pendingControl.set(requestId, { resolve: (value) => { clearTimeout(timer); this.pendingControl.delete(requestId); resolve(value); }, reject: (err) => { clearTimeout(timer); this.pendingControl.delete(requestId); reject(err); }, }); try { this._write({ type: "control_request", request_id: requestId, request: request }); } catch (err) { const pending = this.pendingControl.get(requestId); if (pending) pending.reject(err); } }); } _onStdout(chunk) { this.stdoutBuffer += chunk; let newline = this.stdoutBuffer.indexOf("\n"); while (newline >= 0) { const line = this.stdoutBuffer.slice(0, newline).trim(); this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); if (line) this._handleLine(line); newline = this.stdoutBuffer.indexOf("\n"); } } _onStderr(chunk) { const text = String(chunk).trim(); if (!text) return; this.stderrLines.push(text); if (this.stderrLines.length > 200) this.stderrLines.shift(); log("stderr:", text); } _handleLine(line) { let message; try { message = JSON.parse(line); } catch (err) { // Anything that is not JSON is CLI chatter, not protocol traffic. log("non-JSON output:", line.slice(0, 200)); return; } switch (message.type) { case "control_response": this._handleControlResponse(message.response); return; case "control_request": this._handleControlRequest(message); return; case "control_cancel_request": this._handleControlCancel(message.request_id); return; case "keep_alive": case "transcript_mirror": return; default: break; } if (message.session_id) { this.sessionId = message.session_id; } if (this.handlers.onMessage) { this.handlers.onMessage(message); } } _handleControlResponse(response) { if (!response || !response.request_id) return; const pending = this.pendingControl.get(response.request_id); if (!pending) return; if (response.subtype === "success") { pending.resolve(response.response); } else { pending.reject(new Error(response.error || "Claude Code reported a control error.")); } } async _handleControlRequest(message) { const requestId = message.request_id; const request = message.request || {}; try { let result; if (request.subtype === "can_use_tool") { if (!this.handlers.onPermission) { throw new Error("No permission handler is registered."); } // A handler that throws must still produce an answer: the CLI // blocks on this request, so silence would hang the session. this.inFlightPermissions.set(requestId, request); result = await this.handlers.onPermission(request, requestId); if (!this.inFlightPermissions.has(requestId)) { // Cancelled while the user was deciding; the CLI moved on. return; } this.inFlightPermissions.delete(requestId); result = Object.assign({ toolUseID: request.tool_use_id }, result); } else { throw new Error("Unsupported control request subtype: " + request.subtype); } this._write({ type: "control_response", response: { subtype: "success", request_id: requestId, response: result }, }); } catch (err) { this.inFlightPermissions.delete(requestId); try { this._write({ type: "control_response", response: { subtype: "error", request_id: requestId, error: String((err && err.message) || err), }, }); } catch (writeErr) { warn("could not answer a control request:", writeErr); } } } _handleControlCancel(requestId) { if (this.inFlightPermissions.has(requestId)) { this.inFlightPermissions.delete(requestId); if (this.handlers.onPermissionCancelled) { this.handlers.onPermissionCancelled(requestId); } } } _onExit(status) { this.running = false; this.writer = null; for (const [, pending] of this.pendingControl) { pending.reject(new Error("The Claude session ended.")); } this.pendingControl.clear(); const stderr = this.stderrLines.join("\n"); log("process exited with", status); if (this.handlers.onExit) { this.handlers.onExit(status, stderr); } } } module.exports = { ClaudeClient, uuid };