refactor: 拆分 3 个过大 ACP 文件为模块化子文件(每个 <500 行)

通过 4 阶段 workflow(分析 → 计划 → 重构 → 验证)将 3 个超大的 ACP
源文件拆分为 28 个模块化子文件,每个均严格小于 500 行,且完整保留
所有公共 API(barrel 模式重导出)。

变更概要:
- packages/acp-link/src/server.ts: 1800 → 20 行(barrel),新增 11 个子模块
  (server/types、payload-decode、permission-mode、runtime-state、dispatch、
  handlers-agent、handlers-session、acp-client、client-send、start-server、
  testing-internals)
- src/services/acp/agent.ts: 1297 → 33 行(barrel),新增 9 个子模块
  (agent/AcpAgent、sessionTypes、permissionMode、configOptions、promptQueue、
  internalAccessors、createSessionMethod、sessionLifecycle、promptFlow)
- src/services/acp/bridge.ts: 1516 → 29 行(barrel),新增 8 个子模块
  (bridge/types、paths、contentBlocks、toolInfo、toolResults、modelUsage、
  notifications、forwarding)

验证:
- bun run precheck 全通过(typecheck + lint + 5851 tests)
- ACP service tests: 176 pass / 0 fail
- ACP link tests: 47 pass / 0 fail
- 所有外部消费者(entry.ts、permissions.ts、__tests__/)的 import 路径不变
- 测试文件零修改

迁移计划详见 docs/acp-refactor-plan.md。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-19 15:39:01 +08:00
parent 35768837a7
commit 65f81de52b
32 changed files with 5481 additions and 4591 deletions

281
docs/acp-refactor-plan.md Normal file
View File

@@ -0,0 +1,281 @@
# ACP Refactor Plan: Splitting 3 Large Files into Modular Sub-files
This document is the authoritative migration plan for splitting three oversized ACP (Agent Client Protocol) source files into modular sub-files. Each file exceeds the 500-line-per-module budget; the refactor preserves every public export path so that **no test file and no external consumer requires modification**.
**Hard constraints (all three refactors):**
1. All current public API export paths MUST remain working (`from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'`).
2. Every new file MUST be under 500 lines.
3. Test files MUST NOT be modified — including `permissions.test.ts` which does `require('../bridge.ts')` and snapshots the **entire** export surface (so the bridge barrel MUST export exactly the public API, no more, no less).
4. Only the 3 target files and their NEW sub-modules may be modified.
5. `bun run precheck` MUST pass after every step (typecheck + lint fix + test).
---
## Target Files (current state)
| File | Lines | Public API surface |
|------|------:|--------------------|
| `packages/acp-link/src/server.ts` | 1800 | 8 must-preserve symbols |
| `src/services/acp/bridge.ts` | 1516 | 8 must-preserve symbols |
| `src/services/acp/agent.ts` | 1297 | 1 must-preserve symbol (`AcpAgent`) |
| **Total** | **4613** | |
---
## Migration Order (with rationale)
The three files are refactored **in dependency order, leaf-first**, so that each step has a stable foundation and any cross-file regression is caught immediately:
1. **Phase 1 — `src/services/acp/bridge.ts`** (leaf-ish utility module).
- Rationale: `agent.ts` imports `forwardSessionUpdates`, `replayHistoryMessages`, `ToolUseCache` from `bridge.js`. Splitting bridge first means agent's refactor builds against the new (identical) bridge surface. Bridge has zero imports from agent.ts, so it can be split independently.
- The barrel `bridge/index.ts` re-exports the exact public API, so the existing `from '../bridge.js'` specifier resolves unchanged under both Bun and tsc (directory + `index.ts`).
2. **Phase 2 — `src/services/acp/agent.ts`** (the cohesive AcpAgent class).
- Rationale: Depends on the now-stable bridge module. Only pure helpers and types are extracted; the class body stays intact in `AcpAgent.ts`. `bridge.test.ts`, `agent.test.ts`, `permissions.test.ts` continue to work because `from '../agent.js'` and `from '../bridge.js'` resolve to the barrels.
3. **Phase 3 — `packages/acp-link/src/server.ts`** (largest, most interdependent).
- Rationale: Self-contained inside `acp-link`; does not import from `src/services/acp`. Done last so the most complex module split (12 sub-files, runtime-state container, handler fan-out) can leverage the workflow discipline practiced in Phases 12.
Within each phase, the internal creation order is always: **types → leaf pure helpers → mid-level helpers → handlers → dispatch → barrel → delete original**. This keeps the import graph acyclic at every intermediate commit.
---
## Phase 1 — `src/services/acp/bridge.ts`
### Directory structure
```
src/services/acp/
├── bridge.ts ← DELETED (replaced by directory)
└── bridge/
├── index.ts ← barrel (public API)
├── types.ts ← type definitions
├── paths.ts ← toAbsolutePath
├── contentBlocks.ts ← low-level block conversion
├── toolInfo.ts ← toolInfoFromToolUse
├── toolResults.ts ← tool result → ToolCallContent
├── modelUsage.ts ← context-window prefix helpers
├── notifications.ts ← content-block → SessionUpdate engine
└── forwarding.ts ← stream replay + forwarding loop
```
### Files, responsibilities, line budgets
| File | Responsibility | Exports | Budget |
|------|----------------|---------|-------:|
| `bridge/types.ts` | Shared ACP-bridge type definitions: `ToolUseCache`, `SessionUsage`, `BridgeUsage`, `Bridge*Message` interfaces, `BridgeSDKMessage` discriminated union, `ToolInfo`, `EditToolResponseHunk`, `EditToolResponse`. Re-exports SDK type-only imports (`ContentBlock`, `ToolCallContent`, `ToolCallLocation`, `ToolKind`). | 16 symbols | ~150 |
| `bridge/paths.ts` | Pure path-normalisation helper `toAbsolutePath` used by toolInfo / toolResults / forwarding. Leaf module, no bridge-internal imports. | `toAbsolutePath` | ~20 |
| `bridge/contentBlocks.ts` | Low-level conversion of Claude content block shapes into ACP `ContentBlock` values. `toAcpContentUpdate` wraps arrays/strings into `ToolCallContent[]` via `toAcpContentBlock`. Leaf module. | `toAcpContentUpdate`, `toAcpContentBlock` | ~150 |
| `bridge/toolInfo.ts` | `toolInfoFromToolUse` — large switch mapping each known tool name (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, TodoWrite, ExitPlanMode, default) to ACP `ToolInfo` (title, kind, content, locations). Depends on `paths.toAbsolutePath` and `../utils.js` (`toDisplayPath`). | `toolInfoFromToolUse` | ~250 |
| `bridge/toolResults.ts` | `toolUpdateFromToolResult` (Read markdown escape, Bash console fence, Edit/Write no-op, ExitPlanMode title, default via `toAcpContentUpdate`); `toolUpdateFromEditToolResponse` (parses `structuredPatch` hunks into diff `ToolCallContent` with absolute paths). Depends on `contentBlocks` and `paths`. | `toolUpdateFromToolResult`, `toolUpdateFromEditToolResponse` | ~180 |
| `bridge/modelUsage.ts` | `commonPrefixLength` and `getMatchingModelUsage` — pure helpers used by the forwarding loop to resolve `contextWindow` from `modelUsage` map by prefix match. Leaf module. | `commonPrefixLength`, `getMatchingModelUsage` | ~35 |
| `bridge/notifications.ts` | Core content-block → `SessionUpdate` conversion engine. `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc. and writes into `ToolUseCache`. `assistantMessageToAcpNotifications` and `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus` helper for TodoWrite plan mapping. Depends on `toolInfo.toolInfoFromToolUse`, `toolResults.toolUpdateFromToolResult`, and `types`. **No logger** in original — do NOT add one here. | `toAcpNotifications`, `assistantMessageToAcpNotifications`, `streamEventToAcpNotifications`, `normalizePlanStatus` | ~320 |
| `bridge/forwarding.ts` | `nextSdkMessageOrAbort` (races async generator against `AbortSignal`); `forwardSessionUpdates` (main loop consuming `SDKMessage` stream, dispatching to notification converters, accumulating usage, mapping stop reasons); `replayHistoryMessages` (replays stored user/assistant history through `toAcpNotifications`). The module-level `const logger = console` lives here (only `forwardSessionUpdates` default branch and `replayHistoryMessages` reference `logger.debug`). Depends on `types`, `notifications`, `modelUsage`. | `nextSdkMessageOrAbort`, `forwardSessionUpdates`, `replayHistoryMessages` | ~280 |
| `bridge/index.ts` | Barrel — see content below. | 8 re-exports | ~20 |
### Barrel content — `src/services/acp/bridge/index.ts`
```ts
// Barrel preserving the public API of the former src/services/acp/bridge.ts.
// Do NOT add internal-only exports here: permissions.test.ts snapshots the
// entire module surface via require('../bridge.ts') and would break if the
// exported name set changes.
export type { ToolUseCache, SessionUsage } from './types.js'
export {
toolInfoFromToolUse,
} from './toolInfo.js'
export {
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from './toolResults.js'
export {
nextSdkMessageOrAbort,
forwardSessionUpdates,
replayHistoryMessages,
} from './forwarding.js'
```
### Phase 1 verification
```bash
# After creating all sub-files and deleting bridge.ts:
bun test src/services/acp/__tests__/bridge.test.ts
bun test src/services/acp/__tests__/permissions.test.ts # snapshot-sensitive
bun test src/services/acp/__tests__/agent.test.ts # imports bridge.js + agent.js
bun run precheck # typecheck + lint + test
```
### Phase 1 risk callouts
- **Snapshot sensitivity**: `permissions.test.ts` lines 3435 do `require('../bridge.ts')` and snapshot every named export. The barrel MUST export exactly `{ ToolUseCache, SessionUsage, toolInfoFromToolUse, toolUpdateFromToolResult, toolUpdateFromEditToolResponse, nextSdkMessageOrAbort, forwardSessionUpdates, replayHistoryMessages }`. Do NOT re-export `ToolInfo`, `BridgeSDKMessage`, or any internal helper.
- **Logger alias**: the original `const logger = console` is a top-level const with no runtime side effect. Keep it ONLY in `forwarding.ts`. Do NOT create a shared `logger.ts` (would risk a cycle) and do NOT give `notifications.ts` its own logger (the original does not reference one).
- **`ToolInfo` stays internal**: it is the return type of `toolInfoFromToolUse` but was never exported from the original `bridge.ts`. Keep it module-internal so the public surface matches the original exactly.
---
## Phase 2 — `src/services/acp/agent.ts`
### Directory structure
```
src/services/acp/
├── agent.ts ← DELETED (replaced by directory)
└── agent/
├── index.ts ← barrel (re-exports AcpAgent)
├── sessionTypes.ts ← AcpSession / PendingPrompt types
├── permissionMode.ts ← permission mode resolution
├── configOptions.ts ← config option list builder
├── promptQueue.ts ← pending-prompt queue helpers
└── AcpAgent.ts ← the AcpAgent class body
```
### Files, responsibilities, line budgets
| File | Responsibility | Exports | Budget |
|------|----------------|---------|-------:|
| `agent/sessionTypes.ts` | Type definitions for in-process ACP session state. `AcpSession` and `PendingPrompt` type aliases shared across agent internals and helpers. | `AcpSession`, `PendingPrompt` | ~35 |
| `agent/permissionMode.ts` | Resolve the effective permission mode from `_meta`, settings, and process env. Determine whether ACP `bypassPermissions` mode is available (process + local opt-in + settings). `PermissionMode`-id validation guard. Imports `PermissionMode` type from `../../types/permissions.js` and `resolvePermissionMode` from `../utils.js` — leaf module, does NOT import AcpAgent. | `permissionModeIds`, `isPermissionMode`, `resolveSessionPermissionMode`, `isAcpBypassPermissionModeAvailable`, `hasOwnField` | ~110 |
| `agent/configOptions.ts` | Build the ACP session config option list (mode + model select options) from session states. `flattenConfigOptionValues` flattens grouped/flat select options into valid value strings for validation. Imports ACP SDK types (`SessionModeState`, `SessionModelState`, `SessionConfigOption`). Leaf module. | `buildConfigOptions`, `flattenConfigOptionValues` | ~70 |
| `agent/promptQueue.ts` | Pending-prompt queue management: `popNextPendingPrompt`, `compactPendingQueue` (compacts queue head to bound memory). Pure helpers operating on `AcpSession.pendingQueue` / `pendingMessages`. Imports `sessionTypes` only. | `popNextPendingPrompt`, `compactPendingQueue` | ~45 |
| `agent/AcpAgent.ts` | The `AcpAgent` class implementing the ACP Agent interface. All protocol method handlers (`initialize`, `authenticate`, `newSession`, `resumeSession`, `loadSession`, `listSessions`, `forkSession`, `closeSession`, `prompt`, `cancel`, `setSessionMode`, `setSessionModel`, `setSessionConfigOption`) and private lifecycle helpers (`createSession`, `getOrCreateSession`, `teardownSession`, `replaySessionHistory`, `applySessionMode`, `updateConfigOption`, `syncSessionConfigState`, `sendAvailableCommandsUpdate`, `scheduleAvailableCommandsUpdate`, `maybeEmitSessionInfoUpdate`, `getSetting`). Imports `sessionTypes`, `permissionMode`, `configOptions`, `promptQueue`. Imports `ToolUseCache`, `forwardSessionUpdates`, `replayHistoryMessages` from `../bridge.js` (the Phase 1 barrel). | `AcpAgent` | ~480 |
| `agent/index.ts` | Barrel — see content below. | `AcpAgent` | ~5 |
### Barrel content — `src/services/acp/agent/index.ts`
```ts
// Barrel preserving the public API of the former src/services/acp/agent.ts.
// Tests import AcpAgent via '../agent.js' (Bun/tsc resolve the directory's
// index.ts). Keep this file to a single re-export.
export { AcpAgent } from './AcpAgent.js'
```
### Why the class body is NOT split further
The `AcpAgent` class is a single cohesive unit bound by `this.sessions` and `this.conn`. Methods like `createSession`, `prompt`, `cancel`, `teardownSession`, `applySessionMode`, `updateConfigOption` all reference `this.*` and shared private helpers. Extracting methods to a separate module would require passing the session map and connection as parameters and would create tight bidirectional coupling with high cycle risk. Therefore the class body stays in one module (~480 lines, under the 500 limit); only pure helpers and types are extracted. This keeps the import graph strictly acyclic: `sessionTypes`/`permissionMode`/`configOptions`/`promptQueue` are pure leaves that never import `AcpAgent`.
### Phase 2 verification
```bash
bun test src/services/acp/__tests__/agent.test.ts # imports ../agent.js + ../bridge.js
bun test src/services/acp/__tests__/permissions.test.ts # still green after bridge split
bun run precheck
```
### Phase 2 risk callouts
- **Private method coupling**: keep the class intact in `AcpAgent.ts`; do not be tempted to extract methods even if the file approaches the budget.
- **ToolUseCache shape coupling**: `maybeEmitSessionInfoUpdate` attaches `__sessionInfoTitleSent` to `session.toolUseCache` via a structural cast. Keep that logic inside `AcpAgent.ts` so no cross-module dependency on the extended shape is introduced.
- **Test path stability**: `agent.test.ts` line 195 does `await import('../agent.js')`. With `agent/index.ts` re-exporting `AcpAgent` from `agent/AcpAgent.ts`, the specifier resolves under Bun/TS because directory imports map to `index.ts`. The barrel MUST use the `.js` extension (`export { AcpAgent } from './AcpAgent.js'`) to match the project's ESM convention.
---
## Phase 3 — `packages/acp-link/src/server.ts`
### Directory structure
```
packages/acp-link/src/
├── server.ts ← DELETED (replaced by directory)
└── server/
├── index.ts ← barrel (public API)
├── types.ts ← protocol/state types + JSON-RPC codes
├── runtime-state.ts ← module-scoped mutable state container
├── client-send.ts ← outbound message framing
├── acp-client.ts ← createClient + permission helpers
├── payload-decode.ts ← validation/decode utilities
├── permission-mode.ts ← permission mode resolution
├── handlers-agent.ts ← agent lifecycle handlers
├── handlers-session.ts ← session-scoped handlers
├── dispatch.ts ← dispatch + JSON-RPC wrappers + table
├── testing-internals.ts ← __testing public object
└── start-server.ts ← startServer orchestrator
```
### Files, responsibilities, line budgets
| File | Responsibility | Exports | Budget |
|------|----------------|---------|-------:|
| `server/types.ts` | Shared protocol/state type definitions used across all server modules (`ServerConfig`, `PendingPermission`, `PromptCapabilities`, `SessionModelState`, `AgentCapabilities`, `ClientState`, `ContentBlock`, `PermissionResponsePayload`, `ProxyMessage`); `createClientState` factory; `DEFAULT_CLIENT_INFO` / `DEFAULT_CLIENT_CAPABILITIES` constants; JSON-RPC error code constants. | 16 symbols | ~200 |
| `server/runtime-state.ts` | Module-scoped mutable state container for the running server: holds the `clients` Map, server config fields (`AGENT_*`, `SERVER_*`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`), `rcsUpstream`, loggers, and accessor/mutator helpers. `createRelayWs` virtual `WSContext` factory. `generateRequestId` helper. **MUST NOT import any handler module** to avoid cycles. | `clients`, `getServerConfig`, `setServerConfig`, `getRcsUpstream`, `setRcsUpstream`, `getAgentConfig`, `getDefaultPermissionMode`, `setDefaultPermissionMode`, `logWs`, `logAgent`, `logSession`, `logPrompt`, `logPerm`, `logRelay`, `logServer`, `PERMISSION_TIMEOUT_MS`, `HEARTBEAT_INTERVAL_MS`, `createRelayWs`, `generateRequestId` | ~140 |
| `server/client-send.ts` | Outbound message framing: `send`, `sendJsonRpcRaw`, `sendJsonRpcError`. `LEGACY_NOTIFICATION_TO_JSONRPC` mapping. Depends on `runtime-state` (`clients`, `rcsUpstream`) and `types` (`ClientState`). Reads `rcsUpstream` via runtime-state and the `clients` Map; `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. | `send`, `sendJsonRpcRaw`, `sendJsonRpcError` | ~110 |
| `server/acp-client.ts` | `createClient(ws, clientState)`: builds the `acp.Client` implementation that forwards `requestPermission` / `sessionUpdate` / `readTextFile` / `writeTextFile`. `handlePermissionResponse` and `cancelPendingPermissions`. Depends on `client-send` (`send`) and `runtime-state` (`logPerm`). Import graph: `client-send → runtime-state` (ok), `acp-client → client-send + runtime-state` (ok, no cycle). | `createClient`, `handlePermissionResponse`, `cancelPendingPermissions` | ~110 |
| `server/payload-decode.ts` | Pure validation/decode utilities (`isRecord`, `optionalString`, `optionalStringField`, `payloadRecord`, `optionalPayloadRecord`, `optionalRecord`, `decodeContentBlocks`, `decodePermissionResponsePayload`). `decodeClientMessage` switch turning a raw record into a `ProxyMessage`. Public `decodeClientWsMessage` wrapper. `decodeClientMessage` is also consumed by `start-server.ts` (RCS relay path) — keep it exported here to avoid duplication. | 10 symbols | ~200 |
| `server/permission-mode.ts` | `ACP_LINK_PERMISSION_MODE_ALIASES` + `resolveAcpLinkPermissionMode` + public `resolveNewSessionPermissionMode`. `buildAgentEnv` helper. | `resolveNewSessionPermissionMode`, `resolveAcpLinkPermissionMode`, `ACP_LINK_PERMISSION_MODE_ALIASES`, `buildAgentEnv` | ~90 |
| `server/handlers-agent.ts` | Agent lifecycle + connection handlers: `handleConnect` and `handleDisconnect`. Spawns the agent child process, builds the ACP `ClientSideConnection`, surfaces status. Depends on `runtime-state`, `client-send`, `acp-client`, `types`. | `handleConnect`, `handleDisconnect` | ~160 |
| `server/handlers-session.ts` | Session-scoped handlers: `handleNewSession`, `handleListSessions`, `handleLoadSession`, `handleResumeSession`, `handleCancel`, `handleSetSessionModel`, `handlePrompt`. All operate on `clients.get(ws)` state and forward to `ClientSideConnection`. | 7 symbols | ~360 |
| `server/dispatch.ts` | `dispatchClientMessage` (legacy envelope switch). JSON-RPC wrappers `handleJsonRpcNewSession` / `Prompt` / `ListSessions` / `LoadSession` / `ResumeSession` / `SetSessionModel` / `SetSessionMode` / `CloseSession` / `CancelRequest`. `JSONRPC_METHOD_HANDLERS` table and `dispatchJsonRpcMessage` router. The JSON-RPC wrappers live **alongside** the table in this module (no cross-module forward reference). | `dispatchClientMessage`, `dispatchJsonRpcMessage`, `JSONRPC_METHOD_HANDLERS`, `handleJsonRpcSetSessionMode`, `handleJsonRpcCloseSession`, `handleJsonRpcCancelRequest` | ~290 |
| `server/testing-internals.ts` | `__testing` public object (`dispatchClientMessage` / `dispatchJsonRpcMessage` / `registerClient` / `getClientSessionId` / `setDefaultPermissionMode`). `assertTestingInternalsEnabled` guard gated on `ACP_LINK_TEST_INTERNALS`. Co-locate the guard with the methods that call it. | `__testing`, `assertTestingInternalsEnabled` | ~80 |
| `server/start-server.ts` | `startServer(config)`: configures runtime-state, wires `RcsUpstreamClient` relay, builds the Hono app with `/health` and `/ws` (token validation, `onOpen` / `onMessage` / `onClose`, heartbeat), HTTPS option, startup banner, SIGINT/SIGTERM graceful shutdown. Top-level orchestrator importing from `runtime-state`, `client-send`, `acp-client`, `dispatch`, `payload-decode`. All intervals/sockets MUST be created inside `startServer` (no top-level side effects). | `startServer` | ~280 |
| `server/index.ts` | Barrel — see content below. | 8 re-exports | ~25 |
### Barrel content — `packages/acp-link/src/server/index.ts`
```ts
// Barrel preserving the public API of the former packages/acp-link/src/server.ts.
//
// Re-exports of MAX_CLIENT_WS_PAYLOAD_BYTES / isJsonRpc2Message /
// JsonRpc2ClientMessage MUST come from '../ws-message.js' (single source of
// truth) — do NOT route them through a split module.
export type { ServerConfig } from './types.js'
export {
MAX_CLIENT_WS_PAYLOAD_BYTES,
isJsonRpc2Message,
} from '../ws-message.js'
export type { JsonRpc2ClientMessage } from '../ws-message.js'
export { decodeClientWsMessage } from './payload-decode.js'
export { resolveNewSessionPermissionMode } from './permission-mode.js'
export { __testing } from './testing-internals.js'
export { startServer } from './start-server.js'
```
### Phase 3 verification
```bash
bun test packages/acp-link/src/__tests__/server.test.ts
bun test packages/acp-link/src/__tests__/types.test.ts
bun run precheck
bun run build # confirm chunk count is sane and dist/cli.js builds
```
### Phase 3 risk callouts
- **Module-scoped mutable state**: `AGENT_COMMAND`, `AGENT_ARGS`, `AGENT_CWD`, `SERVER_PORT`, `SERVER_HOST`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`, the `clients` Map, and `rcsUpstream` all live in `runtime-state.ts`. Every other module accesses them via the accessors/setters. Keep `runtime-state.ts` free of any handler import — it is the shared leaf that everything else depends on; importing handlers back into it creates a cycle.
- **Single-flight invariant**: `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. Do not parallelise handlers — the pendingJsonRpc invariant depends on serial mutation of `ClientState`.
- **JSON-RPC wrappers co-located with the table**: `JSONRPC_METHOD_HANDLERS` references the `handleJsonRpc*` wrappers. To avoid cross-module forward references, the wrappers and the table MUST live in the same `dispatch.ts` module.
- **Re-exports stay at source**: `MAX_CLIENT_WS_PAYLOAD_BYTES`, `isJsonRpc2Message`, `JsonRpc2ClientMessage` are re-exported from `'../ws-message.js'` directly. Do NOT re-export them from a split module.
- **No top-level side effects**: the original file only declares module-scoped vars; loggers are created eagerly via `createLogger` (acceptable — pure construction). Do NOT start intervals or open sockets at module top level; keep them inside `startServer`.
- **assertTestingInternalsEnabled gating**: the guard is gated on `ACP_LINK_TEST_INTERNALS` and is called by every `__testing` method. Co-locate it with `__testing` in `testing-internals.ts` and preserve the gating behavior verbatim.
- **Biome lint surface**: 42 rules are disabled for decompiled code. Moving helpers like `optionalStringField` into their own module may surface `noUnusedVariables` if they are not re-exported. Export every helper that was previously file-local but is now cross-module, and run `bun run precheck` to catch new warnings.
---
## Cross-cutting verification (run after ALL three phases)
```bash
# 1. Full type + lint + test gate (REQUIRED zero errors per CLAUDE.md)
bun run precheck
# 2. Targeted regression runs for the three refactored modules
bun test packages/acp-link/src/__tests__/server.test.ts
bun test src/services/acp/__tests__/bridge.test.ts
bun test src/services/acp/__tests__/agent.test.ts
bun test src/services/acp/__tests__/permissions.test.ts
# 3. Build sanity (new chunks are produced for the new sub-files)
bun run build
ls dist/chunks | wc -l # expect a modest increase over the previous count
# 4. Unused-export audit (catches accidentally-leaked internal exports)
bun run check:unused
```
## Acceptance criteria
- [ ] `bun run precheck` passes with zero errors.
- [ ] All four target test files pass unmodified.
- [ ] `from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'` all resolve correctly (verified by the passing tests).
- [ ] No new file exceeds 500 lines.
- [ ] `permissions.test.ts` snapshot of `require('../bridge.ts')` still matches the original 8-symbol public surface.
- [ ] `bun run build` succeeds with a sane chunk count.
- [ ] No test file is modified in the diff.