892 lines
26 KiB
TypeScript
892 lines
26 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
import {
|
|
buildConnectionUpdate,
|
|
buildWorkspaceMetadata,
|
|
type ClineCore,
|
|
type CoreSessionConfig,
|
|
type SessionPendingPrompt,
|
|
SessionSource,
|
|
splitCoreSessionConfig,
|
|
} from "@cline/core";
|
|
import type { Message } from "@cline/llms";
|
|
import { buildClineSystemPrompt } from "@cline/shared";
|
|
import { emitChunk, nowMs, sendEvent } from "./context";
|
|
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
|
import type {
|
|
ChatSessionCommandRequest,
|
|
JsonRecord,
|
|
LiveSession,
|
|
PromptInQueue,
|
|
SidecarContext,
|
|
} from "./types";
|
|
|
|
type SessionConnectionUpdate = Parameters<
|
|
ClineCore["updateSessionConnection"]
|
|
>[1];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session data helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
|
const path = join(
|
|
sharedSessionDataDir(),
|
|
sessionId,
|
|
`${sessionId}.messages.json`,
|
|
);
|
|
if (!existsSync(path)) return null;
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, "utf8").trim()) as
|
|
| { messages?: unknown[] }
|
|
| unknown[];
|
|
if (Array.isArray(parsed)) return parsed;
|
|
return Array.isArray(parsed.messages) ? parsed.messages : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readSessionMetadataTitle(sessionId: string): string | undefined {
|
|
const manifest = readSessionManifest(sessionId);
|
|
const metadata =
|
|
manifest?.metadata && typeof manifest.metadata === "object"
|
|
? (manifest.metadata as JsonRecord)
|
|
: undefined;
|
|
const title = metadata?.title;
|
|
return typeof title === "string" ? title.trim() || undefined : undefined;
|
|
}
|
|
|
|
function readSessionMetadata(sessionId: string): JsonRecord | undefined {
|
|
const manifest = readSessionManifest(sessionId);
|
|
return manifest?.metadata && typeof manifest.metadata === "object"
|
|
? (manifest.metadata as JsonRecord)
|
|
: undefined;
|
|
}
|
|
|
|
function derivePromptFromMessages(messages: unknown[]): string {
|
|
for (const msg of messages) {
|
|
if (!msg || typeof msg !== "object") continue;
|
|
const m = msg as JsonRecord;
|
|
if (m.role !== "user") continue;
|
|
if (typeof m.content === "string") {
|
|
const line = m.content.trim().split("\n")[0]?.trim();
|
|
if (line) return line.slice(0, 200);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live session factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createLiveSession(
|
|
config: JsonRecord,
|
|
overrides?: Partial<LiveSession>,
|
|
): LiveSession {
|
|
return {
|
|
config,
|
|
messages: overrides?.messages ?? [],
|
|
promptsInQueue: overrides?.promptsInQueue ?? [],
|
|
busy: false,
|
|
startedAt: nowMs(),
|
|
status: overrides?.status ?? "idle",
|
|
prompt: overrides?.prompt,
|
|
title: overrides?.title,
|
|
attachedViaHub: overrides?.attachedViaHub ?? false,
|
|
};
|
|
}
|
|
|
|
function isoTimestampToMs(
|
|
value: string | null | undefined,
|
|
): number | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function readReasoningEffort(
|
|
value: unknown,
|
|
): "low" | "medium" | "high" | "xhigh" | undefined {
|
|
if (
|
|
value === "low" ||
|
|
value === "medium" ||
|
|
value === "high" ||
|
|
value === "xhigh"
|
|
) {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readPositiveInteger(value: unknown): number | undefined {
|
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
return Math.trunc(value);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
|
const thinking =
|
|
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
|
const reasoningEffort =
|
|
thinking === false
|
|
? undefined
|
|
: readReasoningEffort(config.reasoningEffort);
|
|
const thinkingBudgetTokens =
|
|
thinking === false
|
|
? undefined
|
|
: readPositiveInteger(
|
|
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
|
);
|
|
return {
|
|
sessionId: config.sessionId ?? config.session_id,
|
|
providerId: config.provider ?? config.providerId ?? "",
|
|
modelId: config.model ?? config.modelId ?? "",
|
|
mode: config.mode ?? "act",
|
|
apiKey: config.apiKey ?? config.api_key ?? "",
|
|
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
|
|
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
|
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
|
maxIterations: config.maxIterations ?? config.max_iterations,
|
|
enableTools: config.enableTools ?? config.enable_tools ?? true,
|
|
enableSpawnAgent:
|
|
config.enableSpawn ??
|
|
config.enableSpawnAgent ??
|
|
config.enable_spawn ??
|
|
false,
|
|
enableAgentTeams:
|
|
config.enableTeams ??
|
|
config.enableAgentTeams ??
|
|
config.enable_teams ??
|
|
false,
|
|
...(thinking !== undefined ? { thinking } : {}),
|
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
|
teamName: config.teamName ?? config.team_name,
|
|
missionLogIntervalSteps:
|
|
config.missionStepInterval ?? config.missionLogIntervalSteps,
|
|
missionLogIntervalMs:
|
|
config.missionTimeIntervalMs ?? config.missionLogIntervalMs,
|
|
checkpoint: { enabled: true },
|
|
sessions: config.sessions,
|
|
initialMessages: config.initialMessages,
|
|
};
|
|
}
|
|
|
|
export function buildSessionConnectionUpdate(
|
|
config: JsonRecord,
|
|
): SessionConnectionUpdate {
|
|
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
|
|
// into typed fields; the thinking/reasoning transition rules live in the
|
|
// shared @cline/core builder.
|
|
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
|
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
|
const rawApiKey =
|
|
typeof config.apiKey === "string"
|
|
? config.apiKey.trim()
|
|
: typeof config.api_key === "string"
|
|
? config.api_key.trim()
|
|
: undefined;
|
|
const baseUrl =
|
|
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
|
|
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
|
const thinkingBudgetTokens = readPositiveInteger(
|
|
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
|
);
|
|
return buildConnectionUpdate({
|
|
...(providerId ? { providerId } : {}),
|
|
...(modelId ? { modelId } : {}),
|
|
...(rawApiKey ? { apiKey: rawApiKey } : {}),
|
|
...(baseUrl ? { baseUrl } : {}),
|
|
...(config.headers && typeof config.headers === "object"
|
|
? { headers: config.headers as Record<string, string> }
|
|
: {}),
|
|
...(config.providerConfig && typeof config.providerConfig === "object"
|
|
? {
|
|
providerConfig:
|
|
config.providerConfig as SessionConnectionUpdate["providerConfig"],
|
|
}
|
|
: {}),
|
|
...(typeof config.thinking === "boolean"
|
|
? { thinking: config.thinking }
|
|
: {}),
|
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
|
});
|
|
}
|
|
|
|
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
|
const cwd = String(
|
|
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
|
).trim();
|
|
if (!cwd) {
|
|
return String(config.systemPrompt ?? config.system_prompt ?? "").trim();
|
|
}
|
|
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
|
const mode = config.autoApproveTools
|
|
? "yolo"
|
|
: config.mode === "plan"
|
|
? "plan"
|
|
: "act";
|
|
const metadata = await buildWorkspaceMetadata(cwd);
|
|
const inlineRules =
|
|
typeof config.rules === "string" && config.rules.trim().length > 0
|
|
? config.rules
|
|
: undefined;
|
|
return buildClineSystemPrompt({
|
|
ide: "Terminal Shell",
|
|
workspaceRoot: cwd,
|
|
workspaceName: basename(cwd),
|
|
metadata,
|
|
rules: inlineRules,
|
|
mode,
|
|
providerId: providerId || undefined,
|
|
overridePrompt:
|
|
typeof config.systemPrompt === "string" &&
|
|
config.systemPrompt.trim().length > 0
|
|
? config.systemPrompt
|
|
: typeof config.system_prompt === "string" &&
|
|
config.system_prompt.trim().length > 0
|
|
? config.system_prompt
|
|
: undefined,
|
|
platform: process.platform || "unknown",
|
|
});
|
|
}
|
|
|
|
function resolveToolPolicies(
|
|
config: JsonRecord,
|
|
): { "*": { autoApprove: boolean } } | undefined {
|
|
return {
|
|
"*": {
|
|
autoApprove: config.autoApproveTools !== false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function sendPromptsInQueueSnapshot(
|
|
ctx: SidecarContext,
|
|
sessionId: string,
|
|
): void {
|
|
const session = ctx.liveSessions.get(sessionId);
|
|
sendEvent(ctx, "prompts_in_queue_state", {
|
|
sessionId,
|
|
items: session?.promptsInQueue ?? [],
|
|
});
|
|
}
|
|
|
|
function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
|
|
return {
|
|
id: item.id,
|
|
prompt: item.prompt,
|
|
steer: item.delivery === "steer",
|
|
attachmentCount: item.attachmentCount,
|
|
};
|
|
}
|
|
|
|
function applyPendingPrompts(
|
|
ctx: SidecarContext,
|
|
sessionId: string,
|
|
prompts: SessionPendingPrompt[],
|
|
): PromptInQueue[] {
|
|
const mapped = prompts.map(mapPendingPrompt).filter((item) => item.id);
|
|
const session = ctx.liveSessions.get(sessionId);
|
|
if (session) {
|
|
session.promptsInQueue = mapped;
|
|
}
|
|
sendPromptsInQueueSnapshot(ctx, sessionId);
|
|
return mapped;
|
|
}
|
|
|
|
function getSessionManager(ctx: SidecarContext): ClineCore {
|
|
if (!ctx.sessionManager) throw new Error("Session manager not initialized");
|
|
return ctx.sessionManager;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chat session action handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function handleStart(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
if (!request.config) throw new Error("config is required");
|
|
const manager = getSessionManager(ctx);
|
|
const systemPrompt = await resolveSystemPrompt(request.config);
|
|
const requestedSessionId = String(
|
|
request.config.sessionId ?? request.config.session_id ?? "",
|
|
).trim();
|
|
const initialMessages =
|
|
Array.isArray(request.config.initialMessages) &&
|
|
request.config.initialMessages.length > 0
|
|
? request.config.initialMessages
|
|
: requestedSessionId
|
|
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
|
|
: undefined;
|
|
const coreConfig: JsonRecord = {
|
|
...buildCoreSessionConfig(request.config),
|
|
systemPrompt,
|
|
...(initialMessages ? { initialMessages } : {}),
|
|
};
|
|
// Note: do NOT pass `prompt` to manager.start() here. When a prompt is
|
|
// provided to start(), the local runtime host runs the full agent turn
|
|
// synchronously inside start(). We always start the session idle and let
|
|
// the frontend call the separate "send" action to dispatch the prompt.
|
|
// This avoids a double-execution bug where start() would run the turn AND
|
|
// the subsequent manager.send() fire-and-forget would run it again.
|
|
console.error(
|
|
`[sidecar:handleStart] calling manager.start provider=${coreConfig.providerId} model=${coreConfig.modelId}`,
|
|
);
|
|
const startResult = await manager.start({
|
|
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
|
|
source: SessionSource.DESKTOP,
|
|
interactive: true,
|
|
...(initialMessages
|
|
? { initialMessages: initialMessages as Message[] }
|
|
: {}),
|
|
toolPolicies: resolveToolPolicies(request.config),
|
|
});
|
|
const sessionId = startResult.sessionId;
|
|
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
|
|
const session = createLiveSession(request.config, {
|
|
messages: initialMessages,
|
|
prompt: initialMessages
|
|
? derivePromptFromMessages(initialMessages)
|
|
: undefined,
|
|
title: requestedSessionId
|
|
? readSessionMetadataTitle(requestedSessionId)
|
|
: undefined,
|
|
status: "idle",
|
|
});
|
|
ctx.liveSessions.set(sessionId, session);
|
|
return { sessionId };
|
|
}
|
|
|
|
async function handleAttach(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
if (!sessionId) {
|
|
throw new Error("sessionId is required");
|
|
}
|
|
|
|
const manager = getSessionManager(ctx);
|
|
const session = await manager.get(sessionId);
|
|
if (!session) {
|
|
throw new Error(`Session ${sessionId} not found`);
|
|
}
|
|
|
|
const metadata =
|
|
session.metadata && typeof session.metadata === "object"
|
|
? (session.metadata as JsonRecord)
|
|
: undefined;
|
|
const existing = ctx.liveSessions.get(sessionId);
|
|
if (ctx.hubClient) {
|
|
await ctx.hubClient.command("session.attach", { sessionId }, sessionId);
|
|
}
|
|
const attachedConfig: JsonRecord = {
|
|
...(existing?.config ?? {}),
|
|
...(request.config ?? {}),
|
|
sessionId,
|
|
provider: session.provider || existing?.config.provider || "",
|
|
model: session.model || existing?.config.model || "",
|
|
cwd:
|
|
session.cwd ||
|
|
session.workspaceRoot ||
|
|
String(request.config?.cwd ?? "").trim() ||
|
|
String(existing?.config.cwd ?? "").trim(),
|
|
workspaceRoot:
|
|
session.workspaceRoot ||
|
|
session.cwd ||
|
|
String(request.config?.workspaceRoot ?? "").trim() ||
|
|
String(existing?.config.workspaceRoot ?? "").trim(),
|
|
};
|
|
ctx.liveSessions.set(
|
|
sessionId,
|
|
createLiveSession(attachedConfig, {
|
|
messages: existing?.messages ?? [],
|
|
promptsInQueue: existing?.promptsInQueue ?? [],
|
|
status: session.status,
|
|
prompt:
|
|
session.prompt ||
|
|
(typeof metadata?.prompt === "string" ? metadata.prompt : undefined) ||
|
|
existing?.prompt,
|
|
title:
|
|
(typeof metadata?.title === "string" ? metadata.title : undefined) ||
|
|
existing?.title,
|
|
endedAt: isoTimestampToMs(session.endedAt),
|
|
attachedViaHub: true,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
sessionId,
|
|
status: session.status,
|
|
provider: session.provider,
|
|
model: session.model,
|
|
cwd: session.cwd,
|
|
workspaceRoot: session.workspaceRoot,
|
|
prompt: session.prompt,
|
|
metadata,
|
|
};
|
|
}
|
|
|
|
async function handleSend(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
if (!sessionId) throw new Error("sessionId is required");
|
|
const prompt = request.prompt?.trim();
|
|
if (!prompt) throw new Error("prompt is required");
|
|
const manager = getSessionManager(ctx);
|
|
const session = ctx.liveSessions.get(sessionId);
|
|
if (request.config) {
|
|
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
|
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
|
if (session) {
|
|
session.config = { ...session.config, ...request.config };
|
|
}
|
|
}
|
|
|
|
// Determine effective delivery mode.
|
|
// When the session is busy and no explicit delivery was requested, queue it
|
|
// via Core so that Core's own pending-prompts mechanism handles draining.
|
|
// This avoids a sidecar-only local queue that never calls manager.send().
|
|
let delivery = request.delivery;
|
|
if (!delivery && session?.busy) {
|
|
delivery = "queue";
|
|
}
|
|
|
|
if (delivery === "queue") {
|
|
if (session) {
|
|
session.prompt = prompt;
|
|
}
|
|
// Delegate queuing to Core — it will drain the prompt once the current
|
|
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
|
|
await manager.send({
|
|
sessionId,
|
|
prompt,
|
|
delivery: "queue",
|
|
userImages: request.attachments?.userImages,
|
|
});
|
|
const prompts = await manager.pendingPrompts.list({ sessionId });
|
|
return {
|
|
sessionId,
|
|
ok: true,
|
|
queued: true,
|
|
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
|
};
|
|
}
|
|
|
|
if (session) {
|
|
session.prompt = prompt;
|
|
session.busy = true;
|
|
session.status = "running";
|
|
}
|
|
try {
|
|
console.error(
|
|
`[sidecar:handleSend] calling manager.send sessionId=${sessionId} prompt=${prompt.slice(0, 80)}`,
|
|
);
|
|
const result = await manager.send({
|
|
sessionId,
|
|
prompt,
|
|
delivery,
|
|
userImages: request.attachments?.userImages,
|
|
});
|
|
console.error(
|
|
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
|
|
);
|
|
if (session) {
|
|
session.busy = false;
|
|
session.status = "idle";
|
|
if (result?.messages) session.messages = result.messages as unknown[];
|
|
}
|
|
return {
|
|
sessionId,
|
|
ok: true,
|
|
result: result
|
|
? {
|
|
text: result.text,
|
|
finishReason: result.finishReason,
|
|
messages: result.messages,
|
|
usage: result.usage,
|
|
iterations: result.iterations,
|
|
toolCalls: result.toolCalls,
|
|
}
|
|
: undefined,
|
|
};
|
|
} catch (error) {
|
|
console.error(
|
|
`[sidecar:handleSend] manager.send THREW sessionId=${sessionId} error=${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
if (session) {
|
|
session.busy = false;
|
|
session.status = "error";
|
|
}
|
|
emitChunk(
|
|
ctx,
|
|
sessionId,
|
|
"chat_core_log",
|
|
JSON.stringify({
|
|
level: "error",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
);
|
|
return {
|
|
sessionId,
|
|
ok: true,
|
|
result: {
|
|
finishReason: "error",
|
|
text: error instanceof Error ? error.message : String(error),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleStop(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
if (!sessionId) throw new Error("sessionId is required");
|
|
await getSessionManager(ctx).stop(sessionId);
|
|
const session = ctx.liveSessions.get(sessionId);
|
|
if (session) {
|
|
session.busy = false;
|
|
session.status = "stopped";
|
|
}
|
|
return { sessionId, ok: true };
|
|
}
|
|
|
|
async function handleAbort(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
if (!sessionId) throw new Error("sessionId is required");
|
|
await getSessionManager(ctx).abort(sessionId, "user_abort");
|
|
const session = ctx.liveSessions.get(sessionId);
|
|
if (session) {
|
|
session.busy = false;
|
|
session.status = "aborted";
|
|
}
|
|
return { sessionId, ok: true };
|
|
}
|
|
|
|
async function handleFork(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sourceSessionId = request.sessionId?.trim();
|
|
if (!sourceSessionId) throw new Error("sessionId is required");
|
|
const manager = getSessionManager(ctx);
|
|
const sourceMessages =
|
|
readPersistedChatMessages(sourceSessionId) ??
|
|
ctx.liveSessions.get(sourceSessionId)?.messages;
|
|
if (!sourceMessages?.length) {
|
|
throw new Error(`No messages found for session ${sourceSessionId}`);
|
|
}
|
|
|
|
const sourceSession = await manager.get(sourceSessionId);
|
|
const sourceMetadata =
|
|
(sourceSession?.metadata && typeof sourceSession.metadata === "object"
|
|
? (sourceSession.metadata as JsonRecord)
|
|
: undefined) ?? readSessionMetadata(sourceSessionId);
|
|
const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config;
|
|
const forkConfig: JsonRecord = {
|
|
...(liveConfig ?? {}),
|
|
...(request.config ?? {}),
|
|
sessionId: undefined,
|
|
provider:
|
|
sourceSession?.provider ||
|
|
liveConfig?.provider ||
|
|
request.config?.provider ||
|
|
request.config?.providerId ||
|
|
"",
|
|
model:
|
|
sourceSession?.model ||
|
|
liveConfig?.model ||
|
|
request.config?.model ||
|
|
request.config?.modelId ||
|
|
"",
|
|
cwd:
|
|
sourceSession?.cwd ||
|
|
sourceSession?.workspaceRoot ||
|
|
liveConfig?.cwd ||
|
|
request.config?.cwd ||
|
|
request.config?.workspaceRoot ||
|
|
request.config?.workspace_root ||
|
|
"",
|
|
workspaceRoot:
|
|
sourceSession?.workspaceRoot ||
|
|
sourceSession?.cwd ||
|
|
liveConfig?.workspaceRoot ||
|
|
request.config?.workspaceRoot ||
|
|
request.config?.workspace_root ||
|
|
request.config?.cwd ||
|
|
"",
|
|
};
|
|
const checkpointMetadata =
|
|
sourceMetadata?.checkpoint !== undefined
|
|
? { checkpoints: sourceMetadata.checkpoint }
|
|
: {};
|
|
const forkMetadata: JsonRecord = {
|
|
...(sourceMetadata ?? {}),
|
|
fork: {
|
|
forkedFromSessionId: sourceSessionId,
|
|
forkedAt: new Date().toISOString(),
|
|
source: sourceSession?.source ?? "desktop",
|
|
...checkpointMetadata,
|
|
},
|
|
};
|
|
const systemPrompt = await resolveSystemPrompt(forkConfig);
|
|
const startResult = await manager.start({
|
|
...splitCoreSessionConfig(
|
|
buildCoreSessionConfig({
|
|
...forkConfig,
|
|
systemPrompt,
|
|
initialMessages: sourceMessages,
|
|
}) as unknown as CoreSessionConfig,
|
|
),
|
|
source: SessionSource.DESKTOP,
|
|
interactive: true,
|
|
initialMessages: sourceMessages as Message[],
|
|
sessionMetadata: forkMetadata,
|
|
toolPolicies: resolveToolPolicies(forkConfig),
|
|
});
|
|
const newSessionId = startResult.sessionId;
|
|
ctx.liveSessions.delete(sourceSessionId);
|
|
ctx.liveSessions.set(
|
|
newSessionId,
|
|
createLiveSession(forkConfig, {
|
|
messages: sourceMessages,
|
|
prompt: derivePromptFromMessages(sourceMessages),
|
|
title: readSessionMetadataTitle(sourceSessionId),
|
|
status: "idle",
|
|
}),
|
|
);
|
|
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
|
sendPromptsInQueueSnapshot(ctx, newSessionId);
|
|
let messages: unknown[] = sourceMessages;
|
|
try {
|
|
const read = await manager.readMessages(newSessionId);
|
|
if (read?.length > 0) messages = read;
|
|
} catch {}
|
|
return {
|
|
sessionId: newSessionId,
|
|
forkedFromSessionId: sourceSessionId,
|
|
messages,
|
|
};
|
|
}
|
|
|
|
async function handleReset(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
if (sessionId) {
|
|
const session = ctx.liveSessions.get(sessionId);
|
|
if (
|
|
session?.busy ||
|
|
session?.status === "starting" ||
|
|
session?.status === "running" ||
|
|
session?.status === "stopping"
|
|
) {
|
|
await getSessionManager(ctx).stop(sessionId);
|
|
}
|
|
ctx.liveSessions.delete(sessionId);
|
|
sendPromptsInQueueSnapshot(ctx, sessionId);
|
|
}
|
|
return { sessionId: request.sessionId, ok: true };
|
|
}
|
|
|
|
async function handleRestoreCheckpoint(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sourceSessionId = request.sessionId?.trim();
|
|
if (!sourceSessionId) throw new Error("sessionId is required");
|
|
const runCount = request.checkpointRunCount;
|
|
if (
|
|
typeof runCount !== "number" ||
|
|
!Number.isInteger(runCount) ||
|
|
runCount < 1
|
|
)
|
|
throw new Error("checkpointRunCount must be a positive integer");
|
|
if (!request.config)
|
|
throw new Error("config is required to restore a checkpoint");
|
|
const cwd =
|
|
(typeof request.config.cwd === "string" && request.config.cwd.trim()) ||
|
|
(typeof request.config.workspaceRoot === "string" &&
|
|
request.config.workspaceRoot.trim()) ||
|
|
"";
|
|
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
|
|
const manager = getSessionManager(ctx);
|
|
const restored = await manager.restore({
|
|
sessionId: sourceSessionId,
|
|
checkpointRunCount: runCount,
|
|
cwd,
|
|
restore: { messages: true, workspace: true },
|
|
start: {
|
|
...splitCoreSessionConfig(
|
|
buildCoreSessionConfig({
|
|
...request.config,
|
|
systemPrompt: await resolveSystemPrompt(request.config),
|
|
}) as unknown as CoreSessionConfig,
|
|
),
|
|
source: SessionSource.DESKTOP,
|
|
interactive: true,
|
|
toolPolicies: resolveToolPolicies(request.config),
|
|
},
|
|
});
|
|
const sessionId = restored.sessionId;
|
|
const restoredMessages = restored.messages;
|
|
if (!sessionId || !restoredMessages) {
|
|
throw new Error("Checkpoint restore did not return a new session");
|
|
}
|
|
ctx.liveSessions.delete(sourceSessionId);
|
|
ctx.liveSessions.set(
|
|
sessionId,
|
|
createLiveSession(request.config, {
|
|
messages: restoredMessages,
|
|
prompt: derivePromptFromMessages(restoredMessages),
|
|
title: readSessionMetadataTitle(sourceSessionId),
|
|
status: "idle",
|
|
}),
|
|
);
|
|
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
|
sendPromptsInQueueSnapshot(ctx, sessionId);
|
|
let messages: unknown[] = restoredMessages;
|
|
try {
|
|
const read = await manager.readMessages(sessionId);
|
|
if (read?.length > 0) messages = read;
|
|
} catch {}
|
|
return {
|
|
sessionId,
|
|
messages,
|
|
restoredCheckpoint: restored.checkpoint,
|
|
};
|
|
}
|
|
|
|
async function handlePendingPrompts(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
if (!sessionId) throw new Error("sessionId is required");
|
|
const prompts = await getSessionManager(ctx).pendingPrompts.list({
|
|
sessionId,
|
|
});
|
|
return {
|
|
sessionId,
|
|
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
|
};
|
|
}
|
|
|
|
async function handleSteerPrompt(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
const promptId = request.promptId?.trim();
|
|
if (!sessionId || !promptId)
|
|
throw new Error("sessionId and promptId are required");
|
|
const manager = getSessionManager(ctx);
|
|
const result = await manager.pendingPrompts.update({
|
|
sessionId,
|
|
promptId,
|
|
delivery: "steer",
|
|
});
|
|
return {
|
|
sessionId,
|
|
updated: result.updated === true,
|
|
promptsInQueue: applyPendingPrompts(ctx, sessionId, result.prompts),
|
|
};
|
|
}
|
|
|
|
async function handleUpdatePendingPrompt(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
const promptId = request.promptId?.trim();
|
|
const prompt = request.prompt?.trim();
|
|
if (!sessionId || !promptId) {
|
|
throw new Error("sessionId and promptId are required");
|
|
}
|
|
if (!prompt) {
|
|
throw new Error("prompt is required");
|
|
}
|
|
const manager = getSessionManager(ctx);
|
|
const result = await manager.pendingPrompts.update({
|
|
sessionId,
|
|
promptId,
|
|
prompt,
|
|
});
|
|
return {
|
|
sessionId,
|
|
updated: result.updated === true,
|
|
prompt: result.prompt ? mapPendingPrompt(result.prompt) : undefined,
|
|
promptsInQueue: applyPendingPrompts(ctx, sessionId, result.prompts),
|
|
};
|
|
}
|
|
|
|
async function handleRemovePendingPrompt(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const sessionId = request.sessionId?.trim();
|
|
const promptId = request.promptId?.trim();
|
|
if (!sessionId || !promptId) {
|
|
throw new Error("sessionId and promptId are required");
|
|
}
|
|
const manager = getSessionManager(ctx);
|
|
const result = await manager.pendingPrompts.delete({
|
|
sessionId,
|
|
promptId,
|
|
});
|
|
return {
|
|
sessionId,
|
|
removed: result.removed === true,
|
|
prompt: result.prompt ? mapPendingPrompt(result.prompt) : undefined,
|
|
promptsInQueue: applyPendingPrompts(ctx, sessionId, result.prompts),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Action dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ACTION_HANDLERS: Record<
|
|
string,
|
|
(ctx: SidecarContext, req: ChatSessionCommandRequest) => Promise<unknown>
|
|
> = {
|
|
start: handleStart,
|
|
attach: handleAttach,
|
|
send: handleSend,
|
|
stop: handleStop,
|
|
abort: handleAbort,
|
|
fork: handleFork,
|
|
reset: handleReset,
|
|
restore_checkpoint: handleRestoreCheckpoint,
|
|
pending_prompts: handlePendingPrompts,
|
|
steer_prompt: handleSteerPrompt,
|
|
update_pending_prompt: handleUpdatePendingPrompt,
|
|
remove_pending_prompt: handleRemovePendingPrompt,
|
|
};
|
|
|
|
export async function handleChatSessionCommand(
|
|
ctx: SidecarContext,
|
|
request: ChatSessionCommandRequest,
|
|
): Promise<unknown> {
|
|
const handler = ACTION_HANDLERS[request.action];
|
|
if (!handler) throw new Error("unsupported action");
|
|
return handler(ctx, request);
|
|
}
|