Files
2026-07-13 12:52:40 +08:00

742 lines
18 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname } from "node:path";
import {
type AgentToolContext,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import type { AgentEvent } from "@cline/shared";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
PendingAskQuestion,
PendingToolApproval,
PromptInQueue,
SidecarContext,
} from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
// ---------------------------------------------------------------------------
// Helpers — WebSocket broadcast
// ---------------------------------------------------------------------------
function nowMs(): number {
return Date.now();
}
function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
const encoded = JSON.stringify({
type: "event",
event: { name, payload },
});
for (const client of ctx.wsClients) {
try {
client.send(encoded);
} catch {
ctx.wsClients.delete(client);
}
}
}
function appendSessionChunk(
sessionId: string,
stream: string,
chunk: string,
ts: number,
): void {
const path = sessionLogPath(sessionId);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ ts, stream, chunk })}\n`, {
flag: "a",
});
}
function emitChunk(
ctx: SidecarContext,
sessionId: string,
stream: string,
chunk: string,
): void {
const ts = nowMs();
appendSessionChunk(sessionId, stream, chunk, ts);
const nextIndex = (ctx.streamIndices.get(sessionId) ?? 0) + 1;
ctx.streamIndices.set(sessionId, nextIndex);
sendEvent(ctx, "chat_event", {
sessionId,
stream,
chunk,
ts,
index: nextIndex,
});
}
export { sendEvent, emitChunk, nowMs };
// ---------------------------------------------------------------------------
// Exported broadcast helpers (used by server.ts / commands)
// ---------------------------------------------------------------------------
export function broadcastEvent(
ctx: SidecarContext,
name: string,
payload: unknown,
): void {
sendEvent(ctx, name, payload);
}
export function broadcastChunk(
ctx: SidecarContext,
sessionId: string,
stream: string,
chunk: string,
): void {
emitChunk(ctx, sessionId, stream, chunk);
}
// ---------------------------------------------------------------------------
// Prompt queue helpers
// ---------------------------------------------------------------------------
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
return session.promptsInQueue;
}
function sendPromptsInQueueSnapshot(
ctx: SidecarContext,
sessionId: string,
): void {
const session = ctx.liveSessions.get(sessionId);
sendEvent(ctx, "prompts_in_queue_state", {
sessionId,
items: session ? getPromptsInQueue(session) : [],
});
}
// ---------------------------------------------------------------------------
// Agent event mapping: AgentEvent → frontend transport chunks
// ---------------------------------------------------------------------------
function handleAgentEvent(
ctx: SidecarContext,
sessionId: string,
event: AgentEvent,
): void {
switch (event.type) {
case "content_start": {
if (event.contentType === "text" && event.text) {
emitChunk(ctx, sessionId, "chat_text", event.text);
} else if (event.contentType === "reasoning" && event.reasoning) {
emitChunk(
ctx,
sessionId,
"chat_reasoning",
JSON.stringify({
text: event.reasoning,
redacted: event.redacted === true,
}),
);
} else if (event.contentType === "tool") {
emitChunk(
ctx,
sessionId,
"chat_tool_call_start",
JSON.stringify({
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
}),
);
}
break;
}
case "content_update": {
if (event.contentType === "tool") {
emitChunk(
ctx,
sessionId,
"chat_tool_call_update",
JSON.stringify({
toolCallId: event.toolCallId,
toolName: event.toolName,
update: event.update,
}),
);
}
break;
}
case "content_end": {
// Text and reasoning `content_start` events are already emitted as
// incremental deltas. Runtime `content_end` carries the final full text,
// so forwarding it as another chat_text/chat_reasoning chunk duplicates
// the live UI while persisted history remains correct after hydration.
if (event.contentType === "text" || event.contentType === "reasoning") {
break;
}
if (event.contentType === "tool") {
emitChunk(
ctx,
sessionId,
"chat_tool_call_end",
JSON.stringify({
toolCallId: event.toolCallId,
toolName: event.toolName,
output: event.output,
error: event.error,
durationMs: event.durationMs,
}),
);
}
break;
}
case "notice": {
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({
level: event.noticeType === "recovery" ? "warn" : "info",
message: event.message,
noticeType: event.noticeType,
reason: event.reason,
}),
);
break;
}
case "usage": {
emitChunk(
ctx,
sessionId,
"chat_usage",
JSON.stringify({
inputTokens: event.inputTokens,
outputTokens: event.outputTokens,
cacheReadTokens: event.cacheReadTokens,
cacheWriteTokens: event.cacheWriteTokens,
cost: event.cost,
totalInputTokens: event.totalInputTokens,
totalOutputTokens: event.totalOutputTokens,
totalCost: event.totalCost,
}),
);
break;
}
case "done": {
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
session.status = event.reason === "completed" ? "idle" : event.reason;
}
emitChunk(
ctx,
sessionId,
"chat_done",
JSON.stringify({
reason: event.reason,
text: event.text,
iterations: event.iterations,
usage: event.usage,
}),
);
break;
}
case "error": {
const message =
event.error instanceof Error
? event.error.message
: String(event.error);
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({
level: "error",
message,
}),
);
break;
}
case "iteration_start":
case "iteration_end":
break;
}
}
// ---------------------------------------------------------------------------
// CoreSessionEvent routing
// ---------------------------------------------------------------------------
function handleCoreSessionEvent(
ctx: SidecarContext,
event: CoreSessionEvent,
): void {
switch (event.type) {
case "chunk": {
const { sessionId, stream, chunk } = event.payload;
if (stream === "agent") break;
emitChunk(ctx, sessionId, stream, chunk);
break;
}
case "agent_event": {
const { sessionId, event: agentEvent } = event.payload;
handleAgentEvent(ctx, sessionId, agentEvent);
break;
}
case "pending_prompts": {
const { sessionId, prompts } = event.payload;
const session = ctx.liveSessions.get(sessionId);
const mapped: PromptInQueue[] = prompts
.map((item) => ({
id: item.id ?? "",
prompt: item.prompt ?? "",
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount ?? 0,
}))
.filter((item) => item.id && item.prompt);
if (session) {
const previous = session.promptsInQueue;
session.promptsInQueue = mapped;
if (
previous.length > mapped.length &&
previous[0] &&
previous[0].id !== mapped[0]?.id
) {
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
}),
);
}
}
sendPromptsInQueueSnapshot(ctx, sessionId);
break;
}
case "pending_prompt_submitted": {
const { sessionId, prompt, attachmentCount } = event.payload;
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
prompt,
attachmentCount: attachmentCount ?? 0,
}),
);
break;
}
case "ended": {
const { sessionId, reason } = event.payload;
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
session.endedAt = nowMs();
session.status = reason || "ended";
}
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
break;
}
case "hook": {
const hookPayload = event.payload;
emitChunk(
ctx,
hookPayload.sessionId,
"chat_hook",
JSON.stringify(hookPayload),
);
break;
}
case "status": {
const { sessionId, status } = event.payload;
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.status = status;
session.busy = status === "running";
}
sendEvent(ctx, "chat_session_status", { sessionId, status });
break;
}
case "team_progress": {
sendEvent(ctx, "team_progress", event.payload);
break;
}
}
}
// ---------------------------------------------------------------------------
// Context factory
// ---------------------------------------------------------------------------
export function createSidecarContext(workspaceRoot: string): SidecarContext {
return {
liveSessions: new Map(),
streamIndices: new Map(),
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
unsubscribeSessionEvents: null,
};
}
export async function disposeSidecarContext(
ctx: SidecarContext,
reason = "code_sidecar_shutdown",
): Promise<void> {
const cleanup: Array<Promise<unknown>> = [];
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
for (const client of ctx.wsClients) {
try {
client.close?.();
} catch {
// Best-effort websocket close during shutdown.
}
}
ctx.wsClients.clear();
for (const pending of ctx.pendingApprovals.values()) {
pending.resolve({ approved: false, reason });
}
ctx.pendingApprovals.clear();
for (const pending of ctx.pendingQuestions.values()) {
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.reject(new Error(reason));
}
ctx.pendingQuestions.clear();
const hubClient = ctx.hubClient;
ctx.hubClient = null;
if (hubClient) {
cleanup.push(hubClient.dispose());
}
const sessionManager = ctx.sessionManager;
ctx.sessionManager = null;
if (sessionManager) {
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (firstFailure) {
throw firstFailure.reason;
}
}
function serializeQuestionContext(
context: AgentToolContext,
): PendingAskQuestion["item"]["context"] {
return {
agentId: context.agentId,
conversationId: context.conversationId,
iteration: context.iteration,
...(context.metadata ? { metadata: context.metadata } : {}),
};
}
export function requestSidecarAskQuestion(
ctx: SidecarContext,
question: string,
options: string[],
context: AgentToolContext,
): Promise<string> {
const choices = options
.map((option) => option.trim())
.filter((option) => option.length > 0)
.slice(0, 5);
if (choices.length === 0) {
return Promise.resolve("");
}
return new Promise<string>((resolve, reject) => {
const requestId = randomUUID();
const timeoutId = setTimeout(() => {
ctx.pendingQuestions.delete(requestId);
reject(
new Error(
`Ask question request timed out after ${ASK_QUESTION_TIMEOUT_MS}ms`,
),
);
sendEvent(ctx, "ask_question_cancelled", {
requestId,
reason: "timeout",
});
}, ASK_QUESTION_TIMEOUT_MS);
const pending: PendingAskQuestion = {
item: {
requestId,
createdAt: new Date().toISOString(),
question,
options: choices,
context: serializeQuestionContext(context),
},
resolve,
reject,
timeoutId,
};
ctx.pendingQuestions.set(requestId, pending);
sendEvent(ctx, "ask_question_requested", pending.item);
});
}
export function resolveSidecarAskQuestion(
ctx: SidecarContext,
requestId: string,
answer: string,
): boolean {
const pending = ctx.pendingQuestions.get(requestId);
if (!pending) {
return false;
}
ctx.pendingQuestions.delete(requestId);
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.resolve(answer);
return true;
}
export function createSidecarRuntimeCapabilities(
ctx: SidecarContext,
): RuntimeCapabilities {
return {
toolExecutors: {
askQuestion: (question, options, context) =>
requestSidecarAskQuestion(ctx, question, options, context),
},
requestToolApproval: (request) => requestSidecarToolApproval(ctx, request),
};
}
function requestSidecarToolApproval(
ctx: SidecarContext,
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
return new Promise<ToolApprovalResult>((resolve) => {
const requestId = randomUUID();
const pending: PendingToolApproval = {
item: {
requestId,
sessionId: request.sessionId,
createdAt: new Date().toISOString(),
toolCallId: request.toolCallId,
toolName: request.toolName,
input: request.input,
iteration: request.iteration,
agentId: request.agentId,
conversationId: request.conversationId,
},
resolve,
};
ctx.pendingApprovals.set(requestId, pending);
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
.filter((approval) => approval.item.sessionId === request.sessionId)
.map((approval) => approval.item);
sendEvent(ctx, "tool_approval_state", {
sessionId: request.sessionId,
items: sessionApprovals,
});
});
}
export function handleHubLiveEvent(
ctx: SidecarContext,
event: {
event: string;
sessionId?: string;
payload?: Record<string, unknown>;
},
): void {
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
if (!sessionId) {
return;
}
const session = ctx.liveSessions.get(sessionId);
if (!session?.attachedViaHub) {
return;
}
switch (event.event) {
case "assistant.delta": {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
if (text) {
emitChunk(ctx, sessionId, "chat_text", text);
}
return;
}
case "reasoning.delta": {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
const redacted = event.payload?.redacted === true;
if (!text && !redacted) {
return;
}
emitChunk(
ctx,
sessionId,
"chat_reasoning",
JSON.stringify({ text, redacted }),
);
return;
}
case "tool.started": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_start",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
input: event.payload?.input,
}),
);
return;
}
case "tool.finished": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_end",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
output: event.payload?.output,
error:
typeof event.payload?.error === "string"
? event.payload.error
: undefined,
}),
);
return;
}
case "run.started":
case "session.attached":
case "session.updated": {
const payloadSession =
event.payload?.session &&
typeof event.payload.session === "object" &&
!Array.isArray(event.payload.session)
? (event.payload.session as Record<string, unknown>)
: undefined;
const status =
typeof payloadSession?.status === "string"
? payloadSession.status
: event.event === "run.started"
? "running"
: session.status;
session.status = status;
session.busy = status === "running";
sendEvent(ctx, "chat_session_status", { sessionId, status });
return;
}
case "run.completed":
case "run.failed":
case "run.aborted": {
const reason =
typeof event.payload?.reason === "string"
? event.payload.reason
: event.event === "run.aborted"
? "aborted"
: event.event === "run.failed"
? "error"
: "completed";
session.status = reason;
session.busy = false;
session.endedAt = nowMs();
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
return;
}
default:
return;
}
}
export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
displayName: "Code App sidecar",
},
});
// Subscribe to all session events and relay them to WS clients
const unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
handleCoreSessionEvent(ctx, event);
});
const runtimeAddress = sessionManager.runtimeAddress?.trim();
let hubClient: NodeHubClient | null = null;
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
}
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}