Files
claude-nova/Scripts/client.js
T
2026-08-11 12:38:02 -03:00

387 lines
12 KiB
JavaScript

//
// 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 };