style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -17,7 +17,8 @@ import {
const _restores: (() => void)[] = []
const originalCwd = process.cwd()
const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE
const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
const originalAcpAllowBypass =
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
function mockModulePreservingExports(
tsPath: string,
@@ -35,10 +36,7 @@ afterAll(() => {
}
_restores.length = 0
restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode)
restoreEnv(
'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS',
originalAcpAllowBypass,
)
restoreEnv('CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS', originalAcpAllowBypass)
})
// ── Module mocks (must precede any import of the module under test) ──
@@ -372,7 +370,9 @@ describe('AcpAgent', () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
() => {},
)
const agent = new AcpAgent(makeConn())
try {
await expect(
@@ -406,7 +406,9 @@ describe('AcpAgent', () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'invalid-mode' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
() => {},
)
const agent = new AcpAgent(makeConn())
try {
const res = await agent.newSession({ cwd: '/tmp' } as any)
@@ -422,7 +424,9 @@ describe('AcpAgent', () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
() => {},
)
const agent = new AcpAgent(makeConn())
try {
await expect(
@@ -976,7 +980,9 @@ describe('AcpAgent', () => {
resolveFirst()
const results = await Promise.all([first, ...queued])
expect(results.every(result => result.stopReason === 'end_turn')).toBe(true)
expect(results.every(result => result.stopReason === 'end_turn')).toBe(
true,
)
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
...Array.from({ length: 1000 }, (_, index) => `queued-${index}`),
@@ -989,13 +995,17 @@ describe('AcpAgent', () => {
let resolveFirst!: () => void
let resolveSecond!: () => void
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
;(
forwardSessionUpdates as ReturnType<typeof mock>
).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
;(
forwardSessionUpdates as ReturnType<typeof mock>
).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveSecond = () => resolve({ stopReason: 'end_turn' })

View File

@@ -748,10 +748,14 @@ describe('forwardSessionUpdates', () => {
ac.signal.addEventListener = addEventListener
ac.signal.removeEventListener = removeEventListener
const msgs = Array.from({ length: 10_000 }, () => ({
type: 'system',
subtype: 'api_retry',
}) as unknown as SDKMessage)
const msgs = Array.from(
{ length: 10_000 },
() =>
({
type: 'system',
subtype: 'api_retry',
}) as unknown as SDKMessage,
)
const result = await forwardSessionUpdates(
's1',

View File

@@ -1,4 +1,12 @@
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import {
afterAll,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from 'bun:test'
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
import type { AssistantMessage } from '../../../types/message.js'
@@ -29,7 +37,10 @@ const bridgeModuleSnapshot = {
afterAll(() => {
mock.module('../bridge.js', () => bridgeModuleSnapshot)
mock.module('../../../utils/permissions/permissions.js', () => permissionsModuleSnapshot)
mock.module(
'../../../utils/permissions/permissions.js',
() => permissionsModuleSnapshot,
)
})
mock.module('../../../utils/permissions/permissions.js', () => ({
@@ -248,12 +259,20 @@ describe('createAcpCanUseTool', () => {
() => false,
)
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_9')
await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_9',
)
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(false)
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(
false,
)
})
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
@@ -268,12 +287,20 @@ describe('createAcpCanUseTool', () => {
() => true,
)
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_10')
await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_10',
)
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(true)
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(
true,
)
})
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
@@ -301,6 +328,8 @@ describe('createAcpCanUseTool', () => {
expect(result.behavior).toBe('deny')
expect(onModeChange).not.toHaveBeenCalled()
expect((conn.sessionUpdate as ReturnType<typeof mock>).mock.calls).toHaveLength(0)
expect(
(conn.sessionUpdate as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
})

View File

@@ -41,7 +41,10 @@ import type {
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 {
getLastSessionLog,
sessionIdExists,
} from '../../utils/sessionStorage.js'
import { QueryEngine } from '../../QueryEngine.js'
import type { QueryEngineConfig } from '../../QueryEngine.js'
import type { Tools } from '../../Tool.js'
@@ -56,16 +59,18 @@ 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 {
forwardSessionUpdates,
replayHistoryMessages,
type ToolUseCache,
} from './bridge.js'
import {
resolvePermissionMode,
computeSessionFingerprint,
sanitizeTitle,
} from './utils.js'
import { promptToQueryInput } from './promptConversion.js'
import {
listSessionsImpl,
} from '../../utils/listSessionsImpl.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
import { getMainLoopModel } from '../../utils/model/model.js'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
@@ -123,8 +128,12 @@ export class AcpAgent implements Agent {
.MACRO !== null
? String(
(
(globalThis as unknown as Record<string, Record<string, unknown>>)
.MACRO as Record<string, unknown>
(
globalThis as unknown as Record<
string,
Record<string, unknown>
>
).MACRO as Record<string, unknown>
).VERSION ?? '0.0.0',
)
: '0.0.0',
@@ -156,7 +165,9 @@ export class AcpAgent implements Agent {
// ── authenticate ──────────────────────────────────────────────
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
async authenticate(
_params: AuthenticateRequest,
): Promise<AuthenticateResponse> {
// No authentication required — this is a self-hosted/custom deployment
return {}
}
@@ -189,7 +200,9 @@ export class AcpAgent implements Agent {
// ── listSessions ───────────────────────────────────────────────
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
async listSessions(
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
const candidates = await listSessionsImpl({
dir: params.cwd ?? undefined,
limit: 100,
@@ -214,13 +227,11 @@ export class AcpAgent implements Agent {
async unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
)
const response = await this.createSession({
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
})
this.scheduleAvailableCommandsUpdate(response.sessionId)
return response
}
@@ -258,7 +269,7 @@ export class AcpAgent implements Agent {
// Handle prompt queuing — if a prompt is already running, queue this one
if (session.promptRunning) {
const promptUuid = randomUUID()
const cancelled = await new Promise<boolean>((resolve) => {
const cancelled = await new Promise<boolean>(resolve => {
session.pendingQueue.push(promptUuid)
session.pendingMessages.set(promptUuid, { resolve })
})
@@ -414,7 +425,7 @@ export class AcpAgent implements Agent {
)
}
const option = session.configOptions.find((o) => o.id === params.configId)
const option = session.configOptions.find(o => o.id === params.configId)
if (!option) {
throw new Error(`Unknown config option: ${params.configId}`)
}
@@ -436,7 +447,7 @@ export class AcpAgent implements Agent {
this.syncSessionConfigState(session, params.configId, value)
session.configOptions = session.configOptions.map((o) =>
session.configOptions = session.configOptions.map(o =>
o.id === params.configId && typeof o.currentValue === 'string'
? { ...o, currentValue: value }
: o,
@@ -449,7 +460,11 @@ export class AcpAgent implements Agent {
private async createSession(
params: NewSessionRequest,
opts: { forceNewId?: boolean; sessionId?: string; initialMessages?: Message[] } = {},
opts: {
forceNewId?: boolean
sessionId?: string
initialMessages?: Message[]
} = {},
): Promise<NewSessionResponse> {
enableConfigs()
@@ -468,140 +483,176 @@ export class AcpAgent implements Agent {
}
try {
// Build tools with a permissive permission context.
const permissionContext = getEmptyToolPermissionContext()
const tools: Tools = getTools(permissionContext)
// Build tools with a permissive permission context.
const permissionContext = getEmptyToolPermissionContext()
const tools: Tools = getTools(permissionContext)
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
const meta = params._meta as Record<string, unknown> | null | undefined
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
const metaPermissionMode = hasMetaPermissionMode
? meta?.permissionMode
: undefined
const settingsPermissionMode = this.getSetting<string>('permissions.defaultMode')
const permissionMode = resolveSessionPermissionMode(
metaPermissionMode,
hasMetaPermissionMode,
settingsPermissionMode,
)
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
const meta = params._meta as Record<string, unknown> | null | undefined
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
const metaPermissionMode = hasMetaPermissionMode
? meta?.permissionMode
: undefined
const settingsPermissionMode = this.getSetting<string>(
'permissions.defaultMode',
)
const permissionMode = resolveSessionPermissionMode(
metaPermissionMode,
hasMetaPermissionMode,
settingsPermissionMode,
)
// Create the permission bridge canUseTool function
const canUseTool = createAcpCanUseTool(
this.conn,
sessionId,
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
this.clientCapabilities,
cwd,
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
() => this.sessions.get(sessionId)?.appState
.toolPermissionContext.isBypassPermissionsModeAvailable ?? false,
)
// Parse MCP servers from ACP params
// MCP server config is handled separately in the tools system
// ACP clients can expose bypass only when both the process and local config allow it.
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
// Create a mutable AppState for the session
const appState: AppState = {
...getDefaultAppState(),
toolPermissionContext: {
...permissionContext,
mode: permissionMode as PermissionMode,
isBypassPermissionsModeAvailable: isBypassAvailable,
},
}
// 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 — bypassPermissions is opt-in for ACP clients.
const availableModes = [
{ 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: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
...(isBypassAvailable
? [{ id: 'bypassPermissions' as const, name: 'Bypass Permissions', description: 'Skip all permission checks' }]
: []),
{ 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,
cancelGeneration: 0,
cwd,
modes,
models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
pendingQueue: [],
pendingQueueHead: 0,
toolUseCache: {},
clientCapabilities: this.clientCapabilities,
appState,
commands,
sessionFingerprint: computeSessionFingerprint({
// Create the permission bridge canUseTool function
const canUseTool = createAcpCanUseTool(
this.conn,
sessionId,
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
this.clientCapabilities,
cwd,
mcpServers: params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined,
}),
}
(modeId: string) => {
this.applySessionMode(sessionId, modeId)
},
() =>
this.sessions.get(sessionId)?.appState.toolPermissionContext
.isBypassPermissionsModeAvailable ?? false,
)
this.sessions.set(sessionId, session)
// Parse MCP servers from ACP params
// MCP server config is handled separately in the tools system
return {
sessionId,
models,
modes,
configOptions,
}
// ACP clients can expose bypass only when both the process and local config allow it.
const isBypassAvailable = isAcpBypassPermissionModeAvailable(
settingsPermissionMode,
)
// Create a mutable AppState for the session
const appState: AppState = {
...getDefaultAppState(),
toolPermissionContext: {
...permissionContext,
mode: permissionMode as PermissionMode,
isBypassPermissionsModeAvailable: isBypassAvailable,
},
}
// 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 — bypassPermissions is opt-in for ACP clients.
const availableModes = [
{
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: 'auto',
name: 'Auto',
description:
'Use a model classifier to approve/deny permission prompts.',
},
...(isBypassAvailable
? [
{
id: 'bypassPermissions' as const,
name: 'Bypass Permissions',
description: 'Skip all permission checks',
},
]
: []),
{
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,
cancelGeneration: 0,
cwd,
modes,
models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
pendingQueue: [],
pendingQueueHead: 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)
return {
sessionId,
models,
modes,
configOptions,
}
} finally {
if (processCwdChanged) {
process.chdir(previousProcessCwd)
@@ -619,8 +670,9 @@ export class AcpAgent implements Agent {
if (existingSession) {
const fingerprint = computeSessionFingerprint({
cwd: params.cwd,
mcpServers:
params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined,
mcpServers: params.mcpServers as
| Array<{ name: string; [key: string]: unknown }>
| undefined,
})
if (fingerprint === existingSession.sessionFingerprint) {
return {
@@ -703,7 +755,9 @@ export class AcpAgent implements Agent {
) {
throw new Error(`Mode not available: ${modeId}`)
}
const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId)
const isAvailable = session.modes.availableModes.some(
mode => mode.id === modeId,
)
if (!isAvailable) {
throw new Error(`Mode not available: ${modeId}`)
}
@@ -727,7 +781,7 @@ export class AcpAgent implements Agent {
this.syncSessionConfigState(session, configId, value)
session.configOptions = session.configOptions.map((o) =>
session.configOptions = session.configOptions.map(o =>
o.id === configId && typeof o.currentValue === 'string'
? { ...o, currentValue: value }
: o,
@@ -761,9 +815,7 @@ export class AcpAgent implements Agent {
const availableCommands = session.commands
.filter(
cmd =>
cmd.type === 'prompt' &&
!cmd.isHidden &&
cmd.userInvocable !== false,
cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false,
)
.map(cmd => ({
name: cmd.name,
@@ -851,14 +903,22 @@ function resolveRequiredPermissionMode(
return resolvePermissionMode(mode, source) as PermissionMode
}
function resolveConfiguredPermissionMode(mode: unknown): PermissionMode | undefined {
function resolveConfiguredPermissionMode(
mode: unknown,
): PermissionMode | undefined {
if (mode === undefined || mode === null) return undefined
try {
return resolvePermissionMode(mode, 'permissions.defaultMode') as PermissionMode
return resolvePermissionMode(
mode,
'permissions.defaultMode',
) as PermissionMode
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err)
console.error('[ACP] Invalid permissions.defaultMode, using default:', reason)
console.error(
'[ACP] Invalid permissions.defaultMode, using default:',
reason,
)
return undefined
}
}
@@ -873,7 +933,8 @@ function hasOwnField(
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
return (
isProcessBypassPermissionModeAvailable() &&
(isAcpBypassLocallyEnabled() || isSettingsBypassPermissionMode(settingsMode))
(isAcpBypassLocallyEnabled() ||
isSettingsBypassPermissionMode(settingsMode))
)
}
@@ -948,11 +1009,13 @@ function buildConfigOptions(
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,
})),
options: modes.availableModes.map(
(m: SessionModeState['availableModes'][number]) => ({
value: m.id,
name: m.name,
description: m.description,
}),
),
},
{
id: 'model',
@@ -961,11 +1024,13 @@ function buildConfigOptions(
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,
})),
options: models.availableModels.map(
(m: SessionModelState['availableModels'][number]) => ({
value: m.modelId,
name: m.name,
description: m.description ?? undefined,
}),
),
},
] as SessionConfigOption[]
}

View File

@@ -72,7 +72,12 @@ export function toolInfoFromToolUse(
title: description,
kind: 'think',
content: prompt
? [{ type: 'content' as const, content: { type: 'text' as const, text: prompt } }]
? [
{
type: 'content' as const,
content: { type: 'text' as const, text: prompt },
},
]
: [],
}
}
@@ -86,7 +91,12 @@ export function toolInfoFromToolUse(
content: _supportsTerminalOutput
? [{ type: 'terminal' as const, terminalId: toolUse.id }]
: description
? [{ type: 'content' as const, content: { type: 'text' as const, text: description } }]
? [
{
type: 'content' as const,
content: { type: 'text' as const, text: description },
},
]
: [],
}
}
@@ -118,8 +128,20 @@ export function toolInfoFromToolUse(
title: displayPath ? `Write ${displayPath}` : 'Write',
kind: 'edit',
content: filePath
? [{ type: 'diff' as const, path: filePath, oldText: null, newText: content }]
: [{ type: 'content' as const, content: { type: 'text' as const, text: content } }],
? [
{
type: 'diff' as const,
path: filePath,
oldText: null,
newText: content,
},
]
: [
{
type: 'content' as const,
content: { type: 'text' as const, text: content },
},
],
locations: filePath ? [{ path: filePath }] : [],
}
}
@@ -133,7 +155,14 @@ export function toolInfoFromToolUse(
title: displayPath ? `Edit ${displayPath}` : 'Edit',
kind: 'edit',
content: filePath
? [{ type: 'diff' as const, path: filePath, oldText: oldString || null, newText: newString }]
? [
{
type: 'diff' as const,
path: filePath,
oldText: oldString || null,
newText: newString,
},
]
: [],
locations: filePath ? [{ path: filePath }] : [],
}
@@ -164,7 +193,8 @@ export function toolInfoFromToolUse(
if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}`
if (input?.output_mode === 'files_with_matches') label += ' -l'
else if (input?.output_mode === 'count') label += ' -c'
if (input?.head_limit !== undefined) label += ` | head -${input.head_limit as number}`
if (input?.head_limit !== undefined)
label += ` | head -${input.head_limit as number}`
if (input?.glob) label += ` --include="${input.glob as string}"`
if (input?.type) label += ` --type=${input.type as string}`
if (input?.multiline) label += ' -P'
@@ -184,7 +214,12 @@ export function toolInfoFromToolUse(
title: url ? `Fetch ${url}` : 'Fetch',
kind: 'fetch',
content: fetchPrompt
? [{ type: 'content' as const, content: { type: 'text' as const, text: fetchPrompt } }]
? [
{
type: 'content' as const,
content: { type: 'text' as const, text: fetchPrompt },
},
]
: [],
}
}
@@ -194,8 +229,10 @@ export function toolInfoFromToolUse(
let label = `"${query}"`
const allowed = input?.allowed_domains as string[] | undefined
const blocked = input?.blocked_domains as string[] | undefined
if (allowed && allowed.length > 0) label += ` (allowed: ${allowed.join(', ')})`
if (blocked && blocked.length > 0) label += ` (blocked: ${blocked.join(', ')})`
if (allowed && allowed.length > 0)
label += ` (allowed: ${allowed.join(', ')})`
if (blocked && blocked.length > 0)
label += ` (blocked: ${blocked.join(', ')})`
return {
title: label,
kind: 'fetch',
@@ -207,7 +244,7 @@ export function toolInfoFromToolUse(
const todos = input?.todos as Array<{ content: string }> | undefined
return {
title: Array.isArray(todos)
? `Update TODOs: ${todos.map((t) => t.content).join(', ')}`
? `Update TODOs: ${todos.map(t => t.content).join(', ')}`
: 'Update TODOs',
kind: 'think',
content: [],
@@ -215,12 +252,19 @@ export function toolInfoFromToolUse(
}
case 'ExitPlanMode': {
const plan = (input as Record<string, unknown>)?.plan as string | undefined
const plan = (input as Record<string, unknown>)?.plan as
| string
| undefined
return {
title: 'Ready to code?',
kind: 'switch_mode',
content: plan
? [{ type: 'content' as const, content: { type: 'text' as const, text: plan } }]
? [
{
type: 'content' as const,
content: { type: 'text' as const, text: plan },
},
]
: [],
}
}
@@ -240,7 +284,11 @@ export function toolUpdateFromToolResult(
toolResult: Record<string, unknown>,
toolUse: { name: string; id: string } | undefined,
_supportsTerminalOutput: boolean = false,
): { content?: ToolCallContent[]; title?: string; _meta?: Record<string, unknown> } {
): {
content?: ToolCallContent[]
title?: string
_meta?: Record<string, unknown>
} {
if (!toolUse) return {}
const isError = toolResult.is_error === true
@@ -261,7 +309,10 @@ export function toolUpdateFromToolResult(
content: [
{
type: 'content' as const,
content: { type: 'text' as const, text: markdownEscape(resultContent) },
content: {
type: 'text' as const,
text: markdownEscape(resultContent),
},
},
],
}
@@ -272,7 +323,10 @@ export function toolUpdateFromToolResult(
type: 'content' as const,
content:
c.type === 'text'
? { type: 'text' as const, text: markdownEscape(c.text as string) }
? {
type: 'text' as const,
text: markdownEscape(c.text as string),
}
: toAcpContentBlock(c, false),
})),
}
@@ -290,10 +344,13 @@ export function toolUpdateFromToolResult(
resultContent &&
typeof resultContent === 'object' &&
!Array.isArray(resultContent) &&
(resultContent as Record<string, unknown>).type === 'bash_code_execution_result'
(resultContent as Record<string, unknown>).type ===
'bash_code_execution_result'
) {
const bashResult = resultContent as Record<string, unknown>
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join('\n')
output = [bashResult.stdout, bashResult.stderr]
.filter(Boolean)
.join('\n')
exitCode = (bashResult.return_code as number) ?? (isError ? 1 : 0)
} else if (typeof resultContent === 'string') {
output = resultContent
@@ -311,7 +368,11 @@ export function toolUpdateFromToolResult(
_meta: {
terminal_info: { terminal_id: terminalId },
terminal_output: { terminal_id: terminalId, data: output },
terminal_exit: { terminal_id: terminalId, exit_code: exitCode, signal: null },
terminal_exit: {
terminal_id: terminalId,
exit_code: exitCode,
signal: null,
},
},
}
}
@@ -342,10 +403,7 @@ export function toolUpdateFromToolResult(
}
default: {
return toAcpContentUpdate(
resultContent ?? '',
isError,
)
return toAcpContentUpdate(resultContent ?? '', isError)
}
}
}
@@ -411,8 +469,12 @@ function toAcpContentBlock(
case 'tool_reference':
return wrapText(`Tool: ${content.tool_name as string}`)
case 'tool_search_tool_search_result': {
const refs = content.tool_references as Array<{ tool_name: string }> | undefined
return wrapText(`Tools found: ${refs?.map((r) => r.tool_name).join(', ') || 'none'}`)
const refs = content.tool_references as
| Array<{ tool_name: string }>
| undefined
return wrapText(
`Tools found: ${refs?.map(r => r.tool_name).join(', ') || 'none'}`,
)
}
case 'tool_search_tool_result_error':
return wrapText(
@@ -428,7 +490,9 @@ function toAcpContentBlock(
return wrapText(`Error: ${content.error_code as string}`)
case 'code_execution_result':
case 'bash_code_execution_result':
return wrapText(`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`)
return wrapText(
`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`,
)
case 'code_execution_tool_result_error':
case 'bash_code_execution_tool_result_error':
return wrapText(`Error: ${content.error_code as string}`)
@@ -508,7 +572,10 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
}
}
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {}
const result: {
content?: ToolCallContent[]
locations?: ToolCallLocation[]
} = {}
if (content.length > 0) result.content = content
if (locations.length > 0) result.locations = locations
return result
@@ -523,10 +590,12 @@ function nextSdkMessageOrAbort(
}
let abortHandler: (() => void) | undefined
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
abortHandler = () => resolve({ done: true, value: undefined })
abortSignal.addEventListener('abort', abortHandler, { once: true })
})
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>(
resolve => {
abortHandler = () => resolve({ done: true, value: undefined })
abortSignal.addEventListener('abort', abortHandler, { once: true })
},
)
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
if (abortHandler) {
@@ -622,7 +691,8 @@ export async function forwardSessionUpdates(
accumulatedUsage.inputTokens += usage.input_tokens
accumulatedUsage.outputTokens += usage.output_tokens
accumulatedUsage.cachedReadTokens += usage.cache_read_input_tokens
accumulatedUsage.cachedWriteTokens += usage.cache_creation_input_tokens
accumulatedUsage.cachedWriteTokens +=
usage.cache_creation_input_tokens
}
// Resolve context window size from modelUsage via prefix matching
@@ -638,12 +708,12 @@ export async function forwardSessionUpdates(
// Send usage_update — use lastAssistantTotalUsage if available
// (more accurate than accumulatedUsage which may include background tasks)
const usedTokens = lastAssistantTotalUsage ?? (
const usedTokens =
lastAssistantTotalUsage ??
accumulatedUsage.inputTokens +
accumulatedUsage.outputTokens +
accumulatedUsage.cachedReadTokens +
accumulatedUsage.cachedWriteTokens
)
accumulatedUsage.outputTokens +
accumulatedUsage.cachedReadTokens +
accumulatedUsage.cachedWriteTokens
const totalCostUsd = msg.total_cost_usd as number | undefined
await conn.sessionUpdate({
@@ -652,9 +722,10 @@ export async function forwardSessionUpdates(
sessionUpdate: 'usage_update',
used: usedTokens,
size: lastContextWindowSize,
cost: totalCostUsd != null
? { amount: totalCostUsd, currency: 'USD' }
: undefined,
cost:
totalCostUsd != null
? { amount: totalCostUsd, currency: 'USD' }
: undefined,
},
})
@@ -724,8 +795,13 @@ export async function forwardSessionUpdates(
case 'assistant': {
// Track last assistant total usage for context window computation
// (only for top-level messages, not subagents)
const assistantMsg = msg.message as Record<string, unknown> | undefined
const parentToolUseId = msg.parent_tool_use_id as string | null | undefined
const assistantMsg = msg.message as
| Record<string, unknown>
| undefined
const parentToolUseId = msg.parent_tool_use_id as
| string
| null
| undefined
if (assistantMsg?.usage && parentToolUseId === null) {
const msgUsage = assistantMsg.usage as Record<string, unknown>
lastAssistantTotalUsage =
@@ -773,7 +849,10 @@ export async function forwardSessionUpdates(
// Handle agent/skill subagent progress
const progressType = progressData.type as string | undefined
if (progressType === 'agent_progress' || progressType === 'skill_progress') {
if (
progressType === 'agent_progress' ||
progressType === 'skill_progress'
) {
const progressMessage = progressData.message as
| Record<string, unknown>
| undefined
@@ -887,7 +966,15 @@ function assistantMessageToAcpNotifications(
]
}
return toAcpNotifications(content, 'assistant', sessionId, toolUseCache, conn, undefined, options)
return toAcpNotifications(
content,
'assistant',
sessionId,
toolUseCache,
conn,
undefined,
options,
)
}
// ── Stream event conversion ───────────────────────────────────────
@@ -907,7 +994,9 @@ function streamEventToAcpNotifications(
switch (event.type as string) {
case 'content_block_start': {
const contentBlock = event.content_block as Record<string, unknown> | undefined
const contentBlock = event.content_block as
| Record<string, unknown>
| undefined
if (!contentBlock) return []
return toAcpNotifications(
[contentBlock],
@@ -1001,7 +1090,9 @@ function toAcpNotifications(
if (source?.type === 'base64') {
update = {
sessionUpdate:
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
role === 'assistant'
? 'agent_message_chunk'
: 'user_message_chunk',
content: {
type: 'image',
data: source.data as string,
@@ -1034,7 +1125,7 @@ function toAcpNotifications(
| Array<{ content: string; status: string }>
| undefined
if (Array.isArray(todos)) {
const entries: PlanEntry[] = todos.map((todo) => ({
const entries: PlanEntry[] = todos.map(todo => ({
content: todo.content,
status: normalizePlanStatus(todo.status),
priority: 'medium',
@@ -1086,8 +1177,7 @@ function toAcpNotifications(
case 'tool_result':
case 'mcp_tool_result': {
const toolUseId =
(chunk.tool_use_id as string | undefined) ?? ''
const toolUseId = (chunk.tool_use_id as string | undefined) ?? ''
const toolUse = toolUseCache[toolUseId]
if (!toolUse) break
@@ -1105,7 +1195,9 @@ function toAcpNotifications(
toolCallId: toolUseId,
sessionUpdate: 'tool_call_update',
status:
(chunk.is_error as boolean | undefined) === true ? 'failed' : 'completed',
(chunk.is_error as boolean | undefined) === true
? 'failed'
: 'completed',
rawOutput: chunk.content,
...toolUpdate,
}
@@ -1178,7 +1270,8 @@ export async function replayHistoryMessages(
const content = messageData?.content
if (!content) continue
const role: 'assistant' | 'user' = type === 'assistant' ? 'assistant' : 'user'
const role: 'assistant' | 'user' =
type === 'assistant' ? 'assistant' : 'user'
if (typeof content === 'string') {
if (!content.trim()) continue
@@ -1234,5 +1327,5 @@ function getMatchingModelUsage(
}
}
return bestKey ? modelUsage[bestKey] ?? null : null
return bestKey ? (modelUsage[bestKey] ?? null) : null
}

View File

@@ -1,7 +1,4 @@
import {
AgentSideConnection,
ndJsonStream,
} from '@agentclientprotocol/sdk'
import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
import type { Stream } from '@agentclientprotocol/sdk'
import { Readable, Writable } from 'node:stream'
import { AcpAgent } from './agent.js'
@@ -39,7 +36,7 @@ export async function runAcpAgent(): Promise<void> {
const stream = createAcpStream(process.stdin, process.stdout)
let agent!: AcpAgent
const connection = new AgentSideConnection((conn) => {
const connection = new AgentSideConnection(conn => {
agent = new AcpAgent(conn)
return agent
}, stream)

View File

@@ -44,14 +44,25 @@ export function createAcpCanUseTool(
context: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
forceDecision?: PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision,
): Promise<PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision> => {
forceDecision?:
| PermissionAllowDecision
| PermissionAskDecision
| PermissionDenyDecision,
): Promise<
PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision
> => {
const supportsTerminalOutput = checkTerminalOutput(clientCapabilities)
// ── ExitPlanMode special handling ────────────────────────────
if (tool.name === 'ExitPlanMode') {
return handleExitPlanMode(
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
conn,
sessionId,
toolUseID,
input,
supportsTerminalOutput,
cwd,
onModeChange,
isBypassModeAvailable,
)
}
@@ -66,7 +77,11 @@ export function createAcpCanUseTool(
// bypassPermissions mode, dontAsk mode, acceptEdits mode, auto mode classifier
try {
const pipelineResult = await hasPermissionsToUseTool(
tool, input, context, assistantMessage, toolUseID,
tool,
input,
context,
assistantMessage,
toolUseID,
)
// If the pipeline resolved to allow or deny, return that
@@ -168,9 +183,21 @@ async function handleExitPlanMode(
isBypassModeAvailable?: () => boolean,
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
const options: Array<PermissionOption> = [
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
{ kind: 'allow_always', name: 'Yes, and auto-accept edits', optionId: 'acceptEdits' },
{ kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' },
{
kind: 'allow_always',
name: 'Yes, and use "auto" mode',
optionId: 'auto',
},
{
kind: 'allow_always',
name: 'Yes, and auto-accept edits',
optionId: 'acceptEdits',
},
{
kind: 'allow_once',
name: 'Yes, and manually approve edits',
optionId: 'default',
},
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
]
if (isBypassModeAvailable?.() === true) {
@@ -215,15 +242,15 @@ async function handleExitPlanMode(
response.outcome.optionId !== undefined
) {
const selectedOption = response.outcome.optionId
const isOfferedOption = options.some(option => option.optionId === selectedOption)
const isOfferedOption = options.some(
option => option.optionId === selectedOption,
)
if (
isOfferedOption &&
(
selectedOption === 'default' ||
(selectedOption === 'default' ||
selectedOption === 'acceptEdits' ||
selectedOption === 'auto' ||
selectedOption === 'bypassPermissions'
)
selectedOption === 'bypassPermissions')
) {
// Sync mode to session state and appState
onModeChange?.(selectedOption)