mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
801
src/services/acp/agent.ts
Normal file
801
src/services/acp/agent.ts
Normal file
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* ACP Agent implementation — bridges ACP protocol methods to Claude Code's
|
||||
* internal QueryEngine / query() pipeline.
|
||||
*
|
||||
* Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk)
|
||||
* to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate.
|
||||
*/
|
||||
import type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
CancelNotification,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
ListSessionsRequest,
|
||||
ListSessionsResponse,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
ForkSessionRequest,
|
||||
ForkSessionResponse,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
ContentBlock,
|
||||
ClientCapabilities,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionConfigOption,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { randomUUID, type UUID } from 'node:crypto'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { deserializeMessages } from '../../utils/conversationRecovery.js'
|
||||
import { getLastSessionLog, sessionIdExists } from '../../utils/sessionStorage.js'
|
||||
import { QueryEngine } from '../../QueryEngine.js'
|
||||
import type { QueryEngineConfig } from '../../QueryEngine.js'
|
||||
import type { Tools } from '../../Tool.js'
|
||||
import { getTools } from '../../tools.js'
|
||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||
import type { PermissionMode } from '../../types/permissions.js'
|
||||
import type { Command } from '../../types/command.js'
|
||||
import { getCommands } from '../../commands.js'
|
||||
import { setOriginalCwd } from '../../bootstrap/state.js'
|
||||
import { enableConfigs } from '../../utils/config.js'
|
||||
import { FileStateCache } from '../../utils/fileStateCache.js'
|
||||
import { getDefaultAppState } from '../../state/AppStateStore.js'
|
||||
import type { AppState } from '../../state/AppStateStore.js'
|
||||
import { createAcpCanUseTool } from './permissions.js'
|
||||
import { forwardSessionUpdates, replayHistoryMessages, type ToolUseCache } from './bridge.js'
|
||||
import {
|
||||
resolvePermissionMode,
|
||||
computeSessionFingerprint,
|
||||
sanitizeTitle,
|
||||
} from './utils.js'
|
||||
import {
|
||||
listSessionsImpl,
|
||||
} from '../../utils/listSessionsImpl.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
import { getModelOptions } from '../../utils/model/modelOptions.js'
|
||||
|
||||
// ── Session state ─────────────────────────────────────────────────
|
||||
|
||||
type AcpSession = {
|
||||
queryEngine: QueryEngine
|
||||
cancelled: boolean
|
||||
cwd: string
|
||||
sessionFingerprint: string
|
||||
modes: SessionModeState
|
||||
models: SessionModelState
|
||||
configOptions: SessionConfigOption[]
|
||||
promptRunning: boolean
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>
|
||||
nextPendingOrder: number
|
||||
toolUseCache: ToolUseCache
|
||||
clientCapabilities?: ClientCapabilities
|
||||
appState: AppState
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
// ── Agent class ───────────────────────────────────────────────────
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
private conn: AgentSideConnection
|
||||
sessions = new Map<string, AcpSession>()
|
||||
private clientCapabilities?: ClientCapabilities
|
||||
|
||||
constructor(conn: AgentSideConnection) {
|
||||
this.conn = conn
|
||||
}
|
||||
|
||||
// ── initialize ────────────────────────────────────────────────
|
||||
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = params.clientCapabilities
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentInfo: {
|
||||
name: 'claude-code',
|
||||
title: 'Claude Code',
|
||||
version:
|
||||
typeof (globalThis as unknown as Record<string, unknown>).MACRO ===
|
||||
'object' &&
|
||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
||||
.MACRO !== null
|
||||
? String(
|
||||
(
|
||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
||||
.MACRO as Record<string, unknown>
|
||||
).VERSION ?? '0.0.0',
|
||||
)
|
||||
: '0.0.0',
|
||||
},
|
||||
agentCapabilities: {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
promptQueueing: true,
|
||||
},
|
||||
},
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
loadSession: true,
|
||||
sessionCapabilities: {
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
close: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── authenticate ──────────────────────────────────────────────
|
||||
|
||||
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
|
||||
// No authentication required — this is a self-hosted/custom deployment
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── newSession ────────────────────────────────────────────────
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
return this.createSession(params)
|
||||
}
|
||||
|
||||
// ── resumeSession ──────────────────────────────────────────────
|
||||
|
||||
async unstable_resumeSession(
|
||||
params: ResumeSessionRequest,
|
||||
): Promise<ResumeSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── loadSession ────────────────────────────────────────────────
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── listSessions ───────────────────────────────────────────────
|
||||
|
||||
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
const candidates = await listSessionsImpl({
|
||||
dir: params.cwd ?? undefined,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const sessions = []
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.cwd) continue
|
||||
sessions.push({
|
||||
sessionId: candidate.sessionId,
|
||||
cwd: candidate.cwd,
|
||||
title: sanitizeTitle(candidate.summary ?? ''),
|
||||
updatedAt: new Date(candidate.lastModified).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
// ── forkSession ────────────────────────────────────────────────
|
||||
|
||||
async unstable_forkSession(
|
||||
params: ForkSessionRequest,
|
||||
): Promise<ForkSessionResponse> {
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId)
|
||||
}, 0)
|
||||
return response
|
||||
}
|
||||
|
||||
// ── closeSession ───────────────────────────────────────────────
|
||||
|
||||
async unstable_closeSession(
|
||||
params: CloseSessionRequest,
|
||||
): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
await this.teardownSession(params.sessionId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── prompt ────────────────────────────────────────────────────
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`)
|
||||
}
|
||||
|
||||
// Reset cancelled state at the start of each prompt (matches official impl)
|
||||
session.cancelled = false
|
||||
|
||||
// Extract text/image content from the prompt
|
||||
const promptInput = promptToQueryInput(params.prompt)
|
||||
|
||||
if (!promptInput.trim()) {
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
|
||||
// Handle prompt queuing — if a prompt is already running, queue this one
|
||||
if (session.promptRunning) {
|
||||
const order = session.nextPendingOrder++
|
||||
const promptUuid = randomUUID()
|
||||
const cancelled = await new Promise<boolean>((resolve) => {
|
||||
session.pendingMessages.set(promptUuid, { resolve, order })
|
||||
})
|
||||
if (cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
|
||||
session.promptRunning = true
|
||||
|
||||
try {
|
||||
// Reset the query engine's abort controller for a fresh query.
|
||||
// After a previous interrupt(), the internal controller is stuck in
|
||||
// aborted state — without this, submitMessage() fails immediately.
|
||||
session.queryEngine.resetAbortController()
|
||||
|
||||
const sdkMessages = session.queryEngine.submitMessage(promptInput)
|
||||
|
||||
const { stopReason, usage } = await forwardSessionUpdates(
|
||||
params.sessionId,
|
||||
sdkMessages,
|
||||
this.conn,
|
||||
session.queryEngine.getAbortSignal(),
|
||||
session.toolUseCache,
|
||||
this.clientCapabilities,
|
||||
session.cwd,
|
||||
() => session.cancelled,
|
||||
)
|
||||
|
||||
// If the session was cancelled during processing, return cancelled
|
||||
if (session.cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
return {
|
||||
stopReason,
|
||||
usage: usage
|
||||
? {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedReadTokens: usage.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens,
|
||||
totalTokens:
|
||||
usage.inputTokens +
|
||||
usage.outputTokens +
|
||||
usage.cachedReadTokens +
|
||||
usage.cachedWriteTokens,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (session.cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
// Check for process death errors
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes('terminated') ||
|
||||
err.message.includes('process exited'))
|
||||
) {
|
||||
this.teardownSession(params.sessionId)
|
||||
throw new Error(
|
||||
'The Claude Agent process exited unexpectedly. Please start a new session.',
|
||||
)
|
||||
}
|
||||
|
||||
console.error('[ACP] prompt error:', err)
|
||||
return { stopReason: 'end_turn' }
|
||||
} finally {
|
||||
session.promptRunning = false
|
||||
// Resolve next pending prompt if any
|
||||
if (session.pendingMessages.size > 0) {
|
||||
const next = [...session.pendingMessages.entries()].sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
)[0]
|
||||
if (next) {
|
||||
next[1].resolve(false)
|
||||
session.pendingMessages.delete(next[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── cancel ────────────────────────────────────────────────────
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) return
|
||||
|
||||
// Set cancelled flag — checked by prompt() loop to break out
|
||||
session.cancelled = true
|
||||
|
||||
// Cancel any queued prompts
|
||||
for (const [, pending] of session.pendingMessages) {
|
||||
pending.resolve(true)
|
||||
}
|
||||
session.pendingMessages.clear()
|
||||
|
||||
// Interrupt the query engine to abort the current API call
|
||||
session.queryEngine.interrupt()
|
||||
}
|
||||
|
||||
// ── setSessionMode ──────────────────────────────────────────────
|
||||
|
||||
async setSessionMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
|
||||
this.applySessionMode(params.sessionId, params.modeId)
|
||||
await this.updateConfigOption(params.sessionId, 'mode', params.modeId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── setSessionModel ─────────────────────────────────────────────
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
// Store the raw value — QueryEngine.submitMessage() calls
|
||||
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
|
||||
session.queryEngine.setModel(params.modelId)
|
||||
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
|
||||
}
|
||||
|
||||
// ── setSessionConfigOption ──────────────────────────────────────
|
||||
|
||||
async setSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
if (typeof params.value !== 'string') {
|
||||
throw new Error(
|
||||
`Invalid value for config option ${params.configId}: ${String(params.value)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const option = session.configOptions.find((o) => o.id === params.configId)
|
||||
if (!option) {
|
||||
throw new Error(`Unknown config option: ${params.configId}`)
|
||||
}
|
||||
|
||||
const value = params.value
|
||||
|
||||
if (params.configId === 'mode') {
|
||||
this.applySessionMode(params.sessionId, value)
|
||||
await this.conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
currentModeId: value,
|
||||
},
|
||||
})
|
||||
} else if (params.configId === 'model') {
|
||||
session.queryEngine.setModel(value)
|
||||
}
|
||||
|
||||
this.syncSessionConfigState(session, params.configId, value)
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === params.configId && typeof o.currentValue === 'string'
|
||||
? { ...o, currentValue: value }
|
||||
: o,
|
||||
)
|
||||
|
||||
return { configOptions: session.configOptions }
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
opts: { forceNewId?: boolean; sessionId?: string; initialMessages?: Message[] } = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
enableConfigs()
|
||||
|
||||
const sessionId = opts.sessionId ?? randomUUID()
|
||||
const cwd = params.cwd
|
||||
|
||||
// Set CWD for the session
|
||||
setOriginalCwd(cwd)
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch {
|
||||
// CWD may not exist yet; best-effort
|
||||
}
|
||||
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
|
||||
// Parse permission mode from settings
|
||||
const permissionMode = resolvePermissionMode(
|
||||
this.getSetting<string>('permissions.defaultMode'),
|
||||
)
|
||||
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
this.conn,
|
||||
sessionId,
|
||||
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
|
||||
this.clientCapabilities,
|
||||
cwd,
|
||||
)
|
||||
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
...getDefaultAppState(),
|
||||
toolPermissionContext: {
|
||||
...permissionContext,
|
||||
mode: permissionMode as PermissionMode,
|
||||
},
|
||||
}
|
||||
|
||||
// Load commands for slash command and skill support
|
||||
const commands = await getCommands(cwd)
|
||||
|
||||
// Build QueryEngine config
|
||||
const engineConfig: QueryEngineConfig = {
|
||||
cwd,
|
||||
tools,
|
||||
commands,
|
||||
mcpClients: [],
|
||||
agents: [],
|
||||
canUseTool,
|
||||
getAppState: () => appState,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => {
|
||||
const updated = updater(appState)
|
||||
Object.assign(appState, updated)
|
||||
},
|
||||
readFileCache: new FileStateCache(500, 50 * 1024 * 1024),
|
||||
includePartialMessages: true,
|
||||
replayUserMessages: true,
|
||||
initialMessages: opts.initialMessages,
|
||||
}
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes
|
||||
const availableModes = [
|
||||
{ id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
|
||||
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
|
||||
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
|
||||
{ id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' },
|
||||
{ id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" },
|
||||
]
|
||||
|
||||
const modes: SessionModeState = {
|
||||
currentModeId: permissionMode,
|
||||
availableModes,
|
||||
}
|
||||
|
||||
// Build models
|
||||
const modelOptions = getModelOptions()
|
||||
const currentModel = getMainLoopModel()
|
||||
const models: SessionModelState = {
|
||||
availableModels: modelOptions.map((m) => ({
|
||||
modelId: String(m.value ?? ''),
|
||||
name: m.label ?? String(m.value ?? ''),
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
currentModelId: currentModel,
|
||||
}
|
||||
|
||||
// Set the model on the engine
|
||||
queryEngine.setModel(currentModel)
|
||||
|
||||
// Build config options
|
||||
const configOptions = buildConfigOptions(modes, models)
|
||||
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
appState,
|
||||
commands,
|
||||
sessionFingerprint: computeSessionFingerprint({
|
||||
cwd,
|
||||
mcpServers: params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined,
|
||||
}),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// Send available commands after session creation
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(sessionId)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes,
|
||||
configOptions,
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateSession(params: {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
mcpServers?: NewSessionRequest['mcpServers']
|
||||
_meta?: NewSessionRequest['_meta']
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions.get(params.sessionId)
|
||||
if (existingSession) {
|
||||
const fingerprint = computeSessionFingerprint({
|
||||
cwd: params.cwd,
|
||||
mcpServers:
|
||||
params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined,
|
||||
})
|
||||
if (fingerprint === existingSession.sessionFingerprint) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
}
|
||||
}
|
||||
|
||||
// Session-defining params changed — tear down and recreate
|
||||
await this.teardownSession(params.sessionId)
|
||||
}
|
||||
|
||||
// Set CWD early so session file lookup can find the right project directory
|
||||
setOriginalCwd(params.cwd)
|
||||
|
||||
// Try to load session history for resume/load
|
||||
let initialMessages: Message[] | undefined
|
||||
if (sessionIdExists(params.sessionId)) {
|
||||
try {
|
||||
const log = await getLastSessionLog(params.sessionId as UUID)
|
||||
if (log && log.messages.length > 0) {
|
||||
initialMessages = deserializeMessages(log.messages)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ACP] Failed to load session history:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{ sessionId: params.sessionId, initialMessages },
|
||||
)
|
||||
|
||||
// Replay history to client if loaded
|
||||
if (initialMessages && initialMessages.length > 0) {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (session) {
|
||||
await replayHistoryMessages(
|
||||
params.sessionId,
|
||||
initialMessages as unknown as Array<Record<string, unknown>>,
|
||||
this.conn,
|
||||
session.toolUseCache,
|
||||
this.clientCapabilities,
|
||||
session.cwd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
modes: response.modes,
|
||||
models: response.models,
|
||||
configOptions: response.configOptions,
|
||||
}
|
||||
}
|
||||
|
||||
private async teardownSession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
await this.cancel({ sessionId })
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
private applySessionMode(sessionId: string, modeId: string): void {
|
||||
const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan']
|
||||
if (!validModes.includes(modeId)) {
|
||||
throw new Error(`Invalid mode: ${modeId}`)
|
||||
}
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.modes = { ...session.modes, currentModeId: modeId }
|
||||
}
|
||||
}
|
||||
|
||||
private async updateConfigOption(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
this.syncSessionConfigState(session, configId, value)
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === 'string'
|
||||
? { ...o, currentValue: value }
|
||||
: o,
|
||||
)
|
||||
|
||||
await this.conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'config_option_update',
|
||||
configOptions: session.configOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private syncSessionConfigState(
|
||||
session: AcpSession,
|
||||
configId: string,
|
||||
value: string,
|
||||
): void {
|
||||
if (configId === 'mode') {
|
||||
session.modes = { ...session.modes, currentModeId: value }
|
||||
} else if (configId === 'model') {
|
||||
session.models = { ...session.models, currentModelId: value }
|
||||
}
|
||||
}
|
||||
|
||||
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const availableCommands = session.commands
|
||||
.filter(
|
||||
cmd =>
|
||||
cmd.type === 'prompt' &&
|
||||
!cmd.isHidden &&
|
||||
cmd.userInvocable !== false,
|
||||
)
|
||||
.map(cmd => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined,
|
||||
}))
|
||||
|
||||
await this.conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Read a setting from Claude config (simplified — no file watching) */
|
||||
private getSetting<T>(key: string): T | undefined {
|
||||
// Simplified: read from environment or return undefined
|
||||
// In a full implementation, this would read from settings.json
|
||||
return undefined as T | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract prompt text from ACP ContentBlock array for QueryEngine input */
|
||||
function promptToQueryInput(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
for (const block of prompt) {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') {
|
||||
parts.push(b.text as string)
|
||||
} else if (b.type === 'resource_link') {
|
||||
parts.push(`[${b.name ?? ''}](${b.uri as string})`)
|
||||
} else if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && 'text' in resource) {
|
||||
parts.push(resource.text as string)
|
||||
}
|
||||
}
|
||||
// Ignore image and other types for text-based prompt
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
): SessionConfigOption[] {
|
||||
return [
|
||||
{
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select' as const,
|
||||
currentValue: modes.currentModeId,
|
||||
options: modes.availableModes.map((m: SessionModeState['availableModes'][number]) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select' as const,
|
||||
currentValue: models.currentModelId,
|
||||
options: models.availableModels.map((m: SessionModelState['availableModels'][number]) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
},
|
||||
] as SessionConfigOption[]
|
||||
}
|
||||
Reference in New Issue
Block a user