From 3ce7625bcbee1ab306656b0bc28c80e355a94bba Mon Sep 17 00:00:00 2001 From: unraid Date: Sat, 25 Apr 2026 16:53:16 +0800 Subject: [PATCH 01/13] fix: harden ACP communication boundaries Harden ACP communication boundaries Remote ACP sessions now cannot widen permission mode through untrusted metadata or client payloads. WebSocket ACP ingress measures payloads by bytes before binary decode, and prompt queue handoff keeps exactly one prompt active while queued prompts are drained FIFO. Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent Constraint: WebSocket payload limits must be byte-based and checked before binary decode Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing Confidence: high Scope-risk: moderate Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build Not-tested: Manual live ACP/RCS session against an external client --- docs/internals/agent-comm-fix-jira-tasks.md | 564 ++++++++++++++++++ docs/internals/agent-comm-fix-questions.md | 74 +++ .../acp-link/src/__tests__/server.test.ts | 24 +- packages/acp-link/src/server.ts | 38 +- .../src/__tests__/routes.test.ts | 147 ++++- .../src/routes/acp/index.ts | 89 +-- .../src/routes/v1/session-ingress.ts | 58 +- .../src/transport/ws-payload.ts | 64 ++ src/screens/REPL.tsx | 21 +- src/services/acp/__tests__/agent.test.ts | 247 +++++++- src/services/acp/__tests__/bridge.test.ts | 96 +++ .../acp/__tests__/promptConversion.test.ts | 28 + src/services/acp/agent.ts | 203 +++++-- src/services/acp/bridge.ts | 51 +- src/services/acp/promptConversion.ts | 40 ++ 15 files changed, 1582 insertions(+), 162 deletions(-) create mode 100644 docs/internals/agent-comm-fix-jira-tasks.md create mode 100644 docs/internals/agent-comm-fix-questions.md create mode 100644 packages/remote-control-server/src/transport/ws-payload.ts create mode 100644 src/services/acp/__tests__/promptConversion.test.ts create mode 100644 src/services/acp/promptConversion.ts diff --git a/docs/internals/agent-comm-fix-jira-tasks.md b/docs/internals/agent-comm-fix-jira-tasks.md new file mode 100644 index 000000000..037254c9b --- /dev/null +++ b/docs/internals/agent-comm-fix-jira-tasks.md @@ -0,0 +1,564 @@ +# Agent 通讯修复 Jira Task + +- 版本:v1.0 +- 生成日期:2026-04-25 +- 来源:由按文件执行清单、Claude 交叉验证意见整理合并 +- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期 +- 使用方式:这是唯一执行任务文档;每个 `JIRA-*` 小节可直接拆成一个 Jira issue,字段保持统一,便于复制或二次导入。 + +--- + +## 方案性质 + +本文档是目标状态式执行方案,不是临时补丁清单。每张 ticket 必须交付明确的代码终态、测试覆盖和回归边界;不得只用局部 workaround 掩盖问题。 + +--- + +## 执行总则 + +1. 先边界安全,后内部优化:先修 WS 入站大小与输入校验,避免线上风险扩大。 +2. 单文件可回滚:每个文件内修改保持内聚,便于回滚与 bisect。 +3. 不改协议语义,只修实现缺陷:除 `resource_link` 表达形式统一外,不改变主流程契约。 +4. 每个文件必须有验收输出:要么测试用例,要么日志/指标验证。 +5. 发布前必须确认协议层行为无回归:`stopReason` 决策与 `sessionUpdate` 发送顺序保持稳定。 + +--- + +## Epic + +### JIRA-EPIC-001:提升 Agent 通讯链路稳定性与边界安全 + +- Issue Type:Epic +- Priority:P0 +- Owner:核心通讯 / 后端网关 / QA +- Scope:ACP Agent、ACP Bridge、Remote Control Server、REPL 初始化生命周期 +- Goal:修复长会话资源泄漏、补齐 WebSocket 入站边界、统一 prompt 转换、收敛类型风险,并补充关键回归测试。 + +#### Epic 验收标准 + +- `bun run typecheck` 0 error。 +- P0 WebSocket 超大消息拒绝逻辑已实现并覆盖测试。 +- ACP bridge abort listener 生命周期无累积。 +- prompt 转换实现单源化。 +- settings/defaultMode 能真实影响 ACP permission mode,且 `_meta.permissionMode` 保持最高优先级。 +- REPL 目标 hook suppress 清理完成,timer cleanup 完整。 + +--- + +## P0 Tickets + +### JIRA-001:为 session ingress WebSocket 补齐消息大小限制 + +- Issue Type:Bug +- Priority:P0 +- Story Points:3 +- Owner:后端/网关 +- Files: + - `packages/remote-control-server/src/routes/v1/session-ingress.ts` +- 后续票:JIRA-008(同文件 P1 类型与 decode path 收尾) + +#### 参考代码位置 + +- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106` + +#### 背景 + +`session-ingress` 当前缺少 WebSocket message size limit。ACP 路由已有类似限制,两个入口边界不一致,可能导致大包占用内存或绕过入口保护。 + +#### 实施要求 + +- 新增 `MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024`,与 ACP 路由的 10MB 上限保持一致。 +- 在 `onMessage` decode 后优先检查 payload size。 +- 超限时执行 `ws.close(1009, "message too large")`。 +- 日志记录 `sessionId`、payload size、limit。 +- 对 `string`、`ArrayBuffer`、`Uint8Array` 进行统一 decode 分流。 +- 非支持类型直接拒绝并记录,不进入业务 handler。 + +#### 验收标准 + +- 11MB payload 被 1009 close。 +- 1KB 合法 payload 仍正常进入 handler。 +- 非支持类型 payload 不进入 handler。 +- 不改变 URL、auth、session 解析逻辑。 + +#### 回归范围 + +- Remote Control Server session ingress WebSocket。 +- 正常会话消息转发。 +- WebSocket close code 行为。 + +#### 风险等级 + +- 中。入口逻辑变更可能影响特殊客户端 payload 类型。 + +#### 必须验证 + +- 在 `packages/remote-control-server/src/__tests__/routes.test.ts` 增加 session-ingress WebSocket 大包、小包、坏类型 payload 用例。 +- 运行 `bun run typecheck`。 + +--- + +### JIRA-002:修复 ACP bridge abort listener 生命周期泄漏 + +- Issue Type:Bug +- Priority:P0 +- Story Points:3 +- Owner:核心通讯 +- Files: + - `src/services/acp/bridge.ts` + +#### 参考代码位置 + +- `src/services/acp/bridge.ts:576-585` + +#### 背景 + +ACP bridge 的 `Promise.race` abort 分支注册 listener 后缺少完整 cleanup。长会话或高频 next 场景可能出现 listener 累积。 + +#### 实施要求 + +- 将 abort race 改为可清理监听器写法。 +- 注册 listener 后保留 handler 引用。 +- `sdkMessages.next()` 先返回时必须 `removeEventListener`。 +- abort、throw、return 等路径都在 `finally` 中清理。 +- 不改变 `stopReason` 决策逻辑。 +- 不改变 `sessionUpdate` 发送顺序。 + +#### 验收标准 + +- 模拟 10k 次 next 且不 abort,listener 不增长。 +- abort 场景仍返回 `cancelled`。 +- 原有 streaming/session update 行为无回归。 + +#### 回归范围 + +- ACP bridge streaming loop。 +- 用户取消请求。 +- SDK generator 异常路径。 + +#### 风险等级 + +- 中。异步控制流变更需要覆盖取消与异常路径。 + +#### 必须验证 + +- 新增 listener cleanup 单元测试。 +- 运行 `bun run typecheck`。 + +--- + +## P1 Tickets + +### JIRA-003:优化 ACP agent pending prompt 队列为 O(1) 出队 + +- Issue Type:Task +- Priority:P1 +- Story Points:5 +- Owner:核心通讯 +- Files: + - `src/services/acp/agent.ts` + +#### 参考代码位置 + +- `src/services/acp/agent.ts:332-339` + +#### 背景 + +当前 pending prompt 队列使用 `Map + sort` 获取下一项,排队量上升时会带来不必要的排序成本。 + +#### 实施要求 + +- 改为 `queue: string[]` + `pendingMap: Map` 组合。 +- 入队执行 `queue.push(id)` 与 `pendingMap.set(id, prompt)`。 +- 出队从队首惰性跳过已取消项。 +- 取消只从 `pendingMap` 删除,不做数组中间删除。 +- 保持现有取消语义和出队顺序。 + +#### 验收标准 + +- 1000 pending prompt 场景下出队顺序正确。 +- 已取消 prompt 不会被 resolve。 +- 出队不再依赖全量 sort。 +- 1000 排队场景下出队耗时低于旧实现;测试记录旧实现复杂度风险和新实现 O(1) 出队路径。 +- 行为与旧实现兼容。 + +#### 回归范围 + +- ACP prompt queue。 +- 并发 prompt 请求。 +- prompt cancel / resolve 边界。 + +#### 风险等级 + +- 中。队列结构变更可能引入取消边界问题。 + +#### 必须验证 + +- 新增 queue 顺序与取消测试。 +- 对 1000 prompt 场景做性能断言或日志记录。 + +--- + +### JIRA-004:接入真实 settings 读取并校验 ACP permission mode + +- Issue Type:Bug +- Priority:P1 +- Story Points:3 +- Owner:核心通讯 +- Files: + - `src/services/acp/agent.ts` + +#### 参考代码位置 + +- `src/services/acp/agent.ts:465-467` + +#### 背景 + +`getSetting()` 当前未真正接入项目配置,导致默认 permission mode 配置无法按预期生效。 + +#### 实施要求 + +- 接入项目现有 settings/config 读取逻辑。 +- 仅接受合法 permission mode 枚举值。 +- 非法值 fallback 到 `default`。 +- `_meta.permissionMode` 继续保持最高优先级。 +- 不改变外部协议字段。 + +#### 验收标准 + +- settings/defaultMode 能影响默认 permission mode。 +- `_meta.permissionMode` 能覆盖 settings。 +- 非法 settings 值不会传播到运行时。 +- 类型检查通过。 + +#### 回归范围 + +- ACP agent session 初始化。 +- 权限模式同步。 +- 客户端 `_meta` 覆盖逻辑。 + +#### 风险等级 + +- 中。配置优先级错误会影响权限行为。 + +#### 必须验证 + +- 新增 defaultMode / `_meta.permissionMode` 优先级测试。 +- 运行 `bun run typecheck`。 + +--- + +### JIRA-005:单源化 ACP prompt 转换逻辑 + +- Issue Type:Refactor +- Priority:P1 +- Story Points:5 +- Owner:核心通讯 +- Files: + - `src/services/acp/agent.ts` + - `src/services/acp/bridge.ts` + - `src/services/acp/promptConversion.ts`(新增) + +#### 参考代码位置 + +- `src/services/acp/agent.ts:754-758` +- `src/services/acp/agent.ts:764-785` +- `src/services/acp/bridge.ts:522-537` + +#### 背景 + +ACP agent 与 bridge 存在重复 prompt 转换逻辑,`resource_link` 等 block 的输出策略容易分叉。 + +#### 实施要求 + +- 新增共享转换模块 `src/services/acp/promptConversion.ts`。 +- `agent.ts` 与 `bridge.ts` 改为调用共享转换函数。 +- 删除 `bridge.ts` 中 `promptToQueryContent` 的真实实现;如导出仍需保留,则只允许保留调用共享函数的 wrapper。 +- `resource_link` 输出改为稳定纯文本元信息,禁止 markdown link。 +- 保持其他 block 转换语义不变。 + +#### 验收标准 + +- 全仓库仅保留一个真实 prompt 转换实现。 +- 相同 input block 在 agent/bridge 输出一致。 +- `resource_link` 不再输出 `[name](uri)` 形式。 +- 相关测试覆盖转换一致性。 + +#### 回归范围 + +- ACP prompt input。 +- bridge query content。 +- resource link prompt 表达。 + +#### 风险等级 + +- 中。文本格式变化可能影响下游 prompt 快照或断言。 + +#### 必须验证 + +- 新增 shared conversion 单元测试。 +- 全仓库搜索重复转换函数。 +- 运行 `bun run typecheck`。 + +--- + +### JIRA-006:治理 REPL onInit effect 依赖并补齐 timer cleanup + +- Issue Type:Task +- Priority:P1 +- Story Points:3 +- Owner:终端 UI +- Files: + - `src/screens/REPL.tsx` + +#### 参考代码位置 + +- `src/screens/REPL.tsx:654-662` +- `src/screens/REPL.tsx:4996-5005` + +#### 背景 + +REPL 中目标初始化 effect 存在 hook dependency suppress,warm-up timer 也需要显式 cleanup,避免频繁挂载/卸载时留下悬挂任务。 + +#### 实施要求 + +- 整理 `onInit` 生命周期,使用稳定引用或 effect 内联。 +- 移除目标段 `exhaustive-deps` suppress。 +- 保持 unmount cleanup 行为不变。 +- warm-up effect 中记录 timeout id。 +- cleanup 中执行 `clearTimeout(timeoutId)`。 +- 保留 `alive` 判定作为并发保护。 + +#### 验收标准 + +- 目标段不再需要 hooks lint suppress。 +- 高频打开/关闭搜索栏无悬挂 timer 增长。 +- REPL 初始化行为无回归。 + +#### 回归范围 + +- REPL 初始化。 +- 搜索栏 warm-up。 +- 组件卸载 cleanup。 + +#### 风险等级 + +- 中。React effect 依赖治理可能改变初始化时机。 + +#### 必须验证 + +- 运行 lint/typecheck。 +- 手动或测试覆盖 REPL mount/unmount。 + +--- + +### JIRA-007:收敛 ACP route WebSocket 事件 any 类型 + +- Issue Type:Task +- Priority:P1 +- Story Points:2 +- Owner:后端/网关 +- Files: + - `packages/remote-control-server/src/routes/acp/index.ts` + +#### 参考代码位置 + +- `packages/remote-control-server/src/routes/acp/index.ts:108-146` + +#### 背景 + +ACP route 中 WebSocket 事件和 socket 参数存在 `any`,降低编译期保护。 + +#### 实施要求 + +- 定义最小 WebSocket 事件类型:open/message/close/error。 +- 将 `_evt: any`、`evt: any`、`ws: any` 替换为窄类型。 +- 不改变 payload decode 与大小检查策略。 +- 不改变现有 handler 行为。 + +#### 验收标准 + +- 编译期能捕获错误事件字段访问。 +- 现有 WebSocket 行为不变。 +- `bun run typecheck` 通过。 + +#### 回归范围 + +- ACP WebSocket route。 +- message decode。 +- close/error handler。 + +#### 风险等级 + +- 低。类型收敛为主。 + +#### 必须验证 + +- 运行 `bun run typecheck`。 +- 保留现有测试通过。 + +--- + +### JIRA-008:收敛 session ingress WebSocket 事件类型与 decode path + +- Issue Type:Task +- Priority:P1 +- Story Points:3 +- Owner:后端/网关 +- Files: + - `packages/remote-control-server/src/routes/v1/session-ingress.ts` +- 前置依赖:JIRA-001 已合并 + +#### 参考代码位置 + +- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106` + +#### 背景 + +在完成 P0 size guard 后,session ingress 仍需要进一步收敛事件类型与 decode path,减少隐式类型风险。 + +#### 实施要求 + +- 定义或复用最小 WebSocket message event 类型。 +- 将 message decode 分支集中到一个小函数。 +- 保持 P0 size guard 与 close code 语义。 +- 不改变 auth/session 解析。 + +#### 验收标准 + +- decode path 单一清晰。 +- 不支持 payload 类型有明确拒绝路径。 +- `bun run typecheck` 通过。 + +#### 回归范围 + +- Session ingress WebSocket message handling。 +- P0 大包拒绝逻辑。 + +#### 风险等级 + +- 低到中。与 P0 同文件,注意避免重复改动冲突。 + +#### 必须验证 + +- 与 JIRA-001 同批测试。 +- 运行 `bun run typecheck`。 + +--- + +## QA Tickets + +### JIRA-009:补充 ACP 通讯回归测试 + +- Issue Type:Test +- Priority:P1 +- Story Points:5 +- Owner:QA/核心通讯 +- Files: + - `src/services/acp/agent.ts` + - `src/services/acp/bridge.ts` + - `src/services/acp/promptConversion.ts` + - `src/services/acp/__tests__/agent.test.ts` + - `src/services/acp/__tests__/bridge.test.ts` + - `src/services/acp/__tests__/promptConversion.test.ts` + +#### 覆盖场景 + +- 长会话 10k turn,无 abort listener 累积。 +- prompt queue 1000 并发排队,取消/出队顺序正确。 +- settings/defaultMode 与 `_meta.permissionMode` 优先级正确。 +- `resource_link` 转换在 agent 与 bridge 输出一致。 + +#### 验收标准 + +- 新增测试在本地稳定通过。 +- 不依赖真实网络或外部服务。 +- 测试 mock 遵守仓库规范,只 mock 有副作用链路。 + +#### 回归范围 + +- ACP bridge。 +- ACP agent。 +- prompt conversion。 +- permission mode resolution。 + +#### 风险等级 + +- 中。异步测试可能有稳定性问题,需要避免时间敏感断言。 + +#### 必须验证 + +- 运行相关 `bun test`。 +- 运行 `bun run typecheck`。 + +--- + +### JIRA-010:补充 Remote Control Server WebSocket 入站回归测试 + +- Issue Type:Test +- Priority:P1 +- Story Points:3 +- Owner:QA/后端 +- Files: + - `packages/remote-control-server/src/__tests__/routes.test.ts` + - `packages/remote-control-server/src/routes/v1/session-ingress.ts` + +#### 覆盖场景 + +- 11MB session ingress payload 被 1009 close(与 10MB 上限对齐)。 +- 合法小 payload 正常进入 handler。 +- 非支持 payload 类型被拒绝。 +- 日志或可观测输出包含 sessionId、payload size、limit。 + +#### 验收标准 + +- 11MB payload 被 1009 close(与 10MB 上限对齐)。 +- 新增测试稳定通过。 +- 不启动真实外部服务。 +- 不改变现有 route public contract。 + +#### 回归范围 + +- RCS session ingress route。 +- WebSocket message handling。 +- close code 行为。 + +#### 风险等级 + +- 中。测试需要适配现有 WebSocket/mock 基础设施。 + +#### 必须验证 + +- 运行 RCS package 相关测试。 +- 运行 `bun run typecheck`。 + +--- + +## 推荐执行顺序 + +执行节奏与原计划保持一致:先完成 P0 全部改动和冒烟验证,再启动 P1 改造;测试票可穿插执行,但不得绕过 P0 gate。 + +1. JIRA-001:先封入口大包风险。 +2. JIRA-002:修长会话 listener 生命周期。 +3. JIRA-010:补 RCS 入站测试,锁住 P0 行为。 +4. JIRA-003:优化 pending prompt queue。 +5. JIRA-004:接入 settings/defaultMode。 +6. JIRA-005:单源化 prompt 转换。 +7. JIRA-009:补 ACP 回归测试。 +8. JIRA-006:治理 REPL effect/timer。 +9. JIRA-007:收敛 ACP route 类型。 +10. JIRA-008:收敛 session ingress 类型与 decode path。 + +--- + +## Release Checklist + +- [ ] `bun run typecheck` 0 error +- [ ] P0 tickets 已合并并测试通过 +- [ ] ACP 回归测试通过 +- [ ] RCS WebSocket 入站测试通过 +- [ ] prompt conversion 单源化已通过代码搜索确认 +- [ ] permission mode 优先级测试通过 +- [ ] 协议层行为无回归(stopReason 决策、sessionUpdate 发送顺序) +- [ ] REPL hook/timer 改动通过 lint/typecheck +- [ ] 最终变更说明包含风险与未覆盖项 diff --git a/docs/internals/agent-comm-fix-questions.md b/docs/internals/agent-comm-fix-questions.md new file mode 100644 index 000000000..97ea4e607 --- /dev/null +++ b/docs/internals/agent-comm-fix-questions.md @@ -0,0 +1,74 @@ +# Agent 通讯修复问题文档 + +- 版本:v1.0 +- 生成日期:2026-04-25 +- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期 +- 配套执行文档:`docs/internals/agent-comm-fix-jira-tasks.md` +- 目的:保留决策前要问的问题、交叉验证提示词和已确认结论;不要在这里写 Jira 执行步骤。 + +--- + +## 1. 当前已确认结论 + +- 只保留两份交付文档:本问题文档 + Jira Task 文档。 +- Jira Task 文档是唯一执行入口,包含 Owner、优先级、文件范围、验收标准、风险和验证建议。 +- Claude 交叉验证结论:整体通过,无 blocking findings;建议补充协议回归 gate、JIRA-001/008 依赖、代码参考位置和阈值一致性,这些建议已合并到 Jira Task 文档。 +- 本次已进入业务代码修复阶段,必须运行 `bun run typecheck` 和相关回归测试。 + +--- + +## 2. 执行前必须问清的问题 + +1. `session-ingress` 的 WebSocket 上限是否固定为 10MB,并与 ACP route 保持一致? +2. 超限 close code 是否统一使用 `1009`,close reason 是否固定为 `message too large`? +3. `resource_link` 的纯文本格式是否已有下游依赖,能否替代当前 markdown link 表达? +4. ACP permission mode 的真实 settings key 是哪个,非法值 fallback 是否统一为 `default`? +5. `_meta.permissionMode` 是否必须始终覆盖 settings/defaultMode? +6. abort listener 测试中,是否能通过 mock signal 或计数器稳定证明 10k next 后无 listener 累积? +7. pending prompt queue 的取消语义是否允许惰性清理,而不是立刻从数组中删除? +8. REPL hook suppress 的清理范围是否只限目标段,不顺手改其他 decompiled React Compiler 结构? +9. RCS WebSocket 测试应放在现有哪个 `__tests__` 布局下,是否已有 route/mock 基础设施可复用? +10. 发布 gate 是否必须包含 `stopReason` 决策与 `sessionUpdate` 发送顺序不回归? + +--- + +## 3. 给 Claude 或 Reviewer 的复核问题 + +```text +请作为外部审查者,复核 docs/internals/agent-comm-fix-jira-tasks.md。 + +请检查: +1. 是否仍满足“按文件分工的执行清单”和“Jira task 文档”要求。 +2. 是否存在遗漏的文件、验收标准、风险或前置依赖。 +3. 是否有重复、误导执行者、优先级不合理或测试不可落地的问题。 +4. 是否还有必须阻断实施的 finding。 + +请用中文输出: +- Verdict +- Blocking Findings +- Non-blocking Findings +- Suggested Edits +- Final Recommendation + +不要修改文件,只输出审查意见。 +``` + +--- + +## 4. 已处理的复核建议 + +- Release Checklist 已补充协议层行为无回归 gate。 +- JIRA-001 与 JIRA-008 已明确同文件前后置关系。 +- JIRA-001 到 JIRA-008 已补充参考代码位置。 +- JIRA-003 已补回 1000 排队场景下的出队耗时验收。 +- JIRA-008 story points 已从 2 调整为 3。 +- JIRA-010 已明确 11MB payload 对齐 10MB 上限并触发 1009 close。 +- 推荐执行顺序已明确 P0 gate:P0 全部改动和冒烟验证完成后,再启动 P1 改造。 + +--- + +## 5. 不在本文档维护的内容 + +- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。 +- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。 +- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。 diff --git a/packages/acp-link/src/__tests__/server.test.ts b/packages/acp-link/src/__tests__/server.test.ts index 98089ac10..a1e0de384 100644 --- a/packages/acp-link/src/__tests__/server.test.ts +++ b/packages/acp-link/src/__tests__/server.test.ts @@ -1,5 +1,8 @@ import { describe, test, expect } from "bun:test"; -import type { ServerConfig } from "../server.js"; +import { + resolveNewSessionPermissionMode, + type ServerConfig, +} from "../server.js"; describe("Server HTTP endpoints", () => { test("package.json has correct bin and main entries", async () => { @@ -62,6 +65,25 @@ describe("WebSocket message types", () => { }); }); +describe("permission mode resolution", () => { + test("uses client requested non-bypass modes", () => { + expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan"); + }); + + test("uses local default when client does not request a mode", () => { + expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits"); + }); + + test("ignores client requested bypassPermissions without local default", () => { + expect(resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits")).toBe("acceptEdits"); + expect(resolveNewSessionPermissionMode("bypassPermissions", undefined)).toBeUndefined(); + }); + + test("allows bypassPermissions when local default already enables it", () => { + expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions"); + }); +}); + describe("Heartbeat constants", () => { test("PERMISSION_TIMEOUT_MS is 5 minutes", () => { const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; diff --git a/packages/acp-link/src/server.ts b/packages/acp-link/src/server.ts index 22061915b..cd8e94419 100644 --- a/packages/acp-link/src/server.ts +++ b/packages/acp-link/src/server.ts @@ -251,6 +251,7 @@ async function handleConnect(ws: WSContext): Promise { const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, { cwd: AGENT_CWD, stdio: ["pipe", "pipe", "inherit"], + env: buildAgentEnv(), }); state.process = agentProcess; @@ -334,7 +335,10 @@ async function handleNewSession( try { const sessionCwd = params.cwd || AGENT_CWD; - const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE; + const permissionMode = resolveNewSessionPermissionMode( + params.permissionMode, + DEFAULT_PERMISSION_MODE, + ); const result = await state.connection.newSession({ cwd: sessionCwd, mcpServers: [], @@ -592,7 +596,37 @@ interface ContentBlock { interface ProxyMessage { type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model"; - payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string }; + payload?: { cwd?: string; permissionMode?: string } | { content: ContentBlock[] } | { modelId: string }; +} + +export function resolveNewSessionPermissionMode( + requestedMode: string | undefined, + defaultMode: string | undefined, +): string | undefined { + if (requestedMode !== "bypassPermissions") { + return requestedMode || defaultMode; + } + + if (defaultMode === "bypassPermissions") { + return requestedMode; + } + + logSession.warn( + { requestedMode, defaultMode }, + "ignoring client-requested bypassPermissions without local default", + ); + return defaultMode; +} + +function buildAgentEnv(): NodeJS.ProcessEnv { + if (!DEFAULT_PERMISSION_MODE) { + return process.env; + } + + return { + ...process.env, + ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE, + }; } export async function startServer(config: ServerConfig): Promise { diff --git a/packages/remote-control-server/src/__tests__/routes.test.ts b/packages/remote-control-server/src/__tests__/routes.test.ts index c061bbf25..f621bc3d6 100644 --- a/packages/remote-control-server/src/__tests__/routes.test.ts +++ b/packages/remote-control-server/src/__tests__/routes.test.ts @@ -27,7 +27,15 @@ import { publishSessionEvent } from "../services/transport"; import v1Sessions from "../routes/v1/sessions"; import v1Environments from "../routes/v1/environments"; import v1EnvironmentsWork from "../routes/v1/environments.work"; -import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress"; +import v1SessionIngress, { + decodeSessionIngressWsMessage, + handleSessionIngressWsPayload, + websocket as sessionIngressWebsocket, +} from "../routes/v1/session-ingress"; +import { + decodeAcpWsMessageData, + handleAcpWsPayload, +} from "../routes/acp"; import v2CodeSessions from "../routes/v2/code-sessions"; import v2Worker from "../routes/v2/worker"; import v2WorkerEventsStream from "../routes/v2/worker-events-stream"; @@ -1160,6 +1168,81 @@ describe("V1 Session Ingress Routes (HTTP)", () => { expect(events[0]?.type).toBe("assistant"); }); + test("GET /v2/session_ingress/ws/:sessionId — accepts small payload into handler", async () => { + const sessRes = await app.request("/v1/sessions", { + method: "POST", + headers: { ...AUTH_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const { id } = await sessRes.json(); + + const server = Bun.serve({ + port: 0, + fetch: app.fetch, + websocket: { + ...sessionIngressWebsocket, + idleTimeout: 30, + }, + }); + + try { + const event = await new Promise((resolve, reject) => { + let ws: WebSocket | undefined; + const timeout = setTimeout(() => { + ws?.close(); + reject(new Error("Timed out waiting for inbound WebSocket payload")); + }, 2000); + const bus = getEventBus(id); + const unsub = bus.subscribe((sessionEvent) => { + if (sessionEvent.direction === "inbound" && sessionEvent.type === "user") { + clearTimeout(timeout); + unsub(); + ws?.close(); + resolve(sessionEvent); + } + }); + ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${id}?token=test-api-key`); + ws.onopen = () => { + ws.send(JSON.stringify({ type: "user", message: { role: "user", content: "hello" } }) + "\n"); + }; + ws.onerror = () => { + clearTimeout(timeout); + unsub(); + reject(new Error("Session ingress WebSocket connection failed")); + }; + }); + + expect((event as { type?: string }).type).toBe("user"); + } finally { + await server.stop(true); + } + }); + + test("GET /v2/session_ingress/ws/:sessionId — closes 11MB payload with 1009", () => { + const close = mock(() => {}); + const handled = handleSessionIngressWsPayload( + { close } as any, + "session_large", + "x".repeat(11 * 1024 * 1024), + ); + + expect(handled).toBe(false); + expect(close).toHaveBeenCalledWith(1009, "message too large"); + }); + + test("session ingress decode rejects unsupported payload types", () => { + const close = mock(() => {}); + const handled = handleSessionIngressWsPayload( + { close } as any, + "session_bad", + { data: "bad" }, + ); + + expect(decodeSessionIngressWsMessage({ data: "bad" }).ok).toBe(false); + expect(handled).toBe(false); + expect(close).toHaveBeenCalledWith(1003, "unsupported message payload"); + }); + test("GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs", async () => { const sessRes = await app.request("/v1/code/sessions", { method: "POST", @@ -1205,7 +1288,7 @@ describe("V1 Session Ingress Routes (HTTP)", () => { }); expect(message).toContain("\"type\":\"user\""); - expect(message).toContain(`\"session_id\":\"${id}\"`); + expect(message).toContain(`"session_id":"${id}"`); expect(message).toContain("compat ws replay"); } finally { await server.stop(true); @@ -1213,6 +1296,66 @@ describe("V1 Session Ingress Routes (HTTP)", () => { }); }); +describe("ACP WebSocket payload guards", () => { + test("rejects oversized multibyte text by byte size", () => { + const close = mock(() => {}); + const handleMessage = mock(() => {}); + const payload = "你".repeat(4 * 1024 * 1024); + const decoded = decodeAcpWsMessageData(payload); + const handled = handleAcpWsPayload( + { close } as any, + "[ACP-WS]", + "wsId=multibyte", + payload, + handleMessage, + ); + + expect(decoded.ok && decoded.size).toBeGreaterThan(10 * 1024 * 1024); + expect(handled).toBe(false); + expect(handleMessage).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledWith(1009, "message too large"); + }); + + test("rejects oversized binary payload by byte size", () => { + const close = mock(() => {}); + const handleMessage = mock(() => {}); + const payload = new Uint8Array(11 * 1024 * 1024); + const decoded = decodeAcpWsMessageData(payload); + const handled = handleAcpWsPayload( + { close } as any, + "[ACP-Relay]", + "relayWsId=binary", + payload, + handleMessage, + ); + + expect(decoded).toEqual({ + ok: false, + reason: "message too large", + size: 11 * 1024 * 1024, + }); + expect(handled).toBe(false); + expect(handleMessage).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledWith(1009, "message too large"); + }); + + test("accepts small payload into ACP handler", () => { + const close = mock(() => {}); + const handleMessage = mock(() => {}); + const handled = handleAcpWsPayload( + { close } as any, + "[ACP-WS]", + "wsId=small", + '{"type":"keep_alive"}', + handleMessage, + ); + + expect(handled).toBe(true); + expect(handleMessage).toHaveBeenCalledWith('{"type":"keep_alive"}'); + expect(close).not.toHaveBeenCalled(); + }); +}); + describe("V2 Worker Events Routes", () => { let app: Hono; diff --git a/packages/remote-control-server/src/routes/acp/index.ts b/packages/remote-control-server/src/routes/acp/index.ts index faef347ea..0c87516fb 100644 --- a/packages/remote-control-server/src/routes/acp/index.ts +++ b/packages/remote-control-server/src/routes/acp/index.ts @@ -1,5 +1,10 @@ import { Hono } from "hono"; +import type { WSContext, WSMessageReceive } from "hono/ws"; import { upgradeWebSocket } from "../../transport/ws-shared"; +import { + decodeWsPayload, + handleSizedWsPayload, +} from "../../transport/ws-payload"; import { apiKeyAuth } from "../../auth/middleware"; import { validateApiKey } from "../../auth/api-key"; import { @@ -22,8 +27,14 @@ import { log, error as logError } from "../../logger"; const app = new Hono(); -/** Maximum WebSocket message size: 10 MB */ -const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024; +type WsMessageEvent = { + data: WSMessageReceive; +}; + +type WsCloseEvent = { + code?: number; + reason?: string; +}; /** Response shape for an ACP agent */ function toAcpAgentResponse(env: ReturnType & {}) { @@ -100,7 +111,7 @@ app.get("/channel-groups/:id/events", async (c) => { // Support Last-Event-ID / from_sequence_num for reconnection const lastEventId = c.req.header("Last-Event-ID"); const fromSeq = c.req.query("from_sequence_num"); - const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0; + const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0; return createAcpSSEStream(c, groupId, fromSeqNum); }); @@ -117,7 +128,7 @@ app.get( if (!token || !validateApiKey(token)) { log("[ACP-WS] Upgrade rejected: unauthorized"); return { - onOpen(_evt: any, ws: any) { + onOpen(_evt: Event, ws: WSContext) { ws.close(4003, "unauthorized"); }, }; @@ -129,26 +140,22 @@ app.get( log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`); return { - onOpen(_evt: any, ws: any) { + onOpen(_evt: Event, ws: WSContext) { handleAcpWsOpen(ws, wsId); }, - onMessage(evt: any, ws: any) { - const data = - typeof evt.data === "string" - ? evt.data - : new TextDecoder().decode(evt.data as ArrayBuffer); - if (data.length > MAX_WS_MESSAGE_SIZE) { - logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`); - ws.close(1009, "message too large"); - return; - } - handleAcpWsMessage(ws, wsId, data); + onMessage(evt: WsMessageEvent, ws: WSContext) { + handleAcpWsPayload( + ws, + "[ACP-WS]", + `wsId=${wsId}`, + evt.data, + data => handleAcpWsMessage(ws, wsId, data), + ); }, - onClose(evt: any, ws: any) { - const closeEvt = evt as unknown as CloseEvent; - handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason); + onClose(evt: WsCloseEvent, ws: WSContext) { + handleAcpWsClose(ws, wsId, evt.code, evt.reason); }, - onError(evt: any, ws: any) { + onError(evt: Event, ws: WSContext) { logError(`[ACP-WS] Error on wsId=${wsId}:`, evt); handleAcpWsClose(ws, wsId, 1006, "websocket error"); }, @@ -172,7 +179,7 @@ app.get( if (!hasUuid && !hasApiKey) { log("[ACP-Relay] Upgrade rejected: unauthorized"); return { - onOpen(_evt: any, ws: any) { + onOpen(_evt: Event, ws: WSContext) { ws.close(4003, "unauthorized"); }, }; @@ -184,26 +191,22 @@ app.get( log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`); return { - onOpen(_evt: any, ws: any) { + onOpen(_evt: Event, ws: WSContext) { handleRelayOpen(ws, relayWsId, agentId); }, - onMessage(evt: any, ws: any) { - const data = - typeof evt.data === "string" - ? evt.data - : new TextDecoder().decode(evt.data as ArrayBuffer); - if (data.length > MAX_WS_MESSAGE_SIZE) { - logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`); - ws.close(1009, "message too large"); - return; - } - handleRelayMessage(ws, relayWsId, data); + onMessage(evt: WsMessageEvent, ws: WSContext) { + handleAcpWsPayload( + ws, + "[ACP-Relay]", + `relayWsId=${relayWsId}`, + evt.data, + data => handleRelayMessage(ws, relayWsId, data), + ); }, - onClose(evt: any, ws: any) { - const closeEvt = evt as unknown as CloseEvent; - handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason); + onClose(evt: WsCloseEvent, ws: WSContext) { + handleRelayClose(ws, relayWsId, evt.code, evt.reason); }, - onError(evt: any, ws: any) { + onError(evt: Event, ws: WSContext) { logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt); handleRelayClose(ws, relayWsId, 1006, "websocket error"); }, @@ -211,4 +214,16 @@ app.get( }), ); +export const decodeAcpWsMessageData = decodeWsPayload; + +export function handleAcpWsPayload( + ws: WSContext, + logPrefix: string, + label: string, + payload: unknown, + handleMessage: (data: string) => void, +): boolean { + return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage); +} + export default app; diff --git a/packages/remote-control-server/src/routes/v1/session-ingress.ts b/packages/remote-control-server/src/routes/v1/session-ingress.ts index eedde7200..6d6fd7981 100644 --- a/packages/remote-control-server/src/routes/v1/session-ingress.ts +++ b/packages/remote-control-server/src/routes/v1/session-ingress.ts @@ -1,6 +1,12 @@ import { log, error as logError } from "../../logger"; import { Hono } from "hono"; +import type { Context } from "hono"; +import type { WSContext, WSMessageReceive } from "hono/ws"; import { upgradeWebSocket, websocket } from "../../transport/ws-shared"; +import { + decodeWsPayload, + handleSizedWsPayload, +} from "../../transport/ws-payload"; import { validateApiKey } from "../../auth/api-key"; import { verifyWorkerJwt } from "../../auth/jwt"; import { @@ -13,8 +19,17 @@ import { getSession, resolveExistingSessionId } from "../../services/session"; const app = new Hono(); +type WsMessageEvent = { + data: WSMessageReceive; +}; + +type WsCloseEvent = { + code?: number; + reason?: string; +}; + /** Authenticate via API key or worker JWT in Authorization header or ?token= query param */ -function authenticateRequest(c: any, label: string, expectedSessionId?: string): boolean { +function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean { const authHeader = c.req.header("Authorization"); const queryToken = c.req.query("token"); const token = authHeader?.replace("Bearer ", "") || queryToken; @@ -76,7 +91,7 @@ app.get( if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) { return { - onOpen(_evt, ws) { + onOpen(_evt: Event, ws: WSContext) { ws.close(4003, "unauthorized"); }, }; @@ -86,7 +101,7 @@ app.get( if (!session) { log(`[WS] Upgrade rejected: session ${sessionId} not found`); return { - onOpen(_evt, ws) { + onOpen(_evt: Event, ws: WSContext) { ws.close(4001, "session not found"); }, }; @@ -94,27 +109,38 @@ app.get( log(`[WS] Upgrade accepted: session=${sessionId}`); return { - onOpen(_evt, ws) { - handleWebSocketOpen(ws as any, sessionId); + onOpen(_evt: Event, ws: WSContext) { + handleWebSocketOpen(ws, sessionId); }, - onMessage(evt, ws) { - const data = - typeof evt.data === "string" - ? evt.data - : new TextDecoder().decode(evt.data as ArrayBuffer); - handleWebSocketMessage(ws as any, sessionId, data); + onMessage(evt: WsMessageEvent, ws: WSContext) { + handleSessionIngressWsPayload(ws, sessionId, evt.data); }, - onClose(evt, ws) { - const closeEvt = evt as unknown as CloseEvent; - handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason); + onClose(evt: WsCloseEvent, ws: WSContext) { + handleWebSocketClose(ws, sessionId, evt.code, evt.reason); }, - onError(evt, ws) { + onError(evt: Event, ws: WSContext) { logError(`[WS] Error on session=${sessionId}:`, evt); - handleWebSocketClose(ws as any, sessionId, 1006, "websocket error"); + handleWebSocketClose(ws, sessionId, 1006, "websocket error"); }, }; }), ); +export const decodeSessionIngressWsMessage = decodeWsPayload; + +export function handleSessionIngressWsPayload( + ws: WSContext, + sessionId: string, + payload: unknown, +): boolean { + return handleSizedWsPayload( + ws, + "[WS]", + `session=${sessionId}`, + payload, + data => handleWebSocketMessage(ws, sessionId, data), + ); +} + export { websocket }; export default app; diff --git a/packages/remote-control-server/src/transport/ws-payload.ts b/packages/remote-control-server/src/transport/ws-payload.ts new file mode 100644 index 000000000..90b0ec88d --- /dev/null +++ b/packages/remote-control-server/src/transport/ws-payload.ts @@ -0,0 +1,64 @@ +import { Buffer } from "node:buffer"; +import type { WSContext } from "hono/ws"; +import { error as logError } from "../logger"; + +const textDecoder = new TextDecoder(); + +export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024; + +export type DecodedWsMessage = + | { ok: true; data: string; size: number } + | { ok: false; reason: string; size?: number }; + +export function decodeWsPayload(data: unknown): DecodedWsMessage { + if (typeof data === "string") { + return { ok: true, data, size: Buffer.byteLength(data, "utf8") }; + } + if (data instanceof ArrayBuffer) { + if (data.byteLength > MAX_WS_MESSAGE_SIZE) { + return { ok: false, reason: "message too large", size: data.byteLength }; + } + return { ok: true, data: textDecoder.decode(data), size: data.byteLength }; + } + if (data instanceof Uint8Array) { + if (data.byteLength > MAX_WS_MESSAGE_SIZE) { + return { ok: false, reason: "message too large", size: data.byteLength }; + } + return { ok: true, data: textDecoder.decode(data), size: data.byteLength }; + } + if (typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer) { + const bytes = new Uint8Array(data); + if (bytes.byteLength > MAX_WS_MESSAGE_SIZE) { + return { ok: false, reason: "message too large", size: bytes.byteLength }; + } + return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength }; + } + return { ok: false, reason: typeof data }; +} + +export function handleSizedWsPayload( + ws: WSContext, + logPrefix: string, + label: string, + payload: unknown, + handleMessage: (data: string) => void, +): boolean { + const decoded = decodeWsPayload(payload); + if (!decoded.ok) { + if (decoded.reason === "message too large" && decoded.size !== undefined) { + logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`); + ws.close(1009, "message too large"); + return false; + } + logError(`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`); + ws.close(1003, "unsupported message payload"); + return false; + } + if (decoded.size > MAX_WS_MESSAGE_SIZE) { + logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`); + ws.close(1009, "message too large"); + return false; + } + handleMessage(decoded.data); + return true; +} diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 2204c97a8..383adc7d4 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -639,6 +639,7 @@ function TranscriptSearchBar({ const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building'); React.useEffect(() => { let alive = true; + let hideTimeout: ReturnType | undefined; const warm = jumpRef.current?.warmSearchIndex; if (!warm) { setIndexStatus(null); // VML not mounted yet — rare, skip indicator @@ -652,14 +653,14 @@ function TranscriptSearchBar({ setIndexStatus(null); } else { setIndexStatus({ ms }); - setTimeout(() => alive && setIndexStatus(null), 2000); + hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000); } }); return () => { alive = false; + if (hideTimeout) clearTimeout(hideTimeout); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // mount-only: bar opens once per / + }, [jumpRef]); // mount-only per stable search bar ref // Gate the query effect on warm completion. setHighlight stays instant // (screen-space overlay, no indexing). setSearchQuery (the scan) waits. const warmDone = indexStatus !== 'building'; @@ -667,8 +668,7 @@ function TranscriptSearchBar({ if (!warmDone) return; jumpRef.current?.setSearchQuery(query); setHighlight(query); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, warmDone]); + }, [jumpRef, query, setHighlight, warmDone]); const off = cursorOffset; const cursorChar = off < query.length ? query[off] : ' '; return ( @@ -4992,16 +4992,19 @@ export function REPL({ } }, [queuedCommands]); + const onInitRef = useRef(onInit); + onInitRef.current = onInit; + const diagnosticTrackerRef = useRef(diagnosticTracker); + diagnosticTrackerRef.current = diagnosticTracker; + // Initial load useEffect(() => { - void onInit(); + void onInitRef.current(); // Cleanup on unmount return () => { - void diagnosticTracker.shutdown(); + void diagnosticTrackerRef.current.shutdown(); }; - // TODO: fix this - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Listen for suspend/resume events diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 194977036..b5f073082 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -14,6 +14,8 @@ import { // so afterAll can restore them, preventing cross-file pollution. const _restores: (() => void)[] = [] +const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE +const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS function safeMockModule(tsPath: string, overrides: Record) { const jsPath = tsPath.replace(/\.ts$/, '.js') @@ -26,11 +28,12 @@ function safeMockModule(tsPath: string, overrides: Record) { // ── Module mocks (must precede any import of the module under test) ── const mockSetModel = mock(() => {}) +const mockSubmitMessage = mock(async function* (_input: string) {}) // Fully synthetic — no real module to snapshot, so plain mock.module suffices. mock.module('../../../QueryEngine.js', () => ({ QueryEngine: class MockQueryEngine { - submitMessage = mock(async function* () {}) + submitMessage = mockSubmitMessage interrupt = mock(() => {}) resetAbortController = mock(() => {}) getAbortSignal = mock(() => new AbortController().signal) @@ -87,7 +90,6 @@ mock.module('../permissions.js', () => ({ })) safeMockModule('../utils.ts', { - resolvePermissionMode: mock(() => 'default'), computeSessionFingerprint: mock(() => '{}'), sanitizeTitle: mock((s: string) => s), }) @@ -124,6 +126,11 @@ safeMockModule('../../../utils/managedEnv.ts', { applySafeConfigEnvironmentVariables: mockApplySafeEnvVars, }) +const mockGetSettings = mock(() => ({})) +mock.module('../../../utils/settings/settings.js', () => ({ + getSettings_DEPRECATED: mockGetSettings, +})) + const mockDeserializeMessages = mock((msgs: unknown[]) => msgs) safeMockModule('../../../utils/conversationRecovery.ts', { deserializeMessages: mockDeserializeMessages, @@ -181,16 +188,52 @@ function makeConn() { } as any } +function removeBypassMode(session: any) { + session.modes = { + ...session.modes, + availableModes: session.modes.availableModes.filter( + (mode: any) => mode.id !== 'bypassPermissions', + ), + } + session.appState.toolPermissionContext = { + ...session.appState.toolPermissionContext, + isBypassPermissionsModeAvailable: false, + } +} + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} + // ── Tests ───────────────────────────────────────────────────────── describe('AcpAgent', () => { afterAll(() => { for (const restore of _restores) restore() + restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode) + restoreEnv( + 'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS', + originalAcpAllowBypass, + ) }) beforeEach(() => { + delete process.env.ACP_PERMISSION_MODE + delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS mockSetModel.mockClear() + mockSubmitMessage.mockReset() + mockSubmitMessage.mockImplementation(async function* (_input: string) {}) mockGetMainLoopModel.mockClear() mockGetDefaultAppState.mockClear() + mockGetSettings.mockReset() + mockGetSettings.mockImplementation(() => ({})) + ;(forwardSessionUpdates as ReturnType).mockReset() + ;(forwardSessionUpdates as ReturnType).mockImplementation( + async () => ({ stopReason: 'end_turn' as const }), + ) }) describe('initialize', () => { @@ -290,6 +333,85 @@ describe('AcpAgent', () => { const res = await agent.newSession({ cwd: '/tmp' } as any) expect(res.sessionId).toBeDefined() }) + + test('uses settings permissions.defaultMode when _meta does not provide a mode', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + + expect(res.modes?.currentModeId).toBe('acceptEdits') + }) + + test('uses _meta.permissionMode before settings permissions.defaultMode', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'plan' }, + } as any) + + expect(res.modes?.currentModeId).toBe('plan') + }) + + test('does not honor _meta.permissionMode bypass without a local ACP bypass gate', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) + const agent = new AcpAgent(makeConn()) + try { + const res = await agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'bypassPermissions' }, + } as any) + + expect(res.modes?.currentModeId).toBe('acceptEdits') + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[ACP] Ignoring _meta.permissionMode bypassPermissions because ACP bypass is not locally enabled', + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + test('falls back to default when settings permissions.defaultMode is invalid', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'invalid-mode' }, + })) + const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) + const agent = new AcpAgent(makeConn()) + try { + const res = await agent.newSession({ cwd: '/tmp' } as any) + + expect(res.modes?.currentModeId).toBe('default') + expect(consoleErrorSpy).toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + + test('falls back to settings when _meta.permissionMode is invalid', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) + const agent = new AcpAgent(makeConn()) + try { + const res = await agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'invalid-mode' }, + } as any) + + expect(res.modes?.currentModeId).toBe('acceptEdits') + expect(consoleErrorSpy).toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe('prompt', () => { @@ -697,6 +819,20 @@ describe('AcpAgent', () => { 'bypassPermissions', ) }) + + test('rejects bypassPermissions when the session does not expose it', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const session = agent.sessions.get(sessionId) + removeBypassMode(session) + + await expect( + agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any), + ).rejects.toThrow('Mode not available') + + expect(session?.modes.currentModeId).toBe('default') + expect(session?.appState.toolPermissionContext.mode).toBe('default') + }) }) describe('setSessionConfigOption', () => { @@ -723,6 +859,24 @@ describe('AcpAgent', () => { } as any), ).rejects.toThrow('Invalid value') }) + + test('rejects unavailable mode config values', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const session = agent.sessions.get(sessionId) + removeBypassMode(session) + + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'mode', + value: 'bypassPermissions', + } as any), + ).rejects.toThrow('Mode not available') + + expect(session?.modes.currentModeId).toBe('default') + expect(session?.appState.toolPermissionContext.mode).toBe('default') + }) }) describe('prompt queueing', () => { @@ -758,6 +912,94 @@ describe('AcpAgent', () => { expect(r2.stopReason).toBe('end_turn') }) + test('drains 1000 queued prompts in FIFO order without sorting the pending map', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + let resolveFirst!: () => void + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + + const first = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'first' }], + } as any) + const queued = Array.from({ length: 1000 }, (_, index) => + agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: `queued-${index}` }], + } as any), + ) + + resolveFirst() + const results = await Promise.all([first, ...queued]) + + 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}`), + ]) + }) + + test('keeps promptRunning true while handing off to the next queued prompt', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + let resolveFirst!: () => void + let resolveSecond!: () => void + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveSecond = () => resolve({ stopReason: 'end_turn' }) + }), + ) + + const p1 = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'first' }], + } as any) + const p2 = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'second' }], + } as any) + + const p3 = p1.then(() => + agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'third' }], + } as any), + ) + + resolveFirst() + await p1 + const session = agent.sessions.get(sessionId) + expect(session?.promptRunning).toBe(true) + expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([ + 'first', + 'second', + ]) + + resolveSecond() + await Promise.all([p2, p3]) + expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([ + 'first', + 'second', + 'third', + ]) + }) + test('queued prompts return cancelled when session is cancelled', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) @@ -828,5 +1070,6 @@ describe('AcpAgent', () => { ) expect(commit.input).toEqual({ hint: '[message]' }) }) + }) }) diff --git a/src/services/acp/__tests__/bridge.test.ts b/src/services/acp/__tests__/bridge.test.ts index c8d3d319b..78c9f0b56 100644 --- a/src/services/acp/__tests__/bridge.test.ts +++ b/src/services/acp/__tests__/bridge.test.ts @@ -5,6 +5,7 @@ import { toolUpdateFromEditToolResponse, forwardSessionUpdates, } from '../bridge.js' +import { promptToQueryInput } from '../promptConversion.js' import { markdownEscape, toDisplayPath } from '../utils.js' import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk' import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js' @@ -336,6 +337,20 @@ describe('toolInfoFromToolUse', () => { }) }) +describe('promptToQueryInput', () => { + test('uses shared prompt conversion for resource links', () => { + expect( + promptToQueryInput([ + { + type: 'resource_link', + name: 'Spec', + uri: 'file:///tmp/spec.md', + } as any, + ]), + ).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md') + }) +}) + // ── toolUpdateFromToolResult ─────────────────────────────────────── describe('toolUpdateFromToolResult', () => { @@ -709,6 +724,87 @@ describe('forwardSessionUpdates', () => { expect(result.stopReason).toBe('cancelled') }) + test('cleans abort listeners when sdkMessages.next wins repeatedly', async () => { + const ac = new AbortController() + let abortListeners = 0 + const add = ac.signal.addEventListener.bind(ac.signal) + const remove = ac.signal.removeEventListener.bind(ac.signal) + const addEventListener: AbortSignal['addEventListener'] = ( + type: keyof AbortSignalEventMap, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => { + if (type === 'abort') abortListeners++ + return add(type, listener, options) + } + const removeEventListener: AbortSignal['removeEventListener'] = ( + type: keyof AbortSignalEventMap, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ) => { + if (type === 'abort') abortListeners-- + return remove(type, listener, options) + } + 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 result = await forwardSessionUpdates( + 's1', + makeStream(msgs), + makeConn(), + ac.signal, + {}, + ) + + expect(result.stopReason).toBe('end_turn') + expect(abortListeners).toBe(0) + }) + + test('cleans abort listeners when abort wins the race', async () => { + const ac = new AbortController() + let abortListeners = 0 + const add = ac.signal.addEventListener.bind(ac.signal) + const remove = ac.signal.removeEventListener.bind(ac.signal) + ac.signal.addEventListener = ( + type: keyof AbortSignalEventMap, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => { + if (type === 'abort') abortListeners++ + return add(type, listener, options) + } + ac.signal.removeEventListener = ( + type: keyof AbortSignalEventMap, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ) => { + if (type === 'abort') abortListeners-- + return remove(type, listener, options) + } + + async function* never(): AsyncGenerator { + await new Promise(() => {}) + } + + const resultPromise = forwardSessionUpdates( + 's1', + never(), + makeConn(), + ac.signal, + {}, + ) + ac.abort() + const result = await resultPromise + + expect(result.stopReason).toBe('cancelled') + expect(abortListeners).toBe(0) + }) + test('forwards assistant text message as agent_message_chunk', async () => { const conn = makeConn() const msgs: SDKMessage[] = [ diff --git a/src/services/acp/__tests__/promptConversion.test.ts b/src/services/acp/__tests__/promptConversion.test.ts new file mode 100644 index 000000000..9f92445e9 --- /dev/null +++ b/src/services/acp/__tests__/promptConversion.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { promptToQueryInput } from '../promptConversion.js' + +describe('promptToQueryInput', () => { + test('converts text and embedded text resources', () => { + expect( + promptToQueryInput([ + { type: 'text', text: 'hello' }, + { + type: 'resource', + resource: { text: 'resource body' }, + } as any, + ]), + ).toBe('hello\nresource body') + }) + + test('renders resource_link as plain metadata instead of markdown link', () => { + expect( + promptToQueryInput([ + { + type: 'resource_link', + name: 'Spec', + uri: 'file:///tmp/spec.md', + } as any, + ]), + ).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md') + }) +}) diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index d96960bd4..2776c962b 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -33,7 +33,6 @@ import type { SetSessionModelResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, - ContentBlock, ClientCapabilities, SessionModeState, SessionModelState, @@ -63,11 +62,13 @@ import { computeSessionFingerprint, sanitizeTitle, } from './utils.js' +import { promptToQueryInput } from './promptConversion.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' // ── Session state ───────────────────────────────────────────────── @@ -80,14 +81,19 @@ type AcpSession = { models: SessionModelState configOptions: SessionConfigOption[] promptRunning: boolean - pendingMessages: Map void; order: number }> - nextPendingOrder: number + pendingMessages: Map + pendingQueue: string[] + pendingQueueHead: number toolUseCache: ToolUseCache clientCapabilities?: ClientCapabilities appState: AppState commands: Command[] } +type PendingPrompt = { + resolve: (cancelled: boolean) => void +} + // ── Agent class ─────────────────────────────────────────────────── export class AcpAgent implements Agent { @@ -157,7 +163,9 @@ export class AcpAgent implements Agent { // ── newSession ──────────────────────────────────────────────── async newSession(params: NewSessionRequest): Promise { - return this.createSession(params) + const result = await this.createSession(params) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result } // ── resumeSession ────────────────────────────────────────────── @@ -166,9 +174,7 @@ export class AcpAgent implements Agent { params: ResumeSessionRequest, ): Promise { const result = await this.getOrCreateSession(params) - setTimeout(() => { - this.sendAvailableCommandsUpdate(params.sessionId) - }, 0) + this.scheduleAvailableCommandsUpdate(result.sessionId) return result } @@ -176,9 +182,7 @@ export class AcpAgent implements Agent { async loadSession(params: LoadSessionRequest): Promise { const result = await this.getOrCreateSession(params) - setTimeout(() => { - this.sendAvailableCommandsUpdate(params.sessionId) - }, 0) + this.scheduleAvailableCommandsUpdate(result.sessionId) return result } @@ -216,9 +220,7 @@ export class AcpAgent implements Agent { _meta: params._meta, }, ) - setTimeout(() => { - this.sendAvailableCommandsUpdate(response.sessionId) - }, 0) + this.scheduleAvailableCommandsUpdate(response.sessionId) return response } @@ -255,10 +257,10 @@ export class AcpAgent implements Agent { // 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((resolve) => { - session.pendingMessages.set(promptUuid, { resolve, order }) + session.pendingQueue.push(promptUuid) + session.pendingMessages.set(promptUuid, { resolve }) }) if (cancelled) { return { stopReason: 'cancelled' } @@ -327,16 +329,13 @@ export class AcpAgent implements Agent { 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]) - } + const nextPrompt = popNextPendingPrompt(session) + if (nextPrompt) { + session.promptRunning = true + nextPrompt.resolve(false) + } else { + session.promptRunning = false } } } @@ -355,6 +354,8 @@ export class AcpAgent implements Agent { pending.resolve(true) } session.pendingMessages.clear() + session.pendingQueue = [] + session.pendingQueueHead = 0 // Interrupt the query engine to abort the current API call session.queryEngine.interrupt() @@ -379,7 +380,7 @@ export class AcpAgent implements Agent { async unstable_setSessionModel( params: SetSessionModelRequest, - ): Promise { + ): Promise { const session = this.sessions.get(params.sessionId) if (!session) { throw new Error('Session not found') @@ -388,6 +389,7 @@ export class AcpAgent implements Agent { // parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo") session.queryEngine.setModel(params.modelId) await this.updateConfigOption(params.sessionId, 'model', params.modelId) + return {} } // ── setSessionConfigOption ────────────────────────────────────── @@ -460,12 +462,11 @@ export class AcpAgent implements Agent { const tools: Tools = getTools(permissionContext) // Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings - const metaPermissionMode = (params._meta as Record | null | undefined)?.permissionMode as string | undefined - console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode) - const permissionMode = resolvePermissionMode( - metaPermissionMode ?? this.getSetting('permissions.defaultMode'), + const metaPermissionMode = (params._meta as Record | null | undefined)?.permissionMode + const permissionMode = resolvePermissionModeWithFallback( + metaPermissionMode, + this.getSetting('permissions.defaultMode'), ) - console.log('[ACP Agent] Resolved permissionMode:', permissionMode) // Create the permission bridge canUseTool function const canUseTool = createAcpCanUseTool( @@ -563,7 +564,8 @@ export class AcpAgent implements Agent { configOptions, promptRunning: false, pendingMessages: new Map(), - nextPendingOrder: 0, + pendingQueue: [], + pendingQueueHead: 0, toolUseCache: {}, clientCapabilities: this.clientCapabilities, appState, @@ -576,11 +578,6 @@ export class AcpAgent implements Agent { this.sessions.set(sessionId, session) - // Send available commands after session creation - setTimeout(() => { - this.sendAvailableCommandsUpdate(sessionId) - }, 0) - return { sessionId, models, @@ -672,12 +669,16 @@ export class AcpAgent implements Agent { } private applySessionMode(sessionId: string, modeId: string): void { - const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan'] - if (!validModes.includes(modeId)) { + if (!isPermissionMode(modeId)) { throw new Error(`Invalid mode: ${modeId}`) } const session = this.sessions.get(sessionId) if (session) { + const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId) + if (!isAvailable) { + throw new Error(`Mode not available: ${modeId}`) + } + session.modes = { ...session.modes, currentModeId: modeId } // Sync mode to appState so the permission pipeline sees the correct mode session.appState.toolPermissionContext = { @@ -750,38 +751,118 @@ export class AcpAgent implements Agent { }) } + private scheduleAvailableCommandsUpdate(sessionId: string): void { + setTimeout(() => { + void this.sendAvailableCommandsUpdate(sessionId).catch(err => { + console.error('[ACP] Failed to send available commands update:', err) + }) + }, 0) + } + /** Read a setting from Claude config (simplified — no file watching) */ private getSetting(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 + const settings = getSettings_DEPRECATED() as Record + const value = key.split('.').reduce((current, segment) => { + if (!current || typeof current !== 'object') return undefined + return (current as Record)[segment] + }, settings) + return value as T | undefined } } // ── Helpers ──────────────────────────────────────────────────────── -/** Extract prompt text from ACP ContentBlock array for QueryEngine input */ -function promptToQueryInput( - prompt: Array | undefined, -): string { - if (!prompt || prompt.length === 0) return '' - - const parts: string[] = [] - for (const block of prompt) { - const b = block as Record - 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 | undefined - if (resource && 'text' in resource) { - parts.push(resource.text as string) - } +const permissionModeIds: readonly PermissionMode[] = [ + 'auto', + 'default', + 'acceptEdits', + 'bypassPermissions', + 'dontAsk', + 'plan', +] + +function isPermissionMode(modeId: string): modeId is PermissionMode { + return (permissionModeIds as readonly string[]).includes(modeId) +} + +function resolvePermissionModeWithFallback( + metaMode: unknown, + settingsMode: unknown, +): PermissionMode { + const metaResolved = tryResolvePermissionMode(metaMode, '_meta.permissionMode') + if (metaResolved) { + if (metaResolved !== 'bypassPermissions' || isAcpBypassLocallyEnabled()) { + return metaResolved } - // Ignore image and other types for text-based prompt + console.error( + '[ACP] Ignoring _meta.permissionMode bypassPermissions because ACP bypass is not locally enabled', + ) + } + + const settingsResolved = tryResolvePermissionMode( + settingsMode, + 'permissions.defaultMode', + ) + return settingsResolved ?? 'default' +} + +function tryResolvePermissionMode( + mode: unknown, + source: string, +): PermissionMode | undefined { + if (mode === undefined || mode === null) return undefined + + try { + return resolvePermissionMode(mode) as PermissionMode + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err) + console.error(`[ACP] Invalid ${source}, ignoring:`, reason) + return undefined + } +} + +function isAcpBypassLocallyEnabled(): boolean { + return ( + process.env.ACP_PERMISSION_MODE === 'bypassPermissions' || + isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS) + ) +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true' +} + +function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined { + while (session.pendingQueueHead < session.pendingQueue.length) { + const nextId = session.pendingQueue[session.pendingQueueHead++] + if (!nextId) continue + const next = session.pendingMessages.get(nextId) + if (!next) continue + session.pendingMessages.delete(nextId) + compactPendingQueue(session) + return next + } + + compactPendingQueue(session) + return undefined +} + +function compactPendingQueue(session: AcpSession): void { + if (session.pendingQueueHead === 0) return + + if (session.pendingQueueHead >= session.pendingQueue.length) { + session.pendingQueue = [] + session.pendingQueueHead = 0 + return + } + + if ( + session.pendingQueueHead > 1024 && + session.pendingQueueHead * 2 > session.pendingQueue.length + ) { + session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead) + session.pendingQueueHead = 0 } - return parts.join('\n') } function buildConfigOptions( diff --git a/src/services/acp/bridge.ts b/src/services/acp/bridge.ts index c6cdd612e..c9b003388 100644 --- a/src/services/acp/bridge.ts +++ b/src/services/acp/bridge.ts @@ -514,28 +514,25 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): { return result } -// ── Prompt conversion ───────────────────────────────────────────── +function nextSdkMessageOrAbort( + sdkMessages: AsyncGenerator, + abortSignal: AbortSignal, +): Promise> { + if (abortSignal.aborted) { + return Promise.resolve({ done: true, value: undefined }) + } -/** - * Convert ACP PromptRequest content blocks into content for QueryEngine. - */ -export function promptToQueryContent( - prompt: Array | undefined, -): string { - if (!prompt) return '' - return prompt - .map((block) => { - const b = block as Record - if (b.type === 'text') return b.text as string - if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})` - if (b.type === 'resource') { - const resource = b.resource as Record | undefined - if (resource && 'text' in resource) return resource.text as string - } - return '' - }) - .filter(Boolean) - .join('\n') + let abortHandler: (() => void) | undefined + const abortPromise = new Promise>((resolve) => { + abortHandler = () => resolve({ done: true, value: undefined }) + abortSignal.addEventListener('abort', abortHandler, { once: true }) + }) + + return Promise.race([sdkMessages.next(), abortPromise]).finally(() => { + if (abortHandler) { + abortSignal.removeEventListener('abort', abortHandler) + } + }) } // ── Main forwarding function ────────────────────────────────────── @@ -573,17 +570,7 @@ export async function forwardSessionUpdates( // Race the next message against the abort signal so we unblock // immediately when cancelled, even if the generator is waiting for // a slow API response. - const nextResult = await Promise.race([ - sdkMessages.next(), - new Promise>((resolve) => { - if (abortSignal.aborted) { - resolve({ done: true, value: undefined }) - return - } - const handler = () => resolve({ done: true, value: undefined }) - abortSignal.addEventListener('abort', handler, { once: true }) - }), - ]) + const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal) if (nextResult.done || abortSignal.aborted) break const msg = nextResult.value diff --git a/src/services/acp/promptConversion.ts b/src/services/acp/promptConversion.ts new file mode 100644 index 000000000..7388d8f67 --- /dev/null +++ b/src/services/acp/promptConversion.ts @@ -0,0 +1,40 @@ +import type { ContentBlock } from '@agentclientprotocol/sdk' + +export function promptToQueryInput( + prompt: Array | undefined, +): string { + if (!prompt || prompt.length === 0) return '' + + const parts: string[] = [] + for (const block of prompt) { + const b = block as Record + if (b.type === 'text') { + parts.push(String(b.text ?? '')) + } else if (b.type === 'resource_link') { + const name = typeof b.name === 'string' ? b.name : undefined + const uri = typeof b.uri === 'string' ? b.uri : undefined + // Keep resource links as metadata, not markdown links, so models do not + // infer user-visible click targets or silently rewrite URI semantics. + parts.push(formatResourceLink(name, uri)) + } else if (b.type === 'resource') { + const resource = b.resource as Record | undefined + if (resource && typeof resource.text === 'string') { + parts.push(resource.text) + } + } + } + + return parts.filter(part => part.length > 0).join('\n') +} + +function formatResourceLink( + name: string | undefined, + uri: string | undefined, +): string { + const details: string[] = [] + if (name && name.length > 0) details.push(`name=${name}`) + if (uri && uri.length > 0) details.push(`uri=${uri}`) + return details.length > 0 + ? `Resource link: ${details.join(', ')}` + : 'Resource link' +} From eb4d44156f854419d7d36f1fa0913465f83a08f4 Mon Sep 17 00:00:00 2001 From: unraid Date: Sat, 25 Apr 2026 16:53:48 +0800 Subject: [PATCH 02/13] fix: restore repository verification gates Keep the full repository test, typecheck, build, and Biome lint gates usable after the ACP fix pass. This commit is intentionally separate from the ACP behavior change: it fixes Windows-safe Langfuse home redaction, removes stale lint suppressions, resolves Biome warning/info diagnostics, and keeps env expansion tests explicit without template-placeholder lint noise. Constraint: The project completion contract requires full typecheck, lint, test, and build evidence Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims Confidence: high Scope-risk: narrow Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build Not-tested: Manual Langfuse export against a real external Langfuse service --- src/bridge/bridgeMain.ts | 2 +- src/cli/handlers/mcp.tsx | 25 --------- src/cli/transports/SSETransport.ts | 4 +- src/cli/transports/WebSocketTransport.ts | 2 +- src/commands/ide/ide.tsx | 4 +- src/commands/model/model.tsx | 4 +- src/commands/plugin/AddMarketplace.tsx | 1 - src/commands/plugin/ManageMarketplaces.tsx | 1 - src/components/AutoUpdater.tsx | 1 - src/components/MemoryUsageIndicator.tsx | 1 - src/components/MessageSelector.tsx | 1 - src/components/NativeAutoUpdater.tsx | 1 - src/components/PromptInput/Notifications.tsx | 7 ++- src/components/PromptInput/PromptInput.tsx | 4 +- .../PromptInput/PromptInputFooterLeftSide.tsx | 10 ++-- .../PromptInput/PromptInputQueuedCommands.tsx | 2 +- src/components/ScrollKeybindingHandler.tsx | 4 +- src/components/Spinner.tsx | 2 +- src/components/StatusLine.tsx | 2 - src/components/TextInput.tsx | 6 +-- src/components/WorktreeExitDialog.tsx | 3 +- src/components/agents/ColorPicker.tsx | 2 +- src/components/mcp/MCPSettings.tsx | 2 +- src/components/mcp/MCPToolListView.tsx | 2 +- src/components/messages/AttachmentMessage.tsx | 2 +- src/components/messages/UserPromptMessage.tsx | 6 +-- .../UserToolSuccessMessage.tsx | 2 +- src/components/teams/TeamsDialog.tsx | 1 - src/entrypoints/cli.tsx | 2 - src/hooks/renderPlaceholder.ts | 2 +- src/hooks/useArrowKeyHistory.tsx | 2 +- src/hooks/useGlobalKeybindings.tsx | 3 +- src/hooks/useReplBridge.tsx | 10 ++-- src/hooks/useVoiceIntegration.tsx | 8 ++- src/main.tsx | 9 ---- src/native-ts/file-index/index.ts | 2 +- src/query.ts | 3 +- src/services/MagicDocs/prompts.ts | 2 +- src/services/SessionMemory/prompts.ts | 2 +- src/services/analytics/sink.ts | 2 +- src/services/api/claude.ts | 16 +++--- src/services/api/filesApi.ts | 2 +- src/services/api/gemini/index.ts | 2 +- src/services/api/grok/index.ts | 2 +- src/services/api/sessionIngress.ts | 2 +- src/services/api/withRetry.ts | 2 +- src/services/langfuse/sanitize.ts | 27 ++++++++-- src/services/lsp/LSPServerInstance.ts | 2 +- .../mcp/__tests__/envExpansion.test.ts | 39 ++++++++------ src/services/mcp/auth.ts | 2 +- src/services/mcp/oauthPort.ts | 2 - src/services/mcp/officialRegistry.ts | 2 +- src/services/mcp/useManageMCPConnections.ts | 2 +- src/services/plugins/pluginOperations.ts | 1 - src/services/tokenEstimation.ts | 1 - src/state/AppState.tsx | 1 - src/utils/__tests__/sliceAnsi.test.ts | 5 +- src/utils/__tests__/stringUtils.test.ts | 3 +- src/utils/ansiToPng.ts | 8 +-- src/utils/bash/ast.ts | 2 - src/utils/bash/bashParser.ts | 4 -- src/utils/bash/shellQuote.ts | 1 - src/utils/claudeInChrome/setup.ts | 2 +- src/utils/computerUse/executor.ts | 2 +- src/utils/deepLink/protocolHandler.ts | 2 - src/utils/fsOperations.ts | 2 +- src/utils/hooks.ts | 3 +- src/utils/jetbrains.ts | 2 - src/utils/messages.ts | 2 - src/utils/nativeInstaller/installer.ts | 2 +- src/utils/plugins/installedPluginsManager.ts | 2 +- .../officialMarketplaceStartupCheck.ts | 2 +- src/utils/ripgrep.ts | 2 +- src/utils/sessionEnvironment.ts | 2 +- src/utils/sessionStorage.ts | 1 - src/utils/sessionStoragePortable.ts | 1 - src/utils/slowOperations.ts | 4 +- src/utils/staticRender.tsx | 53 +++++++++---------- src/utils/suggestions/skillUsageTracking.ts | 2 +- 79 files changed, 157 insertions(+), 208 deletions(-) diff --git a/src/bridge/bridgeMain.ts b/src/bridge/bridgeMain.ts index 0819eab6e..1873657c7 100644 --- a/src/bridge/bridgeMain.ts +++ b/src/bridge/bridgeMain.ts @@ -1675,7 +1675,7 @@ async function stopWorkWithRetry( } const errMsg = errorMessage(err) if (attempt < MAX_ATTEMPTS) { - const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1)) + const delay = addJitter(baseDelayMs * 2 ** (attempt - 1)) logger.logVerbose( `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`, ) diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index ab543af11..f9d8b3ba1 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -190,12 +190,10 @@ export async function mcpListHandler(): Promise { logEvent('tengu_mcp_list', {}) const { servers: configs } = await getAllMcpConfigs() if (Object.keys(configs).length === 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( 'No MCP servers configured. Use `claude mcp add` to add a server.', ) } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Checking MCP server health...\n') // Check servers concurrently @@ -213,18 +211,14 @@ export async function mcpListHandler(): Promise { for (const { name, server, status } of results) { // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${name}: ${server.url} (SSE) - ${status}`) } else if (server.type === 'http') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${name}: ${server.url} (HTTP) - ${status}`) } else if (server.type === 'claudeai-proxy') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${name}: ${server.url} - ${status}`) } else if (!server.type || server.type === 'stdio') { const stdioServer = server as { command: string; args: string[]; type?: string } const args = Array.isArray(stdioServer.args) ? stdioServer.args : [] - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`) } } @@ -244,27 +238,20 @@ export async function mcpGetHandler(name: string): Promise { cliError(`No MCP server found with name: ${name}`) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${name}:`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Scope: ${getScopeLabel(server.scope)}`) // Check server health const status = await checkMcpServerHealth(name, server) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Status: ${status}`) // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Type: sse`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` URL: ${server.url}`) if (server.headers) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(' Headers:') for (const [key, value] of Object.entries(server.headers)) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${key}: ${value}`) } } @@ -277,19 +264,14 @@ export async function mcpGetHandler(name: string): Promise { } if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` OAuth: ${parts.join(', ')}`) } } else if (server.type === 'http') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Type: http`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` URL: ${server.url}`) if (server.headers) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(' Headers:') for (const [key, value] of Object.entries(server.headers)) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${key}: ${value}`) } } @@ -302,27 +284,20 @@ export async function mcpGetHandler(name: string): Promise { } if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` OAuth: ${parts.join(', ')}`) } } else if (server.type === 'stdio') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Type: stdio`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Command: ${server.command}`) const args = Array.isArray(server.args) ? server.args : [] - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Args: ${args.join(' ')}`) if (server.env) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(' Environment:') for (const [key, value] of Object.entries(server.env)) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${key}=${value}`) } } } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( `\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`, ) diff --git a/src/cli/transports/SSETransport.ts b/src/cli/transports/SSETransport.ts index ca9c396da..21725fdc9 100644 --- a/src/cli/transports/SSETransport.ts +++ b/src/cli/transports/SSETransport.ts @@ -518,7 +518,7 @@ export class SSETransport implements Transport { this.reconnectAttempts++ const baseDelay = Math.min( - RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1), + RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1), RECONNECT_MAX_DELAY_MS, ) // Add ±25% jitter @@ -668,7 +668,7 @@ export class SSETransport implements Transport { } const delayMs = Math.min( - POST_BASE_DELAY_MS * Math.pow(2, attempt - 1), + POST_BASE_DELAY_MS * 2 ** (attempt - 1), POST_MAX_DELAY_MS, ) await sleep(delayMs) diff --git a/src/cli/transports/WebSocketTransport.ts b/src/cli/transports/WebSocketTransport.ts index 5d5d8fd75..74985ac75 100644 --- a/src/cli/transports/WebSocketTransport.ts +++ b/src/cli/transports/WebSocketTransport.ts @@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport { this.reconnectAttempts++ const baseDelay = Math.min( - DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1), + DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1), DEFAULT_MAX_RECONNECT_DELAY, ) // Add ±25% jitter to avoid thundering herd diff --git a/src/commands/ide/ide.tsx b/src/commands/ide/ide.tsx index d5944636d..a8267d352 100644 --- a/src/commands/ide/ide.tsx +++ b/src/commands/ide/ide.tsx @@ -61,7 +61,7 @@ function IDEScreen({ } else if (value === 'None' && shouldShowDisableAutoConnectDialog()) { setShowDisableAutoConnectDialog(true) } else { - onSelect(availableIDEs.find(ide => ide.port === parseInt(value))) + onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10))) } }, [availableIDEs, onSelect], @@ -216,7 +216,7 @@ function IDEOpenSelection({ const handleSelectIDE = useCallback( (value: string) => { const selectedIDE = availableIDEs.find( - ide => ide.port === parseInt(value), + ide => ide.port === parseInt(value, 10), ) onSelectIDE(selectedIDE) }, diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 8311fada1..bc49e77ce 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -78,7 +78,7 @@ function ModelPickerWrapper({ } // Turn off fast mode if switching to unsupported model - let wasFastModeToggledOn = undefined + let wasFastModeToggledOn if (isFastModeEnabled()) { clearFastModeCooldown() if (!isFastModeSupportedByModel(model) && isFastMode) { @@ -214,7 +214,7 @@ function SetModelAndClose({ })) let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}` - let wasFastModeToggledOn = undefined + let wasFastModeToggledOn if (isFastModeEnabled()) { clearFastModeCooldown() if (!isFastModeSupportedByModel(modelValue) && isFastMode) { diff --git a/src/commands/plugin/AddMarketplace.tsx b/src/commands/plugin/AddMarketplace.tsx index 7a9138333..a7807bf83 100644 --- a/src/commands/plugin/AddMarketplace.tsx +++ b/src/commands/plugin/AddMarketplace.tsx @@ -133,7 +133,6 @@ export function AddMarketplace({ void handleAdd() } // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, []) // Only run once on mount return ( diff --git a/src/commands/plugin/ManageMarketplaces.tsx b/src/commands/plugin/ManageMarketplaces.tsx index 5ec3dbe80..b36b55896 100644 --- a/src/commands/plugin/ManageMarketplaces.tsx +++ b/src/commands/plugin/ManageMarketplaces.tsx @@ -190,7 +190,6 @@ export function ManageMarketplaces({ } void loadMarketplaces() // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, [targetMarketplace, action, error]) // Check if there are any pending changes diff --git a/src/components/AutoUpdater.tsx b/src/components/AutoUpdater.tsx index 6e9898ada..f6c1863be 100644 --- a/src/components/AutoUpdater.tsx +++ b/src/components/AutoUpdater.tsx @@ -204,7 +204,6 @@ export function AutoUpdater({ // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref }, [onAutoUpdaterResult]) // Initial check diff --git a/src/components/MemoryUsageIndicator.tsx b/src/components/MemoryUsageIndicator.tsx index 92bc9e419..11efa4a22 100644 --- a/src/components/MemoryUsageIndicator.tsx +++ b/src/components/MemoryUsageIndicator.tsx @@ -13,7 +13,6 @@ export function MemoryUsageIndicator(): React.ReactNode { } // eslint-disable-next-line react-hooks/rules-of-hooks - // biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant const memoryUsage = useMemoryUsage() if (!memoryUsage) { diff --git a/src/components/MessageSelector.tsx b/src/components/MessageSelector.tsx index caca52aa7..014d90275 100644 --- a/src/components/MessageSelector.tsx +++ b/src/components/MessageSelector.tsx @@ -879,7 +879,6 @@ function computeDiffStatsBetweenMessages( } } } catch { - continue } } diff --git a/src/components/NativeAutoUpdater.tsx b/src/components/NativeAutoUpdater.tsx index 77d84e968..5aaafda9f 100644 --- a/src/components/NativeAutoUpdater.tsx +++ b/src/components/NativeAutoUpdater.tsx @@ -169,7 +169,6 @@ export function NativeAutoUpdater({ // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref }, [onAutoUpdaterResult, channel]) // Initial check diff --git a/src/components/PromptInput/Notifications.tsx b/src/components/PromptInput/Notifications.tsx index 6ddccb3cc..9c1509df4 100644 --- a/src/components/PromptInput/Notifications.tsx +++ b/src/components/PromptInput/Notifications.tsx @@ -254,18 +254,17 @@ function NotificationContent({ // Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook) const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useVoiceState(s => s.voiceState) : ('idle' as const) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false const voiceError = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useVoiceState(s => s.voiceError) : null const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useAppState(s => s.isBriefOnly) : false diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 03b627602..c3455c8bd 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -449,7 +449,7 @@ function PromptInput({ // its own marginTop, so the gap stays even without ours. const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false const mainLoopModel_ = useAppState(s => s.mainLoopModel) @@ -2384,7 +2384,7 @@ function PromptInput({ useBuddyNotification() const companionSpeaking = feature('BUDDY') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useAppState(s => s.companionReaction !== undefined) : false const { columns, rows } = useTerminalSize() diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 8130c8ef1..bdae47d7d 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -238,14 +238,13 @@ function ModeIndicator({ proactiveModule?.getNextTickAt ?? NULL, NULL, ) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useVoiceState(s => s.voiceState) : ('idle' as const) const voiceWarmingUp = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useVoiceState(s => s.voiceWarmingUp) : false const hasSelection = useHasSelection() @@ -282,7 +281,7 @@ function ModeIndicator({ 'ctrl+x ctrl+k', ) const voiceKeyShortcut = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '' // Captured at mount so the hint doesn't flicker mid-session if another @@ -291,14 +290,13 @@ function ModeIndicator({ // shown" without tracking the exact render-time condition (which depends // on parts/hintParts computed after the early-return hooks boundary). const [voiceHintUnderCap] = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useState( () => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS, ) : [false] - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null useEffect(() => { if (feature('VOICE_MODE')) { diff --git a/src/components/PromptInput/PromptInputQueuedCommands.tsx b/src/components/PromptInput/PromptInputQueuedCommands.tsx index e96ba3532..a351f87ce 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.tsx @@ -100,7 +100,7 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode { // component early-returns when viewing a teammate. const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useAppState(s => s.isBriefOnly) : false diff --git a/src/components/ScrollKeybindingHandler.tsx b/src/components/ScrollKeybindingHandler.tsx index cdaac3256..527e21e18 100644 --- a/src/components/ScrollKeybindingHandler.tsx +++ b/src/components/ScrollKeybindingHandler.tsx @@ -258,7 +258,7 @@ export function computeWheelStep( // the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac — // rounding loss is minor at high mult, and frac persisting across idle // was causing off-by-one on the first click back. - const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) + const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS) const cap = Math.max(WHEEL_MODE_CAP, state.base * 2) const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP) @@ -299,7 +299,7 @@ export function computeWheelStep( state.mult = 2 state.frac = 0 } else { - const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) + const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS) const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m) diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index c2ebc9b67..96e446672 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -95,7 +95,7 @@ export function SpinnerWithVerb(props: Props): React.ReactNode { // Hoisted to mount-time — this component re-renders at animation framerate. const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) : false diff --git a/src/components/StatusLine.tsx b/src/components/StatusLine.tsx index b2f2c7c91..2effdb89c 100644 --- a/src/components/StatusLine.tsx +++ b/src/components/StatusLine.tsx @@ -370,7 +370,6 @@ function StatusLineInner({ } } // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, []) // Only run once on mount - settings stable for initial logging // Initial update on mount + cleanup on unmount @@ -384,7 +383,6 @@ function StatusLineInner({ } } // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, []) // Only run once on mount, not when doUpdate changes // Get padding from settings or default to 0 diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index 0e1308336..9edc95c23 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -48,20 +48,20 @@ export default function TextInput(props: Props): React.ReactNode { const reducedMotion = settings.prefersReducedMotion ?? false const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useVoiceState(s => s.voiceState) : ('idle' as const) const isVoiceRecording = voiceState === 'recording' const audioLevels = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useVoiceState(s => s.voiceAudioLevels) : [] const smoothedRef = useRef(new Array(CURSOR_WAVEFORM_WIDTH).fill(0)) const needsAnimation = isVoiceRecording && !reducedMotion const [animRef, animTime] = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + ? useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0] diff --git a/src/components/WorktreeExitDialog.tsx b/src/components/WorktreeExitDialog.tsx index 83ea8d554..52fe46eb8 100644 --- a/src/components/WorktreeExitDialog.tsx +++ b/src/components/WorktreeExitDialog.tsx @@ -63,7 +63,7 @@ export function WorktreeExitDialog({ '--count', `${worktreeSession.originalHeadCommit}..HEAD`, ]) - const count = parseInt(commitsStr.trim()) || 0 + const count = parseInt(commitsStr.trim(), 10) || 0 setCommitCount(count) // If no changes and no commits, clean up silently @@ -94,7 +94,6 @@ export function WorktreeExitDialog({ } void loadChanges() // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, [worktreeSession]) useEffect(() => { diff --git a/src/components/agents/ColorPicker.tsx b/src/components/agents/ColorPicker.tsx index 3e74ea8ca..f731ed971 100644 --- a/src/components/agents/ColorPicker.tsx +++ b/src/components/agents/ColorPicker.tsx @@ -27,7 +27,7 @@ export function ColorPicker({ const [selectedIndex, setSelectedIndex] = useState( Math.max( 0, - COLOR_OPTIONS.findIndex(opt => opt === currentColor), + COLOR_OPTIONS.indexOf(currentColor), ), ) diff --git a/src/components/mcp/MCPSettings.tsx b/src/components/mcp/MCPSettings.tsx index b350bf91e..c49157ce9 100644 --- a/src/components/mcp/MCPSettings.tsx +++ b/src/components/mcp/MCPSettings.tsx @@ -60,7 +60,7 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode { const isSSE = client.config.type === 'sse' const isHTTP = client.config.type === 'http' const isClaudeAIProxy = client.config.type === 'claudeai-proxy' - let isAuthenticated: boolean | undefined = undefined + let isAuthenticated: boolean | undefined if (isSSE || isHTTP) { const authProvider = new ClaudeAuthProvider( diff --git a/src/components/mcp/MCPToolListView.tsx b/src/components/mcp/MCPToolListView.tsx index ad3eb3695..9e2762913 100644 --- a/src/components/mcp/MCPToolListView.tsx +++ b/src/components/mcp/MCPToolListView.tsx @@ -88,7 +88,7 @@ export function MCPToolListView({