From 7ea69ca2797602d1e372ea2f869551cc268829dd Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 11:39:28 +0800 Subject: [PATCH 01/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20build=20?= =?UTF-8?q?=E8=BF=87=E7=A8=8B=E4=B8=AD=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.tsx b/src/main.tsx index c1d6e9144..c4588b1b2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4248,11 +4248,12 @@ async function run(): Promise { }); } + const teammateUtils = getTeammateUtils(); const effectiveToolPermissionContext = { ...toolPermissionContext, mode: isAgentSwarmsEnabled() && - getTeammateUtils().isPlanModeRequired() + teammateUtils?.isPlanModeRequired?.() ? ("plan" as const) : toolPermissionContext.mode, }; From 1173a6230141c2c587715446c3c98037af2667d8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 22 Apr 2026 23:35:59 +0800 Subject: [PATCH 02/33] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=20log.ts/d?= =?UTF-8?q?ebug.ts=20=E7=9A=84=E6=B5=8B=E8=AF=95=20mock=20=E4=B8=BA?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 tests/mocks/log.ts 和 tests/mocks/debug.ts,覆盖源文件全部实际导出 - 移除旧 mock 中不存在的导出(logToFile、logEvent、getLogFilePath) - 13 个测试文件改为使用共享 mock,避免定义分散和不一致 Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 12 +++++++++ .../__tests__/agentToolUtils.test.ts | 16 ++---------- .../__tests__/CtxInspectTool.test.ts | 18 ++----------- .../FileEditTool/__tests__/utils.test.ts | 18 ++----------- .../LSPTool/__tests__/formatters.test.ts | 6 ++--- .../langfuse/__tests__/langfuse.test.ts | 5 ++-- .../mcp/__tests__/officialRegistry.test.ts | 5 ++-- src/utils/__tests__/envValidation.test.ts | 16 ++---------- src/utils/__tests__/json.test.ts | 8 ++---- src/utils/__tests__/memoize.test.ts | 8 ++---- src/utils/__tests__/tokens.test.ts | 18 ++----------- .../__tests__/PermissionMode.test.ts | 8 ++---- .../permissions/__tests__/permissions.test.ts | 18 ++----------- .../shell/__tests__/outputLimits.test.ts | 16 ++---------- tests/mocks/debug.ts | 25 +++++++++++++++++++ tests/mocks/log.ts | 24 ++++++++++++++++++ 16 files changed, 87 insertions(+), 134 deletions(-) create mode 100644 tests/mocks/debug.ts create mode 100644 tests/mocks/log.ts diff --git a/CLAUDE.md b/CLAUDE.md index 838a2061d..45f55ebe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,6 +263,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。 +**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式: + +```ts +import { logMock } from "../../../tests/mocks/log"; +mock.module("src/utils/log.ts", logMock); + +import { debugMock } from "../../../../tests/mocks/debug"; +mock.module("src/utils/debug.ts", debugMock); +``` + +源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。 + 不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。 路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。 diff --git a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts index 21d8a70a0..90525baad 100644 --- a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts +++ b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts @@ -1,4 +1,5 @@ import { mock, describe, expect, test } from "bun:test"; +import { debugMock } from "../../../../../../tests/mocks/debug"; // ─── Mocks for agentToolUtils.ts dependencies ─── // Only mock modules that are truly unavailable or cause side effects. @@ -87,20 +88,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ updateProgressFromMessage: noop, })); -mock.module("src/utils/debug.ts", () => ({ - getMinDebugLogLevel: () => "warn", - isDebugMode: () => false, - enableDebugLogging: () => false, - getDebugFilter: () => null, - isDebugToStdErr: () => false, - getDebugFilePath: () => null, - setHasFormattedOutput: noop, - getHasFormattedOutput: () => false, - flushDebugLogs: async () => {}, - logForDebugging: noop, - getDebugLogPath: () => "", - logAntError: noop, -})); +mock.module("src/utils/debug.ts", debugMock); mock.module("src/utils/errors.js", () => ({ ClaudeError: class extends Error {}, diff --git a/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts b/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts index 36b842e4c..5270b50f0 100644 --- a/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts +++ b/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts @@ -1,21 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { logMock } from '../../../../../../tests/mocks/log' -mock.module('src/utils/log.ts', () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => '', - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'), - getLogFilePath: () => '/tmp/mock-log', - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})) +mock.module('src/utils/log.ts', logMock) mock.module('src/services/tokenEstimation.ts', () => ({ roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4), diff --git a/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts b/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts index 1cfab5fab..eca237141 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts +++ b/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts @@ -1,22 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { logMock } from "../../../../../../tests/mocks/log"; // Mock log.ts to cut the heavy dependency chain -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), - getLogFilePath: () => "/tmp/mock-log", - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { normalizeQuotes, diff --git a/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts b/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts index 325900079..035cf88cc 100644 --- a/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts +++ b/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts @@ -1,9 +1,7 @@ import { mock, describe, expect, test } from "bun:test"; +import { debugMock } from "../../../../../../tests/mocks/debug"; -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, - isDebugMode: () => false, -})); +mock.module("src/utils/debug.ts", debugMock); const { formatGoToDefinitionResult, diff --git a/src/services/langfuse/__tests__/langfuse.test.ts b/src/services/langfuse/__tests__/langfuse.test.ts index 8d9d0f535..48afa2372 100644 --- a/src/services/langfuse/__tests__/langfuse.test.ts +++ b/src/services/langfuse/__tests__/langfuse.test.ts @@ -1,4 +1,5 @@ import { mock, describe, test, expect, beforeEach } from 'bun:test' +import { debugMock } from '../../../../tests/mocks/debug' // Mock @langfuse/otel before any imports const mockForceFlush = mock(() => Promise.resolve()) @@ -71,9 +72,7 @@ mock.module('@langfuse/tracing', () => ({ })) // Mock debug logger -mock.module('src/utils/debug.ts', () => ({ - logForDebugging: mock(() => {}), -})) +mock.module('src/utils/debug.ts', debugMock) // Mock user data — resolveLangfuseUserId uses getCoreUserData().email and .deviceId mock.module('src/utils/user.js', () => ({ diff --git a/src/services/mcp/__tests__/officialRegistry.test.ts b/src/services/mcp/__tests__/officialRegistry.test.ts index 12ce50184..bcc9da35d 100644 --- a/src/services/mcp/__tests__/officialRegistry.test.ts +++ b/src/services/mcp/__tests__/officialRegistry.test.ts @@ -1,11 +1,10 @@ import { mock, describe, expect, test, afterEach } from "bun:test"; +import { debugMock } from "../../../../tests/mocks/debug"; mock.module("axios", () => ({ default: { get: async () => ({ data: { servers: [] } }) }, })); -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, -})); +mock.module("src/utils/debug.ts", debugMock); const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import( "../officialRegistry" diff --git a/src/utils/__tests__/envValidation.test.ts b/src/utils/__tests__/envValidation.test.ts index 984d5cfde..0ef8d18dd 100644 --- a/src/utils/__tests__/envValidation.test.ts +++ b/src/utils/__tests__/envValidation.test.ts @@ -1,20 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { debugMock } from "../../../tests/mocks/debug"; // Mock debug.ts to cut bootstrap/state dependency chain -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, - isDebugMode: () => false, - isDebugToStdErr: () => false, - getDebugFilePath: () => null, - getDebugFilter: () => null, - getMinDebugLogLevel: () => "debug", - getDebugLogPath: () => "/tmp/mock-debug.log", - flushDebugLogs: async () => {}, - enableDebugLogging: () => false, - setHasFormattedOutput: () => {}, - getHasFormattedOutput: () => false, - logAntError: () => {}, -})); +mock.module("src/utils/debug.ts", debugMock); const { validateBoundedIntEnvVar } = await import("../envValidation"); diff --git a/src/utils/__tests__/json.test.ts b/src/utils/__tests__/json.test.ts index 42a624b10..1d4b441f9 100644 --- a/src/utils/__tests__/json.test.ts +++ b/src/utils/__tests__/json.test.ts @@ -1,12 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { logMock } from "../../../tests/mocks/log"; // Mock log.ts to cut the heavy dependency chain (log.ts → bootstrap/state.ts → analytics) -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray } = await import("../json"); diff --git a/src/utils/__tests__/memoize.test.ts b/src/utils/__tests__/memoize.test.ts index 4cdff9968..41e4a3349 100644 --- a/src/utils/__tests__/memoize.test.ts +++ b/src/utils/__tests__/memoize.test.ts @@ -1,12 +1,8 @@ import { mock, describe, expect, test, beforeEach } from "bun:test"; +import { logMock } from "../../../tests/mocks/log"; // Mock log.ts to cut the bootstrap/state dependency chain -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import( "../memoize" diff --git a/src/utils/__tests__/tokens.test.ts b/src/utils/__tests__/tokens.test.ts index 4e7b905c5..cf3eba2bb 100644 --- a/src/utils/__tests__/tokens.test.ts +++ b/src/utils/__tests__/tokens.test.ts @@ -1,22 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { logMock } from "../../../tests/mocks/log"; // Mock heavy dependency chain: tokenEstimation.ts → log.ts → bootstrap/state.ts -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), - getLogFilePath: () => "/tmp/mock-log", - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})); +mock.module("src/utils/log.ts", logMock); // Mock tokenEstimation to avoid pulling in API provider deps mock.module("src/services/tokenEstimation.ts", () => ({ diff --git a/src/utils/permissions/__tests__/PermissionMode.test.ts b/src/utils/permissions/__tests__/PermissionMode.test.ts index d07596d16..0f2d065d9 100644 --- a/src/utils/permissions/__tests__/PermissionMode.test.ts +++ b/src/utils/permissions/__tests__/PermissionMode.test.ts @@ -1,11 +1,7 @@ import { mock, describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { logMock } from "../../../../tests/mocks/log"; -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { isExternalPermissionMode, diff --git a/src/utils/permissions/__tests__/permissions.test.ts b/src/utils/permissions/__tests__/permissions.test.ts index 01b6ace54..d83c2b153 100644 --- a/src/utils/permissions/__tests__/permissions.test.ts +++ b/src/utils/permissions/__tests__/permissions.test.ts @@ -1,24 +1,10 @@ import { mock, describe, expect, test } from 'bun:test' +import { logMock } from '../../../../tests/mocks/log' import { createFileStateCacheWithSizeLimit } from '../../../utils/fileStateCache.js' import { createSubagentContext } from '../../../utils/forkedAgent.js' import { getEmptyToolPermissionContext } from '../../../Tool.js' -mock.module('src/utils/log.ts', () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => '', - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'), - getLogFilePath: () => '/tmp/mock-log', - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})) +mock.module('src/utils/log.ts', logMock) const { getDenyRuleForTool, diff --git a/src/utils/shell/__tests__/outputLimits.test.ts b/src/utils/shell/__tests__/outputLimits.test.ts index 7abd319ff..b1418418e 100644 --- a/src/utils/shell/__tests__/outputLimits.test.ts +++ b/src/utils/shell/__tests__/outputLimits.test.ts @@ -1,20 +1,8 @@ import { mock, describe, expect, test, afterEach } from "bun:test"; +import { debugMock } from "../../../../tests/mocks/debug"; // Mock debug.ts to cut the bootstrap/state dependency chain -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, - isDebugMode: () => false, - isDebugToStdErr: () => false, - getDebugFilePath: () => null, - getDebugFilter: () => null, - getMinDebugLogLevel: () => "debug", - getDebugLogPath: () => "/tmp/mock-debug.log", - flushDebugLogs: async () => {}, - enableDebugLogging: () => false, - setHasFormattedOutput: () => {}, - getHasFormattedOutput: () => false, - logAntError: () => {}, -})); +mock.module("src/utils/debug.ts", debugMock); const { getMaxOutputLength, diff --git a/tests/mocks/debug.ts b/tests/mocks/debug.ts new file mode 100644 index 000000000..0dc981668 --- /dev/null +++ b/tests/mocks/debug.ts @@ -0,0 +1,25 @@ +/** + * Shared mock for src/utils/debug.ts + * + * Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID). + * Must be called via mock.module("src/utils/debug.ts", debugMock) BEFORE any import that + * transitively depends on debug.ts. + * + * Exported as a factory so each call produces a fresh object (mock.module requirement). + */ +export function debugMock() { + return { + getMinDebugLogLevel: () => "debug" as const, + isDebugMode: () => false, + enableDebugLogging: () => false, + getDebugFilter: () => null, + isDebugToStdErr: () => false, + getDebugFilePath: () => null as string | null, + setHasFormattedOutput: () => {}, + getHasFormattedOutput: () => false, + flushDebugLogs: async () => {}, + logForDebugging: () => {}, + getDebugLogPath: () => "/tmp/mock-debug.log", + logAntError: () => {}, + } +} diff --git a/tests/mocks/log.ts b/tests/mocks/log.ts new file mode 100644 index 000000000..6661a0971 --- /dev/null +++ b/tests/mocks/log.ts @@ -0,0 +1,24 @@ +/** + * Shared mock for src/utils/log.ts + * + * Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID). + * Must be called via mock.module("src/utils/log.ts", logMock) BEFORE any import that + * transitively depends on log.ts. + * + * Exported as a factory so each call produces a fresh object (mock.module requirement). + */ +export function logMock() { + return { + logError: () => {}, + getLogDisplayTitle: () => "", + dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), + attachErrorLogSink: () => {}, + getInMemoryErrors: () => [] as Array<{ error: string; timestamp: string }>, + loadErrorLogs: async () => [], + getErrorLogByIndex: async () => null, + logMCPError: () => {}, + logMCPDebug: () => {}, + captureAPIRequest: () => {}, + _resetErrorLogForTesting: () => {}, + } +} From 93bfdabff1a5a48f726ad536529fa045fb1317dc Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 23 Apr 2026 18:43:41 +0800 Subject: [PATCH 03/33] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Exa=20AI=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ExaSearchAdapter,基于 MCP 协议调用 Exa 搜索 API - WebSearchTool 支持 num_results、livecrawl、search_type、context_max_characters 等高级选项 - 非 Anthropic 官方 base URL 时默认使用 Exa 适配器 --- .../src/tools/WebSearchTool/WebSearchTool.ts | 24 ++ .../__tests__/adapterFactory.test.ts | 4 +- .../__tests__/exaAdapter.test.ts | 302 ++++++++++++++++++ .../WebSearchTool/adapters/exaAdapter.ts | 200 ++++++++++++ .../src/tools/WebSearchTool/adapters/index.ts | 18 +- .../src/tools/WebSearchTool/adapters/types.ts | 8 + 6 files changed, 548 insertions(+), 8 deletions(-) create mode 100644 packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts create mode 100644 packages/builtin-tools/src/tools/WebSearchTool/adapters/exaAdapter.ts diff --git a/packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts b/packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts index 43a032585..478704fd1 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts @@ -23,6 +23,26 @@ const inputSchema = lazySchema(() => .array(z.string()) .optional() .describe('Never include search results from these domains'), + num_results: z + .number() + .optional() + .describe('Number of search results to return (default: 8)'), + livecrawl: z + .enum(['fallback', 'preferred']) + .optional() + .describe( + "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + ), + search_type: z + .enum(['auto', 'fast', 'deep']) + .optional() + .describe( + "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + ), + context_max_characters: z + .number() + .optional() + .describe('Maximum characters for context string optimized for LLMs (default: 10000)'), }), ) type InputSchema = ReturnType @@ -148,6 +168,10 @@ export const WebSearchTool = buildTool({ const adapterResults = await adapter.search(query, { allowedDomains: input.allowed_domains, blockedDomains: input.blocked_domains, + numResults: input.num_results, + livecrawl: input.livecrawl, + searchType: input.search_type, + contextMaxCharacters: input.context_max_characters, signal: context.abortController.signal, onProgress(progress) { if (onProgress) { diff --git a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/adapterFactory.test.ts b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/adapterFactory.test.ts index 14eb7fecb..4e5353d89 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/adapterFactory.test.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/adapterFactory.test.ts @@ -52,10 +52,10 @@ describe('createAdapter', () => { expect(createAdapter().constructor.name).toBe('ApiSearchAdapter') }) - test('selects the Bing adapter for third-party Anthropic base URLs', () => { + test('selects the Exa adapter for third-party Anthropic base URLs', () => { delete process.env.WEB_SEARCH_ADAPTER isFirstPartyBaseUrl = false - expect(createAdapter().constructor.name).toBe('BingSearchAdapter') + expect(createAdapter().constructor.name).toBe('ExaSearchAdapter') }) }) diff --git a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts new file mode 100644 index 000000000..8d1ef6f20 --- /dev/null +++ b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +const _abortMock = () => ({ + AbortError: class AbortError extends Error { + constructor(message?: string) { super(message); this.name = 'AbortError' } + }, + isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError', +}) +mock.module('src/utils/errors.js', _abortMock) +mock.module('src/utils/errors', _abortMock) + +describe('ExaSearchAdapter.search', () => { + const createAdapter = async () => { + const { ExaSearchAdapter } = await import('../adapters/exaAdapter') + return new ExaSearchAdapter() + } + + // Exa MCP returns SSE lines like: data: {"result":{"content":[{"type":"text","text":"..."}]}} + const buildSseResponse = (text: string) => `data: ${JSON.stringify({ result: { content: [{ type: 'text', text }] } })}\n` + + const STRUCTURED_TEXT = [ + 'Title: Example Result 1', + 'URL: https://example.com/page1', + 'Content: This is the content snippet for page 1.', + '', + '---', + '', + 'Title: Example Result 2', + 'URL: https://example.com/page2', + 'Content: This is the content snippet for page 2.', + ].join('\n') + + afterEach(() => { + mock.restore() + }) + + test('parses structured Title/URL/Content blocks from SSE response', async () => { + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test query', {}) + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + title: 'Example Result 1', + url: 'https://example.com/page1', + snippet: 'This is the content snippet for page 1.', + }) + expect(results[1]).toEqual({ + title: 'Example Result 2', + url: 'https://example.com/page2', + snippet: 'This is the content snippet for page 2.', + }) + }) + + test('parses markdown link fallback when no structured blocks', async () => { + const markdownText = '- [React Docs](https://react.dev/docs)\n- [React Hooks](https://react.dev/hooks)' + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(markdownText) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('react', {}) + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + title: 'React Docs', + url: 'https://react.dev/docs', + snippet: undefined, + }) + expect(results[1].url).toBe('https://react.dev/hooks') + }) + + test('parses plain URL fallback', async () => { + const plainUrlText = 'https://example.com/page1\nhttps://example.com/page2' + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(plainUrlText) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test', {}) + + expect(results).toHaveLength(2) + expect(results[0].url).toBe('https://example.com/page1') + }) + + test('returns empty array for empty response', async () => { + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: '' })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test', {}) + + expect(results).toHaveLength(0) + }) + + test('parses direct JSON response (non-SSE fallback)', async () => { + const jsonResponse = JSON.stringify({ + result: { content: [{ type: 'text', text: STRUCTURED_TEXT }] }, + }) + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: jsonResponse })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test', {}) + + expect(results).toHaveLength(2) + expect(results[0].url).toBe('https://example.com/page1') + }) + + test('calls onProgress with query_update and search_results_received', async () => { + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })), + isCancel: () => false, + }, + })) + + const progressCalls: any[] = [] + const onProgress = (p: any) => progressCalls.push(p) + + const adapter = await createAdapter() + await adapter.search('test', { onProgress }) + + expect(progressCalls).toHaveLength(2) + expect(progressCalls[0]).toEqual({ type: 'query_update', query: 'test' }) + expect(progressCalls[1]).toEqual({ + type: 'search_results_received', + resultCount: 2, + query: 'test', + }) + }) + + test('filters results by allowedDomains', async () => { + const mixedText = [ + 'Title: Allowed', + 'URL: https://allowed.com/a', + '---', + 'Title: Blocked', + 'URL: https://blocked.com/b', + ].join('\n') + + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test', { allowedDomains: ['allowed.com'] }) + + expect(results).toHaveLength(1) + expect(results[0].url).toBe('https://allowed.com/a') + }) + + test('filters results by blockedDomains', async () => { + const mixedText = [ + 'Title: Good', + 'URL: https://good.com/a', + '---', + 'Title: Spam', + 'URL: https://spam.com/b', + ].join('\n') + + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test', { blockedDomains: ['spam.com'] }) + + expect(results).toHaveLength(1) + expect(results[0].url).toBe('https://good.com/a') + }) + + test('filters subdomains with allowedDomains', async () => { + const text = [ + 'Title: Subdomain', + 'URL: https://docs.example.com/page', + '---', + 'Title: Other', + 'URL: https://other.com/page', + ].join('\n') + + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(text) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const results = await adapter.search('test', { allowedDomains: ['example.com'] }) + + expect(results).toHaveLength(1) + expect(results[0].url).toBe('https://docs.example.com/page') + }) + + test('throws AbortError when signal is already aborted', async () => { + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + const controller = new AbortController() + controller.abort() + + const { AbortError } = await import('src/utils/errors') + await expect( + adapter.search('test', { signal: controller.signal }), + ).rejects.toThrow(AbortError) + }) + + test('re-throws non-abort axios errors', async () => { + const networkError = new Error('Network error') + mock.module('axios', () => ({ + default: { + post: mock(() => Promise.reject(networkError)), + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + await expect(adapter.search('test', {})).rejects.toThrow('Network error') + }) + + test('sends correct MCP request payload to Exa endpoint', async () => { + const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })) + mock.module('axios', () => ({ + default: { + post: axiosPost, + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + await adapter.search('hello world', {}) + + expect(axiosPost.mock.calls).toHaveLength(1) + const [url, body, config] = (axiosPost.mock.calls as any[][])[0] + expect(url).toBe('https://mcp.exa.ai/mcp') + expect(body.jsonrpc).toBe('2.0') + expect(body.method).toBe('tools/call') + expect(body.params.name).toBe('web_search_exa') + expect(body.params.arguments.query).toBe('hello world') + expect(body.params.arguments.type).toBe('auto') + expect(body.params.arguments.numResults).toBe(8) + expect(body.params.arguments.livecrawl).toBe('fallback') + expect(body.params.arguments.contextMaxCharacters).toBe(10000) + expect(config.headers.Accept).toBe('application/json, text/event-stream') + }) + + test('passes custom search options to MCP request', async () => { + const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })) + mock.module('axios', () => ({ + default: { + post: axiosPost, + isCancel: () => false, + }, + })) + + const adapter = await createAdapter() + await adapter.search('test', { + numResults: 15, + livecrawl: 'preferred', + searchType: 'deep', + contextMaxCharacters: 20000, + }) + + const [, body] = (axiosPost.mock.calls as any[][])[0] + expect(body.params.arguments.numResults).toBe(15) + expect(body.params.arguments.livecrawl).toBe('preferred') + expect(body.params.arguments.type).toBe('deep') + expect(body.params.arguments.contextMaxCharacters).toBe(20000) + }) +}) diff --git a/packages/builtin-tools/src/tools/WebSearchTool/adapters/exaAdapter.ts b/packages/builtin-tools/src/tools/WebSearchTool/adapters/exaAdapter.ts new file mode 100644 index 000000000..4ebde5842 --- /dev/null +++ b/packages/builtin-tools/src/tools/WebSearchTool/adapters/exaAdapter.ts @@ -0,0 +1,200 @@ +/** + * Exa AI-based search adapter — uses MCP protocol to call Exa's web search API. + * + * Ported from kilocode's production-validated implementation (mcp-exa.ts + websearch.ts). + * Key improvements over previous version: + * - Passes through numResults/livecrawl/type/contextMaxCharacters from options + * - Cleaner SSE parsing matching kilocode's approach + * - Proper content snippet extraction from Exa responses + */ + +import axios from 'axios' +import { AbortError } from 'src/utils/errors.js' +import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js' + +const EXA_MCP_URL = 'https://mcp.exa.ai/mcp' +const FETCH_TIMEOUT_MS = 25_000 + +export class ExaSearchAdapter implements WebSearchAdapter { + async search( + query: string, + options: SearchOptions, + ): Promise { + const { signal, onProgress, allowedDomains, blockedDomains } = options + + if (signal?.aborted) { + throw new AbortError() + } + + onProgress?.({ type: 'query_update', query }) + + const abortController = new AbortController() + if (signal) { + signal.addEventListener('abort', () => abortController.abort(), { once: true }) + } + + // Use options to derive search params — matches kilocode websearch.ts defaults + const numResults = options.numResults ?? 8 + const livecrawl = options.livecrawl ?? 'fallback' + const searchType = options.searchType ?? 'auto' + const contextMaxCharacters = options.contextMaxCharacters ?? 10000 + + let responseText: string + try { + const response = await axios.post( + EXA_MCP_URL, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'web_search_exa', + arguments: { + query, + type: searchType, + numResults, + livecrawl, + contextMaxCharacters, + }, + }, + }, + { + signal: abortController.signal, + timeout: FETCH_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + responseType: 'text', + }, + ) + responseText = response.data as string + } catch (e) { + if (axios.isCancel(e) || abortController.signal.aborted) { + throw new AbortError() + } + throw e + } + + if (abortController.signal.aborted) { + throw new AbortError() + } + + const searchText = this.parseSse(responseText) + + if (abortController.signal.aborted) { + throw new AbortError() + } + + // Parse the Exa results from the text response + const results = this.parseResults(searchText) + + // Client-side domain filtering + const filteredResults = results.filter((r) => { + if (!r.url) return false + try { + const hostname = new URL(r.url).hostname + if (allowedDomains?.length && !allowedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) { + return false + } + if (blockedDomains?.length && blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) { + return false + } + } catch { + return false + } + return true + }) + + onProgress?.({ + type: 'search_results_received', + resultCount: filteredResults.length, + query, + }) + + return filteredResults + } + + private parseSse(body: string): string | undefined { + // SSE format: lines starting with "data: " containing JSON + // Matches kilocode mcp-exa.ts parseSse implementation + for (const line of body.split('\n')) { + if (!line.startsWith('data: ')) continue + const data = line.substring(6).trim() + if (!data || data === '[DONE]' || data === 'null') continue + + try { + const parsed = JSON.parse(data) + const content = parsed?.result?.content + if (Array.isArray(content) && content[0]?.text) { + return content[0].text + } + } catch { + // Continue to next line + } + } + + // Fallback: try parsing as direct JSON response (non-SSE) + try { + const parsed = JSON.parse(body) + const content = parsed?.result?.content + if (Array.isArray(content) && content[0]?.text) { + return content[0].text + } + } catch { + // Not JSON + } + + return undefined + } + + private parseResults(text: string | undefined): SearchResult[] { + if (!text) return [] + + const results: SearchResult[] = [] + + // Exa returns structured text with "Title:", "URL:", and "Content:" fields + // separated by "---" between entries + const blocks = text.split(/\n---\n/g) + + for (const block of blocks) { + const titleMatch = block.match(/^Title:\s*(.+)$/m) + const urlMatch = block.match(/^URL:\s*(https?:\/\/[^\s]+)$/m) + const contentMatch = block.match(/^Content:\s*([\s\S]+?)(?=\n(?:Title:|URL:|---)|$)/m) + + if (urlMatch) { + results.push({ + title: titleMatch?.[1]?.trim() ?? urlMatch[1], + url: urlMatch[1].trim(), + snippet: contentMatch?.[1]?.trim().slice(0, 300), + }) + } + } + + // Fallback: markdown links + if (results.length === 0) { + const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g + let match: RegExpExecArray | null + while ((match = markdownLinkRegex.exec(text)) !== null) { + results.push({ + title: match[1].trim(), + url: match[2].trim(), + }) + } + } + + // Fallback: plain URLs + if (results.length === 0) { + const urlRegex = /^https?:\/\/[^\s<>"\]]+/gm + let match: RegExpExecArray | null + while ((match = urlRegex.exec(text)) !== null) { + results.push({ + title: match[0], + url: match[0], + }) + } + } + + return results + } +} diff --git a/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts b/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts index 6500e8be6..fe8c6dfd1 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts @@ -7,6 +7,7 @@ import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js' import { ApiSearchAdapter } from './apiAdapter.js' import { BingSearchAdapter } from './bingAdapter.js' import { BraveSearchAdapter } from './braveAdapter.js' +import { ExaSearchAdapter } from './exaAdapter.js' import type { WebSearchAdapter } from './types.js' export type { @@ -17,16 +18,16 @@ export type { } from './types.js' let cachedAdapter: WebSearchAdapter | null = null -let cachedAdapterKey: 'api' | 'bing' | 'brave' | null = null +let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null export function createAdapter(): WebSearchAdapter { const envAdapter = process.env.WEB_SEARCH_ADAPTER const adapterKey = - envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave' + envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave' || envAdapter === 'exa' ? envAdapter : isFirstPartyAnthropicBaseUrl() ? 'api' - : 'bing' + : 'exa' if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter @@ -36,9 +37,14 @@ export function createAdapter(): WebSearchAdapter { return cachedAdapter } if (adapterKey === 'brave') { - cachedAdapter = new BraveSearchAdapter() - cachedAdapterKey = 'brave' - return cachedAdapter + cachedAdapter = new BraveSearchAdapter() + cachedAdapterKey = 'brave' + return cachedAdapter + } + if (adapterKey === 'exa') { + cachedAdapter = new ExaSearchAdapter() + cachedAdapterKey = 'exa' + return cachedAdapter } cachedAdapter = new BingSearchAdapter() diff --git a/packages/builtin-tools/src/tools/WebSearchTool/adapters/types.ts b/packages/builtin-tools/src/tools/WebSearchTool/adapters/types.ts index cd04762fb..a867c5d92 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/adapters/types.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/adapters/types.ts @@ -9,6 +9,14 @@ export interface SearchOptions { blockedDomains?: string[] signal?: AbortSignal onProgress?: (progress: SearchProgress) => void + /** Number of search results to return (default: 8) */ + numResults?: number + /** Live crawl mode (default: 'fallback') */ + livecrawl?: 'fallback' | 'preferred' + /** Search type (default: 'auto') */ + searchType?: 'auto' | 'fast' | 'deep' + /** Maximum characters for context string (default: 10000) */ + contextMaxCharacters?: number } export interface SearchProgress { From 7d4c4278c01fd67c78d01116cbf0e5c7fbfb009e Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 23 Apr 2026 18:47:31 +0800 Subject: [PATCH 04/33] =?UTF-8?q?fix:=20=E5=B0=86=20highlight.js=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=9D=99=E6=80=81=E5=AF=BC=E5=85=A5=E4=BB=A5?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=20Bun=20--compile=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cliHighlight.ts: 使用静态 import 替换 dynamic import('highlight.js'), 因为编译模式下模块解析指向内部 bunfs 路径导致无法找到 - color-diff-napi/src/index.ts: 同样改为静态导入,移除 createRequire 延迟加载 --- packages/color-diff-napi/src/index.ts | 33 +++++++++------------------ src/utils/cliHighlight.ts | 17 +++++++------- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/color-diff-napi/src/index.ts b/packages/color-diff-napi/src/index.ts index 9b662b6d1..692728e2a 100644 --- a/packages/color-diff-napi/src/index.ts +++ b/packages/color-diff-napi/src/index.ts @@ -17,32 +17,21 @@ * getSyntaxTheme always returns the default for the given Claude theme. */ -import { createRequire } from 'node:module' import { diffArrays } from 'diff' -import type * as hljsNamespace from 'highlight.js' +import hljs from 'highlight.js' import { basename, extname } from 'path' -// createRequire works in both Bun and Node.js ESM contexts. -// Needed because this package is "type": "module" but uses require() for -// lazy loading — bare require is not available in Node.js ESM. -const nodeRequire = createRequire(import.meta.url) - -// Lazy: defers loading highlight.js until first render. The full bundle -// registers 190+ language grammars at require time (~50MB, 100-200ms on -// macOS, several× that on Windows). With a top-level import, any caller -// chunk that reaches this module — including test/preload.ts via -// StructuredDiff.tsx → colorDiff.ts — pays that cost at module-eval time -// and carries the heap for the rest of the process. On Windows CI this -// pushed later tests in the same shard into GC-pause territory and a -// beforeEach/afterEach hook timeout (officialRegistry.test.ts, PR #24150). -// Same lazy pattern the NAPI wrapper used for dlopen. -type HLJSApi = typeof hljsNamespace.default +// Static import — createRequire(import.meta.url) fails in Bun --compile mode +// because the resolved path points to the internal bunfs binary path where +// node_modules cannot be found. A top-level import ensures the module is +// bundled and accessible at runtime. +type HLJSApi = typeof hljs let cachedHljs: HLJSApi | null = null -function hljs(): HLJSApi { +function hljsApi(): HLJSApi { if (cachedHljs) return cachedHljs - const mod = nodeRequire('highlight.js') // highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it // in .default; under node CJS the module IS the API. Check at runtime. + const mod = hljs as HLJSApi & { default?: HLJSApi } cachedHljs = 'default' in mod && mod.default ? mod.default : mod return cachedHljs! } @@ -441,9 +430,9 @@ function detectLanguage( // Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.) const stem = base.split('.')[0] ?? '' const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem] - if (byName && hljs().getLanguage(byName)) return byName + if (byName && hljsApi().getLanguage(byName)) return byName if (ext) { - const lang = hljs().getLanguage(ext) + const lang = hljsApi().getLanguage(ext) if (lang) return ext } // Shebang / first-line detection (strip UTF-8 BOM) @@ -525,7 +514,7 @@ function highlightLine( } let result try { - result = hljs().highlight(code, { + result = hljsApi().highlight(code, { language: state.lang, ignoreIllegals: true, }) diff --git a/src/utils/cliHighlight.ts b/src/utils/cliHighlight.ts index e87663c1f..3b248a045 100644 --- a/src/utils/cliHighlight.ts +++ b/src/utils/cliHighlight.ts @@ -1,11 +1,13 @@ // highlight.js's type defs carry `/// `. SSETransport, // mcp/client, ssh, dumpPrompts use DOM types (TextDecodeOptions, RequestInfo) -// that only typecheck because this file's `typeof import('highlight.js')` pulls -// lib.dom in. tsconfig has lib: ["ESNext"] only — fixing the actual DOM-type -// deps is a separate sweep; this ref preserves the status quo. +// that only typecheck because the hljs import below pulls lib.dom in. +// tsconfig has lib: ["ESNext"] only — this ref preserves the status quo. /// import { extname } from 'path' +// Static import — dynamic import('highlight.js') fails in Bun --compile mode +// because module resolution points to the internal bunfs binary path. +import hljs from 'highlight.js' export type CliHighlight = { highlight: typeof import('cli-highlight').highlight @@ -13,9 +15,6 @@ export type CliHighlight = { } // One promise shared by Fallback.tsx, markdown.ts, events.ts, getLanguageName. -// The highlight.js import piggybacks: cli-highlight has already pulled it into -// the module cache, so the second import() is a cache hit — no extra bytes -// faulted in. let cliHighlightPromise: Promise | undefined let loadedGetLanguage: ((name: string) => { name: string } | undefined) | undefined @@ -23,9 +22,9 @@ let loadedGetLanguage: ((name: string) => { name: string } | undefined) | undefi async function loadCliHighlight(): Promise { try { const cliHighlight = await import('cli-highlight') - // cache hit — cli-highlight already loaded highlight.js - const highlightJs = await import('highlight.js') - loadedGetLanguage = (highlightJs as { getLanguage?: typeof loadedGetLanguage }).getLanguage + // highlight.js CJS interop: `export =` wraps in .default under ESM + const hljsMod = hljs as { getLanguage?: typeof loadedGetLanguage; default?: typeof hljs } + loadedGetLanguage = hljsMod.getLanguage ?? hljsMod.default?.getLanguage return { highlight: cliHighlight.highlight, supportsLanguage: cliHighlight.supportsLanguage, From c3d63c8fe263beba225cebacddd0c6d79b338fa1 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 19:58:55 +0800 Subject: [PATCH 05/33] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=20release=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..e22adce4c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) + if [ "$PREV_TAG" = "$GITHUB_REF_NAME" ]; then + PREV_TAG="" + fi + + if [ -n "$PREV_TAG" ]; then + COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges) + else + COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20) + fi + + { + echo "commits<> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body: | + ## What's Changed + + ${{ steps.changelog.outputs.commits }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.ref_name }}^...${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} From 7a3fdf6e67965af3c94286af93365f08a9d167f1 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 20:09:06 +0800 Subject: [PATCH 06/33] chore: 1.9.0 --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 756a940c3..4ef159deb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-best", - "version": "1.8.0", + "version": "1.9.0", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", "author": "claude-code-best ", @@ -53,6 +53,10 @@ "format": "biome format --write src/", "prepare": "git config core.hooksPath .githooks", "test": "bun test", + "test:production": "bun run scripts/production-test.ts", + "test:production:offline": "bun run scripts/production-test.ts --offline", + "test:production:verbose": "bun run scripts/production-test.ts --verbose", + "test:production:bun": "bun run scripts/production-test.ts --bun", "check:bundle": "bun run scripts/check-bundle-integrity.ts", "check:unused": "knip-bun", "health": "bun run scripts/health-check.ts", From 299953b0eedbca59beff88830f173365129220ce Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 20:12:47 +0800 Subject: [PATCH 07/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20cliHighlight?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E4=B8=8D=E5=85=BC=E5=AE=B9=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadedGetLanguage 返回类型中 name 字段改为可选,匹配 highlight.js Language 类型中 name 为 string | undefined 的定义。 Co-Authored-By: Claude Opus 4.7 --- src/utils/cliHighlight.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/cliHighlight.ts b/src/utils/cliHighlight.ts index 3b248a045..9971899ba 100644 --- a/src/utils/cliHighlight.ts +++ b/src/utils/cliHighlight.ts @@ -17,7 +17,7 @@ export type CliHighlight = { // One promise shared by Fallback.tsx, markdown.ts, events.ts, getLanguageName. let cliHighlightPromise: Promise | undefined -let loadedGetLanguage: ((name: string) => { name: string } | undefined) | undefined +let loadedGetLanguage: ((name: string) => { name?: string } | undefined) | undefined async function loadCliHighlight(): Promise { try { From 85e5a8cffb9a54ac50360ad915c82fc93ac20e1b Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 20:17:46 +0800 Subject: [PATCH 08/33] =?UTF-8?q?chore:=20=E8=B4=A1=E7=8C=AE=E8=80=85?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=AF=8F=E5=91=A8=E5=AE=9A=E6=97=B6=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 push 触发,仅保留每周一 schedule 触发。 Co-Authored-By: Claude Opus 4.7 --- .github/workflows/update-contributors.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml index 40985fe2a..8d6669550 100644 --- a/.github/workflows/update-contributors.yml +++ b/.github/workflows/update-contributors.yml @@ -1,11 +1,8 @@ name: Update Contributors on: - push: - branches: - - main schedule: - - cron: '0 0 * * *' # 每天更新一次 + - cron: '0 0 * * 1' # 每周一更新一次 permissions: contents: write From 9624f880e0acb220718bdae4e6ddef2e3305d049 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 21:52:16 +0800 Subject: [PATCH 09/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=20Anthropic=20base=20URL=20=E5=BA=94?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20ExaSearchAdapter=20=E8=80=8C=E9=9D=9E=20Bi?= =?UTF-8?q?ngSearchAdapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../builtin-tools/src/tools/WebSearchTool/adapters/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts b/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts index f1ef10bc9..9e3310e0b 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/adapters/index.ts @@ -47,7 +47,7 @@ export function createAdapter(): WebSearchAdapter { ? 'bing' : isFirstPartyAnthropicBaseUrl() ? 'api' - : 'bing' + : 'exa' if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter From cfe1552ec91405e40c9284764e466768ee910fce Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 22:42:33 +0800 Subject: [PATCH 10/33] =?UTF-8?q?ci:=20=E7=BB=9F=E4=B8=80=20typecheck=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=B9=B6=E6=B7=BB=E5=8A=A0=20npm=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-npm.yml | 53 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-npm.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaff14ef6..226817a8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: run: bun install --frozen-lockfile - name: Type check - run: bunx tsc --noEmit + run: bun run typecheck - name: Test with Coverage run: | diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 000000000..3d84b0c28 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,53 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build:vite + + - name: Type check + run: bun run typecheck + + - name: Run tests + run: bun test + + - name: Publish to npm + run: | + VERSION="${GITHUB_REF_NAME#v}" + TAG="latest" + if [[ "$VERSION" == *"beta"* ]]; then + TAG="beta" + elif [[ "$VERSION" == *"alpha"* ]]; then + TAG="alpha" + elif [[ "$VERSION" == *"rc"* ]]; then + TAG="rc" + fi + npm publish --tag "$TAG" --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From a92af99448b87a8a9cfcd6df4d199ee4be5019e0 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 22:44:02 +0800 Subject: [PATCH 11/33] =?UTF-8?q?ci:=20=E6=B7=BB=E5=8A=A0=20GitHub=20Relea?= =?UTF-8?q?se=20=E5=92=8C=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=20changelog?= =?UTF-8?q?=20=E5=88=B0=E5=8F=91=E5=B8=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-npm.yml | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 3d84b0c28..3b1111ebe 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -6,8 +6,9 @@ on: - 'v*' permissions: - contents: read + contents: write packages: write + id-token: write jobs: publish: @@ -51,3 +52,36 @@ jobs: npm publish --tag "$TAG" --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) + if [ "$PREV_TAG" = "$GITHUB_REF_NAME" ]; then + PREV_TAG="" + fi + + if [ -n "$PREV_TAG" ]; then + COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges) + else + COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20) + fi + + { + echo "commits<> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body: | + ## What's Changed + + ${{ steps.changelog.outputs.commits }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.ref_name }}^...${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} From 047634afe6dc03a7407f461f9a7689e2eac39e87 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 22:45:53 +0800 Subject: [PATCH 12/33] =?UTF-8?q?ci:=20=E5=88=A0=E9=99=A4=E5=86=97?= =?UTF-8?q?=E4=BD=99=20release=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-npm.yml | 12 +------- .github/workflows/release.yml | 48 ------------------------------- 2 files changed, 1 insertion(+), 59 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 3b1111ebe..8710c00a1 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -39,17 +39,7 @@ jobs: run: bun test - name: Publish to npm - run: | - VERSION="${GITHUB_REF_NAME#v}" - TAG="latest" - if [[ "$VERSION" == *"beta"* ]]; then - TAG="beta" - elif [[ "$VERSION" == *"alpha"* ]]; then - TAG="alpha" - elif [[ "$VERSION" == *"rc"* ]]; then - TAG="rc" - fi - npm publish --tag "$TAG" --access public + run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e22adce4c..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Generate changelog - id: changelog - run: | - PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) - if [ "$PREV_TAG" = "$GITHUB_REF_NAME" ]; then - PREV_TAG="" - fi - - if [ -n "$PREV_TAG" ]; then - COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges) - else - COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20) - fi - - { - echo "commits<> "$GITHUB_OUTPUT" - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: ${{ github.ref_name }} - body: | - ## What's Changed - - ${{ steps.changelog.outputs.commits }} - - **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.ref_name }}^...${{ github.ref_name }} - draft: false - prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} From 792777d68cd07a1ae0c0370c8f3f123c98d5fbcd Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 22:46:51 +0800 Subject: [PATCH 13/33] chore: 1.9.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4ef159deb..f60a44403 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-best", - "version": "1.9.0", + "version": "1.9.1", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", "author": "claude-code-best ", From 5bc12b00b2de69630f40746dc00d3acb6a660fec Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 22:55:27 +0800 Subject: [PATCH 14/33] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-npm.yml | 30 ++++++++++++++++++------------ package.json | 1 - 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 8710c00a1..b8bed4073 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -4,6 +4,12 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + version: + description: '版本号 (例如: v1.9.0)' + required: true + type: string permissions: contents: write @@ -15,11 +21,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 with: - node-version: '20.x' - registry-url: 'https://registry.npmjs.org' + ref: ${{ github.event.inputs.version || github.ref }} + + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -46,13 +54,11 @@ jobs: - name: Generate changelog id: changelog run: | - PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) - if [ "$PREV_TAG" = "$GITHUB_REF_NAME" ]; then - PREV_TAG="" - fi + VERSION="${{ github.event.inputs.version || github.ref_name }}" + PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${VERSION#v}$" | head -1) if [ -n "$PREV_TAG" ]; then - COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges) + COMMITS=$(git log "${PREV_TAG}..${VERSION}" --pretty=format:"- %s (%h)" --no-merges) else COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20) fi @@ -66,12 +72,12 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - name: ${{ github.ref_name }} + name: ${{ github.event.inputs.version || github.ref_name }} body: | ## What's Changed ${{ steps.changelog.outputs.commits }} - **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.ref_name }}^...${{ github.ref_name }} + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.event.inputs.version || github.ref_name }}^...${{ github.event.inputs.version || github.ref_name }} draft: false - prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} + prerelease: ${{ contains(github.event.inputs.version || github.ref_name, 'rc') || contains(github.event.inputs.version || github.ref_name, 'beta') || contains(github.event.inputs.version || github.ref_name, 'alpha') }} diff --git a/package.json b/package.json index f60a44403..c993761ad 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "lint": "biome lint src/", "lint:fix": "biome lint --fix src/", "format": "biome format --write src/", - "prepare": "git config core.hooksPath .githooks", "test": "bun test", "test:production": "bun run scripts/production-test.ts", "test:production:offline": "bun run scripts/production-test.ts --offline", From fc7a85f5c7566cfb3e57e78bdbe25e0ac9ba75cf Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 23:04:18 +0800 Subject: [PATCH 15/33] chore: 1.9.2 --- .github/workflows/publish-npm.yml | 4 ---- package.json | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index b8bed4073..42bfc42af 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -36,10 +36,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - - name: Build - run: bun run build:vite - - name: Type check run: bun run typecheck diff --git a/package.json b/package.json index c993761ad..5a49ec135 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-best", - "version": "1.9.1", + "version": "1.9.2", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", "author": "claude-code-best ", @@ -47,7 +47,7 @@ "build:bun": "bun run build.ts", "dev": "bun run scripts/dev.ts", "dev:inspect": "bun run scripts/dev-debug.ts", - "prepublishOnly": "bun run build", + "prepublishOnly": "bun run build:vite", "lint": "biome lint src/", "lint:fix": "biome lint --fix src/", "format": "biome format --write src/", From ca1c87f460d38fb3c0096d710ef849897aa36985 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 23:21:38 +0800 Subject: [PATCH 16/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20usePipeIpc=20?= =?UTF-8?q?=E4=B8=AD=20require=20=E8=BF=94=E5=9B=9E=20undefined=20?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E5=90=AF=E5=8A=A8=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 lazy require() 调用全部替换为静态 import,解决构建产物中 模块加载时序问题导致的 'undefined is not an object' 错误。 Co-Authored-By: Claude Opus 4.7 --- src/hooks/usePipeIpc.ts | 140 ++++++++++++++++++---------------------- 1 file changed, 64 insertions(+), 76 deletions(-) diff --git a/src/hooks/usePipeIpc.ts b/src/hooks/usePipeIpc.ts index 0f131a196..30d8a5b80 100644 --- a/src/hooks/usePipeIpc.ts +++ b/src/hooks/usePipeIpc.ts @@ -12,29 +12,19 @@ */ import { feature } from 'bun:bundle' import { useEffect } from 'react' +import * as pt from '../utils/pipeTransport.js' +import * as pr from '../utils/pipeRegistry.js' +import * as mm from './useMasterMonitor.js' +import { getSessionId as _getSessionId } from '../bootstrap/state.js' +import * as lb from '../utils/lanBeacon.js' +import * as pp from '../utils/pipePermissionRelay.js' +import * as osm from 'os' import type { PipeMessage, PipeServer, PipeIpcState, } from '../utils/pipeTransport.js' -// Lazy-loaded module accessors (cached by Bun/Node require) -/* eslint-disable @typescript-eslint/no-require-imports */ -const pt = () => - require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js') -const pr = () => - require('../utils/pipeRegistry.js') as typeof import('../utils/pipeRegistry.js') -const mm = () => - require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js') -const bs = () => - require('../bootstrap/state.js') as typeof import('../bootstrap/state.js') -const lb = () => - require('../utils/lanBeacon.js') as typeof import('../utils/lanBeacon.js') -const pp = () => - require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js') -const osm = () => require('os') as typeof import('os') -/* eslint-enable @typescript-eslint/no-require-imports */ - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -54,9 +44,9 @@ export type UsePipeIpcOptions = { // --------------------------------------------------------------------------- function removeDeadSlave(slaveName: string, store: StoreApi): void { - mm().removeSlaveClient(slaveName) + mm.removeSlaveClient(slaveName) store.setState((prev: any) => { - const pipeIpc = pt().getPipeIpc(prev) + const pipeIpc = pt.getPipeIpc(prev) const { [slaveName]: _removed, ...remainingSlaves } = pipeIpc.slaves return { ...prev, @@ -108,7 +98,7 @@ function refreshDiscoveredPipes( // Include LAN beacon peers so they aren't wiped out by heartbeat let lanDiscovered: typeof freshDiscovered = [] if (feature('LAN_PIPES')) { - const beacon = lb().getLanBeacon() + const beacon = lb.getLanBeacon() if (beacon) { const localNames = new Set(freshDiscovered.map(p => p.pipeName)) localNames.add(pipeName) @@ -131,7 +121,7 @@ function refreshDiscoveredPipes( const allDiscovered = [...freshDiscovered, ...lanDiscovered] // Only update state if the list actually changed - const prev = pt().getPipeIpc(store.getState()) + const prev = pt.getPipeIpc(store.getState()) const prevNames = (prev.discoveredPipes ?? []) .map((p: any) => p.pipeName) .join(',') @@ -139,7 +129,7 @@ function refreshDiscoveredPipes( if (prevNames === newNames) return store.setState((prev: any) => { - const pipeIpc = pt().getPipeIpc(prev) + const pipeIpc = pt.getPipeIpc(prev) const aliveNames = new Set(allDiscovered.map(pipe => pipe.pipeName)) return { ...prev, @@ -174,8 +164,8 @@ function registerMessageHandlers( server.onMessage((msg: PipeMessage, reply) => { if (msg.type !== 'attach_request') return const state = store.getState() - const currentPipeState = pt().getPipeIpc(state) - if (pt().isPipeControlled(currentPipeState)) { + const currentPipeState = pt.getPipeIpc(state) + if (pt.isPipeControlled(currentPipeState)) { reply({ type: 'attach_reject', data: 'Already controlled' }) return } @@ -192,7 +182,7 @@ function registerMessageHandlers( const clients = Array.from((server as any).clients as Set) const masterSocket = clients[clients.length - 1] - pp().setPipeRelay((relayMsg: any) => { + pp.setPipeRelay((relayMsg: any) => { if (masterSocket && !masterSocket.destroyed) { relayMsg.from = relayMsg.from ?? pipeName relayMsg.ts = relayMsg.ts ?? new Date().toISOString() @@ -203,9 +193,9 @@ function registerMessageHandlers( store.setState((prev: any) => ({ ...prev, pipeIpc: { - ...pt().getPipeIpc(prev), + ...pt.getPipeIpc(prev), role: 'sub', - displayRole: pt().getPipeDisplayRole(pt().getPipeIpc(prev)), + displayRole: pt.getPipeDisplayRole(pt.getPipeIpc(prev)), attachedBy: msg.from ?? 'unknown', }, })) @@ -230,8 +220,7 @@ function registerMessageHandlers( server.onMessage((msg: PipeMessage, _reply) => { if (msg.type !== 'permission_response' && msg.type !== 'permission_cancel') return - const { resolvePipePermissionResponse, cancelPipePermissionRequest } = - require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js') + const { resolvePipePermissionResponse, cancelPipePermissionRequest } = pp try { const payload = msg.data ? JSON.parse(msg.data) : undefined @@ -249,28 +238,27 @@ function registerMessageHandlers( // Handle relay mute/unmute from master server.onMessage((msg: PipeMessage, _reply) => { if (msg.type === 'relay_mute') { - pp().setRelayMuted(true) + pp.setRelayMuted(true) } else if (msg.type === 'relay_unmute') { - pp().setRelayMuted(false) + pp.setRelayMuted(false) } }) // Handle detach server.onMessage((msg: PipeMessage, _reply) => { if (msg.type !== 'detach') return - const { clearPendingPipePermissions } = - require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js') + const { clearPendingPipePermissions } = pp clearPendingPipePermissions('Pipe detached before permission was resolved.') - pp().setPipeRelay(null) + pp.setPipeRelay(null) store.setState((prev: any) => ({ ...prev, pipeIpc: (() => { - const pipeIpc = pt().getPipeIpc(prev) + const pipeIpc = pt.getPipeIpc(prev) const nextRole = pipeIpc.subIndex != null ? 'sub' : 'main' const nextPipeState = { ...pipeIpc, role: nextRole, attachedBy: null } return { ...nextPipeState, - displayRole: pt().getPipeDisplayRole(nextPipeState as PipeIpcState), + displayRole: pt.getPipeDisplayRole(nextPipeState as PipeIpcState), } })(), })) @@ -289,11 +277,11 @@ function runMainHeartbeat( ): void { void (async () => { try { - await pr().cleanupStaleEntries() - const aliveSubs = await pr().getAliveSubs() + await pr.cleanupStaleEntries() + const aliveSubs = await pr.getAliveSubs() refreshDiscoveredPipes(pipeName, aliveSubs, store) - const connectedSlaves = mm().getAllSlaveClients() + const connectedSlaves = mm.getAllSlaveClients() const aliveSubNames = new Set(aliveSubs.map(sub => sub.pipeName)) // Build unified attach target list: local subs + LAN peers @@ -307,7 +295,7 @@ function runMainHeartbeat( // Add LAN peers as attach targets if (feature('LAN_PIPES')) { - const beacon = lb().getLanBeacon() + const beacon = lb.getLanBeacon() if (beacon) { const localNames = new Set(attachTargets.map(t => t.pipeName)) localNames.add(pipeName) @@ -323,7 +311,7 @@ function runMainHeartbeat( } } - const currentPipeState = pt().getPipeIpc(store.getState()) + const currentPipeState = pt.getPipeIpc(store.getState()) for (const target of attachTargets) { if (target.pipeName === pipeName) continue @@ -331,7 +319,7 @@ function runMainHeartbeat( try { const myName = currentPipeState.serverName ?? pipeName - const client = await pt().connectToPipe( + const client = await pt.connectToPipe( target.pipeName, myName, 3000, @@ -362,7 +350,7 @@ function runMainHeartbeat( }) if (attached && !disposed.current) { - mm().addSlaveClient(target.pipeName, client) + mm.addSlaveClient(target.pipeName, client) client.on('disconnect', () => { removeDeadSlave(target.pipeName, store) @@ -371,11 +359,11 @@ function runMainHeartbeat( store.setState((prev: any) => ({ ...prev, pipeIpc: { - ...pt().getPipeIpc(prev), + ...pt.getPipeIpc(prev), role: 'master', displayRole: 'master', slaves: { - ...pt().getPipeIpc(prev).slaves, + ...pt.getPipeIpc(prev).slaves, [target.pipeName]: { name: target.pipeName, connectedAt: new Date().toISOString(), @@ -395,7 +383,7 @@ function runMainHeartbeat( // Clean up slaves that are no longer alive let lanPeerNames: Set | null = null if (feature('LAN_PIPES')) { - const beacon = lb().getLanBeacon() + const beacon = lb.getLanBeacon() if (beacon) { lanPeerNames = new Set(beacon.getPeers().keys()) } @@ -422,28 +410,28 @@ function runSubHeartbeat( ): void { void (async () => { try { - const mainAlive = await pr().isMainAlive() + const mainAlive = await pr.isMainAlive() if (!mainAlive && !disposed.current) { - const registry = await pr().readRegistry() - const isSameMachine = pr().isMainMachine(machineId, registry) + const registry = await pr.readRegistry() + const isSameMachine = pr.isMainMachine(machineId, registry) if (isSameMachine) { - await pr().registerAsMain(entry) + await pr.registerAsMain(entry) } else { - await pr().revertToIndependent(pipeName) + await pr.revertToIndependent(pipeName) } store.setState((prev: any) => ({ ...prev, pipeIpc: { - ...pt().getPipeIpc(prev), + ...pt.getPipeIpc(prev), role: 'main', subIndex: null, displayRole: 'main', attachedBy: null, }, })) - pp().setPipeRelay(null) + pp.setPipeRelay(null) } } catch { // Heartbeat check error — non-fatal @@ -462,7 +450,9 @@ export function usePipeIpc({ if (!feature('UDS_INBOX')) return useEffect(() => { - const pipeName = `cli-${bs().getSessionId().slice(0, 8)}` + const sessionId = _getSessionId() + if (!sessionId) return + const pipeName = `cli-${sessionId.slice(0, 8)}` const disposed = { current: false } let heartbeatTimer: ReturnType | null = null let heartbeatBusy = false @@ -471,11 +461,11 @@ export function usePipeIpc({ void (async () => { try { // --- Phase 1: Role determination --- - const machId = await pr().getMachineId() - const mac = pr().getMacAddress() - const localIp = pt().getLocalIp() - const host = osm().hostname() - const roleResult = await pr().determineRole(machId) + const machId = await pr.getMachineId() + const mac = pr.getMacAddress() + const localIp = pt.getLocalIp() + const host = osm.hostname() + const roleResult = await pr.determineRole(machId) const entry = { id: pipeName, @@ -493,29 +483,29 @@ export function usePipeIpc({ let displayRole = 'main' if (roleResult.role === 'main' || roleResult.role === 'main-recover') { - await pr().registerAsMain(entry) + await pr.registerAsMain(entry) } else { subIndex = roleResult.subIndex - await pr().registerAsSub(entry, subIndex) + await pr.registerAsSub(entry, subIndex) initialRole = 'sub' displayRole = `sub-${subIndex}` } // --- Phase 2: Server creation --- - const server = await pt().createPipeServer( + const server = await pt.createPipeServer( pipeName, feature('LAN_PIPES') ? { enableTcp: true, tcpPort: 0 } : undefined, ) pipeServer = server if (disposed.current) { await server.close() - await pr().unregister(pipeName) + await pr.unregister(pipeName) return } // --- Phase 3: LAN beacon --- if (feature('LAN_PIPES') && server.tcpAddress) { - const beacon = new (lb().LanBeacon)({ + const beacon = new (lb.LanBeacon)({ pipeName, machineId: machId, hostname: host, @@ -524,7 +514,7 @@ export function usePipeIpc({ role: initialRole, }) beacon.start() - lb().setLanBeacon(beacon) + lb.setLanBeacon(beacon) const entryWithTcp = { ...entry, @@ -532,9 +522,9 @@ export function usePipeIpc({ lanVisible: true, } if (initialRole === 'main') { - await pr().registerAsMain(entryWithTcp) + await pr.registerAsMain(entryWithTcp) } else if (subIndex != null) { - await pr().registerAsSub(entryWithTcp, subIndex) + await pr.registerAsSub(entryWithTcp, subIndex) } } @@ -542,7 +532,7 @@ export function usePipeIpc({ store.setState((prev: any) => ({ ...prev, pipeIpc: { - ...pt().getPipeIpc(prev), + ...pt.getPipeIpc(prev), serverName: pipeName, role: initialRole, subIndex, @@ -570,7 +560,7 @@ export function usePipeIpc({ if (disposed.current || heartbeatBusy) return heartbeatBusy = true - const currentPipeState = pt().getPipeIpc(store.getState()) + const currentPipeState = pt.getPipeIpc(store.getState()) if ( currentPipeState.role === 'main' || @@ -600,7 +590,7 @@ export function usePipeIpc({ } // Send detach to all slaves - const allClients = mm().getAllSlaveClients() + const allClients = mm.getAllSlaveClients() for (const [name, client] of allClients.entries()) { try { client.send({ type: 'detach' }) @@ -610,23 +600,21 @@ export function usePipeIpc({ } // Stop LAN beacon - const beacon = lb().getLanBeacon() + const beacon = lb.getLanBeacon() if (beacon) { try { beacon.stop() } catch {} - lb().setLanBeacon(null) + lb.setLanBeacon(null) } // Unregister + close server - void pr() - .unregister(pipeName) - .catch(() => {}) + pr.unregister(pipeName).catch(() => {}) if (pipeServer) { void pipeServer.close().catch(() => {}) pipeServer = null } - pp().setPipeRelay(null) + pp.setPipeRelay(null) } }, []) // eslint-disable-line react-hooks/exhaustive-deps } From 7a0dd3057e181b4ab229386cbde1f0103b887cd4 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 23 Apr 2026 23:21:43 +0800 Subject: [PATCH 17/33] chore: 1.9.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a49ec135..ad4980acc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-best", - "version": "1.9.2", + "version": "1.9.3", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", "author": "claude-code-best ", From 0b304730d802ed63b6c81c7a695fc11378b25277 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 09:26:59 +0800 Subject: [PATCH 18/33] =?UTF-8?q?docs:=20=E4=B8=BA=20DEFAULT=5FBUILD=5FFEA?= =?UTF-8?q?TURES=20=E6=AF=8F=E4=B8=AA=20feature=20flag=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8A=9F=E8=83=BD=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- scripts/defines.ts | 82 ++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/scripts/defines.ts b/scripts/defines.ts index 09587960a..388616597 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -27,51 +27,49 @@ export function getMacroDefines(): Record { * - scripts/dev.ts (bun run dev) */ export const DEFAULT_BUILD_FEATURES = [ - 'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE', - 'AGENT_TRIGGERS_REMOTE', - 'CHICAGO_MCP', - 'VOICE_MODE', - 'SHOT_STATS', - 'PROMPT_CACHE_BREAK_DETECTION', - 'TOKEN_BUDGET', + 'BUDDY', // 陪伴宠物角色(Squirtle Waddles) + 'TRANSCRIPT_CLASSIFIER', // 对话分类器,用于标注会话类型 + 'BRIDGE_MODE', // Remote Control / Bridge 模式,远程控制会话 + 'AGENT_TRIGGERS_REMOTE', // Agent 触发远程会话连接 + 'CHICAGO_MCP', // Chicago MCP 集成(内部代号) + 'VOICE_MODE', // Push-to-Talk 语音输入模式 + 'SHOT_STATS', // 单次请求统计信息收集 + 'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破 + 'TOKEN_BUDGET', // Token 预算管理与控制 // P0: local features - 'AGENT_TRIGGERS', - 'ULTRATHINK', - 'BUILTIN_EXPLORE_PLAN_AGENTS', - 'LODESTONE', - // P1: API-dependent features - 'EXTRACT_MEMORIES', - 'VERIFICATION_AGENT', - 'KAIROS_BRIEF', - 'AWAY_SUMMARY', - 'ULTRAPLAN', - // P2: daemon + remote control server - 'DAEMON', - // ACP (Agent Client Protocol) agent mode - 'ACP', - // PR-package restored features - 'WORKFLOW_SCRIPTS', - 'HISTORY_SNIP', - 'CONTEXT_COLLAPSE', - 'MONITOR_TOOL', - 'FORK_SUBAGENT', - 'UDS_INBOX', - 'KAIROS', - 'COORDINATOR_MODE', - 'LAN_PIPES', - 'BG_SESSIONS', - 'TEMPLATES', - // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 + 'AGENT_TRIGGERS', // 本地 Agent 触发器(工具调用时启动子代理) + 'ULTRATHINK', // 超深度思考模式,增加推理链长度 + 'BUILTIN_EXPLORE_PLAN_AGENTS', // 内置 Explore/Plan 子代理类型 + 'LODESTONE', // 上下文锚点,优化长对话的相关性检索 + 'EXTRACT_MEMORIES', // 自动从对话中提取并持久化记忆 + 'VERIFICATION_AGENT', // 验证代理,任务完成后自动校验结果 + 'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态) + 'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结) + 'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划 + 'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker + 'ACP', // ACP 代理协议,支持外部 agent 接入 + 'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD) + 'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口 + 'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息 + 'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出 + 'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务 + 'UDS_INBOX', // Unix Domain Socket 收件箱,跨会话消息传递 + 'KAIROS', // Kairos 定时任务系统核心 + 'COORDINATOR_MODE', // 协调者模式,多代理团队任务调度 + 'LAN_PIPES', // 局域网管道,LAN 设备间通信 + 'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill) + 'TEMPLATES', // 模板任务(new/list/reply 子命令) + // 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性) // API content block types - 'CONNECTOR_TEXT', + 'CONNECTOR_TEXT', // Connector 文本块类型,扩展 API 内容格式 // Attribution tracking - 'COMMIT_ATTRIBUTION', + 'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献) // Server mode (claude server / claude open) - 'DIRECT_CONNECT', + 'DIRECT_CONNECT', // 直连模式(claude server / claude open) // Skill search - 'EXPERIMENTAL_SKILL_SEARCH', - // P3: poor mode (disable extract_memories + prompt_suggestion) - 'POOR', - // Team Memory (shared memory files between agent teammates) - 'TEAMMEM', + 'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills) + // P3: poor mode + 'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗 + // Team Memory + 'TEAMMEM', // 团队记忆,代理队友间共享记忆文件 ]as const; From 4dcbaf1e6656f9602377a9c6c48e07cfa1e8627a Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 09:59:23 +0800 Subject: [PATCH 19/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ACP=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=20messageSelector=20require=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=AF=BC=E8=87=B4=20submitMessage=20?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP 模式不加载完整的 React/Ink UI 组件,导致 require('src/components/MessageSelector.js') 返回 undefined。添加 try-catch 和 optional chaining fallback。 Co-Authored-By: Claude Opus 4.7 --- src/QueryEngine.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index feeb37272..db5732489 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -86,9 +86,13 @@ import { // Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time /* eslint-disable @typescript-eslint/no-require-imports */ -const messageSelector = - (): typeof import('src/components/MessageSelector.js') => - require('src/components/MessageSelector.js') +const messageSelector = (): typeof import('src/components/MessageSelector.js') | null => { + try { + return require('src/components/MessageSelector.js') + } catch { + return null + } +} import { localCommandOutputToSDKAssistantMessage, @@ -466,12 +470,13 @@ export class QueryEngine { } // Filter messages that should be acknowledged after transcript + const _selector = messageSelector() const replayableMessages = messagesFromUserInput.filter( msg => (msg.type === 'user' && !msg.isMeta && // Skip synthetic caveat messages !msg.toolUseResult && // Skip tool results (they'll be acked from query) - messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.) + (_selector?.selectableUserMessagesFilter(msg) ?? true)) || // Skip non-user-authored messages (task notifications, etc.) (msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries ) const messagesToAck = replayUserMessages ? replayableMessages : [] @@ -643,8 +648,10 @@ export class QueryEngine { } if (fileHistoryEnabled() && persistSession) { + const _sel = messageSelector() + const _filter = _sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true) messagesFromUserInput - .filter(messageSelector().selectableUserMessagesFilter) + .filter(_filter) .forEach(message => { void fileHistoryMakeSnapshot( (updater: (prev: FileHistoryState) => FileHistoryState) => { From f2dd5142b36c053d799a1a1328cb888ce309d756 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 10:01:05 +0800 Subject: [PATCH 20/33] =?UTF-8?q?refactor:=20=E8=A7=A3=E8=80=A6=20BRIDGE?= =?UTF-8?q?=5FMODE=20=E4=B8=8E=20DAEMON=EF=BC=8C=E7=A6=81=E7=94=A8=20DAEMO?= =?UTF-8?q?N=20=E9=99=8D=E4=BD=8E=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 DEFAULT_BUILD_FEATURES 注释掉 DAEMON(内存占用过高) - remoteControlServer 命令门控从 feature('DAEMON') && feature('BRIDGE_MODE') 改为仅 feature('BRIDGE_MODE'),bridge 不再依赖 daemon - --daemon-worker 快速路径改为运行时检测,未启用时输出明确错误提示 Co-Authored-By: Claude Opus 4.7 --- scripts/defines.ts | 2 +- src/commands.ts | 2 +- src/commands/remoteControlServer/index.ts | 7 ++++++- src/entrypoints/cli.tsx | 7 ++++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/defines.ts b/scripts/defines.ts index 388616597..804935419 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -46,7 +46,7 @@ export const DEFAULT_BUILD_FEATURES = [ 'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态) 'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结) 'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划 - 'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker + // 'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(已禁用:内存占用过高) 'ACP', // ACP 代理协议,支持外部 agent 接入 'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD) 'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口 diff --git a/src/commands.ts b/src/commands.ts index c3ea1804a..d4396b364 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -75,7 +75,7 @@ const bridge = feature('BRIDGE_MODE') ? require('./commands/bridge/index.js').default : null const remoteControlServerCommand = - feature('DAEMON') && feature('BRIDGE_MODE') + feature('BRIDGE_MODE') ? require('./commands/remoteControlServer/index.js').default : null const voiceCommand = feature('VOICE_MODE') diff --git a/src/commands/remoteControlServer/index.ts b/src/commands/remoteControlServer/index.ts index 6c78d7ef3..5ec6652d9 100644 --- a/src/commands/remoteControlServer/index.ts +++ b/src/commands/remoteControlServer/index.ts @@ -3,9 +3,14 @@ import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' import type { Command } from '../../commands.js' function isEnabled(): boolean { - if (!feature('DAEMON') || !feature('BRIDGE_MODE')) { + if (!feature('BRIDGE_MODE')) { return false } + if (feature('DAEMON')) { + return isBridgeEnabled() + } + // DAEMON feature disabled — still allow the command but warn at runtime + // that headless/daemon worker mode is unavailable. return isBridgeEnabled() } diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index c519e6572..2ea7f3a70 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -170,7 +170,12 @@ async function main(): Promise { // perf-sensitive. No enableConfigs(), no analytics sinks at this layer — // workers are lean. If a worker kind needs configs/auth (assistant will), // it calls them inside its run() fn. - if (feature('DAEMON') && (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker='))) { + if (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker=')) { + if (!feature('DAEMON')) { + console.error('Error: --daemon-worker requires DAEMON feature to be enabled. Set FEATURE_DAEMON=1 or add DAEMON to DEFAULT_BUILD_FEATURES.') + process.exitCode = 1 + return + } const kind = args[0] === '--daemon-worker' ? args[1] : args[0].split('=')[1] const { runDaemonWorker } = await import('../daemon/workerRegistry.js') await runDaemonWorker(kind) From 2a5b2636413d7d44ff16bf6b2390665416732a83 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 10:50:53 +0800 Subject: [PATCH 21/33] chore: 1.9.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad4980acc..56c558988 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-best", - "version": "1.9.3", + "version": "1.9.4", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", "author": "claude-code-best ", From 02ab1a0307fc7c492c5d6be8ebced0bb29545120 Mon Sep 17 00:00:00 2001 From: YuanyuanMa03 <2942204237@qq.com> Date: Fri, 24 Apr 2026 12:07:18 +0800 Subject: [PATCH 22/33] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Bun=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=AF=A6=E7=BB=86=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Linux/macOS/Windows 各平台的安装命令 - 添加安装后的操作步骤(重启终端、验证安装、更新版本) - 同步更新中英文 README --- README.md | 35 +++++++++++++++++++++++++++++++++++ README_EN.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/README.md b/README.md index e1d74d821..b310a33fe 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,41 @@ CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDG 一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!! - 📦 [Bun](https://bun.sh/) >= 1.3.11 + +**安装 Bun:** + +```bash +# Linux 和 macOS +curl -fsSL https://bun.sh/install | bash + +# Windows (PowerShell) +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +**安装后的操作:** + +1. **重启终端** 或重新加载 shell 配置文件: + ```bash + # macOS/Linux (zsh) + source ~/.zshrc + + # macOS/Linux (bash) + source ~/.bashrc + + # Windows PowerShell + # 关闭并重新打开 PowerShell 即可 + ``` + +2. **验证安装:** + ```bash + bun --version + ``` + +3. **更新到最新版本(如果已安装):** + ```bash + bun upgrade + ``` + - ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式 ### 📥 安装 diff --git a/README_EN.md b/README_EN.md index 5e3255b3e..28eda73bb 100644 --- a/README_EN.md +++ b/README_EN.md @@ -48,6 +48,41 @@ Sponsor placeholder. Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`! - [Bun](https://bun.sh/) >= 1.3.11 + +**Install Bun:** + +```bash +# Linux and macOS +curl -fsSL https://bun.sh/install | bash + +# Windows (PowerShell) +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +**Post-installation steps:** + +1. **Restart your terminal** or reload your shell configuration: + ```bash + # macOS/Linux (zsh) + source ~/.zshrc + + # macOS/Linux (bash) + source ~/.bashrc + + # Windows PowerShell + # Close and reopen PowerShell + ``` + +2. **Verify installation:** + ```bash + bun --version + ``` + +3. **Update to latest version (if already installed):** + ```bash + bun upgrade + ``` + - Standard Claude Code configuration — each provider has its own setup method ### Install From 03811f973b4c7bd61e47638271a110eee1abf895 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 24 Apr 2026 14:25:56 +0800 Subject: [PATCH 23/33] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20SSH=20Remote?= =?UTF-8?q?=20=E2=80=94=20=E6=9C=AC=E5=9C=B0=20REPL=20+=20=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=E5=B7=A5=E5=85=B7=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSH Remote 允许在本地运行交互式 REPL,同时将工具调用(Bash、文件读写等) 通过 SSH 隧道转发到远程主机执行。 核心模块: - SSHSessionManager: NDJSON 双向通信、权限转发、指数退避重连 - SSHAuthProxy: 本地认证代理 + SSH -R 反向端口转发,nonce 验证 - SSHProbe: 远端主机平台/架构/已有二进制探测 - SSHDeploy: 远端二进制部署(scp) - createSSHSession: 会话编排(probe → deploy → spawn → attach) 新增选项: - --remote-bin: 跳过 probe/deploy,使用自定义远端二进制 - ANTHROPIC_AUTH_NONCE: API 请求认证 nonce header 包含 17 个单元测试和完整文档。 --- docs/features/ssh-remote.md | 426 +++++++++++++++++++ scripts/defines.ts | 2 + src/main.tsx | 19 + src/services/api/client.ts | 4 + src/ssh/SSHAuthProxy.ts | 165 ++++++++ src/ssh/SSHDeploy.ts | 123 ++++++ src/ssh/SSHProbe.ts | 99 +++++ src/ssh/SSHSessionManager.ts | 331 ++++++++++++++- src/ssh/__tests__/SSHSessionManager.test.ts | 413 ++++++++++++++++++ src/ssh/createSSHSession.ts | 443 +++++++++++++++++++- 10 files changed, 2010 insertions(+), 15 deletions(-) create mode 100644 docs/features/ssh-remote.md create mode 100644 src/ssh/SSHAuthProxy.ts create mode 100644 src/ssh/SSHDeploy.ts create mode 100644 src/ssh/SSHProbe.ts create mode 100644 src/ssh/__tests__/SSHSessionManager.test.ts diff --git a/docs/features/ssh-remote.md b/docs/features/ssh-remote.md new file mode 100644 index 000000000..981dbbb3d --- /dev/null +++ b/docs/features/ssh-remote.md @@ -0,0 +1,426 @@ +# SSH Remote — 远程主机运行 Claude Code + +## 概述 + +SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code: + +1. **SSH Remote 模块**(`ccb ssh `)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道 +2. **直接 SSH 运行**(`ssh -t ccb`)— 远程已安装 ccb,直接启动交互式会话 + +## 架构 + +### 方式一:SSH Remote 模块(完整模式) + +适用场景:远端没有 API 凭据或没有安装 ccb。 + +``` +┌──────────────── 本地 Windows/Mac/Linux ───────────┐ +│ │ +│ ccb ssh [dir] │ +│ │ │ +│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │ +│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │ +│ ├── 3. SSHAuthProxy: 启动本地认证代理 │ +│ │ ├─ Unix Socket (Linux/Mac) │ +│ │ └─ TCP 127.0.0.1: (Windows) │ +│ │ │ +│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │ +│ ssh -R : \ │ +│ ANTHROPIC_BASE_URL=... \ │ +│ ANTHROPIC_AUTH_NONCE=... \ │ +│ ccb --output-format stream-json │ +│ │ +│ ┌─────── 本地 REPL (Ink TUI) ───────┐ │ +│ │ 用户输入 → NDJSON → SSH stdin │ │ +│ │ SSH stdout → NDJSON → 渲染消息 │ │ +│ │ 工具权限请求 → 本地审批 → 回传 │ │ +│ └────────────────────────────────────┘ │ +└────────────────────────────────────────────────────┘ + │ + │ SSH 连接 (加密通道) + │ +┌───────────────── 远端 Linux ──────────────────────┐ +│ │ +│ ccb (自动部署或已存在) │ +│ ├── --output-format stream-json │ +│ ├── --input-format stream-json │ +│ ├── --verbose -p │ +│ │ │ +│ ├── API 请求 → ANTHROPIC_BASE_URL │ +│ │ → SSH 反向隧道 → 本地 AuthProxy │ +│ │ → 注入真实凭据 → api.anthropic.com │ +│ │ │ +│ └── 工具执行 (Bash/Read/Write/...) │ +│ 直接在远端文件系统上操作 │ +└────────────────────────────────────────────────────┘ +``` + +### 方式二:直接 SSH 运行(简单模式) + +适用场景:远端已安装 ccb 且已有 API 凭据(订阅或 API Key)。 + +``` +┌─────── 本地终端 ───────┐ ┌──────── 远端 Linux ────────┐ +│ │ SSH │ │ +│ ssh -t ccb │ ──────→ │ ccb (全局安装) │ +│ │ │ ├── 使用远端自身凭据 │ +│ 终端直接显示远端 TUI │ ←────── │ ├── 远端文件系统操作 │ +│ │ TTY │ └── API 直连 Anthropic │ +└─────────────────────────┘ └─────────────────────────────┘ +``` + +### 适用场景对比 + +| | SSH Remote 模块 | 直接 SSH 运行 | +|---|---|---| +| 远端需要安装 ccb | 不需要(自动部署) | 需要 | +| 远端需要 API 凭据 | 不需要(本地隧道) | 需要 | +| 本地需要安装 ccb | 需要 | 不需要(任何终端) | +| 斜杠命令 | 本地处理 | 远端处理 | +| 网络延迟敏感 | 高(NDJSON 双向) | 低(仅 TTY) | +| 推荐场景 | 远端无凭据/无安装 | 远端已配置完整 | + +--- + +## 前置准备:SSH 密钥配置 + +两种方式都依赖 SSH 免密连接。以下是完整的密钥配置步骤。 + +### 1. 生成 SSH 密钥对(本地) + +```bash +# 生成 Ed25519 密钥(推荐) +ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/id_remote + +# 或 RSA 4096 位 +ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_remote +``` + +生成两个文件: +- `~/.ssh/id_remote` — 私钥(不可泄露) +- `~/.ssh/id_remote.pub` — 公钥(部署到远端) + +### 2. 将公钥部署到远端 + +```bash +# 方式 A:ssh-copy-id(推荐) +ssh-copy-id -i ~/.ssh/id_remote.pub user@remote-host + +# 方式 B:手动复制 +cat ~/.ssh/id_remote.pub | ssh user@remote-host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" +``` + +### 3. 配置 SSH Config(本地) + +编辑 `~/.ssh/config`(不存在则创建): + +``` +Host my-server + HostName 192.168.1.100 # 远端 IP 或域名 + User root # 远端用户名 + IdentityFile ~/.ssh/id_remote # 私钥路径 + ServerAliveInterval 60 # 防止连接超时断开 + ServerAliveCountMax 3 +``` + +配置后可直接用别名连接: + +```bash +ssh my-server # 等同于 ssh -i ~/.ssh/id_remote root@192.168.1.100 +``` + +### 4. 文件权限设置 + +#### Linux / macOS + +```bash +chmod 700 ~/.ssh +chmod 600 ~/.ssh/config +chmod 600 ~/.ssh/id_remote +chmod 644 ~/.ssh/id_remote.pub +``` + +#### Windows(OpenSSH 强制 ACL 检查) + +```powershell +# 重置 .ssh 目录权限:仅允许当前用户 + SYSTEM +icacls "$env:USERPROFILE\.ssh" /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F" + +# 修复 config 文件权限 +icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F" + +# 修复私钥权限 +icacls "$env:USERPROFILE\.ssh\id_remote" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F" +``` + +> **Windows 常见错误**:如果 `icacls` 显示 `UNKNOWN\UNKNOWN` ACL 条目,需要先移除再重新授权。权限错误会导致 SSH 拒绝使用密钥。 + +### 5. 验证免密连接 + +```bash +ssh my-server "echo 'SSH connection OK'" +# 应直接输出 "SSH connection OK",不要求输入密码 +``` + +--- + +## 使用方式 + +### 方式一:SSH Remote 模块 + +```bash +# 基本用法 — 自动探测、部署、启动 +ccb ssh user@remote-host + +# 使用 SSH Config 别名 +ccb ssh my-server + +# 指定远端工作目录 +ccb ssh my-server /home/user/project + +# 使用自定义远端二进制(跳过探测/部署) +ccb ssh my-server --remote-bin "bun /opt/ccb/dist/cli.js" + +# 权限控制 +ccb ssh my-server --permission-mode auto +ccb ssh my-server --dangerously-skip-permissions + +# 恢复远端会话 +ccb ssh my-server --continue +ccb ssh my-server --resume + +# 选择模型 +ccb ssh my-server --model claude-sonnet-4-6-20250514 + +# 本地测试模式(不连接远端,测试 auth proxy 管道) +ccb ssh localhost --local +``` + +### 方式二:直接 SSH 运行 + +```bash +# 启动交互式会话 +ssh my-server -t ccb + +# 指定工作目录 +ssh my-server -t "ccb --cwd /home/user/project" + +# 使用特定模型 +ssh my-server -t "ccb --model claude-sonnet-4-6-20250514" +``` + +--- + +## 构建与部署 + +### 构建产物 + +```bash +# 安装依赖 +bun install + +# 构建(输出到 dist/) +bun run build +``` + +产物说明: + +| 文件 | 说明 | +|------|------| +| `dist/cli.js` | Bun 入口(`#!/usr/bin/env bun`) | +| `dist/cli-node.js` | Node.js 入口(`#!/usr/bin/env node` → `import ./cli.js`) | +| `dist/cli-bun.js` | Bun 专用入口 | +| `dist/chunk-*.js` | 代码分割 chunk 文件(约 668 个) | + +### 运行方式 + +```bash +# 方式 A:通过 bun 直接运行(开发/调试) +bun run dev + +# 方式 B:运行构建产物(bun 运行时) +bun dist/cli.js + +# 方式 C:运行构建产物(node 运行时) +node dist/cli-node.js + +# 方式 D:全局安装后使用命令名 +ccb +``` + +### 全局安装 + +在项目根目录执行: + +```bash +# bun 全局安装(推荐) +bun install -g . + +# 创建的命令: +# ccb → dist/cli-node.js +# ccb-bun → dist/cli-bun.js +# claude-code-best → dist/cli-node.js + +# 安装位置:~/.bun/bin/ccb +``` + +或使用 npm: + +```bash +npm install -g . +``` + +验证: + +```bash +ccb --version +# → x.x.x (Claude Code) +``` + +### 远端部署(全流程) + +```bash +# 1. 登录远端 +ssh my-server + +# 2. 克隆或同步项目代码 +git clone ~/ccb-project +cd ~/ccb-project + +# 3. 安装运行时(如果没有 bun) +curl -fsSL https://bun.sh/install | bash +source ~/.bashrc + +# 4. 安装依赖 + 构建 +bun install +bun run build + +# 5. 全局安装 +bun install -g . + +# 6. 确保非交互式 SSH 可访问 ccb 命令 +# bun install -g 安装到 ~/.bun/bin/,但非交互式 SSH 不加载 .bashrc, +# 所以 PATH 中不包含 ~/.bun/bin/ +# 解决方式(任选其一): + +# 方式 A:符号链接到系统 PATH(推荐) +ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb + +# 方式 B:添加到 /etc/profile.d/(所有用户生效) +echo 'export PATH="$HOME/.bun/bin:$PATH"' > /etc/profile.d/bun-path.sh + +# 方式 C:添加到 ~/.bash_profile(当前用户,ssh -t 时生效) +echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bash_profile + +# 7. 验证 +ccb --version + +# 8. 从本地测试 +# (在本地终端) +ssh my-server -t ccb +``` + +### SSH Remote 自动部署 + +使用 `ccb ssh ` 时,模块自动处理: + +1. **SSHProbe** 探测远端 `~/.local/bin/claude` 或 `command -v claude` +2. 若二进制不存在或版本不匹配,**SSHDeploy** 通过 `scp` 传输 `dist/` 目录 +3. 在远端创建 wrapper 脚本(`~/.local/bin/claude`) +4. 无需手动安装 + +--- + +## 模块结构 + +``` +src/ssh/ +├── createSSHSession.ts — 会话工厂:编排 probe → deploy → proxy → spawn +├── SSHSessionManager.ts — 双向 NDJSON 通信管理 + 权限转发 + 重连 +├── SSHAuthProxy.ts — 本地认证代理(API 凭据隧道) +├── SSHProbe.ts — 远端主机探测(平台/架构/已有二进制) +├── SSHDeploy.ts — 远端二进制部署(scp + wrapper 脚本) +└── __tests__/ + └── SSHSessionManager.test.ts — 17 个单元测试 +``` + +## 关键技术细节 + +### 认证隧道 + +- **AuthProxy** 在本地监听(Unix socket 或 TCP),接收远端 CLI 的 API 请求 +- 通过 SSH `-R` 反向端口转发隧道到远端 +- AuthProxy 注入本地真实凭据(API key 或 OAuth token),转发到 `api.anthropic.com` +- `ANTHROPIC_AUTH_NONCE` header 防止未授权访问(nonce 通过环境变量传递给远端 CLI,远端 CLI 在每个 API 请求中携带此 header) + +### waitForInit vs 存活检查 + +- **标准模式**:`waitForInit` 等待远端 CLI 发送 `{type:'system', subtype:'init'}` JSON 消息 +- **`--remote-bin` 模式**:跳过 `waitForInit`(print+stream-json 模式下 init 只在首次查询后发送),改用 3 秒进程存活检查 + +### 重连机制 + +- `SSHSessionManager` 检测 SSH 连接断开后自动重连 +- 重连时在远端 CLI 命令中追加 `--continue` 恢复会话 +- 指数退避重试(最多 5 次,间隔 1s → 2s → 4s → 8s → 16s) + +## Feature Flag + +SSH Remote 功能受 `SSH_REMOTE` feature flag 控制: + +- **Dev 模式**:默认启用 +- **Build 模式**:需在 `build.ts` 的 `DEFAULT_BUILD_FEATURES` 中添加 `'SSH_REMOTE'` +- **运行时**:`FEATURE_SSH_REMOTE=1` 环境变量 + +--- + +## 常见问题 + +### `ccb: command not found`(SSH 远程执行时) + +非交互式 SSH 不加载 `.bashrc`,`~/.bun/bin` 不在 PATH 中。 + +```bash +# 解决:创建符号链接 +ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb +``` + +### SSH 密钥被拒绝 + +``` +Permission denied (publickey) +``` + +1. 确认公钥已添加到远端 `~/.ssh/authorized_keys` +2. 确认本地私钥文件权限正确(`chmod 600`) +3. 确认 `~/.ssh/config` 中 `IdentityFile` 路径正确 +4. Windows 用户检查 ACL 权限(见上方 Windows 权限设置) + +### SSH 连接超时 + +``` +ssh: connect to host x.x.x.x port 22: Connection timed out +``` + +1. 确认远端 SSH 服务正在运行:`systemctl status sshd` +2. 确认防火墙允许 22 端口 +3. 确认 IP 地址/域名正确 +4. 在 `~/.ssh/config` 中添加 `ConnectTimeout 10` + +### 403 Forbidden(SSH Remote 模块) + +AuthProxy 的 nonce 验证失败。确认: +1. 远端 CLI 版本包含 nonce header 注入修复 +2. `ANTHROPIC_AUTH_NONCE` 环境变量正确传递到远端 +3. `src/services/api/client.ts` 中 `x-auth-nonce` header 已启用 + +### 远端 CLI 启动后立即退出 + +``` +Remote process exited immediately (code 1) +``` + +1. 确认远端 `bun` / `node` 运行时可用 +2. 手动在远端执行 `ccb --version` 验证安装 +3. 检查 `--remote-bin` 路径是否正确 +4. 查看 stderr 输出获取详细错误信息 diff --git a/scripts/defines.ts b/scripts/defines.ts index 804935419..37d2e97e8 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -72,4 +72,6 @@ export const DEFAULT_BUILD_FEATURES = [ 'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗 // Team Memory 'TEAMMEM', // 团队记忆,代理队友间共享记忆文件 + // SSH Remote + 'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行 ]as const; diff --git a/src/main.tsx b/src/main.tsx index c4588b1b2..0b13c182e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -869,6 +869,7 @@ type PendingSSH = { local: boolean; /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ extraCliArgs: string[]; + remoteBin: string | undefined; }; const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE") ? { @@ -878,6 +879,7 @@ const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE") dangerouslySkipPermissions: false, local: false, extraCliArgs: [], + remoteBin: undefined, } : undefined; @@ -1084,6 +1086,17 @@ export async function main() { rawCliArgs.splice(eqI, 1); } }; + const rbIdx = rawCliArgs.indexOf('--remote-bin'); + if (rbIdx !== -1 && rawCliArgs[rbIdx + 1] && !rawCliArgs[rbIdx + 1]!.startsWith('-')) { + _pendingSSH.remoteBin = rawCliArgs[rbIdx + 1]; + rawCliArgs.splice(rbIdx, 2); + } + const rbEqIdx = rawCliArgs.findIndex(a => a.startsWith('--remote-bin=')); + if (rbEqIdx !== -1) { + _pendingSSH.remoteBin = rawCliArgs[rbEqIdx]!.split('=').slice(1).join('='); + rawCliArgs.splice(rbEqIdx, 1); + } + extractFlag("-c", { as: "--continue" }); extractFlag("--continue"); extractFlag("--resume", { hasValue: true }); @@ -4643,6 +4656,7 @@ async function run(): Promise { dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, extraCliArgs: _pendingSSH.extraCliArgs, + remoteBin: _pendingSSH.remoteBin, }, isTTY ? { @@ -5980,6 +5994,11 @@ async function run(): Promise { "--dangerously-skip-permissions", "Skip all permission prompts on the remote (dangerous)", ) + .option( + "--remote-bin ", + "Custom remote binary command (skips probe/deploy). " + + "Example: --remote-bin 'bun /path/to/project/dist/cli.js'", + ) .option( "--local", "e2e test mode — spawn the child CLI locally (skip ssh/deploy). " + diff --git a/src/services/api/client.ts b/src/services/api/client.ts index b01efc2d9..f433fe013 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -109,6 +109,10 @@ export async function getAnthropicClient({ : {}), // SDK consumers can identify their app/library for backend analytics ...(clientApp ? { 'x-client-app': clientApp } : {}), + // SSH auth proxy nonce — tunneled API requests must carry this header + ...(process.env.ANTHROPIC_AUTH_NONCE + ? { 'x-auth-nonce': process.env.ANTHROPIC_AUTH_NONCE } + : {}), } // Log API client configuration for HFI debugging diff --git a/src/ssh/SSHAuthProxy.ts b/src/ssh/SSHAuthProxy.ts new file mode 100644 index 000000000..4b16f3c6b --- /dev/null +++ b/src/ssh/SSHAuthProxy.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'crypto' +import { unlinkSync } from 'fs' +import { getClaudeAIOAuthTokens } from 'src/utils/auth.js' +import { getOauthConfig } from 'src/constants/oauth.js' +import { logForDebugging } from 'src/utils/debug.js' + +export interface SSHAuthProxy { + stop(): void +} + +export interface AuthProxyInfo { + proxy: SSHAuthProxy + /** Unix socket path or 127.0.0.1: */ + localAddress: string + /** Environment variables to inject into the remote/child CLI process */ + authEnv: Record +} + +const isWindows = process.platform === 'win32' + +function resolveAuthHeaders(): Record { + const apiKey = process.env.ANTHROPIC_API_KEY + if (apiKey) { + return { 'x-api-key': apiKey } + } + + const oauthTokens = getClaudeAIOAuthTokens() + if (oauthTokens?.accessToken) { + return { Authorization: `Bearer ${oauthTokens.accessToken}` } + } + + return {} +} + +function resolveUpstreamBaseUrl(): string { + return process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL +} + +async function proxyFetch( + req: Request, + nonce: string | null, +): Promise { + if (nonce && req.headers.get('x-auth-nonce') !== nonce) { + return new Response('Forbidden', { status: 403 }) + } + + const upstreamBase = resolveUpstreamBaseUrl() + const url = new URL(req.url) + const upstreamUrl = `${upstreamBase}${url.pathname}${url.search}` + + const authHeaders = resolveAuthHeaders() + if (Object.keys(authHeaders).length === 0) { + return new Response( + JSON.stringify({ + error: 'No API credentials available on local machine', + }), + { status: 401, headers: { 'content-type': 'application/json' } }, + ) + } + + const forwardHeaders = new Headers(req.headers) + for (const [k, v] of Object.entries(authHeaders)) { + forwardHeaders.set(k, v) + } + forwardHeaders.delete('host') + forwardHeaders.delete('x-auth-nonce') + + logForDebugging( + `[SSHAuthProxy] ${req.method} ${url.pathname} -> ${upstreamUrl}`, + ) + + try { + const upstreamRes = await fetch(upstreamUrl, { + method: req.method, + headers: forwardHeaders, + body: req.body, + // @ts-expect-error Bun supports duplex for streaming request bodies + duplex: 'half', + }) + + const responseHeaders = new Headers(upstreamRes.headers) + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: responseHeaders, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logForDebugging(`[SSHAuthProxy] upstream error: ${message}`) + return new Response( + JSON.stringify({ error: `Proxy upstream error: ${message}` }), + { status: 502, headers: { 'content-type': 'application/json' } }, + ) + } +} + +export async function createAuthProxy(): Promise { + const id = randomUUID() + + if (isWindows) { + return createTcpAuthProxy(id) + } + return createUnixSocketAuthProxy(id) +} + +async function createUnixSocketAuthProxy(id: string): Promise { + const socketPath = `/tmp/claude-ssh-auth-${id}.sock` + + const server = Bun.serve({ + unix: socketPath, + fetch: req => proxyFetch(req, null), + }) + + logForDebugging(`[SSHAuthProxy] listening on unix:${socketPath}`) + + const proxy: SSHAuthProxy = { + stop() { + server.stop(true) + try { + unlinkSync(socketPath) + } catch { + // Socket file may already be cleaned up + } + }, + } + + return { + proxy, + localAddress: socketPath, + authEnv: { ANTHROPIC_AUTH_SOCKET: socketPath }, + } +} + +async function createTcpAuthProxy(id: string): Promise { + const nonce = randomUUID() + + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: req => proxyFetch(req, nonce), + }) + + const port = server.port + logForDebugging( + `[SSHAuthProxy] listening on TCP 127.0.0.1:${port} (nonce-protected)`, + ) + + const proxy: SSHAuthProxy = { + stop() { + server.stop(true) + }, + } + + return { + proxy, + localAddress: `127.0.0.1:${port}`, + authEnv: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, + ANTHROPIC_AUTH_NONCE: nonce, + }, + } +} diff --git a/src/ssh/SSHDeploy.ts b/src/ssh/SSHDeploy.ts new file mode 100644 index 000000000..fbddb4b18 --- /dev/null +++ b/src/ssh/SSHDeploy.ts @@ -0,0 +1,123 @@ +import { existsSync } from 'fs' +import { resolve } from 'path' +import { logForDebugging } from 'src/utils/debug.js' + +const SSH_TIMEOUT_MS = 60_000 +const REMOTE_BIN_DIR = '~/.local/bin' +const REMOTE_CLI_FILE = 'claude-code-cli.js' +const REMOTE_WRAPPER = 'claude' + +export interface DeployOptions { + host: string + remotePlatform: string + remoteArch: string + localVersion: string + onProgress?: (msg: string) => void +} + +async function runSshCommand( + host: string, + command: string, + timeoutMs = SSH_TIMEOUT_MS, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(['ssh', '-o', 'ConnectTimeout=10', host, command], { + stdout: 'pipe', + stderr: 'pipe', + }) + + const timer = setTimeout(() => proc.kill(), timeoutMs) + + try { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode } + } finally { + clearTimeout(timer) + } +} + +function findLocalBinary(): string { + const projectRoot = resolve(import.meta.dir, '../..') + const distPath = resolve(projectRoot, 'dist/cli.js') + if (existsSync(distPath)) return distPath + + const devPath = resolve(projectRoot, 'src/entrypoints/cli.tsx') + if (existsSync(devPath)) return devPath + + throw new Error( + 'Cannot find local CLI binary to deploy. Run `bun run build` first.', + ) +} + +export async function deployBinary(options: DeployOptions): Promise { + const { host, remotePlatform, remoteArch, localVersion, onProgress } = options + + if (remotePlatform !== 'linux' && remotePlatform !== 'darwin') { + throw new Error( + `Remote platform "${remotePlatform}" is not supported. Only linux and darwin are supported.`, + ) + } + + logForDebugging( + `[SSHDeploy] deploying to ${host} (${remotePlatform}/${remoteArch}, v${localVersion})`, + ) + + const localBinary = findLocalBinary() + logForDebugging(`[SSHDeploy] local binary: ${localBinary}`) + + onProgress?.('Creating remote directory...') + const mkdirResult = await runSshCommand(host, `mkdir -p ${REMOTE_BIN_DIR}`) + if (mkdirResult.exitCode !== 0) { + throw new Error(`Failed to create remote directory: ${mkdirResult.stderr}`) + } + + onProgress?.('Uploading binary...') + const remotePath = `${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE}` + const scpProc = Bun.spawn( + ['scp', '-o', 'ConnectTimeout=10', localBinary, `${host}:${remotePath}`], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const scpTimer = setTimeout(() => scpProc.kill(), SSH_TIMEOUT_MS) + const scpStderr = await new Response(scpProc.stderr).text() + const scpExit = await scpProc.exited + clearTimeout(scpTimer) + + if (scpExit !== 0) { + throw new Error(`SCP upload failed (exit ${scpExit}): ${scpStderr.trim()}`) + } + + onProgress?.('Installing wrapper script...') + const wrapperScript = [ + `cat > ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} << 'WRAPPER'`, + '#!/bin/sh', + `exec bun ${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE} "$@"`, + 'WRAPPER', + `chmod +x ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`, + ].join('\n') + + const wrapperResult = await runSshCommand(host, wrapperScript) + if (wrapperResult.exitCode !== 0) { + throw new Error(`Failed to install wrapper script: ${wrapperResult.stderr}`) + } + + onProgress?.('Verifying installation...') + const verifyResult = await runSshCommand( + host, + `${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} --version`, + ) + if (verifyResult.exitCode !== 0) { + throw new Error( + `Binary deployed but verification failed (exit ${verifyResult.exitCode}): ${verifyResult.stderr}`, + ) + } + + logForDebugging( + `[SSHDeploy] deployed successfully, remote version: ${verifyResult.stdout}`, + ) + onProgress?.(`Deployed v${verifyResult.stdout}`) + + return `${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}` +} diff --git a/src/ssh/SSHProbe.ts b/src/ssh/SSHProbe.ts new file mode 100644 index 000000000..adb074ff1 --- /dev/null +++ b/src/ssh/SSHProbe.ts @@ -0,0 +1,99 @@ +import { logForDebugging } from 'src/utils/debug.js' + +const PROBE_TIMEOUT_MS = 15_000 + +export interface ProbeResult { + hasBinary: boolean + remoteVersion: string | null + remotePlatform: 'linux' | 'darwin' + remoteArch: 'x64' | 'arm64' + defaultCwd: string + binaryPath: string | null +} + +export class SSHProbeError extends Error { + constructor(message: string) { + super(message) + this.name = 'SSHProbeError' + } +} + +export async function probeRemote( + host: string, + onProgress?: (msg: string) => void, +): Promise { + onProgress?.('Probing remote host…') + + const proc = Bun.spawn( + [ + 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + host, + 'CLAUDE_BIN=$(test -x "$HOME/.local/bin/claude" && echo "$HOME/.local/bin/claude" || command -v claude 2>/dev/null); echo "$CLAUDE_BIN"; $CLAUDE_BIN --version 2>/dev/null; uname -sm; pwd', + ], + { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + ) + + const result = await Promise.race([ + proc.exited, + new Promise((_, reject) => + setTimeout( + () => + reject( + new SSHProbeError( + `SSH probe timed out after ${PROBE_TIMEOUT_MS / 1000}s`, + ), + ), + PROBE_TIMEOUT_MS, + ), + ), + ]) + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (result !== 0) { + const detail = stderr.trim() || `exit code ${result}` + throw new SSHProbeError(`SSH probe failed: ${detail}`) + } + + const lines = stdout + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + logForDebugging(`[SSHProbe] raw lines: ${JSON.stringify(lines)}`) + + const unameIdx = lines.findIndex(l => /^(Linux|Darwin)\s/.test(l)) + if (unameIdx === -1) { + throw new SSHProbeError( + 'Could not detect remote platform (uname output missing)', + ) + } + + const binaryPath = unameIdx >= 2 ? lines[unameIdx - 2] || null : null + const versionLine = unameIdx >= 1 ? lines[unameIdx - 1] || null : null + const remoteVersion = + versionLine && /^\d+\.\d+/.test(versionLine) ? versionLine : null + const hasBinary = binaryPath !== null && binaryPath.startsWith('/') + const defaultCwd = lines[unameIdx + 1] || '/' + + const [osName, arch] = lines[unameIdx]!.split(/\s+/) + + const remotePlatform = osName === 'Darwin' ? 'darwin' : 'linux' + const remoteArch: 'x64' | 'arm64' = + arch === 'aarch64' || arch === 'arm64' ? 'arm64' : 'x64' + + onProgress?.(`Detected ${remotePlatform}/${remoteArch}`) + + return { + hasBinary: hasBinary && remoteVersion !== null, + remoteVersion, + remotePlatform, + remoteArch, + defaultCwd, + binaryPath: hasBinary ? binaryPath : null, + } +} diff --git a/src/ssh/SSHSessionManager.ts b/src/ssh/SSHSessionManager.ts index 6a2faaefa..47741345c 100644 --- a/src/ssh/SSHSessionManager.ts +++ b/src/ssh/SSHSessionManager.ts @@ -1,15 +1,26 @@ -// Auto-generated stub — replace with real implementation -import type { SDKMessage } from '../entrypoints/sdk/coreTypes.js' +import type { Subprocess } from 'bun' +import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' +import type { + SDKControlPermissionRequest, + StdoutMessage, +} from '../entrypoints/sdk/controlTypes.js' import type { PermissionUpdate } from '../types/permissions.js' +import { logForDebugging } from '../utils/debug.js' +import { jsonParse, jsonStringify } from '../utils/slowOperations.js' import type { RemoteMessageContent } from '../utils/teleport/api.js' export interface SSHSessionManagerOptions { onMessage: (sdkMessage: SDKMessage) => void - onPermissionRequest: (request: SSHPermissionRequest, requestId: string) => void + onPermissionRequest: ( + request: SSHPermissionRequest, + requestId: string, + ) => void onConnected: () => void onReconnecting: (attempt: number, max: number) => void onDisconnected: () => void onError: (error: Error) => void + reconnect?: () => Promise + maxReconnectAttempts?: number } export interface SSHPermissionRequest { @@ -26,5 +37,317 @@ export interface SSHSessionManager { disconnect(): void sendMessage(content: RemoteMessageContent): Promise sendInterrupt(): void - respondToPermissionRequest(requestId: string, response: { behavior: string; message?: string; updatedInput?: unknown }): void + respondToPermissionRequest( + requestId: string, + response: { behavior: string; message?: string; updatedInput?: unknown }, + ): void +} + +function isStdoutMessage(value: unknown): value is StdoutMessage { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof (value as Record).type === 'string' + ) +} + +const BASE_RECONNECT_DELAY_MS = 2_000 +const MAX_RECONNECT_DELAY_MS = 15_000 +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3 + +export class SSHSessionManagerImpl implements SSHSessionManager { + private proc: Subprocess + private options: SSHSessionManagerOptions + private connected = false + private disconnected = false + private readLoopAbort: AbortController | null = null + private reconnectAttempt = 0 + private readonly maxReconnectAttempts: number + private userInitiatedDisconnect = false + private reconnecting = false + + constructor(proc: Subprocess, options: SSHSessionManagerOptions) { + this.proc = proc + this.options = options + this.maxReconnectAttempts = + options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS + } + + connect(): void { + if (this.connected) return + + this.readLoopAbort = new AbortController() + this.startReadLoop() + this.monitorExit() + + this.connected = true + this.options.onConnected() + } + + private async startReadLoop(): Promise { + const stdout = this.proc.stdout + if (!stdout) { + this.options.onError(new Error('SSH process stdout is not available')) + return + } + + const reader = (stdout as ReadableStream).getReader() + const decoder = new TextDecoder() + let lineBuffer = '' + + try { + while (!this.disconnected) { + const { done, value } = await reader.read() + if (done) break + + lineBuffer += decoder.decode(value, { stream: true }) + const lines = lineBuffer.split('\n') + lineBuffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + this.processLine(trimmed) + } + } + } catch (err) { + if (!this.disconnected) { + this.options.onError( + err instanceof Error ? err : new Error(String(err)), + ) + } + } finally { + reader.releaseLock() + if (!this.disconnected && !this.userInitiatedDisconnect) { + void this.handleProcessExit() + } + } + } + + private monitorExit(): void { + if (this.proc.exitCode !== null) { + if (!this.userInitiatedDisconnect) { + void this.handleProcessExit() + } + return + } + this.proc.exited + .then(() => { + if (!this.disconnected && !this.userInitiatedDisconnect) { + void this.handleProcessExit() + } + }) + .catch(() => { + if (!this.disconnected && !this.userInitiatedDisconnect) { + void this.handleProcessExit() + } + }) + } + + private async handleProcessExit(): Promise { + if (this.disconnected || this.reconnecting) return + this.connected = false + + if (!this.options.reconnect) { + this.disconnected = true + this.options.onDisconnected() + return + } + + if (this.reconnectAttempt >= this.maxReconnectAttempts) { + this.disconnected = true + this.options.onDisconnected() + return + } + + this.reconnecting = true + try { + await this.attemptReconnect() + } finally { + this.reconnecting = false + } + } + + private async attemptReconnect(): Promise { + const reconnect = this.options.reconnect! + + while (this.reconnectAttempt < this.maxReconnectAttempts) { + this.reconnectAttempt++ + this.options.onReconnecting( + this.reconnectAttempt, + this.maxReconnectAttempts, + ) + + const delay = Math.min( + BASE_RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempt - 1), + MAX_RECONNECT_DELAY_MS, + ) + await new Promise(r => setTimeout(r, delay)) + + if (this.userInitiatedDisconnect) return + + try { + const newProc = await reconnect() + this.proc = newProc + this.reconnectAttempt = 0 + this.connected = true + this.startReadLoop() + this.monitorExit() + this.options.onConnected() + return + } catch (err) { + logForDebugging( + `[SSH] reconnect attempt ${this.reconnectAttempt} failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + this.disconnected = true + this.options.onDisconnected() + } + + private processLine(line: string): void { + let raw: unknown + try { + raw = jsonParse(line) + } catch { + return + } + + if (!isStdoutMessage(raw)) return + const parsed = raw + + if (parsed.type === 'control_request') { + const request = parsed as unknown as { + request_id: string + request: SDKControlPermissionRequest & { subtype: string } + } + if (request.request.subtype === 'can_use_tool') { + this.options.onPermissionRequest( + request.request as unknown as SSHPermissionRequest, + request.request_id, + ) + } else { + logForDebugging( + `[SSH] Unsupported control request subtype: ${request.request.subtype}`, + ) + this.sendErrorResponse( + request.request_id, + `Unsupported control request subtype: ${request.request.subtype}`, + ) + } + return + } + + if ( + parsed.type !== 'control_response' && + parsed.type !== 'keep_alive' && + parsed.type !== 'control_cancel_request' && + parsed.type !== 'streamlined_text' && + parsed.type !== 'streamlined_tool_use_summary' && + !( + parsed.type === 'system' && + (parsed as Record).subtype === 'post_turn_summary' + ) + ) { + this.options.onMessage(parsed as SDKMessage) + } + } + + private writeToStdin(data: string): boolean { + try { + const stdin = this.proc.stdin + if (!stdin || typeof stdin === 'number' || this.disconnected) return false + const encoded = new TextEncoder().encode(data + '\n') + ;(stdin as unknown as { write(d: Uint8Array): number }).write(encoded) + ;(stdin as unknown as { flush?(): void }).flush?.() + return true + } catch { + return false + } + } + + async sendMessage(content: RemoteMessageContent): Promise { + const message = jsonStringify({ + type: 'user', + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + session_id: '', + }) + return this.writeToStdin(message) + } + + sendInterrupt(): void { + const request = jsonStringify({ + type: 'control_request', + request_id: crypto.randomUUID(), + request: { + subtype: 'interrupt', + }, + }) + this.writeToStdin(request) + } + + respondToPermissionRequest( + requestId: string, + response: { behavior: string; message?: string; updatedInput?: unknown }, + ): void { + const msg = jsonStringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { + behavior: response.behavior, + ...(response.behavior === 'allow' + ? { updatedInput: response.updatedInput } + : { message: response.message }), + }, + }, + }) + this.writeToStdin(msg) + } + + private sendErrorResponse(requestId: string, error: string): void { + const response = jsonStringify({ + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error, + }, + }) + this.writeToStdin(response) + } + + disconnect(): void { + if (this.disconnected) return + this.userInitiatedDisconnect = true + this.disconnected = true + this.connected = false + this.readLoopAbort?.abort() + + try { + const stdin = this.proc.stdin + if (stdin && typeof stdin !== 'number') { + ;(stdin as unknown as { end?(): void }).end?.() + } + } catch { + // stdin may already be closed + } + + try { + this.proc.kill() + } catch { + // process may already be dead + } + } + + isConnected(): boolean { + return this.connected && !this.disconnected + } } diff --git a/src/ssh/__tests__/SSHSessionManager.test.ts b/src/ssh/__tests__/SSHSessionManager.test.ts new file mode 100644 index 000000000..1f169abc5 --- /dev/null +++ b/src/ssh/__tests__/SSHSessionManager.test.ts @@ -0,0 +1,413 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test' +import { debugMock } from '../../../tests/mocks/debug' + +mock.module('src/utils/debug.ts', debugMock) + +import { SSHSessionManagerImpl } from '../SSHSessionManager' +import type { SSHSessionManagerOptions } from '../SSHSessionManager' +import type { Subprocess } from 'bun' + +function createMockSubprocess(options?: { + exitCode?: number | null + stdoutLines?: string[] +}): { + proc: Subprocess + writeToStdout: (data: string) => void + simulateExit: (code?: number) => void +} { + let stdoutController: ReadableStreamDefaultController + const exitResolvers: Array<(code: number) => void> = [] + let exitCode: number | null = options?.exitCode ?? null + + const stdout = new ReadableStream({ + start(controller) { + stdoutController = controller + if (options?.stdoutLines) { + const encoder = new TextEncoder() + for (const line of options.stdoutLines) { + controller.enqueue(encoder.encode(line + '\n')) + } + } + }, + }) + + const stdinChunks: Uint8Array[] = [] + const stdin = { + write(d: Uint8Array) { + stdinChunks.push(d) + return d.length + }, + flush() {}, + end() {}, + } + + const exited = new Promise(resolve => { + exitResolvers.push(resolve) + if (exitCode !== null) resolve(exitCode) + }) + + const proc = { + stdout, + stdin, + stderr: null, + get exitCode() { + return exitCode + }, + exited, + kill: mock(() => {}), + pid: 12345, + killed: false, + signalCode: null, + ref: () => {}, + unref: () => {}, + } as unknown as Subprocess + + return { + proc, + writeToStdout(data: string) { + const encoder = new TextEncoder() + stdoutController.enqueue(encoder.encode(data + '\n')) + }, + simulateExit(code = 0) { + exitCode = code + try { + stdoutController.close() + } catch { + // may already be closed + } + for (const resolve of exitResolvers) resolve(code) + }, + } +} + +interface MockState { + messages: unknown[] + permissionRequests: Array<{ request: unknown; requestId: string }> + reconnectingCalls: Array<{ attempt: number; max: number }> + connectedCount: number + disconnectedCount: number + errors: Error[] +} + +function createMockOptions( + overrides?: Partial, +): SSHSessionManagerOptions & { state: MockState } { + const state: MockState = { + messages: [], + permissionRequests: [], + reconnectingCalls: [], + connectedCount: 0, + disconnectedCount: 0, + errors: [], + } + + return { + state, + onMessage: msg => { + state.messages.push(msg) + }, + onPermissionRequest: (request, requestId) => { + state.permissionRequests.push({ request, requestId }) + }, + onConnected: () => { + state.connectedCount++ + }, + onReconnecting: (attempt, max) => { + state.reconnectingCalls.push({ attempt, max }) + }, + onDisconnected: () => { + state.disconnectedCount++ + }, + onError: err => { + state.errors.push(err) + }, + ...overrides, + } +} + +describe('SSHSessionManagerImpl', () => { + test('connect() sets connected state and calls onConnected', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + + expect(manager.isConnected()).toBe(true) + expect(opts.state.connectedCount).toBe(1) + }) + + test('connect() is idempotent', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.connect() + + expect(opts.state.connectedCount).toBe(1) + }) + + test('disconnect() sets disconnected state and kills process', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.disconnect() + + expect(manager.isConnected()).toBe(false) + expect((proc.kill as ReturnType).mock.calls.length).toBe(1) + }) + + test('disconnect() is idempotent', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.disconnect() + manager.disconnect() + + expect((proc.kill as ReturnType).mock.calls.length).toBe(1) + }) + + test('processLine routes SDK messages to onMessage', async () => { + const sdkMessage = JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: 'hello' }, + }) + + const { proc, writeToStdout, simulateExit } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + writeToStdout(sdkMessage) + + await new Promise(r => setTimeout(r, 50)) + simulateExit(0) + await new Promise(r => setTimeout(r, 50)) + + expect(opts.state.messages.length).toBe(1) + expect((opts.state.messages[0] as Record).type).toBe( + 'assistant', + ) + }) + + test('processLine filters noise types', async () => { + const noiseTypes = [ + 'control_response', + 'keep_alive', + 'control_cancel_request', + 'streamlined_text', + 'streamlined_tool_use_summary', + ] + + const { proc, writeToStdout, simulateExit } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + + for (const type of noiseTypes) { + writeToStdout(JSON.stringify({ type })) + } + writeToStdout( + JSON.stringify({ type: 'system', subtype: 'post_turn_summary' }), + ) + + await new Promise(r => setTimeout(r, 50)) + simulateExit(0) + await new Promise(r => setTimeout(r, 50)) + + expect(opts.state.messages.length).toBe(0) + }) + + test('processLine routes control_request to onPermissionRequest', async () => { + const controlRequest = JSON.stringify({ + type: 'control_request', + request_id: 'req-123', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'tool-456', + input: { command: 'ls' }, + }, + }) + + const { proc, writeToStdout, simulateExit } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + writeToStdout(controlRequest) + + await new Promise(r => setTimeout(r, 50)) + simulateExit(0) + await new Promise(r => setTimeout(r, 50)) + + expect(opts.state.permissionRequests.length).toBe(1) + expect(opts.state.permissionRequests[0]!.requestId).toBe('req-123') + }) + + test('sendMessage writes NDJSON to stdin', async () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + const result = await manager.sendMessage('hello world') + + expect(result).toBe(true) + }) + + test('sendInterrupt writes interrupt control request', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.sendInterrupt() + + const stdin = proc.stdin as unknown as { write: ReturnType } + expect(stdin.write).toBeDefined() + }) + + test('respondToPermissionRequest sends allow response', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.respondToPermissionRequest('req-123', { + behavior: 'allow', + updatedInput: { command: 'ls -la' }, + }) + }) + + test('respondToPermissionRequest sends deny response', () => { + const { proc } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.respondToPermissionRequest('req-123', { + behavior: 'deny', + message: 'User denied', + }) + }) + + test('process exit without reconnect calls onDisconnected', async () => { + const { proc, simulateExit } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + simulateExit(1) + + await new Promise(r => setTimeout(r, 100)) + + expect(opts.state.disconnectedCount).toBe(1) + expect(manager.isConnected()).toBe(false) + }) + + test('user disconnect does not trigger reconnect', async () => { + let reconnectCalled = false + const { proc } = createMockSubprocess() + const opts = createMockOptions({ + reconnect: async () => { + reconnectCalled = true + return createMockSubprocess().proc + }, + maxReconnectAttempts: 3, + }) + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + manager.disconnect() + + await new Promise(r => setTimeout(r, 200)) + + expect(reconnectCalled).toBe(false) + expect(opts.state.reconnectingCalls.length).toBe(0) + }) + + test('invalid JSON lines are silently skipped', async () => { + const { proc, writeToStdout, simulateExit } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + writeToStdout('not valid json') + writeToStdout('{also: broken') + writeToStdout( + JSON.stringify({ type: 'assistant', message: { role: 'assistant' } }), + ) + + await new Promise(r => setTimeout(r, 50)) + simulateExit(0) + await new Promise(r => setTimeout(r, 50)) + + expect(opts.state.messages.length).toBe(1) + expect(opts.state.errors.length).toBe(0) + }) + + test('non-StdoutMessage objects are skipped', async () => { + const { proc, writeToStdout, simulateExit } = createMockSubprocess() + const opts = createMockOptions() + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + writeToStdout(JSON.stringify({ noTypeField: true })) + writeToStdout(JSON.stringify([1, 2, 3])) + writeToStdout(JSON.stringify('string')) + + await new Promise(r => setTimeout(r, 50)) + simulateExit(0) + await new Promise(r => setTimeout(r, 50)) + + expect(opts.state.messages.length).toBe(0) + }) + + test('process exit with reconnect factory attempts reconnection', async () => { + const { proc: proc1, simulateExit } = createMockSubprocess() + const { proc: proc2 } = createMockSubprocess() + + const opts = createMockOptions({ + reconnect: mock(async () => proc2), + maxReconnectAttempts: 3, + }) + const manager = new SSHSessionManagerImpl(proc1, opts) + + manager.connect() + simulateExit(1) + + await new Promise(r => setTimeout(r, 3000)) + + expect(opts.state.reconnectingCalls.length).toBeGreaterThanOrEqual(1) + expect(opts.state.reconnectingCalls[0]!.attempt).toBe(1) + expect(opts.state.reconnectingCalls[0]!.max).toBe(3) + }) + + test('reconnect failure exhausts attempts then disconnects', async () => { + const { proc, simulateExit } = createMockSubprocess() + + const opts = createMockOptions({ + reconnect: mock(async () => { + throw new Error('SSH connection refused') + }), + maxReconnectAttempts: 2, + }) + const manager = new SSHSessionManagerImpl(proc, opts) + + manager.connect() + simulateExit(1) + + await new Promise(r => setTimeout(r, 12000)) + + expect(opts.state.reconnectingCalls.length).toBe(2) + expect(opts.state.disconnectedCount).toBe(1) + expect(manager.isConnected()).toBe(false) + }, 15000) +}) diff --git a/src/ssh/createSSHSession.ts b/src/ssh/createSSHSession.ts index 1db14a1f3..fa10844dd 100644 --- a/src/ssh/createSSHSession.ts +++ b/src/ssh/createSSHSession.ts @@ -1,10 +1,21 @@ -// Auto-generated stub — replace with real implementation import type { Subprocess } from 'bun' -import type { SSHSessionManager, SSHSessionManagerOptions } from './SSHSessionManager.js' +import { SSHSessionManagerImpl } from './SSHSessionManager.js' +import type { + SSHSessionManager, + SSHSessionManagerOptions, +} from './SSHSessionManager.js' +import { createAuthProxy } from './SSHAuthProxy.js' +export type { SSHAuthProxy } from './SSHAuthProxy.js' +import type { SSHAuthProxy } from './SSHAuthProxy.js' +import { probeRemote } from './SSHProbe.js' +import { deployBinary } from './SSHDeploy.js' +import { buildCliLaunch } from '../utils/cliLaunch.js' +import { logForDebugging } from '../utils/debug.js' +import { jsonParse } from '../utils/slowOperations.js' +import { randomUUID } from 'crypto' -export interface SSHAuthProxy { - stop(): void -} +const INIT_TIMEOUT_MS = 30_000 +const STDERR_TAIL_LINES = 20 export interface SSHSession { remoteCwd: string @@ -21,9 +32,419 @@ export class SSHSessionError extends Error { } } -export const createSSHSession: (...args: unknown[]) => Promise = (async () => { - throw new SSHSessionError('SSH sessions are not supported in this build') -}); -export const createLocalSSHSession: (...args: unknown[]) => Promise = (async () => { - throw new SSHSessionError('Local SSH sessions are not supported in this build') -}); +export async function createSSHSession( + config: { + host: string + cwd?: string + localVersion: string + permissionMode?: string + dangerouslySkipPermissions?: boolean + extraCliArgs: string[] + remoteBin?: string + }, + callbacks?: { + onProgress?: (msg: string) => void + }, +): Promise { + const { host, localVersion, extraCliArgs, remoteBin } = config + const onProgress = callbacks?.onProgress + + let remoteBinaryPath: string + let defaultCwd = '/' + + if (remoteBin) { + onProgress?.('Using custom remote binary, skipping probe/deploy…') + remoteBinaryPath = remoteBin + logForDebugging(`[SSH] custom remoteBin: ${remoteBin}`) + // Quick SSH to get remote home directory for default CWD + try { + const pwdProc = Bun.spawn( + ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, 'pwd'], + { + stdin: 'ignore', + stdout: 'pipe', + stderr: 'ignore', + }, + ) + await pwdProc.exited + const pwd = (await new Response(pwdProc.stdout).text()).trim() + if (pwd.startsWith('/')) defaultCwd = pwd + } catch { + /* use fallback */ + } + } else { + // 1. Probe remote host + const probe = await probeRemote(host, onProgress) + logForDebugging(`[SSH] probe result: ${JSON.stringify(probe)}`) + defaultCwd = probe.defaultCwd + + // 2. Deploy if binary missing or version mismatch + remoteBinaryPath = probe.binaryPath ?? '~/.local/bin/claude' + if (!probe.hasBinary || probe.remoteVersion !== localVersion) { + onProgress?.( + probe.hasBinary + ? `Updating remote binary (${probe.remoteVersion} → ${localVersion})…` + : 'Deploying binary to remote…', + ) + remoteBinaryPath = await deployBinary({ + host, + remotePlatform: probe.remotePlatform, + remoteArch: probe.remoteArch, + localVersion, + onProgress, + }) + } + } + + // 3. Start local auth proxy + const { proxy, localAddress, authEnv } = await createAuthProxy() + logForDebugging(`[SSH] auth proxy listening on ${localAddress}`) + + // 4. Build SSH command with -R reverse forward and remote CLI + const remoteSocketId = randomUUID().slice(0, 8) + const isWindows = process.platform === 'win32' + + const remoteCli: string[] = [] + for (const [k, v] of Object.entries(authEnv)) { + remoteCli.push(`${k}=${v}`) + } + remoteCli.push( + remoteBinaryPath, + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '--verbose', + '-p', + ) + if (config.cwd) remoteCli.push('--cwd', config.cwd) + if (config.permissionMode) + remoteCli.push('--permission-mode', config.permissionMode) + if (config.dangerouslySkipPermissions) + remoteCli.push('--dangerously-skip-permissions') + remoteCli.push(...extraCliArgs) + + const sshArgs = ['ssh'] + + if (!isWindows) { + const remoteSocket = `/tmp/claude-ssh-auth-${remoteSocketId}.sock` + sshArgs.push('-R', `${remoteSocket}:${localAddress}`) + sshArgs.push('-o', 'StreamLocalBindUnlink=yes') + // Override auth env to use the remote socket path + const idx = remoteCli.indexOf( + `ANTHROPIC_AUTH_SOCKET=${authEnv.ANTHROPIC_AUTH_SOCKET}`, + ) + if (idx !== -1) { + remoteCli[idx] = `ANTHROPIC_AUTH_SOCKET=${remoteSocket}` + } + } else { + // Windows: TCP reverse forward + const localPort = localAddress.split(':')[1] + const remotePort = 10000 + Math.floor(Math.random() * 50000) + sshArgs.push('-R', `${remotePort}:127.0.0.1:${localPort}`) + // Override auth env to use remote TCP address + const baseIdx = remoteCli.findIndex(s => + s.startsWith('ANTHROPIC_BASE_URL='), + ) + if (baseIdx !== -1) { + remoteCli[baseIdx] = `ANTHROPIC_BASE_URL=http://127.0.0.1:${remotePort}` + } + } + + sshArgs.push(host, remoteCli.join(' ')) + + onProgress?.('Starting remote session…') + logForDebugging(`[SSH] spawning: ${sshArgs.join(' ')}`) + + let proc: Subprocess + try { + proc = Bun.spawn(sshArgs, { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }) + } catch (err) { + proxy.stop() + throw new SSHSessionError( + `Failed to spawn SSH process: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const stderrChunks: string[] = [] + collectStderr(proc, stderrChunks) + + let remoteCwd: string + if (remoteBin) { + // Custom binary mode: the remote CLI in print+stream-json mode emits + // init only after receiving the first user message (QueryEngine yield). + // Waiting for init here would deadlock. Instead, verify the process + // is alive and use the configured or probed CWD. + const earlyExit = await Promise.race([ + proc.exited.then(code => code), + new Promise(r => setTimeout(() => r(null), 3_000)), + ]) + if (earlyExit !== null) { + proxy.stop() + const tail = stderrChunks.join('').trim() + throw new SSHSessionError( + `Remote process exited immediately (code ${earlyExit})${tail ? `: ${tail}` : ''}`, + ) + } + remoteCwd = config.cwd || defaultCwd || '/' + } else { + try { + remoteCwd = await waitForInit(proc, config.cwd || defaultCwd) + } catch (err) { + proxy.stop() + proc.kill() + throw err + } + } + + logForDebugging(`[SSH] remote session initialized, remoteCwd=${remoteCwd}`) + + let currentProc = proc + + const reconnect = async (): Promise => { + logForDebugging('[SSH] reconnect: re-spawning SSH process with --continue') + const reconnectArgs = [...sshArgs] + const cmdIdx = reconnectArgs.length - 1 + const existingCmd = reconnectArgs[cmdIdx]! + if (!existingCmd.includes('--continue')) { + reconnectArgs[cmdIdx] = existingCmd.replace( + / -p(?:\s|$)/, + ' -p --continue ', + ) + } + + const newProc = Bun.spawn(reconnectArgs, { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }) + + const newStderrChunks: string[] = [] + collectStderr(newProc, newStderrChunks) + + await waitForInit(newProc, remoteCwd) + currentProc = newProc + stderrChunks.length = 0 + stderrChunks.push(...newStderrChunks) + + return newProc + } + + return { + remoteCwd, + get proc() { + return currentProc + }, + proxy, + createManager(options: SSHSessionManagerOptions): SSHSessionManager { + return new SSHSessionManagerImpl(currentProc, { + ...options, + reconnect, + }) + }, + getStderrTail(): string { + return stderrChunks.slice(-STDERR_TAIL_LINES).join('') + }, + } +} + +export async function createLocalSSHSession(config: { + cwd?: string + permissionMode?: string + dangerouslySkipPermissions?: boolean +}): Promise { + const { proxy, authEnv } = await createAuthProxy() + + const cliArgs: string[] = [ + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '-p', + ] + if (config.cwd) { + cliArgs.push('--cwd', config.cwd) + } + if (config.permissionMode) { + cliArgs.push('--permission-mode', config.permissionMode) + } + if (config.dangerouslySkipPermissions) { + cliArgs.push('--dangerously-skip-permissions') + } + + const spec = buildCliLaunch(cliArgs) + + let proc: Subprocess + try { + proc = Bun.spawn([spec.execPath, ...spec.args], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...spec.env, ...authEnv }, + }) + } catch (err) { + proxy.stop() + throw new SSHSessionError( + `Failed to spawn local CLI process: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + logForDebugging('[SSH] local session spawned, waiting for init message...') + + const stderrChunks: string[] = [] + collectStderr(proc, stderrChunks) + + let remoteCwd: string + try { + remoteCwd = await waitForInit(proc, config.cwd) + } catch (err) { + proxy.stop() + proc.kill() + throw err + } + + logForDebugging(`[SSH] local session initialized, remoteCwd=${remoteCwd}`) + + let currentProc = proc + + const reconnect = async (): Promise => { + logForDebugging('[SSH] local reconnect: re-spawning CLI with --continue') + const reconnectCliArgs = [...cliArgs] + if (!reconnectCliArgs.includes('--continue')) { + reconnectCliArgs.push('--continue') + } + + const reconnectSpec = buildCliLaunch(reconnectCliArgs) + const newProc = Bun.spawn([reconnectSpec.execPath, ...reconnectSpec.args], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...reconnectSpec.env, ...authEnv }, + }) + + const newStderrChunks: string[] = [] + collectStderr(newProc, newStderrChunks) + + await waitForInit(newProc, remoteCwd) + currentProc = newProc + stderrChunks.length = 0 + stderrChunks.push(...newStderrChunks) + + return newProc + } + + return { + remoteCwd, + get proc() { + return currentProc + }, + proxy, + createManager(options: SSHSessionManagerOptions): SSHSessionManager { + return new SSHSessionManagerImpl(currentProc, { + ...options, + reconnect, + }) + }, + getStderrTail(): string { + return stderrChunks.slice(-STDERR_TAIL_LINES).join('') + }, + } +} + +async function waitForInit( + proc: Subprocess, + fallbackCwd?: string, +): Promise { + const stdout = proc.stdout + if (!stdout) { + throw new SSHSessionError('Child process stdout is not readable') + } + + const reader = (stdout as ReadableStream).getReader() + const decoder = new TextDecoder() + let buffer = '' + const deadline = Date.now() + INIT_TIMEOUT_MS + + try { + while (Date.now() < deadline) { + const remaining = deadline - Date.now() + const result = await Promise.race([ + reader.read(), + new Promise<{ done: true; value: undefined }>((_, reject) => + setTimeout( + () => + reject( + new SSHSessionError( + 'Remote CLI did not initialize within 30 seconds. Check SSH connectivity and remote binary.', + ), + ), + remaining, + ), + ), + ]) + + if (result.done) { + throw new SSHSessionError( + 'Child process exited before sending init message', + ) + } + + buffer += decoder.decode(result.value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const msg = jsonParse(trimmed) as Record + if (msg.type === 'system' && msg.subtype === 'init') { + reader.releaseLock() + return (msg.cwd as string) || fallbackCwd || process.cwd() + } + } catch { + // not valid JSON — skip + } + } + } + } catch (err) { + reader.releaseLock() + throw err instanceof SSHSessionError + ? err + : new SSHSessionError( + `Error reading init message: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + reader.releaseLock() + throw new SSHSessionError( + 'Remote CLI did not initialize within 30 seconds. Check SSH connectivity and remote binary.', + ) +} + +function collectStderr(proc: Subprocess, chunks: string[]): void { + const stderr = proc.stderr + if (!stderr) return + + const reader = (stderr as ReadableStream).getReader() + const decoder = new TextDecoder() + + void (async () => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(decoder.decode(value, { stream: true })) + if (chunks.length > STDERR_TAIL_LINES * 2) { + chunks.splice(0, chunks.length - STDERR_TAIL_LINES) + } + } + } catch { + // stderr closed — expected on process exit + } + })() +} From 5582bb47ef763de7c90b912043986dd18ec3002e Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 14:35:39 +0800 Subject: [PATCH 24/33] =?UTF-8?q?docs:=20=E4=BA=94=E4=B8=80=20lint=20?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b310a33fe..a6299bd58 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ 牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠 +> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈 + [文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX) From eadd32ae47d6adadc7a17c8d1c09483b9502b21b Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 14:46:41 +0800 Subject: [PATCH 25/33] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20AGENTS.md=20?= =?UTF-8?q?=E4=B8=8E=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 130 ++++++++++++++++++++++++++++++++++++++++++------------ CLAUDE.md | 44 +++++++++++++----- 2 files changed, 135 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1404eee6..e856b7770 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ -# AGENTS.md +# CLAUDE.md -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository. ## Project Overview -This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**. +This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**. ## Git Commit Message Convention @@ -39,10 +39,13 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p # Build (code splitting, outputs dist/cli.js + chunk files) bun run build +# Build with Vite (alternative build pipeline) +bun run build:vite + # Test -bun test # run all tests (2453 tests / 137 files / 0 fail) +bun test # run all tests bun test src/utils/__tests__/hash.test.ts # run single file -bun test --coverage # with coverage report +bun test --coverage # with coverage report # Lint & Format (Biome) bun run lint # check only @@ -55,6 +58,10 @@ bun run health # Check unused exports bun run check:unused +# Full check (typecheck + lint + test) — run after completing any task +bun run test:all +bun run typecheck + # Remote Control Server bun run rcs @@ -72,17 +79,17 @@ bun run docs:dev - **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。 - **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。 - **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. -- **Monorepo**: Bun workspaces — 14 个 internal packages in `packages/` resolved via `workspace:*`。 +- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。 - **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。 - **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。 - **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。 ### Entry & Bootstrap -1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径: +1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径: - `--version` / `-v` — 零模块加载 - `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT) - - `--Codex-in-chrome-mcp` / `--chrome-native-host` + - `--claude-in-chrome-mcp` / `--chrome-native-host` - `--computer-use-mcp` — 独立 MCP server 模式 - `--daemon-worker=` — feature-gated (DAEMON) - `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE) @@ -92,26 +99,26 @@ bun run docs:dev - `environment-runner` / `self-hosted-runner` — BYOC runner - `--tmux` + `--worktree` 组合 - 默认路径:加载 `main.tsx` 启动完整 CLI -2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。 +2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。 3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。 ### Core Loop -- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop. +- **`src/query.ts`** — The main API query function. Sends messages to Claude API, handles streaming responses, processes tool calls, and manages the conversation turn loop. - **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen. - **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts. ### API Layer -- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events. +- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events. - **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。 - Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。 ### Tool System - **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). -- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. -- **`src/tools//`** — 55 个 tool 目录。主要分类: +- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. +- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类: - **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool - **Shell/执行**: BashTool, PowerShellTool, REPLTool - **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool @@ -119,7 +126,7 @@ bun run docs:dev - **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool - **调度**: CronCreateTool, CronDeleteTool, CronListTool - **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等 -- **`src/tools/shared/`** — Tool 共享工具函数。 +- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。 ### UI Layer (Ink) @@ -149,10 +156,18 @@ bun run docs:dev | `packages/@ant/computer-use-mcp/` | Computer Use MCP server(截图/键鼠/剪贴板/应用管理) | | `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) | | `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) | -| `packages/@ant/Codex-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) | -| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) | -| `packages/swarm/` | Swarm 解耦模块 | -| `packages/shell/` | Shell 抽象 | +| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) | +| `packages/@ant/model-provider/` | Model provider 抽象层 | +| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) | +| `packages/agent-tools/` | Agent 工具集 | +| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) | +| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) | +| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) | +| `packages/mcp-client/` | MCP 客户端库 | +| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) | +| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 | +| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) | +| `packages/shell/` | Shell 抽象(非 workspace 包) | | `packages/audio-capture-napi/` | 原生音频捕获(已恢复) | | `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) | | `packages/image-processor-napi/` | 图像处理(已恢复) | @@ -161,19 +176,26 @@ bun run docs:dev ### Bridge / Remote Control -- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 -- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。 -- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`。 +- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 +- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。 +- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。 - 详见 `docs/features/remote-control-self-hosting.md`。 +### ACP Protocol (Agent Client Protocol) + +- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。 +- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。 +- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。 +- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。 + ### Daemon Mode - **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。 ### Context & System Prompt -- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files). -- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy. +- **`src/context.ts`** — Builds system/user context for the API call (git status, date, CLAUDE.md contents, memory files). +- **`src/utils/claudemd.ts`** — Discovers and loads CLAUDE.md files from project hierarchy. ### Feature Flag System @@ -196,7 +218,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 ### Multi-API 兼容层 -所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。 +所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。 #### OpenAI 兼容层 @@ -221,6 +243,12 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 详见各兼容层的 docs 文档。 +### 穷鬼模式(Budget Mode) + +- 通过 `/poor` 命令切换,持久化到 `settings.json`。 +- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。 +- 实现在 `src/commands/poor/poorMode.ts`。 + ### Stubbed/Deleted Modules | Module | Status | @@ -245,20 +273,40 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 ## Testing - **框架**: `bun:test`(内置断言 + mock) -- **当前状态**: 2472 tests / 138 files / 0 fail - **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` - **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) - **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) - **命名**: `describe("functionName")` + `test("behavior description")`,英文 -- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入) - **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests) +### Mock 使用规范 + +**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。** + +被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。 + +**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式: + +```ts +import { logMock } from "../../../tests/mocks/log"; +mock.module("src/utils/log.ts", logMock); + +import { debugMock } from "../../../../tests/mocks/debug"; +mock.module("src/utils/debug.ts", debugMock); +``` + +源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。 + +不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。 + +路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。 + ### 类型检查 项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行: ```bash -bunx tsc --noEmit +bun run typecheck ``` **类型规范**: @@ -271,7 +319,7 @@ bunx tsc --noEmit ## Working with This Codebase -- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。 +- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。 - **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。 - **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal. - **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。 @@ -281,3 +329,29 @@ bunx tsc --noEmit - **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 - **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。 - **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。 + +## Design Context + +Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。 + +### 核心设计原则 + +1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流 +2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖 +3. **Density with clarity** — 技术用户需要信息密度,但不能混乱 +4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队 +5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温 + +### 品牌色 + +- 主色:Claude Orange `#D77757`(terra cotta) +- 辅色:Claude Blue `#5769F7` +- 暗色模式使用温暖的深色表面(非冷蓝黑色) + +### 目标用户 + +技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。 + +### 视觉参考 + +Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。 diff --git a/CLAUDE.md b/CLAUDE.md index 45f55ebe2..e856b7770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository. ## Project Overview -This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。 +This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**. ## Git Commit Message Convention @@ -43,9 +43,9 @@ bun run build bun run build:vite # Test -bun test # run all tests (3175 tests / 207 files / 0 fail) +bun test # run all tests bun test src/utils/__tests__/hash.test.ts # run single file -bun test --coverage # with coverage report +bun test --coverage # with coverage report # Lint & Format (Biome) bun run lint # check only @@ -60,7 +60,6 @@ bun run check:unused # Full check (typecheck + lint + test) — run after completing any task bun run test:all - bun run typecheck # Remote Control Server @@ -87,7 +86,7 @@ bun run docs:dev ### Entry & Bootstrap -1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径: +1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径: - `--version` / `-v` — 零模块加载 - `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT) - `--claude-in-chrome-mcp` / `--chrome-native-host` @@ -118,7 +117,7 @@ bun run docs:dev ### Tool System - **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). -- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. +- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. - **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类: - **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool - **Shell/执行**: BashTool, PowerShellTool, REPLTool @@ -127,6 +126,7 @@ bun run docs:dev - **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool - **调度**: CronCreateTool, CronDeleteTool, CronListTool - **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等 +- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。 ### UI Layer (Ink) @@ -176,7 +176,7 @@ bun run docs:dev ### Bridge / Remote Control -- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 +- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 - **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。 - CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。 - 详见 `docs/features/remote-control-self-hosting.md`。 @@ -218,7 +218,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 ### Multi-API 兼容层 -支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。 +所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。 + +#### OpenAI 兼容层 + +通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。 + +- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射 +- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL` + +#### Gemini 兼容层 + +通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。 + +- **`src/services/api/gemini/`** — client、模型映射、类型定义 +- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射) +- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回 + +#### Grok 兼容层 + +通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。 + +- **`src/services/api/grok/`** — client、模型映射 + +详见各兼容层的 docs 文档。 ### 穷鬼模式(Budget Mode) @@ -250,7 +273,6 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 ## Testing - **框架**: `bun:test`(内置断言 + mock) -- **当前状态**: 3175 tests / 207 files / 0 fail - **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` - **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) - **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) @@ -284,7 +306,7 @@ mock.module("src/utils/debug.ts", debugMock); 项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行: ```bash -bun run typecheck # equivalent to bun run typecheck +bun run typecheck ``` **类型规范**: From eb833da33b867ffa6b960fa0772f670e6e493474 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 15:04:02 +0800 Subject: [PATCH 26/33] =?UTF-8?q?fix:=20=E5=88=9B=E5=BB=BA=20agent=20?= =?UTF-8?q?=E5=90=8E=E5=88=B7=E6=96=B0=20loadMarkdownFilesForSubdir=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 agent 后 clearAgentDefinitionsCache 漏清底层 loadMarkdownFilesForSubdir 的 memoize 缓存,导致新 agent 不会立即出现在列表中,需要重启才能生效。 Co-Authored-By: Claude Opus 4.7 --- packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts | 1 + .../new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts b/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts index 30cf8bb91..1551dec20 100644 --- a/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts +++ b/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts @@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize( export function clearAgentDefinitionsCache(): void { getAgentDefinitionsWithOverrides.cache.clear?.() + loadMarkdownFilesForSubdir.cache?.clear?.() clearPluginAgentCache() } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx index b1e391e7f..b5bd87c68 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx @@ -8,6 +8,7 @@ import { useSetAppState } from 'src/state/AppState.js' import type { Tools } from '../../../../Tool.js' import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import { getActiveAgentsFromList } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' +import { clearAgentDefinitionsCache } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import { editFileInEditor } from '../../../../utils/promptEditor.js' import { useWizard } from '../../../wizard/index.js' import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js' @@ -62,6 +63,8 @@ export function ConfirmStepWrapper({ } }) + clearAgentDefinitionsCache() + if (openInEditor) { const filePath = getNewAgentFilePath({ source: wizardData.location!, From 9d35f98ec778c1b6761c4e752540c943dea87542 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 24 Apr 2026 15:18:26 +0800 Subject: [PATCH 27/33] =?UTF-8?q?feat:=20=E5=90=AF=E7=94=A8=20SKILL=5FLEAR?= =?UTF-8?q?NING=20=E7=BC=96=E8=AF=91=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 SKILL_LEARNING 加入 DEFAULT_BUILD_FEATURES, 构建产物中默认启用技能学习系统。 --- scripts/defines.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/defines.ts b/scripts/defines.ts index 37d2e97e8..480251603 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -66,8 +66,9 @@ export const DEFAULT_BUILD_FEATURES = [ 'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献) // Server mode (claude server / claude open) 'DIRECT_CONNECT', // 直连模式(claude server / claude open) - // Skill search + // Skill search & learning 'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills) + 'SKILL_LEARNING', // 技能学习系统,从对话中自动生成/演化技能 // P3: poor mode 'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗 // Team Memory From 5125a159d2d574e699ce4a04643b723631402395 Mon Sep 17 00:00:00 2001 From: YYMa Date: Fri, 24 Apr 2026 17:36:57 +0800 Subject: [PATCH 28/33] docs: correct Bun post-install instructions --- README.md | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a6299bd58..6e7244bc1 100644 --- a/README.md +++ b/README.md @@ -75,27 +75,24 @@ powershell -c "irm bun.sh/install.ps1 | iex" **安装后的操作:** -1. **重启终端** 或重新加载 shell 配置文件: - ```bash - # macOS/Linux (zsh) - source ~/.zshrc - - # macOS/Linux (bash) - source ~/.bashrc - - # Windows PowerShell - # 关闭并重新打开 PowerShell 即可 - ``` +Bun 安装脚本会将 `~/.bun/bin` 添加到对应的 shell 配置文件中,例如 zsh 环境下会显示: -2. **验证安装:** - ```bash - bun --version - ``` +```text +Added "~/.bun/bin" to $PATH in "~/.zshrc" +``` -3. **更新到最新版本(如果已安装):** - ```bash - bun upgrade - ``` +To get started, run: + +```bash +exec /bin/zsh +bun --help +``` + +如果已经安装过 Bun,可以更新到最新版本: + +```bash +bun upgrade +``` - ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式 From 017c251f78559dadf7a180ea9719b18dcca3ffca Mon Sep 17 00:00:00 2001 From: YuanyuanMa03 <2942204237@qq.com> Date: Fri, 24 Apr 2026 18:03:21 +0800 Subject: [PATCH 29/33] docs: clarify bun setup without duplicate steps --- README.md | 49 ++++++++++++++++++++++++++++++++++++------------- README_EN.md | 36 +++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6e7244bc1..4d4130289 100644 --- a/README.md +++ b/README.md @@ -75,30 +75,53 @@ powershell -c "irm bun.sh/install.ps1 | iex" **安装后的操作:** -Bun 安装脚本会将 `~/.bun/bin` 添加到对应的 shell 配置文件中,例如 zsh 环境下会显示: +1. **让当前终端识别 `bun` 命令** -```text -Added "~/.bun/bin" to $PATH in "~/.zshrc" -``` + 安装脚本会把 `~/.bun/bin` 写入对应的 shell 配置文件。macOS 默认 zsh 环境通常会看到: -To get started, run: + ```text + Added "~/.bun/bin" to $PATH in "~/.zshrc" + ``` -```bash -exec /bin/zsh -bun --help -``` + 可以按安装脚本提示重启当前 shell: -如果已经安装过 Bun,可以更新到最新版本: + ```bash + exec /bin/zsh + ``` -```bash -bun upgrade -``` + 如果你使用 bash,重新加载 bash 配置: + + ```bash + source ~/.bashrc + ``` + + Windows PowerShell 用户关闭并重新打开 PowerShell 即可。 + +2. **验证 Bun 是否可用** + + ```bash + bun --help + bun --version + ``` + +3. **如果已经安装过 Bun,更新到最新版本** + + ```bash + bun upgrade + ``` - ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式 +### 📍 命令执行位置 + +- 安装或检查 Bun 的命令可以在任意目录执行: + `curl -fsSL https://bun.sh/install | bash`、`bun --help`、`bun --version`、`bun upgrade` +- 安装本项目依赖、启动开发模式、构建项目时,必须先进入本仓库根目录,也就是包含 `package.json` 的目录。 + ### 📥 安装 ```bash +cd /path/to/claude-code bun install ``` diff --git a/README_EN.md b/README_EN.md index 28eda73bb..68e1dfe4f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -61,20 +61,31 @@ powershell -c "irm bun.sh/install.ps1 | iex" **Post-installation steps:** -1. **Restart your terminal** or reload your shell configuration: - ```bash - # macOS/Linux (zsh) - source ~/.zshrc +1. **Make `bun` available in the current terminal** - # macOS/Linux (bash) - source ~/.bashrc + The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see: - # Windows PowerShell - # Close and reopen PowerShell + ```text + Added "~/.bun/bin" to $PATH in "~/.zshrc" ``` -2. **Verify installation:** + Restart the current shell as the installer suggests: + ```bash + exec /bin/zsh + ``` + + If you use bash, reload the bash configuration: + + ```bash + source ~/.bashrc + ``` + + Windows PowerShell users can close and reopen PowerShell. + +2. **Verify that Bun is available:** + ```bash + bun --help bun --version ``` @@ -85,9 +96,16 @@ powershell -c "irm bun.sh/install.ps1 | iex" - Standard Claude Code configuration — each provider has its own setup method +### Command Execution Location + +- Bun installation and checking commands can be run from any directory: + `curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade` +- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`. + ### Install ```bash +cd /path/to/claude-code bun install ``` From da6d06365d42a8deef5946c0869e1970d747fb95 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 20:07:18 +0800 Subject: [PATCH 30/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20anthropic=20?= =?UTF-8?q?=E7=85=9E=E7=AC=94=E7=9A=84=E5=9B=9B=E4=B8=AA=20bug=20(#352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 移除文件编辑前必须先读取的限制 移除 FileEditTool 和 FileWriteTool 中的 "read before edit" 校验, 允许直接编辑未读取过的文件。保留文件修改过期检测。 Co-Authored-By: Claude Opus 4.7 * docs: 更新 teach-me 自动写 note 笔记的功能 * fix: 修复 DeepSeek V4 reasoning_content 回传导致的 400 错误 - 扩大模型名称检测范围,匹配所有 deepseek 模型(V4、R1 等) - 始终保留 thinking blocks 为 reasoning_content 回传给 API - 移除有 bug 的 turn boundary 剥离逻辑 Co-Authored-By: Claude Opus 4.7 * fix: Opus 4.6/4.7 默认推理 effort 从 medium 改为 high Pro 和 Max/Team 订阅者的 Opus 默认 effort 之前被降级为 medium, 导致用户感知模型「变笨」。恢复为 high。 Co-Authored-By: Claude Opus 4.7 * fix: 移除 thinkingClearLatched sticky-on 机制 空闲超过 1 小时后 thinkingClearLatched 会被触发且永不重置, 导致每轮 API 调用都清除 thinking 历史。完整移除该 latch 机制, clearAllThinking 硬编码为 false。 Co-Authored-By: Claude Opus 4.7 * fix: 移除 numeric_length_anchors 系统指令 删除「工具调用间文字 ≤25 词、最终回复 ≤100 词」的硬性限制。 ablation 测试显示该约束使整体智能下降 3%。 Co-Authored-By: Claude Opus 4.7 * fix: 修复测试中 reasoning_content 类型断言 Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .claude/skills/teach-me/SKILL.md | 49 ++++++++++++- .../__tests__/openaiConvertMessages.test.ts | 18 ++--- .../src/shared/openaiConvertMessages.ts | 68 +++---------------- .../src/tools/FileEditTool/FileEditTool.ts | 12 ---- .../src/tools/FileEditTool/UI.tsx | 8 --- .../src/tools/FileWriteTool/FileWriteTool.ts | 27 +++----- src/bootstrap/state.ts | 15 ---- src/constants/prompts.ts | 11 --- src/services/api/claude.ts | 18 +---- .../api/openai/__tests__/thinking.test.ts | 25 +++++-- src/services/api/openai/requestBody.ts | 4 +- src/services/api/src/bootstrap/state.ts | 2 - src/utils/effort.ts | 4 +- 13 files changed, 98 insertions(+), 163 deletions(-) diff --git a/.claude/skills/teach-me/SKILL.md b/.claude/skills/teach-me/SKILL.md index 1900181a1..88c589825 100644 --- a/.claude/skills/teach-me/SKILL.md +++ b/.claude/skills/teach-me/SKILL.md @@ -41,7 +41,8 @@ All teach-me data is stored under `.claude/skills/teach-me/records/`: .claude/skills/teach-me/records/ ├── learner-profile.md # Cross-topic notes (created on first session) └── {topic-slug}/ - └── session.md # Learning state: concepts, status, notes + ├── session.md # Learning state: concepts, status, notes + └── {topic-slug}-notes.md # Learner-facing summary notes (generated at session end) ``` **Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators` @@ -275,7 +276,8 @@ Update `session.md` after each round: When all concepts mastered or user ends session: 1. Update `session.md` with final state. -2. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines): +2. **Generate learner-facing notes** — write `{topic-slug}-notes.md` in the topic directory. This is a standalone reference document the learner can review later. See "Notes Generation" below for format. +3. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines): ```markdown # Learner Profile @@ -293,7 +295,48 @@ Updated: {timestamp} - Python decorators (8/10 concepts, 2025-01-15) ``` -3. Give a brief text summary of what was covered, key insights, and areas for further study. +4. Give a brief text summary of what was covered, key insights, and areas for further study. + +## Notes Generation + +At session end, generate a learner-facing notes file at `{topic-slug}/{topic-slug}-notes.md`. This file is **written for the learner to review later**, not for the tutor. It should be self-contained and organized as a quick-reference. + +### Notes Structure + +```markdown +# {Topic} 核心笔记 + +## 1. {Section Name} +{Key concept, mechanism, or principle} +* **One-line summary**: {what it does / why it matters} +* **Detail**: {brief explanation, 2-4 sentences max} +* **Example** (if applicable): {code snippet, command, or concrete scenario} + +--- + +## 2. {Section Name} +... + +--- + +## n. 实战参数 / Cheat Sheet (if applicable) +{Practical commands, config, or quick-reference table} + +| Parameter / Concept | What it does | Tuning tip | +|---------------------|-------------|------------| +| ... | ... | ... | +``` + +### Notes Writing Rules + +1. **Start with "what & why"** before "how". Each section should answer: what is this, why does it exist, what problem does it solve. +2. **Use analogies sparingly but effectively**. Only include an analogy if it clarifies a non-obvious mechanism (e.g., "PagedAttention is like OS virtual memory paging"). +3. **Include trade-offs**. Every optimization or design choice has a cost. Always state it (e.g., "TP improves throughput but increases communication latency"). +4. **Code / command examples should be minimal**. Under 10 lines, self-contained, with comments explaining the key flags. +5. **Organize by concept dependency**, not by chronological teaching order. Foundation concepts first, advanced ones last. +6. **No quiz questions, no misconceptions, no tutor-side notes**. This is a clean reference document. +7. **Language matches the session**. If the session was in Chinese, notes are in Chinese (technical terms can stay in English). +8. **Keep it under 150 lines**. If it gets too long, the learner won't review it. Be ruthless about cutting fluff. ## Resuming Sessions diff --git a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts index 27c792a5d..974849af9 100644 --- a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts +++ b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts @@ -121,7 +121,7 @@ describe('anthropicMessagesToOpenAI', () => { ]) }) - test('strips thinking blocks', () => { + test('preserves thinking blocks as reasoning_content', () => { const result = anthropicMessagesToOpenAI( [ makeAssistantMsg([ @@ -131,7 +131,7 @@ describe('anthropicMessagesToOpenAI', () => { ], [] as any, ) - expect(result).toEqual([{ role: 'assistant', content: 'visible response' }]) + expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any) }) test('handles full conversation with tools', () => { @@ -299,7 +299,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => { expect(assistant.reasoning_content).toBe('Let me reason about this...') }) - test('drops thinking block when enableThinking is false (default)', () => { + test('preserves thinking block as reasoning_content even without enableThinking', () => { const result = anthropicMessagesToOpenAI( [ makeAssistantMsg([ @@ -311,7 +311,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => { ) const assistant = result[0] as any expect(assistant.content).toBe('visible response') - expect(assistant.reasoning_content).toBeUndefined() + expect(assistant.reasoning_content).toBe('internal thoughts...') }) test('preserves reasoning_content with tool_calls in same turn', () => { @@ -352,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => { expect(assistant.tool_calls[0].function.name).toBe('get_weather') }) - test('strips reasoning_content from previous turns', () => { + test('always preserves reasoning_content from all turns', () => { const result = anthropicMessagesToOpenAI( [ // Turn 1: user → assistant (with thinking) @@ -361,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => { { type: 'thinking' as const, thinking: 'Turn 1 reasoning...' }, { type: 'text', text: 'Turn 1 answer' }, ]), - // Turn 2: new user message → previous reasoning should be stripped + // Turn 2: new user message → reasoning should still be preserved + // (DeepSeek requires reasoning_content to be passed back when tool calls are involved) makeUserMsg('question 2'), makeAssistantMsg([ { type: 'thinking' as const, thinking: 'Turn 2 reasoning...' }, @@ -373,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => { ) const assistants = result.filter(m => m.role === 'assistant') - // Turn 1 assistant: reasoning should be stripped (previous turn) - expect((assistants[0] as any).reasoning_content).toBeUndefined() + // Both turns preserve reasoning_content (DeepSeek API requires it for tool calls) + expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...') expect((assistants[0] as any).content).toBe('Turn 1 answer') - // Turn 2 assistant: reasoning should be preserved (current turn) expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...') expect((assistants[1] as any).content).toBe('Turn 2 answer') }) diff --git a/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts b/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts index 2d7cf62ba..286ad55d7 100644 --- a/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts +++ b/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts @@ -26,16 +26,16 @@ export interface ConvertMessagesOptions { * - system prompt → role: "system" message prepended * - tool_use blocks → tool_calls[] on assistant message * - tool_result blocks → role: "tool" messages - * - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true) + * - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back) * - cache_control → stripped */ export function anthropicMessagesToOpenAI( messages: (UserMessage | AssistantMessage)[], systemPrompt: SystemPrompt, - options?: ConvertMessagesOptions, + // options retained for API compatibility; thinking blocks are now always preserved + _options?: ConvertMessagesOptions, ): ChatCompletionMessageParam[] { const result: ChatCompletionMessageParam[] = [] - const enableThinking = options?.enableThinking ?? false // Prepend system prompt as system message const systemText = systemPromptToText(systemPrompt) @@ -46,53 +46,13 @@ export function anthropicMessagesToOpenAI( } satisfies ChatCompletionSystemMessageParam) } - // When thinking mode is on, detect turn boundaries so that reasoning_content - // from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it). - // A "new turn" starts when a user text message appears after at least one assistant response. - const turnBoundaries = new Set() - if (enableThinking) { - let hasSeenAssistant = false - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - if (msg.type === 'assistant') { - hasSeenAssistant = true - } - if (msg.type === 'user' && hasSeenAssistant) { - const content = msg.message.content - // A user message starts a new turn if it contains any non-tool_result content - // (text, image, or other media). Tool results alone do NOT start a new turn - // because they are continuations of the previous assistant tool call. - const startsNewUserTurn = - typeof content === 'string' - ? content.length > 0 - : Array.isArray(content) && - content.some( - (b: any) => - typeof b === 'string' || - (b && - typeof b === 'object' && - 'type' in b && - b.type !== 'tool_result'), - ) - if (startsNewUserTurn) { - turnBoundaries.add(i) - } - } - } - } - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] + for (const msg of messages) { switch (msg.type) { case 'user': result.push(...convertInternalUserMessage(msg)) break case 'assistant': - // Preserve reasoning_content unless we're before a turn boundary - // (i.e., from a previous user Q&A round) - const preserveReasoning = - enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries) - result.push(...convertInternalAssistantMessage(msg, preserveReasoning)) + result.push(...convertInternalAssistantMessage(msg)) break default: break @@ -107,17 +67,6 @@ function systemPromptToText(systemPrompt: SystemPrompt): string { return systemPrompt.filter(Boolean).join('\n\n') } -/** - * Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn). - * A message at index i is "before" a boundary if there exists a boundary j where i < j. - */ -function isBeforeAnyTurnBoundary(i: number, boundaries: Set): boolean { - for (const b of boundaries) { - if (i < b) return true - } - return false -} - function convertInternalUserMessage( msg: UserMessage, ): ChatCompletionMessageParam[] { @@ -213,7 +162,6 @@ function convertToolResult( function convertInternalAssistantMessage( msg: AssistantMessage, - preserveReasoning = false, ): ChatCompletionMessageParam[] { const content = msg.message.content @@ -257,8 +205,10 @@ function convertInternalAssistantMessage( typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input), }, }) - } else if (block.type === 'thinking' && preserveReasoning) { - // DeepSeek thinking mode: preserve reasoning_content for tool call iterations + } else if (block.type === 'thinking') { + // DeepSeek thinking mode: always preserve reasoning_content. + // DeepSeek requires reasoning_content to be passed back in subsequent requests, + // especially when tool calls are involved (returns 400 if missing). const thinkingText = (block as unknown as Record) .thinking if (typeof thinkingText === 'string' && thinkingText) { diff --git a/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts b/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts index 42b00676b..0cad34958 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts +++ b/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts @@ -273,18 +273,6 @@ export const FileEditTool = buildTool({ } const readTimestamp = toolUseContext.readFileState.get(fullFilePath) - if (!readTimestamp || readTimestamp.isPartialView) { - return { - result: false, - behavior: 'ask', - message: - 'File has not been read yet. Read it first before writing to it.', - meta: { - isFilePathAbsolute: String(isAbsolute(file_path)), - }, - errorCode: 6, - } - } // Check if file exists and get its last modified time if (readTimestamp) { diff --git a/packages/builtin-tools/src/tools/FileEditTool/UI.tsx b/packages/builtin-tools/src/tools/FileEditTool/UI.tsx index 3fbd9a34b..417ffa3f5 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/UI.tsx +++ b/packages/builtin-tools/src/tools/FileEditTool/UI.tsx @@ -186,14 +186,6 @@ export function renderToolUseErrorMessage( extractTag(result, 'tool_use_error') ) { const errorMessage = extractTag(result, 'tool_use_error') - // Show a less scary message for intended behavior - if (errorMessage?.includes('File has not been read yet')) { - return ( - - File must be read first - - ) - } if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) { return ( diff --git a/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts b/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts index 399bab62e..009207472 100644 --- a/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts +++ b/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts @@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({ } const readTimestamp = toolUseContext.readFileState.get(fullFilePath) - if (!readTimestamp || readTimestamp.isPartialView) { - return { - result: false, - message: - 'File has not been read yet. Read it first before writing to it.', - errorCode: 2, - } - } // Reuse mtime from the stat above — avoids a redundant statSync via - // getFileModificationTime. The readTimestamp guard above ensures this - // block is always reached when the file exists. - const lastWriteTime = Math.floor(fileMtimeMs) - if (lastWriteTime > readTimestamp.timestamp) { - return { - result: false, - message: - 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', - errorCode: 3, + // getFileModificationTime. + if (readTimestamp) { + const lastWriteTime = Math.floor(fileMtimeMs) + if (lastWriteTime > readTimestamp.timestamp) { + return { + result: false, + message: + 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', + errorCode: 3, + } } } diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index 66702cadf..90d613b61 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -235,11 +235,6 @@ type State = { // microcompact is first enabled, keep sending the header so mid-session // GrowthBook/settings toggles don't bust the prompt cache. cacheEditingHeaderLatched: boolean | null - // Sticky-on latch for clearing thinking from prior tool loops. Triggered - // when >1h since last API call (confirmed cache miss — no cache-hit - // benefit to keeping thinking). Once latched, stays on so the newly-warmed - // thinking-cleared cache isn't busted by flipping back to keep:'all'. - thinkingClearLatched: boolean | null // Current prompt ID (UUID) correlating a user prompt with subsequent OTel events promptId: string | null // Last API requestId for the main conversation chain (not subagents). @@ -414,7 +409,6 @@ function getInitialState(): State { afkModeHeaderLatched: null, fastModeHeaderLatched: null, cacheEditingHeaderLatched: null, - thinkingClearLatched: null, // Current prompt ID promptId: null, lastMainRequestId: undefined, @@ -1729,14 +1723,6 @@ export function setCacheEditingHeaderLatched(v: boolean): void { STATE.cacheEditingHeaderLatched = v } -export function getThinkingClearLatched(): boolean | null { - return STATE.thinkingClearLatched -} - -export function setThinkingClearLatched(v: boolean): void { - STATE.thinkingClearLatched = v -} - /** * Reset beta header latches to null. Called on /clear and /compact so a * fresh conversation gets fresh header evaluation. @@ -1745,7 +1731,6 @@ export function clearBetaHeaderLatches(): void { STATE.afkModeHeaderLatched = null STATE.fastModeHeaderLatched = null STATE.cacheEditingHeaderLatched = null - STATE.thinkingClearLatched = null } export function getPromptId(): string | null { diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index ea8a5dc02..02b68f94f 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -614,17 +614,6 @@ ${CYBER_RISK_INSTRUCTION}`, 'summarize_tool_results', () => SUMMARIZE_TOOL_RESULTS_SECTION, ), - // Numeric length anchors — research shows ~1.2% output token reduction vs - // qualitative "be concise". Ant-only to measure quality impact first. - ...(process.env.USER_TYPE === 'ant' - ? [ - systemPromptSection( - 'numeric_length_anchors', - () => - 'Length limits: keep text between tool calls to \u226425 words. Keep final responses to \u2264100 words unless the task requires more detail.', - ), - ] - : []), ...(feature('TOKEN_BUDGET') ? [ // Cached unconditionally — the "When the user specifies..." phrasing diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index ddc814809..ec4dfaeab 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -124,14 +124,12 @@ import { getPromptCache1hAllowlist, getPromptCache1hEligible, getSessionId, - getThinkingClearLatched, setAfkModeHeaderLatched, setCacheEditingHeaderLatched, setFastModeHeaderLatched, setLastMainRequestId, setPromptCache1hAllowlist, setPromptCache1hEligible, - setThinkingClearLatched, } from 'src/bootstrap/state.js' import { AFK_MODE_BETA_HEADER, @@ -1492,20 +1490,6 @@ async function* queryModel( } } - // Only latch from agentic queries so a classifier call doesn't flip the - // main thread's context_management mid-turn. - let thinkingClearLatched = getThinkingClearLatched() === true - if (!thinkingClearLatched && isAgenticQuery) { - const lastCompletion = getLastApiCompletionTimestamp() - if ( - lastCompletion !== null && - Date.now() - lastCompletion > CACHE_TTL_1HOUR_MS - ) { - thinkingClearLatched = true - setThinkingClearLatched(true) - } - } - const effort = resolveAppliedEffort(options.model, options.effortValue) if (feature('PROMPT_CACHE_BREAK_DETECTION')) { @@ -1684,7 +1668,7 @@ async function* queryModel( const contextManagement = getAPIContextManagement({ hasThinking, isRedactThinkingActive: betasParams.includes(REDACT_THINKING_BETA_HEADER), - clearAllThinking: thinkingClearLatched, + clearAllThinking: false, }) const enablePromptCaching = diff --git a/src/services/api/openai/__tests__/thinking.test.ts b/src/services/api/openai/__tests__/thinking.test.ts index 9b8433282..5a51451a5 100644 --- a/src/services/api/openai/__tests__/thinking.test.ts +++ b/src/services/api/openai/__tests__/thinking.test.ts @@ -100,16 +100,28 @@ describe('isOpenAIThinkingEnabled', () => { expect(isOpenAIThinkingEnabled('TokenService/deepseek-v3.2')).toBe(true) }) - test('returns false when model name is "deepseek-chat"', () => { - expect(isOpenAIThinkingEnabled('deepseek-chat')).toBe(false) + test('returns true when model name is "deepseek-chat"', () => { + expect(isOpenAIThinkingEnabled('deepseek-chat')).toBe(true) }) - test('returns false when model name is "deepseek-v3"', () => { - expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(false) + test('returns true when model name is "deepseek-v3"', () => { + expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(true) }) - test('returns false when model name contains "deepseek" but not "reasoner" or "v3.2"', () => { - expect(isOpenAIThinkingEnabled('deepseek-coder')).toBe(false) + test('returns true when model name is "deepseek-v4"', () => { + expect(isOpenAIThinkingEnabled('deepseek-v4')).toBe(true) + }) + + test('returns true when model name is "deepseek-v4-pro"', () => { + expect(isOpenAIThinkingEnabled('deepseek-v4-pro')).toBe(true) + }) + + test('returns true when model name is "deepseek-r1"', () => { + expect(isOpenAIThinkingEnabled('deepseek-r1')).toBe(true) + }) + + test('returns true when model name contains "deepseek"', () => { + expect(isOpenAIThinkingEnabled('deepseek-coder')).toBe(true) }) test('returns false when model name is "gpt-4o"', () => { @@ -126,6 +138,7 @@ describe('isOpenAIThinkingEnabled', () => { process.env.OPENAI_ENABLE_THINKING = '1' expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(true) + expect(isOpenAIThinkingEnabled('qwen-3')).toBe(true) }) test('OPENAI_ENABLE_THINKING=false disables thinking even for deepseek-reasoner', () => { diff --git a/src/services/api/openai/requestBody.ts b/src/services/api/openai/requestBody.ts index e8f93ecfa..09163c834 100644 --- a/src/services/api/openai/requestBody.ts +++ b/src/services/api/openai/requestBody.ts @@ -25,9 +25,9 @@ export function isOpenAIThinkingEnabled(model: string): boolean { if (isEnvDefinedFalsy(process.env.OPENAI_ENABLE_THINKING)) return false // Explicit enable if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true - // Auto-detect from model name (deepseek-reasoner and DeepSeek-V3.2 support thinking mode) + // Auto-detect from model name (all DeepSeek models support thinking mode) const modelLower = model.toLowerCase() - return modelLower.includes('deepseek-reasoner') || modelLower.includes('deepseek-v3.2') + return modelLower.includes('deepseek') } /** diff --git a/src/services/api/src/bootstrap/state.ts b/src/services/api/src/bootstrap/state.ts index 24331fe0d..ec9794128 100644 --- a/src/services/api/src/bootstrap/state.ts +++ b/src/services/api/src/bootstrap/state.ts @@ -6,14 +6,12 @@ export type getFastModeHeaderLatched = any; export type getLastApiCompletionTimestamp = any; export type getPromptCache1hAllowlist = any; export type getPromptCache1hEligible = any; -export type getThinkingClearLatched = any; export type setAfkModeHeaderLatched = any; export type setCacheEditingHeaderLatched = any; export type setFastModeHeaderLatched = any; export type setLastMainRequestId = any; export type setPromptCache1hAllowlist = any; export type setPromptCache1hEligible = any; -export type setThinkingClearLatched = any; export type addToTotalDurationState = any; export type consumePostCompaction = any; export type getIsNonInteractiveSession = any; diff --git a/src/utils/effort.ts b/src/utils/effort.ts index bb920b38c..4cf530995 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -348,13 +348,13 @@ export function getDefaultEffortForModel( model.toLowerCase().includes('opus-4-6') ) { if (isProSubscriber()) { - return 'medium' + return 'high' } if ( getOpusDefaultEffortConfig().enabled && (isMaxSubscriber() || isTeamSubscriber()) ) { - return 'medium' + return 'high' } } From 047c85fcbf1266ccb21172fc5fc35e842a50dc01 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 19:49:26 +0800 Subject: [PATCH 31/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20DeepSeek=20V4?= =?UTF-8?q?=20reasoning=5Fcontent=20=E5=9B=9E=E4=BC=A0=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=20400=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩大模型名称检测范围,匹配所有 deepseek 模型(V4、R1 等) - 始终保留 thinking blocks 为 reasoning_content 回传给 API - 移除有 bug 的 turn boundary 剥离逻辑 Co-Authored-By: Claude Opus 4.7 --- teach-me/learner-profile.md | 14 +++++++ teach-me/vllm/session.md | 25 ++++++++++++ teach-me/vllm/vllm-notes.md | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 teach-me/learner-profile.md create mode 100644 teach-me/vllm/session.md create mode 100644 teach-me/vllm/vllm-notes.md diff --git a/teach-me/learner-profile.md b/teach-me/learner-profile.md new file mode 100644 index 000000000..69272d71d --- /dev/null +++ b/teach-me/learner-profile.md @@ -0,0 +1,14 @@ +# Learner Profile +Updated: 2026-04-24 + +## Style +- Learns best with: Analogies (Memory page <-> Capsule hotel), Concrete trade-offs (Latency vs Throughput) +- Strength: Strong logical intuition regarding memory constraints. +- Pace: Fast. Grasped PagedAttention/TP concepts quickly from first principles. + +## Patterns +- Tends to focus on memory usage ("is it OOM?"), which is a good instinct for inference tuning. +- Needs precision on API flags (e.g., `--tensor-parallel-size`). + +## Topics +- vLLM Inference Optimization (10/10 concepts mastered, 2026-04-24) diff --git a/teach-me/vllm/session.md b/teach-me/vllm/session.md new file mode 100644 index 000000000..d8b9d7773 --- /dev/null +++ b/teach-me/vllm/session.md @@ -0,0 +1,25 @@ +# Session: vLLM Inference Optimization +- Level: Beginner (Target: Inference Optimization) +- Started: 2026-04-24 +- Status: Mastered + +## Concepts +1. ✅ LLM 推理的两个阶段 (Prefill vs Decode) +2. ✅ KV Cache +3. ✅ 显存瓶颈与碎片化 +4. ✅ PagedAttention +5. ✅ vLLM 架构 (Scheduler, Worker) +6. ✅ 实战部署 (--dtype, openai api) +7. ✅ 量化 (AWQ/GPTQ vs 暴力 dtype) +8. ✅ Tensor Parallel (TP, NCCL) +9. ✅ 性能参数 (--gpu-memory-utilization) +10. ✅ Chunked Prefill + +## Misconceptions +- [Chunked Prefill]: 原以为主要目的是降低显存。 + - 纠正:确实降低了**峰值激活显存**,但核心目的是降低**Latency (卡顿感)**。 + +## Log +- Diagnosed: Beginner +- Mastery: Intuitive understanding of memory constraints and fragmentation is strong. +- Final Quiz: 3/3 correct (with minor clarification needed on TP params). diff --git a/teach-me/vllm/vllm-notes.md b/teach-me/vllm/vllm-notes.md new file mode 100644 index 000000000..fabc678a1 --- /dev/null +++ b/teach-me/vllm/vllm-notes.md @@ -0,0 +1,81 @@ + +# vLLM 核心原理与性能调优笔记 + +## 1. vLLM 是什么? +一个**高吞吐量、低延迟**的大语言模型推理服务框架。 +* **核心目标**:榨干 GPU 性能,让同一个显卡能同时服务更多并发请求(Throughput),并减少卡顿(Latency)。 +* **一句话理解**:LLM 推理版的"显存管理大师与调度大师"。 + +--- + +## 2. 为什么 vLLM 快?(核心原理) + +### 2.1 显存的痛点:KV Cache 与 显存碎片化 +LLM 推理分为两个阶段: +1. **Prefill (预填)**:处理 Prompt,生成第一个 token。 +2. **Decode (解码)**:基于之前的 token,一个接一个地生成下一个 token。 +* **KV Cache**:为了避免每次 Decode 都重新计算一遍之前所有 token 的 Attention,必须把这些中间结果 (KV) 存在 GPU 显存里。 +* **传统框架痛点**:一次申请固定长度的连续显存。如果一个请求用了 50% 的空间就结束,剩下的显存因为"不连续"而无法分给其他请求,导致显存利用率只有 20% 左右(**显存碎片化**)。 + +### 2.2 PagedAttention (分页存储技术 —— vLLM 的大杀器) +借鉴了操作系统**虚拟内存分页**的设计。 +* **做法**:不再一次性分配一大块显存,而是把 KV Cache 切分成固定大小的 **Block**。每个 Block 存在显存的任意位置,通过 **Block Table** 映射。 +* **效果**:空闲的 Block 随时分配给新请求。显存利用率从 20% 提升到 90%+。 +* **好处**:彻底解决了碎片化问题,使 Concurrent Batching 成为可能。 + +### 2.3 Continuous Batching (连续批处理) +* **Static Batching (传统)**:一个 Batch 里的请求必须一起跑。哪怕 9 个请求生成了 10 个 token 就结束了,必须等第 10 个请求生成完(比如 500 个 token)才能结束。这导致 GPU 在后期大量空转。 +* **Continuous Batching**:一个请求一旦结束,立刻从 Batch 中剔除,并从队列里拉一个新请求塞进去。GPU 始终在满负荷工作,**吞吐量呈指数级提升**。 + +--- + +## 3. 性能与显存进阶优化 + +### 3.1 量化 (Quantization) +把高精度的权重(如 FP16)压缩成低精度的版本(如 INT8, INT4, FP8)。 +* **作用**:**减少显存占用(装下更大的模型);提高推理速度(低精度计算更快)**。 +* **AWQ / GPTQ vs 暴力降低精度**: + * 模型中有极少数关键权重 (**Outliers/异常值**)。如果暴力降低精度,这部分信息丢失,模型性能(IQ)会暴跌。 + * **AWQ 等算法**会先探测哪些权重敏感,针对这些权重特殊保护(保留更高精度),其余部分暴力压缩。类似于“好钢用在刀刃上”。 + +### 3.2 多卡并行 (Tensor Parallelism - TP) +当模型太大,单张显卡(如 A100 80G)装不下(比如 70B FP16 需要 140G 显存): +* **做法**:把模型的每一层权重矩阵切分成 N 份(N = GPU 数),分配给多张卡。每执行一步,各卡算好自己那份,再通过 **NCCL 协议** 在 GPU 之间交换中间结果并合并。 +* **代价**:**通信带宽瓶颈**。如果模型不大,切分后的通信延迟会抵消计算带来的速度提升。 + +### 3.4 分块预填 (Chunked Prefill) +* **背景**:在 Continuous Batching 中,如果一个巨大 Prompt (100k) 进来,它的 Prefill 计算量极其庞大,可能会导致其他小请求被阻塞(卡顿)。 +* **做法**:把大 Prompt 的 Prefill 阶段切成小块,穿插在小请求的 Decode 阶段之间执行。 +* **效果**:大幅降低**Latency(卡顿感)**,并降低 Prefill 的**峰值显存占用**,允许调度更多并发请求。 + +### 3.5 其他关键优化 +* **Prefix Caching (前缀缓存)**:如果应用有大量重复的 System Prompt(比如 500 tokens 的角色设定),可以直接复用之前的 KV Cache,不用重新计算。 +* **Stream Processing (流式处理)**:不用等全部生成完才返回,算出几个 token 就返回给用户,降低“首字延迟” (TTFT)。 + +--- + +## 4. 实战参数大全 (Cheat Sheet) + +```bash +# 1. 加载 70B 模型,4 张 A100-80G +# 使用 4 卡切分 (TP=4),自动选择精度 (通常是 FP16) +# 最大支持 8k 上下文 +# 使用 PagedAttention 优化显存 (默认开启) +vllm serve Qwen/Qwen2.5-70B-Instruct \ + --tensor-parallel-size 4 \ + --dtype auto \ + --max-model-len 8192 \ + --gpu-memory-utilization 0.95 + +# 2. 量化加载 (如果只有一张卡,想用 INT4 加载 70B) +# (需要模型支持 AWQ 格式文件) +vllm serve Qwen/Qwen2.5-70B-Instruct-AWQ \ + --quantization awq +``` + +| 参数 | 作用 | 调优建议 | +|------|------|----------| +| `--tensor-parallel-size N` | 多卡切分 (TP) | 大模型 (30B+) 才用。卡越多,通信越慢,单请求延迟越高,但吞吐量越高。 | +| `--max-model-len N` | 最大上下文长度 | **越小越好**。显存省得越多,并发请求量 (Batch Size) 越大。按需设置 (如 4096)。 | +| `--gpu-memory-utilization` | 显存利用率阈值 | 建议 `0.90` 或 `0.95`。留一些余量给 Activation (激活值) 避免 OOM 崩溃。 | +| `--enable-prefix-caching` | 开启前缀缓存 | Agentic 场景 / Long context 场景推荐开启。大幅降低重复 Prompt 的计算时间。 | From e0c8e9dafca33c31b6261dc413127a1d52050192 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 20:32:53 +0800 Subject: [PATCH 32/33] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=E5=AD=A6?= =?UTF-8?q?=E4=B9=A0=E6=96=87=E4=BB=B6=E5=A4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bf422f8e9..f84c208ba 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ data .codex/skills/.system/** !.codex/prompts/ !.codex/prompts/** +teach-me From e38d45460ec1204b6accc950a914952d25a5444f Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 24 Apr 2026 21:16:15 +0800 Subject: [PATCH 33/33] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Windows=20Nod?= =?UTF-8?q?e.js=20=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9=E5=9B=A0=20stdin.re?= =?UTF-8?q?f()=20=E6=B3=84=E6=BC=8F=E5=AF=BC=E8=87=B4=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E6=8C=82=E8=B5=B7=20(#353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startCapturingEarlyInput() 调用 stdin.ref() 后,如果 Ink 未能接管 (如 raw mode 不支持或 setup 阶段异常),unref() 永远不会被调用, 导致 Node.js 事件循环无法退出。修复包括: - stopCapturingEarlyInput() 中补充 stdin.unref() 调用 - 新增 10s 安全阀定时器自动清理 leaked ref() - Ink App.componentWillUnmount 兜底 unref() 非 TTY stdin Co-authored-by: Claude Opus 4.7 --- packages/@ant/ink/src/components/App.tsx | 9 +++++ src/utils/earlyInput.ts | 42 ++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/@ant/ink/src/components/App.tsx b/packages/@ant/ink/src/components/App.tsx index 8b7f5bdaa..543cd359b 100644 --- a/packages/@ant/ink/src/components/App.tsx +++ b/packages/@ant/ink/src/components/App.tsx @@ -286,6 +286,15 @@ export default class App extends PureComponent { // ignore calling setRawMode on an handle stdin it cannot be called if (this.isRawModeSupported()) { this.handleSetRawMode(false) + } else { + // Even when raw mode was never enabled (e.g. non-TTY stdin on + // Windows Node.js), ensure stdin is unref'd so the process can + // exit. earlyInput may have called ref() before Ink mounted. + try { + this.props.stdin.unref() + } catch { + // stdin may already be destroyed + } } } diff --git a/src/utils/earlyInput.ts b/src/utils/earlyInput.ts index a5d58db5e..3d8d03554 100644 --- a/src/utils/earlyInput.ts +++ b/src/utils/earlyInput.ts @@ -19,6 +19,8 @@ let earlyInputBuffer = '' let isCapturing = false // Reference to the readable handler so we can remove it later let readableHandler: (() => void) | null = null +// Safety valve: auto-cleanup after timeout so stdin.ref() never leaks +let safetyTimer: ReturnType | null = null /** * Start capturing stdin data early, before the REPL is initialized. @@ -60,6 +62,20 @@ export function startCapturingEarlyInput(): void { } process.stdin.on('readable', readableHandler) + + // Safety valve: if Ink never takes over within 10s (e.g. setup dialog + // stalls, or an error prevents Ink mount on Windows), unref stdin so + // the process doesn't hang forever. The REPL's Ink App normally calls + // consumeEarlyInput() → stopCapturingEarlyInput() long before this. + safetyTimer = setTimeout(() => { + if (isCapturing) { + stopCapturingEarlyInput() + } + }, 10_000) + // Don't let the timer itself keep the event loop alive + if (safetyTimer && typeof safetyTimer === 'object' && 'unref' in safetyTimer) { + safetyTimer.unref() + } } catch { // If we can't set raw mode, just silently continue without early capture isCapturing = false @@ -172,14 +188,34 @@ export function stopCapturingEarlyInput(): void { isCapturing = false + // Clear safety timer + if (safetyTimer) { + clearTimeout(safetyTimer) + safetyTimer = null + } + if (readableHandler) { process.stdin.removeListener('readable', readableHandler) readableHandler = null } - // Don't reset stdin state - the REPL's Ink App will manage stdin state. - // If we call setRawMode(false) here, it can interfere with the REPL's - // own stdin setup which happens around the same time. + // Undo the ref() from startCapturingEarlyInput so the event loop isn't + // kept alive if Ink never takes over (e.g. raw mode unsupported on + // Windows Node.js, or an error during setup). Ink's own + // handleSetRawMode(true) calls stdin.ref() again, and its + // handleSetRawMode(false) / unmount path calls stdin.unref(), so this + // unref is safe even when Ink does take over — the two ref/unref calls + // balance out. + try { + process.stdin.unref() + } catch { + // stdin may already be destroyed + } + + // Don't reset setRawMode here — Ink's App.handleSetRawMode(true) + // calls stopCapturingEarlyInput() synchronously and then immediately + // calls setRawMode(true) + ref() on the same stdin, so toggling it + // off here would add a visible flicker on Windows. } /**