387 lines
12 KiB
JavaScript
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 };
|