feat: ACP 协议版本 remote control (#293)

* fix: 添加 usage 字段缺失时的防御性防护

第三方 API(如智谱 GLM)在某些流式响应中不返回 usage 字段,
导致 usage.input_tokens 访问 undefined 崩溃并连锁影响后续所有请求。

- claude.ts: content_block_stop 创建消息时 fallback 到 EMPTY_USAGE
- LocalAgentTask.tsx: usage 为 undefined 时提前返回
- tokens.ts: getTokenCountFromUsage 加 null guard 和 ?? 0
- cost-tracker.ts: input_tokens/output_tokens 加 ?? 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: ACP Plan 展示 — 支持 session/update plan 类型的可视化

补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件
渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 穷鬼模式下跳过 verification agent 以节省 token

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: 补充 RCS 后端 + 前端测试覆盖 (+116 tests)

后端新增 3 个测试文件 (70 tests):
- automationState: normalize/snapshot/equals 纯函数
- client-payload: toClientPayload 协议转换
- transport-normalize: normalizePayload + extractContent

前端新增 2 个测试文件 (46 tests):
- utils: formatTime/statusClass/truncate/extractEventText 等
- api-client: getUuid/setUuid/api GET/POST 错误处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: RCS ACP 页面添加权限模式选择器 + 权限响应修复

- 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递
- 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug
- 权限模式和模型选择持久化到 localStorage
- acp-link 直接连接路径同步支持 permissionMode 透传

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: RCS Web UI 重构 + QR 修复 + ACP 扫描自动跳转

- RCS Web UI 组件全面重构: Dialog 迁移 Radix UI, lazy loading,
  主题系统改进, 组件样式优化
- IdentityPanel QR 码显示修复: requestAnimationFrame 延迟绘制
  解决 Radix Dialog Portal 挂载时序问题
- ACP QR 扫描自动跳转: IdentityPanel 扫描 ACP 格式 { url, token }
  后存储 sessionStorage 并跳转 /code/?acp=1
- 新增 ACPDirectView 组件: ACP 直连视图, 用 ACPClient 连接并
  渲染 ACPMain 聊天界面

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: ACP 权限管道改进 — 模式同步 + bypass 检测 + 统一权限流水线

- agent.ts: applySessionMode 同步 appState.toolPermissionContext.mode
- agent.ts: bypassPermissions 可用性检测 (非 root 或 sandbox 环境)
- permissions.ts: createAcpCanUseTool 接入 hasPermissionsToUseTool
  统一权限流水线, 替代原来分散的处理逻辑
- permissions.ts: 支持 onModeChange 回调, 模式变更时实时同步

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: acp-link 支持 permissionMode 默认值传递给 agent

客户端 (Zed/VS Code 等) 的 new_session 不一定携带 permissionMode,
导致 agent 收到 _meta: undefined, permission 回退到 default。

修复: handleNewSession 使用 fallback 链:
  客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量

使用: ACP_PERMISSION_MODE=auto acp-link claude

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新文档及说明

* fix: 修复类型错误

* chore: 提交脚本

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-18 21:54:22 +08:00
committed by GitHub
parent 34154ee3f5
commit 2e9aaf4993
54 changed files with 2400 additions and 435 deletions

View File

@@ -7,6 +7,7 @@ import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { getCurrentWorktreeSession } from '../utils/worktree.js'
import { getSessionStartDate } from './common.js'
import { getInitialSettings } from '../utils/settings/settings.js'
import { isPoorModeActive } from '../commands/poor/poorMode.js'
import {
AGENT_TOOL_NAME,
VERIFICATION_AGENT_TYPE,
@@ -391,7 +392,9 @@ function getSessionSpecificGuidanceSection(
hasAgentTool &&
feature('VERIFICATION_AGENT') &&
// 3P default: false — verification agent is ant-only A/B
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
// Poor mode: skip verification agent to save tokens
!isPoorModeActive()
? `The contract: when non-trivial implementation happens on your turn, independent adversarial verification must happen before you report completion \u2014 regardless of who did the implementing (you directly, a fork you spawned, or a subagent). You are the one reporting to the user; you own the gate. Non-trivial means: 3+ file edits, backend/API changes, or infrastructure changes. Spawn the ${AGENT_TOOL_NAME} tool with subagent_type="${VERIFICATION_AGENT_TYPE}". Your own checks, caveats, and a fork's self-checks do NOT substitute \u2014 only the verifier assigns a verdict; you cannot self-assign PARTIAL. Pass the original user request, all files changed (by anyone), the approach, and the plan file path if applicable. Flag concerns if you have them but do NOT share test results or claim things work. On FAIL: fix, resume the verifier with its findings plus your fix, repeat until PASS. On PASS: spot-check it \u2014 re-run 2-3 commands from its report, confirm every PASS has a Command run block with output that matches your re-run. If any PASS lacks a command block or diverges, resume the verifier with the specifics. On PARTIAL (from the verifier): report what passed and what could not be verified.`
: null,
].filter(item => item !== null)

View File

@@ -263,8 +263,8 @@ function addToTotalModelUsage(
maxOutputTokens: 0,
}
modelUsage.inputTokens += usage.input_tokens
modelUsage.outputTokens += usage.output_tokens
modelUsage.inputTokens += usage.input_tokens ?? 0
modelUsage.outputTokens += usage.output_tokens ?? 0
modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
modelUsage.webSearchRequests +=

View File

@@ -459,10 +459,13 @@ export class AcpAgent implements Agent {
const permissionContext = getEmptyToolPermissionContext()
const tools: Tools = getTools(permissionContext)
// Parse permission mode from settings
// Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings
const metaPermissionMode = (params._meta as Record<string, unknown> | null | undefined)?.permissionMode as string | undefined
console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode)
const permissionMode = resolvePermissionMode(
this.getSetting<string>('permissions.defaultMode'),
metaPermissionMode ?? this.getSetting<string>('permissions.defaultMode'),
)
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
// Create the permission bridge canUseTool function
const canUseTool = createAcpCanUseTool(
@@ -471,17 +474,24 @@ export class AcpAgent implements Agent {
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
this.clientCapabilities,
cwd,
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
)
// Parse MCP servers from ACP params
// MCP server config is handled separately in the tools system
// Check if bypass permissions is available (not running as root unless in sandbox)
const isBypassAvailable =
(typeof process.geteuid === 'function' ? process.geteuid() !== 0 : true) ||
!!process.env.IS_SANDBOX
// Create a mutable AppState for the session
const appState: AppState = {
...getDefaultAppState(),
toolPermissionContext: {
...permissionContext,
mode: permissionMode as PermissionMode,
isBypassPermissionsModeAvailable: isBypassAvailable,
},
}
@@ -666,6 +676,11 @@ export class AcpAgent implements Agent {
const session = this.sessions.get(sessionId)
if (session) {
session.modes = { ...session.modes, currentModeId: modeId }
// Sync mode to appState so the permission pipeline sees the correct mode
session.appState.toolPermissionContext = {
...session.appState.toolPermissionContext,
mode: modeId as PermissionMode,
}
}
}

View File

@@ -22,6 +22,7 @@ import type {
} from '../../types/permissions.js'
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
import type { AssistantMessage } from '../../types/message.js'
import { hasPermissionsToUseTool } from '../../utils/permissions/permissions.js'
import { toolInfoFromToolUse } from './bridge.js'
const IS_ROOT =
@@ -42,31 +43,52 @@ export function createAcpCanUseTool(
getCurrentMode: () => string,
clientCapabilities?: ClientCapabilities,
cwd?: string,
onModeChange?: (modeId: string) => void,
): CanUseToolFn {
return async (
tool: ToolType,
input: Record<string, unknown>,
_context: ToolUseContext,
_assistantMessage: AssistantMessage,
context: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
_forceDecision?: 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)
return handleExitPlanMode(
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
)
}
// ── bypassPermissions mode ───────────────────────────────────
if (getCurrentMode() === 'bypassPermissions') {
return {
behavior: 'allow',
updatedInput: input,
// ── Force decision bypass (used by coordinator/swarm workers) ──
if (forceDecision !== undefined) {
return forceDecision
}
// ── Run through the normal permission pipeline ────────────────
// This handles: deny rules, allow rules, tool-specific checks,
// bypassPermissions mode, dontAsk mode, acceptEdits mode, auto mode classifier
try {
const pipelineResult = await hasPermissionsToUseTool(
tool, input, context, assistantMessage, toolUseID,
)
// If the pipeline resolved to allow or deny, return that
if (pipelineResult.behavior === 'allow') {
return pipelineResult as PermissionAllowDecision
}
if (pipelineResult.behavior === 'deny') {
return pipelineResult as PermissionDenyDecision
}
// behavior === 'ask' → fall through to client delegation
} catch (err) {
// If the pipeline fails, fall through to client delegation
console.error('[ACP Permissions] Pipeline error, falling back to client:', err)
}
// ── Standard tool permission ─────────────────────────────────
// ── Delegate to ACP client for interactive permission decision ──
const info = toolInfoFromToolUse(
{ name: tool.name, id: toolUseID, input },
supportsTerminalOutput,
@@ -139,6 +161,7 @@ async function handleExitPlanMode(
input: Record<string, unknown>,
supportsTerminalOutput: boolean,
cwd?: string,
onModeChange?: (modeId: string) => void,
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
const options: Array<PermissionOption> = [
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
@@ -194,6 +217,9 @@ async function handleExitPlanMode(
selectedOption === 'auto' ||
selectedOption === 'bypassPermissions'
) {
// Sync mode to session state and appState
onModeChange?.(selectedOption)
await conn.sessionUpdate({
sessionId,
update: {

View File

@@ -2238,6 +2238,7 @@ async function* queryModel(
const m: AssistantMessage = {
message: {
...partialMessage,
usage: partialMessage.usage ?? { ...EMPTY_USAGE },
content: normalizeContentFromAPI(
[contentBlock] as BetaContentBlock[],
tools,

View File

@@ -106,7 +106,10 @@ export function updateProgressFromMessage(
if (message.type !== 'assistant') {
return
}
const usage = message.message!.usage as BetaUsage
const usage = message.message!.usage as BetaUsage | undefined
if (!usage) {
return
}
// Keep latest input (it's cumulative in the API), sum outputs
tracker.latestInputTokens =
(usage.input_tokens as number) +

View File

@@ -46,11 +46,14 @@ function getAssistantMessageId(message: Message): string | undefined {
* Use tokenCountWithEstimation() when you need context size from messages.
*/
export function getTokenCountFromUsage(usage: Usage): number {
if (!usage) {
return 0
}
return (
usage.input_tokens +
(usage.input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
usage.output_tokens
(usage.output_tokens ?? 0)
)
}