openclaudeimproved
§ 00 · Source-level Observatory

openclaude-improved 多模型 Claude Code 兼容层

openclaude-improved 把 Claude Code 风格的编码代理运行时扩展到 OpenAI、Gemini、Ollama、Codex、GitHub Copilot 等 provider,同时保留 bridge、权限、安装、会话和 SDK 兼容路径。

★ 583
GitHub Stars
50M
源码快照
3.2K
Files
2026-07-27
Last commit
CLI / SDK ├─ QueryEngine submitMessage ├─ Bridge session runner ├─ Provider profile resolver └─ Installer / diagnostics
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个仓库不是简单换壳 CLI。src/QueryEngine.ts:187 把单轮对话生命周期抽成可复用类,src/bridge/replBridge.ts:92 又把远端会话的初始化参数显式化,因此它的核心价值是把交互式编码代理拆成 SDK、bridge、provider 和 installer 几条可替换链路。

它值得源码级阅读,是因为安全边界写在实现里而不是只写在 README:src/bridge/sessionRunner.ts:24 用环境变量 allowlist 阻断父进程 secret 继承,src/bridge/workSecret.ts:64 拒绝非本地明文 HTTP,src/bridge/sessionRunner.test.ts:34 又用测试锁住任意 secret 不外泄。

▸核心数据

架构模式
CLI runtime + bridge transport + provider profile + installer hardening
核心抽象
QueryEngine、BridgeCoreParams、SessionSpawner、ProviderProfile
性能/精度数据
仓库主打 200+ 模型兼容,源码侧重点是 provider route 和 bridge lifecycle
下游依赖
Bun、React Ink、Anthropic SDK、MCP SDK、OpenAI-compatible APIs
§ 01 · ARCHITECTURE

Bridge 如何把本地 CLI 接到远端会话

▸子进程环境 allowlist

Bridge 层的设计重点是“只传该传的”。src/bridge/sessionRunner.ts:56 从父环境构造子进程环境,src/bridge/sessionRunner.ts:72 单独注入 session access token,避免把用户 shell 里的 API key、数据库密码或代理 secret 直接交给子进程。

src/bridge/sessionRunner.ts · L24-80TypeScript
24const CHILD_ENV_ALLOWLIST = new Set([
25  // System / shell
26  'PATH', 'HOME', 'USERPROFILE', 'HOMEPATH', 'HOMEDRIVE',
27  'USERNAME', 'USER', 'LOGNAME',
28  'TEMP', 'TMP', 'TMPDIR',
29  'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC', 'WINDIR',
30  'LANG', 'LC_ALL', 'LC_CTYPE',
31  // Node.js runtime
32  'NODE_OPTIONS', 'NODE_PATH', 'NODE_ENV',
33  // OpenClaude session / bridge (non-secret)
34  'CLAUDE_CODE_ENVIRONMENT_KIND',
35  'CLAUDE_CODE_FORCE_SANDBOX',
36  'CLAUDE_CODE_BUBBLEWRAP',
37  'CLAUDE_CODE_ENTRYPOINT',
38  'CLAUDE_CODE_COORDINATOR_MODE',
39  'CLAUDE_CODE_PERMISSIONS_VERSION',
40  'CLAUDE_CODE_PERMISSIONS_SETTING',
41  // Display / terminal
42  'TERM', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR',
43])
44
45type BuildChildEnvOpts = {
46  accessToken: string
47  useCcrV2: boolean
48  workerEpoch?: number
49  sandbox?: boolean
50}
51
52/**
53 * Build the environment for the child CC process from an explicit allowlist.
54 * This prevents the parent's API keys and credentials from leaking to the child.
55 */
56export function buildChildEnv(
57  parentEnv: NodeJS.ProcessEnv,
58  opts: BuildChildEnvOpts,
59): NodeJS.ProcessEnv {
60  // Start from allowlisted parent vars only
61  const env: NodeJS.ProcessEnv = {}
62  for (const key of Object.keys(parentEnv)) {
63    if (CHILD_ENV_ALLOWLIST.has(key)) {
64      env[key] = parentEnv[key]
65    }
66  }
67
68  // Bridge-required overrides
69  env.CLAUDE_CODE_OAUTH_TOKEN = undefined // explicitly strip
70  env.CLAUDE_CODE_ENVIRONMENT_KIND = 'bridge'
71  if (opts.sandbox) env.CLAUDE_CODE_FORCE_SANDBOX = '1'
72  env.CLAUDE_CODE_SESSION_ACCESS_TOKEN = opts.accessToken
73  env.CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 = '1'
74  if (opts.useCcrV2) {
75    env.CLAUDE_CODE_USE_CCR_V2 = '1'
76    env.CLAUDE_CODE_WORKER_EPOCH = String(opts.workerEpoch)
77  }
78
79  return env
80}

▸会话 ID 与权限请求

会话 runner 还处理文件名安全与权限请求结构。src/bridge/sessionRunner.ts:87 会清理 session id,src/bridge/sessionRunner.ts:96 定义 control_request 结构,src/bridge/sessionRunner.ts:132 把工具名转成人类可读的活动摘要。

src/bridge/sessionRunner.ts · L87-167TypeScript
87export function safeFilenameId(id: string): string {
88  return id.replace(/[^a-zA-Z0-9_-]/g, '_')
89}
90
91/**
92 * A control_request emitted by the child CLI when it needs permission to
93 * execute a **specific** tool invocation (not a general capability check).
94 * The bridge forwards this to the server so the user can approve/deny.
95 */
96export type PermissionRequest = {
97  type: 'control_request'
98  request_id: string
99  request: {
100    /** Per-invocation permission check — "may I run this tool with these inputs?" */
101    subtype: 'can_use_tool'
102    tool_name: string
103    input: Record<string, unknown>
104    tool_use_id: string
105  }
106}
107
108type SessionSpawnerDeps = {
109  execPath: string
110  /**
111   * Arguments that must precede the CLI flags when spawning. Empty for
112   * compiled binaries (where execPath is the claude binary itself); contains
113   * the script path (process.argv[1]) for npm installs where execPath is the
114   * node runtime. Without this, node sees --sdk-url as a node option and
115   * exits with "bad option: --sdk-url" (see anthropics/claude-code#28334).
116   */
117  scriptArgs: string[]
118  env: NodeJS.ProcessEnv
119  verbose: boolean
120  sandbox: boolean
121  debugFile?: string
122  permissionMode?: string
123  onDebug: (msg: string) => void
124  onActivity?: (sessionId: string, activity: SessionActivity) => void
125  onPermissionRequest?: (
126    sessionId: string,
127    request: PermissionRequest,
128    accessToken: string,
129  ) => void
130}
131
132/** Map tool names to human-readable verbs for the status display. */
133const TOOL_VERBS: Record<string, string> = {
134  Read: 'Reading',
135  Write: 'Writing',
136  Edit: 'Editing',
137  MultiEdit: 'Editing',
138  Bash: 'Running',
139  Glob: 'Searching',
140  Grep: 'Searching',
141  WebFetch: 'Fetching',
142  WebSearch: 'Searching',
143  Task: 'Running task',
144  FileReadTool: 'Reading',
145  FileWriteTool: 'Writing',
146  FileEditTool: 'Editing',
147  GlobTool: 'Searching',
148  GrepTool: 'Searching',
149  BashTool: 'Running',
150  NotebookEditTool: 'Editing notebook',
151  LSP: 'LSP',
152}
153
154function toolSummary(name: string, input: Record<string, unknown>): string {
155  const verb = TOOL_VERBS[name] ?? name
156  const target =
157    (input.file_path as string) ??
158    (input.filePath as string) ??
159    (input.pattern as string) ??
160    (input.command as string | undefined)?.slice(0, 60) ??
161    (input.url as string) ??
162    (input.query as string) ??
163    ''
164  if (target) {
165    return `${verb} ${target}`
166  }
167  return verb

▸深度阅读入口

深度阅读 · Bridge session isolation
§ 02 · CORE MODULES

QueryEngine 如何承载无界多轮会话

▸QueryEngine 配置面

QueryEngine 是 headless/SDK 路径的主循环。src/QueryEngine.ts:132 把工具、命令、MCP clients、agents、budget 和 schema 都放进配置对象,src/QueryEngine.ts:213 让每次 submitMessage 作为异步生成器产出 SDKMessage。

src/QueryEngine.ts · L132-211TypeScript
132export type QueryEngineConfig = {
133  cwd: string
134  tools: Tools
135  commands: Command[]
136  mcpClients: MCPServerConnection[]
137  agents: AgentDefinition[]
138  canUseTool: CanUseToolFn
139  getAppState: () => AppState
140  setAppState: (f: (prev: AppState) => AppState) => void
141  initialMessages?: Message[]
142  readFileCache: FileStateCache
143  customSystemPrompt?: string
144  appendSystemPrompt?: string
145  userSpecifiedModel?: string
146  fallbackModel?: string
147  thinkingConfig?: ThinkingConfig
148  maxTurns?: number
149  maxBudgetUsd?: number
150  taskBudget?: { total: number }
151  jsonSchema?: Record<string, unknown>
152  verbose?: boolean
153  replayUserMessages?: boolean
154  /** Handler for URL elicitations triggered by MCP tool -32042 errors. */
155  handleElicitation?: ToolUseContext['handleElicitation']
156  includePartialMessages?: boolean
157  setSDKStatus?: (status: SDKStatus) => void
158  abortController?: AbortController
159  orphanedPermission?: OrphanedPermission
160  /**
161   * Snip-boundary handler: receives each yielded system message plus the
162   * current mutableMessages store. Returns undefined if the message is not a
163   * snip boundary; otherwise returns the replayed snip result. Injected by
164   * ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside
165   * the gated module (keeps QueryEngine free of excluded strings and testable
166   * despite feature() returning false under bun test). SDK-only: the REPL
167   * keeps full history for UI scrollback and projects on demand via
168   * projectSnippedView; QueryEngine truncates here to bound memory in long
169   * headless sessions (no UI to preserve).
170   */
171  snipReplay?: (
172    yieldedSystemMsg: Message,
173    store: Message[],
174  ) => { messages: Message[]; executed: boolean } | undefined
175  query?: typeof defaultQuery
176}
177
178/**
179 * QueryEngine owns the query lifecycle and session state for a conversation.
180 * It extracts the core logic from ask() into a standalone class that can be
181 * used by both the headless/SDK path and (in a future phase) the REPL.
182 *
183 * One QueryEngine per conversation. Each submitMessage() call starts a new
184 * turn within the same conversation. State (messages, file cache, usage, etc.)
185 * persists across turns.
186 */
187export class QueryEngine {
188  private config: QueryEngineConfig
189  private mutableMessages: Message[]
190  private abortController: AbortController
191  private permissionDenials: SDKPermissionDenial[]
192  private totalUsage: NonNullableUsage
193  private hasHandledOrphanedPermission = false
194  private readFileState: FileStateCache
195  private autoCompactTracking: AutoCompactTrackingState | undefined
196  // Turn-scoped skill discovery tracking (feeds was_discovered on
197  // tengu_skill_tool_invocation). Must persist across the two
198  // processUserInputContext rebuilds inside submitMessage, but is cleared
199  // at the start of each submitMessage to avoid unbounded growth across
200  // many turns in SDK mode.
201  private discoveredSkillNames = new Set<string>()
202  private loadedNestedMemoryPaths = new Set<string>()
203
204  constructor(config: QueryEngineConfig) {
205    this.config = config
206    this.mutableMessages = config.initialMessages ?? []
207    this.abortController = config.abortController ?? createAbortController()
208    this.permissionDenials = []
209    this.readFileState = config.readFileCache
210    this.totalUsage = EMPTY_USAGE
211  }

▸输入处理上下文

每一轮开始时,src/QueryEngine.ts:242 清空本轮 skill discovery,src/QueryEngine.ts:248 包装 canUseTool 记录拒绝,src/QueryEngine.ts:339 再构造 processUserInputContext,把 slash command、file history、nested memory 和动态 skill 发现放在同一个可变上下文里。

src/QueryEngine.ts · L213-430TypeScript
213  async *submitMessage(
214    prompt: string | ContentBlockParam[],
215    options?: { uuid?: string; isMeta?: boolean },
216  ): AsyncGenerator<SDKMessage, void, unknown> {
217    const {
218      cwd,
219      commands,
220      tools,
221      mcpClients,
222      verbose = false,
223      thinkingConfig,
224      maxTurns,
225      maxBudgetUsd,
226      taskBudget,
227      canUseTool,
228      customSystemPrompt,
229      appendSystemPrompt,
230      userSpecifiedModel,
231      fallbackModel,
232      jsonSchema,
233      getAppState,
234      setAppState,
235      replayUserMessages = false,
236      includePartialMessages = false,
237      agents = [],
238      setSDKStatus,
239      orphanedPermission,
240    } = this.config
241
242    this.discoveredSkillNames.clear()
243    setCwd(cwd)
244    const persistSession = !isSessionPersistenceDisabled()
245    const startTime = Date.now()
246
247    // Wrap canUseTool to track permission denials
248    const wrappedCanUseTool: CanUseToolFn = async (
249      tool,
250      input,
251      toolUseContext,
252      assistantMessage,
253      toolUseID,
254      forceDecision,
255    ) => {
256      const result = await canUseTool(
257        tool,
258        input,
259        toolUseContext,
260        assistantMessage,
261        toolUseID,
262        forceDecision,
263      )
264
265      // Track denials for SDK reporting
266      if (result.behavior !== 'allow') {
267        this.permissionDenials.push({
268          tool_name: sdkCompatToolName(tool.name),
269          tool_use_id: toolUseID,
270          tool_input: input,
271        })
272      }
273
274      return result
275    }
276
277    const initialAppState = getAppState()
278    const initialMainLoopModel = userSpecifiedModel
279      ? parseUserSpecifiedModel(userSpecifiedModel)
280      : getMainLoopModel()
281
282    const initialThinkingConfig: ThinkingConfig = thinkingConfig
283      ? thinkingConfig
284      : shouldEnableThinkingByDefault() !== false
285        ? { type: 'adaptive' }
286        : { type: 'disabled' }
287
288    headlessProfilerCheckpoint('before_getSystemPrompt')
289    // Narrow once so TS tracks the type through the conditionals below.
290    const customPrompt =
291      typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined
292    const {
293      defaultSystemPrompt,
294      userContext: baseUserContext,
295      systemContext,
296    } = await fetchSystemPromptParts({
297      tools,
298      mainLoopModel: initialMainLoopModel,
299      additionalWorkingDirectories: Array.from(
300        initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(),
301      ),
302      mcpClients,
303      customSystemPrompt: customPrompt,
304    })
305    headlessProfilerCheckpoint('after_getSystemPrompt')
306    const userContext = {
307      ...baseUserContext,
308      ...getCoordinatorUserContext(
309        mcpClients,
310        isScratchpadEnabled() ? getScratchpadDir() : undefined,
311      ),
312    }
313
314    // When an SDK caller provides a custom system prompt AND has set
315    // CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt.
316    // The env var is an explicit opt-in signal — the caller has wired up
317    // a memory directory and needs Claude to know how to use it (which
318    // Write/Edit tools to call, MEMORY.md filename, loading semantics).
319    // The caller can layer their own policy text via appendSystemPrompt.
320    const memoryMechanicsPrompt =
321      customPrompt !== undefined && hasAutoMemPathOverride()
322        ? await loadMemoryPrompt()
323        : null
324
325    const systemPrompt = asSystemPrompt([
326      ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
327      ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
328      ...(appendSystemPrompt ? [appendSystemPrompt] : []),
329    ])
330
331    // Register function hook for structured output enforcement
332    const hasStructuredOutputTool = tools.some(t =>
333      toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME),
334    )
335    if (jsonSchema && hasStructuredOutputTool) {
336      registerStructuredOutputEnforcement(setAppState, getSessionId())
337    }
338
339    let processUserInputContext: ProcessUserInputContext = {
340      messages: this.mutableMessages,
341      // Slash commands that mutate the message array (e.g. /clear)
342      // call setMessages(fn).  In interactive mode this writes back to
343      // AppState; in print mode we write back to mutableMessages so the
344      // rest of the query loop (push at :389, snapshot at :392) sees
345      // the result.  The second processUserInputContext below (after
346      // slash-command processing) keeps the no-op — nothing else calls
347      // setMessages past that point.
348      setMessages: fn => {
349        this.mutableMessages = fn(this.mutableMessages)
350      },
351      onChangeAPIKey: () => {},
352      handleElicitation: this.config.handleElicitation,
353      options: {
354        commands,
355        debug: false, // we use stdout, so don't want to clobber it
356        tools,
357        verbose,
358        mainLoopModel: initialMainLoopModel,
359        thinkingConfig: initialThinkingConfig,
360        mcpClients,
361        mcpResources: {},
362        ideInstallationStatus: null,
363        isNonInteractiveSession: true,
364        customSystemPrompt,
365        appendSystemPrompt,
366        agentDefinitions: { activeAgents: agents, allAgents: agents },
367        theme: resolveThemeSetting(getGlobalConfig().theme),
368        maxBudgetUsd,
369      },
370      getAppState,
371      setAppState,
372      abortController: this.abortController,
373      readFileState: this.readFileState,
374      nestedMemoryAttachmentTriggers: new Set<string>(),
375      loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
376      dynamicSkillDirTriggers: new Set<string>(),
377      discoveredSkillNames: this.discoveredSkillNames,
378      setInProgressToolUseIDs: () => {},
379      setResponseLength: () => {},
380      updateFileHistoryState: (
381        updater: (prev: FileHistoryState) => FileHistoryState,
382      ) => {
383        setAppState(prev => {
384          const updated = updater(prev.fileHistory)
385          if (updated === prev.fileHistory) return prev
386          return { ...prev, fileHistory: updated }
387        })
388      },
389      updateAttributionState: (
390        updater: (prev: AttributionState) => AttributionState,
391      ) => {
392        setAppState(prev => {
393          const updated = updater(prev.attribution)
394          if (updated === prev.attribution) return prev
395          return { ...prev, attribution: updated }
396        })
397      },
398      setSDKStatus,
399    }
400
401    // Handle orphaned permission (only once per engine lifetime)
402    if (orphanedPermission && !this.hasHandledOrphanedPermission) {
403      this.hasHandledOrphanedPermission = true
404      for await (const message of handleOrphanedPermission(
405        orphanedPermission,
406        tools,
407        this.mutableMessages,
408        processUserInputContext,
409      )) {
410        yield message
411      }
412    }
413
414    const {
415      messages: messagesFromUserInput,
416      shouldQuery,
417      allowedTools,
418      model: modelFromUserInput,
419      resultText,
420    } = await processUserInput({
421      input: prompt,
422      mode: 'prompt',
423      setToolJSX: () => {},
424      context: {
425        ...processUserInputContext,
426        messages: this.mutableMessages,
427      },
428      messages: this.mutableMessages,
429      uuid: options?.uuid,
430      isMeta: options?.isMeta,

▸深度阅读入口

深度阅读 · Query lifecycle
§ 03 · KEY FLOW

Provider 与安装链路的现实边界

▸Provider profile

Provider 配置不是一个字符串枚举那么简单。src/utils/providerProfile.ts:58 收拢大量环境变量,src/utils/providerProfile.ts:124 明确列出 anthropic、openai、gemini、codex、ollama 等兼容 profile,src/utils/providerProfile.ts:215 又把 secret source 设计成开放类型,避免新增 provider 时无法统一脱敏。

src/utils/providerProfile.ts · L49-122TypeScript
49export const PROFILE_FILE_NAME = '.openclaude-profile.json'
50export const DEFAULT_GEMINI_BASE_URL =
51  'https://generativelanguage.googleapis.com/v1beta/openai'
52export const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview'
53export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
54export const DEFAULT_MISTRAL_MODEL = 'mistral-vibe-cli-latest'
55export const DEFAULT_STARTUP_PROVIDER_ENV_VAR =
56  'CLAUDE_CODE_DEFAULT_STARTUP_PROVIDER'
57
58const PROFILE_ENV_KEYS = [
59  'CLAUDE_CODE_USE_OPENAI',
60  'CLAUDE_CODE_USE_GITHUB',
61  'CLAUDE_CODE_USE_GEMINI',
62  'CLAUDE_CODE_USE_MISTRAL',
63  'CLAUDE_CODE_USE_BEDROCK',
64  'CLAUDE_CODE_USE_VERTEX',
65  'CLAUDE_CODE_USE_FOUNDRY',
66  'ANTHROPIC_BASE_URL',
67  'ANTHROPIC_MODEL',
68  'ANTHROPIC_API_KEY',
69  'ANTHROPIC_AUTH_TOKEN',
70  'ANTHROPIC_CUSTOM_HEADERS',
71  'ANTHROPIC_BEDROCK_BASE_URL',
72  'ANTHROPIC_VERTEX_BASE_URL',
73  'OPENAI_BASE_URL',
74  'OPENAI_API_BASE',
75  'OPENAI_MODEL',
76  'OPENAI_API_FORMAT',
77  'OPENAI_AZURE_STYLE',
78  'OPENAI_AUTH_HEADER',
79  'OPENAI_AUTH_SCHEME',
80  'OPENAI_AUTH_HEADER_VALUE',
81  'OPENAI_API_KEYS',
82  'OPENAI_API_KEY',
83  'GITHUB_COPILOT_KEY',
84  'GITHUB_ENTERPRISE_URL',
85  'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS',
86  'CODEX_API_KEY',
87  'CODEX_CREDENTIAL_SOURCE',
88  'CHATGPT_ACCOUNT_ID',
89  'CODEX_ACCOUNT_ID',
90  'GEMINI_API_KEY',
91  'GEMINI_AUTH_MODE',
92  'GEMINI_ACCESS_TOKEN',
93  'GEMINI_MODEL',
94  'GEMINI_BASE_URL',
95  'GOOGLE_API_KEY',
96  'NVIDIA_NIM',
97  'NVIDIA_API_KEY',
98  'NVIDIA_MODEL',
99  'MINIMAX_API_KEY',
100  'MINIMAX_BASE_URL',
101  'MINIMAX_MODEL',
102  'MISTRAL_BASE_URL',
103  'MISTRAL_API_KEY',
104  'MISTRAL_MODEL',
105  'BANKR_BASE_URL',
106  'BNKR_API_KEY',
107  'BANKR_MODEL',
108  'XAI_API_KEY',
109  'XAI_CREDENTIAL_SOURCE',
110  'AIMLAPI_API_KEY',
111  'VENICE_API_KEY',
112  'MIMO_API_KEY',
113  'ATLAS_CLOUD_API_KEY',
114  'NEARAI_API_KEY',
115  'FIREWORKS_API_KEY',
116  'LONGCAT_API_KEY',
117  'CLINE_API_KEY',
118  'OPENCODE_API_KEY',
119  'CLAUDE_CODE_PROVIDER_ROUTE_ID',
120  'CLOUDFLARE_API_TOKEN',
121  DEFAULT_STARTUP_PROVIDER_ENV_VAR,
122] as const

▸安装与修复流程

安装命令也体现了“兼容但不越界”。src/commands/install.tsx:107 检测没有 native distribution 的 npm build,src/commands/install.tsx:131 调用 installLatest,src/commands/install.tsx:149 清理旧 npm 安装,src/commands/install.tsx:177 再验证 shell launcher。

src/commands/install.tsx · L91-180TypeScript
91export function Install({
92  onDone,
93  force,
94  target
95}: InstallProps): React.ReactNode {
96  const [state, setState] = useState<InstallState>({
97    type: 'checking'
98  });
99  useEffect(() => {
100    async function run() {
101      try {
102        logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`);
103
104        // npm-only builds have no native binary to install. Downloading here
105        // would fetch the first-party Claude Code binary and replace the npm
106        // install the user is running, so just confirm the current install.
107        if (!hasNativeDistribution()) {
108          logForDebugging('Install: build has no native distribution; reporting npm installation instead');
109          logEvent('tengu_claude_install_command', {
110            has_version: 1,
111            forced: force ? 1 : 0
112          });
113          setState({
114            type: 'success',
115            version: MACRO.DISPLAY_VERSION,
116            location: process.argv[1] || 'npm global installation',
117            setupMessages: [`This build is distributed via npm (${MACRO.PACKAGE_URL}) and has no native binary, so nothing was downloaded.`, `To update, run: npm install -g ${MACRO.PACKAGE_URL}@latest`]
118          });
119          return;
120        }
121
122        // Install native build first
123        const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest';
124        setState({
125          type: 'installing',
126          version: channelOrVersion
127        });
128
129        // Pass force flag to trigger reinstall even if up to date
130        logForDebugging(`Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`);
131        const result = await installLatest(channelOrVersion, force);
132        logForDebugging(`Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`);
133
134        // Check specifically for lock failure
135        if (result.lockFailed) {
136          throw new Error('Could not install - another process is currently installing Claude. Please try again in a moment.');
137        }
138
139        // If we couldn't get the version, there might be an issue
140        if (!result.latestVersion) {
141          logForDebugging('Install: Failed to retrieve version information during install', {
142            level: 'error'
143          });
144        }
145        if (!result.wasUpdated) {
146          logForDebugging('Install: Already up to date');
147        }
148
149        // Now that native installation succeeded, clean up old npm installations.
150        // npm uninstall owns its bin entries and can remove ~/.local/bin/openclaude
151        // when the npm prefix overlaps the native launcher directory, so repair the
152        // native launcher after cleanup before checking the final install state.
153        logForDebugging('Install: Cleaning up npm installations after successful install');
154        const {
155          removed,
156          errors,
157          warnings
158        } = await cleanupNpmInstallations();
159        if (removed > 0) {
160          logForDebugging(`Cleaned up ${removed} npm installation(s)`);
161        }
162        if (errors.length > 0) {
163          logForDebugging(`Cleanup errors: ${errors.join(', ')}`);
164          // Continue despite cleanup errors - native install already succeeded
165        }
166
167        setState({
168          type: 'setting-up'
169        });
170        if (!result.latestVersion) {
171          throw new Error('Could not repair native launcher - installed version is unknown.');
172        }
173        logForDebugging(`Install: Repairing native launcher after npm cleanup`);
174        await repairNativeLauncher(result.latestVersion);
175
176        // Set up launcher and shell integration against the final post-cleanup state
177        const setupMessages = await checkInstall(true);
178        logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`);
179        if (setupMessages.length > 0) {
180          setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));

▸系统流程图

User prompt -> QueryEngine.submitMessage -> processUserInput / commands / tools -> provider profile routes model calls -> bridge transport mirrors remote session state -> tests enforce env and permission boundaries
§ 04 · TESTS & VALIDATION

测试把安全假设固定下来

▸Secret 不外泄

最重要的测试不是 UI 快照,而是安全不变量。src/bridge/sessionRunner.test.ts:13 验证 ANTHROPIC_API_KEY 不传给 child,src/bridge/sessionRunner.test.ts:24 覆盖 OPENAI_API_KEY,src/bridge/sessionRunner.test.ts:34 覆盖 DB、AWS、GitHub token。

src/bridge/sessionRunner.test.ts · L13-85TypeScript
13test('buildChildEnv does not leak ANTHROPIC_API_KEY to child', () => {
14  const parentEnv = {
15    PATH: '/usr/bin',
16    HOME: '/home/user',
17    ANTHROPIC_API_KEY: 'sk-ant-secret-key',
18    CLAUDE_CODE_SESSION_ACCESS_TOKEN: 'will-be-overwritten',
19  }
20  const env = buildChildEnv(parentEnv, baseOpts)
21  expect(env.ANTHROPIC_API_KEY).toBeUndefined()
22})
23
24test('buildChildEnv does not leak OPENAI_API_KEY to child', () => {
25  const parentEnv = {
26    PATH: '/usr/bin',
27    HOME: '/home/user',
28    OPENAI_API_KEY: 'sk-openai-secret',
29  }
30  const env = buildChildEnv(parentEnv, baseOpts)
31  expect(env.OPENAI_API_KEY).toBeUndefined()
32})
33
34test('buildChildEnv does not leak arbitrary secrets to child', () => {
35  const parentEnv = {
36    PATH: '/usr/bin',
37    HOME: '/home/user',
38    DB_PASSWORD: 'super-secret',
39    AWS_SECRET_ACCESS_KEY: 'aws-secret',
40    GITHUB_TOKEN: 'ghp_token',
41  }
42  const env = buildChildEnv(parentEnv, baseOpts)
43  expect(env.DB_PASSWORD).toBeUndefined()
44  expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined()
45  expect(env.GITHUB_TOKEN).toBeUndefined()
46})
47
48test('buildChildEnv includes PATH and HOME from parent', () => {
49  const parentEnv = {
50    PATH: '/usr/bin:/usr/local/bin',
51    HOME: '/home/user',
52    ANTHROPIC_API_KEY: 'sk-secret',
53  }
54  const env = buildChildEnv(parentEnv, baseOpts)
55  expect(env.PATH).toBe('/usr/bin:/usr/local/bin')
56  expect(env.HOME).toBe('/home/user')
57})
58
59test('buildChildEnv sets CLAUDE_CODE_SESSION_ACCESS_TOKEN from opts', () => {
60  const env = buildChildEnv({ PATH: '/usr/bin' }, { ...baseOpts, accessToken: 'my-token' })
61  expect(env.CLAUDE_CODE_SESSION_ACCESS_TOKEN).toBe('my-token')
62})
63
64test('buildChildEnv sets CLAUDE_CODE_ENVIRONMENT_KIND to bridge', () => {
65  const env = buildChildEnv({ PATH: '/usr/bin' }, baseOpts)
66  expect(env.CLAUDE_CODE_ENVIRONMENT_KIND).toBe('bridge')
67})
68
69test('buildChildEnv does not pass CLAUDE_CODE_OAUTH_TOKEN to child', () => {
70  const parentEnv = {
71    PATH: '/usr/bin',
72    CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-to-strip',
73  }
74  const env = buildChildEnv(parentEnv, baseOpts)
75  expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
76})
77
78test('buildChildEnv sets CCR v2 vars when useCcrV2 is true', () => {
79  const env = buildChildEnv(
80    { PATH: '/usr/bin' },
81    { accessToken: 'tok', useCcrV2: true, workerEpoch: 42 },
82  )
83  expect(env.CLAUDE_CODE_USE_CCR_V2).toBe('1')
84  expect(env.CLAUDE_CODE_WORKER_EPOCH).toBe('42')
85})

▸消息路由测试面

实现本身提供了足够可测接口:src/bridge/bridgeMessaging.ts:36 是 SDKMessage type guard,src/bridge/bridgeMessaging.ts:77 过滤 eligible message,src/bridge/bridgeMessaging.ts:132 则把 ingress parsing 和去重逻辑集中起来。

src/bridge/bridgeMessaging.ts · L31-122TypeScript
31// ─── Type guards ─────────────────────────────────────────────────────────────
32
33/** Type predicate for parsed WebSocket messages. SDKMessage is a
34 *  discriminated union on `type` — validating the discriminant is
35 *  sufficient for the predicate; callers narrow further via the union. */
36export function isSDKMessage(value: unknown): value is SDKMessage {
37  return (
38    value !== null &&
39    typeof value === 'object' &&
40    'type' in value &&
41    typeof value.type === 'string'
42  )
43}
44
45/** Type predicate for control_response messages from the server. */
46export function isSDKControlResponse(
47  value: unknown,
48): value is SDKControlResponse {
49  return (
50    value !== null &&
51    typeof value === 'object' &&
52    'type' in value &&
53    value.type === 'control_response' &&
54    'response' in value
55  )
56}
57
58/** Type predicate for control_request messages from the server. */
59export function isSDKControlRequest(
60  value: unknown,
61): value is SDKControlRequest {
62  return (
63    value !== null &&
64    typeof value === 'object' &&
65    'type' in value &&
66    value.type === 'control_request' &&
67    'request_id' in value &&
68    'request' in value
69  )
70}
71
72/**
73 * True for message types that should be forwarded to the bridge transport.
74 * The server only wants user/assistant turns and slash-command system events;
75 * everything else (tool_result, progress, etc.) is internal REPL chatter.
76 */
77export function isEligibleBridgeMessage(m: Message): boolean {
78  // Virtual messages (REPL inner calls) are display-only — bridge/SDK
79  // consumers see the REPL tool_use/result which summarizes the work.
80  if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) {
81    return false
82  }
83  return (
84    m.type === 'user' ||
85    m.type === 'assistant' ||
86    (m.type === 'system' && m.subtype === 'local_command')
87  )
88}
89
90/**
91 * Extract title-worthy text from a Message for onUserMessage. Returns
92 * undefined for messages that shouldn't title the session: non-user, meta
93 * (nudges), tool results, compact summaries, non-human origins (task
94 * notifications, channel messages), or pure display-tag content
95 * (<ide_opened_file>, <session-start-hook>, etc.).
96 *
97 * Synthetic interrupts ([Request interrupted by user]) are NOT filtered here —
98 * isSyntheticMessage lives in messages.ts (heavy import, pulls command
99 * registry). The initialMessages path in initReplBridge checks it; the
100 * writeMessages path reaching an interrupt as the *first* message is
101 * implausible (an interrupt implies a prior prompt already flowed through).
102 */
103export function extractTitleText(m: Message): string | undefined {
104  if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
105    return undefined
106  if (m.origin && m.origin.kind !== 'human') return undefined
107  const content = m.message.content
108  let raw: string | undefined
109  if (typeof content === 'string') {
110    raw = content
111  } else {
112    for (const block of content) {
113      if (block.type === 'text') {
114        raw = block.text
115        break
116      }
117    }
118  }
119  if (!raw) return undefined
120  const clean = stripDisplayTagsAllowEmpty(raw)
121  return clean || undefined
122}
§ 05 · RISKS

技术亮点之外,别忽略这些

▸源码证据地图

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:24 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:56 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:72 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:87 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:96 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:170 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:201 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:247 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.ts:310 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:92 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:120 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:151 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:166 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:183 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:193 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/replBridge.ts:214 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:187 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:213 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:242 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:248 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:292 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:320 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:339 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:401 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/QueryEngine.ts:414 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:36 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:77 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:103 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:143 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:151 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:171 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:183 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:194 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:206 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/workSecret.ts:5 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/workSecret.ts:44 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/workSecret.ts:64 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/workSecret.ts:81 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/workSecret.ts:101 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/workSecret.ts:136 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.test.ts:13 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.test.ts:34 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/bridge/sessionRunner.test.ts:78 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/utils/providerProfile.ts:49 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/utils/providerProfile.ts:58 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/utils/providerProfile.ts:124 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/utils/providerProfile.ts:209 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/utils/providerProfile.ts:215 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/commands/install.tsx:91 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/commands/install.tsx:107 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/commands/install.tsx:131 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/commands/install.tsx:149 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

主页面源码分析 的证据点 src/commands/install.tsx:177 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。

▸亮点与风险

亮点
Bridge 与 QueryEngine 有明确分层,适合做多 provider coding-agent runtime。
风险
仓库包含大量兼容与 fork 痕迹,后续维护质量取决于测试能否覆盖 provider 差异。
Generated · 2026-07-29Source · 0xwilliamortiz/openclaude-improved