Compare commits

...

165 Commits

Author SHA1 Message Date
gnanam1990
632807d0ba fix(input): strip leading ! when entering bash mode
The PromptInput onChange handler had two branches for entering bash
mode: a single-char path that just toggled the mode and a multi-char
paste path that also stripped the leading `!` from the buffer. The
single-char path returned without stripping, so typing a bare `!` into
empty input switched modes but left the literal `!` visible.

Consolidated both paths through a new pure helper `detectModeEntry`
that returns the new mode plus the stripped buffer value, so there is
no longer a branch where the mode character can leak into the buffer.

Fixes #662
2026-04-29 06:59:17 +05:30
viudes
6ea3eb6483 feat(api): deterministic request-body serialization via stableStringify (#882)
* feat(api): deterministic request-body serialization via stableStringify

Add `stableStringify` helper that emits JSON with object keys sorted
lexicographically at every depth (arrays preserved). Adopt it in the
OpenAI-compatible shim and the Codex Responses-API shim for the outgoing
request body.

WHY: OpenAI / Kimi / DeepSeek / Codex use implicit prefix caching keyed
on exact request bytes. Spurious insertion-order differences in
spread-merged body objects otherwise invalidate the cache on every turn.
Also a pre-requisite for Anthropic `cache_control` breakpoint hits.

Byte-equivalent to `JSON.stringify` when keys already happen to be in
lexical insertion order, so strictly additive across providers.

* fix(api): preserve circular-ref TypeError in stableStringify + cover GitHub fallback

Replace two-pass sortingReplacer approach with a single-pass deepSort that
tracks ancestor objects via WeakSet, throwing TypeError on cycles (same
contract as native JSON.stringify) and correctly handling DAGs via
try/finally cleanup. Switch the GitHub Copilot /responses fallback in
openaiShim.ts from JSON.stringify to stableStringify so that path is also
byte-stable for prefix caching.

Regression coverage added: top-level cycle, deep nested cycle, DAG safety.

* fix(api): align stableStringify with native JSON.stringify pre-processing

Replicate native JSON.stringify pre-processing inside deepSort so
serialization output matches native behavior beyond key ordering:

- invoke toJSON(key) when present (Date, URL, user classes); pass ''
  at top-level, property name for nested values, index string for
  array elements
- unbox Number/String/Boolean wrappers via valueOf() so new Boolean(false)
  doesn't get truthy-coerced
- run cycle detection on the post-toJSON value so a toJSON returning
  an ancestor still throws TypeError; DAGs continue to not throw
- drop properties whose toJSON returns undefined, matching native

Add focused stableStringify.test.ts (21 cases) asserting equality with
JSON.stringify across toJSON paths, wrapper unboxing, cycle/DAG handling,
and sortKeysDeep parity.
2026-04-27 23:33:15 +08:00
vrdons
f699c1f2fc fix routing path (#923) 2026-04-27 20:05:17 +08:00
github-actions[bot]
52b4c5c2ff chore(main): release 0.7.0 (#817)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-27 11:47:52 +08:00
FluxLuFFy
c6c5f0608c fix: bugs (#885)
* fix: error output truncation (10KB→40KB) and MCP tool bugs

- toolErrors.ts: increase error truncation limit from 10KB to 40KB
  Shell output can be up to 30KB, so 10KB was silently cutting off
  error logs from systemctl, apt, python, etc.

- MCPTool: cache compiled AJV validators (was recompiling every call)
- MCPTool: fix validateInput error message showing [object Object]
- MCPTool: null-guard mapToolResultToToolResultBlockParam
- MCPTool: explicit null check in isResultTruncated
- ReadMcpResourceTool: null-guard mapToolResultToToolResultBlockParam

Tests (84 passing):
- src/utils/toolErrors.test.ts (13 tests)
- src/tools/BashTool/commandSemantics.test.ts (24 tests)
- src/tools/BashTool/utils.test.ts (32 tests)
- src/tools/MCPTool/MCPTool.test.ts (15 tests)

* fix: address review blockers from PR #885

Blocker 1: Fix abort path in callMCPTool
- Previously returned { content: undefined } on AbortError, which masked
  the cancellation and caused mapToolResultToToolResultBlockParam to send
  empty content to the API as if it were a successful result.
- Now converts abort errors to our AbortError class and re-throws, so the
  tool execution framework handles it properly (skips logging, creates
  is_error: true result with [Request interrupted by user for tool use]).

Blocker 2: Fix memory leak in AJV validator cache
- Changed compiledValidatorCache from Map to WeakMap so schemas from
  disconnected/refreshed MCP tools can be garbage collected instead of
  accumulating strong references indefinitely.

Also: null guards now return descriptive indicators instead of empty
strings, making it clear when content is unexpectedly missing.

---------

Co-authored-by: FluxLuFFy <FluxLuFFy@users.noreply.github.com>
Co-authored-by: Fix Bot <fix@openclaw.ai>
2026-04-26 23:11:19 +08:00
Kevin Codex
46a9d3eec4 chore: rebrand user-facing copy to OpenClaude (#851)
* chore: rebrand user-facing copy to OpenClaude

Replace lingering Claude Code branding in CLI, tips, and runtime UI with OpenClaude/openclaude, including the startup tip Gitlawb mention.

Co-Authored-By: Claude GPT-5.4 <noreply@openclaude.dev>

* chore: address branding-sweep review feedback

- PermissionRequest.tsx: rebrand the two remaining "Claude needs your
  approval/permission" notifications to OpenClaude (review-artifact and
  generic tool permission paths).
- main.tsx, teleport.tsx, session.tsx, WebFetchTool/utils.ts,
  skills/bundled/{debug,updateConfig}.ts: replace leftover `claude --…`
  CLI hints and "Claude Code" labels missed by the original sweep.
- main.tsx: drop the inline gitlawb.com marketing copy from the
  stale-prompt tip; keep it a pure rebrand.
- auth.ts: finish the half-rename so both `claude setup-token` and
  `claude auth login` references in the same error block now read
  `openclaude …`.
- mcp/client.ts: keep `name: 'claude-code'` for MCP server allowlist
  compatibility (now explicit via comment) and replace the
  "Anthropic's agentic coding tool" description with an OpenClaude one.
- MCPSettings.tsx: point the empty-server-list hint at
  https://github.com/Gitlawb/openclaude instead of code.claude.com.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: replace help link with OpenClaude repo URL

Replace https://code.claude.com/docs/en/overview with
https://github.com/Gitlawb/openclaude in the help screen.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: Claude GPT-5.4 <noreply@openclaude.dev>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-26 22:14:36 +08:00
Kevin Codex
2586a9cddb feat: add xAI as official provider (#865)
* feat: add xAI as official provider

- Add xAI preset to ProviderManager (alphabetical order)
- Add xAI provider detection via XAI_API_KEY
- Add xAI startup screen heuristic (x.ai base URL or grok model)
- Add xAI status display properties
- Add grok-4 and grok-3 context windows
- Add xAI model fallbacks across all tiers
- Fix JSDoc priority order in providerAutoDetect

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

* fix(xai): persist relaunch classification for xAI profiles

Addresses reviewer feedback on feat/xai-official-provider:
- isProcessEnvAlignedWithProfile now validates XAI_API_KEY for x.ai
  base URLs, mirroring the Bankr pattern. Without this, relaunch
  skips re-applying the profile, XAI_API_KEY stays unset, and
  getAPIProvider() falls back to 'openai'.
- buildOpenAICompatibleStartupEnv now sets XAI_API_KEY when syncing
  active xAI profile to the legacy fallback file.
- Adds 'xai' to VALID_PROVIDERS and --provider xai CLI flag support.
- Adds xAI detection to providerDiscovery label heuristics.
- Adds 'xai' to legacy ProviderProfile type/isProviderProfile guard.
- Adds targeted tests for relaunch alignment, flag application, and
  discovery labeling.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-26 21:26:44 +08:00
Rayan Alkhelaiwi
d45628c413 fix(startup): show --model flag override on startup screen (#898)
The startup screen was only reading model from env vars and settings,
ignoring the --model CLI flag since it's parsed by Commander.js after
the banner prints. Now eagerly parses --model from argv before rendering
so the displayed model matches what the session will actually use.
2026-04-26 20:24:44 +08:00
TechBrewBoss
6dedffe5ff Add OpenAI responses mode and custom auth headers (#906)
* Add OpenAI profile responses and custom auth header support

* Fix knowledge graph config reference in query loop

* Address OpenAI profile review edge cases

* Remove unused getGlobalConfig import

Delete an unused import of getGlobalConfig from src/query.ts. This cleans up dead code and avoids unused-import lint warnings; no functional behavior changes.

* Address follow-up OpenAI profile review comments

* Refine OpenAI responses auth review fixes

* Fix custom auth header default scheme
2026-04-26 20:24:03 +08:00
emsanakhchivan
a3e728a114 fix(agent): provider-aware fallback for haiku/sonnet aliases (#908)
* fix(agent): provider-aware fallback for haiku/sonnet aliases

Explore agent fails on custom providers (Z.AI GLM, Alibaba Anthropic-compatible,
local OpenAI endpoints) because 'haiku' alias resolves to a non-existent model.

Changes:
- Add isClaudeNativeProvider check (Bedrock, Vertex, Foundry, official Anthropic)
- For non-Claude-native providers, haiku/sonnet aliases inherit parent model
- Add 8 tests for provider-aware fallback behavior

Fixes Explore agent "model not found" errors on custom Anthropic-compatible APIs.

* test(agent): use Bun mock.module() for provider tests

Replace env manipulation with proper Bun mock.module() to reliably
mock getAPIProvider() and isFirstPartyAnthropicBaseUrl() functions.
This ensures tests work correctly on CI where module caching caused
false negatives.

---------

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-26 20:08:55 +08:00
Kevin Codex
818689b2ee fix(query): restore system prompt structure and add missing config import (#907)
- import getGlobalConfig — six call sites referenced it without an import;
  five short-circuited via feature() gates, but src/query.ts:1896 always
  ran and crashed every queryLoop iteration with "getGlobalConfig is not
  defined" (e.g. Explore subagent: "Agent failed: getGlobalConfig is not
  defined").
- stop coercing SystemPrompt (string[]) into a template-string before
  appendSystemContext — that made [...systemPrompt] spread the string
  character-by-character, replacing the structured prompt with thousands
  of one-char system blocks. Append arcSummary as its own array element
  instead.
- gate the finalizeArcTurn call behind feature('CONVERSATION_ARC') so it
  matches the rest of the memory-PR call sites and gets dead-code-
  eliminated for users without the flag.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-26 12:45:09 +08:00
Kevin Codex
d9ae56bc58 fix provider switch not presistingin session (#903)
* fix provider switch not presistingin session

* fix broken tests
2026-04-26 11:15:25 +08:00
Pedry
af9a3caa4d Fix file path and update placeholder key in PLAYBOOK.md (#886)
Updated file paths and placeholder key in PLAYBOOK.md.
2026-04-26 08:20:25 +08:00
chioarub
a0d657ee18 feat(zai): add Z.AI GLM Coding Plan provider preset (#896)
* feat(zai): add Z.AI GLM Coding Plan provider preset

Add dedicated Z.AI provider support for the GLM Coding Plan, enabling
use of GLM-5.1, GLM-5-Turbo, GLM-4.7, and GLM-4.5-Air models through
the OpenAI-compatible shim with proper thinking mode (reasoning_content),
max_tokens handling, and context window sizing.

* fix(zai): unify GLM max output token limits across casing variants

glm-5/glm-4.7 had conservative 16K max output while GLM-5/GLM-4.7
had 131K. Use consistent Z.AI coding plan limits for all GLM variants.

* fix(zai): restore DashScope GLM limits, enable GLM thinking support

- Restore lowercase glm-5/glm-4.7 to 16_384 max output (DashScope limits)
  while keeping Z.AI coding plan high limits on uppercase GLM-* keys only
- Add GLM model support to modelSupportsThinking() so reasoning_content
  is enabled when using GLM-5.x/GLM-4.7 models on Z.AI

* fix(zai): tighten GLM regexes, fix misleading context window comment

- Use precise regex in thinking.ts: exact GLM model matches only,
  no false positives on glm-50/glm-4, includes glm-4.5-air
- Use uppercase-only match in StartupScreen rawModel fallback so
  DashScope lowercase glm-* models aren't mislabeled as Z.AI
- Clarify context window comment: lowercase glm-5.1/glm-5-turbo/
  glm-4.5-air are Z.AI-specific aliases, not DashScope

* fix(zai): scope GLM detection to Z.AI

* improve readability of max_completion_tokens check

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-26 08:18:59 +08:00
3kin0x
29f7579377 feat(memory): implement persistent project-level Knowledge Graph and RAG (#899)
- Shift memory from session-scope to persistent project-scope\n- Add native JSON RAG with BM25-lite ranking\n- Implement passive technical concept extraction (IPs, versions, frameworks)\n- Orchestrate hierarchical context injection in the conversation loop
2026-04-26 08:17:02 +08:00
viudes
9e23c2bec4 feat(api): expose cache metrics in REPL + normalize across providers (#813)
* feat(api): expose cache metrics in REPL + /cache-stats command

* fix(api): normalize Kimi/DeepSeek/Gemini cache fields through shim layer

* test(api): cover /cache-stats rendering + fix CacheMetrics docstring drift

* fix(api): always reset cache turn counter + include date in /cache-stats rows

* refactor(api): unify shim usage builder + add cost-tracker wiring test

* fix(api): classify private-IP/self-hosted OpenAI endpoints as N/A instead of cold

* fix(api): require colon guard on IPv6 ULA prefix to avoid public-host over-match

* perf(api): ring buffer for cache history + hit rate clamp + .localhost TLD

* fix(api): null guards on formatters + document Codex Responses API shape

* fix(api): defensive start-of-turn reset + config gate fallback + env var docs

* fix(api): trust forwarded cache data on self-hosted URLs (data-driven)

* refactor(api): delegate streaming Responses usage to shared makeUsage helper
2026-04-25 12:38:25 +08:00
JATMN
9070220292 Add Kimi Code provider preset and rename Moonshot API preset (#862)
* Add Kimi Code provider preset

* fix desc.

Co-authored-by: Copilot <copilot@github.com>

* more desc. fixes.

* Fix release validation tests

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-25 12:36:54 +08:00
JATMN
26413f6d30 feat(minimax): add /usage support and fix MiniMax quota parsing (#869)
* Add MiniMax usage UI and API support

* Fix MiniMax usage parsing and refresh UI

* Refactor MiniMax usage handling
2026-04-25 12:33:22 +08:00
3kin0x
44f9cac70d Feature/memory pr (#894)
* feat: multi-turn context and conversation arc memory

PR 2E - Section 2.9, 2.10:
- Add multiTurnContext.ts with turn tracking and state preservation
- Add conversationArc.ts with goal/decision/milestone tracking
- Wire into query.ts after tool execution
- Feature-flags: MULTI_TURN_CONTEXT, CONVERSATION_ARC
- Add comprehensive tests (22 passing)

* feat(cli): add /knowledge command to manage native memory

- Add /knowledge enable <yes|no> to toggle Knowledge Graph learning\n- Add /knowledge clear to reset memory\n- Add persistent knowledgeGraphEnabled setting to global config\n- Integrated user setting into the query execution loop

* feat(cli): add /knowledge command (stable local-jsx version)

- Resolve conflicts between .ts and .tsx files\n- Align with LocalJSXCommandCall signature\n- Fix onDone and args errors

* test(cli): fix knowledge command tests by properly isolating global config

* fix(cli): make knowledge command defensive against undefined args and leaky tests

* fix(cli): correct data source for entity count and fix test isolation

* fix(cli): reinforce knowledge test by explicitly defining property on test config

* fix(cli): explicitly define property in test config to avoid undefined in CI

* fix(cli): make knowledge tests resistant to global config mocks in CI

* chore(memory): surgical improvements from architectural audit

- Fix: Implement entity deduplication in Knowledge Graph\n- Fix: Ensure fact extraction from user messages in query loop\n- Fix: Refine regexes for better quality learning (less noise)

---------

Co-authored-by: LifeJiggy <Bloomtonjovish@gmail.com>
2026-04-25 07:19:41 +08:00
JATMN
ff2a380723 Add DeepSeek V4 flash/pro support and DeepSeek thinking compatibility (#877)
* Add DeepSeek V4 support and thinking compatibility

* Fix DeepSeek profile persistence regression

* Align multi-model handling with openai-multi-model
2026-04-25 02:29:46 +08:00
JATMN
c4cb98a4f0 fix: normalize /provider multi-model selection and semicolon parsing (#841)
* fix provider multi-model selection

* fix provider manager multi-model save path
2026-04-25 02:28:14 +08:00
3kin0x
b5f7047358 Feature/memory pr (#889)
* feat: multi-turn context and conversation arc memory

PR 2E - Section 2.9, 2.10:
- Add multiTurnContext.ts with turn tracking and state preservation
- Add conversationArc.ts with goal/decision/milestone tracking
- Wire into query.ts after tool execution
- Feature-flags: MULTI_TURN_CONTEXT, CONVERSATION_ARC
- Add comprehensive tests (22 passing)

* feat(memory): resolve review blockers and integrate native Knowledge Graph into Conversation Arcs

- Fix: Extract text from production block arrays in phase detector\n- Fix: Ensure proper turn segmentation in query loop\n- Fix: Respect options in multi-turn context tracker\n- Feat: Add native Knowledge Graph (Entities/Relations) to ConversationArc architecture\n- Test: Comprehensive test suite for all fixes and new graph features

* test(perf): add automated performance benchmarks for Knowledge Graph extraction and summary

---------

Co-authored-by: LifeJiggy <Bloomtonjovish@gmail.com>
2026-04-25 02:26:02 +08:00
Kevin Codex
64b1014b9a Feat/bankr provider (#888)
* feat(provider): add Bankr LLM Gateway support

Add Bankr as an OpenAI-compatible provider preset with dedicated env vars:
- BNKR_API_KEY, BANKR_BASE_URL, BANKR_MODEL
- Uses X-API-Key header instead of Authorization Bearer
- Base URL: https://llm.bankr.bot/v1
- Default model: claude-opus-4.6

Changes:
- Add 'bankr' to VALID_PROVIDERS and provider flag handling
- Add buildBankrProfileEnv() with env key registration
- Add Bankr detection in startup screen and provider discovery
- Map Bankr env vars to OpenAI-compatible vars in shim
- Add Bankr preset to ProviderManager (alphabetical order)
- Update PRESET_ORDER test to include Bankr

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fixup(provider): address Bankr PR review feedback

1. Map BNKR_API_KEY → OPENAI_API_KEY in providerFlag.ts so
   --provider bankr works with BNKR_API_KEY in non-interactive startup.

2. Remove unconditional BANKR_MODEL read from model.ts; it maps to
   OPENAI_MODEL via providerFlag.ts and openaiShim.ts, preventing
   cross-provider leakage.

3. Use X-API-Key for Bankr model discovery in openaiModelDiscovery.ts
   and providerDiscovery.ts, matching chat request auth.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-24 23:03:45 +08:00
TechBrewBoss
5a21d05741 Persist active provider profile across restarts (#833)
* Persist active provider profile across restarts

* Clear stale startup provider overrides

* Fix provider profile restart fallback

* Fix provider profile restart fallback

* Omit empty OpenAI API key from startup env

* Fix startup override settings typing
2026-04-24 19:36:21 +08:00
Kevin Codex
038f715b7a feat(model): add GPT-5.5 support for Codex provider (#880)
- Bump Codex provider defaults from gpt-5.4 to gpt-5.5 across all ModelConfigs
- Update codexplan alias to resolve to gpt-5.5
- Add gpt-5.5 and gpt-5.5-mini to model picker with reasoning effort mappings
- Add context window and max output token specs for gpt-5.5 family
- Add gpt-5.5 entries to COPILOT_MODELS registry
- Keep official OpenAI API preset at gpt-5.4 (API availability pending)
- Update codexShim tests to expect gpt-5.5 from codexplan alias

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-24 19:06:36 +08:00
Kevin Codex
b694ccfff1 Add sponsors section to README (#874) 2026-04-24 11:47:55 +08:00
KRATOS
dcbe29558a fix(mcp): disable MCP_SKILLS feature flag — source not mirrored (#872)
Closes #856.

MCP servers that expose resources (e.g. RepoPrompt) failed to load
their tools in the open build with:

    Error fetching tools/commands/resources:
    fetchMcpSkillsForClient is not a function

Root cause: scripts/build.ts set MCP_SKILLS: true, which made
feature('MCP_SKILLS') evaluate to true at build time. The guards
around the dynamic skill discovery path therefore stayed live. The
underlying source file src/skills/mcpSkills.ts is not mirrored into
the open tree, so the bundler fell back to its generic missing-module
stub — which only exports `default` for require()-style imports, not
the named `fetchMcpSkillsForClient` binding. At runtime the require
returned an object without that property, and calling it threw.

`openclaude mcp doctor` reported RepoPrompt as healthy because doctor
does not exercise the skills-fetch path.

Fix: flip MCP_SKILLS to false and move it into the "Disabled: missing
source" group. With the flag off, every `if (feature('MCP_SKILLS'))`
guard becomes a no-op at build time, the require() branch is dead
code, and MCP servers with resources load normally via the existing
`Promise.resolve([])` fallbacks already present at each call site.

Also adds scripts/feature-flags-source-guard.test.ts to fail fast if
MCP_SKILLS (or any future flag in the same category) is re-enabled
without the corresponding source file being mirrored first.

Verification:
  - Test fails on main, passes with this fix
  - `bun run build` produces a bundle with no
    `missing-module-stub:../../skills/mcpSkills.js` reference
  - Full `bun test` — 1222 pass / 12 fail (same pre-existing 12 as
    main; new test adds the +1 pass)
2026-04-24 11:35:59 +08:00
KRATOS
a4c6757023 fix(shell): recover when CWD path was replaced by a non-directory (#871)
* fix(shell): recover when CWD path was replaced by a non-directory

Closes #844.

When the session's cached working directory is renamed on disk and
a file is subsequently created at the old path (e.g. `mv orig renamed
&& touch orig`), every Bash tool invocation failed with
`ENOTDIR: not a directory, posix_spawn '/usr/bin/zsh'` (exit 126),
and `!`-prefixed commands silently failed. No recovery was possible
without restarting the session.

Root cause: the pre-spawn guard in `src/utils/Shell.ts:exec()` used
`realpath(cwd)` to detect a missing CWD. `realpath()` succeeds on
any existing path — file or directory — so a path that was replaced
with a regular file slipped past the check. spawn() was then called
with `cwd` pointing at a non-directory and failed with ENOTDIR.

Fix: replace `realpath()` with `stat().isDirectory()` for both the
primary CWD check and the `getOriginalCwd()` fallback check. When
the cached CWD is no longer a directory, fall back to the original
CWD (as before) and update state so subsequent tools recover
transparently.

Verification:
  - Repro: `mkdir -p /tmp/x/orig && mv /tmp/x/orig /tmp/x/renamed
    && touch /tmp/x/orig`, then exec with stale cwd=/tmp/x/orig
  - Before: exit 126, stderr "ENOTDIR: not a directory, posix_spawn"
  - After:  exit 0, cwd transparently recovered to originalCwd
  - `bun test` — no new regressions (pre-existing model/provider
    test failures are unrelated and present on main)

* fix(shell): drop now-unused realpath import
2026-04-24 11:34:08 +08:00
KRATOS
6e58b81937 fix(update): show real package version and give actionable guidance (#870)
The `openclaude update` / `openclaude upgrade` command printed
`Current version: 99.0.0` and, in the development-build branch, exited
with only `Warning: Cannot update development build` (closes #852).

Root cause: `MACRO.VERSION` is hardcoded to `'99.0.0'` in
`scripts/build.ts` as an internal compatibility sentinel so OpenClaude
passes upstream minimum-version guards. The real package version is
exposed separately as `MACRO.DISPLAY_VERSION`. `update.ts` was using
`MACRO.VERSION` for both the version shown to the user and for every
`latestVersion` comparison, which meant:

- Users always saw `99.0.0` as their "current version".
- `99.0.0 >= <any real npm version>`, so the "up to date" and
  "update available" checks could never fire correctly.

Fix (scoped to `src/cli/update.ts`):

- Use `MACRO.DISPLAY_VERSION` for all user-facing version strings and
  version comparisons.
- Replace the dead-end `Warning: Cannot update development build`
  (which exited 1 with no guidance) with actionable instructions for
  both source builds (`git pull && bun install && bun run build`) and
  npm installs (`npm install -g @gitlawb/openclaude@latest`).
- Extend the existing third-party-provider branch to also show the
  current version and the npm reinstall command, so users who
  installed via npm aren't told only to rebuild from source.
2026-04-24 11:33:03 +08:00
0xfandom
e346b8d5ec fix(startup): url authoritative over model name in banner provider detect (#864)
The banner provider branch tested model-name substrings (`/deepseek/`, `/kimi/`,
`/mistral/`, `/llama/`) before aggregator base-URL substrings (`/openrouter/`,
`/together/`, `/groq/`, `/azure/`). When running OpenRouter/Together/Groq with
vendor-prefixed model IDs (e.g. `deepseek/deepseek-chat`, `moonshotai/kimi-k2`,
`deepseek-r1-distill-llama-70b`), the banner mislabelled the provider.

Reorder: explicit env flags (NVIDIA_NIM, MINIMAX_API_KEY) and codex transport
win first; base-URL host checks run before rawModel fallback; rawModel only
fires when the base URL is generic/custom. Add unit tests covering the
aggregator × vendor-prefixed-model matrix plus direct-vendor regressions.

Closes #855
2026-04-24 01:52:27 +08:00
hika, maeng
b750e9e97d fix: make OpenAI fallback context window configurable + support external model lookup (#861)
* fix: make OpenAI fallback context window configurable and support external lookup table

Unknown OpenAI-compatible models fell back to a hardcoded 128k constant,
causing auto-compact to fire prematurely on models with larger windows
(issue #635 follow-up). Two escape hatches are added without touching the
built-in table:

- CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW (number): overrides the 128k
  default for all unknown models.
- CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS (JSON object): per-model overrides that
  take precedence over the built-in OPENAI_CONTEXT_WINDOWS table; supports
  the same provider-qualified and prefix-matching lookup as the built-in path.
- CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS (JSON object): same pattern for output
  token limits.

This lets operators deploy new or private models without patching
openaiContextWindows.ts on every model release.

* docs: add new OpenAI context window env vars to .env.example

Document CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW,
CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS, and
CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS with usage examples.

Addresses reviewer feedback on PR #861.

---------

Co-authored-by: opencode <dev@example.com>
2026-04-24 00:34:08 +08:00
0xfandom
28de94df5d feat: add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress hidden tool-output reminders (#837)
Gates three injection sites behind OPENCLAUDE_DISABLE_TOOL_REMINDERS:
- FileReadTool cyber-risk mitigation reminder (appended to every Read
  result when the model is not in MITIGATION_EXEMPT_MODELS)
- todo_reminder attachment for TodoWrite usage
- task_reminder attachment for TaskCreate/TaskUpdate usage

All three reminders are model-only side-channel instructions the user
cannot see today. Users who want full transparency over what the model
receives can now opt out without patching dist/cli.mjs on every upgrade.

Default behavior is unchanged when the flag is unset.

Closes #809
2026-04-23 01:37:02 +08:00
0xfandom
23e8cfbd5b fix(test): add missing teammate exports to hookChains integration mock (#840)
mock.module('./teammate.js', ...) only declared getAgentName/getTeamName/
getTeammateColor. Bun applies module mocks process-globally and
mock.restore() does not undo them, so whenever another test file ran
after hookChains.integration.test.ts and reached the real teammate
module it received undefined for isTeammate/isPlanModeRequired/
getAgentId/getParentSessionId.

This surfaced in CI as intermittent failures in
src/commands/provider/provider.test.tsx (TextEntryDialog / wizard
remount / ProviderWizard hides Codex OAuth), because getDefaultAppState
in AppStateStore.ts calls teammateUtils.isTeammate().

Match the mock surface to the real teammate.ts exports so downstream
consumers keep working even after the integration test pollutes the
module cache. Keeps the same behavioral overrides this test needed.

Closes #839
2026-04-23 01:36:42 +08:00
Kevin Codex
531e3f1059 feat(tools): resilient web search and fetch across all providers (#836)
- Add exponential backoff retry to DuckDuckGo adapter (3 attempts with
  jitter) to handle transient rate-limiting and connection errors.
- Add native fetch() fallback in WebFetch when axios hangs with custom
  DNS lookup in bundled contexts.
- Prevent broken native-path fallback for web search on OpenAI shim
  providers (minimax, moonshot, nvidia-nim, etc.) that do not support
  Anthropic's web_search_20250305 tool.
- Cherry-pick existing fixes:
  - a48bd56: cover codex/minimax/nvidia-nim in getSmallFastModel()
  - 31f0b68: 45s budget + raw-markdown fallback for secondary model
  - 446c1e8: sparse Codex /responses payload parsing
  - ae3f0b2: echo reasoning_content on assistant tool-call messages
- Fix domainCheck.test.ts mock modules to include isFirstPartyAnthropicBaseUrl
  and isGithubNativeAnthropicMode exports.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-23 01:14:00 +08:00
KRATOS
3c4d8435c4 fix: surface actionable error when DuckDuckGo web search is rate-limited (#834)
Non-Anthropic / non-codex providers (minimax, kimi, generic OpenAI-compatible)
fell through to the DDG adapter when no paid search key was configured. DDG's
scraper is blocked on most IPs, so web_search surfaced an opaque "anomaly in
the request" error. Catch that response in the DDG provider and rethrow with
the exact env vars that would unblock the tool, or the option to switch to a
native-search provider.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:58:20 +08:00
Kevin Codex
67de6bd2cf fix(openai-shim): echo reasoning_content on assistant tool-call messages for Moonshot (#828)
Kimi / Moonshot's chat completions endpoint requires that every assistant
message carrying tool_calls also carry reasoning_content when the
"thinking" feature is active. When an agent sends prior-turn assistant
history back (standard multi-turn / subagent / Explore patterns), the
shim previously stripped the thinking block:

  case 'thinking':
  case 'redacted_thinking':
    // Strip thinking blocks for OpenAI-compatible providers.
    break

That's correct for providers that would mis-interpret serialized
<thinking> tags, but Moonshot validates the schema strictly and rejects
with:

  API Error: 400 {"error":{"message":"thinking is enabled but
  reasoning_content is missing in assistant tool call message at
  index N","type":"invalid_request_error"}}

Reproducer: launch with Kimi profile, run any tool-using command
(Explore, Bash, etc.) — every request after the first 400s.

Fix: in convertMessages(), when the per-request flag
preserveReasoningContent is set (only for Moonshot baseUrls today),
attach the original thinking block's text as reasoning_content on the
outgoing OpenAI-shaped assistant message. Other providers continue to
strip (unknown-field rejection risk).

OpenAIMessage type grows a reasoning_content?: string field.
convertMessages() accepts an options object and threads the flag
through; the only call site (_doOpenAIRequest) gates via
isMoonshotBaseUrl(request.baseUrl).

Tests (openaiShim.test.ts):
  - Moonshot: echoes reasoning_content on assistant tool-call messages
    (regression for the reported 400)
  - non-Moonshot providers do NOT receive reasoning_content (guards
    against leaking the field to strict-parse endpoints)

Full suite: 1195/1195 pass under --max-concurrency=1. PR scan clean.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-22 22:47:57 +08:00
0xfandom
4d559c9135 docs(env): document OPENCLAUDE_DISABLE_STRICT_TOOLS in .env.example (#826)
Code support was merged in #770 but the .env.example entry was
missed, leaving users without a discoverable way to find the flag.

Closes #737
2026-04-22 22:16:47 +08:00
JATMN
b7b83eff13 Fix bracketed paste blocking provider form submit (#818) 2026-04-22 19:48:33 +08:00
Urvish L.
44a2c30d5f feat: implement Hook Chains runtime integration for self-healing agent mesh MVP (#711)
* feat: implement Hook Chains runtime integration for self-healing agent mesh MVP

- Add Hook Chains config loader, evaluator, and dispatcher in src/utils/hookChains.ts
- Wire PostToolUseFailure hook dispatch in executePostToolUseFailureHooks()
- Wire TaskCompleted hook dispatch in executeTaskCompletedHooks()
- Integrate fallback-agent launcher with permission preservation (canUseTool threading)
- Add safety hardening for config-read errors (try-catch protection)
- Update docs with MVP runtime trigger explanation
- Add 10 unit tests and 4 integration tests covering config, rules, guards, and actions

This completes the self-healing agent mesh MVP by enabling declarative rule-based
responses to tool failures and task completions, with fallback agent spawning,
team notification, and capacity warming actions.

* Update docs/hook-chains.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/utils/hookChains.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: address PR #711 review blockers for Hook Chains

- Gate hook-chain dispatch behind feature('HOOK_CHAINS') and default env gate to off
- Remove committed local artifact (agent.log) and ignore it in .gitignore
- Revert hook dispatcher signature threading changes for canUseTool
- Use ToolUseContext metadata hookChainsCanUseTool for fallback launch permissions
- Make spawn_fallback_agent fail explicitly when launcher context is unavailable
- Add config cache max age and guard map size limits to bound runtime memory
- Update docs and tests for default-off gating and explicit fallback failure

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 19:40:23 +08:00
ArkhAngelLifeJiggy
5b9cd21e37 feat: add streaming optimizer and structured request logging (#703)
* Integrate request logging and streaming optimizer

- Add logApiCallStart/End for API request tracking with correlation IDs
- Add streaming state tracking with processStreamChunk
- Flush buffer and log stream stats at stream end
- Resolve merge conflict with main branch

* feat: add streaming optimizer and structured request logging

* fix: address PR review feedback

- Remove buffering from streamingOptimizer - now purely observational
- Use logForDebugging instead of console.log for structured logging
- Remove dead code (streamResponse, bufferedStreamResponse, etc.)
- Use existing logging infrastructure instead of raw console.log
- Keep only used functions: createStreamState, processStreamChunk, getStreamStats

* test: add unit tests for requestLogging and streamingOptimizer

- streamingOptimizer.test.ts: 6 tests for createStreamState, processStreamChunk, getStreamStats
- requestLogging.test.ts: 6 tests for createCorrelationId, logApiCallStart, logApiCallEnd

* fix: correct durationMs test to be >= 0 instead of exactly 0

* fix: address PR #703 blockers and non-blockers

1. BLOCKER FIX: Skip clone() for streaming responses
   - Only call response.clone() + .json() for non-streaming requests
   - For streaming, usage comes via stream chunks anyway

2. NON-BLOCKER: Document dead code in flushStreamBuffer
   - Added comment explaining it's a no-op kept for API compat

3. NON-BLOCKER: vi.mock in tests - left as-is (test framework issue)

* fix: address all remaining non-blockers for PR #703

1. Remove dead code: flushStreamBuffer call and unused import
2. Fix test for Bun: remove vi.mock, use simple no-throw tests
2026-04-22 15:36:07 +08:00
ArkhAngelLifeJiggy
e92e5274b2 feat: add model-specific tokenizers and compression ratio detection (#799)
- ModelTokenizerConfig for different model families
- getTokenizerConfig() / getBytesPerTokenForModel()
- Content type detection (json, code, prose, list, technical)
- COMPRESSION_RATIOS - empirical ratios per content type
- estimateWithBounds() - confidence intervals

Features: 1.1, 1.14, 1.15
Tests: 13 passing
2026-04-22 13:24:12 +08:00
github-actions[bot]
86bce4ae74 chore(main): release 0.6.0 (#786)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-22 09:41:30 +08:00
Kevin Codex
c13842e91c fix(test): autoCompact floor assertion is flag-sensitive (#816)
The test "never returns negative even for unknown 3P models (issue #635)"
asserted that getEffectiveContextWindowSize() returns >= 33_000 for an
unknown 3P model under the OpenAI shim. That specific number assumes
reservedTokensForSummary = 20_000 (MAX_OUTPUT_TOKENS_FOR_SUMMARY), which
holds only when the tengu_otk_slot_v1 GrowthBook flag is disabled.

When the flag is ON — which is the case in CI but not always locally —
getMaxOutputTokensForModel() caps the model's default output at
CAPPED_DEFAULT_MAX_TOKENS (8_000). Then reservedTokensForSummary = 8_000,
floor = 8_000 + 13_000 = 21_000, and the test fails with 21_000 < 33_000.

The test reliably passes locally and reliably fails in CI, manifesting as
the intermittent PR-check failure.

Fix: relax the lower bound to 21_000 (cap-enabled worst case), which is
still well above zero — preserving the anti-regression intent of
issue #635 (no infinite auto-compact from a negative effective window)
without binding the test to GrowthBook flag state.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-22 09:37:57 +08:00
Kevin Codex
458120889f fix(model): codex/nvidia-nim/minimax now read OPENAI_MODEL env (#815)
getUserSpecifiedModelSetting() decides which env var to consult based on
the active provider. The check included openai and github but omitted
codex, nvidia-nim, and minimax — even though all three use the OpenAI
shim transport and get their model routing via CLAUDE_CODE_USE_OPENAI=1
+ OPENAI_MODEL (set by applyProviderProfileToProcessEnv).

Concrete failure: user switches from Moonshot profile (which persisted
settings.model='kimi-k2.6') to the Codex profile. The new profile
correctly writes OPENAI_MODEL=codexplan + base URL to
chatgpt.com/backend-api/codex. Startup banner reflects Codex / gpt-5.4
correctly. But at request time getUserSpecifiedModelSetting() returns
early for provider='codex' (not in the env-consult list), falls through
to the stale settings.model='kimi-k2.6', and the Codex API rejects:

  API Error 400: "The 'kimi-k2.6' model is not supported when using
  Codex with a ChatGPT account."

Fix: extract an isOpenAIShimProvider flag covering openai|codex|github|
nvidia-nim|minimax — all providers that set OPENAI_MODEL as their model
env var. The Gemini and Mistral branches stay as-is (they use
GEMINI_MODEL / MISTRAL_MODEL).

Five regression tests pin the fix for each OpenAI-shim provider plus
guard tests for openai and github that already worked.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-22 09:01:44 +08:00
Mike
ee19159c17 feat(provider): expose Atomic Chat in /provider picker with autodetect (#810)
Adds Atomic Chat as a first-class preset inside the in-session /provider
slash command, mirroring the Ollama auto-detect flow. Picking it probes
127.0.0.1:1337/v1/models, lists loaded models for direct selection, and
falls back to "Enter manually" / "Back" when the server is unreachable
or no models are loaded. README updated to reflect the new setup path.

Made-with: Cursor
2026-04-22 07:55:53 +08:00
Kevin Codex
13de4e85df fix(provider): saved profile ignored when stale CLAUDE_CODE_USE_* in shell (#807)
* fix(provider): saved profile ignored when stale CLAUDE_CODE_USE_* in shell

Users reported "my saved /provider profile isn't picked up at startup —
the banner shows gpt-4o / api.openai.com even though I saved Moonshot".

Root cause: applyActiveProviderProfileFromConfig() bailed out whenever
hasProviderSelectionFlags(processEnv) was true — i.e. whenever ANY
CLAUDE_CODE_USE_* flag was present. But a bare `CLAUDE_CODE_USE_OPENAI=1`
with no paired OPENAI_BASE_URL / OPENAI_MODEL is almost always a stale
shell export left over from a prior manual setup, not genuine startup
intent. Respecting it skipped the saved profile and let StartupScreen.ts
fall through to the hardcoded `gpt-4o` / `https://api.openai.com/v1`
defaults — the exact symptom users see.

Fix: narrow the guard from "any flag set" to "flag set AND at least one
concrete config value (BASE_URL, MODEL, or API_KEY)". A bare stale flag
no longer blocks the saved profile. A real shell selection (flag + URL
or flag + model) still wins, preserving the "explicit startup intent
overrides saved profile" contract.

New helper: hasCompleteProviderSelection(env). Per-provider check for a
paired concrete value. Bedrock/Vertex/Foundry keep the flag-alone
semantic since they rely on ambient AWS/GCP credentials rather than env
config.

Three new tests cover the bug and the two counter-cases:
  - bare USE flag → profile applies (fixes the bug)
  - USE flag + BASE_URL → profile blocked (preserves explicit intent)
  - USE flag + MODEL → profile blocked (preserves explicit intent)

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(provider): don't overlay stale legacy profile on plural-managed env

Second half of the "saved profile not picked up in banner" bug. The prior
commit fixed the guard that prevented applyActiveProviderProfileFromConfig()
from firing when a stale CLAUDE_CODE_USE_* flag was in the shell. But even
when the plural system applies correctly, buildStartupEnvFromProfile() was
then loading the legacy .openclaude-profile.json AND overwriting the
plural-managed env with whatever that file contained.

addProviderProfile() (the call path the /provider preset picker uses) does
NOT sync the legacy file, so a user who went:

  manual setup: CLAUDE_CODE_USE_OPENAI=1 + OPENAI_MODEL=gpt-4o
              → writes .openclaude-profile.json as { openai, gpt-4o, ... }
  /provider:   add Moonshot preset, mark active
              → writes plural config; legacy file UNCHANGED

would see startup reliably apply Moonshot env first, then get it clobbered
by the stale legacy file. Banner shows gpt-4o / api.openai.com while
runtime ends up with the correct env via a different code path — exactly
the user-reported symptom.

Fix: in buildStartupEnvFromProfile, when the plural system has already
set env (CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'), skip the
legacy-file overlay entirely and return processEnv unchanged. Legacy is
now strictly a first-run / fallback path for users who haven't adopted
the plural system.

Also removes the stripped-then-rebuilt env construction that was part of
the old overlay path — no longer needed.

Test updates:
  - Replaced "lets saved startup profile override profile-managed env"
    (encoded the old broken behavior) with a regression test that pins
    the new semantic: plural env survives when legacy is stale.
  - Added "falls back to legacy when plural hasn't applied" to pin the
    first-run path still works.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-22 00:59:32 +08:00
Kevin Codex
a5bfcbbadf feat(provider): zero-config autodetection primitive (#784)
First-run users with a credential already exported (ANTHROPIC_API_KEY,
OPENAI_API_KEY, etc.) currently still have to navigate the provider picker
or set CLAUDE_CODE_USE_* flags manually. Selecting the right provider from
ambient state should be automatic.

New module src/utils/providerAutoDetect.ts:

- detectProviderFromEnv() — synchronous env scan in a deterministic priority
  order (anthropic → codex → github → openai → gemini → mistral → minimax).
  Also detects Codex via ~/.codex/auth.json presence.
- detectLocalService() — parallel probes for Ollama (:11434) and LM Studio
  (:1234), with honoring of OLLAMA_BASE_URL / LM_STUDIO_BASE_URL overrides.
  Short 1.2s default timeout so first-run latency stays low when no local
  service is running.
- detectBestProvider() — orchestrator. Env scan short-circuits the probe;
  only hits the network when env has nothing.

All detection paths are side-effect-free: returns a DetectedProvider
descriptor describing what was found and why. Callers decide whether to
apply it (gated on hasExplicitProviderSelection() / profile file existence)
and how to hydrate the launch env.

Codex auth-file check is injectable (hasCodexAuth option) so tests are
hermetic from the dev machine's ~/.codex/auth.json state.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-21 23:37:04 +08:00
ArkhAngelLifeJiggy
268c0398e4 feat: add thinking token extraction (#798)
* feat: add thinking token tracking and historical analytics

- extractThinkingTokens(): separate thinking from output tokens
- TokenUsageTracker class for historical analytics
- Track: cache hit rate, most used model, requests per hour/day
- Analytics: average tokens per request, totals
- Add tests (7 passing)

PR 4B: Features 1.10 + 1.11

* refactor: extract thinking and analytics to separate files

- Create thinkingTokenExtractor.ts with ThinkingTokenAnalyzer
- Create tokenAnalytics.ts with TokenUsageTracker
- Add production-grade methods and tests
- Update test imports
2026-04-21 23:25:12 +08:00
nickmesen
761924daa7 fix: Collapse all-text arrays to string for DeepSeek compatibility (#806)
Fixes #774. When tool_result content contains multiple text blocks,
they were serialized as arrays instead of strings, causing DeepSeek
to reject the request with 400 error.

Changes:
- convertToolResultContent: collapse all-text arrays to joined string
- convertContentBlocks: defensive collapse for user/assistant messages
- Arrays with images are preserved (not collapsed)

Tests: 3 new tests added, 53 pass, 0 fail

Co-authored-by: nick.mesen <nickmesen@users.noreply.github.com>
2026-04-21 23:17:12 +08:00
Kevin Codex
e908864da7 feat(api): smart model routing primitive (cheap-for-simple, strong-for-hard) (#785)
Most everyday turns ("ok", "thanks", "yep go ahead", "what does that do?")
get no measurable quality improvement from Opus-tier models over Haiku-tier,
but cost ~10x more and stream slower. Smart routing opts a user into
automatically routing obviously-simple turns to a cheaper model while
keeping the strong model for anything non-trivial.

New module src/services/api/smartModelRouting.ts:

- routeModel(input, config) → { model, complexity, reason }
- Pure primitive: no env reads, no state, caller supplies everything.
- Config is opt-in (enabled: false by default).

Routes to strong (conservative) when ANY of:
  - First turn of session (task-setup is worth the quality)
  - Code fence or inline code span present
  - Reasoning/planning keyword (plan, design, refactor, debug, architect,
    investigate, root cause, etc. — 20+ anchors)
  - Multi-paragraph input
  - Over char/word cutoff (defaults: 160 chars, 28 words; matches hermes)

Routes to simple only for clearly-trivial chatter.
Decision includes a reason string for a future UI indicator that shows
which tier handled the turn.

Integration into query path is intentionally deferred to a follow-up PR so
the heuristics can be reviewed and tuned in isolation first.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-21 21:50:24 +08:00
Kevin Codex
b95d2221df Feat/kimi moonshot support (#805)
* feat(provider): first-class Moonshot (Kimi) direct-API support

Moonshot's direct API (api.moonshot.ai/v1) is OpenAI-compatible and works
today via the generic OpenAI shim, including the reasoning_content channel
that Kimi returns alongside the user-visible content. But the UX was rough:
unknown context window triggered the conservative 128k fallback + a warning,
and the provider displayed as "Local OpenAI-compatible".

Makes Moonshot a recognized provider:

- src/utils/model/openaiContextWindows.ts: add the Kimi K2 family and
  moonshot-v1-* variants to both the context-window and max-output tables.
  Values from Moonshot's model card — K2.6 and K2-thinking are 256K,
  K2/K2-instruct are 128K, moonshot-v1 sizes are embedded in the model id.
- src/utils/providerDiscovery.ts: recognize the api.moonshot.ai hostname
  and label it "Moonshot (Kimi)" in the startup banner and provider UI.

Users can now launch with:

  CLAUDE_CODE_USE_OPENAI=1 \
  OPENAI_BASE_URL=https://api.moonshot.ai/v1 \
  OPENAI_API_KEY=sk-... \
  OPENAI_MODEL=kimi-k2.6 \
  openclaude

and get accurate compaction + correct labeling + correct max_tokens out
of the box.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(openai-shim): Moonshot API compatibility — max_tokens + strip store

Moonshot's direct API (api.moonshot.ai and api.moonshot.cn) uses the
classic OpenAI `max_tokens` parameter, not the newer `max_completion_tokens`
that the shim defaults to. It also hasn't published support for `store`
and may reject it on strict-parse — same class of error as Gemini's
"Unknown name 'store': Cannot find field" 400.

- Adds isMoonshotBaseUrl() that recognizes both .ai and .cn hosts.
- Converts max_completion_tokens → max_tokens for Moonshot requests
  (alongside GitHub / Mistral / local providers).
- Strips body.store for Moonshot requests (alongside Mistral / Gemini).

Two shim tests cover both the .ai and .cn hostnames.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix: null-safe access on getCachedMCConfig() in external builds

External builds stub src/services/compact/cachedMicrocompact.ts so
getCachedMCConfig() returns null, but two call sites still dereferenced
config.supportedModels directly. The ?. operator was in the wrong place
(config.supportedModels? instead of config?.supportedModels), so the null
config threw "Cannot read properties of null (reading 'supportedModels')"
on every request.

Reproduces with any external-build provider (notably Kimi/Moonshot just
enabled in the sibling commits, but equally DeepSeek, Mistral, Groq,
Ollama, etc.):

  ❯ hey
  ⏺ Cannot read properties of null (reading 'supportedModels')

- prompts.ts: early-return from getFunctionResultClearingSection() when
  config is null, before touching .supportedModels.
- claude.ts: guard the debug-log jsonStringify with ?. so the log line
  never throws.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(startup): show "Moonshot (Kimi)" on the startup banner

The startup-screen provider detector had regex branches for OpenRouter,
DeepSeek, Groq, Together, Azure, etc., but nothing for Moonshot. Remote
Moonshot sessions fell through to the generic "OpenAI" label —
getLocalOpenAICompatibleProviderLabel() only runs for local URLs, and
api.moonshot.ai / api.moonshot.cn are not local.

Adds a Moonshot branch matching /moonshot/ in the base URL OR /kimi/ in
the model id. Now launches with:

  OPENAI_BASE_URL=https://api.moonshot.ai/v1 OPENAI_MODEL=kimi-k2.6

display the Provider row as "Moonshot (Kimi)" instead of "OpenAI".

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* refactor(provider): sort preset picker alphabetically; Custom at end

The /provider preset picker was in ad-hoc order (Anthropic, Ollama,
OpenAI, then a jumble of third-party / local / codex / Alibaba / custom /
nvidia / minimax). Hard to scan when you know the provider name you want.

Sorts the list alphabetically by label A→Z. Pins "Custom" to the end —
it's the catch-all / escape hatch so it's scanned last, not shuffled into
the alphabetical run where a user looking for a named provider might
grab it by mistake. First-run-only "Skip for now" stays at the very
bottom, after Custom.

Test churn:
- ProviderManager.test.tsx: four tests hardcoded press counts (1 or 3 'j'
  presses) that broke when targets moved. Replaces them with a
  navigateToPreset(stdin, label) helper driven from a declared
  PRESET_ORDER array, so future list edits only update the array.
- ConsoleOAuthFlow.test.tsx: the 13-row test frame only renders the first
  ~13 providers. "Ollama", "OpenAI", "LM Studio" sentinels moved below
  the fold; swap them for alphabetically-early providers still visible
  in-frame ("Azure OpenAI", "DeepSeek", "Google Gemini"). Test intent
  (picker opened with providers listed) is preserved.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-21 21:20:54 +08:00
ArkhAngelLifeJiggy
2b15e16421 feat: add model caching and benchmarking utilities (#671)
* feat: add model caching and benchmarking utilities

- Add modelCache.ts for disk caching of model lists
- Add benchmark.ts for testing model speed/quality

* fix: address review feedback - async fs, multi-provider support, error handling

* feat: add /benchmark slash command and unit tests

* feat: add /benchmark slash command and unit tests
2026-04-21 18:36:16 +08:00
Nourrisse Florian
6a62e3ff76 feat: enable 15 additional feature flags in open build (#667)
* feat: enable 16 additional feature flags in open build

Activate features whose source is fully available in the mirror and
that have no Anthropic-internal infrastructure dependencies:

UI/UX: MESSAGE_ACTIONS, HISTORY_PICKER, QUICK_SEARCH, HOOK_PROMPTS
Reasoning: ULTRATHINK, TOKEN_BUDGET, SHOT_STATS
Agents: FORK_SUBAGENT, VERIFICATION_AGENT, MCP_SKILLS
Memory: EXTRACT_MEMORIES, AWAY_SUMMARY
Optimization: CACHED_MICROCOMPACT, PROMPT_CACHE_BREAK_DETECTION
Safety: TRANSCRIPT_CLASSIFIER
Debug: DUMP_SYSTEM_PROMPT

Also reorganize featureFlags into documented sections (disabled/upstream/new)
with inline comments explaining each flag's purpose.

* feat: add centralized GrowthBook defaults map for open build

Add _openBuildDefaults in the GrowthBook stub (no-telemetry-plugin.ts)
with all 66 runtime feature keys, organized by category with inline
comments describing each flag's purpose.

Override tengu_sedge_lantern (AWAY_SUMMARY) and tengu_hive_evidence
(VERIFICATION_AGENT) to true so these features work out of the box
without requiring manual ~/.claude/feature-flags.json setup.

Priority: feature-flags.json > _openBuildDefaults > upstream default

* feat: replace refusal language with positive security guidance

Remove refusal instructions from CYBER_RISK_INSTRUCTION since they are
redundant for Anthropic models (applied server-side) and useless for
uncensored models in multi-provider setups. Keep positive guidance for
security testing contexts and add red teaming support.

* Revert "feat: replace refusal language with positive security guidance"

This reverts commit 0463676a8f.

* fix: add EXTRACT_MEMORIES runtime gate overrides to open-build defaults

EXTRACT_MEMORIES was enabled at build-time but its runtime GrowthBook
gates (tengu_passport_quail, tengu_coral_fern) still defaulted to false,
preventing the feature from activating. Add both keys to
_openBuildDefaults so memory extraction works out of the box.

Also adds test coverage for _openBuildDefaults precedence behavior.

* docs: update GrowthBook runtime keys catalog to 88 keys

Expand the reference catalog in no-telemetry-plugin.ts from ~62 to 88
unique keys, covering all tengu_* call sites found in src/. Adds 27
previously undocumented keys including VSCode gates, dynamic configs
(auto-mode, cron, bridge), security gates, and KAIROS cron keys.

Adds "not exhaustive" disclaimer as suggested by Copilot reviewer.
Reorganizes categories with section dividers for readability.
2026-04-21 18:34:51 +08:00
3kin0x
06e7684eb5 fix(api): ensure strict role sequence and filter empty assistant messages after interruption (#745 regression) (#794) 2026-04-21 18:28:57 +08:00
Juan Camilo Auriti
ae3b723f3b fix(security): harden project settings trust boundary + MCP sanitization (#789)
* fix(security): harden project settings trust boundary + MCP sanitization

- Sanitize MCP tool result text with recursivelySanitizeUnicode() to prevent
  Unicode injection via malicious MCP servers (tool definitions and prompts
  were already sanitized, but tool call results were not)
- Read sandbox.enabled only from trusted settings sources (user, local, flag,
  policy) — exclude projectSettings to prevent malicious repos from silently
  disabling the sandbox via .claude/settings.json
- Disable git hooks in plugin marketplace clone/pull/submodule operations
  with core.hooksPath=/dev/null to prevent code execution from cloned repos
- Remove ANTHROPIC_FOUNDRY_API_KEY from SAFE_ENV_VARS to prevent credential
  injection from project-scoped settings without trust verification
- Add ssrfGuardedLookup to WebFetch HTTP requests to block DNS rebinding
  attacks that could reach cloud metadata or internal services

Security: closes trust boundary gap where project settings could override
security-critical configuration. Follows the existing pattern established
by hasAllowBypassPermissionsMode() which already excludes projectSettings.

Co-authored-by: auriti <auriti@users.noreply.github.com>

* fix(security): remove unauthenticated file-based permission polling

Remove the legacy file-based permission polling from useSwarmPermissionPoller
that read from ~/.claude/teams/{name}/permissions/resolved/ — an unauthenticated
directory where any local process could forge approval files to auto-approve
tool uses for swarm teammates.

The file polling was dead code:
- The useSwarmPermissionPoller() hook was never mounted by any component
- resolvePermission() (the file writer) was never imported outside its module
- Permission responses are delivered exclusively via the mailbox system:
  Leader: sendPermissionResponseViaMailbox() → writeToMailbox()
  Worker: useInboxPoller → processMailboxPermissionResponse()

Changes:
- Remove file polling loop, processResponse(), and React hook imports from
  useSwarmPermissionPoller.ts (now a pure callback registry module)
- Mark 7 file-based functions as @deprecated in permissionSync.ts
- Add 4 regression tests verifying the removal

No exported functions removed — only deprecated. All 5 consumer modules
verified: they import only mailbox-based functions that remain unchanged.

---------

Co-authored-by: auriti <auriti@users.noreply.github.com>
2026-04-21 18:28:03 +08:00
viudes
a6a3de5ac1 feat(api): compress old tool_result content for small-context providers (#801)
* feat(api): compress old tool_result content for small-context providers

Adds a shim-layer pass that tiers tool_result content by age on
providers
  with small effective context windows (Copilot gpt-4o 128k, Mistral,
  Ollama). Recent turns remain full; mid-tier results are truncated to
2k
  chars; older results are replaced with a stub that preserves tool name
  and arguments so the model can re-invoke if needed.

  Tier sizes auto-tune via getEffectiveContextWindowSize, same
calculation
  used by auto-compact. Reuses COMPACTABLE_TOOLS and
  TOOL_RESULT_CLEARED_MESSAGE to complement (not duplicate)
microCompact.
  Configurable via /config toolHistoryCompressionEnabled.

  Addresses active-session context accumulation on Copilot where
  microCompact's time-based trigger never fires, which surfaces as
  "tools appearing in a loop" and prompt_too_long errors after ~15
turns.

* fix: config tool history
2026-04-21 17:36:26 +08:00
Juan Camilo Auriti
64582c119d fix: replace discontinued gemini-2.5-pro-preview-03-25 with stable gemini-2.5-pro (#802)
Updates both the model config mappings (configs.ts) and the runtime
fallback in getDefaultOpusModel() (model.ts) so Gemini mode no longer
falls back to the discontinued preview model when GEMINI_MODEL is unset.

Fixes #398
2026-04-21 17:01:33 +08:00
emsanakhchivan
85eab2751e fix(ui): prevent provider manager lag by deferring sync I/O (#803)
ProviderManager was blocking the main thread with synchronous file I/O
on mount (useState initializer), activation (setActiveProviderProfile),
and refresh (getProviderProfiles). This caused noticeable lag on Windows
where disk I/O can be slow due to antivirus scans, NTFS metadata, or
cache misses.

Changes to ProviderManager:
- Deferred initialization: useState now starts empty, loads via queueMicrotask
- Added isInitializing state with loading UI
- refreshProfiles() now defers reads via queueMicrotask
- activateSelectedProvider() now defers writes via queueMicrotask
- Memoized menuOptions array to prevent re-renders during navigation

Note: ProviderChooser useMemo change was reverted as it's dead code
(ProviderWizard is not used in production - /provider uses ProviderManager).

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-21 17:00:58 +08:00
Zartris
4d4fb2880e fix: rename .claude.json to .openclaude.json with legacy fallback (#582)
* fix: rename .claude.json to .openclaude.json with legacy fallback

Rename the global config file from ~/.claude.json to ~/.openclaude.json,
following the same migration pattern as the config directory
(~/.claude → ~/.openclaude).

- getGlobalClaudeFile() now prefers .openclaude.json; falls back to
  .claude.json only if the legacy file exists and the new one does not
- Add .openclaude.json to filesystem permissions allowlist (keep
  .claude.json for legacy file protection)
- Update all comment/string references from ~/.claude.json to
  ~/.openclaude.json across 12 files

New installs get .openclaude.json from the start. Existing users
continue using .claude.json until they rename it (or a future explicit
migration).

* test: add unit tests for getGlobalClaudeFile migration branches

Covers the three cases:
- new install (neither file exists) → .openclaude.json
- existing user (only legacy .claude.json exists) → .claude.json
- migrated user (both files exist) → .openclaude.json

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-20 17:13:09 +08:00
Zartris
fdef4a1b4c feat: native Anthropic API mode for Claude models on GitHub Copilot (#579)
* feat: native Anthropic API mode for Claude models on GitHub Copilot

When using Claude models through GitHub Copilot, automatically switch from
the OpenAI-compatible shim to Anthropic's native messages API format.

The Copilot proxy (api.githubcopilot.com) supports Anthropic's native API
for Claude models. This enables cache_control blocks to be sent and
honoured, allowing explicit prompt caching control (as opposed to relying
solely on server-side auto-caching).

Changes:
- Add isGithubNativeAnthropicMode() in providers.ts that auto-enables when
  the resolved model starts with "claude-" and the GitHub provider is active
- Create a native Anthropic client in client.ts using the GitHub base URL
  and Bearer token authentication when native mode is detected
- Enable prompt caching in claude.ts for native GitHub mode so cache_control
  blocks are sent (previously only allowed for firstParty/bedrock/vertex)
- CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 env var to force native mode for any
  model

Benefits:
- Proper Anthropic message format (no lossy OpenAI translation)
- Explicit cache_control blocks for fine-grained caching control
- Potentially better Claude model behaviour with native format

Related: #515

* fix: scope force flag to Claude models and add isGithubNativeAnthropicMode tests

- CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 now returns false for non-Claude models
  (force flag still useful for aliases like 'github:copilot' with no model
  resolved yet, where it returns true when model is empty)
- Add 7 focused tests covering mode detection: off without GitHub provider,
  auto-detect via OPENAI_MODEL and resolvedModel, non-Claude model rejection,
  and force-flag behaviour for claude/non-claude/no-model cases

* fix: detect github:copilot:claude- compound format, remove force flag

OPENAI_MODEL for GitHub Copilot uses the format 'github:copilot:MODEL'
(e.g. 'github:copilot:claude-sonnet-4'), which does not start with 'claude-'.
Auto-detection now handles both bare model names and the compound format.

The CLAUDE_CODE_GITHUB_ANTHROPIC_API force flag is removed: with proper
compound-format detection there is no remaining gap it could fill, and
keeping a broad override flag without a concrete use case invites misuse.

Tests updated to cover the compound format, generic alias (false), and
non-Claude compound model (github:copilot:gpt-4o → false).

* fix: use includes('claude-') for model detection, remove force flag

Detection was broken for the standard GitHub Copilot compound format
'github:copilot:claude-sonnet-4' which does not start with 'claude-'.
Using includes('claude-') handles bare names, compound names, and any
future variants without needing updates.

The CLAUDE_CODE_GITHUB_ANTHROPIC_API force flag is removed as it was
a workaround for the broken detection, not a genuine use case.

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-20 16:34:58 +08:00
nehan
4cb963e660 feat(api): improve local provider reliability with readiness and self-healing (#738)
* feat(api): classify openai-compatible provider failures

* Update src/services/api/providerConfig.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(api): harden openai-compatible diagnostics and env fallback

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix openaiShim duplicate requests and diagnostics

* remove unused url from http failure classifier

* dedupe env diagnostic warnings

* Remove hardcoded URLs from OpenAI error tests

Removed hardcoded URLs from network failure classification tests.

* Update providerConfig.envDiagnostics.test.ts

* fix(openai-shim): return successful responses and restore localhost classifier tests

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(provider): add truthful local generation readiness checks

Implement Phase 2 provider readiness behavior by adding structured Ollama generation probes, wiring setup flows to readiness states, extending system-check with generation readiness output, and updating focused tests.

* feat(api): add local self-healing fallback retries

Implement Phase 3 self-healing behavior for local OpenAI-compatible providers: retry base URL fallbacks for localhost resolution and endpoint mismatches, plus capability-gated toolless retry for tool-incompatible local models; include diagnostics and focused tests.

* fix(api): address review blockers for local provider reliability

* Update src/utils/providerDiscovery.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: harden readiness probes and cross-platform test stability

* fix: refresh toolless retry payload and stabilize osc clipboard test

* fix: harden Ollama readiness parsing and redact provider URLs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 16:24:02 +08:00
github-actions[bot]
b09972f223 chore(main): release 0.5.2 (#781)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 15:25:42 +08:00
Kevin Codex
336ddcc50d fix(api): replace phrase-based reasoning sanitizer with tag-based filter (#779)
Reasoning models (MiniMax M2.7, GLM-4.5/5, DeepSeek, Kimi K2) inline
chain-of-thought inside <think>...</think> tags in the content field
rather than using the reasoning_content channel. The prior phrase-matching
sanitizer (looksLikeLeakedReasoningPrefix) only caught English-prose
preambles like "I should"/"the user asked", missed tag-based leaks
entirely, and risked false-stripping legitimate assistant output.

Replace with a structural tag-based approach (same pattern as hermes-agent):

- createThinkTagFilter() — streaming state machine that buffers partial
  tags across SSE delta boundaries (<th| + |ink>), so tags split mid-chunk
  still parse correctly.
- stripThinkTags() — whole-text cleanup for non-streaming responses and
  as a safety net. Handles closed pairs, unterminated opens at block
  boundaries, and orphan tags.
- Recognizes think, thinking, reasoning, thought, REASONING_SCRATCHPAD
  case-insensitively, including tags with attributes.
- False-negative bias: flush() discards buffered partial tags at stream
  end rather than leaking them.

Existing phrase-based shim tests updated to exercise the actual <think>
tag leak. Added regression tests confirming legitimate prose starting
with "I should..." is preserved (the old sanitizer's main false-positive).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:18:58 +08:00
github-actions[bot]
c0b8a59a23 chore(main): release 0.5.1 (#776)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 12:47:40 +08:00
Kevin Codex
aab489055c fix: require trusted approval for sandbox override (#778) 2026-04-20 12:01:44 +08:00
Kevin Codex
7002cb302b fix: enforce Bash path constraints after sandbox allow (#777) 2026-04-20 11:46:24 +08:00
Kevin Codex
739b8d1f40 fix: enforce MCP OAuth callback state before errors (#775) 2026-04-20 09:36:05 +08:00
github-actions[bot]
f166ec1a4e chore(main): release 0.5.0 (#758)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 08:30:58 +08:00
Kevin Codex
13e9f22a83 feat: mask provider api key input (#772) 2026-04-20 08:25:22 +08:00
Kevin Codex
f828171ef1 fix: allow provider recovery during startup (#765) 2026-04-20 06:46:05 +08:00
Allan Almeida
e6e8d9a248 feat: add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization (#770)
When set, disables strict schema normalization for non-Gemini providers.
Useful for OpenAI-compatible endpoints that reject MCP tools with complex
optional params (e.g. list[dict]) with "Extra required key ... supplied"
errors.
2026-04-20 06:45:01 +08:00
Sreedhar Busanelli
2c98be7002 fix: remove cached mcpClient in diagnostic tracking to prevent stale references (#727)
* fix: remove cached mcpClient in diagnostic tracking to prevent stale references

Resolves TODO comment about not caching the connected mcpClient since it can change.

Changes:
- Remove cached mcpClient field from DiagnosticTrackingService
- Add currentMcpClients storage to track active clients
- Update beforeFileEdited, getNewDiagnostics, and ensureFileOpened to accept client parameter
- Add backward-compatible methods to maintain existing API
- Update all callers to use new methods
- Add comprehensive test coverage

This prevents using stale MCP client references during reconnections,
making diagnostic tracking more reliable.

Fixes #TODO

* docs: add my contributions section to README

Add fork-specific section highlighting:
- Diagnostic tracking enhancement (PR #727)
- Technical skills demonstrated
- Links to original project and my work
- Professional contribution showcase

* revert: remove README.md contributions section to comply with reviewer request

- Remove 'My Fork & Contributions' section from README.md
- Keep README.md focused on original project documentation
- Maintain clean, project-focused README as requested by reviewer
2026-04-19 09:02:52 +08:00
3kin0x
b786b765f0 fix(api): drop orphan tool results to satisfy strict role sequence (#745)
* fix(api): drop orphan tool results to satisfy Mistral/OpenAI strict role sequence

* test: add test for orphan tool results and restore gemini comments
2026-04-19 08:57:14 +08:00
bpawnzz
55c5f262a9 fix: use raw context window for auto-compact percentage display (#748)
Problem: After auto-compaction with DeepSeek models (e.g., deepseek-chat),
the status line displayed ~16% remaining until next auto-compact, but users
expected ~30% (since compaction reduces usage to roughly half of the full
128k context).

Root cause: calculateTokenWarningState() used the auto-compaction threshold
(effectiveContextWindow - 13k buffer) as the denominator for percentLeft.
For DeepSeek-chat:
- Raw context: 128,000
- Effective: 119,808 (128k - 8,192 output reservation)
- Threshold: 106,808 (effective - 13k buffer)
At 90k usage:
  - Old: (106,808 - 90k) / 106,808 ≈ 16%
  - Expected: (128,000 - 90k) / 128,000 ≈ 30%

Fix: Change percentLeft calculation to use raw context window from
getContextWindowForModel() as denominator, while keeping threshold-based
warnings/triggers unchanged. This makes the displayed percentage show
remaining capacity relative to the model's full context size.

Impact:
- UI now shows correct % of total context remaining
- Auto-compaction trigger point unchanged (still ~90% of effective window)
- All other threshold calculations unaffected

Testing:
- Manual verification: DeepSeek-chat at 90k tokens shows 30% remaining (was 16%)
- Manual verification: Threshold still triggers at ~106k tokens
- Build succeeds: npm run build
- No breaking changes: Callers only depend on percentLeft for display; threshold logic unchanged

Fixes the user-reported discrepancy for DeepSeek and other OpenAI-compatible models.
2026-04-19 08:55:41 +08:00
Kagura
002a8f1f6d fix(mcp): sync required array with properties in tool schemas (#754)
* fix(mcp): sync required array with properties in tool schemas

MCP servers can emit schemas where the required array contains keys
not present in properties. This causes API 400 errors:
"Extra required key 'X' supplied."

- Add sanitizeSchemaRequired() to filter required arrays
- Apply it to MCP tool inputJSONSchema before sending to API
- Also fix filterSwarmFieldsFromSchema to update required after
  removing properties

Fixes #525

* test: add MCP schema required sanitization test
2026-04-19 06:44:25 +08:00
dhenuh
3d1979ff06 fix(help): prevent /help tab crash from undefined descriptions (#732)
- Guard formatDescriptionWithSource() so missing command descriptions become ''
- Harden truncate helpers to accept undefined text/path safely
- Add regression tests covering undefined input cases
2026-04-19 06:38:44 +08:00
lunamonke
b0d9fe7112 Provider loading fix (#623)
* add mistral and gemini provider type for profile provider field

* load latest locally selected

* env variables take precedence over json save

* add gemini context windows and fix gemini defaulting for env

* load on startup fix

* fix failing tests

* clarify test message

* fix variable mismatches

* fix failing test

* delete keys and set profile.apiKey for mistral and gemini

* switch model as well when switching provider

* set model when adding a new model
2026-04-18 01:46:20 +08:00
github-actions[bot]
651123db1f chore(main): release 0.4.0 (#704)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-17 19:36:32 +08:00
emsanakhchivan
34246635fb fix(ui): show correct endpoint URL in intro screen for custom Anthropic endpoints (#735)
Previously, the startup intro screen always displayed
'https://api.anthropic.com' as the endpoint for Anthropic provider,
even when a custom endpoint was configured via ANTHROPIC_BASE_URL.

This fix reads ANTHROPIC_BASE_URL from environment and displays the
actual configured endpoint, providing accurate information to users
about where their API requests will be sent (proxy gateways, staging,
custom Anthropic-compatible APIs).

Also adds isLocal detection for local endpoints to show appropriate
visual indicator in the startup banner.

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-17 19:06:47 +08:00
regisksc
43ac6dba75 feat: add Alibaba Coding Plan (DashScope) provider support (#509)
* feat: add Alibaba Coding Plan provider presets

* fix: add DashScope presets to ProviderManager UI selection list

* feat: read DASHSCOPE_API_KEY env var for DashScope provider presets

* adds regression testing for alibaba models

* docs: add time descriptive comment

* feat(dashscope): add qwen3.6-plus model support

* fix(dashscope): remove MiniMax-M2.5 entries to prevent future key conflicts
2026-04-17 19:06:21 +08:00
nehan
80a00acc2c feat(api): classify openai-compatible provider failures (#708)
* feat(api): classify openai-compatible provider failures

* Update src/services/api/providerConfig.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(api): harden openai-compatible diagnostics and env fallback

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix openaiShim duplicate requests and diagnostics

* remove unused url from http failure classifier

* dedupe env diagnostic warnings

* Remove hardcoded URLs from OpenAI error tests

Removed hardcoded URLs from network failure classification tests.

* Update providerConfig.envDiagnostics.test.ts

* fix(openai-shim): return successful responses and restore localhost classifier tests

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 18:01:40 +08:00
Andrei Parshin
eed77e6579 fix: prevent crash in commands tab when description is undefined (#730)
This commit fixes a crash in the CLI that occurs when navigating to the /help commands tab. The issue happens because the truncate function receives an undefined value for the str parameter if a command lacks a description, causing the .indexOf() method to throw an exception. To resolve this, an early return check was added at the beginning of the function to gracefully handle empty values and prevent the UI from crashing.
2026-04-17 13:57:40 +08:00
guanjiawei
b280c740a6 fix serialize git worktree mutations and forward teammate PATH (#721) 2026-04-16 21:44:56 +08:00
guanjiawei
2ff5710329 fix retry Codex and OpenAI fetches via proxy-aware helper (#720) 2026-04-16 21:42:14 +08:00
emsanakhchivan
d6f5130c20 fix: focus "Done" option after completing provider manager actions (#718)
When returning to the provider manager menu after completing an action
(add, edit, delete, set active, etc.), the cursor now lands on "Done"
instead of the first option ("Add provider"). This prevents accidental
re-entry into the same action if the user presses Enter quickly.

On initial /provider invocation, the cursor still starts on the first
option ("Add provider") as expected.

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-16 21:39:13 +08:00
Rubens Oliveira
d32a2a1329 docs: add Ollama launch integration documentation (#716)
Document the new `ollama launch openclaude` command as a shortcut
for running OpenClaude through a local Ollama instance. This is
now supported in Ollama's launch system and handles all environment
variable setup automatically — no manual env vars needed.

Changes:
- README.md: Add "Using Ollama's launch command" section after the
  manual Ollama env var setup, and update the provider table to
  list `ollama launch` as a setup path for Ollama
- docs/advanced-setup.md: Add `ollama launch` as the recommended
  method at the top of the Ollama section, with the manual env var
  approach kept below as an alternative
2026-04-16 21:23:44 +08:00
henriquepasquini2
fbcd928f7f feat(vscode): add full chat interface to OpenClaude extension (#608)
Add a Claude Code-like chat experience to the VS Code extension with:
- Streaming chat panel (sidebar + editor tab) with markdown rendering
- Tool use visualization with inline diffs (replace/with display)
- Session history browser with JSONL transcript parsing
- Thinking block indicator with elapsed time and token count
- Clickable file paths that open in the editor
- Permission mode setting (acceptEdits default)
- Multi-turn conversation support via NDJSON stream-json protocol
- Status bar with live activity indicators
- Ctrl+Shift+L keybinding to open chat panel

Made-with: Cursor

Co-authored-by: henriquepasquini2 <henriquepasquini2@users.noreply.github.com>
2026-04-16 05:04:31 +08:00
Yakout
77083d769b Fix/MCP exposure v2 TODO's (#675)
* fix: OAuth tokens secure storage for Windows & Linux

* fix(mcp): MCP Tool Re-exposure & Strict Input Validation

Fixes the MCP re-exposure bug by correctly handling tool deduplication, input validation with Ajv, and structured output (including images). Also disables experimental API betas by default to prevent 500 errors on external accounts.

* fix(mcp): skip official registry prefetch in non-first-party mode

Prevents unnecessary calls to Anthropic's MCP registry when using other API providers.

* fix(cli): disable experimental API betas by default

This prevents 500 errors from Anthropic's API when tool-calling with non-Anthropic accounts or models that don't support certain beta features.

* fix: issues raised in the PR review for #675
2026-04-16 05:03:06 +08:00
emsanakhchivan
b66633ea4d Feat/multi model provider support (#692)
* test: add tests for provider model env updates and multi-model profiles

Add comprehensive tests covering:
- OPENAI_MODEL/ANTHROPIC_MODEL env updates on provider activation
- Cross-provider type switches (openai ↔ anthropic) clearing stale env
- Multi-model profile activation using only the first model for env vars
- Model options cache population from comma-separated model lists
- getProfileModelOptions generating correct ModelOption arrays

* feat: multi-model provider support and model auto-switch

Support comma-separated model names in provider profiles (e.g.
"glm-4.7, glm-4.7-flash"). The first model is used as default on
activation; all models appear in the /model picker for easy switching.

When switching active providers, the session model now automatically
updates to the new provider's first model. The multi-model list is
preserved across switches and /model selections.

Changes:
- Add parseModelList, getPrimaryModel, hasMultipleModels utilities
  with full test coverage (19 tests)
- Use getPrimaryModel when applying profiles to process.env so only
  the primary model is set in OPENAI_MODEL/ANTHROPIC_MODEL
- Update ProviderManager UI to hint at multi-model syntax and show
  model count in provider list summaries
- Populate model options cache from multi-model profiles on activation
  so all models appear in /model picker regardless of base URL type
- Guard persistActiveProviderProfileModel against overwriting
  comma-separated lists: models already in the profile are session
  selections, not profile edits
- Set AppState.mainLoopModel to the actual model string on provider
  switch so Anthropic profiles use the configured model instead of
  falling back to the built-in default

* fix: only show profile models when provider profile env is applied

Guard the profile model picker options behind a
PROFILE_ENV_APPLIED check. getActiveProviderProfile() has a
?? profiles[0] fallback that returns the first profile even when
no profile is explicitly active, causing users with inactive
profiles to lose all standard model options (Opus, Haiku, etc.)
from the /model picker.

* fix: show all model names for profiles with 3 or fewer models

Instead of a summary format for multi-model profiles, display all
model names when there are 3 or fewer. Only use the "+ N more"
format for profiles with 4+ models.

* fix: preserve standard model options in picker alongside profile models

The previous implementation used an early return that replaced all
standard picker options (Opus, Haiku, Sonnet for Anthropic; Codex/GPT
models for OpenAI) with only the profile's custom models.

Changes:
- Collect profile models into a shared array instead of early returning
- Append profile models to firstParty path (Opus + Haiku + Sonnet + custom)
- Append profile models to PAYG 3P path (Codex + Sonnet + Opus + Haiku + custom)
- Guard collection behind PROFILE_ENV_APPLIED to avoid ?? profiles[0] fallback

Fixes review feedback: standard models are no longer hidden when a
provider profile with custom models is active. Users see both the
standard options and their profile's models.

---------

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-16 05:01:55 +08:00
ArkhAngelLifeJiggy
51191d6132 feat: add NVIDIA NIM and MiniMax provider support (#552)
* feat: add NVIDIA NIM and MiniMax provider support

- Add nvidia-nim and minimax to --provider CLI flag
- Add model discovery for NVIDIA NIM (160+ models) and MiniMax
- Update /model picker to show provider-specific models
- Fix provider detection in startup banner
- Update .env.example with new provider options

Supported providers:
- NVIDIA NIM: https://integrate.api.nvidia.com/v1
- MiniMax: https://api.minimax.io/v1

* fix: resolve conflict in StartupScreen (keep NVIDIA/MiniMax + add Codex detection)

* fix: resolve providerProfile conflict (add imports from main, keep NVIDIA/MiniMax)

* fix: revert providerSecrets to match main (NVIDIA/MiniMax handled elsewhere)

* fix: add context window entries for NVIDIA NIM and new MiniMax models

* fix: use GLM-5 as NVIDIA NIM default and MiniMax-M2.5 for consistency

* fix: address remaining review items - add GLM/Kimi context entries, max output tokens, fix .env.example, revert to Nemotron default

* fix: filter NVIDIA NIM picker to chat/instruct models only, set provider-specific API keys from saved profiles

* chore: add more NVIDIA NIM context window entries for popular models

* fix: address remaining non-blocking items - fix base model, clear provider API keys on profile switch
2026-04-15 20:26:13 +08:00
Jeevan Mohan Pawar
6b2121da12 fix(models): prevent /models crash from non-string saved model values (#691)
* fix(models): guard GitHub default model setting against non-string values

* test(models): avoid brittle GitHub default assertion in model guard test
2026-04-15 19:47:02 +08:00
dhenuh
c207cdbdcc ci: skip release-please on fork repositories (#701) 2026-04-15 19:46:39 +08:00
Nourrisse Florian
a00b7928de fix: strip comments before scanning for missing imports (#676)
* fix: strip comments before scanning for missing imports

The scanForMissingImports regex matched require() and import() patterns
inside JSDoc comments, causing false-positive missing module detection.
A documented path like `require('./commands/proactive.js')` in a comment
was resolved from the wrong directory, marked as missing, then the global
onResolve handler intercepted ALL imports of that specifier — including
valid ones — replacing them with truthy noop stubs that broke runtime.

Strip block (/* */) and line (//) comments from source before scanning.

* fix: repair 10 pre-existing test failures

- promptIdentity.test.ts: define MACRO global (ISSUES_EXPLAINER etc.)
  for test mode where Bun.define build-time replacements aren't active
- context.test.ts: clear OPENAI_MODEL env var in each test — the user's
  environment (e.g. OPENAI_MODEL=github_copilot/gpt-5.4) polluted the
  provider-qualified lookup, returning wrong context windows
- openclaudePaths.test.ts: set CLAUDE_CONFIG_DIR to force .openclaude
  path when ~/.openclaude doesn't exist on the test machine
2026-04-15 19:42:26 +08:00
3kin0x
12dd3755c6 feat: add ripgrep to Dockerfile for faster file searching (#688) 2026-04-15 19:42:06 +08:00
dhenuh
114f772a4a tests: avoid global fetch mutation in GitHub device flow tests (#702) 2026-04-15 19:38:46 +08:00
Kevin Codex
7187fc007a docs: add Star History chart to README (#686)
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-15 02:38:05 +08:00
Fexiven
0ed50ccfe7 Fix Docker deployment (#685)
* feat: add Docker image build and push to GHCR on release

Add Dockerfile (multi-stage build with node:22-slim) and a new docker
job in the release workflow that builds and pushes to ghcr.io when
release-please creates a tag.

* feat(docker): run as non-root user and add smoke test

Run the container as a non-root appuser to reduce blast radius.
Add a smoke test step that runs --version before pushing to GHCR.

* fix(docker): use existing node user instead of creating appuser

Closes #681
2026-04-15 01:22:08 +08:00
github-actions[bot]
131b31bf0e chore(main): release 0.3.0 (#661)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-14 19:24:31 +08:00
Nourrisse Florian
c1beea9867 feat: open useful USER_TYPE-gated features to all users (#644)
* feat: open useful USER_TYPE-gated features to all users

Remove 13 process.env.USER_TYPE === 'ant' gates that restricted useful
features to Anthropic employees. These features work without Anthropic
infrastructure and are now available to all open-build users.

Features opened:
- Agent nesting (sub-agents can spawn sub-agents)
- Effort 'max' persistence in settings
- Plan mode interview phase (controlled by feature flags)
- Sandbox disabled commands (via ~/.claude/feature-flags.json)
- All tips visible to all users (plan mode, feedback, shift-tab)

Simplified:
- Fullscreen defaults to off (use /config to enable)
- Explore agent always uses haiku model
- Plan mode tool uses conservative prompt for all users

Continues the USER_TYPE cleanup from #637 (dead code) and builds
on #639 (local feature flags).

* fix: address Copilot review comments — remove residual dead code

1. bridgeConfig.ts: ungate bridge override functions — return env vars
   directly instead of hardcoded undefined
2. bridgeMain.ts + initReplBridge.ts: ungate sessionIngressUrl — read
   CLAUDE_BRIDGE_SESSION_INGRESS_URL without USER_TYPE check
3. tools.ts: remove dead ConfigTool/TungstenTool imports, narrow
   eslint-disable scope, stub REPLTool/SuggestBackgroundPRTool to null
4. readOnlyValidation.ts: remove orphaned ANT_ONLY_COMMAND_ALLOWLIST
   and unused GH_READ_ONLY_COMMANDS import
5. insights.ts: remove entire remote collection plumbing (types,
   functions, options, display logic)
6. osc.ts: hardcode supportsTabStatus() to false (internal-only feature)
7. state.ts: simplify addSlowOperation/getSlowOperations to no-ops,
   remove dead constants

* fix: address Copilot review on PR #644

1. settings/types.ts: allow 'max' effort level for all users in Zod
   schema — was still gated behind USER_TYPE=ant, causing 'max' to be
   silently dropped on settings reload
2. shouldUseSandbox.ts: defensively normalize disabledCommands from
   feature flag config with Array.isArray() guards

* fix: address second round of Copilot review on PR #644

1. shouldUseSandbox.ts: validate top-level shape of disabledCommands
   before accessing properties (handles null/primitive from feature flag)
2. fullscreen.ts: update JSDoc to reflect removal of USER_TYPE default
3. osc.ts: update JSDoc — "Ant-only" → "Currently disabled"
2026-04-14 19:08:54 +08:00
Fexiven
658d076909 feat: add Docker image build and push to GHCR on release (#656)
* feat: add Docker image build and push to GHCR on release

Add Dockerfile (multi-stage build with node:22-slim) and a new docker
job in the release workflow that builds and pushes to ghcr.io when
release-please creates a tag.

* feat(docker): run as non-root user and add smoke test

Run the container as a non-root appuser to reduce blast radius.
Add a smoke test step that runs --version before pushing to GHCR.
2026-04-14 19:03:10 +08:00
Vasanth T
a07e5ef990 fix: bump axios 1.14.0 → 1.15.0 (Dependabot #4, #5) (#670)
* fix: bump axios 1.14.0 → 1.15.0 (Dependabot #4, #5)

Resolve two critical Dependabot alerts:
- #5: Unrestricted Cloud Metadata Exfiltration via Header Injection Chain
- #4: NO_PROXY Hostname Normalization Bypass Leads to SSRF

Both require axios >= 1.15.0.

* fix: update bun.lock for axios 1.15.0

CI failed with 'lockfile had changes, but lockfile is frozen'.
Regenerated lockfile after axios bump.

---------

Co-authored-by: root <root@vm7508.lumadock.com>
2026-04-14 19:00:55 +08:00
FluxLuFFy
25ce2ca7bf fix: resolve 12 bugs across API, MCP, agent tools, web search, and context overflow (#674)
* fix: resolve 12 bugs across API, MCP, agent tools, web search, and context overflow

API fixes:
- Fix Gemini 400 error: delete 'store: false' field for Gemini endpoints
  (was globally injected, Gemini rejects unknown fields)
- Fix session timeout 500 errors after ~25min: add 120s idle timeout
  on SSE stream readers in openaiShim and codexShim to detect dead
  connections and trigger withRetry reconnection
- Fix context overflow 500 errors: add handler in errors.ts for 500
  responses caused by oversized conversation context (too many tokens),
  surfacing user-friendly message with recovery actions instead of raw
  'API Error: 500'

Agent loop fix:
- Fix premature task completion: detect continuation signals like
  'so now I have to do it' in assistant text without tool calls and
  inject a meta nudge to force the agent to continue

Web search improvements:
- Increase result counts: Bing/Tavily/Exa/Firecrawl from 10→15,
  Mojeek/You/Jina from default→10 (explicit), max_uses 8→15

MCP fixes:
- Reduce default tool timeout from ~27.8 hours to 5 minutes
  (tools no longer hang indefinitely on unresponsive servers)
- Add retry logic (3 attempts) for tools/list fetch failures
  (prevents all MCP tools from silently disappearing on timeout)
- Add abort signal check in URL elicitation retry loop
- Improve MCP error messages with server and tool name context

Agent tool fixes:
- Fix SendMessage race condition: double-check task status before
  auto-resuming stopped agents to prevent duplicate registration
- Fix auto-compact circuit breaker gap: when auto-compact fails 3+
  consecutive times, proactively block oversized context BEFORE the
  API call instead of letting it 500. Clear message with recovery
  instructions (/new, /compact, rewind).

Tests: 850 total, 0 failures (25 new bugfix tests)

* fix: address all 4 review blockers + 6 additional issues from PR #674

Blockers (from Vasanthdev2004 review):

1. Continuation nudge infinite loop — no loop guard
   Added continuationNudgeCount to State, capped at MAX_CONTINUATION_NUDGES (3).
   Counter increments on each nudge, resets on tool execution (next_turn).

2. Continuation signal regexes too broad — high false-positive rate
   Tightened all patterns to require explicit action verbs. Added completion
   marker check (done/finished/completed/summary). Broad patterns only fire
   on messages <80 chars.

3. BUGFIXES.md in repo root — scope contamination
   Removed. PR description already contains this info.

4. AgentTool dump state cleanup is comment-only, not a bug fix
   Wrapped clearInvokedSkillsForAgent and clearDumpState in individual
   try/catch blocks so one failure doesn't prevent the other.

Additional issues:

5+6. readWithTimeout ignores AbortSignal, timer leak on abort
   Added optional signal param to openaiStreamToAnthropic,
   codexStreamToAnthropic, collectCodexCompletedResponse, readSseEvents.
   Added abort listener that clears idle timer so AbortError surfaces
   cleanly instead of spurious idle timeout.

7. MCP error format change breaks consumers
   Reverted human-readable message to original errorDetails format.
   Moved server/tool context to telemetryMessage param only.

10. AgentTool test broken by comment change
   Updated test assertions to match new defensive cleanup text + try/catch.

12. Mojeek test regex dangerously broad
   Tightened to match searchParams.set('t', '10') specifically.

14. linkup.ts in providerCounts test — no result count field
   Removed from providers list (uses depth param, not result count).

15. Error message overlap between errors.ts and query.ts
   Prefixed errorDetails with 'Context overflow (500):' to distinguish.

Tests: 851 pass, 0 fail

---------

Co-authored-by: openclaude-bot <bot@openclaude.ai>
Co-authored-by: Fix Bot <fix@openclaude.dev>
2026-04-14 18:59:53 +08:00
Kevin Codex
1741f32cb7 docs: add GitLawb mirror to README (#669) 2026-04-13 22:53:56 +08:00
Henrique Fernandes
fc7dc9ca0d Add Codex OAuth provider flow for ChatGPT account sign-in (#503)
* feat: add Codex OAuth provider flow

* fix: harden Codex OAuth storage, session activation, and UI
2026-04-13 22:34:16 +08:00
Nourrisse Florian
252808bbd0 feat: activate message actions in open build (#632)
Enable the MESSAGE_ACTIONS feature flag so open-build users get the
shift+up keybinding for the message actions panel.

Gate sites: src/keybindings/defaultBindings.ts, src/screens/REPL.tsx
(5 total). Pure UI/keybinding feature with zero external dependencies.
2026-04-13 21:48:29 +08:00
Nourrisse Florian
0e48884f56 feat: local feature flag overrides via ~/.claude/feature-flags.json (#639)
* feat: local feature flag overrides via ~/.claude/feature-flags.json

Replace the GrowthBook no-op stub with a local JSON file reader that
gives open-build users control over ~50 tengu_* feature flags without
needing Anthropic's GrowthBook server.

How it works:
- On first flag lookup, lazily reads ~/.claude/feature-flags.json
- Returns the configured value if the key exists, defaultValue otherwise
- When the file is absent, behavior is identical to the current stub
- CLAUDE_FEATURE_FLAGS_FILE env var overrides the file path (CI/testing)

Example ~/.claude/feature-flags.json:
  { "tengu_kairos_cron": true, "tengu_scratch": true }

Continues the infrastructure work from #315 and #352. This is a
prerequisite for replacing remaining USER_TYPE gates with local config.

* fix: use ESM imports and validate JSON shape in growthbook stub

- Replace require('fs'/'path'/'os') with ESM imports (node: prefix)
  to avoid ReferenceError in ESM bundle output
- Validate JSON.parse result is a plain object before using `in` operator
  to prevent TypeError on non-object JSON values

Addresses Copilot review comments on #639

* fix: reset flags cache in resetGrowthBook and refreshGrowthBookFeatures

Set _flags back to undefined so subsequent lookups re-read the JSON
file. Enables runtime reload and proper test isolation.

Addresses Copilot review comment on #639

* docs: explain why checkSecurityRestrictionGate is excluded from local flags

This is a remote killswitch for bypassPermissions mode — exposing it
via the local JSON file would let users accidentally disable
--dangerously-skip-permissions without understanding why.

* test: add unit tests for growthbook stub local feature flags

Covers: valid JSON loading, missing file fallback, malformed JSON,
non-object JSON (primitive, array), cache invalidation via
resetGrowthBook/refreshGrowthBookFeatures, all getter variants,
and checkSecurityRestrictionGate always returning false.

12 tests, 21 assertions.

* fix: use Object.hasOwn instead of in operator for flag lookup

Prevents inherited prototype properties (toString, constructor, etc.)
from being returned as flag values.

Addresses Copilot review comment on #639

* fix: align gate stub signatures and add Boolean coercion

Address remaining Copilot review feedback:
- checkSecurityRestrictionGate: accept gate param to match real signature
- checkStatsigFeatureGate/checkGate: coerce with Boolean() like real impl
2026-04-13 21:40:33 +08:00
Nourrisse Florian
b818dd5958 feat: implement Monitor tool for streaming shell output (#649)
* feat: implement Monitor tool for streaming shell output

Add the Monitor tool that executes shell commands in the background and
streams stdout line-by-line as notifications to the model. This enables
real-time monitoring of logs, builds, and long-running processes.

Implementation:
- MonitorTool (src/tools/MonitorTool/) — spawns LocalShellTask with
  kind='monitor', returns immediately with task ID
- MonitorMcpTask (src/tasks/MonitorMcpTask/) — task lifecycle management
  and agent cleanup via killMonitorMcpTasksForAgent()
- MonitorPermissionRequest — permission dialog component

The codebase already had all integration points wired (tools.ts, tasks.ts,
PermissionRequest.tsx, LocalShellTask kind='monitor', BashTool prompt).
This PR provides the missing implementations.

* fix: command-specific permission rule + architecture docs

- MonitorPermissionRequest: "don't ask again" now creates a
  command-prefix rule (like BashTool) instead of a blanket
  tool-name-only rule that would auto-allow all Monitor commands
- MonitorMcpTask: clarify architecture comments explaining why
  monitor_mcp type exists as a registry stub while actual tasks
  are local_bash with kind='monitor'

* fix: address Copilot review feedback

- Fix permission rule field: expression → ruleContent (Copilot #1)
- Handle empty command prefix: skip rule creation (Copilot #2)
- Remove unused useTheme() import (Copilot #3)
- Save permission rules under 'Bash' toolName so bashToolHasPermission
  can match them — Monitor delegates to Bash permission system (Copilot #4)
- Remove unused logError import from MonitorMcpTask (Copilot #6)
- Copilot #5 (getAppState throws): same pattern as BashTool:915, not a bug
2026-04-13 21:39:07 +08:00
Nourrisse Florian
24d485f42f feat: activate local-only team memory in open build (#648)
* feat: activate local-only team memory in open build

Enable the TEAMMEM feature flag and the isTeamMemoryEnabled() gate so
team memory works in local-only mode for all open-build users.

Team memory is a shared memory system scoped per-project, stored at
~/.claude/projects/<project>/memory/team/. The implementation is
already almost entirely local — extraction, UI, prompts, file
detection, and path validation all work on local files.

The cloud sync overlay (OAuth + API) is cleanly separated: the
watcher does an early return when OAuth is unavailable, so the
feature degrades gracefully to local-only storage with no crashes.

What works locally:
- Memory extraction (auto + team, combined prompts)
- Team MEMORY.md loaded into conversation context
- File selector with team memory folder option
- Collapse tracking (read/search/write counts)
- Secret scanning before persistence
- Path validation + symlink protection

What requires OAuth (not available in open build):
- Cloud sync between team members
- Automatic push/pull via file watcher

* fix: preserve opt-out gate for team memory via feature flag

Change isTeamMemoryEnabled() to read tengu_herring_clock with default
true instead of unconditional return true. This enables team memory by
default while preserving user opt-out via ~/.claude/feature-flags.json.
2026-04-13 21:29:10 +08:00
Nourrisse Florian
99a17144ee feat: activate coordinator mode in open build (#647)
* feat: activate coordinator mode in open build

Enable the COORDINATOR_MODE feature flag and create the missing
src/coordinator/workerAgent.ts module that provides worker agent
definitions for the coordinator.

Coordinator mode is a multi-agent system where a coordinator agent
orchestrates independent workers via AgentTool, SendMessageTool,
and TaskStopTool. The implementation was already 99% complete
(19KB coordinatorMode.ts, 26 gate sites across 15 files) — only
the workerAgent module was missing from the source snapshot.

Workers get the standard built-in agents (general-purpose, explore,
plan). The coordinator system prompt (252 lines) handles all
orchestration logic.

Activate at runtime: CLAUDE_CODE_COORDINATOR_MODE=1
Optional scratchpad: set {"tengu_scratch": true} in
~/.claude/feature-flags.json (#639)

* fix: add worker agent type for coordinator mode

The coordinator system prompt instructs the model to spawn workers with
subagent_type: "worker", but no agent had agentType === 'worker'.
This caused AgentTool to throw "Agent type 'worker' not found" on
every coordinator spawn attempt.

Add a WORKER_AGENT definition that spreads GENERAL_PURPOSE_AGENT with
agentType: 'worker'. Also use the narrower BuiltInAgentDefinition type.

* feat: activate built-in explore and plan agents in open build

Enable BUILTIN_EXPLORE_PLAN_AGENTS so Explore (fast, haiku, read-only)
and Plan (architect, read-only) agents are available to all users in
both normal and coordinator modes.

This resolves the inconsistency flagged in code review: coordinator
workers had access to Explore/Plan agents while normal sessions did not.

The GrowthBook A/B test gate (tengu_amber_stoat) defaults to true via
the no-telemetry stub. Users can disable via feature-flags.json (#639).
2026-04-13 21:19:57 +08:00
muhnehh
df2b9f2b7b fix: improve fetch diagnostics for bootstrap and session requests (#646)
* fix: improve fetch diagnostics for bootstrap and session requests

* chore: derive session timeout from shared constant
2026-04-13 21:17:12 +08:00
Nourrisse Florian
adbe391e63 fix: replace broken bun:bundle shim with source pre-processing (#657)
* fix: replace broken bun:bundle shim with source pre-processing

The `onResolve`/`onLoad` plugin shim for `bun:bundle` was silently
ineffective in Bun v1.3.9+ — the `bun:` namespace is resolved by
Bun's native C++ resolver before the JS plugin phase runs. This meant
ALL `feature()` flags evaluated to `false` regardless of the
`featureFlags` map in build.ts (including `MONITOR_TOOL: true`).

Replace the shim with a source pre-processing step that:
1. Strips `import { feature } from 'bun:bundle'` from .ts/.tsx files
2. Replaces `feature('FLAG')` calls with boolean literals
3. Restores original files in a `finally` block after Bun.build()

Also extend the missing-module scanner to detect `require()` and
dynamic `import()` calls — not just static `import ... from` — since
modules behind feature() gates become resolvable when flags are enabled.

* fix: ensure source files are always restored after build

- Add SIGINT/SIGTERM handlers to restore pre-processed source files
  on abrupt termination (Ctrl+C, kill)
- Replace process.exit(1) with process.exitCode = 1 so the finally
  block runs on build failure
2026-04-13 21:07:08 +08:00
emsanakhchivan
03e0b06e07 fix: extend provider guard to protect anthropic profiles from cross-terminal override (#641)
The provider profile activation guard in applyActiveProviderProfileFromConfig()
only checked CLAUDE_CODE_USE_* environment flags, which are never set for the
default anthropic provider. This allowed two terminals sharing ~/.claude.json
to overwrite each other's active provider when one was using anthropic and
the other a third-party provider.

Now also checks the OCODE_PROVIDER_PROFILE_APPLIED flag, which is set by all
profiles including anthropic, preventing cross-terminal interference.

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-13 20:22:50 +08:00
Nourrisse Florian
31be66d764 feat: add allowBypassPermissionsMode setting (#658)
* feat: add allowBypassPermissionsMode setting

Allow bypass permissions mode to appear in the mode list via
settings.json without requiring the --allow-dangerously-skip-permissions
CLI flag. The disableBypassPermissionsMode setting retains priority.

* fix: address Copilot review feedback on allowBypassPermissionsMode

- Security: read allowBypassPermissionsMode only from trusted settings
  sources (user/local/flag/policy), excluding projectSettings to prevent
  a malicious repo from enabling bypass mode
- UX: update error messages to reference the correct CLI flag
  (--allow-dangerously-skip-permissions) and the new settings option
- Tests: add schema validation tests for the new field
2026-04-13 20:05:21 +08:00
Meetpatel006
7c8bdcc3e2 fix: route OpenAI Codex shortcuts to correct endpoint (#566)
* feat: enhance codex provider resolution with shortcut aliases and improved base URL handling

* fix: enhance codex alias resolution to include shell model

* feat: enhance Codex provider resolution to support new aliases and base URL handling

* fix: update base URL resolution logic for Codex models in GitHub mode

* fix: update provider transport logic to enforce Codex responses and adjust base URL handling

* fix: update provider request resolution to respect custom base URLs and adjust transport logic

* fix: restore OPENAI_MODEL environment variable handling in tests and provider config
2026-04-13 18:31:15 +08:00
Khaled Moayad
64298a663f feat: implement /loop command with fixed and dynamic scheduling (#621)
* feat: implement /loop command with fixed and dynamic scheduling modes

Enable cron tools and /loop skill without the AGENT_TRIGGERS build flag
by removing feature guards from tools.ts, REPL.tsx, and skill registration.
The isKairosCronEnabled() runtime gate now enables cron unconditionally for
open builds while preserving the GrowthBook kill switch for ant builds.

The /loop skill supports four modes: fixed-interval with prompt, fixed-interval
maintenance, dynamic-prompt (self-pacing), and dynamic maintenance (bare /loop).

* chore: remove unused DEFAULT_INTERVAL constant from loop skill

* revert: drop infra changes, scope PR to /loop skill rewrite only

The cron activation layer (AGENT_TRIGGERS guard removal, isKairosCronEnabled
hardcode) is covered by an in-flight stack (#633, #639). Scope this PR to
just the loop.ts rewrite and its tests so it can land cleanly on top.

* fix: restore infra changes needed for /loop in open build

Bun's constant folder evaluates feature('AGENT_TRIGGERS') at bundle time
through the bun:bundle shim — even when the flag is flipped to true in
build.ts, the folded value is cached from the previous build and stays false.
This means the feature-gated require() blocks for cron tools, useScheduledTasks,
and loop skill registration all compile to dead code regardless of the flag.

Fix by removing the AGENT_TRIGGERS guards from the specific paths /loop needs:
- tools.ts: cron tools always registered (isEnabled gates visibility)
- REPL.tsx: useScheduledTasks always mounted
- index.ts: registerLoopSkill via static import, called unconditionally
- prompt.ts: isKairosCronEnabled() bypasses feature flag for non-ant builds

* fix: replace backslash line continuations with explicit delimiters in loop prompts

The backslash-newline sequences inside template literals were acting as
line continuations, collapsing newlines and merging prompt content with
surrounding instruction text. Replace with --- BEGIN/END --- markers
for unambiguous delimiting.

Also add tests for trailing "every" clause parsing, human-readable unit
normalization, and the non-interval "check every PR" case.

* fix: remove remaining AGENT_TRIGGERS guards from print.ts and constants/tools.ts

Completes the cron guard removal started in the previous commit.
The cron scheduler in non-interactive (-p) mode was dead because
print.ts still gated cronSchedulerModule/cronGate requires behind
feature('AGENT_TRIGGERS'), which Bun constant-folds to false in open
builds. Similarly, cron tool names were absent from
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.

Remove all three guards so the scheduler initialises (gated at runtime
by isKairosCronEnabled) and cron tools are allowed for in-process
teammates in all builds.
2026-04-13 18:28:42 +08:00
Juan Camilo Auriti
30c866d31a fix(openai-shim): preserve tool result images and local token caps (#659)
Keep tool-result images as real image_url parts for OpenAI-compatible requests and use max_tokens for local providers like Ollama and LM Studio.
2026-04-13 18:20:05 +08:00
github-actions[bot]
f6a4455ecf chore(main): release 0.2.3 (#638)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 02:06:34 +08:00
Vasanth T
aeaa658f77 fix: prevent infinite auto-compact loop for unknown 3P models (#635) (#636)
- Raise context window fallback from 8k to 128k for unknown OpenAI-compat models.
  The 8k fallback caused effective context (8k minus output reservation) to go
  negative, making auto-compact fire on every single message.
- Add safety floor in getEffectiveContextWindowSize(): effective context is
  always at least reservedTokensForSummary + 13k buffer, ensuring the
  auto-compact threshold stays positive.
- Add missing MiniMax model entries (M2.5, M2.5-highspeed, M2.1, M2.1-highspeed)
  all at 204,800 context / 131,072 max output per MiniMax docs.
- Add tests for MiniMax variants, 128k fallback, and autoCompact floor.

Fixes #635

Co-authored-by: root <root@vm7508.lumadock.com>
2026-04-13 02:03:02 +08:00
github-actions[bot]
d2a057c6f1 chore(main): release 0.2.2 (#631)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 01:03:57 +08:00
Jeevan Mohan Pawar
08cc6f3287 fix(read/edit): make compact line prefix unambiguous for tab-indented files (#613) 2026-04-13 01:00:33 +08:00
Kevin Codex
84fcc7f7e0 ci: publish npm in release workflow (#630) 2026-04-13 01:00:07 +08:00
github-actions[bot]
ad11414def chore(main): release 0.2.1 (#629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 00:41:31 +08:00
Jeevan Mohan Pawar
9419e8a4a2 fix(provider): add recovery guidance for missing OpenAI API key (#616) 2026-04-13 00:37:04 +08:00
Kevin Codex
41a86d05fa ci: publish from release events (#628) 2026-04-13 00:33:43 +08:00
Kevin Codex
fa4b6a96c0 Fix/manual publish current release (#627)
* ci: keep manual publish path for current release

* ci: fix trusted publishing metadata
2026-04-13 00:23:00 +08:00
Kevin Codex
d03d77b110 ci: keep manual publish path for current release (#626) 2026-04-13 00:18:43 +08:00
Kevin Codex
15de1d6190 Fix/release please invalid input (#624)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics

* ci: allow manual publish of existing release tags
2026-04-12 23:59:19 +08:00
Kevin Codex
812facf024 Fix/release please invalid input (#622)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics
2026-04-12 23:33:22 +08:00
Kevin Codex
2e39d2607a Fix/release please invalid input (#620)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics
2026-04-12 23:24:39 +08:00
github-actions[bot]
a3633ac094 chore(main): release 0.2.0 (#617)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-12 22:43:06 +08:00
Kevin Codex
3cefe2297d ci: remove invalid release-please input (#618) 2026-04-12 22:40:38 +08:00
Kevin Codex
40ac164501 ci: add secure automated release workflow (#615)
* ci: add secure automated release workflow

* ci: fix release-please action pin
2026-04-12 21:57:00 +08:00
ZhaoXiaoLuo
b3f3dc4e66 Prefer AGENTS.md over CLAUDE.md for project instructions (#439)
* Prefer AGENTS.md over CLAUDE.md for project instructions

* fix: preserve CLAUDE.md fallback behavior

* fix: isolate onboarding tests and preserve legacy init

* fix: restore full fsOperations exports in test mock and align compact cwd

* Fix onboarding test isolation and init migration guidance

* Tighten init prompt coverage and onboarding copy

* Handle nested project instruction paths consistently

* Fix NEW_INIT feature gate for Bun build

---------

Co-authored-by: 赵小落 <zhaoxiaoluo@zhaoxiaoluodeMac-mini.local>
Co-authored-by: zhaomo01 <zhaomo01@baidu.com>
2026-04-12 21:31:33 +08:00
Nourrisse Florian
2e0e14d713 fix: add LiteLLM-style aliases for GitHub Copilot context windows (#606)
The OPENAI_CONTEXT_WINDOWS/OPENAI_MAX_OUTPUT_TOKENS tables only contained
the `github:copilot:<model>` namespaced form used when talking directly to
Copilot via /onboard-github. When OpenClaude is pointed at a LiteLLM proxy
(which routes Copilot using the standard `github_copilot/<model>` convention),
the lookup missed and fell back to the conservative 8k default — causing the
compaction loop to fire repeatedly on every tick and blocking requests
before they left the client with repeated "not in context window table"
warnings on stderr.

Mirror the 11 active Copilot models with LiteLLM-style keys in both tables.
No behavior change for users of /onboard-github since namespaced entries
remain untouched and `lookupByKey` picks exact matches first.
2026-04-12 21:10:17 +08:00
euxaristia
a02c44143b fix(web-search): close SSRF bypasses in custom provider hostname guard (#610)
The previous `isPrivateHostname` used a list of regexes against
`URL.hostname`. Several literal-address forms slipped past it:

- IPv4-mapped IPv6 `[::ffff:127.0.0.1]` (WHATWG URL normalizes to
  `[::ffff:7f00:1]`, which no regex matched) — lets callers reach
  loopback and other private v4 via an IPv6 literal.
- ULA `fc00::/7` (e.g. `[fc00::1]`) — not covered.
- Link-local `fe80::/10` (e.g. `[fe80::1]`) — not covered.
- IPv4 `169.254.0.0/16` (cloud metadata, including 169.254.169.254),
  `100.64.0.0/10` (CGNAT), and the full `0.0.0.0/8` — not covered.
- The IPv6 regex `/^\[::1?\]$/` also required brackets, but `URL.hostname`
  returns bracketed form anyway, so this part happened to work.

WHATWG `new URL(...)` already normalizes short-form / numeric / hex /
octal IPv4 to dotted-quad before we see it, so those cases were in fact
handled — the remaining gaps were IPv6 and a few missing v4 ranges.

Replace the regex list with:
- a dotted-quad IPv4 parser + int range check covering 0/8, 10/8,
  100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16;
- a small IPv6 parser (handles `::` compression and embedded v4 suffix)
  + a byte-range check covering `::`, `::1`, IPv4-mapped (recursing
  into the v4 classifier), IPv4-compatible, `fc00::/7`, `fe80::/10`,
  and `fec0::/10`.

Export `isPrivateHostname` and add unit tests covering every bypass
listed above plus public-address negatives.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:09:46 +08:00
euxaristia
7817fe88bd fix(web-search): stop leaking abort listeners in custom provider retry (#611)
`fetchWithRetry` created a fresh `AbortController` per attempt and did:

    signal?.addEventListener('abort', () => controller.abort(), { once: true })

The listener was never removed. Consequences:

- On retry, a second listener was attached to the caller's signal,
  each closing over a different controller.
- After a successful fetch, the listener remained on the caller's
  signal indefinitely, referencing a controller whose work was done.
  For a long-lived caller signal this is a slow leak.
- The `{ once: true }` only helps if the signal actually fires — on
  non-aborted signals the listener stays attached forever.

Replace the manual controller + timer + listener dance with
`AbortSignal.any([signal, AbortSignal.timeout(ms)])`, which the
codebase already uses elsewhere (see src/services/mcp/xaa.ts). This:

- has no user-code listener to leak,
- gives each attempt a fresh independent timeout,
- cleanly distinguishes caller-initiated abort from timeout via
  `signal.aborted` vs `timeoutSignal.aborted` before rewriting the
  error as "Custom search timed out after Ns".

Also resets `lastStatus` per attempt so a 5xx on attempt 0 can't leak
into attempt 1's retry decision, and collapses the two redundant
retry branches (`lastStatus >= 500` and `lastStatus === undefined`)
into one.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 19:37:08 +08:00
lunamonke
4c50977f3c Decouple and fix mistral (#595)
* decouple and fix mistral

* fix wrong variable for currentBaseUrl and buildAPIProviderProperties
2026-04-12 15:26:14 +08:00
euxaristia
b126e38b1a fix: display selected model in startup screen instead of hardcoded sonnet 4.6 (#587) 2026-04-11 21:20:00 +08:00
Alina Lisova
6e94dd9136 fix(ink): restore host prop updates in React 19 reconciler (#589)
React 19's react-reconciler@0.33 mutation path calls commitUpdate with
(instance, type, oldProps, newProps, fiber), but our Ink host config
still expected an updatePayload from prepareUpdate. That left mounted
ink-* nodes with stale onKeyDown, tabIndex, and textStyles, making menu
navigation and highlights appear stuck until remount.

Diff old/new props directly inside commitUpdate and add regression tests
covering in-place updates for ink-box handlers/attributes and ink-text
styles.
2026-04-11 21:19:39 +08:00
FluxLuFFy
91e4cfb15b fix: WebSearch providers + MCPTool bugs (#593)
* fix: WebSearch providers + MCPTool bugs

WebSearchTool:
- custom.ts: fix buildAuthHeadersForPreset WEB_AUTH_HEADER opt-out
- custom.ts: fix WEB_AUTH_SCHEME empty string handling
- custom.ts: fix walkJsonPath null safety for jsonPath parsing
- duckduckgo.ts: use SafeSearchType enum instead of raw 0
- mojeek.ts: always send Accept: application/json header
- README: fix timeout documentation (15s -> 120s to match code)
- custom.test.ts: add tests for auth header behavior

MCPTool:
- MCPTool.ts: fix outputSchema to accept ContentBlockParam[] (not just string)
- MCPTool.ts: fix isResultTruncated for array output (iterates text blocks)

* fix: address PR #593 review feedback

1. Export buildAuthHeadersForPreset and add direct tests for:
   - WEB_AUTH_HEADER="" explicit opt-out behavior
   - WEB_AUTH_SCHEME="" stripping scheme prefix
   - Preset defaults (authHeader + authScheme)
   - No WEB_KEY returns empty headers

2. Add duckduckgo.test.ts verifying SafeSearchType.STRICT === 0,
   confirming the enum change is semantically identical to the
   previous raw value.

Addresses review by @Vasanthdev2004 at
pullrequestreview-4093533095

---------

Co-authored-by: FluxLuFFy <flux@openclaude.dev>
Co-authored-by: Fix Bot <fix@openclaude.local>
2026-04-11 21:07:20 +08:00
Zartris
f4ac709fa6 fix: report cache reads in streaming and correct cost calculation (#577)
* fix: report cache reads in streaming and correct cost calculation

Fix two bugs in how the OpenAI-to-Anthropic shim handles cached tokens:

1. codexShim: streaming message_delta missing cache_read_input_tokens
   The codexStreamToAnthropic() function builds the final message_delta
   usage object inline (not through makeUsage()), and only included
   input_tokens and output_tokens. cache_read_input_tokens was always 0,
   so /cost never showed cache reads for Responses API models (GPT-5+).

   Also fix makeUsage() to read input_tokens_details.cached_tokens and
   prompt_tokens_details.cached_tokens for the non-streaming path.

2. Both shims: cost double-counting from convention mismatch
   OpenAI includes cached tokens in input_tokens/prompt_tokens (i.e.,
   input_tokens = uncached + cached). Anthropic treats input_tokens as
   uncached only. The cost formula was:
     cost = input_tokens * inputRate + cache_read * cacheRate
   This double-counts cached tokens. Fix by subtracting cached from
   input during the conversion:
     input_tokens = prompt_tokens - cached_tokens

   In practice this was inflating reported costs by ~2x for sessions
   with high cache hit rates (which is most sessions, since Copilot
   auto-caches server-side).

Fixes #515

* fix: omit zero cache read/write fields from /cost output

Only show "cache read" and "cache write" in /cost per-model usage when
the value is > 0. Providers like GitHub Copilot never report
cache_creation_input_tokens (the server manages its own cache), so
showing "0 cache write" on every line is misleading — it implies caching
is not working when it actually is.

Before:
  claude-haiku:  2.6k input, 151 output, 39.8k cache read, 0 cache write ($0.04)

After:
  claude-haiku:  2.6k input, 151 output, 39.8k cache read ($0.04)

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-10 23:40:42 +08:00
Zartris
8aaa4f22ac fix: add store:false to Chat Completions and /responses fallback (#578)
Set store: false in the request body for both the Chat Completions path
and the /responses fallback path in openaiShim.ts.

The codexShim (Responses API primary path) already sets store: false.
The Chat Completions path and the /responses fallback in openaiShim were
missing it.

store: false tells the API provider not to persist conversation data for
model training, logging, or other non-operational purposes. This is a
privacy measure — it does not affect caching or functionality.

Note: Whether third-party proxies (e.g. GitHub Copilot) honour this
parameter is provider-dependent, but setting it is a reasonable default
for user privacy.

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-10 23:40:09 +08:00
Zartris
a7f5982f64 fix: add GitHub Copilot model context windows and output limits (#576)
Add context_window and max_output_tokens entries for all models available
through the GitHub Copilot proxy (Claude, GPT, Gemini, Grok), sourced from
https://api.githubcopilot.com/models.

Models are namespaced as "github:copilot:<model>" to avoid collisions with
the same model names served by other providers (which may have different
limits). A new lookupByKey() helper and qualified-key lookup in
lookupByModel() ensures the correct limits are selected when
OPENAI_MODEL=github:copilot.

Without this, Claude models on Copilot would use default context/output
limits that may not match the proxy's actual constraints, causing 400 errors
like "max_tokens is too large".

Related: #515

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-10 22:00:26 +08:00
Juan Camilo Auriti
cb8f8b7ac2 fix: let saved provider profiles win on restart (#513)
Treat profile-managed env as restart state rather than explicit user intent so saved OpenAI-compatible profiles can replace stale Ollama values on startup and persist correctly across restarts.

Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
2026-04-10 21:58:33 +08:00
ibaaaaal
07621a6f8d fix: scrub canonical Anthropic headers from 3P shim requests (#499)
* Stop canonical Anthropic headers from leaking into 3P shim requests

The remaining blocker from PR #268 was that canonical Anthropic headers such as
`anthropic-version` and `anthropic-beta` could still ride through supported 3P
paths even after the earlier x-anthropic/x-claude scrubber work. This tightens
header filtering inside the shim itself so direct defaultHeaders, env-driven
client setup, providerOverride routing, and per-request header injection all
share the same scrubber.

Constraint: Preserve non-Anthropic custom headers and provider auth while stripping only Anthropic/OpenClaude-internal headers from 3P requests
Rejected: Rely on client.ts filtering alone | direct shim construction and per-request headers would still leave gaps
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep header scrubbing centralized in the shim so new call paths do not reopen 3P leakage bugs
Tested: bun test src/services/api/openaiShim.test.ts src/services/api/client.test.ts src/utils/context.test.ts
Tested: bun run test:provider
Tested: bun run build && node dist/cli.mjs --version
Not-tested: bun run typecheck (repository baseline currently fails in many unrelated files)

* Keep OpenAI client tests from restoring undefined env as strings

The new header-leak regression tests in client.test.ts restored environment
variables via direct assignment, which can leave literal "undefined" strings in
process.env when the original value was unset. This switches the teardown over
to the same restore helper pattern already used in openaiShim.test.ts.

Constraint: Keep the fix limited to test hygiene without altering runtime behavior
Rejected: Restore only the two env vars Copilot called out | using one helper for all test env restores is simpler and less error-prone
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Use restore helpers for env teardown in tests so unset values stay deleted instead of becoming the string "undefined"
Tested: bun test src/services/api/client.test.ts src/services/api/openaiShim.test.ts src/utils/context.test.ts
Not-tested: Full provider suite (unchanged runtime path)

* Prevent GitHub Codex requests from forwarding unsanitized Anthropic headers

A base-sync with upstream exposed a separate GitHub+Codex transport branch
that still merged per-request headers raw before adding Copilot headers.
This keeps the filter aligned across Codex-family paths and adds explicit
regression tests for GitHub Codex routing, including providerOverride.

Constraint: Must not push or modify GitHub state while validating the reviewer concern
Rejected: Leave the GitHub Codex path unchanged | runtime repro showed anthropic-* headers still leaked after the upstream sync
Confidence: high
Scope-risk: narrow
Directive: Keep header scrubbing consistent across every Codex-family transport branch when provider routing changes
Tested: bun test src/services/api/openaiShim.test.ts
Tested: bun test src/services/api/client.test.ts src/services/api/codexShim.test.ts src/services/api/providerConfig.github.test.ts
Tested: bun run build
Not-tested: Full repository test suite
2026-04-10 21:56:40 +08:00
Anandan
692471850f fix: update theme preview on focus change (#562)
Treat default select focus as initial state so /theme and first-run previews follow keyboard navigation again.

Co-authored-by: anandh8x <test@example.com>
2026-04-10 21:55:15 +08:00
Anandan
68c296833d fix: restore Ollama auto-detect in first-run setup (#561)
Co-authored-by: anandh8x <test@example.com>
2026-04-10 21:53:30 +08:00
Zartris
9ccaa7a675 feat: add /cache-probe diagnostic command (#580)
Add a /cache-probe slash command for debugging prompt caching behaviour
on OpenAI-compatible providers (GitHub Copilot, OpenAI direct).

The command sends two identical API requests in sequence and compares the
raw server response usage stats, showing:
- Input/output token counts
- Cache read tokens (from prompt_tokens_details or input_tokens_details)
- Latency for each request
- Cache hit rate percentage

Usage:
  /cache-probe                    # test default model
  /cache-probe claude-sonnet-4    # test specific model
  /cache-probe gpt-5.4 --no-key  # test without prompt_cache_key

The --no-key flag omits prompt_cache_key/prompt_cache_retention/store to
test whether the server does content-based auto-caching (it does on
GitHub Copilot).

This is a debugging/diagnostic tool, not intended for regular use. It was
instrumental in discovering that:
1. Copilot auto-caches server-side based on content hash
2. prompt_cache_key is ignored by the proxy
3. The streaming path was not reporting cached tokens

Only enabled when the provider is OpenAI or GitHub (not for firstParty
Anthropic which has different caching semantics).

Related: #515

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-10 21:34:38 +08:00
Kevin Codex
598651f423 fix: rebrand prompt identity to openclaude (#496)
* fix: rebrand prompt identity to openclaude

* fix prompt branding

* fix: align prompt branding with config compatibility
2026-04-10 01:20:05 +08:00
KRATOS
c385047abb feat: add auto-fix service — auto-lint and test after AI file edits (#508)
* feat: add AutoFix config schema and reader module

Implements AutoFixConfigSchema (Zod v4) with validation for lint/test
commands, maxRetries (0-10, default 3), and timeout (1000-300000ms,
default 30000). Adds getAutoFixConfig helper that returns null for
disabled or invalid configs. All 9 unit tests pass.

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

* feat: add autoFix runner with lint/test command execution

Implements AutoFixRunner (Task 2) - executes lint and test shell commands
sequentially, short-circuits on lint failure, handles timeouts, and
produces structured AutoFixResult with AI-friendly error summaries.

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

* feat: add autoFix field to SettingsSchema with integration tests

Integrates AutoFixConfigSchema into SettingsSchema so autoFix settings
are validated at the settings layer. Adds two integration tests verifying
that valid configs are accepted and invalid configs (enabled with no
commands) are rejected.

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

* feat: add autoFix hook integration helpers (Task 4)

Implements shouldRunAutoFix and buildAutoFixContext functions used by
the PostToolUse hook to determine when to run auto-fix and format
errors as AI-readable context for injection.

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

* feat: wire autoFix into PostToolUse hook flow (Task 5)

Add auto-fix lint/test check after existing PostToolUse hooks in
runPostToolUseHooks. When autoFix is configured in settings, runs
lint/test commands after file_edit/file_write tools and yields
errors as hook_additional_context for the model to act on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /auto-fix slash command

Adds the /auto-fix prompt command that helps users configure autoFix settings
(lint/test commands, maxRetries, timeout) in .claude/settings.json.

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

* fix: remove unused imports in autoFixRunner test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — enforce maxRetries, wire abort signal, use cross-platform shell

1. Enforce maxRetries: track auto-fix attempts per query chain in toolHooks.ts
   and stop feeding errors back after the configured limit is reached.

2. Wire abort signal to subprocess: subscribe to AbortController signal in
   runCommand() and kill the process tree on abort. Uses detached process
   groups on Unix to ensure child processes are also terminated.

3. Replace hardcoded bash with shell:true: use Node's cross-platform shell
   resolution instead of spawn('bash', ['-c', ...]) so auto-fix commands
   work on Windows and non-bash environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 21:18:57 +08:00
Kevin Codex
42b121bd0d Fix/openclaude diagnostics settings (#483)
* fix: use openclaude paths in diagnostics and settings

* fix: strip leaked reasoning from assistant output

* fix: preserve legacy claude config compatibility

* fix: tighten path and reasoning compatibility

* fix: buffer streamed reasoning leak preambles

* test: cover openclaude migration and reasoning fixes

* test: isolate execFileNoThrow from cross-file mocks
2026-04-09 20:42:51 +08:00
FluxLuFFy
32fbd0c7b4 fix: custom web search — WEB_URL_TEMPLATE not recognized, timeout too short, silent native fallback (#537)
* fix: custom web search — WEB_URL_TEMPLATE not recognized, timeout too short, silent native fallback

1. custom.ts: Add WEB_URL_TEMPLATE to isConfigured() so the custom provider
   is recognized when configured via URL template alone.

2. custom.ts: Bump DEFAULT_TIMEOUT_SECONDS from 15s to 120s.
   Self-hosted search APIs (SearXNG, internal) commonly need 30-90s.

3. WebSearchTool.ts: When an explicit adapter is selected via
   WEB_SEARCH_PROVIDER=custom, do not silently fall through to the
   native Anthropic path on adapter errors or 0-hit results.
   - 0 hits: return directly (no fallback)
   - Error: throw the real error (no fallback)
   - Auto mode: existing fallback behavior preserved

* fix: tighten auto-mode adapter fallback — only swallow transient errors

Address review feedback: in auto mode, only fall through to native on
transient errors (network failure, timeout, HTTP 5xx). Config and
guardrail errors (SSRF, HTTPS, bad URL, header allowlist, etc.) now
surface properly instead of being silently swallowed.

---------

Co-authored-by: FluxLuFFy <fluxluffy@users.noreply.github.com>
2026-04-09 20:41:58 +08:00
sooth
e30ad17ae0 fix(tui): restore prompt rendering on startup (#498)
* fix(tui): restore prompt rendering on startup

* test(tui): document render-time command split

* fix(tui): reduce ghostty prompt repaint scope
2026-04-09 20:40:06 +08:00
Kevin Codex
c328fdf9e2 feat: add wiki mvp commands (#532) 2026-04-09 14:54:38 +08:00
FluxLuFFy
4ad6bc50c1 refactor: provider adapter system + 7 new search providers (bug-fixed) (#512)
* refactor: provider adapter system + 7 new search providers

Architecture:
- Each search backend is a small adapter implementing SearchProvider
- 12 providers: custom, tavily, exa, you, jina, bing, mojeek, linkup, firecrawl, duckduckgo + native
- WEB_SEARCH_PROVIDER controls selection: auto (fallback chain) or specific provider
- Auth always in headers, never in query strings

Bug fixes from review feedback:
- Fix applyDomainFilters catch block: keep hits with malformed URLs on blocked_domains
  (can't confirm blocked), drop on allowed_domains (can't confirm allowed)
- Add safeHostname() helper: safely extract hostname from URLs without throwing
- Replace unsafe new URL(r.url).hostname in 7 providers with safeHostname()
- Remove dead code: buildAllHeaders, buildAuthHeaders, parseExtraHeaders from types.ts
- Fix WEB_PARMS typo: consistently use WEB_QUERY_PARAM everywhere
- AbortSignal forwarded to fetch() in all 12 providers
- DuckDuckGo: wrap dynamic import in try/catch for graceful error
- Exa: remove double domain filtering (server-side already)
- runSearch(): aggregate all provider errors instead of throwing only the last one
- Retry logic: check numeric status code directly, retry 5xx/network, skip 4xx

Test coverage (44 tests, all passing):
- types.test.ts: safeHostname, normalizeHit, applyDomainFilters (20 tests)
- index.test.ts: getProviderMode, getProviderChain, getAvailableProviders (13 tests)
- custom.test.ts: extractHits flexible response parsing (11 tests)

Co-authored-by: FluxLuFFy <195792511+FluxLuFFy@users.noreply.github.com>

* security: add guardrails to custom search provider (Option B)

- HTTPS-only by default (opt-out: WEB_CUSTOM_ALLOW_HTTP=true)
- Private/localhost IPs blocked by default (opt-out: WEB_CUSTOM_ALLOW_PRIVATE=true)
- Header allowlist: only known-safe headers allowed unless WEB_CUSTOM_ALLOW_ARBITRARY_HEADERS=true
- Configurable timeout in seconds (WEB_CUSTOM_TIMEOUT_SEC, default 15)
- Configurable POST body limit (WEB_CUSTOM_MAX_BODY_KB, default 300)
- Removed max URL size restriction
- Audit log warning on first custom search call
- Updated .env.example and README_SEARCH_PROVIDERS.md with all new options

* fix: remove custom provider from auto chain (Option 1)

Remove customProvider from the auto fallback chain so it is only
available when WEB_SEARCH_PROVIDER=custom is explicitly selected.

Changes:
- Remove customProvider from ALL_PROVIDERS array in providers/index.ts
- Add 3 new tests verifying custom is excluded from auto chain
- Update README_SEARCH_PROVIDERS.md: auto priority, mode table, note
- Update .env.example: auto priority comment, custom mode annotation

All 47 tests pass (44 existing + 3 new).

Co-Authored-By: @Vasanthdev2004

* fix: address review blockers (routing, abort, config check, domain matching)

1. Native/Codex routing precedence in auto mode
   shouldUseAdapterProvider() now checks if native/first-party/vertex/foundry
   or Codex paths are available before falling back to adapter providers.
   Auto mode: native paths take precedence; adapter is fallback only.

2. AbortError stops provider chain immediately
   runSearch() now checks for AbortError/aborted signal before continuing
   the fallback chain. Cancelled searches don't create extra outbound requests.

3. Explicit provider mode fails fast on missing credentials
   runSearch() validates isConfigured() for explicit modes before attempting
   requests. Throws clear error: 'Search provider "X" is not configured.'

4. Domain filter exact-or-subdomain matching (fixes suffix collision)
   New hostMatchesDomain() helper: exact match or .subdomain match.
   badexample.com no longer matches example.com.

5. Tests: 56 pass (9 new) covering all 4 fixes

Co-Authored-By: @Vasanthdev2004

---------

Co-authored-by: Claude Fix <fix@openclaude.local>
Co-authored-by: FluxLuFFy <195792511+FluxLuFFy@users.noreply.github.com>
Co-authored-by: bot <bot@openclaw.ai>
2026-04-09 02:51:25 +08:00
José Zechel
284d9bda36 Error: Fix of an image in the conversation exceeds the dimension limit for many-image requests (2000px) (#520)
Root cause: IMAGE_MAX_WIDTH and IMAGE_MAX_HEIGHT were set to 2000 — exactly the APIs many-image dimension limit. Images resized to exactly 2000px would get rejected when the conversation accumulated enough images to trigger the API's many-image mode.

      Fix: Changed both constants from 2000 to 1568 in src/constants/apiLimits.ts:42-43. This is the resolution the API internally downscales to anyway (documented in the API's encoding/full_encoding.py), so there is zero effective quality loss. All images are now safely below the many-image threshold.

      export const IMAGE_MAX_WIDTH = 1568
      export const IMAGE_MAX_HEIGHT = 1568

      Impact: The single constant change propagates everywhere — imageResizer.ts uses IMAGE_MAX_WIDTH/IMAGE_MAX_HEIGHT for all resize decisions, and the error messages reference these constants dynamically. No other files need changes.
2026-04-08 22:12:57 +08:00
Vasanth T
537c469c3a fix: replace isDeepStrictEqual with navigation-aware options comparison (#507)
The select cursor highlight was broken because isDeepStrictEqual in
use-select-navigation.ts and use-multi-select-state.ts would fail when
options contained identity-unstable properties (JSX label elements,
function onChange callbacks, computed disabled booleans). This caused
the reset logic to fire on every re-render, resetting focusedValue
back to the first option.

Replace isDeepStrictEqual with optionsNavigateEqual which only compares
properties that affect navigation behavior: value, disabled, and type.
ReactNode labels and function callbacks are intentionally excluded as
they are identity-unstable but don't change navigation semantics.

Fixes #472

Co-authored-by: OpenClaude Worker 3 <worker-3@openclaude.local>
2026-04-08 16:44:42 +08:00
Juan Camilo Auriti
ccaa193eec fix: preserve only originally-required properties in strict tool schemas (#471)
Fixes #430. In normalizeSchemaForOpenAI(), the strict branch was adding every
property key to required[], including optional ones. This caused providers like
Groq, Azure OpenAI, and others to reject valid tool calls with a 400 /
tool_use_failed error because the model correctly omits optional arguments but
the provider sees them as missing required fields.

Root cause: the strict branch used `[...existingRequired, ...allKeys]` instead
of `existingRequired.filter(k => k in normalizedProps)`. The Gemini branch
already had the correct logic.

Fix: align the strict branch with the Gemini branch — only keep properties that
were already marked required in the original schema. The additionalProperties:
false constraint is preserved as strict-mode providers still require it.

Add regression test covering the Read tool schema (file_path required,
offset/limit/pages optional).
2026-04-08 16:42:11 +08:00
Vasanth T
2caf2fd982 fix: defer startup checks and suppress recommendation dialogs during startup window (issue #363) (#504)
* fix: defer startup plugin checks and suppress recommendation dialogs during startup window (issue #363)

Root cause: performStartupChecks() fires immediately on REPL mount,
triggering plugin loading which populates trackedFiles, which triggers
useLspPluginRecommendation to surface an LSP recommendation dialog.
Since promptTypingSuppressionActive is false before any user input,
getFocusedInputDialog() returns the dialog, unmounting PromptInput
entirely and making the CLI appear frozen.

Fix: Two-pronged approach:
1. Defer performStartupChecks by 1500ms and gate on
   promptTypingSuppressionActive so startup checks dont run while
   the user is typing or has early input buffered
2. Suppress lower-priority startup dialogs (LSP recommendation,
   plugin hint, desktop upsell) until startupChecksStartedRef is
   true, preventing them from stealing focus during the vulnerable
   startup window

This also explains why --bare mode and disabling plugins work:
--bare mode skips plugin loading entirely, and disabling the
autoresearch plugin eliminates the LSP match, so lspRecommendation
stays null and PromptInput renders normally.

* fix: move startup checks effect after promptTypingSuppressionActive declaration

Fixes temporal dead zone warning flagged by code-quality bot.
promptTypingSuppressionActive is declared on line ~1340 but the
useEffect was on line ~800, causing a reference-before-declaration.
Also adds missing semicolons for style consistency.

* fix: gate startup checks on prompt readiness, not just a timeout (issue #363)

The previous approach used a fixed 1500ms timeout, but as gnanam1990
pointed out, if a user pauses for >1.5s before typing the timer can
still fire and recommendation dialogs can steal focus. This is a
timing mitigation, not a reliable fix.

New approach: gate startup checks on actual prompt readiness:
1. After first message submission (submitCount > 0) — always safe
2. After grace period (3s) elapsed AND user is idle — safe because
   no dialog will interrupt an idle user who hasn't started typing
3. While user is actively typing — deferrred until they stop

This ensures startup checks never steal focus from a prompt the user
is about to type into, regardless of how long they pause before typing.

Also removes the old STARTUP_CHECK_DELAY_MS constant in favor of
STARTUP_GRACE_PERIOD_MS with clearer semantics.

* fix: move startup checks after submitCount declaration to avoid temporal dead zone

Code quality bot flagged that submitCount was used before its declaration.
Moved the entire startup checks block to after the submitCount useState
declaration. Also added nullish coalescing (submitCount ?? 0) per bot
suggestion.

* fix: gate startup checks strictly on first submission, remove grace period (issue #363)

As gnanam1990 pointed out, the 3s grace period still allows the failure
mode: if a user pauses for a few seconds before typing, startup checks
fire and recommendation dialogs steal focus. A grace period is still a
timing mitigation, not a reliable fix.

New approach: startup checks only run after the user has submitted their
first message (submitCount > 0). No grace period, no timeout. This
guarantees the prompt gets first interaction — no dialog can steal focus
before the user has actually used the CLI.

If the user never submits a message, startup checks never run. That's
acceptable because with no user interaction there's no need for plugin
installations or marketplace seeding.

---------

Co-authored-by: OpenClaude Worker 3 <worker-3@openclaude.local>
2026-04-08 16:08:36 +08:00
Meetpatel006
ad724dc3a4 Improve GitHub Copilot provider: official OAuth onboarding, Copilot API routing, and test hardening and auto refresh token logic (#288)
* update gitHub copilot API with offical client id and update model configurations

* test: add unit tests for exchangeForCopilotToken and enhance GitHub model normalization

* remove PAT token feature

* test(api): harden provider tests against env leakage

* Added back trimmed github auth token

* added auto refresh logic for auto token along with test

* fix: remove forked provider validation in cli.tsx and clear stale provider env vars in /onboard-github

* refactor: streamline environment variable handling in mergeUserSettingsEnv

* fix: clear stale provider env vars to ensure correct GH routing

* Remove internal-only tooling from the external build (#352)

* Remove internal-only tooling without changing external runtime contracts

This trims the lowest-risk internal-only surfaces first: deleted internal
modules are replaced by build-time no-op stubs, the bundled stuck skill is
removed, and the insights S3 upload path now stays local-only. The privacy
verifier is expanded and the remaining bundled internal Slack/Artifactory
strings are neutralized without broad repo-wide renames.

Constraint: Keep the first PR deletion-heavy and avoid mass rewrites of USER_TYPE, tengu, or claude_code identifiers
Rejected: One-shot DMCA cleanup branch | too much semantic risk for a first PR
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat full-repo typecheck as a baseline issue on this upstream snapshot; do not claim this commit introduced the existing non-Phase-A errors without isolating them first
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Not-tested: Full repo typecheck (currently fails on widespread pre-existing upstream errors outside this change set)

* Keep minimal source shims so CI can import Phase A cleanup paths

The first PR removed internal-only source files entirely, but CI provider
and context tests import those modules directly from source rather than
through the build-time no-telemetry stubs. This restores tiny no-op source
shims so tests and local source imports resolve while preserving the same
external runtime behavior.

Constraint: GitHub Actions runs source-level tests in addition to bundled build/privacy checks
Rejected: Revert the entire deletion pass | unnecessary once the import contract is satisfied by small shims
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: For later cleanup phases, treat build-time stubs and source-test imports as separate compatibility surfaces
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (still noisy on this upstream snapshot)

---------

Co-authored-by: anandh8x <test@example.com>

* Reduce internal-only labeling noise in source comments (#355)

This pass rewrites comment-only ANT-ONLY markers to neutral internal-only
language across the source tree without changing runtime strings, flags,
commands, or protocol identifiers. The goal is to lower obvious internal
prose leakage while keeping the diff mechanically safe and easy to review.

Constraint: Phase B is limited to comments/prose only; runtime strings and user-facing labels remain deferred
Rejected: Broad search-and-replace across strings and command descriptions | too risky for a prose-only pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Remaining ANT-ONLY hits are mostly runtime/user-facing strings and should be handled separately from comment cleanup
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

Co-authored-by: anandh8x <test@example.com>

* Neutralize internal Anthropic prose in explanatory comments (#357)

This is a small prose-only follow-up that rewrites clearly internal or
explanatory Anthropic comment language to neutral wording in a handful of
high-confidence files. It avoids runtime strings, flags, command labels,
protocol identifiers, and provider-facing references.

Constraint: Keep this pass narrowly scoped to comments/documentation only
Rejected: Broader Anthropic comment sweep across functional API/protocol references | too ambiguous for a safe prose-only PR
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Leave functional Anthropic references (API behavior, SDKs, URLs, provider labels, protocol docs) for separate reviewed passes
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

Co-authored-by: anandh8x <test@example.com>

* Neutralize remaining internal-only diagnostic labels (#359)

This pass rewrites a small set of ant-only diagnostic and UI labels to
neutral internal wording while leaving command definitions, flags, and
runtime logic untouched. It focuses on internal debug output, dead UI
branches, and noninteractive headings rather than broader product text.

Constraint: Label cleanup only; do not change command semantics or ant-only logic gates
Rejected: Renaming ant-only command descriptions in main.tsx | broader UX surface better handled in a separate reviewed pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Remaining ANT-ONLY hits are mostly command descriptions and intentionally deferred user-facing strings
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

Co-authored-by: anandh8x <test@example.com>

* Finish eliminating remaining ANT-ONLY source labels (#360)

This extends the label-only cleanup to the remaining internal-only command,
debug, and heading strings so the source tree no longer contains ANT-ONLY
markers. The pass still avoids logic changes and only renames labels shown
in internal or gated surfaces.

Constraint: Update the existing label-cleanup PR without widening scope into behavior changes
Rejected: Leave the last ANT-ONLY strings for a later pass | low-cost cleanup while the branch is already focused on labels
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: The next phase should move off label cleanup and onto a separately scoped logic or rebrand slice
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

Co-authored-by: anandh8x <test@example.com>

* Stub internal-only recording and model capability helpers (#377)

This follow-up Phase C-lite slice replaces purely internal helper modules
with stable external no-op surfaces and collapses internal elevated error
logging to a no-op. The change removes additional USER_TYPE-gated helper
behavior without touching product-facing runtime flows.

Constraint: Keep this PR limited to isolated helper modules that are already external no-ops in practice
Rejected: Pulling in broader speculation or logging sink changes | less isolated and easier to debate during review
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Continue Phase C with similarly isolated helpers before moving into mixed behavior files
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

Co-authored-by: anandh8x <test@example.com>

* Remove internal-only bundled skills and mock helpers (#376)

* Remove internal-only bundled skills and mock rate-limit behavior

This takes the next planned Phase C-lite slice by deleting bundled skills
that only ever registered for internal users and replacing the internal
mock rate-limit helper with a stable no-op external stub. The external
build keeps the same behavior while removing a concentrated block of
USER_TYPE-gated dead code.

Constraint: Limit this PR to isolated internal-only helpers and avoid bridge, oauth, or rebrand behavior
Rejected: Broad USER_TYPE cleanup across mixed runtime surfaces | too risky for the next medium-sized PR
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: The next cleanup pass should continue with similarly isolated USER_TYPE helpers before touching main.tsx or protocol-heavy code
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy)

* Align internal-only helper removal with remaining user guidance

This follow-up fixes the mock billing stub to be a true no-op and removes
stale user-facing references to /verify and /skillify from the same PR.
It also leaves a clearer paper trail for review: the deleted verify skill
was explicitly ant-gated before removal, and the remaining mock helper
callers still resolve to safe no-op returns in the external build.

Constraint: Keep the PR focused on consistency fixes and reviewer-requested evidence, not new cleanup scope
Rejected: Leave stale guidance for a later PR | would make this branch internally inconsistent after skill removal
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When deleting gated features, always sweep user guidance and coordinator prompts in the same pass
Tested: bun run build
Tested: bun run smoke
Tested: bun run verify:privacy
Tested: bun run test:provider
Tested: bun run test:provider-recommendation
Not-tested: Full repo typecheck (upstream baseline remains noisy; changed-file scan still shows only pre-existing tipRegistry errors outside edited lines)

* Clarify generic workflow wording after skill removal

This removes the last generic verification-skill wording that could still
be read as pointing at a deleted bundled command. The guidance now talks
about project workflows rather than a specific bundled verify skill.

Constraint: Keep the follow-up limited to reviewer-facing wording cleanup on the same PR
Rejected: Leave generic wording as-is | still too easy to misread after the explicit /verify references were removed
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When removing bundled commands, scrub both explicit and generic references in the same branch
Tested: bun run build
Tested: bun run smoke
Not-tested: Additional checks unchanged by wording-only follow-up

---------

Co-authored-by: anandh8x <test@example.com>

* test(api): add GEMINI_AUTH_MODE to environment setup in tests

* test: isolate GitHub/Gemini credential tests with fresh module imports and explicit non-bare env setup to prevent cross-test mock/cache leaks

* fix: update GitHub Copilot base URL and model defaults for improved compatibility

* fix: enhance error handling in OpenAI API response processing

* fix: improve error handling for GitHub Copilot API responses and streamline error body consumption

* fix: enhance response handling in OpenAI API shim for better error reporting and support for streaming responses

* feat: enhance GitHub device flow with fresh module import and token validation improvements

* fix: separate Copilot API routing from GitHub Models, clear stale env vars, honor providerOverride.apiKey

* fix: route GitHub GPT-5/Codex to Copilot API, show all Copilot models in picker, clear stale env vars

* fix GitHub Models API regression

* feat: update GitHub authentication to require OAuth tokens, normalize model handling for Copilot and GitHub Models

* fix: update GitHub token validation to support OAuth tokens and improve endpoint type handling

---------

Co-authored-by: Anandan <anandan.8x@gmail.com>
Co-authored-by: anandh8x <test@example.com>
2026-04-08 16:03:31 +08:00
Urvish Lanje
648ae8053b ci: run python provider tests in pr-checks (#477)
* Add WakaTime extension to devcontainer configuration

* ci: run python provider tests in pr-checks

* Delete .devcontainer directory

* ci: added requirements.txt for pip caching

* ci: addressed security and mainenance issues

* ci: updated release tag

* Update .github/workflows/pr-checks.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* ci: added full commit SHA for python setup

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-08 15:18:04 +08:00
lunamonke
3188f6ac66 fix example agents (#438) 2026-04-08 02:55:27 +08:00
Kevin Codex
69ea1f1e4a fix: restore default context window for unknown 3p models (#494)
* fix: restore default context window for unknown 3p models

* fix: add MiniMax context metadata
2026-04-08 02:45:49 +08:00
KRATOS
f9ce81bfb3 fix: handle missing skill parameter in SkillTool (#485)
* fix: handle missing skill parameter in SkillTool

* fix: preserve SkillTool schema contract

* fix: align SkillTool schema error output
2026-04-08 00:33:52 +08:00
Juan Camilo Auriti
4975cfc2e0 fix: strip Anthropic params from 3P resume paths (#479)
* fix: strip Anthropic-specific params from 3P provider paths

Three silent failure modes affecting all third-party provider users:

1. Thinking blocks serialized as <thinking> text corrupt multi-turn
   context — strip them instead of converting to raw text tags.

2. Unknown models fall through to 200k context window default, so
   auto-compact never triggers — use conservative 8k for unknown
   3P models with a warning log.

3. Session resume with thinking blocks causes 400 or context corruption
   on 3P providers — strip thinking/redacted_thinking content blocks
   from deserialized messages when resuming against a non-Anthropic
   provider.

Addresses findings 2, 3, and 5 from #248.

* test: align resume stripping expectation with orphan-thinking filter

* test: isolate provider env in conversation recovery tests

* test: move provider-sensitive resume coverage behind module mocks

* test: trim extra blank lines in conversation recovery test

Keep the focused provider-resume test diff clean so the regression branch stays easy to review.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

---------

Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
2026-04-07 23:24:10 +08:00
492 changed files with 49080 additions and 3596 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
coverage
reports
vscode-extension
python
docs
*.md
!README.md
.github
.tsbuildinfo

View File

@@ -145,9 +145,44 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# CLAUDE_CODE_USE_OPENAI=1
# OPENAI_API_KEY=sk-your-key-here
# OPENAI_MODEL=gpt-4o
# For DeepSeek, set:
# OPENAI_BASE_URL=https://api.deepseek.com/v1
# OPENAI_MODEL=deepseek-v4-flash
# Optional: OPENAI_MODEL=deepseek-v4-pro
# Legacy aliases also work: deepseek-chat and deepseek-reasoner
# For Z.AI GLM Coding Plan, set:
# OPENAI_BASE_URL=https://api.z.ai/api/coding/paas/v4
# OPENAI_MODEL=GLM-5.1
# Optional: OPENAI_MODEL=GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
# OPENAI_BASE_URL=https://api.openai.com/v1
# Choose the OpenAI-compatible API surface (optional — defaults to chat_completions)
# Supported: chat_completions, responses
# OPENAI_API_FORMAT=chat_completions
# Choose a custom auth header for OpenAI-compatible providers (optional).
# Authorization defaults to Bearer; custom headers default to the raw API key.
# Set OPENAI_AUTH_HEADER_VALUE when the header value differs from OPENAI_API_KEY.
# OPENAI_AUTH_HEADER=api-key
# OPENAI_AUTH_SCHEME=raw
# OPENAI_AUTH_HEADER_VALUE=your-header-value-here
# Fallback context window size (tokens) when the model is not found in the
# built-in table (default: 128000). Increase this for models with larger
# context windows (e.g. 200000 for Claude-sized contexts).
# CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW=128000
# Per-model context window overrides as a JSON object.
# Takes precedence over the built-in table, so you can register new or
# custom models without patching source.
# Example: CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS={"my-corp/llm-v3":262144,"gpt-4o-mini":128000}
# CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS=
# Per-model maximum output token overrides as a JSON object.
# Use this alongside CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS when your model
# supports a different output limit than what the built-in table specifies.
# Example: CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS={"my-corp/llm-v3":8192}
# CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS=
# -----------------------------------------------------------------------------
@@ -225,6 +260,30 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# GOOGLE_CLOUD_PROJECT=your-gcp-project-id
# -----------------------------------------------------------------------------
# Option 9: NVIDIA NIM
# -----------------------------------------------------------------------------
# NVIDIA NIM provides hosted inference endpoints for NVIDIA models.
# Get your API key from https://build.nvidia.com/
#
# CLAUDE_CODE_USE_OPENAI=1
# NVIDIA_API_KEY=nvapi-your-key-here
# OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
# OPENAI_MODEL=nvidia/llama-3.1-nemotron-70b-instruct
# -----------------------------------------------------------------------------
# Option 10: MiniMax
# -----------------------------------------------------------------------------
# MiniMax API provides text generation models.
# Get your API key from https://platform.minimax.io/
#
# CLAUDE_CODE_USE_OPENAI=1
# MINIMAX_API_KEY=your-minimax-key-here
# OPENAI_BASE_URL=https://api.minimax.io/v1
# OPENAI_MODEL=MiniMax-M2.5
# =============================================================================
# OPTIONAL TUNING
# =============================================================================
@@ -243,8 +302,122 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Disable "Co-authored-by" line in git commits made by OpenClaude
# OPENCLAUDE_DISABLE_CO_AUTHORED_BY=1
# Disable strict tool schema normalization for non-Gemini providers
# Useful when MCP tools with complex optional params (e.g. list[dict])
# trigger "Extra required key ... supplied" errors from OpenAI-compatible endpoints
# OPENCLAUDE_DISABLE_STRICT_TOOLS=1
# Disable hidden <system-reminder> messages injected into tool output
# Suppresses the file-read cyber-risk reminder and the todo/task tool nudges
# Useful for users who want full transparency over what the model sees
# OPENCLAUDE_DISABLE_TOOL_REMINDERS=1
# Log structured per-request token usage (including cache metrics) to stderr.
# Useful for auditing cache hit rate / debugging cost spikes outside the REPL.
# Any truthy value enables it ("verbose", "1", "true").
#
# Complements (does NOT replace) CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT —
# they serve different audiences:
# - OPENCLAUDE_LOG_TOKEN_USAGE is user-facing: one JSON line per API
# request on stderr, intended for humans inspecting cost/caching.
# - CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT is model-facing: injects
# a context-usage attachment INTO the prompt so the model can reason
# about its own remaining context. Does not touch stderr.
# Turn on whichever audience you're debugging; both can run together.
# OPENCLAUDE_LOG_TOKEN_USAGE=verbose
# Custom timeout for API requests in milliseconds (default: varies)
# API_TIMEOUT_MS=60000
# Enable debug logging
# CLAUDE_DEBUG=1
# =============================================================================
# WEB SEARCH (OPTIONAL)
# =============================================================================
# OpenClaude includes a web search tool. By default it uses DuckDuckGo (free)
# or the provider's native search (Anthropic firstParty / vertex).
#
# Set one API key below to enable a provider. That's it.
# ── Provider API keys — set ONE of these ────────────────────────────
# Tavily (AI-optimized search, recommended)
# TAVILY_API_KEY=tvly-your-key-here
# Exa (neural/semantic search)
# EXA_API_KEY=your-exa-key-here
# You.com (RAG-ready snippets)
# YOU_API_KEY=your-you-key-here
# Jina (s.jina.ai endpoint)
# JINA_API_KEY=your-jina-key-here
# Bing Web Search
# BING_API_KEY=your-bing-key-here
# Mojeek (privacy-focused)
# MOJEEK_API_KEY=your-mojeek-key-here
# Linkup
# LINKUP_API_KEY=your-linkup-key-here
# Firecrawl (premium, uses @mendable/firecrawl-js)
# FIRECRAWL_API_KEY=fc-your-key-here
# ── Provider selection mode ─────────────────────────────────────────
#
# WEB_SEARCH_PROVIDER controls fallback behavior:
#
# "auto" (default) — try all configured providers, fall through on failure
# "custom" — custom API only, throw on failure (NOT in auto chain)
# "firecrawl" — firecrawl only
# "tavily" — tavily only
# "exa" — exa only
# "you" — you.com only
# "jina" — jina only
# "bing" — bing only
# "mojeek" — mojeek only
# "linkup" — linkup only
# "ddg" — duckduckgo only
# "native" — anthropic native / codex only
#
# Auto mode priority: firecrawl → tavily → exa → you → jina → bing → mojeek →
# linkup → ddg
# Note: "custom" is NOT in the auto chain. To use the custom API provider,
# you must explicitly set WEB_SEARCH_PROVIDER=custom.
#
# WEB_SEARCH_PROVIDER=auto
# ── Built-in custom API presets ─────────────────────────────────────
#
# Use with WEB_KEY for the API key:
# WEB_PROVIDER=searxng|google|brave|serpapi
# WEB_KEY=your-api-key-here
# ── Custom API endpoint (advanced) ──────────────────────────────────
#
# WEB_SEARCH_API — base URL of your search endpoint
# WEB_QUERY_PARAM — query parameter name (default: "q")
# WEB_METHOD — GET or POST (default: GET)
# WEB_PARAMS — extra static query params as JSON: {"lang":"en","count":"10"}
# WEB_URL_TEMPLATE — URL template with {query} for path embedding
# WEB_BODY_TEMPLATE — custom POST body with {query} placeholder
# WEB_AUTH_HEADER — header name for API key (default: "Authorization")
# WEB_AUTH_SCHEME — prefix before key (default: "Bearer")
# WEB_HEADERS — extra headers as "Name: value; Name2: value2"
# WEB_JSON_PATH — dot-path to results array in response
# ── Custom API security guardrails ──────────────────────────────────
#
# The custom provider enforces security guardrails by default.
# Override these only if you understand the risks.
#
# WEB_CUSTOM_TIMEOUT_SEC=15 — request timeout in seconds (default 15)
# WEB_CUSTOM_MAX_BODY_KB=300 — max POST body size in KB (default 300)
# WEB_CUSTOM_ALLOW_ARBITRARY_HEADERS=false — set "true" to use non-standard headers
# WEB_CUSTOM_ALLOW_HTTP=false — set "true" to allow http:// URLs
# WEB_CUSTOM_ALLOW_PRIVATE=false — set "true" to target localhost/private IPs
# (needed for self-hosted SearXNG)

View File

@@ -29,6 +29,13 @@ jobs:
with:
bun-version: 1.3.11
- name: Set up Python
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: python/requirements.txt
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -38,6 +45,12 @@ jobs:
- name: Full unit test suite
run: bun test --max-concurrency=1
- name: Install Python test dependencies
run: python -m pip install -r python/requirements.txt
- name: Python unit tests
run: python -m pytest -q python/tests
- name: Suspicious PR intent scan
run: bun run security:pr-scan -- --base ${{ github.event.pull_request.base.sha || 'origin/main' }}
- name: Provider tests

144
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,144 @@
name: Auto Release
on:
push:
branches:
- main
concurrency:
group: auto-release-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
if: ${{ github.repository == 'Gitlawb/openclaude' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: node
publish-npm:
name: Publish to npm
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ needs.release-please.outputs.tag_name }}
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run unit tests
run: bun test --max-concurrency=1
- name: Smoke test
run: bun run smoke
- name: Build
run: bun run build
- name: Dry-run package
run: npm pack --dry-run
- name: Clear token auth for trusted publishing
run: |
unset NODE_AUTH_TOKEN
echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV"
- name: Publish to npm
run: npm publish --access public --provenance
- name: Release summary
run: |
{
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo
echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude"
echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY"
docker:
name: Build & Push Docker Image
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
load: true
tags: openclaude:smoke
cache-from: type=gha
- name: Smoke test
run: docker run --rm openclaude:smoke --version
- name: Build and push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ dist/
.openclaude-profile.json
reports/
GEMINI.md
CLAUDE.md
package-lock.json
/.claude
coverage/
agent.log

View File

@@ -0,0 +1,3 @@
{
".": "0.7.0"
}

210
CHANGELOG.md Normal file
View File

@@ -0,0 +1,210 @@
# Changelog
## [0.7.0](https://github.com/Gitlawb/openclaude/compare/v0.6.0...v0.7.0) (2026-04-26)
### Features
* add model-specific tokenizers and compression ratio detection ([#799](https://github.com/Gitlawb/openclaude/issues/799)) ([e92e527](https://github.com/Gitlawb/openclaude/commit/e92e5274b223d935d380b1fbd234cb631ab03211))
* add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress hidden tool-output reminders ([#837](https://github.com/Gitlawb/openclaude/issues/837)) ([28de94d](https://github.com/Gitlawb/openclaude/commit/28de94df5dcd7718cb334e2e793e9472f5b291c5)), closes [#809](https://github.com/Gitlawb/openclaude/issues/809)
* add streaming optimizer and structured request logging ([#703](https://github.com/Gitlawb/openclaude/issues/703)) ([5b9cd21](https://github.com/Gitlawb/openclaude/commit/5b9cd21e373823a77fd552d6e02f5d4b68ae06b1))
* add xAI as official provider ([#865](https://github.com/Gitlawb/openclaude/issues/865)) ([2586a9c](https://github.com/Gitlawb/openclaude/commit/2586a9cddbd2512826bca81cb5deb3ec97f00f0f))
* **api:** expose cache metrics in REPL + normalize across providers ([#813](https://github.com/Gitlawb/openclaude/issues/813)) ([9e23c2b](https://github.com/Gitlawb/openclaude/commit/9e23c2bec43697187762601db5b1585c9b0fb1a3))
* implement Hook Chains runtime integration for self-healing agent mesh MVP ([#711](https://github.com/Gitlawb/openclaude/issues/711)) ([44a2c30](https://github.com/Gitlawb/openclaude/commit/44a2c30d5f9b98027e454466c680360f6b4625fc))
* **memory:** implement persistent project-level Knowledge Graph and RAG ([#899](https://github.com/Gitlawb/openclaude/issues/899)) ([29f7579](https://github.com/Gitlawb/openclaude/commit/29f757937732be0f8cca2bc0627a27eeafc2a992))
* **minimax:** add /usage support and fix MiniMax quota parsing ([#869](https://github.com/Gitlawb/openclaude/issues/869)) ([26413f6](https://github.com/Gitlawb/openclaude/commit/26413f6d307928a4f14c9c61c9860a28f8d81358))
* **model:** add GPT-5.5 support for Codex provider ([#880](https://github.com/Gitlawb/openclaude/issues/880)) ([038f715](https://github.com/Gitlawb/openclaude/commit/038f715b7ab9714340bda421b73a86d8590cf531))
* **tools:** resilient web search and fetch across all providers ([#836](https://github.com/Gitlawb/openclaude/issues/836)) ([531e3f1](https://github.com/Gitlawb/openclaude/commit/531e3f10592a73d81f26675c2479d46a3d5b55f5))
* **zai:** add Z.AI GLM Coding Plan provider preset ([#896](https://github.com/Gitlawb/openclaude/issues/896)) ([a0d657e](https://github.com/Gitlawb/openclaude/commit/a0d657ee188f52f8a4ceaad1658c81343a32fdad))
### Bug Fixes
* **agent:** provider-aware fallback for haiku/sonnet aliases ([#908](https://github.com/Gitlawb/openclaude/issues/908)) ([a3e728a](https://github.com/Gitlawb/openclaude/commit/a3e728a114f6379b80daefc8abcac17a752c5f96))
* bugs ([#885](https://github.com/Gitlawb/openclaude/issues/885)) ([c6c5f06](https://github.com/Gitlawb/openclaude/commit/c6c5f0608cf6509b412b121954547d72b3f3a411))
* make OpenAI fallback context window configurable + support external model lookup ([#861](https://github.com/Gitlawb/openclaude/issues/861)) ([b750e9e](https://github.com/Gitlawb/openclaude/commit/b750e9e97d15926d094d435772b2d6d12e5e545c))
* **mcp:** disable MCP_SKILLS feature flag — source not mirrored ([#872](https://github.com/Gitlawb/openclaude/issues/872)) ([dcbe295](https://github.com/Gitlawb/openclaude/commit/dcbe29558ab9c74d335b138488005a6509aa906a))
* normalize /provider multi-model selection and semicolon parsing ([#841](https://github.com/Gitlawb/openclaude/issues/841)) ([c4cb98a](https://github.com/Gitlawb/openclaude/commit/c4cb98a4f092062da02a4728cf59fed0fc3a6d3f))
* **openai-shim:** echo reasoning_content on assistant tool-call messages for Moonshot ([#828](https://github.com/Gitlawb/openclaude/issues/828)) ([67de6bd](https://github.com/Gitlawb/openclaude/commit/67de6bd2cffc3381f0f28fd3ffce043970611667))
* **query:** restore system prompt structure and add missing config import ([#907](https://github.com/Gitlawb/openclaude/issues/907)) ([818689b](https://github.com/Gitlawb/openclaude/commit/818689b2ee71cb6966cb4dc5a5ebd90fd22b0fcb))
* **shell:** recover when CWD path was replaced by a non-directory ([#871](https://github.com/Gitlawb/openclaude/issues/871)) ([a4c6757](https://github.com/Gitlawb/openclaude/commit/a4c67570238794317d049a225396672b465fdbfc))
* **startup:** show --model flag override on startup screen ([#898](https://github.com/Gitlawb/openclaude/issues/898)) ([d45628c](https://github.com/Gitlawb/openclaude/commit/d45628c41300b83b466e6a97983099615a50e7d7))
* **startup:** url authoritative over model name in banner provider detect ([#864](https://github.com/Gitlawb/openclaude/issues/864)) ([e346b8d](https://github.com/Gitlawb/openclaude/commit/e346b8d5ec2d58a4e8db337918d52d844ee52766)), closes [#855](https://github.com/Gitlawb/openclaude/issues/855)
* surface actionable error when DuckDuckGo web search is rate-limited ([#834](https://github.com/Gitlawb/openclaude/issues/834)) ([3c4d843](https://github.com/Gitlawb/openclaude/commit/3c4d8435c42e1ee04f9defd31c4c589017f524c5))
* **test:** add missing teammate exports to hookChains integration mock ([#840](https://github.com/Gitlawb/openclaude/issues/840)) ([23e8cfb](https://github.com/Gitlawb/openclaude/commit/23e8cfbd5b22179684276bef4131e26b830ce69c)), closes [#839](https://github.com/Gitlawb/openclaude/issues/839)
* **update:** show real package version and give actionable guidance ([#870](https://github.com/Gitlawb/openclaude/issues/870)) ([6e58b81](https://github.com/Gitlawb/openclaude/commit/6e58b819370128b923dda4fcc774bb556f4b951a))
## [0.6.0](https://github.com/Gitlawb/openclaude/compare/v0.5.2...v0.6.0) (2026-04-22)
### Features
* add model caching and benchmarking utilities ([#671](https://github.com/Gitlawb/openclaude/issues/671)) ([2b15e16](https://github.com/Gitlawb/openclaude/commit/2b15e16421f793f954a92c53933a07094544b29d))
* add thinking token extraction ([#798](https://github.com/Gitlawb/openclaude/issues/798)) ([268c039](https://github.com/Gitlawb/openclaude/commit/268c0398e4bf1ab898069c61500a2b3c226a0322))
* **api:** compress old tool_result content for small-context providers ([#801](https://github.com/Gitlawb/openclaude/issues/801)) ([a6a3de5](https://github.com/Gitlawb/openclaude/commit/a6a3de5ac155fe9d00befbfcab98d439314effd8))
* **api:** improve local provider reliability with readiness and self-healing ([#738](https://github.com/Gitlawb/openclaude/issues/738)) ([4cb963e](https://github.com/Gitlawb/openclaude/commit/4cb963e660dbd6ee438c04042700db05a9d32c59))
* **api:** smart model routing primitive (cheap-for-simple, strong-for-hard) ([#785](https://github.com/Gitlawb/openclaude/issues/785)) ([e908864](https://github.com/Gitlawb/openclaude/commit/e908864da7e7c987a98053ac5d18d702e192db2b))
* enable 15 additional feature flags in open build ([#667](https://github.com/Gitlawb/openclaude/issues/667)) ([6a62e3f](https://github.com/Gitlawb/openclaude/commit/6a62e3ff76ba9ba446b8e20cf2bb139ee76a9387))
* native Anthropic API mode for Claude models on GitHub Copilot ([#579](https://github.com/Gitlawb/openclaude/issues/579)) ([fdef4a1](https://github.com/Gitlawb/openclaude/commit/fdef4a1b4ce218ded4937ca83b30acce7c726472))
* **provider:** expose Atomic Chat in /provider picker with autodetect ([#810](https://github.com/Gitlawb/openclaude/issues/810)) ([ee19159](https://github.com/Gitlawb/openclaude/commit/ee19159c17b3de3b4a8b4a4541a6569f4261d54e))
* **provider:** zero-config autodetection primitive ([#784](https://github.com/Gitlawb/openclaude/issues/784)) ([a5bfcbb](https://github.com/Gitlawb/openclaude/commit/a5bfcbbadf8e9a1fd42f3e103d295524b8da64b0))
### Bug Fixes
* **api:** ensure strict role sequence and filter empty assistant messages after interruption ([#745](https://github.com/Gitlawb/openclaude/issues/745) regression) ([#794](https://github.com/Gitlawb/openclaude/issues/794)) ([06e7684](https://github.com/Gitlawb/openclaude/commit/06e7684eb56df8e694ac784575e163641931c44c))
* Collapse all-text arrays to string for DeepSeek compatibility ([#806](https://github.com/Gitlawb/openclaude/issues/806)) ([761924d](https://github.com/Gitlawb/openclaude/commit/761924daa7e225fe8acf41651408c7cae639a511))
* **model:** codex/nvidia-nim/minimax now read OPENAI_MODEL env ([#815](https://github.com/Gitlawb/openclaude/issues/815)) ([4581208](https://github.com/Gitlawb/openclaude/commit/458120889f6ce54cc9f0b287461d5e38eae48a20))
* **provider:** saved profile ignored when stale CLAUDE_CODE_USE_* in shell ([#807](https://github.com/Gitlawb/openclaude/issues/807)) ([13de4e8](https://github.com/Gitlawb/openclaude/commit/13de4e85df7f5fadc8cd15a76076374dc112360b))
* rename .claude.json to .openclaude.json with legacy fallback ([#582](https://github.com/Gitlawb/openclaude/issues/582)) ([4d4fb28](https://github.com/Gitlawb/openclaude/commit/4d4fb2880e4d0e3a62d8715e1ec13d932e736279))
* replace discontinued gemini-2.5-pro-preview-03-25 with stable gemini-2.5-pro ([#802](https://github.com/Gitlawb/openclaude/issues/802)) ([64582c1](https://github.com/Gitlawb/openclaude/commit/64582c119d5d0278195271379da4a68d59a89c1f)), closes [#398](https://github.com/Gitlawb/openclaude/issues/398)
* **security:** harden project settings trust boundary + MCP sanitization ([#789](https://github.com/Gitlawb/openclaude/issues/789)) ([ae3b723](https://github.com/Gitlawb/openclaude/commit/ae3b723f3b297b49925cada4728f3174aee8bf12))
* **test:** autoCompact floor assertion is flag-sensitive ([#816](https://github.com/Gitlawb/openclaude/issues/816)) ([c13842e](https://github.com/Gitlawb/openclaude/commit/c13842e91c7227246520955de6ae0636b30def9a))
* **ui:** prevent provider manager lag by deferring sync I/O ([#803](https://github.com/Gitlawb/openclaude/issues/803)) ([85eab27](https://github.com/Gitlawb/openclaude/commit/85eab2751e7d351bb0ed6a3fe0e15461d241c9cb))
## [0.5.2](https://github.com/Gitlawb/openclaude/compare/v0.5.1...v0.5.2) (2026-04-20)
### Bug Fixes
* **api:** replace phrase-based reasoning sanitizer with tag-based filter ([#779](https://github.com/Gitlawb/openclaude/issues/779)) ([336ddcc](https://github.com/Gitlawb/openclaude/commit/336ddcc50d59d79ebff50993f2673652aecb0d7d))
## [0.5.1](https://github.com/Gitlawb/openclaude/compare/v0.5.0...v0.5.1) (2026-04-20)
### Bug Fixes
* enforce Bash path constraints after sandbox allow ([#777](https://github.com/Gitlawb/openclaude/issues/777)) ([7002cb3](https://github.com/Gitlawb/openclaude/commit/7002cb302b78ea2a19da3f26226de24e2903fa1d))
* enforce MCP OAuth callback state before errors ([#775](https://github.com/Gitlawb/openclaude/issues/775)) ([739b8d1](https://github.com/Gitlawb/openclaude/commit/739b8d1f40fde0e401a5cbd2b9a55d88bd5124ad))
* require trusted approval for sandbox override ([#778](https://github.com/Gitlawb/openclaude/issues/778)) ([aab4890](https://github.com/Gitlawb/openclaude/commit/aab489055c53dd64369414116fe93226d2656273))
## [0.5.0](https://github.com/Gitlawb/openclaude/compare/v0.4.0...v0.5.0) (2026-04-20)
### Features
* add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization ([#770](https://github.com/Gitlawb/openclaude/issues/770)) ([e6e8d9a](https://github.com/Gitlawb/openclaude/commit/e6e8d9a24897e4c9ef08b72df20fabbf8ef27f38))
* mask provider api key input ([#772](https://github.com/Gitlawb/openclaude/issues/772)) ([13e9f22](https://github.com/Gitlawb/openclaude/commit/13e9f22a83a2b0f85f557b1e12c9442ba61241e4))
### Bug Fixes
* allow provider recovery during startup ([#765](https://github.com/Gitlawb/openclaude/issues/765)) ([f828171](https://github.com/Gitlawb/openclaude/commit/f828171ef1ab94e2acf73a28a292799e4e26cc0d))
* **api:** drop orphan tool results to satisfy strict role sequence ([#745](https://github.com/Gitlawb/openclaude/issues/745)) ([b786b76](https://github.com/Gitlawb/openclaude/commit/b786b765f01f392652eaf28ed3579a96b7260a53))
* **help:** prevent /help tab crash from undefined descriptions ([#732](https://github.com/Gitlawb/openclaude/issues/732)) ([3d1979f](https://github.com/Gitlawb/openclaude/commit/3d1979ff066db32415e0c8321af916d81f5f2621))
* **mcp:** sync required array with properties in tool schemas ([#754](https://github.com/Gitlawb/openclaude/issues/754)) ([002a8f1](https://github.com/Gitlawb/openclaude/commit/002a8f1f6de2fcfc917165d828501d3047bad61f))
* remove cached mcpClient in diagnostic tracking to prevent stale references ([#727](https://github.com/Gitlawb/openclaude/issues/727)) ([2c98be7](https://github.com/Gitlawb/openclaude/commit/2c98be700274a4241963b5f43530bf3bd8f8963f))
* use raw context window for auto-compact percentage display ([#748](https://github.com/Gitlawb/openclaude/issues/748)) ([55c5f26](https://github.com/Gitlawb/openclaude/commit/55c5f262a9a5a8be0aa9ae8dc6c7dafc465eb2c6))
## [0.4.0](https://github.com/Gitlawb/openclaude/compare/v0.3.0...v0.4.0) (2026-04-17)
### Features
* add Alibaba Coding Plan (DashScope) provider support ([#509](https://github.com/Gitlawb/openclaude/issues/509)) ([43ac6db](https://github.com/Gitlawb/openclaude/commit/43ac6dba75537282da1e2ad8f855082bc4e25f1e))
* add NVIDIA NIM and MiniMax provider support ([#552](https://github.com/Gitlawb/openclaude/issues/552)) ([51191d6](https://github.com/Gitlawb/openclaude/commit/51191d61326e1f8319d70b3a3c0d9229e185a564))
* add ripgrep to Dockerfile for faster file searching ([#688](https://github.com/Gitlawb/openclaude/issues/688)) ([12dd375](https://github.com/Gitlawb/openclaude/commit/12dd3755c619cc27af3b151ae8fdb9d425a7b9a2))
* **api:** classify openai-compatible provider failures ([#708](https://github.com/Gitlawb/openclaude/issues/708)) ([80a00ac](https://github.com/Gitlawb/openclaude/commit/80a00acc2c6dc4657a78de7366f7a9ebc920bfbb))
* **vscode:** add full chat interface to OpenClaude extension ([#608](https://github.com/Gitlawb/openclaude/issues/608)) ([fbcd928](https://github.com/Gitlawb/openclaude/commit/fbcd928f7f8511da795aea3ad318bddf0ab9a1a7))
### Bug Fixes
* focus "Done" option after completing provider manager actions ([#718](https://github.com/Gitlawb/openclaude/issues/718)) ([d6f5130](https://github.com/Gitlawb/openclaude/commit/d6f5130c204d8ffe582212466768706cd7fd6774))
* **models:** prevent /models crash from non-string saved model values ([#691](https://github.com/Gitlawb/openclaude/issues/691)) ([6b2121d](https://github.com/Gitlawb/openclaude/commit/6b2121da12189fa7ce1f33394d18abd24cf8a01b))
* prevent crash in commands tab when description is undefined ([#730](https://github.com/Gitlawb/openclaude/issues/730)) ([eed77e6](https://github.com/Gitlawb/openclaude/commit/eed77e6579866a98384dcc948a0ad6406614ede3))
* strip comments before scanning for missing imports ([#676](https://github.com/Gitlawb/openclaude/issues/676)) ([a00b792](https://github.com/Gitlawb/openclaude/commit/a00b7928de9662ffb7ef6abd8cd040afe6f4f122))
* **ui:** show correct endpoint URL in intro screen for custom Anthropic endpoints ([#735](https://github.com/Gitlawb/openclaude/issues/735)) ([3424663](https://github.com/Gitlawb/openclaude/commit/34246635fb9a09499047a52e7f96ca9b36c8a85a))
## [0.3.0](https://github.com/Gitlawb/openclaude/compare/v0.2.3...v0.3.0) (2026-04-14)
### Features
* activate coordinator mode in open build ([#647](https://github.com/Gitlawb/openclaude/issues/647)) ([99a1714](https://github.com/Gitlawb/openclaude/commit/99a17144ee285b892a0801acb6abcc9af68879af))
* activate local-only team memory in open build ([#648](https://github.com/Gitlawb/openclaude/issues/648)) ([24d485f](https://github.com/Gitlawb/openclaude/commit/24d485f42f5b1405d2fab13f2f497d5edd3b5300))
* activate message actions in open build ([#632](https://github.com/Gitlawb/openclaude/issues/632)) ([252808b](https://github.com/Gitlawb/openclaude/commit/252808bbd0a12a6ccf97e2cb09752a0212ea3acd))
* add allowBypassPermissionsMode setting ([#658](https://github.com/Gitlawb/openclaude/issues/658)) ([31be66d](https://github.com/Gitlawb/openclaude/commit/31be66d7645ea3473334c9ce89ea1a5095b8df6e))
* add Docker image build and push to GHCR on release ([#656](https://github.com/Gitlawb/openclaude/issues/656)) ([658d076](https://github.com/Gitlawb/openclaude/commit/658d076909e14eb0459bcb98aee9aa0472118265))
* implement /loop command with fixed and dynamic scheduling ([#621](https://github.com/Gitlawb/openclaude/issues/621)) ([64298a6](https://github.com/Gitlawb/openclaude/commit/64298a663f1391b16aa1f5a49e8a877e1d3742f2))
* implement Monitor tool for streaming shell output ([#649](https://github.com/Gitlawb/openclaude/issues/649)) ([b818dd5](https://github.com/Gitlawb/openclaude/commit/b818dd5958f4e8428566ce25a1a6be5fd4fe66f8))
* local feature flag overrides via ~/.claude/feature-flags.json ([#639](https://github.com/Gitlawb/openclaude/issues/639)) ([0e48884](https://github.com/Gitlawb/openclaude/commit/0e48884f56c6c008f047a7926d3b2cb924170625))
* open useful USER_TYPE-gated features to all users ([#644](https://github.com/Gitlawb/openclaude/issues/644)) ([c1beea9](https://github.com/Gitlawb/openclaude/commit/c1beea98676a413c54152a45a6b9fbe7fb9ed028))
### Bug Fixes
* bump axios 1.14.0 → 1.15.0 (Dependabot [#4](https://github.com/Gitlawb/openclaude/issues/4), [#5](https://github.com/Gitlawb/openclaude/issues/5)) ([#670](https://github.com/Gitlawb/openclaude/issues/670)) ([a07e5ef](https://github.com/Gitlawb/openclaude/commit/a07e5ef990a5ed01a72e83fdbd1fcab36f515a08))
* extend provider guard to protect anthropic profiles from cross-terminal override ([#641](https://github.com/Gitlawb/openclaude/issues/641)) ([03e0b06](https://github.com/Gitlawb/openclaude/commit/03e0b06e0784e4ea46945b3950840b10b6e3ca49))
* improve fetch diagnostics for bootstrap and session requests ([#646](https://github.com/Gitlawb/openclaude/issues/646)) ([df2b9f2](https://github.com/Gitlawb/openclaude/commit/df2b9f2b7b4c661ee3d9ed5dc58b3064de0599d1))
* **openai-shim:** preserve tool result images and local token caps ([#659](https://github.com/Gitlawb/openclaude/issues/659)) ([30c866d](https://github.com/Gitlawb/openclaude/commit/30c866d31ad8538496460667d86ed5efbd4a8547))
* replace broken bun:bundle shim with source pre-processing ([#657](https://github.com/Gitlawb/openclaude/issues/657)) ([adbe391](https://github.com/Gitlawb/openclaude/commit/adbe391e63721918b5d147f4f845111c1a3143db))
* resolve 12 bugs across API, MCP, agent tools, web search, and context overflow ([#674](https://github.com/Gitlawb/openclaude/issues/674)) ([25ce2ca](https://github.com/Gitlawb/openclaude/commit/25ce2ca7bff8937b0b79ad7f85c6dc1c68432069))
* route OpenAI Codex shortcuts to correct endpoint ([#566](https://github.com/Gitlawb/openclaude/issues/566)) ([7c8bdcc](https://github.com/Gitlawb/openclaude/commit/7c8bdcc3e2ac1ecb98286c705c85671044be3d6b))
## [0.2.3](https://github.com/Gitlawb/openclaude/compare/v0.2.2...v0.2.3) (2026-04-12)
### Bug Fixes
* prevent infinite auto-compact loop for unknown 3P models ([#635](https://github.com/Gitlawb/openclaude/issues/635)) ([#636](https://github.com/Gitlawb/openclaude/issues/636)) ([aeaa658](https://github.com/Gitlawb/openclaude/commit/aeaa658f776fb8df95721e8b8962385f8b00f66a))
## [0.2.2](https://github.com/Gitlawb/openclaude/compare/v0.2.1...v0.2.2) (2026-04-12)
### Bug Fixes
* **read/edit:** make compact line prefix unambiguous for tab-indented files ([#613](https://github.com/Gitlawb/openclaude/issues/613)) ([08cc6f3](https://github.com/Gitlawb/openclaude/commit/08cc6f328711cd93ce9fa53351266c29a0b0a341))
## [0.2.1](https://github.com/Gitlawb/openclaude/compare/v0.2.0...v0.2.1) (2026-04-12)
### Bug Fixes
* **provider:** add recovery guidance for missing OpenAI API key ([#616](https://github.com/Gitlawb/openclaude/issues/616)) ([9419e8a](https://github.com/Gitlawb/openclaude/commit/9419e8a4a21b3771d9ddb10f7072e0a8c5b5b631))
## [0.2.0](https://github.com/Gitlawb/openclaude/compare/v0.1.8...v0.2.0) (2026-04-12)
### Features
* add /cache-probe diagnostic command ([#580](https://github.com/Gitlawb/openclaude/issues/580)) ([9ccaa7a](https://github.com/Gitlawb/openclaude/commit/9ccaa7a6759b6991f4a566b4118c06e68a2398fe)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add auto-fix service — auto-lint and test after AI file edits ([#508](https://github.com/Gitlawb/openclaude/issues/508)) ([c385047](https://github.com/Gitlawb/openclaude/commit/c385047abba4366866f4c87bfb5e0b0bd4dcbb9d))
* Add Gemini support with thought_signature fix ([#404](https://github.com/Gitlawb/openclaude/issues/404)) ([5012c16](https://github.com/Gitlawb/openclaude/commit/5012c160c9a2dff9418e7ee19dc9a4d29ef2b024))
* add headless gRPC server for external agent integration ([#278](https://github.com/Gitlawb/openclaude/issues/278)) ([26eef92](https://github.com/Gitlawb/openclaude/commit/26eef92fe72e9c3958d61435b8d3571e12bf2b74))
* add wiki mvp commands ([#532](https://github.com/Gitlawb/openclaude/issues/532)) ([c328fdf](https://github.com/Gitlawb/openclaude/commit/c328fdf9e2fe59ad101b049301298ce9ff24caca))
* GitHub provider lifecycle and onboarding hardening ([#351](https://github.com/Gitlawb/openclaude/issues/351)) ([ff7d499](https://github.com/Gitlawb/openclaude/commit/ff7d49990de515825ddbe4099f3a39b944b61370))
### Bug Fixes
* add File polyfill for Node &lt; 20 to prevent startup deadlock with proxy ([#442](https://github.com/Gitlawb/openclaude/issues/442)) ([85aa8b0](https://github.com/Gitlawb/openclaude/commit/85aa8b0985c8f3cb8801efa5141114a0ab0f6a83))
* add GitHub Copilot model context windows and output limits ([#576](https://github.com/Gitlawb/openclaude/issues/576)) ([a7f5982](https://github.com/Gitlawb/openclaude/commit/a7f5982f6438ab0ddc3f0daae31ea68ac7ac206c)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add LiteLLM-style aliases for GitHub Copilot context windows ([#606](https://github.com/Gitlawb/openclaude/issues/606)) ([2e0e14d](https://github.com/Gitlawb/openclaude/commit/2e0e14d71313e0e501efaa9e55c6c56f2742fb10))
* add store:false to Chat Completions and /responses fallback ([#578](https://github.com/Gitlawb/openclaude/issues/578)) ([8aaa4f2](https://github.com/Gitlawb/openclaude/commit/8aaa4f22ac5b942d82aa9cad54af30d56034515a))
* address code scanning alerts ([#434](https://github.com/Gitlawb/openclaude/issues/434)) ([e365cb4](https://github.com/Gitlawb/openclaude/commit/e365cb4010becabacd7cbccb4c3e59ea23a41e90))
* avoid sync github credential reads in provider manager ([#428](https://github.com/Gitlawb/openclaude/issues/428)) ([aff2bd8](https://github.com/Gitlawb/openclaude/commit/aff2bd87e4f2821992f74fb95481c505d0ba5d5d))
* convert dragged file paths to [@mentions](https://github.com/mentions) for attachment ([#382](https://github.com/Gitlawb/openclaude/issues/382)) ([112df59](https://github.com/Gitlawb/openclaude/commit/112df5911791ea71ee9efbb98ea59c5ded1ea161))
* custom web search — WEB_URL_TEMPLATE not recognized, timeout too short, silent native fallback ([#537](https://github.com/Gitlawb/openclaude/issues/537)) ([32fbd0c](https://github.com/Gitlawb/openclaude/commit/32fbd0c7b4168b32dcb13a5b69342e2727269201))
* defer startup checks and suppress recommendation dialogs during startup window (issue [#363](https://github.com/Gitlawb/openclaude/issues/363)) ([#504](https://github.com/Gitlawb/openclaude/issues/504)) ([2caf2fd](https://github.com/Gitlawb/openclaude/commit/2caf2fd982af1ec845c50152ad9d28d1a597f82f))
* display selected model in startup screen instead of hardcoded sonnet 4.6 ([#587](https://github.com/Gitlawb/openclaude/issues/587)) ([b126e38](https://github.com/Gitlawb/openclaude/commit/b126e38b1affddd2de83fcc3ba26f2e44b42a509))
* handle missing skill parameter in SkillTool ([#485](https://github.com/Gitlawb/openclaude/issues/485)) ([f9ce81b](https://github.com/Gitlawb/openclaude/commit/f9ce81bfb384e909353813fb6f6760cadd508ae7))
* include MCP tool results in microcompact to reduce token waste ([#348](https://github.com/Gitlawb/openclaude/issues/348)) ([52d33a8](https://github.com/Gitlawb/openclaude/commit/52d33a87a047b943aedaaaf772cd48636c263509))
* **ink:** restore host prop updates in React 19 reconciler ([#589](https://github.com/Gitlawb/openclaude/issues/589)) ([6e94dd9](https://github.com/Gitlawb/openclaude/commit/6e94dd913688b2d6433a9abe62a245c5f031b776))
* let saved provider profiles win on restart ([#513](https://github.com/Gitlawb/openclaude/issues/513)) ([cb8f8b7](https://github.com/Gitlawb/openclaude/commit/cb8f8b7ac2e3e74516ee219a3a48156db7c6ed78))
* normalize malformed Bash tool arguments from OpenAI-compatible providers ([#385](https://github.com/Gitlawb/openclaude/issues/385)) ([b4bd95b](https://github.com/Gitlawb/openclaude/commit/b4bd95b47715c9896240d708c106777507fd26ec))
* preserve only originally-required properties in strict tool schemas ([#471](https://github.com/Gitlawb/openclaude/issues/471)) ([ccaa193](https://github.com/Gitlawb/openclaude/commit/ccaa193eec5761f0972ffb58eb3189a81a9244b0))
* preserve unicode in Windows clipboard fallback ([#388](https://github.com/Gitlawb/openclaude/issues/388)) ([c193497](https://github.com/Gitlawb/openclaude/commit/c1934974aaf64db460cc850a044bd13cc744cce7))
* rebrand prompt identity to openclaude ([#496](https://github.com/Gitlawb/openclaude/issues/496)) ([598651f](https://github.com/Gitlawb/openclaude/commit/598651f42389ce76311ec00e8a9c701c939ead27))
* replace isDeepStrictEqual with navigation-aware options comparison ([#507](https://github.com/Gitlawb/openclaude/issues/507)) ([537c469](https://github.com/Gitlawb/openclaude/commit/537c469c3a2f7cb0eed05fa2f54dca57b6bc273f)), closes [#472](https://github.com/Gitlawb/openclaude/issues/472)
* report cache reads in streaming and correct cost calculation ([#577](https://github.com/Gitlawb/openclaude/issues/577)) ([f4ac709](https://github.com/Gitlawb/openclaude/commit/f4ac709fa6eda732bf45204fcab625ba6c5674b9))
* restore default context window for unknown 3p models ([#494](https://github.com/Gitlawb/openclaude/issues/494)) ([69ea1f1](https://github.com/Gitlawb/openclaude/commit/69ea1f1e4a99e9436215d8cb391a116a64442b94))
* restore Grep and Glob reliability on OpenAI paths ([#461](https://github.com/Gitlawb/openclaude/issues/461)) ([600c01f](https://github.com/Gitlawb/openclaude/commit/600c01faf761a080a2c7dede872ddbe05a132f23))
* restore Ollama auto-detect in first-run setup ([#561](https://github.com/Gitlawb/openclaude/issues/561)) ([68c2968](https://github.com/Gitlawb/openclaude/commit/68c296833dcef54ce44cb18b24357230b5204dbc))
* scrub canonical Anthropic headers from 3P shim requests ([#499](https://github.com/Gitlawb/openclaude/issues/499)) ([07621a6](https://github.com/Gitlawb/openclaude/commit/07621a6f8d0918170281869a47b5dbff90e71594))
* strip Anthropic params from 3P resume paths ([#479](https://github.com/Gitlawb/openclaude/issues/479)) ([4975cfc](https://github.com/Gitlawb/openclaude/commit/4975cfc2e0ddbe34aa4e8e3f52ee5eba07fbe465))
* suppress startup dialogs when input is buffered ([#423](https://github.com/Gitlawb/openclaude/issues/423)) ([8ece290](https://github.com/Gitlawb/openclaude/commit/8ece2900872dadd157e798ef501ddf126dac66c4))
* **tui:** restore prompt rendering on startup ([#498](https://github.com/Gitlawb/openclaude/issues/498)) ([e30ad17](https://github.com/Gitlawb/openclaude/commit/e30ad17ae0056787273be2caafd6cf5340b6ab57))
* update theme preview on focus change ([#562](https://github.com/Gitlawb/openclaude/issues/562)) ([6924718](https://github.com/Gitlawb/openclaude/commit/692471850fc789ee0797190089272407f9a4d953))
* **web-search:** close SSRF bypasses in custom provider hostname guard ([#610](https://github.com/Gitlawb/openclaude/issues/610)) ([a02c441](https://github.com/Gitlawb/openclaude/commit/a02c44143b257fbee7f38f1b93873cc0ea68a1f9))
* WebSearch providers + MCPTool bugs ([#593](https://github.com/Gitlawb/openclaude/issues/593)) ([91e4cfb](https://github.com/Gitlawb/openclaude/commit/91e4cfb15b62c04615834fd3c417fe38b4feb914))

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# ---- build stage ----
FROM node:22-slim AS build
# Install Bun
RUN npm install -g bun@1.3.11
WORKDIR /app
# Copy dependency manifests first for better layer caching
COPY package.json bun.lock ./
# Install all dependencies (including devDependencies for build)
RUN bun install --frozen-lockfile
# Copy source code
COPY src/ src/
COPY scripts/ scripts/
COPY bin/ bin/
COPY tsconfig.json ./
# Build the CLI bundle
RUN bun run build
# Prune devDependencies
RUN rm -rf node_modules && bun install --frozen-lockfile --production
# ---- runtime stage ----
FROM node:22-slim
WORKDIR /app
# Copy only what's needed to run
COPY --from=build /app/dist/cli.mjs dist/cli.mjs
COPY --from=build /app/bin/ bin/
COPY --from=build /app/node_modules/ node_modules/
COPY --from=build /app/package.json package.json
COPY README.md ./
# Install git and ripgrep — many CLI tool operations depend on them
RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Run as non-root user
USER node
ENTRYPOINT ["node", "/app/dist/cli.mjs"]

View File

@@ -132,7 +132,7 @@ Cause:
Fix:
```powershell
cd C:\Users\Lucas Pedry\Documents\openclaude\openclaude
cd <PATH>
bun run dev:profile
```
@@ -189,7 +189,7 @@ Or pick a local Ollama profile automatically by goal:
bun run profile:init -- --provider ollama --goal balanced
```
## 6.5 Placeholder key (`SUA_CHAVE`) error
## 6.5 Placeholder key (`YOUR_KEY`) error
Cause:

View File

@@ -2,7 +2,7 @@
OpenClaude is an open-source coding-agent CLI for cloud and local model providers.
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
[![PR Checks](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml/badge.svg?branch=main)](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
[![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags)
@@ -10,13 +10,38 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
[![Security Policy](https://img.shields.io/badge/security-policy-0f766e)](SECURITY.md)
[![License](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE)
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
OpenClaude is also mirrored to GitLawb:
[gitlawb.com/node/repos/z6MkqDnb/openclaude](https://gitlawb.com/node/repos/z6MkqDnb/openclaude)
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Sponsors](#sponsors) | [Community](#community)
## Sponsors
<p align="center">
<a href="https://gitlawb.com">
<img src="https://gitlawb.com/logo.png" alt="GitLawb logo" width="96">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bankr.bot">
<img src="https://bankr.bot/favicon.svg" alt="Bankr.bot logo" width="96">
</a>
</p>
<p align="center">
<a href="https://gitlawb.com"><strong>GitLawb</strong></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bankr.bot"><strong>Bankr.bot</strong></a>
</p>
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=gitlawb/openclaude&type=date&legend=top-left)](https://www.star-history.com/?repos=gitlawb%2Fopenclaude&type=date&legend=top-left)
## Why OpenClaude
- Use one CLI across cloud APIs and local model backends
- Save provider profiles inside the app with `/provider`
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported providers
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported providers
- Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
- Use the bundled VS Code extension for launch integration and theme support
@@ -85,6 +110,16 @@ $env:OPENAI_MODEL="qwen2.5-coder:7b"
openclaude
```
### Using Ollama's launch command
If you have [Ollama](https://ollama.com) installed, you can skip the env var setup entirely:
```bash
ollama launch openclaude --model qwen2.5-coder:7b
```
This automatically sets `ANTHROPIC_BASE_URL`, model routing, and auth so all API traffic goes through your local Ollama instance. Works with any model you have pulled — local or cloud.
## Setup Guides
Beginner-friendly guides:
@@ -105,9 +140,10 @@ Advanced and source-build guides:
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
| Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` |
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex | `/provider` | Uses existing Codex credentials when available |
| Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend |
| Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
| Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
| Ollama | `/provider`, env vars, or `ollama launch` | Local inference with no API key |
| Atomic Chat | `/provider`, env vars, or `bun run dev:atomic-chat` | Local Model Provider; auto-detects loaded models |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
## What Works
@@ -134,12 +170,12 @@ For best results, use models with strong tool/function calling support.
OpenClaude can route different agents to different models through settings-based routing. This is useful for cost optimization or splitting work by model strength.
Add to `~/.claude/settings.json`:
Add to `~/.openclaude.json`:
```json
{
"agentModels": {
"deepseek-chat": {
"deepseek-v4-flash": {
"base_url": "https://api.deepseek.com/v1",
"api_key": "sk-your-key"
},
@@ -149,10 +185,10 @@ Add to `~/.claude/settings.json`:
}
},
"agentRouting": {
"Explore": "deepseek-chat",
"Explore": "deepseek-v4-flash",
"Plan": "gpt-4o",
"general-purpose": "gpt-4o",
"frontend-dev": "deepseek-chat",
"frontend-dev": "deepseek-v4-flash",
"default": "gpt-4o"
}
}
@@ -313,7 +349,8 @@ For larger changes, open an issue first so the scope is clear before implementat
- `bun run build`
- `bun run test:coverage`
- `bun run smoke`
- focused `bun test ...` runs for touched areas
- focused `bun test ...` runs for files and flows you changed
## Disclaimer

View File

@@ -30,7 +30,7 @@
"@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.14.0",
"axios": "1.15.0",
"bidi-js": "1.0.3",
"chalk": "5.6.2",
"chokidar": "4.0.3",
@@ -479,7 +479,7 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
@@ -1151,6 +1151,8 @@
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@mendable/firecrawl-js/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="],
@@ -1377,6 +1379,8 @@
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"firecrawl/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],

View File

@@ -48,6 +48,8 @@ export OPENAI_MODEL=gpt-4o
`codexplan` maps to GPT-5.4 on the Codex backend with high reasoning.
`codexspark` maps to GPT-5.3 Codex Spark for faster loops.
If you use the in-app provider wizard, choose `Codex OAuth` to open ChatGPT sign-in in your browser and let OpenClaude store Codex credentials securely.
If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`.
```bash
@@ -66,9 +68,11 @@ openclaude
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://api.deepseek.com/v1
export OPENAI_MODEL=deepseek-chat
export OPENAI_MODEL=deepseek-v4-flash
```
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` remain available as DeepSeek's legacy API aliases.
### Google Gemini via OpenRouter
```bash
@@ -82,6 +86,16 @@ OpenRouter model availability changes over time. If a model stops working, try a
### Ollama
Using `ollama launch` (recommended if you have Ollama installed):
```bash
ollama launch openclaude --model llama3.3:70b
```
This handles all environment setup automatically — no env vars needed. Works with any local or cloud model available in your Ollama instance.
Using environment variables manually:
```bash
ollama pull llama3.3:70b
@@ -137,10 +151,9 @@ export OPENAI_MODEL=llama-3.3-70b-versatile
### Mistral
```bash
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_API_KEY=...
export OPENAI_BASE_URL=https://api.mistral.ai/v1
export OPENAI_MODEL=mistral-large-latest
export CLAUDE_CODE_USE_MISTRAL=1
export MISTRAL_API_KEY=...
export MISTRAL_MODEL=mistral-large-latest
```
### Azure OpenAI
@@ -158,12 +171,13 @@ export OPENAI_MODEL=gpt-4o
|----------|----------|-------------|
| `CLAUDE_CODE_USE_OPENAI` | Yes | Set to `1` to enable the OpenAI provider |
| `OPENAI_API_KEY` | Yes* | Your API key (`*` not needed for local models like Ollama or Atomic Chat) |
| `OPENAI_MODEL` | Yes | Model name such as `gpt-4o`, `deepseek-chat`, or `llama3.3:70b` |
| `OPENAI_MODEL` | Yes | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
| `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` |
| `CODEX_API_KEY` | Codex only | Codex or ChatGPT access token override |
| `CODEX_AUTH_JSON_PATH` | Codex only | Path to a Codex CLI `auth.json` file |
| `CODEX_HOME` | Codex only | Alternative Codex home directory |
| `OPENCLAUDE_DISABLE_CO_AUTHORED_BY` | No | Suppress the default `Co-Authored-By` trailer in generated git commits |
| `OPENCLAUDE_LOG_TOKEN_USAGE` | No | When truthy (e.g. `verbose`), emits one JSON line on stderr per API request with input/output/cache tokens and the resolved provider. **User-facing debug output** — complements the REPL display controlled by `/config showCacheStats`. Distinct from `CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, which is **model-facing** (injects context usage info into the prompt itself). Both can run together. |
You can also use `ANTHROPIC_MODEL` to override the model name. `OPENAI_MODEL` takes priority.

333
docs/hook-chains.md Normal file
View File

@@ -0,0 +1,333 @@
# Hook Chains (Self-Healing Agent Mesh MVP)
Hook Chains provide an event-driven recovery layer for important workflow failures.
When a matching hook event occurs, OpenClaude evaluates declarative rules and can dispatch remediation actions such as:
- `spawn_fallback_agent`
- `notify_team`
- `warm_remote_capacity`
## Disabled-By-Default Rollout
> **Rollout recommendation:** keep Hook Chains disabled until you validate rules in your environment.
>
> - Set top-level config to `"enabled": false` initially.
> - Enable per environment when ready.
> - Dispatch is gated by `feature('HOOK_CHAINS')`.
> - Env gate defaults to off unless `CLAUDE_CODE_ENABLE_HOOK_CHAINS=1` is set.
This keeps existing workflows unchanged while you tune guard windows and action behavior.
## Feature Overview
Hook Chains are loaded from a deterministic config file and evaluated on dispatched hook events.
MVP runtime trigger wiring:
- `PostToolUseFailure` hooks dispatch Hook Chains with outcome `failed`.
- `TaskCompleted` hooks dispatch Hook Chains with outcome:
- `success` when completion hooks did not block.
- `failed` when completion hooks returned blocking errors or prevented continuation.
Default config path:
- `.openclaude/hook-chains.json`
Override path:
- `CLAUDE_CODE_HOOK_CHAINS_CONFIG_PATH=/abs/or/relative/path/to/hook-chains.json`
Global gate:
- `feature('HOOK_CHAINS')` must be enabled in the build
- `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0|1` (defaults to disabled when unset)
## Safety Guarantees
The runtime is intentionally conservative:
- **Depth guard:** chain dispatch is blocked when `chainDepth >= maxChainDepth`.
- **Rule cooldown:** each rule can only re-fire after cooldown expires.
- **Dedup window:** identical event/action combinations are suppressed for a window.
- **Abort-safe behavior:** if the current signal is aborted, actions skip safely.
- **Policy-aware remote warm:** `warm_remote_capacity` skips when remote sessions are policy denied.
- **Bridge inactive no-op:** `warm_remote_capacity` safely skips when no active bridge handle exists.
- **Missing team context safety:** `notify_team` skips with structured reason if no team context/team file is available.
- **Fallback launcher safety:** `spawn_fallback_agent` fails with a structured reason when launch permissions/context are unavailable.
## Configuration Schema Reference
Top-level object:
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 30000,
"defaultDedupWindowMs": 30000,
"rules": []
}
```
### Top-Level Fields
| Field | Type | Required | Notes |
|---|---|---:|---|
| `version` | `1` | No | Defaults to `1`. |
| `enabled` | `boolean` | No | Global feature switch for this config file. |
| `maxChainDepth` | `integer` | No | Global depth guard (default `2`, max `10`). |
| `defaultCooldownMs` | `integer` | No | Default rule cooldown in ms (default `30000`). |
| `defaultDedupWindowMs` | `integer` | No | Default action dedup window in ms (default `30000`). |
| `rules` | `HookChainRule[]` | No | Defaults to `[]`. May be omitted or empty; when no rules are present, dispatch is a no-op and returns `enabled: false`. |
> **Note:** An empty ruleset is valid and can be used to keep Hook Chains configured but effectively disabled until rules are added.
### Rule Object (`HookChainRule`)
```json
{
"id": "task-failure-recovery",
"enabled": true,
"trigger": {
"event": "TaskCompleted",
"outcome": "failed"
},
"condition": {
"toolNames": ["Edit"],
"taskStatuses": ["failed"],
"errorIncludes": ["timeout", "permission denied"],
"eventFieldEquals": {
"meta.source": "scheduler"
}
},
"cooldownMs": 60000,
"dedupWindowMs": 30000,
"maxDepth": 2,
"actions": []
}
```
| Field | Type | Required | Notes |
|---|---|---:|---|
| `id` | `string` | Yes | Stable identifier used in telemetry/guards. |
| `enabled` | `boolean` | No | Per-rule switch. |
| `trigger.event` | `HookEvent` | Yes | Event name to match. |
| `trigger.outcome` | `"success"|"failed"|"timeout"|"unknown"` | No | Single outcome matcher. |
| `trigger.outcomes` | `Outcome[]` | No | Multi-outcome matcher. Use either `outcome` or `outcomes`. |
| `condition` | `object` | No | Optional extra matching constraints. |
| `cooldownMs` | `integer` | No | Overrides global cooldown for this rule. |
| `dedupWindowMs` | `integer` | No | Overrides global dedup for this rule. |
| `maxDepth` | `integer` | No | Per-rule depth cap. |
| `actions` | `HookChainAction[]` | Yes | One or more actions to execute in order. |
### Condition Fields
| Field | Type | Notes |
|---|---|---|
| `toolNames` | `string[]` | Matches `tool_name` / `toolName` in event payload. |
| `taskStatuses` | `string[]` | Matches `task_status` / `taskStatus` / `status`. |
| `errorIncludes` | `string[]` | Case-insensitive substring match against `error` / `reason` / `message`. |
| `eventFieldEquals` | `Record<string, string\|number\|boolean>` | Dot-path equality against payload (example: `"meta.source": "scheduler"`). |
### Actions
#### `spawn_fallback_agent`
```json
{
"type": "spawn_fallback_agent",
"id": "fallback-1",
"enabled": true,
"dedupWindowMs": 30000,
"description": "Fallback recovery for failed task",
"promptTemplate": "Recover task ${TASK_SUBJECT}. Event=${EVENT_NAME}, outcome=${OUTCOME}, error=${ERROR}. Payload=${PAYLOAD_JSON}",
"agentType": "general-purpose",
"model": "sonnet"
}
```
#### `notify_team`
```json
{
"type": "notify_team",
"id": "notify-ops",
"enabled": true,
"dedupWindowMs": 30000,
"teamName": "mesh-team",
"recipients": ["*"],
"summary": "Hook chain ${RULE_ID} fired",
"messageTemplate": "Event=${EVENT_NAME} outcome=${OUTCOME}\nTask=${TASK_ID}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
}
```
#### `warm_remote_capacity`
```json
{
"type": "warm_remote_capacity",
"id": "warm-bridge",
"enabled": true,
"dedupWindowMs": 60000,
"createDefaultEnvironmentIfMissing": false
}
```
## Complete Example Configs
### 1) Retry via Fallback Agent
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 30000,
"defaultDedupWindowMs": 30000,
"rules": [
{
"id": "retry-task-via-fallback",
"trigger": {
"event": "TaskCompleted",
"outcome": "failed"
},
"cooldownMs": 60000,
"actions": [
{
"type": "spawn_fallback_agent",
"id": "spawn-retry-agent",
"description": "Retry failed task with fallback agent",
"promptTemplate": "A task failed. Recover it safely.\nTask=${TASK_SUBJECT}\nDescription=${TASK_DESCRIPTION}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}",
"agentType": "general-purpose",
"model": "sonnet"
}
]
}
]
}
```
### 2) Notify Only
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 30000,
"defaultDedupWindowMs": 30000,
"rules": [
{
"id": "notify-on-tool-failure",
"trigger": {
"event": "PostToolUseFailure",
"outcome": "failed"
},
"condition": {
"toolNames": ["Edit", "Write", "Bash"]
},
"actions": [
{
"type": "notify_team",
"id": "notify-team-failure",
"recipients": ["*"],
"summary": "Tool failure detected",
"messageTemplate": "Tool failure detected.\nEvent=${EVENT_NAME} outcome=${OUTCOME}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
}
]
}
]
}
```
### 3) Combined Fallback + Notify + Bridge Warm
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 45000,
"defaultDedupWindowMs": 30000,
"rules": [
{
"id": "full-recovery-chain",
"trigger": {
"event": "TaskCompleted",
"outcomes": ["failed", "timeout"]
},
"condition": {
"errorIncludes": ["timeout", "capacity", "connection"]
},
"cooldownMs": 90000,
"actions": [
{
"type": "spawn_fallback_agent",
"id": "fallback-agent",
"description": "Recover failed task execution",
"promptTemplate": "Recover failed task and produce a concise fix summary.\nTask=${TASK_SUBJECT}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
},
{
"type": "notify_team",
"id": "notify-team",
"recipients": ["*"],
"summary": "Recovery chain triggered",
"messageTemplate": "Recovery chain ${RULE_ID} fired.\nOutcome=${OUTCOME}\nTask=${TASK_SUBJECT}\nError=${ERROR}"
},
{
"type": "warm_remote_capacity",
"id": "warm-capacity",
"createDefaultEnvironmentIfMissing": false
}
]
}
]
}
```
## Template Variables
The following placeholders are supported by `promptTemplate`, `summary`, and `messageTemplate`:
- `${EVENT_NAME}`
- `${OUTCOME}`
- `${RULE_ID}`
- `${TASK_SUBJECT}`
- `${TASK_DESCRIPTION}`
- `${TASK_ID}`
- `${ERROR}`
- `${PAYLOAD_JSON}`
## Troubleshooting
### Rule never triggers
- Verify `trigger.event` and `trigger.outcome`/`trigger.outcomes` exactly match dispatched event data.
- Check `condition` filters (especially `toolNames` and `eventFieldEquals` dot-path keys).
- Confirm the config file is valid JSON and schema-valid.
### Actions show as skipped
Common skip reasons:
- `action disabled`
- `rule cooldown active ...`
- `dedup window active ...`
- `max chain depth reached ...`
- `No team context is available ...`
- `Team file not found ...`
- `Remote sessions are blocked by policy`
- `Bridge is not active; warm_remote_capacity is a safe no-op`
- `No fallback agent launcher is registered in runtime context`
### Config changes not reflected
- Loader uses memoization by file mtime/size.
- Ensure your editor writes the file fully and updates mtime.
- If needed, force reload from the caller side with `forceReloadConfig: true`.
### Existing workflows changed unexpectedly
- Set `"enabled": false` at top-level.
- Or globally disable with `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0`.
- Re-enable gradually after validating one rule at a time.

View File

@@ -41,11 +41,13 @@ openclaude
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_API_KEY=sk-your-key-here
export OPENAI_BASE_URL=https://api.deepseek.com/v1
export OPENAI_MODEL=deepseek-chat
export OPENAI_MODEL=deepseek-v4-flash
openclaude
```
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` still work as DeepSeek's legacy API aliases.
### Option C: Ollama
Install Ollama first from:

View File

@@ -41,11 +41,13 @@ openclaude
$env:CLAUDE_CODE_USE_OPENAI="1"
$env:OPENAI_API_KEY="sk-your-key-here"
$env:OPENAI_BASE_URL="https://api.deepseek.com/v1"
$env:OPENAI_MODEL="deepseek-chat"
$env:OPENAI_MODEL="deepseek-v4-flash"
openclaude
```
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` still work as DeepSeek's legacy API aliases.
### Option C: Ollama
Install Ollama first from:

View File

@@ -1,7 +1,7 @@
{
"name": "@gitlawb/openclaude",
"version": "0.1.8",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"version": "0.7.0",
"description": "OpenClaude opens coding-agent workflows to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module",
"bin": {
"openclaude": "./bin/openclaude"
@@ -76,7 +76,7 @@
"@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.14.0",
"axios": "1.15.0",
"bidi-js": "1.0.3",
"chalk": "5.6.2",
"chokidar": "4.0.3",
@@ -140,7 +140,7 @@
},
"repository": {
"type": "git",
"url": "https://gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude"
"url": "https://github.com/Gitlawb/openclaude.git"
},
"keywords": [
"claude-code",

3
python/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
pytest==7.4.4
pytest-asyncio==0.23.3
httpx==0.25.2

View File

@@ -112,6 +112,14 @@ def build_default_providers() -> list[Provider]:
big_model=big if "gemini" in big else "gemini-2.5-pro",
small_model=small if "gemini" in small else "gemini-2.0-flash",
),
Provider(
name="mistral",
ping_url="",
api_key_env="MISTRAL_API_KEY",
cost_per_1k_tokens=0.0001,
big_model=big if "mistral" in big else "devstral-latest",
small_model=small if "small" in small else "ministral-3b-latest",
),
Provider(
name="ollama",
ping_url=f"{ollama_url}/api/tags",

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"package-name": "@gitlawb/openclaude",
"bump-minor-pre-major": true,
"include-v-in-tag": true
}
}
}

View File

@@ -8,7 +8,8 @@
* - src/ path aliases
*/
import { readFileSync } from 'fs'
import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { noTelemetryPlugin } from './no-telemetry-plugin'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -18,31 +19,106 @@ const version = pkg.version
// Most Anthropic-internal features stay off; open-build features can be
// selectively enabled here when their full source exists in the mirror.
const featureFlags: Record<string, boolean> = {
VOICE_MODE: false,
PROACTIVE: false,
KAIROS: false,
BRIDGE_MODE: false,
DAEMON: false,
AGENT_TRIGGERS: false,
MONITOR_TOOL: false,
ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false,
CACHED_MICROCOMPACT: false,
COORDINATOR_MODE: false,
CONTEXT_COLLAPSE: false,
COMMIT_ATTRIBUTION: false,
TEAMMEM: false,
UDS_INBOX: false,
BG_SESSIONS: false,
AWAY_SUMMARY: false,
TRANSCRIPT_CLASSIFIER: false,
WEB_BROWSER_TOOL: false,
MESSAGE_ACTIONS: false,
BUDDY: true,
CHICAGO_MCP: false,
COWORKER_TYPE_TELEMETRY: false,
// ── Disabled: require Anthropic infrastructure or missing source ─────
VOICE_MODE: false, // Push-to-talk STT via claude.ai OAuth endpoint
PROACTIVE: false, // Autonomous agent mode (missing proactive/ module)
KAIROS: false, // Persistent assistant/session mode (cloud backend)
BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure
DAEMON: false, // Background daemon process (stubbed in open build)
AGENT_TRIGGERS: false, // Scheduled remote agent triggers
ABLATION_BASELINE: false, // A/B testing harness for eval experiments
CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed)
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
CHICAGO_MCP: false, // Computer-use MCP (native Swift modules stubbed)
COWORKER_TYPE_TELEMETRY: false, // Telemetry for agent/coworker type classification
MCP_SKILLS: false, // Dynamic MCP skill discovery (src/skills/mcpSkills.ts not mirrored; enabling this causes "fetchMcpSkillsForClient is not a function" when MCP servers with resources connect — see #856)
// ── Enabled: upstream defaults ──────────────────────────────────────
COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation
BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents
BUDDY: true, // Buddy mode for paired programming
MONITOR_TOOL: true, // MCP server monitoring/streaming tool
TEAMMEM: true, // Team memory management
MESSAGE_ACTIONS: true, // Message action buttons in the UI
// ── Enabled: new activations ────────────────────────────────────────
DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging
CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization
AWAY_SUMMARY: true, // "While you were away" recap after 5min blur
TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses
ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning
TOKEN_BUDGET: true, // Token budget tracking with usage warnings
HISTORY_PICKER: true, // Enhanced interactive prompt history picker
QUICK_SEARCH: true, // Ctrl+G quick search across prompts
SHOT_STATS: true, // Shot distribution stats in session summary
EXTRACT_MEMORIES: true, // Auto-extract durable memories from conversations
FORK_SUBAGENT: true, // Implicit context-forking when omitting subagent_type
VERIFICATION_AGENT: true, // Built-in read-only agent for test/verification
PROMPT_CACHE_BREAK_DETECTION: true, // Detect & log unexpected prompt cache invalidations
HOOK_PROMPTS: true, // Allow tools to request interactive user prompts
}
// ── Pre-process: replace feature() calls with boolean literals ──────
// Bun v1.3.9+ resolves `import { feature } from 'bun:bundle'` natively
// before plugins can intercept it via onResolve. The bun: namespace is
// handled by Bun's C++ resolver which runs before the JS plugin phase,
// so the previous onResolve/onLoad shim was silently ineffective — ALL
// feature() calls evaluated to false regardless of the featureFlags map.
//
// Fix: pre-process source files to strip the bun:bundle import and
// replace feature('FLAG') calls with their boolean literal. Files are
// modified in-place before Bun.build() and restored in a finally block.
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
const modifiedFiles = new Map<string, string>() // path → original content
function preProcessFeatureFlags(dir: string) {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, ent.name)
if (ent.isDirectory()) { preProcessFeatureFlags(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const raw = readFileSync(full, 'utf-8')
if (!raw.includes('feature(')) continue
let contents = raw
contents = contents.replace(featureImportRe, '')
contents = contents.replace(featureCallRe, (_match, name) =>
String((featureFlags as Record<string, boolean>)[name] ?? false),
)
if (contents !== raw) {
modifiedFiles.set(full, raw)
writeFileSync(full, contents)
}
}
}
function restoreModifiedFiles() {
for (const [path, original] of modifiedFiles) {
writeFileSync(path, original)
}
modifiedFiles.clear()
}
preProcessFeatureFlags(join(import.meta.dir, '..', 'src'))
const numModified = modifiedFiles.size
// Restore source files on abrupt termination (Ctrl+C, kill, etc.)
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
restoreModifiedFiles()
process.exit(signal === 'SIGINT' ? 130 : 143)
})
}
try {
const result = await Bun.build({
entrypoints: ['./src/entrypoints/cli.tsx'],
outdir: './dist',
@@ -103,18 +179,11 @@ export async function handleBgFlag() { throw new Error("Background sessions are
],
] as const)
// Resolve `import { feature } from 'bun:bundle'` to a shim
build.onResolve({ filter: /^bun:bundle$/ }, () => ({
path: 'bun:bundle',
namespace: 'bun-bundle-shim',
}))
build.onLoad(
{ filter: /.*/, namespace: 'bun-bundle-shim' },
() => ({
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
loader: 'js',
}),
)
// bun:bundle feature() replacement is handled by the source
// pre-processing step above (see preProcessFeatureFlags).
// The previous onResolve/onLoad shim was ineffective in Bun
// v1.3.9+ because the bun: namespace is resolved natively
// before the JS plugin phase runs.
build.onResolve(
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
@@ -274,16 +343,7 @@ export const SeverityNumber = {};
// Scan source to find imports that can't resolve
function scanForMissingImports() {
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const code: string = fs.readFileSync(full, 'utf-8')
// Collect all imports
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
const specifier = m[4]
const namedPart = m[1] || m[3] || ''
function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
const names = namedPart.split(',')
.map((s: string) => s.trim().replace(/^type\s+/, ''))
.filter((s: string) => s && !s.startsWith('type '))
@@ -303,8 +363,7 @@ export const SeverityNumber = {};
}
// Check relative .js imports
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
const dir2 = pathMod.dirname(full)
const resolved = pathMod.resolve(dir2, specifier)
const resolved = pathMod.resolve(fileDir, specifier)
const tsVariant = resolved.replace(/\.js$/, '.ts')
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
@@ -317,6 +376,38 @@ export const SeverityNumber = {};
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
for (const n of names) missingModuleExports.get(specifier)!.add(n)
}
}
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const rawCode: string = fs.readFileSync(full, 'utf-8')
const fileDir = pathMod.dirname(full)
// Strip comments before scanning for imports/requires.
// The regex scanner matches require()/import() patterns
// inside JSDoc comments, causing false-positive missing
// module detection that breaks the build with noop stubs.
const code = rawCode
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/.*$/gm, '') // line comments
// Collect static imports: import { X } from '...'
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
}
// Collect dynamic requires: require('...') — these are used
// behind feature() gates and become live when flags are enabled.
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
// Collect dynamic imports: import('...')
for (const m of code.matchAll(/import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
}
}
@@ -389,7 +480,13 @@ if (!result.success) {
for (const log of result.logs) {
console.error(log)
}
process.exit(1)
process.exitCode = 1
} else {
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
}
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
} finally {
// Always restore source files, even if Bun.build() throws
restoreModifiedFiles()
console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`)
}

View File

@@ -0,0 +1,47 @@
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { expect, test } from 'bun:test'
// Regression guard for #856. Several build feature flags require source files
// that are not mirrored into the open build. When such a flag is set to `true`
// without the source present, the bundler falls back to a missing-module stub
// that only exports `default`, which causes runtime errors like
// `fetchMcpSkillsForClient is not a function` when downstream code reaches
// through the `require()` to a named export.
//
// This test fails fast at test-time if someone re-enables one of these flags
// without first mirroring the corresponding source file.
const BUILD_SCRIPT = join(import.meta.dir, 'build.ts')
const REPO_ROOT = join(import.meta.dir, '..')
type FlagGuard = {
flag: string
source: string // path relative to repo root
}
const FLAG_REQUIRES_SOURCE: FlagGuard[] = [
{ flag: 'MCP_SKILLS', source: 'src/skills/mcpSkills.ts' },
]
test('build feature flags are not enabled without their source files', () => {
const buildScript = readFileSync(BUILD_SCRIPT, 'utf-8')
for (const { flag, source } of FLAG_REQUIRES_SOURCE) {
const enabledRe = new RegExp(`^\\s*${flag}\\s*:\\s*true\\b`, 'm')
const isEnabled = enabledRe.test(buildScript)
const sourceExists = existsSync(join(REPO_ROOT, source))
if (isEnabled && !sourceExists) {
throw new Error(
`Feature flag ${flag} is enabled in scripts/build.ts, but its required source file "${source}" does not exist. ` +
`Enabling this flag without the source will cause runtime errors (missing named exports from the missing-module stub). ` +
`Either mirror the source file or set ${flag}: false.`,
)
}
// When the source IS present, the flag can be either true or false; either
// is fine. We only care about the "enabled but missing" combination.
expect(true).toBe(true)
}
})

View File

@@ -0,0 +1,163 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
// ---------------------------------------------------------------------------
// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to
// a temp .mjs file, and dynamically import it so we can test the real code
// that gets bundled.
// ---------------------------------------------------------------------------
const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8')
const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/)
if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts')
const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`)
const stubFile = join(testDir, 'growthbook-stub.mjs')
const flagsFile = join(testDir, 'test-flags.json')
mkdirSync(testDir, { recursive: true })
writeFileSync(stubFile, stubMatch[1])
// Point the stub at our test flags file (checked by _loadFlags on first access)
process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile
const stub = await import(stubFile)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('growthbook stub — local feature flag overrides', () => {
beforeEach(() => {
stub.resetGrowthBook()
try { unlinkSync(flagsFile) } catch { /* may not exist */ }
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
delete process.env.CLAUDE_FEATURE_FLAGS_FILE
})
// ── File absent ──────────────────────────────────────────────────
test('returns defaultValue when flags file is absent', () => {
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 42)).toBe(42)
})
test('getAllGrowthBookFeatures returns {} when file is absent', () => {
expect(stub.getAllGrowthBookFeatures()).toEqual({})
})
// ── Open-build defaults (_openBuildDefaults) ────────────────────
test('returns open-build default when flags file is absent', () => {
// tengu_passport_quail is in _openBuildDefaults as true; without a
// flags file the stub should return the open-build override, not
// the call-site defaultValue.
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)).toBe(true)
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_coral_fern', false)).toBe(true)
})
test('flags file overrides open-build defaults', () => {
// User-provided feature-flags.json takes priority over _openBuildDefaults.
writeFileSync(flagsFile, JSON.stringify({ tengu_passport_quail: false }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', true)).toBe(false)
})
// ── Valid JSON object ────────────────────────────────────────────
test('loads and returns values from a valid JSON file', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true, tengu_bar: 'hello' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', false)).toBe(true)
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_bar', 'default')).toBe('hello')
})
test('returns defaultValue for keys not present in the file', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_missing', 99)).toBe(99)
})
test('getAllGrowthBookFeatures returns the full flags object', () => {
const flags = { tengu_a: true, tengu_b: false, tengu_c: 42 }
writeFileSync(flagsFile, JSON.stringify(flags))
expect(stub.getAllGrowthBookFeatures()).toEqual(flags)
})
// ── Malformed / non-object JSON ──────────────────────────────────
test('falls back to defaults on malformed JSON', () => {
writeFileSync(flagsFile, '{not valid json!!!')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
test('falls back to defaults when JSON is a primitive (true)', () => {
writeFileSync(flagsFile, 'true')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
test('falls back to defaults when JSON is an array', () => {
writeFileSync(flagsFile, '["a", "b"]')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
// ── Cache invalidation ───────────────────────────────────────────
test('resetGrowthBook clears cache so the file is re-read', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'first' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
// Update the file — cached value is still 'first'
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'second' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
// After reset, the new value is picked up
stub.resetGrowthBook()
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second')
})
test('refreshGrowthBookFeatures clears cache', async () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1')
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' }))
await stub.refreshGrowthBookFeatures()
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2')
})
// ── Multiple getter variants ─────────────────────────────────────
test('all getter functions read from local flags', async () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } }))
expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true)
stub.resetGrowthBook()
expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true)
stub.resetGrowthBook()
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true)
stub.resetGrowthBook()
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true)
stub.resetGrowthBook()
expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 })
stub.resetGrowthBook()
expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 })
})
// ── Security gate ────────────────────────────────────────────────
test('checkSecurityRestrictionGate always returns false regardless of flags', async () => {
writeFileSync(flagsFile, JSON.stringify({
tengu_disable_bypass_permissions_mode: true,
}))
expect(await stub.checkSecurityRestrictionGate()).toBe(false)
})
})

View File

@@ -34,28 +34,201 @@ export function _resetForTesting() {}
`,
'services/analytics/growthbook': `
import _fs from 'node:fs';
import _path from 'node:path';
import _os from 'node:os';
let _flags = undefined;
// ── Open-build GrowthBook overrides ───────────────────────────────────
// Override upstream defaultValue for runtime gates tied to build-time
// features. Only keys that DIFFER from upstream belong here — the
// catalog below is pure documentation and does NOT affect resolution.
//
// Priority: ~/.claude/feature-flags.json > _openBuildDefaults > defaultValue
//
// To override at runtime, create ~/.claude/feature-flags.json:
// { "tengu_some_flag": true }
const _openBuildDefaults = {
'tengu_sedge_lantern': true, // AWAY_SUMMARY — "while you were away" recap (upstream: false)
'tengu_hive_evidence': true, // VERIFICATION_AGENT — read-only test/verification agent (upstream: false)
'tengu_passport_quail': true, // EXTRACT_MEMORIES — enable memory extraction (upstream: false)
'tengu_coral_fern': true, // EXTRACT_MEMORIES — enable memory search in past context (upstream: false)
};
/* ── Known runtime feature keys (reference only) ───────────────────────
* This catalog does NOT participate in flag resolution. It documents
* the known GrowthBook keys and their upstream default values, scraped
* from src/ call sites. It is NOT exhaustive — new keys may be added
* upstream between catalog updates.
*
* Some keys have different defaults at different call sites — this is
* intentional upstream (the server unifies the value at runtime).
*
* To activate any of these, add them to ~/.claude/feature-flags.json
* or to _openBuildDefaults above.
*
* ── Reasoning & thinking ──────────────────────────────────────────────
* tengu_turtle_carbon = true ULTRATHINK deep thinking runtime gate
* tengu_thinkback = gate /thinkback replay command
*
* ── Agents & orchestration ────────────────────────────────────────────
* tengu_amber_flint = true Agent swarms coordination
* tengu_amber_stoat = true Built-in agent availability (Explore, Plan, etc.)
* tengu_agent_list_attach = true Attach file context to agent list
* tengu_auto_background_agents = false Auto-spawn background agents
* tengu_slim_subagent_claudemd = true Lighter ClaudeMD for subagents
* tengu_hive_evidence = false Verification agent / evidence tracking (4 call sites)
* tengu_ultraplan_model = model cfg ULTRAPLAN model selection (dynamic config)
*
* ── Memory & context ──────────────────────────────────────────────────
* tengu_passport_quail = false EXTRACT_MEMORIES main gate (isExtractModeActive)
* tengu_coral_fern = false EXTRACT_MEMORIES search in past context
* tengu_slate_thimble = false Memory dir paths (non-interactive sessions)
* tengu_herring_clock = true/false Team memory paths (varies by call site)
* tengu_bramble_lintel = null Extract memories throttle (null → every turn)
* tengu_sedge_lantern = false AWAY_SUMMARY "while you were away" recap
* tengu_session_memory = false Session memory service
* tengu_sm_config = {} Session memory config (dynamic)
* tengu_sm_compact_config = {} Session memory compaction config (dynamic)
* tengu_cobalt_raccoon = false Reactive compaction (suppress auto-compact)
* tengu_pebble_leaf_prune = false Session storage pruning
*
* ── Kairos & cron ─────────────────────────────────────────────────────
* tengu_kairos_brief = false Brief layout mode (KAIROS)
* tengu_kairos_brief_config = {} Brief config (dynamic)
* tengu_kairos_cron = true Cron scheduler enable
* tengu_kairos_cron_durable = true Durable (disk-persistent) cron tasks
* tengu_kairos_cron_config = {} Cron jitter config (dynamic)
*
* ── Bridge & remote (require Anthropic infra) ─────────────────────────
* tengu_ccr_bridge = false CCR bridge connection
* tengu_ccr_bridge_multi_session = gate Multi-session spawn mode
* tengu_ccr_mirror = false CCR session mirroring
* tengu_ccr_bundle_seed_enabled = gate Git bundle seeding for CCR
* tengu_ccr_bundle_max_bytes = null Bundle size limit (null → default)
* tengu_bridge_repl_v2 = false Environment-less REPL bridge v2
* tengu_bridge_repl_v2_cse_shim_enabled = true CSE→Session tag retag shim
* tengu_bridge_min_version = {min:'0'} Min CLI version for bridge (dynamic)
* tengu_bridge_initial_history_cap = 200 Initial history cap for bridge
* tengu_bridge_system_init = false Bridge system initialization
* tengu_cobalt_harbor = false Auto-connect CCR at startup
* tengu_cobalt_lantern = false Remote setup preconditions
* tengu_remote_backend = false Remote TUI backend
* tengu_surreal_dali = false Remote agent tasks / triggers
*
* ── Prompt & API ──────────────────────────────────────────────────────
* tengu_attribution_header = true Attribution header in API requests
* tengu_basalt_3kr = true MCP instructions delta
* tengu_slate_prism = true/false Message formatting (varies by call site)
* tengu_amber_prism = false Message content formatting
* tengu_amber_json_tools = false JSON format for tool schemas
* tengu_fgts = false API feature gates
* tengu_otk_slot_v1 = false One-time key slots for API auth
* tengu_cicada_nap_ms = 0 Background GrowthBook refresh throttle (ms)
* tengu_miraculo_the_bard = false Service initialization gate
* tengu_immediate_model_command = false Immediate /model command execution
* tengu_chomp_inflection = false Prompt suggestions after responses
* tengu_tool_pear = gate API betas for tool use
* tengu-off-switch = {act:false} Service kill switch (dynamic; uses dash)
*
* ── Permissions & security ────────────────────────────────────────────
* tengu_birch_trellis = true Bash auto-mode permissions config
* tengu_auto_mode_config = {} Auto-mode configuration (dynamic, many call sites)
* tengu_iron_gate_closed = true Permission iron gate (with refresh)
* tengu_destructive_command_warning = false Warning for destructive bash commands
* tengu_disable_bypass_permissions_mode = security Security killswitch (always false in open build)
*
* ── UI & UX ───────────────────────────────────────────────────────────
* tengu_willow_mode = 'off' REPL rendering mode
* tengu_terminal_panel = false Terminal panel keybinding
* tengu_terminal_sidebar = false Terminal sidebar in REPL/config
* tengu_marble_sandcastle = false Fast mode gate
* tengu_jade_anvil_4 = false Rate limit options UI ordering
* tengu_collage_kaleidoscope = true Native clipboard image paste (macOS)
* tengu_lapis_finch = false Plugin/hint recommendation
* tengu_lodestone_enabled = false Deep links claude-cli:// protocol
* tengu_copper_panda = false Skill improvement suggestions
* tengu_desktop_upsell = {} Desktop app upsell config (dynamic)
* tengu-top-of-feed-tip = {} Emergency tip of feed (dynamic; uses dash)
*
* ── File operations ───────────────────────────────────────────────────
* tengu_quartz_lantern = false File read/write dedup optimization
* tengu_moth_copse = false Attachments handling (variant A)
* tengu_marble_fox = false Attachments handling (variant B)
* tengu_scratch = gate Scratchpad filesystem access / coordinator
*
* ── MCP & plugins ─────────────────────────────────────────────────────
* tengu_harbor = false MCP channel allowlist verification
* tengu_harbor_permissions = false MCP channel permissions enforcement
* tengu_copper_bridge = false Chrome MCP bridge
* tengu_chrome_auto_enable = false Auto-enable Chrome MCP on startup
* tengu_glacier_2xr = false Enhanced tool search / ToolSearchTool
* tengu_malort_pedway = {} Computer-use (Chicago) config (dynamic)
*
* ── VSCode / IDE ──────────────────────────────────────────────────────
* tengu_quiet_fern = false VSCode browser support
* tengu_vscode_cc_auth = false VSCode in-band OAuth via claude_authenticate
* tengu_vscode_review_upsell = gate VSCode review upsell
* tengu_vscode_onboarding = gate VSCode onboarding experience
*
* ── Voice ─────────────────────────────────────────────────────────────
* tengu_amber_quartz_disabled = false VOICE_MODE kill-switch (false = voice allowed)
*
* ── Auto-updater (stubbed in open build) ──────────────────────────────
* tengu_version_config = {min:'0'} Min version enforcement (dynamic)
* tengu_max_version_config = {} Max version / deprecation config (dynamic)
*
* ── Telemetry & tracing ───────────────────────────────────────────────
* tengu_trace_lantern = false Beta session tracing
* tengu_chair_sermon = gate Analytics / message formatting gate
* tengu_strap_foyer = false Settings sync to cloud
*/
function _loadFlags() {
if (_flags !== undefined) return;
try {
const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE
|| _path.join(_os.homedir(), '.claude', 'feature-flags.json');
const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8'));
_flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
} catch {
_flags = null;
}
}
function _getFlagValue(key, defaultValue) {
_loadFlags();
if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key];
if (Object.hasOwn(_openBuildDefaults, key)) return _openBuildDefaults[key];
return defaultValue;
}
const noop = () => {};
export function onGrowthBookRefresh() { return noop; }
export function hasGrowthBookEnvOverride() { return false; }
export function getAllGrowthBookFeatures() { return {}; }
export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
export function getGrowthBookConfigOverrides() { return {}; }
export function setGrowthBookConfigOverride() {}
export function clearGrowthBookConfigOverrides() {}
export function getApiBaseUrlHost() { return undefined; }
export const initializeGrowthBook = async () => null;
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; }
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; }
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; }
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; }
export async function checkSecurityRestrictionGate() { return false; }
export async function checkGate_CACHED_OR_BLOCKING() { return false; }
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); }
// Security killswitch — always false in the open build. Anthropic uses this
// gate to remotely disable bypassPermissions mode; exposing it via local flags
// would let users accidentally lock themselves out of --dangerously-skip-permissions.
export async function checkSecurityRestrictionGate(gate) { return false; }
export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); }
export function refreshGrowthBookAfterAuthChange() {}
export function resetGrowthBook() {}
export async function refreshGrowthBookFeatures() {}
export function resetGrowthBook() { _flags = undefined; }
export async function refreshGrowthBookFeatures() { _flags = undefined; }
export function setupPeriodicGrowthBookRefresh() {}
export function stopPeriodicGrowthBookRefresh() {}
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; }
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; }
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
`,
'services/analytics/sink': `

View File

@@ -11,6 +11,7 @@ import {
buildAtomicChatProfileEnv,
buildCodexProfileEnv,
buildGeminiProfileEnv,
buildMistralProfileEnv,
buildOllamaProfileEnv,
buildOpenAIProfileEnv,
createProfileFile,
@@ -37,7 +38,7 @@ function parseArg(name: string): string | null {
function parseProviderArg(): ProviderProfile | 'auto' {
const p = parseArg('--provider')?.toLowerCase()
if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'atomic-chat') return p
if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'mistral' || p === 'atomic-chat') return p
return 'auto'
}
@@ -90,6 +91,21 @@ async function main(): Promise<void> {
process.exit(1)
}
env = builtEnv
} else if (selected === 'mistral') {
const builtEnv = buildMistralProfileEnv({
model: argModel || null,
baseUrl: argBaseUrl || null,
apiKey: argApiKey || null,
processEnv: process.env,
})
if (!builtEnv) {
console.error('Mistral profile requires an API key. Use --api-key or set MISTRAL_API_KEY.')
console.error('Get a free key at: https://admin.mistral.ai/organization/api-keys')
process.exit(1)
}
env = builtEnv
} else if (selected === 'ollama') {
resolvedOllamaModel ??= await resolveOllamaModel(argModel, argBaseUrl, goal)
@@ -169,7 +185,7 @@ async function main(): Promise<void> {
console.log(`Saved profile: ${selected}`)
console.log(`Goal: ${goal}`)
console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`)
console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.MISTRAL_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`)
console.log(`Path: ${outputPath}`)
console.log('Next: bun run dev:profile')
}

View File

@@ -50,7 +50,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions {
continue
}
if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower === 'atomic-chat') && requestedProfile === 'auto') {
if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower ==='mistral' || lower === 'atomic-chat') && requestedProfile === 'auto') {
requestedProfile = lower as ProviderProfile | 'auto'
continue
}
@@ -124,6 +124,8 @@ function printSummary(profile: ProviderProfile): void {
console.log(`Launching profile: ${profile}`)
if (profile === 'gemini') {
console.log('Using configured Gemini provider settings.')
} else if (profile === 'mistral') {
console.log('Using configured Mistral provider settings.')
} else if (profile === 'codex') {
console.log('Using configured Codex/OpenAI-compatible provider settings.')
} else if (profile === 'atomic-chat') {
@@ -139,7 +141,7 @@ async function main(): Promise<void> {
const options = parseLaunchOptions(process.argv.slice(2))
const requestedProfile = options.requestedProfile
if (!requestedProfile) {
console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|atomic-chat|auto] [--fast] [--goal <latency|balanced|coding>] [-- <cli args>]')
console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|mistral|atomic-chat|mistral|auto] [--fast] [--goal <latency|balanced|coding>] [-- <cli args>]')
process.exit(1)
}
@@ -205,6 +207,11 @@ async function main(): Promise<void> {
process.exit(1)
}
if (profile === 'mistral' && !env.MISTRAL_API_KEY) {
console.error('MISTRAL_API_KEY is required for mistral profile. Run: bun run profile:init -- --provider mistral --api-key <key>')
process.exit(1)
}
if (profile === 'openai' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) {
console.error('OPENAI_API_KEY is required for openai profile and cannot be SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key <key>')
process.exit(1)

View File

@@ -20,6 +20,23 @@ describe('formatReachabilityFailureDetail', () => {
)
})
test('redacts credentials and sensitive query parameters in endpoint details', () => {
const detail = formatReachabilityFailureDetail(
'http://user:pass@localhost:11434/v1/models?token=abc123&mode=test',
502,
'bad gateway',
{
transport: 'chat_completions',
requestedModel: 'llama3.1:8b',
resolvedModel: 'llama3.1:8b',
},
)
expect(detail).toBe(
'Unexpected status 502 from http://redacted:redacted@localhost:11434/v1/models?token=redacted&mode=test. Body: bad gateway',
)
})
test('adds alias/entitlement hint for codex model support 400s', () => {
const detail = formatReachabilityFailureDetail(
'https://chatgpt.com/backend-api/codex/responses',

View File

@@ -7,6 +7,11 @@ import {
resolveProviderRequest,
isLocalProviderUrl as isProviderLocalUrl,
} from '../src/services/api/providerConfig.js'
import {
getLocalOpenAICompatibleProviderLabel,
probeOllamaGenerationReadiness,
} from '../src/utils/providerDiscovery.js'
import { redactUrlForDisplay } from '../src/utils/urlRedaction.js'
type CheckResult = {
ok: boolean
@@ -69,7 +74,7 @@ export function formatReachabilityFailureDetail(
},
): string {
const compactBody = responseBody.trim().replace(/\s+/g, ' ').slice(0, 240)
const base = `Unexpected status ${status} from ${endpoint}.`
const base = `Unexpected status ${status} from ${redactUrlForDisplay(endpoint)}.`
const bodySuffix = compactBody ? ` Body: ${compactBody}` : ''
if (request.transport !== 'codex_responses' || status !== 400) {
@@ -118,14 +123,18 @@ function isLocalBaseUrl(baseUrl: string): boolean {
}
const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
const MISTRAL_DEFAULT_BASE_URL = 'https://api.mistral.ai/v1'
const GITHUB_COPILOT_BASE = 'https://api.githubcopilot.com'
function currentBaseUrl(): string {
if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL
}
if (isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)) {
return process.env.MISTRAL_BASE_URL ?? MISTRAL_DEFAULT_BASE_URL
}
if (isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
return process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE
return process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE
}
return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'
}
@@ -155,9 +164,34 @@ function checkGeminiEnv(): CheckResult[] {
return results
}
function checkMistralEnv(): CheckResult[] {
const results: CheckResult[] = []
const model = process.env.MISTRAL_MODEL
const key = process.env.MISTRAL_API_KEY
const baseUrl = process.env.MISTRAL_BASE_URL ?? MISTRAL_DEFAULT_BASE_URL
results.push(pass('Provider mode', 'Mistral provider enabled.'))
if (!model) {
results.push(pass('MISTRAL_MODEL', 'Not set. Default will be used at runtime.'))
} else {
results.push(pass('MISTRAL_MODEL', model))
}
results.push(pass('MISTRAL_BASE_URL', baseUrl))
if (!key) {
results.push(fail('MISTRAL_API_KEY', 'Missing. Set MISTRAL_API_KEY.'))
} else {
results.push(pass('MISTRAL_API_KEY', 'Configured.'))
}
return results
}
function checkGithubEnv(): CheckResult[] {
const results: CheckResult[] = []
const baseUrl = process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE
const baseUrl = process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE
results.push(pass('Provider mode', 'GitHub Models provider enabled.'))
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
@@ -186,12 +220,17 @@ function checkOpenAIEnv(): CheckResult[] {
const results: CheckResult[] = []
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const useMistral = isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
if (useGemini) {
return checkGeminiEnv()
}
if (useMistral) {
return checkMistralEnv()
}
if (useGithub && !useOpenAI) {
return checkGithubEnv()
}
@@ -221,7 +260,7 @@ function checkOpenAIEnv(): CheckResult[] {
results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL))
}
results.push(pass('OPENAI_BASE_URL', request.baseUrl))
results.push(pass('OPENAI_BASE_URL', redactUrlForDisplay(request.baseUrl)))
if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(process.env)
@@ -268,12 +307,13 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const useMistral = isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
if (!useGemini && !useOpenAI && !useGithub) {
if (!useGemini && !useOpenAI && !useGithub && !useMistral) {
return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).')
}
if (useGithub) {
if (useGithub && !useOpenAI) {
return pass(
'Provider reachability',
'Skipped for GitHub Models (inference endpoint differs from OpenAI /models probe).',
@@ -291,6 +331,7 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
const endpoint = request.transport === 'codex_responses'
? `${request.baseUrl}/responses`
: `${request.baseUrl}/models`
const redactedEndpoint = redactUrlForDisplay(endpoint)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 4000)
@@ -326,6 +367,8 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
})
} else if (useGemini && (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY)) {
headers.Authorization = `Bearer ${process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY}`
} else if (useMistral && process.env.MISTRAL_API_KEY) {
headers.Authorization = `Bearer ${process.env.MISTRAL_API_KEY}`
} else if (process.env.OPENAI_API_KEY) {
headers.Authorization = `Bearer ${process.env.OPENAI_API_KEY}`
}
@@ -338,7 +381,10 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
})
if (response.status === 200 || response.status === 401 || response.status === 403) {
return pass('Provider reachability', `Reached ${endpoint} (status ${response.status}).`)
return pass(
'Provider reachability',
`Reached ${redactedEndpoint} (status ${response.status}).`,
)
}
const responseBody = await response.text().catch(() => '')
@@ -354,12 +400,100 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return fail('Provider reachability', `Failed to reach ${endpoint}: ${message}`)
return fail(
'Provider reachability',
`Failed to reach ${redactedEndpoint}: ${message}`,
)
} finally {
clearTimeout(timeout)
}
}
async function checkProviderGenerationReadiness(): Promise<CheckResult> {
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const useMistral = isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
if (!useGemini && !useOpenAI && !useGithub && !useMistral) {
return pass('Provider generation readiness', 'Skipped (OpenAI-compatible mode disabled).')
}
if (useGithub && !useOpenAI) {
return pass(
'Provider generation readiness',
'Skipped for GitHub Models (runtime generation uses a different endpoint flow).',
)
}
if (useGemini || useMistral) {
return pass(
'Provider generation readiness',
'Skipped for managed provider mode.',
)
}
if (!useOpenAI) {
return pass('Provider generation readiness', 'Skipped (OpenAI-compatible mode disabled).')
}
const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL,
})
if (request.transport === 'codex_responses') {
return pass(
'Provider generation readiness',
'Skipped for Codex responses (reachability probe already performs a lightweight generation request).',
)
}
if (!isLocalBaseUrl(request.baseUrl)) {
return pass('Provider generation readiness', 'Skipped for non-local provider URL.')
}
const localProviderLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl)
if (localProviderLabel !== 'Ollama') {
return pass(
'Provider generation readiness',
`Skipped for ${localProviderLabel} (no provider-specific generation probe).`,
)
}
const readiness = await probeOllamaGenerationReadiness({
baseUrl: request.baseUrl,
model: request.requestedModel,
})
if (readiness.state === 'ready') {
return pass(
'Provider generation readiness',
`Generated a test response with ${readiness.probeModel ?? request.requestedModel}.`,
)
}
if (readiness.state === 'unreachable') {
return fail(
'Provider generation readiness',
`Could not reach Ollama at ${redactUrlForDisplay(request.baseUrl)}.`,
)
}
if (readiness.state === 'no_models') {
return fail(
'Provider generation readiness',
'Ollama is reachable, but no installed models were found. Pull a model first (for example: ollama pull qwen2.5-coder:7b).',
)
}
const detailSuffix = readiness.detail ? ` Detail: ${readiness.detail}.` : ''
return fail(
'Provider generation readiness',
`Ollama is reachable, but generation failed for ${readiness.probeModel ?? request.requestedModel}.${detailSuffix}`,
)
}
function isAtomicChatUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl)
@@ -373,7 +507,8 @@ function checkOllamaProcessorMode(): CheckResult {
if (
!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) ||
isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
) {
return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).')
}
@@ -425,6 +560,14 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY),
}
}
if (isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)) {
return {
CLAUDE_CODE_USE_MISTRAL: true,
MISTRAL_MODEL: process.env.MISTRAL_MODEL ?? '(unset, default: devstral-latest)',
MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL ?? 'https://api.mistral.ai/v1',
MISTRAL_API_KEY_SET: Boolean(process.env.MISTRAL_API_KEY),
}
}
if (
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) &&
!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
@@ -435,7 +578,7 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
process.env.OPENAI_MODEL ??
'(unset, default: github:copilot → openai/gpt-4.1)',
OPENAI_BASE_URL:
process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE,
process.env.OPENAI_BASE_URL ?? GITHUB_COPILOT_BASE,
GITHUB_TOKEN_SET: Boolean(
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
),
@@ -521,6 +664,7 @@ async function main(): Promise<void> {
results.push(checkBuildArtifacts())
results.push(...checkOpenAIEnv())
results.push(await checkBaseUrlReachability())
results.push(await checkProviderGenerationReadiness())
results.push(checkOllamaProcessorMode())
if (!options.json) {

View File

@@ -249,6 +249,11 @@ export type ToolUseContext = {
/** When true, canUseTool must always be called even when hooks auto-approve.
* Used by speculation for overlay file path rewriting. */
requireCanUseTool?: boolean
/**
* Optional callback used by hook-chain fallback actions that launch
* AgentTool from hook runtime paths.
*/
hookChainsCanUseTool?: CanUseToolFn
messages: Message[]
fileReadingLimits?: {
maxTokens?: number

View File

@@ -0,0 +1,290 @@
/**
* Tests for Bug Fixes applied to openclaude.
*
* Covers:
* 1. Gemini `store: false` rejection fix
* 2. Session timeout / 500 error fix (stream idle timeout)
* 3. Agent loop continuation nudge
* 4. Web search result count improvements
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..')
const file = (relative: string) => Bun.file(resolve(SRC, relative))
// ---------------------------------------------------------------------------
// Fix 1: Gemini `store: false` rejection
// ---------------------------------------------------------------------------
describe('Gemini store field fix', () => {
test('isGeminiMode is imported and used in openaiShim', async () => {
const content = await file('services/api/openaiShim.ts').text()
// Verify the fix: store deletion should check for Gemini mode
expect(content).toContain('isGeminiMode()')
expect(content).toContain("mistral and gemini don't recognize body.store")
// Ensure the delete body.store is guarded for both Mistral and Gemini
expect(content).toMatch(/isMistral\s*\|\|\s*isGeminiMode\(\)/)
})
test('store: false is still set by default (OpenAI needs it)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// The body should still have store: false by default
expect(content).toMatch(/store:\s*false/)
// But it should be deleted for non-OpenAI providers
expect(content).toMatch(/delete body\.store/)
})
})
// ---------------------------------------------------------------------------
// Fix 2: Session timeout — stream idle timeout
// ---------------------------------------------------------------------------
describe('Session timeout fix', () => {
test('openaiShim has idle timeout for SSE streams', async () => {
const content = await file('services/api/openaiShim.ts').text()
expect(content).toContain('STREAM_IDLE_TIMEOUT_MS')
expect(content).toContain('readWithTimeout')
expect(content).toMatch(/readWithTimeout\(\)/)
})
test('codexShim has idle timeout for SSE streams', async () => {
const content = await file('services/api/codexShim.ts').text()
expect(content).toContain('STREAM_IDLE_TIMEOUT_MS')
expect(content).toContain('readWithTimeout')
expect(content).toMatch(/readWithTimeout\(\)/)
})
test('idle timeout is set to a reasonable value (>= 60s)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// Extract the timeout value (supports numeric separators like 120_000)
const match = content.match(/STREAM_IDLE_TIMEOUT_MS\s*=\s*([\d_]+)/)
expect(match).not.toBeNull()
const timeoutMs = parseInt(match![1].replace(/_/g, ''), 10)
expect(timeoutMs).toBeGreaterThanOrEqual(60_000)
})
})
// ---------------------------------------------------------------------------
// Fix 3: Agent loop continuation nudge
// ---------------------------------------------------------------------------
describe('Agent loop continuation nudge', () => {
test('query.ts has continuation signal detection', async () => {
const content = await file('query.ts').text()
expect(content).toContain('continuationSignals')
expect(content).toContain('Continuation nudge triggered')
expect(content).toContain('continuation_nudge')
})
test('continuation signals include tightened patterns', async () => {
const content = await file('query.ts').text()
// Should detect tightened patterns requiring explicit action verbs
expect(content).toMatch(/so now \(i\|let me\|we\)/)
expect(content).toContain('completionMarkers')
expect(content).toContain('MAX_CONTINUATION_NUDGES')
// Verify the nudge counter guard exists
expect(content).toMatch(/continuationNudgeCount\s*<\s*MAX_CONTINUATION_NUDGES/)
})
test('nudge creates a meta user message to continue', async () => {
const content = await file('query.ts').text()
expect(content).toContain(
'Continue with the task. Use the appropriate tools to proceed.',
)
})
})
// ---------------------------------------------------------------------------
// Fix 4: Web search result count improvements
// ---------------------------------------------------------------------------
describe('Web search result count improvements', () => {
test('Bing provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/bing.ts',
).text()
expect(content).toMatch(/count.*['"]15['"]/)
})
test('Tavily provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/tavily.ts',
).text()
expect(content).toMatch(/max_results:\s*15/)
})
test('Exa provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/exa.ts',
).text()
expect(content).toMatch(/numResults:\s*15/)
})
test('Firecrawl provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/firecrawl.ts',
).text()
expect(content).toMatch(/limit:\s*15/)
})
test('Mojeek provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/mojeek.ts',
).text()
// Mojeek uses 't' param for result count — verify it's set to 10
expect(content).toMatch(/searchParams\.set\('t',\s*'10'\)/)
})
test('You.com provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/you.ts',
).text()
expect(content).toMatch(/num_web_results.*['"]10['"]/)
})
test('Jina provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/jina.ts',
).text()
expect(content).toMatch(/count.*['"]10['"]/)
})
test('Native Anthropic web search max_uses increased to 15', async () => {
const content = await file(
'tools/WebSearchTool/WebSearchTool.ts',
).text()
expect(content).toMatch(/max_uses:\s*15/)
})
test('codex web search path guarantees a non-empty result body', async () => {
const content = await file(
'tools/WebSearchTool/WebSearchTool.ts',
).text()
expect(content).toContain("results.push('No results found.')")
})
})
// ---------------------------------------------------------------------------
// Fix 5: MCP tool timeout fix
// ---------------------------------------------------------------------------
describe('MCP tool timeout fix', () => {
test('default MCP tool timeout is reasonable (not 27 hours)', async () => {
const content = await file('services/mcp/client.ts').text()
// Should NOT have the old ~27.8 hour default
expect(content).not.toContain('100_000_000')
// Should have a reasonable timeout (5 minutes = 300_000ms)
expect(content).toMatch(/DEFAULT_MCP_TOOL_TIMEOUT_MS\s*=\s*300_000/)
})
test('MCP tools/list has retry logic', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toContain('tools/list failed (attempt')
expect(content).toContain('Retrying...')
})
test('MCP URL elicitation checks abort signal', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toContain('signal.aborted')
expect(content).toContain('Tool call aborted during URL elicitation')
})
test('MCP tool error messages include server and tool name in telemetry', async () => {
const content = await file('services/mcp/client.ts').text()
// Telemetry message should include context like "MCP tool [serverName] toolName: error"
// The human-readable message stays unchanged to avoid breaking error consumers
expect(content).toContain('MCP tool [${name}] ${tool}:')
})
})
// ---------------------------------------------------------------------------
// Cross-cutting: verify no regressions
// ---------------------------------------------------------------------------
describe('Regression checks', () => {
test('store field is still set for OpenAI (not deleted unconditionally)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// store: false should exist in body construction
expect(content).toMatch(/store:\s*false/)
// But delete body.store should be conditional (guarded by if)
const deleteLines = content.split('\n').filter(l => l.includes('delete body.store'))
expect(deleteLines.length).toBeGreaterThan(0)
// Verify the delete is inside a conditional block by checking surrounding context
for (const line of deleteLines) {
const trimmed = line.trim()
// Should be either inside an if block (indented delete) or a comment
expect(
trimmed.startsWith('delete') && !trimmed.includes('// unconditional'),
).toBe(true)
}
})
})
// ---------------------------------------------------------------------------
// Fix 6: SendMessageTool race condition guard
// ---------------------------------------------------------------------------
describe('SendMessageTool race condition fix', () => {
test('SendMessageTool has double-check for concurrent resume', async () => {
const content = await file('tools/SendMessageTool/SendMessageTool.ts').text()
// Should have a second status check before resuming to prevent race
expect(content).toContain('was concurrently resumed')
// The freshTask check should re-read from getAppState
expect(content).toMatch(/const freshTask = context\.getAppState\(\)\.tasks\[agentId\]/)
})
})
// ---------------------------------------------------------------------------
// Fix 7: AgentTool dump state cleanup
// ---------------------------------------------------------------------------
describe('AgentTool cleanup fix', () => {
test('backgrounded agent always cleans up dump state', async () => {
const content = await file('tools/AgentTool/AgentTool.tsx').text()
// The backgrounded agent's finally block should clean up regardless
// of whether the agent crashed or completed normally
expect(content).toContain('Defensive cleanup: wrap each call so one failure')
// Verify cleanup is wrapped in try/catch for defensive execution
expect(content).toMatch(/try\s*\{\s*clearInvokedSkillsForAgent/)
expect(content).toMatch(/try\s*\{\s*clearDumpState/)
})
})
// ---------------------------------------------------------------------------
// Fix 8: Context overflow 500 error handling
// ---------------------------------------------------------------------------
describe('Context overflow 500 fix', () => {
test('errors.ts has handler for context overflow 500 errors', async () => {
const content = await file('services/api/errors.ts').text()
expect(content).toContain('500 errors caused by context overflow')
expect(content).toContain('too many tokens')
expect(content).toContain('The conversation has grown too large')
})
test('query.ts has circuit breaker safety net for oversized context', async () => {
const content = await file('query.ts').text()
expect(content).toContain('Safety net: when auto-compact')
expect(content).toContain('circuit breaker has tripped')
expect(content).toContain('automatic compaction has failed')
})
})

View File

@@ -0,0 +1,55 @@
/**
* Tests for Web Search Provider result count configurations.
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..', 'tools', 'WebSearchTool', 'providers')
const file = (name: string) => Bun.file(resolve(SRC, name))
describe('Provider result counts', () => {
const providers = [
'bing.ts',
'tavily.ts',
'exa.ts',
'firecrawl.ts',
'mojeek.ts',
'you.ts',
'jina.ts',
'duckduckgo.ts',
// linkup.ts excluded — uses depth param, not a result count field
]
for (const name of providers) {
test(`${name} exists and is readable`, async () => {
const f = file(name)
expect(await f.exists()).toBe(true)
const content = await f.text()
expect(content.length).toBeGreaterThan(100)
})
}
test('No provider hardcodes a limit below 10', async () => {
const suspiciousPatterns = [
/count['":\s]*['"]([1-9])['"]/i,
/limit['":\s]*([1-9])\b/,
/max_results['":\s]*([1-9])\b/,
/numResults['":\s]*([1-9])\b/,
]
for (const name of providers) {
const content = await file(name).text()
for (const pattern of suspiciousPatterns) {
const match = content.match(pattern)
if (match) {
const num = parseInt(match[1], 10)
expect(num).toBeGreaterThanOrEqual(
10,
`${name} has suspiciously low result count: ${match[0]}`,
)
}
}
}
})
})

View File

@@ -0,0 +1,191 @@
/**
* Security hardening regression tests.
*
* Covers:
* 1. MCP tool result Unicode sanitization
* 2. Sandbox settings source filtering (exclude projectSettings)
* 3. Plugin git clone/pull hooks disabled
* 4. ANTHROPIC_FOUNDRY_API_KEY removed from SAFE_ENV_VARS
* 5. WebFetch SSRF protection via ssrfGuardedLookup
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..')
const file = (relative: string) => Bun.file(resolve(SRC, relative))
// ---------------------------------------------------------------------------
// Fix 1: MCP tool result Unicode sanitization
// ---------------------------------------------------------------------------
describe('MCP tool result sanitization', () => {
test('transformResultContent sanitizes text content', async () => {
const content = await file('services/mcp/client.ts').text()
// Tool definitions are already sanitized (line ~1798)
expect(content).toContain('recursivelySanitizeUnicode(result.tools)')
// Tool results must also be sanitized
expect(content).toMatch(
/case 'text':[\s\S]*?recursivelySanitizeUnicode\(resultContent\.text\)/,
)
})
test('resource text content is also sanitized', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toMatch(
/recursivelySanitizeUnicode\(\s*`\$\{prefix\}\$\{resource\.text\}`/,
)
})
})
// ---------------------------------------------------------------------------
// Fix 2: Sandbox settings source filtering
// ---------------------------------------------------------------------------
describe('Sandbox settings trust boundary', () => {
test('getSandboxEnabledSetting does not use getSettings_DEPRECATED', async () => {
const content = await file('utils/sandbox/sandbox-adapter.ts').text()
// Extract the getSandboxEnabledSetting function body
const fnMatch = content.match(
/function getSandboxEnabledSetting\(\)[^{]*\{([\s\S]*?)\n\}/,
)
expect(fnMatch).not.toBeNull()
const fnBody = fnMatch![1]
// Must NOT use getSettings_DEPRECATED (reads all sources including project)
expect(fnBody).not.toContain('getSettings_DEPRECATED')
// Must use getSettingsForSource for individual trusted sources
expect(fnBody).toContain("getSettingsForSource('userSettings')")
expect(fnBody).toContain("getSettingsForSource('policySettings')")
// Must NOT read from projectSettings
expect(fnBody).not.toContain("'projectSettings'")
})
})
// ---------------------------------------------------------------------------
// Fix 3: Plugin git hooks disabled
// ---------------------------------------------------------------------------
describe('Plugin git operations disable hooks', () => {
test('gitClone includes core.hooksPath=/dev/null', async () => {
const content = await file('utils/plugins/marketplaceManager.ts').text()
// The clone args must disable hooks
const cloneSection = content.slice(
content.indexOf('export async function gitClone('),
content.indexOf('export async function gitClone(') + 2000,
)
expect(cloneSection).toContain("'core.hooksPath=/dev/null'")
})
test('gitPull includes core.hooksPath=/dev/null', async () => {
const content = await file('utils/plugins/marketplaceManager.ts').text()
const pullSection = content.slice(
content.indexOf('export async function gitPull('),
content.indexOf('export async function gitPull(') + 2000,
)
expect(pullSection).toContain("'core.hooksPath=/dev/null'")
})
test('gitSubmoduleUpdate includes core.hooksPath=/dev/null', async () => {
const content = await file('utils/plugins/marketplaceManager.ts').text()
const subSection = content.slice(
content.indexOf('async function gitSubmoduleUpdate('),
content.indexOf('async function gitSubmoduleUpdate(') + 1000,
)
expect(subSection).toContain("'core.hooksPath=/dev/null'")
})
})
// ---------------------------------------------------------------------------
// Fix 4: ANTHROPIC_FOUNDRY_API_KEY not in SAFE_ENV_VARS
// ---------------------------------------------------------------------------
describe('SAFE_ENV_VARS excludes credentials', () => {
test('ANTHROPIC_FOUNDRY_API_KEY is not in SAFE_ENV_VARS', async () => {
const content = await file('utils/managedEnvConstants.ts').text()
// Extract the SAFE_ENV_VARS set definition
const safeStart = content.indexOf('export const SAFE_ENV_VARS')
const safeEnd = content.indexOf('])', safeStart)
const safeSection = content.slice(safeStart, safeEnd)
expect(safeSection).not.toContain('ANTHROPIC_FOUNDRY_API_KEY')
})
})
// ---------------------------------------------------------------------------
// Fix 5: WebFetch SSRF protection
// ---------------------------------------------------------------------------
describe('WebFetch SSRF guard', () => {
test('getWithPermittedRedirects uses ssrfGuardedLookup', async () => {
const content = await file('tools/WebFetchTool/utils.ts').text()
expect(content).toContain(
"import { ssrfGuardedLookup } from '../../utils/hooks/ssrfGuard.js'",
)
// The axios.get call in getWithPermittedRedirects must include lookup
const fnSection = content.slice(
content.indexOf('export async function getWithPermittedRedirects('),
content.indexOf('export async function getWithPermittedRedirects(') +
1000,
)
expect(fnSection).toContain('lookup: ssrfGuardedLookup')
})
})
// ---------------------------------------------------------------------------
// Fix 6: Swarm permission file polling removed (security hardening)
// ---------------------------------------------------------------------------
describe('Swarm permission file polling removed', () => {
test('useSwarmPermissionPoller hook no longer exists', async () => {
const content = await file(
'hooks/useSwarmPermissionPoller.ts',
).text()
// The file-based polling hook must not exist — it read from an
// unauthenticated resolved/ directory where any local process could
// forge approval files.
expect(content).not.toContain('function useSwarmPermissionPoller(')
// The file-based processResponse must not exist
expect(content).not.toContain('function processResponse(')
})
test('poller does not import from permissionSync', async () => {
const content = await file(
'hooks/useSwarmPermissionPoller.ts',
).text()
// Must not import anything from permissionSync — all file-based
// functions have been removed from this module's dependencies
expect(content).not.toContain('permissionSync')
})
test('file-based permission functions are marked deprecated', async () => {
const content = await file(
'utils/swarm/permissionSync.ts',
).text()
// All file-based functions must have @deprecated JSDoc
const deprecatedFns = [
'writePermissionRequest',
'readPendingPermissions',
'readResolvedPermission',
'resolvePermission',
'pollForResponse',
'removeWorkerResponse',
]
for (const fn of deprecatedFns) {
// Find the function and check that @deprecated appears before it
const fnIndex = content.indexOf(`export async function ${fn}(`)
if (fnIndex === -1) continue // submitPermissionRequest is a const, not async function
const preceding = content.slice(Math.max(0, fnIndex - 500), fnIndex)
expect(preceding).toContain('@deprecated')
}
})
test('mailbox-based functions are NOT deprecated', async () => {
const content = await file(
'utils/swarm/permissionSync.ts',
).text()
// These are the active path — must not be deprecated
const activeFns = [
'sendPermissionRequestViaMailbox',
'sendPermissionResponseViaMailbox',
]
for (const fn of activeFns) {
const fnIndex = content.indexOf(`export async function ${fn}(`)
expect(fnIndex).not.toBe(-1)
const preceding = content.slice(Math.max(0, fnIndex - 300), fnIndex)
expect(preceding).not.toContain('@deprecated')
}
})
})

View File

@@ -1562,29 +1562,8 @@ export function clearInvokedSkillsForAgent(agentId: string): void {
}
}
// Slow operations tracking for dev bar
const MAX_SLOW_OPERATIONS = 10
const SLOW_OPERATION_TTL_MS = 10000
export function addSlowOperation(operation: string, durationMs: number): void {
if (process.env.USER_TYPE !== 'ant') return
// Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
// These are intentionally slow since the user is drafting text
if (operation.includes('exec') && operation.includes('claude-prompt-')) {
return
}
const now = Date.now()
// Remove stale operations
STATE.slowOperations = STATE.slowOperations.filter(
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
)
// Add new operation
STATE.slowOperations.push({ operation, durationMs, timestamp: now })
// Keep only the most recent operations
if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
}
}
// Slow operations tracking removed (was internal-only).
// Functions kept as no-ops to avoid breaking callers.
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
operation: string
@@ -1592,32 +1571,17 @@ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
timestamp: number
}> = []
export function addSlowOperation(
_operation: string,
_durationMs: number,
): void {}
export function getSlowOperations(): ReadonlyArray<{
operation: string
durationMs: number
timestamp: number
}> {
// Most common case: nothing tracked. Return a stable reference so the
// caller's setState() can bail via Object.is instead of re-rendering at 2fps.
if (STATE.slowOperations.length === 0) {
return EMPTY_SLOW_OPERATIONS
}
const now = Date.now()
// Only allocate a new array when something actually expired; otherwise keep
// the reference stable across polls while ops are still fresh.
if (
STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
) {
STATE.slowOperations = STATE.slowOperations.filter(
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
)
if (STATE.slowOperations.length === 0) {
return EMPTY_SLOW_OPERATIONS
}
}
// Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
// before pushing, so the array held in React state is never mutated.
return STATE.slowOperations
return EMPTY_SLOW_OPERATIONS
}
export function getMainThreadAgentType(): string | undefined {

View File

@@ -14,21 +14,14 @@
import { getOauthConfig } from '../constants/oauth.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
/** Dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
export function getBridgeTokenOverride(): string | undefined {
return (
(process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
undefined
)
return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
}
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
/** Dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
export function getBridgeBaseUrlOverride(): string | undefined {
return (
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
undefined
)
return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
}
/**

View File

@@ -70,13 +70,13 @@ export async function isBridgeEnabledBlocking(): Promise<boolean> {
export async function getBridgeDisabledReason(): Promise<string | null> {
if (feature('BRIDGE_MODE')) {
if (!isClaudeAISubscriber()) {
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
return 'Remote Control requires a claude.ai subscription. Run `openclaude auth login` to sign in with your claude.ai account.'
}
if (!hasProfileScope()) {
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.'
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `openclaude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `openclaude auth login` to use Remote Control.'
}
if (!getOauthAccountInfo()?.organizationUuid) {
return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.'
return 'Unable to determine your organization for Remote Control eligibility. Run `openclaude auth login` to refresh your account information.'
}
if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
return 'Remote Control is not yet enabled for your account.'
@@ -166,7 +166,7 @@ export function checkBridgeMinVersion(): string | null {
minVersion: string
}>('tengu_bridge_min_version', { minVersion: '0.0.0' })
if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.`
return `Your version of OpenClaude (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`openclaude update\` to update.`
}
}
return null

View File

@@ -2194,14 +2194,10 @@ export async function bridgeMain(args: string[]): Promise<void> {
// Session ingress URL for WebSocket connections. In production this is the
// same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress).
// Locally, session-ingress runs on a different port (9413) than the
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
// Locally, session-ingress may run on a different port, so
// CLAUDE_BRIDGE_SESSION_INGRESS_URL can override the default.
const sessionIngressUrl =
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js'
@@ -2252,7 +2248,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
})
// biome-ignore lint/suspicious/noConsole: intentional dialog output
console.log(
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from OpenClaude on the web or your mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
`Spawn mode for this project:\n` +
` [1] same-dir \u2014 sessions share the current directory (default)\n` +
` [2] worktree \u2014 each session gets an isolated git worktree\n\n` +
@@ -2851,10 +2847,7 @@ export async function runBridgeHeadless(
)
}
const sessionIngressUrl =
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js'

View File

@@ -217,25 +217,39 @@ export async function getBridgeSession(
}
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
const timeoutMs = 10_000
logForDebugging(`[bridge] Fetching session ${sessionId}`)
let response
try {
response = await axios.get<{ environment_id?: string; title?: string }>(
url,
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
{ headers, timeout: timeoutMs, validateStatus: s => s < 500 },
)
} catch (err: unknown) {
logForDebugging(
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
)
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 'no-response'
const code = err.code ?? 'unknown-code'
const requestUrl = err.config?.url ?? url
const method = err.config?.method?.toUpperCase() ?? 'GET'
const message = err.message ?? errorMessage(err)
const timeout = err.config?.timeout ?? timeoutMs
logForDebugging(
`[bridge] Session fetch request failed: status=${status} code=${code} method=${method} url=${requestUrl} timeout=${timeout} message=${message}`,
)
} else {
logForDebugging(
`[bridge] Session fetch request failed: url=${url} timeout=${timeoutMs} message=${errorMessage(err)}`,
)
}
return null
}
if (response.status !== 200) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
`[bridge] Session fetch failed with status ${response.status} url=${url}${detail ? `: ${detail}` : ''}`,
)
return null
}

View File

@@ -147,7 +147,7 @@ export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
const cfg = await getEnvLessBridgeConfig()
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
return `Your version of OpenClaude (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`openclaude update\` to update.`
}
return null
}

View File

@@ -415,7 +415,7 @@ export async function initReplBridge(
`[bridge:repl] Skipping: ${versionError}`,
true,
)
onStateChange?.('failed', 'run `claude update` to upgrade')
onStateChange?.('failed', 'run `openclaude update` to upgrade')
return null
}
logForDebugging(
@@ -456,7 +456,7 @@ export async function initReplBridge(
const versionError = checkBridgeMinVersion()
if (versionError) {
logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`)
onStateChange?.('failed', 'run `claude update` to upgrade')
onStateChange?.('failed', 'run `openclaude update` to upgrade')
return null
}
@@ -465,10 +465,7 @@ export async function initReplBridge(
const branch = await getBranch()
const gitRepoUrl = await getRemoteUrl()
const sessionIngressUrl =
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
// Assistant-mode sessions advertise a distinct worker_type so the web UI
// can filter them into a dedicated picker. KAIROS guard keeps the

View File

@@ -147,7 +147,7 @@ export async function enrollTrustedDevice(): Promise<void> {
device_id?: string
}>(
`${baseUrl}/api/auth/trusted_devices`,
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
{ display_name: `OpenClaude on ${hostname()} · ${process.platform}` },
{
headers: {
Authorization: `Bearer ${accessToken}`,

View File

@@ -287,7 +287,7 @@ export async function authStatus(opts: {
}
if (!loggedIn) {
process.stdout.write(
'Not logged in. Run claude auth login to authenticate.\n',
'Not logged in. Run openclaude auth login to authenticate.\n',
)
}
} else {

View File

@@ -83,7 +83,7 @@ export async function autoModeCritiqueHandler(options: {
process.stdout.write(
'No custom auto mode rules found.\n\n' +
'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' +
'Run `claude auto-mode defaults` to see the default rules for reference.\n',
'Run `openclaude auto-mode defaults` to see the default rules for reference.\n',
)
return
}

View File

@@ -11,7 +11,12 @@ import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopI
import { render } from '../../ink.js';
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
import {
clearMcpClientConfig,
clearServerTokensFromSecureStorage,
readClientSecret,
saveMcpClientSecret,
} from '../../services/mcp/auth.js'
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
@@ -228,7 +233,7 @@ export async function mcpRemoveHandler(name: string, options: {
});
process.stderr.write('\nTo remove from a specific scope, use:\n');
scopes.forEach(scope => {
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`);
process.stderr.write(` openclaude mcp remove "${name}" -s ${scope}\n`);
});
cliError();
}
@@ -245,7 +250,7 @@ export async function mcpListHandler(): Promise<void> {
} = await getAllMcpConfigs();
if (Object.keys(configs).length === 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('No MCP servers configured. Use `claude mcp add` to add a server.');
console.log('No MCP servers configured. Use `openclaude mcp add` to add a server.');
} else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Checking MCP server health...\n');
@@ -369,7 +374,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
}
}
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`);
console.log(`\nTo remove this server, run: openclaude mcp remove "${name}" -s ${server.scope}`);
// Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0);
@@ -450,5 +455,5 @@ export async function mcpResetChoicesHandler(): Promise<void> {
disabledMcpjsonServers: [],
enableAllProjectMcpServers: false
}));
cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.');
cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start OpenClaude.');
}

View File

@@ -352,7 +352,7 @@ export async function pluginListHandler(options: {
// through to the session section so the failure is visible.
if (inlineLoadErrors.length === 0) {
cliOk(
'No plugins installed. Use `claude plugin install` to install a plugin.',
'No plugins installed. Use `openclaude plugin install` to install a plugin.',
)
}
}

View File

@@ -362,15 +362,9 @@ const proactiveModule =
feature('PROACTIVE') || feature('KAIROS')
? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
: null
const cronSchedulerModule = feature('AGENT_TRIGGERS')
? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js'))
: null
const cronJitterConfigModule = feature('AGENT_TRIGGERS')
? (require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js'))
: null
const cronGate = feature('AGENT_TRIGGERS')
? (require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js'))
: null
const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
@@ -2701,11 +2695,7 @@ function runHeadlessStreaming(
// the end of run() picks up the queued command.
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null
if (
feature('AGENT_TRIGGERS') &&
cronSchedulerModule &&
cronGate?.isKairosCronEnabled()
) {
if (cronGate.isKairosCronEnabled()) {
cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => {
if (inputClosed) return
@@ -2727,8 +2717,8 @@ function runHeadlessStreaming(
void run()
},
isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
isKilled: () => !cronGate?.isKairosCronEnabled(),
getJitterConfig: cronJitterConfigModule.getCronJitterConfig,
isKilled: () => !cronGate.isKairosCronEnabled(),
})
cronScheduler.start()
}
@@ -4592,7 +4582,7 @@ function handleSetPermissionMode(
subtype: 'error',
request_id: requestId,
error:
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
'Cannot set permission mode to bypassPermissions. Enable it with --allow-dangerously-skip-permissions or set permissions.allowBypassPermissionsMode in settings.json',
},
})
return toolPermissionContext
@@ -5036,7 +5026,7 @@ async function loadInitialMessages(
)
if (!parsedSessionId) {
let errorMessage =
'Error: --resume requires a valid session ID when used with --print. Usage: claude -p --resume <session-id>'
'Error: --resume requires a valid session ID when used with --print. Usage: openclaude -p --resume <session-id>'
if (typeof options.resume === 'string') {
errorMessage += `. Session IDs must be in UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000). Provided value "${options.resume}" is not a valid UUID`
}

View File

@@ -35,15 +35,20 @@ export async function update() {
// binary (without it).
if (getAPIProvider() !== 'firstParty') {
writeToStdout(
chalk.yellow('Auto-update is not available for third-party provider builds.\n') +
'To update, pull the latest source from the repository and rebuild:\n' +
' git pull && bun install && bun run build\n',
chalk.yellow(
`Auto-update is not available for third-party provider builds.\n`,
) +
`Current version: ${MACRO.DISPLAY_VERSION}\n\n` +
`To update, reinstall from npm:\n` +
chalk.bold(` npm install -g ${MACRO.PACKAGE_URL}@latest`) + '\n\n' +
`Or, if you built from source, pull and rebuild:\n` +
chalk.bold(' git pull && bun install && bun run build') + '\n',
)
return
await gracefulShutdown(0)
}
logEvent('tengu_update_check', {})
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
writeToStdout(`Current version: ${MACRO.DISPLAY_VERSION}\n`)
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
writeToStdout(`Checking for updates to ${channel} version...\n`)
@@ -123,9 +128,14 @@ export async function update() {
if (diagnostic.installationType === 'development') {
writeToStdout('\n')
writeToStdout(
chalk.yellow('Warning: Cannot update development build') + '\n',
chalk.yellow('You are running a development build — auto-update is unavailable.') + '\n',
)
await gracefulShutdown(1)
writeToStdout('To update, pull the latest source and rebuild:\n')
writeToStdout(chalk.bold(' git pull && bun install && bun run build') + '\n')
writeToStdout('\n')
writeToStdout('Or reinstall from npm:\n')
writeToStdout(chalk.bold(` npm install -g ${MACRO.PACKAGE_URL}@latest`) + '\n')
await gracefulShutdown(0)
}
// Check if running from a package manager
@@ -136,8 +146,8 @@ export async function update() {
if (packageManager === 'homebrew') {
writeToStdout('Claude is managed by Homebrew.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
@@ -147,8 +157,8 @@ export async function update() {
} else if (packageManager === 'winget') {
writeToStdout('Claude is managed by winget.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(
@@ -160,8 +170,8 @@ export async function update() {
} else if (packageManager === 'apk') {
writeToStdout('Claude is managed by apk.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
@@ -250,14 +260,14 @@ export async function update() {
await gracefulShutdown(1)
}
if (result.latestVersion === MACRO.VERSION) {
if (result.latestVersion === MACRO.DISPLAY_VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
chalk.green(`OpenClaude is up to date (${MACRO.DISPLAY_VERSION})`) + '\n',
)
} else {
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
`Successfully updated from ${MACRO.DISPLAY_VERSION} to version ${result.latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
@@ -266,7 +276,7 @@ export async function update() {
} catch (error) {
process.stderr.write('Error: Failed to install native update\n')
process.stderr.write(String(error) + '\n')
process.stderr.write('Try running "claude doctor" for diagnostics\n')
process.stderr.write('Try running "openclaude doctor" for diagnostics\n')
await gracefulShutdown(1)
}
}
@@ -320,15 +330,15 @@ export async function update() {
}
// Check if versions match exactly, including any build metadata (like SHA)
if (latestVersion === MACRO.VERSION) {
if (latestVersion === MACRO.DISPLAY_VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
chalk.green(`OpenClaude is up to date (${MACRO.DISPLAY_VERSION})`) + '\n',
)
await gracefulShutdown(0)
}
writeToStdout(
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
`New version available: ${latestVersion} (current: ${MACRO.DISPLAY_VERSION})\n`,
)
writeToStdout('Installing update...\n')
@@ -388,7 +398,7 @@ export async function update() {
case 'success':
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
`Successfully updated from ${MACRO.DISPLAY_VERSION} to version ${latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
@@ -400,12 +410,12 @@ export async function update() {
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write('Try running with sudo or fix npm permissions\n')
process.stderr.write(
'Or consider using native installation with: claude install\n',
'Or consider using native installation with: openclaude install\n',
)
}
await gracefulShutdown(1)
@@ -415,11 +425,11 @@ export async function update() {
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write(
'Or consider using native installation with: claude install\n',
'Or consider using native installation with: openclaude install\n',
)
}
await gracefulShutdown(1)

30
src/commands.test.ts Normal file
View File

@@ -0,0 +1,30 @@
import { formatDescriptionWithSource } from './commands.js'
describe('formatDescriptionWithSource', () => {
test('returns empty text for prompt commands missing a description', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'builtin',
description: undefined,
} as any
expect(formatDescriptionWithSource(command)).toBe('')
})
test('formats plugin commands with missing description safely', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'plugin',
description: undefined,
pluginInfo: {
pluginManifest: {
name: 'MyPlugin',
},
},
} as any
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
})
})

View File

@@ -21,6 +21,7 @@ import dream from './commands/dream/index.js'
import ctx_viz from './commands/ctx_viz/index.js'
import doctor from './commands/doctor/index.js'
import onboardGithub from './commands/onboard-github/index.js'
import knowledge from './commands/knowledge/index.js'
import memory from './commands/memory/index.js'
import help from './commands/help/index.js'
import ide from './commands/ide/index.js'
@@ -32,6 +33,8 @@ import logout from './commands/logout/index.js'
import installGitHubApp from './commands/install-github-app/index.js'
import installSlackApp from './commands/install-slack-app/index.js'
import breakCache from './commands/break-cache/index.js'
import cacheProbe from './commands/cache-probe/index.js'
import cacheStats from './commands/cacheStats/index.js'
import mcp from './commands/mcp/index.js'
import mobile from './commands/mobile/index.js'
import onboarding from './commands/onboarding/index.js'
@@ -136,6 +139,7 @@ import hooks from './commands/hooks/index.js'
import files from './commands/files/index.js'
import branch from './commands/branch/index.js'
import agents from './commands/agents/index.js'
import autoFix from './commands/auto-fix.js'
import plugin from './commands/plugin/index.js'
import reloadPlugins from './commands/reload-plugins/index.js'
import rewind from './commands/rewind/index.js'
@@ -143,6 +147,7 @@ import heapDump from './commands/heapdump/index.js'
import mockLimits from './commands/mock-limits/index.js'
import bridgeKick from './commands/bridge-kick.js'
import version from './commands/version.js'
import wiki from './commands/wiki/index.js'
import summary from './commands/summary/index.js'
import {
resetLimits,
@@ -194,7 +199,7 @@ import stats from './commands/stats/index.js'
const usageReport: Command = {
type: 'prompt',
name: 'insights',
description: 'Generate a report analyzing your Claude Code sessions',
description: 'Generate a report analyzing your OpenClaude sessions',
contentLength: 0,
progressMessage: 'analyzing your sessions',
source: 'builtin',
@@ -263,8 +268,11 @@ const COMMANDS = memoize((): Command[] => [
addDir,
advisor,
agents,
autoFix,
branch,
btw,
cacheProbe,
cacheStats,
chrome,
clear,
color,
@@ -287,6 +295,7 @@ const COMMANDS = memoize((): Command[] => [
ide,
init,
keybindings,
knowledge,
installGitHubApp,
installSlackApp,
mcp,
@@ -324,6 +333,7 @@ const COMMANDS = memoize((): Command[] => [
usage,
usageReport,
vim,
wiki,
...(webCmd ? [webCmd] : []),
...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []),
@@ -734,23 +744,23 @@ export function getCommand(commandName: string, commands: Command[]): Command {
*/
export function formatDescriptionWithSource(cmd: Command): string {
if (cmd.type !== 'prompt') {
return cmd.description
return cmd.description ?? ''
}
if (cmd.kind === 'workflow') {
return `${cmd.description} (workflow)`
return `${cmd.description ?? ''} (workflow)`
}
if (cmd.source === 'plugin') {
const pluginName = cmd.pluginInfo?.pluginManifest.name
if (pluginName) {
return `(${pluginName}) ${cmd.description}`
return `(${pluginName}) ${cmd.description ?? ''}`
}
return `${cmd.description} (plugin)`
return `${cmd.description ?? ''} (plugin)`
}
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
return cmd.description
return cmd.description ?? ''
}
if (cmd.source === 'bundled') {

25
src/commands/auto-fix.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { Command } from '../types/command.js'
const command: Command = {
name: 'auto-fix',
description: 'Configure auto-fix: run lint/test after AI edits',
isEnabled: () => true,
type: 'prompt',
progressMessage: 'Configuring auto-fix...',
contentLength: 0,
source: 'builtin',
async getPromptForCommand() {
return [
{
type: 'text',
text:
'The user wants to configure auto-fix settings. Auto-fix automatically runs lint and test commands after AI file edits, feeding errors back for self-repair.\n\n' +
'Current settings location: `.claude/settings.json` or `.claude/settings.local.json`\n\n' +
'Example configuration:\n```json\n{\n "autoFix": {\n "enabled": true,\n "lint": "eslint . --fix",\n "test": "bun test",\n "maxRetries": 3,\n "timeout": 30000\n }\n}\n```\n\n' +
'Ask the user what lint and test commands they use, then help them set up the configuration.',
},
]
},
}
export default command

56
src/commands/benchmark.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { ToolUseContext } from '../Tool.js'
import type { Command } from '../types/command.js'
import {
benchmarkModel,
benchmarkMultipleModels,
formatBenchmarkResults,
isBenchmarkSupported,
} from '../utils/model/benchmark.js'
import { getOllamaModelOptions } from '../utils/model/ollamaModels.js'
async function runBenchmark(
model?: string,
context?: ToolUseContext,
): Promise<void> {
if (!isBenchmarkSupported()) {
context?.stdout?.write(
'Benchmark not supported for this provider.\n' +
'Supported: OpenAI-compatible endpoints (Ollama, NVIDIA NIM, MiniMax)\n',
)
return
}
let modelsToBenchmark: string[]
if (model) {
modelsToBenchmark = [model]
} else {
const ollamaModels = getOllamaModelOptions()
modelsToBenchmark = ollamaModels.slice(0, 3).map((m) => m.value)
}
context?.stdout?.write(`Benchmarking ${modelsToBenchmark.length} model(s)...\n`)
const results = await benchmarkMultipleModels(
modelsToBenchmark,
(completed, total, result) => {
context?.stdout?.write(
`[${completed}/${total}] ${result.model}: ` +
`${result.success ? result.tokensPerSecond.toFixed(1) + ' tps' : 'FAILED'}\n`,
)
},
)
context?.stdout?.write('\n' + formatBenchmarkResults(results) + '\n')
}
export const benchmark: Command = {
name: 'benchmark',
async onExecute(context: ToolUseContext): Promise<void> {
const args = context.args ?? {}
const model = args.model as string | undefined
await runBenchmark(model, context)
},
}

View File

@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
const buddy = {
type: 'local-jsx',
name: 'buddy',
description: 'Hatch, pet, and manage your Open Claude companion',
description: 'Hatch, pet, and manage your OpenClaude companion',
immediate: true,
argumentHint: '[status|mute|unmute|help]',
load: () => import('./buddy.js'),

View File

@@ -0,0 +1,413 @@
import { getSessionId } from '../../bootstrap/state.js'
import { resolveProviderRequest } from '../../services/api/providerConfig.js'
import type { LocalCommandCall } from '../../types/command.js'
import { logForDebugging } from '../../utils/debug.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import { getMainLoopModel } from '../../utils/model/model.js'
const COPILOT_HEADERS: Record<string, string> = {
'User-Agent': 'GitHubCopilotChat/0.26.7',
'Editor-Version': 'vscode/1.99.3',
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
'Copilot-Integration-Id': 'vscode-chat',
}
// Large system prompt (~6000 chars, ~1500 tokens) to cross the 1024-token cache threshold
const SYSTEM_PROMPT = [
'You are a coding assistant. Answer concisely.',
'CONTEXT: User is working on a TypeScript project with Bun runtime.',
...Array.from(
{ length: 80 },
(_, i) =>
`Rule ${i + 1}: Follow best practices for TypeScript including strict typing, error handling, testing, and clean code. Prefer explicit types over any. Use const assertions. Await all async operations.`,
),
].join('\n\n')
const USER_MESSAGE = 'Say "hello" and nothing else.'
const DELAY_MS = 3000
/**
* Extract model family from a versioned model string.
* e.g. "gpt-5.4-0626" → "gpt-5.4", "codex-mini-latest" → "codex-mini"
*/
function getModelFamily(model: string | undefined): string {
if (!model) return 'unknown'
return model
.replace(/-\d{4,}$/, '')
.replace(/-latest$/, '')
.replace(/-preview$/, '')
}
function getField(obj: unknown, path: string): unknown {
return path
.split('.')
.reduce((o: any, k: string) => (o != null ? o[k] : undefined), obj)
}
interface ProbeResult {
label: string
status: number
elapsed: number
headers: Record<string, string>
usage: Record<string, unknown> | null
responseId: string | null
error: string | null
}
async function sendProbe(
url: string,
headers: Record<string, string>,
body: Record<string, unknown>,
label: string,
): Promise<ProbeResult> {
const start = Date.now()
let response: Response
try {
response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
} catch (err: any) {
return {
label,
status: 0,
elapsed: Date.now() - start,
headers: {},
usage: null,
responseId: null,
error: err.message,
}
}
const elapsed = Date.now() - start
const respHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
respHeaders[key] = value
})
if (!response.ok) {
const errorBody = await response.text().catch(() => '')
return {
label,
status: response.status,
elapsed,
headers: respHeaders,
usage: null,
responseId: null,
error: errorBody,
}
}
// Parse SSE stream for usage data
const text = await response.text()
let usage: Record<string, unknown> | null = null
let responseId: string | null = null
const isResponses = url.endsWith('/responses')
for (const chunk of text.split('\n\n')) {
const lines = chunk
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
if (isResponses) {
const eventLine = lines.find((l) => l.startsWith('event: '))
const dataLines = lines.filter((l) => l.startsWith('data: '))
if (!eventLine || !dataLines.length) continue
const event = eventLine.slice(7).trim()
if (
event === 'response.completed' ||
event === 'response.incomplete'
) {
try {
const data = JSON.parse(
dataLines.map((l) => l.slice(6)).join('\n'),
)
usage = (data?.response?.usage as Record<string, unknown>) ?? null
responseId = (data?.response?.id as string) ?? null
} catch {}
}
} else {
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const raw = line.slice(6).trim()
if (raw === '[DONE]') continue
try {
const data = JSON.parse(raw) as Record<string, unknown>
if (data.usage) {
usage = data.usage as Record<string, unknown>
responseId = (data.id as string) ?? null
}
} catch {}
}
}
}
return { label, status: response.status, elapsed, headers: respHeaders, usage, responseId, error: null }
}
function formatResult(r: ProbeResult): string {
const lines: string[] = [`--- ${r.label} ---`]
if (r.error) {
lines.push(` ERROR (HTTP ${r.status}): ${r.error.slice(0, 200)}`)
return lines.join('\n')
}
lines.push(` HTTP ${r.status}${r.elapsed}ms`)
if (r.responseId) lines.push(` response.id: ${r.responseId}`)
if (r.usage) {
lines.push(' Usage:')
lines.push(` ${JSON.stringify(r.usage, null, 2).replace(/\n/g, '\n ')}`)
} else {
lines.push(' Usage: null')
}
// Interesting headers
for (const h of [
'openai-processing-ms',
'x-ratelimit-remaining',
'x-ratelimit-limit',
'x-ms-region',
'x-github-request-id',
'x-request-id',
]) {
if (r.headers[h]) lines.push(` ${h}: ${r.headers[h]}`)
}
return lines.join('\n')
}
export const call: LocalCommandCall = async (args) => {
const parts = (args ?? '').trim().split(/\s+/).filter(Boolean)
const noKey = parts.includes('--no-key')
const modelOverride = parts.find((p) => !p.startsWith('--')) || undefined
const modelStr = modelOverride ?? getMainLoopModel()
const request = resolveProviderRequest({ model: modelStr })
const isGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
// Resolve API key the same way the OpenAI shim does
let apiKey = process.env.OPENAI_API_KEY ?? ''
if (!apiKey && isGithub) {
hydrateGithubModelsTokenFromSecureStorage()
apiKey =
process.env.OPENAI_API_KEY ??
process.env.GITHUB_TOKEN ??
process.env.GH_TOKEN ??
''
}
if (!apiKey) {
return {
type: 'text',
value:
'No API key found. Make sure you are in an active OpenAI-compatible or GitHub Copilot session.\n' +
'For GitHub Copilot: run /onboard-github first.\n' +
'For OpenAI-compatible: set OPENAI_API_KEY.',
}
}
const useResponses = request.transport === 'codex_responses'
const endpoint = useResponses ? '/responses' : '/chat/completions'
const url = `${request.baseUrl}${endpoint}`
const family = getModelFamily(request.resolvedModel)
const cacheKey = `${getSessionId()}:${family}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
originator: 'openclaude',
}
if (isGithub) {
Object.assign(headers, COPILOT_HEADERS)
}
let body: Record<string, unknown>
if (useResponses) {
body = {
model: request.resolvedModel,
instructions: SYSTEM_PROMPT,
input: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: USER_MESSAGE }],
},
],
stream: true,
...(noKey ? {} : {
store: false,
prompt_cache_key: cacheKey,
prompt_cache_retention: '24h',
}),
}
} else {
body = {
model: request.resolvedModel,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: USER_MESSAGE },
],
stream: true,
stream_options: { include_usage: true },
max_tokens: 20,
...(noKey ? {} : {
store: false,
prompt_cache_key: cacheKey,
}),
}
}
// Log configuration
const config = [
`[cache-probe] Starting cache probe${noKey ? ' (--no-key: cache params OMITTED)' : ''}`,
` model: ${request.resolvedModel} (family: ${family})`,
` transport: ${request.transport}`,
` endpoint: ${url}`,
` prompt_cache_key: ${noKey ? 'NOT SENT' : cacheKey}`,
` store: ${noKey ? 'NOT SENT' : 'false'}`,
` system prompt: ~${Math.round(SYSTEM_PROMPT.length / 4)} tokens`,
` delay between calls: ${DELAY_MS}ms`,
].join('\n')
logForDebugging(config)
// Call 1 — Cold
const r1 = await sendProbe(url, headers, body, 'CALL 1 — Cold (no cache)')
logForDebugging(`[cache-probe]\n${formatResult(r1)}`)
if (r1.error) {
return {
type: 'text',
value: `Cache probe failed on first call: HTTP ${r1.status}\n${r1.error.slice(0, 300)}\n\nFull details in debug log.`,
}
}
// Wait
await new Promise((r) => setTimeout(r, DELAY_MS))
// Call 2 — Warm
const r2 = await sendProbe(url, headers, body, 'CALL 2 — Warm (cache expected)')
logForDebugging(`[cache-probe]\n${formatResult(r2)}`)
// --- Comparison ---
const fields = [
'input_tokens',
'output_tokens',
'total_tokens',
'prompt_tokens',
'completion_tokens',
'input_tokens_details.cached_tokens',
'prompt_tokens_details.cached_tokens',
'output_tokens_details.reasoning_tokens',
]
const comparison: string[] = ['[cache-probe] COMPARISON']
comparison.push(
` ${'Field'.padEnd(42)} ${'Call 1'.padStart(8)} ${'Call 2'.padStart(8)} ${'Delta'.padStart(8)}`,
)
comparison.push(` ${'-'.repeat(72)}`)
for (const f of fields) {
const v1 = getField(r1.usage, f)
const v2 = getField(r2.usage, f)
if (v1 === undefined && v2 === undefined) continue
const d =
typeof v1 === 'number' && typeof v2 === 'number' ? v2 - v1 : ''
comparison.push(
` ${f.padEnd(42)} ${String(v1 ?? '-').padStart(8)} ${String(v2 ?? '-').padStart(8)} ${String(d).padStart(8)}`,
)
}
comparison.push('')
comparison.push(
` Latency: ${r1.elapsed}ms → ${r2.elapsed}ms (${r2.elapsed - r1.elapsed > 0 ? '+' : ''}${r2.elapsed - r1.elapsed}ms)`,
)
// Header comparison
for (const h of ['openai-processing-ms', 'x-ms-region', 'x-ratelimit-remaining']) {
const v1 = r1.headers[h]
const v2 = r2.headers[h]
if (v1 || v2) {
comparison.push(` ${h}: ${v1 ?? '-'}${v2 ?? '-'}`)
}
}
// Verdict
const cached2 =
(getField(r2.usage, 'input_tokens_details.cached_tokens') as number) ??
(getField(r2.usage, 'prompt_tokens_details.cached_tokens') as number) ??
0
const input1 =
((r1.usage?.input_tokens ?? r1.usage?.prompt_tokens) as number) ?? 0
const input2 =
((r2.usage?.input_tokens ?? r2.usage?.prompt_tokens) as number) ?? 0
let verdict: string
if (cached2 > 0) {
const rate = input2 > 0 ? Math.round((cached2 / input2) * 100) : '?'
verdict = `CACHE HIT: ${cached2} cached tokens (${rate}% of input)`
} else if (input1 === 0 && input2 === 0) {
verdict = 'INCONCLUSIVE: Server returns 0 input_tokens — cannot measure'
} else if (r2.elapsed < r1.elapsed * 0.6 && input1 > 100) {
verdict = `POSSIBLE SILENT CACHING: Call 2 was ${Math.round((1 - r2.elapsed / r1.elapsed) * 100)}% faster but no cached_tokens reported`
} else {
verdict = 'NO CACHE DETECTED'
}
comparison.push(`\n Verdict: ${verdict}`)
// --- Simulate what main's shim code does with this usage ---
// codexShim.ts makeUsage() — used for Responses API (GPT-5+/Codex)
function mainMakeUsage(u: any) {
return {
input_tokens: u?.input_tokens ?? 0,
output_tokens: u?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0, // ← main hardcodes this to 0
}
}
// openaiShim.ts convertChunkUsage() — used for Chat Completions
function mainConvertChunkUsage(u: any) {
return {
input_tokens: u?.prompt_tokens ?? 0,
output_tokens: u?.completion_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
}
}
const shimFn = useResponses ? mainMakeUsage : mainConvertChunkUsage
const shim1 = shimFn(r1.usage)
const shim2 = shimFn(r2.usage)
comparison.push('')
comparison.push(` --- What main's shim reports (${useResponses ? 'codexShim.makeUsage' : 'openaiShim.convertChunkUsage'}) ---`)
comparison.push(` Call 1: cache_read_input_tokens=${shim1.cache_read_input_tokens}`)
comparison.push(` Call 2: cache_read_input_tokens=${shim2.cache_read_input_tokens}`)
if (useResponses && cached2 > 0) {
comparison.push(` BUG: Server returned ${cached2} cached tokens but main's makeUsage() drops it → reports 0`)
} else if (!useResponses && shim2.cache_read_input_tokens > 0) {
comparison.push(` OK: Chat Completions path on main correctly reads cached_tokens`)
}
logForDebugging(comparison.join('\n'))
// User-facing summary
const mode = noKey ? ' (NO cache key sent)' : ''
const shimLabel = useResponses ? 'codexShim.makeUsage()' : 'openaiShim.convertChunkUsage()'
const summary = [
`Cache Probe — ${request.resolvedModel} via ${useResponses ? 'Responses API' : 'Chat Completions'}${mode}`,
'',
`Call 1: ${r1.elapsed}ms, input=${input1}, cached=${(getField(r1.usage, 'input_tokens_details.cached_tokens') as number) ?? (getField(r1.usage, 'prompt_tokens_details.cached_tokens') as number) ?? 0}`,
`Call 2: ${r2.elapsed}ms, input=${input2}, cached=${cached2}`,
'',
verdict,
'',
`What main's ${shimLabel} reports:`,
` Call 2 cache_read_input_tokens = ${shim2.cache_read_input_tokens}${useResponses && cached2 > 0 ? ' ← BUG: server sent ' + cached2 + ' but main drops it' : ''}`,
'',
'Full details written to debug log.',
].join('\n')
return { type: 'text', value: summary }
}

View File

@@ -0,0 +1,17 @@
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
const cacheProbe: Command = {
type: 'local',
name: 'cache-probe',
description:
'Send identical requests to test prompt caching (results in debug log)',
argumentHint: '[model] [--no-key]',
isEnabled: () =>
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB),
supportsNonInteractive: false,
load: () => import('./cache-probe.js'),
}
export default cacheProbe

View File

@@ -0,0 +1,157 @@
/**
* Tests for `/cache-stats` command rendering.
*
* The command has non-trivial string formatting (timestamp slicing, model
* label padding, conditional N/A footnote, recent-rows cap) which can
* silently regress — these snapshot tests keep it honest.
*/
import { beforeEach, describe, expect, test } from 'bun:test'
import type { CacheMetrics } from '../../services/api/cacheMetrics.js'
import {
_setHistoryCapForTesting,
recordRequest,
resetSessionCacheStats,
} from '../../services/api/cacheStatsTracker.js'
import { call } from './cacheStats.js'
function supported(partial: Partial<CacheMetrics>): CacheMetrics {
return {
read: 0,
created: 0,
total: 0,
hitRate: null,
supported: true,
...partial,
}
}
const UNSUPPORTED: CacheMetrics = {
read: 0,
created: 0,
total: 0,
hitRate: null,
supported: false,
}
// The command signature requires a LocalJSXCommandContext. Our command
// doesn't actually read it — we pass an empty stand-in so the test can
// invoke call() without dragging the whole REPL context in.
const EMPTY_CTX = {} as Parameters<typeof call>[1]
// /cache-stats always returns a text result. Narrow the union here so
// the assertions don't need to redo the discriminant check every call.
async function runCommand(): Promise<string> {
const result = await call('', EMPTY_CTX)
if (result.type !== 'text') {
throw new Error(
`cacheStats command must return type:'text', got ${result.type}`,
)
}
return result.value
}
beforeEach(() => {
resetSessionCacheStats()
_setHistoryCapForTesting(500)
})
describe('/cache-stats — empty session', () => {
test('shows friendly "no requests yet" message', async () => {
const value = await runCommand()
expect(value).toContain('No API requests yet this session')
expect(value).toContain('/cache-stats')
})
})
describe('/cache-stats — supported-only session', () => {
test('renders Cache stats header, turn and session summaries', async () => {
recordRequest(
supported({ read: 500, total: 1_000, hitRate: 0.5 }),
'claude-sonnet-4',
)
const value = await runCommand()
expect(value).toContain('Cache stats')
expect(value).toContain('Current turn:')
expect(value).toContain('Session total:')
// Compact metric line should appear in the recent-requests table.
expect(value).toContain('claude-sonnet-4')
expect(value).toContain('read')
})
test('omits the N/A footnote when every row is supported', async () => {
recordRequest(supported({ read: 200, total: 400, hitRate: 0.5 }), 'model-A')
const value = await runCommand()
expect(value).not.toContain('N/A rows')
})
})
describe('/cache-stats — mixed supported + unsupported', () => {
test('renders N/A footnote when any row is unsupported', async () => {
recordRequest(UNSUPPORTED, 'gpt-4-copilot')
recordRequest(
supported({ read: 100, total: 500, hitRate: 0.2 }),
'claude-sonnet-4',
)
const value = await runCommand()
expect(value).toContain(
'N/A rows: provider API does not expose cache usage',
)
expect(value).toContain('GitHub Copilot')
expect(value).toContain('Ollama')
})
})
describe('/cache-stats — recent-rows cap', () => {
test('caps the breakdown at 20 rows and reports omitted count', async () => {
for (let i = 0; i < 25; i++) {
recordRequest(
supported({ read: i, total: 100, hitRate: i / 100 }),
`model-${i}`,
)
}
const value = await runCommand()
// 20 shown, 5 omitted from the oldest end.
expect(value).toContain('(20 of 25, 5 older omitted)')
// Oldest rows (model-0..model-4) should not appear; newest must.
expect(value).toContain('model-24')
expect(value).not.toContain('model-0 ')
})
test('does not mention "older omitted" when all rows fit', async () => {
for (let i = 0; i < 5; i++) {
recordRequest(supported({ read: i, total: 10 }), `m${i}`)
}
const value = await runCommand()
expect(value).not.toContain('older omitted')
expect(value).toContain('(5)')
})
})
describe('/cache-stats — model label rendering', () => {
test('truncates long model labels to fit the column width', async () => {
// cacheStats.ts pads+slices the label to 28 chars for alignment.
const longLabel = 'some-extremely-long-model-identifier-that-wraps'
recordRequest(supported({ read: 10, total: 100, hitRate: 0.1 }), longLabel)
const value = await runCommand()
// Sliced to 28 chars.
expect(value).toContain(longLabel.slice(0, 28))
// And the full string should NOT appear (would mean no truncation).
expect(value).not.toContain(longLabel)
})
})
describe('/cache-stats — timestamp rendering', () => {
test('renders each row with full date and time (YYYY-MM-DD HH:MM:SS)', async () => {
recordRequest(supported({ read: 5, total: 10, hitRate: 0.5 }), 'claude-x')
const value = await runCommand()
// Match the full ISO-ish date + time the row uses. We assert the shape,
// not a specific timestamp — real clock is used, so a regex on the
// format is the right assertion.
expect(value).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)
// Bare time-of-day alone (no date) should NOT appear in isolation — it
// must always be preceded by the date. Guards against regression if
// someone shortens the formatter again.
const timeOnlyInRow = /\n\s*#\s*\d+\s+\d{2}:\d{2}:\d{2}\s/.test(value)
expect(timeOnlyInRow).toBe(false)
})
})

View File

@@ -0,0 +1,74 @@
import {
getCacheStatsHistory,
getCurrentTurnCacheMetrics,
getSessionCacheMetrics,
type CacheStatsEntry,
} from '../../services/api/cacheStatsTracker.js'
import {
formatCacheMetricsCompact,
formatCacheMetricsFull,
type CacheMetrics,
} from '../../services/api/cacheMetrics.js'
import type { LocalCommandCall } from '../../types/command.js'
// Cap the per-request breakdown to keep output readable. Users wanting
// the full history can rely on OPENCLAUDE_LOG_TOKEN_USAGE=verbose for
// structured per-request stderr output.
const MAX_RECENT_ROWS = 20
function formatRow(entry: CacheStatsEntry, idx: number): string {
// `YYYY-MM-DD HH:MM:SS` — long-running sessions can span midnight and a
// bare time-of-day makes the wrong row look "most recent" when two
// entries on different days share the same HH:MM:SS.
const iso = new Date(entry.timestamp).toISOString()
const ts = `${iso.slice(0, 10)} ${iso.slice(11, 19)}`
const line = formatCacheMetricsCompact(entry.metrics)
return ` #${String(idx + 1).padStart(3)} ${ts} ${entry.label.padEnd(28).slice(0, 28)} ${line}`
}
function summarize(label: string, m: CacheMetrics): string {
return `${label.padEnd(18)}${formatCacheMetricsFull(m)}`
}
export const call: LocalCommandCall = async () => {
const history = getCacheStatsHistory()
const session = getSessionCacheMetrics()
const turn = getCurrentTurnCacheMetrics()
if (history.length === 0) {
return {
type: 'text',
value:
'Cache stats\n No API requests yet this session.\n Start a turn and re-run /cache-stats to see results.',
}
}
const recent = history.slice(-MAX_RECENT_ROWS)
const omitted = history.length - recent.length
const lines: string[] = ['Cache stats', '']
lines.push(summarize('Current turn:', turn))
lines.push(summarize('Session total:', session))
lines.push('')
lines.push(`Recent requests (${recent.length}${omitted > 0 ? ` of ${history.length}, ${omitted} older omitted` : ''}):`)
lines.push(` # time model cache`)
for (const [i, entry] of recent.entries()) {
lines.push(formatRow(entry, history.length - recent.length + i))
}
// Honesty footnote — providers without cache reporting (vanilla Copilot,
// Ollama) show [Cache: N/A] rather than a fake 0%. Tell the user so they
// don't read "N/A" as "broken".
const hasUnsupported = recent.some((e) => !e.metrics.supported)
if (hasUnsupported) {
lines.push('')
lines.push(
' N/A rows: provider API does not expose cache usage (GitHub Copilot, Ollama).',
)
lines.push(
' The request still ran normally — only the metric is unavailable.',
)
}
return { type: 'text', value: lines.join('\n') }
}

View File

@@ -0,0 +1,24 @@
/**
* /cache-stats — per-session cache diagnostics.
*
* Always-on diagnostic command (no toggle) that surfaces the metrics
* tracked in `cacheStatsTracker.ts`. Breaks cache usage down by request
* and also reports the session-wide aggregate — useful when the user
* suspects a cache bust (e.g. after /reload-plugins) and wants to see
* whether recent turns still hit the cache.
*
* Lazy-loaded (implementation in cacheStats.ts) to keep startup time
* minimal — same pattern used by /cost and /cache-probe.
*/
import type { Command } from '../../commands.js'
const cacheStats = {
type: 'local',
name: 'cache-stats',
description:
'Show per-turn and session cache hit/miss stats (works across all providers)',
supportsNonInteractive: true,
load: () => import('./cacheStats.js'),
} satisfies Command
export default cacheStats

View File

@@ -197,7 +197,7 @@ function ClaudeInChromeMenu(t0) {
}
let t6;
if ($[20] === Symbol.for("react.memo_cache_sentinel")) {
t6 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.</Text>;
t6 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from OpenClaude. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.</Text>;
$[20] = t6;
} else {
t6 = $[20];

View File

@@ -45,7 +45,7 @@ function getPromptContent(
<!-- CHANGELOG:END -->`
let slackStep = `
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
5. After creating/updating the PR, check if the user's AGENTS.md or CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
prefix = getUndercoverInstructions() + '\n'
reviewerArg = ''

View File

@@ -48,7 +48,7 @@ export function createMovedToPluginCommand({
text: `This command has been moved to a plugin. Tell the user:
1. To install the plugin, run:
claude plugin install ${pluginName}@claude-code-marketplace
openclaude plugin install ${pluginName}@claude-code-marketplace
2. After installation, use /${pluginName}:${pluginCommand} to run this command

View File

@@ -3,7 +3,7 @@ import { isEnvTruthy } from '../../utils/envUtils.js'
const doctor: Command = {
name: 'doctor',
description: 'Diagnose and verify your Claude Code installation and settings',
description: 'Diagnose and verify your OpenClaude installation and settings',
isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND),
type: 'local-jsx',
load: () => import('./doctor.js'),

View File

@@ -7,7 +7,7 @@ const feedback = {
aliases: ['bug'],
type: 'local-jsx',
name: 'feedback',
description: `Submit feedback about Claude Code`,
description: `Submit feedback about OpenClaude`,
argumentHint: '[report]',
isEnabled: () =>
!(

43
src/commands/init.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { afterEach, expect, mock, test } from 'bun:test'
const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT
async function importInitCommand() {
return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default
}
afterEach(() => {
mock.restore()
if (originalClaudeCodeNewInit === undefined) {
delete process.env.CLAUDE_CODE_NEW_INIT
} else {
process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit
}
})
test('NEW_INIT prompt preserves existing root CLAUDE.md by default', async () => {
process.env.CLAUDE_CODE_NEW_INIT = '1'
mock.module('../projectOnboardingState.js', () => ({
maybeMarkProjectOnboardingComplete: () => {},
}))
mock.module('./initMode.js', () => ({
isNewInitEnabled: () => true,
}))
const command = await importInitCommand()
const blocks = await command.getPromptForCommand()
expect(blocks).toHaveLength(1)
expect(blocks[0]?.type).toBe('text')
expect(String(blocks[0]?.text)).toContain(
'checked-in root `CLAUDE.md` and does NOT already have a root `AGENTS.md`',
)
expect(String(blocks[0]?.text)).toContain(
'do NOT silently create a second root instruction file',
)
expect(String(blocks[0]?.text)).toContain(
'update the existing root `CLAUDE.md` in place by default',
)
})

View File

@@ -1,7 +1,6 @@
import { feature } from 'bun:bundle'
import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { isNewInitEnabled } from './initMode.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
@@ -25,19 +24,19 @@ Usage notes:
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\``
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
const NEW_INIT_PROMPT = `Set up a minimal AGENTS.md (and optionally CLAUDE.local.md, skills, and hooks) for this repo. The root project instruction file is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
## Phase 1: Ask what to set up
Use AskUserQuestion to find out what the user wants:
- "Which CLAUDE.md files should /init set up?"
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
- "Which instruction files should /init set up?"
Options: "Project AGENTS.md" | "Personal CLAUDE.local.md" | "Both project + personal"
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
- "Also set up skills and hooks?"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just the instruction file(s)"
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
@@ -59,24 +58,24 @@ Note what you could NOT figure out from code alone — these become interview qu
## Phase 3: Fill in the gaps
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer.
Use AskUserQuestion to gather what you still need to write good instruction files and skills. Ask only things the code can't answer.
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose project AGENTS.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project AGENTS.md — that would check a personal reference into the team-shared file.
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, an AGENTS.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
- **AGENTS.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into.
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or an AGENTS.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes an AGENTS.md note. Never propose an artifact type the user didn't opt into.
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
@@ -86,17 +85,19 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
• **AGENTS.md note** (guideline) — "run lint/typecheck/test before marking done"
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
## Phase 4: Write CLAUDE.md (if user chose project or both)
## Phase 4: Write AGENTS.md (if user chose project or both)
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
Write a minimal AGENTS.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
If the repo already has a checked-in root \`CLAUDE.md\` and does NOT already have a root \`AGENTS.md\`, do NOT silently create a second root instruction file. In that case, update the existing root \`CLAUDE.md\` in place by default. Only create or migrate to root \`AGENTS.md\` if the user explicitly asks to migrate.
**Consume \`note\` entries from the Phase 3 preference queue whose target is AGENTS.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
Include:
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
@@ -111,7 +112,7 @@ Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
- Standard language conventions Claude already knows
- Generic advice ("write clean code", "handle errors")
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating AGENTS.md
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
@@ -123,20 +124,20 @@ Do not repeat yourself and do not make up sections like "Common Development Task
Prefix the file with:
\`\`\`
# CLAUDE.md
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\`
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
If AGENTS.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside AGENTS.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory AGENTS.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside AGENTS.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
@@ -147,7 +148,7 @@ Include:
Keep it short — only include what would make Claude's responses noticeably better for this user.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project AGENTS.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
@@ -183,7 +184,7 @@ Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For w
## Phase 7: Suggest additional optimizations
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place.
Tell the user you're going to suggest a few additional optimizations now that AGENTS.md and skills (if chosen) are in place.
Check the environment and ask about each gap you find (use AskUserQuestion):
@@ -195,7 +196,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion):
For each hook preference (from the queue or the formatter fallback):
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
1. Target file: default based on the Phase 1 instruction-file choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
2. Pick the event and matcher from the preference:
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
@@ -227,11 +228,9 @@ const command = {
type: 'prompt',
name: 'init',
get description() {
return feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new CLAUDE.md file with codebase documentation'
return isNewInitEnabled()
? 'Initialize new project instruction file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new project instruction file with codebase documentation'
},
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your codebase',
@@ -242,12 +241,7 @@ const command = {
return [
{
type: 'text',
text:
feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? NEW_INIT_PROMPT
: OLD_INIT_PROMPT,
text: isNewInitEnabled() ? NEW_INIT_PROMPT : OLD_INIT_PROMPT,
},
]
},

13
src/commands/initMode.ts Normal file
View File

@@ -0,0 +1,13 @@
import { feature } from 'bun:bundle'
import { isEnvTruthy } from '../utils/envUtils.js'
export function isNewInitEnabled(): boolean {
if (feature('NEW_INIT')) {
return (
process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)
)
}
return false
}

View File

@@ -1,17 +1,12 @@
import { execFileSync } from 'child_process'
import { diffLines } from 'diff'
import { constants as fsConstants } from 'fs'
import {
copyFile,
mkdir,
mkdtemp,
readdir,
readFile,
rm,
unlink,
writeFile,
} from 'fs/promises'
import { tmpdir } from 'os'
import { extname, join } from 'path'
import type { Command } from '../commands.js'
import { queryWithModel } from '../services/api/claude.js'
@@ -22,7 +17,6 @@ import {
import type { LogOption } from '../types/logs.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { toError } from '../utils/errors.js'
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
import { logError } from '../utils/log.js'
import { extractTextContent } from '../utils/messages.js'
import { getDefaultOpusModel } from '../utils/model/model.js'
@@ -47,180 +41,6 @@ function getInsightsModel(): string {
return getDefaultOpusModel()
}
// ============================================================================
// Homespace Data Collection
// ============================================================================
type RemoteHostInfo = {
name: string
sessionCount: number
}
/* eslint-disable custom-rules/no-process-env-top-level */
const getRunningRemoteHosts: () => Promise<string[]> =
process.env.USER_TYPE === 'ant'
? async () => {
const { stdout, code } = await execFileNoThrow(
'coder',
['list', '-o', 'json'],
{ timeout: 30000 },
)
if (code !== 0) return []
try {
const workspaces = jsonParse(stdout) as Array<{
name: string
latest_build?: { status?: string }
}>
return workspaces
.filter(w => w.latest_build?.status === 'running')
.map(w => w.name)
} catch {
return []
}
}
: async () => []
const getRemoteHostSessionCount: (hs: string) => Promise<number> =
process.env.USER_TYPE === 'ant'
? async (homespace: string) => {
const { stdout, code } = await execFileNoThrow(
'ssh',
[
`${homespace}.coder`,
'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l',
],
{ timeout: 30000 },
)
if (code !== 0) return 0
return parseInt(stdout.trim(), 10) || 0
}
: async () => 0
const collectFromRemoteHost: (
hs: string,
destDir: string,
) => Promise<{ copied: number; skipped: number }> =
process.env.USER_TYPE === 'ant'
? async (homespace: string, destDir: string) => {
const result = { copied: 0, skipped: 0 }
// Create temp directory
const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-'))
try {
// SCP the projects folder
const scpResult = await execFileNoThrow(
'scp',
['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir],
{ timeout: 300000 },
)
if (scpResult.code !== 0) {
// SCP failed
return result
}
const projectsDir = join(tempDir, 'projects')
let projectDirents: Awaited<ReturnType<typeof readdir>>
try {
projectDirents = await readdir(projectsDir, { withFileTypes: true })
} catch {
return result
}
// Merge into destination (parallel per project directory)
await Promise.all(
projectDirents.map(async dirent => {
const projectName = dirent.name
const projectPath = join(projectsDir, projectName)
// Skip if not a directory
if (!dirent.isDirectory()) return
const destProjectName = `${projectName}__${homespace}`
const destProjectPath = join(destDir, destProjectName)
try {
await mkdir(destProjectPath, { recursive: true })
} catch {
// Directory may already exist
}
// Copy session files (skip existing)
let files: Awaited<ReturnType<typeof readdir>>
try {
files = await readdir(projectPath, { withFileTypes: true })
} catch {
return
}
await Promise.all(
files.map(async fileDirent => {
const fileName = fileDirent.name
if (!fileName.endsWith('.jsonl')) return
const srcFile = join(projectPath, fileName)
const destFile = join(destProjectPath, fileName)
try {
await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL)
result.copied++
} catch {
// EEXIST from COPYFILE_EXCL means dest already exists
result.skipped++
}
}),
)
}),
)
} finally {
try {
await rm(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
return result
}
: async () => ({ copied: 0, skipped: 0 })
const collectAllRemoteHostData: (destDir: string) => Promise<{
hosts: RemoteHostInfo[]
totalCopied: number
totalSkipped: number
}> =
process.env.USER_TYPE === 'ant'
? async (destDir: string) => {
const rHosts = await getRunningRemoteHosts()
const result: RemoteHostInfo[] = []
let totalCopied = 0
let totalSkipped = 0
// Collect from all hosts in parallel (SCP per host can take seconds)
const hostResults = await Promise.all(
rHosts.map(async hs => {
const sessionCount = await getRemoteHostSessionCount(hs)
if (sessionCount > 0) {
const { copied, skipped } = await collectFromRemoteHost(
hs,
destDir,
)
return { name: hs, sessionCount, copied, skipped }
}
return { name: hs, sessionCount, copied: 0, skipped: 0 }
}),
)
for (const hr of hostResults) {
result.push({ name: hr.name, sessionCount: hr.sessionCount })
totalCopied += hr.copied
totalSkipped += hr.skipped
}
return { hosts: result, totalCopied, totalSkipped }
}
: async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 })
/* eslint-enable custom-rules/no-process-env-top-level */
// ============================================================================
// Types
// ============================================================================
@@ -427,7 +247,7 @@ function getSessionMetaDir(): string {
return join(getDataDir(), 'session-meta')
}
const FACET_EXTRACTION_PROMPT = `Analyze this Claude Code session and extract structured facets.
const FACET_EXTRACTION_PROMPT = `Analyze this OpenClaude session and extract structured facets.
CRITICAL GUIDELINES:
@@ -867,7 +687,7 @@ function formatTranscriptForFacets(log: LogOption): string {
return lines.join('\n')
}
const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a Claude Code session transcript. Focus on:
const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a OpenClaude session transcript. Focus on:
1. What the user asked for
2. What Claude did (tools used, files modified)
3. Any friction or issues
@@ -1336,12 +1156,12 @@ type InsightSection = {
const INSIGHT_SECTIONS: InsightSection[] = [
{
name: 'project_areas',
prompt: `Analyze this Claude Code usage data and identify project areas.
prompt: `Analyze this OpenClaude usage data and identify project areas.
RESPOND WITH ONLY A VALID JSON OBJECT:
{
"areas": [
{"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how Claude Code was used."}
{"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how OpenClaude was used."}
]
}
@@ -1350,18 +1170,18 @@ Include 4-5 areas. Skip internal CC operations.`,
},
{
name: 'interaction_style',
prompt: `Analyze this Claude Code usage data and describe the user's interaction style.
prompt: `Analyze this OpenClaude usage data and describe the user's interaction style.
RESPOND WITH ONLY A VALID JSON OBJECT:
{
"narrative": "2-3 paragraphs analyzing HOW the user interacts with Claude Code. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.",
"narrative": "2-3 paragraphs analyzing HOW the user interacts with OpenClaude. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.",
"key_pattern": "One sentence summary of most distinctive interaction style"
}`,
maxTokens: 8192,
},
{
name: 'what_works',
prompt: `Analyze this Claude Code usage data and identify what's working well for this user. Use second person ("you").
prompt: `Analyze this OpenClaude usage data and identify what's working well for this user. Use second person ("you").
RESPOND WITH ONLY A VALID JSON OBJECT:
{
@@ -1376,7 +1196,7 @@ Include 3 impressive workflows.`,
},
{
name: 'friction_analysis',
prompt: `Analyze this Claude Code usage data and identify friction points for this user. Use second person ("you").
prompt: `Analyze this OpenClaude usage data and identify friction points for this user. Use second person ("you").
RESPOND WITH ONLY A VALID JSON OBJECT:
{
@@ -1391,7 +1211,7 @@ Include 3 friction categories with 2 examples each.`,
},
{
name: 'suggestions',
prompt: `Analyze this Claude Code usage data and suggest improvements.
prompt: `Analyze this OpenClaude usage data and suggest improvements.
## CC FEATURES REFERENCE (pick from these for features_to_try):
1. **MCP Servers**: Connect Claude to external tools, databases, and APIs via Model Context Protocol.
@@ -1434,7 +1254,7 @@ IMPORTANT for features_to_try: Pick 2-3 from the CC FEATURES REFERENCE above. In
},
{
name: 'on_the_horizon',
prompt: `Analyze this Claude Code usage data and identify future opportunities.
prompt: `Analyze this OpenClaude usage data and identify future opportunities.
RESPOND WITH ONLY A VALID JSON OBJECT:
{
@@ -1451,7 +1271,7 @@ Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iter
? [
{
name: 'cc_team_improvements',
prompt: `Analyze this Claude Code usage data and suggest product improvements for the CC team.
prompt: `Analyze this OpenClaude usage data and suggest product improvements for the CC team.
RESPOND WITH ONLY A VALID JSON OBJECT:
{
@@ -1465,7 +1285,7 @@ Include 2-3 improvements based on friction patterns observed.`,
},
{
name: 'model_behavior_improvements',
prompt: `Analyze this Claude Code usage data and suggest model behavior improvements.
prompt: `Analyze this OpenClaude usage data and suggest model behavior improvements.
RESPOND WITH ONLY A VALID JSON OBJECT:
{
@@ -1481,7 +1301,7 @@ Include 2-3 improvements based on friction patterns observed.`,
: []),
{
name: 'fun_ending',
prompt: `Analyze this Claude Code usage data and find a memorable moment.
prompt: `Analyze this OpenClaude usage data and find a memorable moment.
RESPOND WITH ONLY A VALID JSON OBJECT:
{
@@ -1735,7 +1555,7 @@ async function generateParallelInsights(
.join('\n') || ''
// Now generate "At a Glance" with access to other sections' outputs
const atAGlancePrompt = `You're writing an "At a Glance" summary for a Claude Code usage insights report for Claude Code users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve.
const atAGlancePrompt = `You're writing an "At a Glance" summary for a OpenClaude usage insights report for OpenClaude users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve.
Use this 4-part structure:
@@ -1743,7 +1563,7 @@ Use this 4-part structure:
2. **What's hindering you** - Split into (a) Claude's fault (misunderstandings, wrong approaches, bugs) and (b) user-side friction (not providing enough context, environment issues -- ideally more general than just one project). Be honest but constructive.
3. **Quick wins to try** - Specific Claude Code features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.)
3. **Quick wins to try** - Specific OpenClaude features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.)
4. **Ambitious workflows for better models** - As we move to much more capable models over the next 3-6 months, what should they prepare for? What workflows that seem impossible now will become possible? Draw from the appropriate section below.
@@ -2006,7 +1826,7 @@ function generateHtmlReport(
const interactionStyle = insights.interaction_style
const interactionHtml = interactionStyle?.narrative
? `
<h2 id="section-usage">How You Use Claude Code</h2>
<h2 id="section-usage">How You Use OpenClaude</h2>
<div class="narrative">
${markdownToHtml(interactionStyle.narrative)}
${interactionStyle.key_pattern ? `<div class="key-insight"><strong>Key pattern:</strong> ${escapeHtml(interactionStyle.key_pattern)}</div>` : ''}
@@ -2070,7 +1890,7 @@ function generateHtmlReport(
<h2 id="section-features">Existing CC Features to Try</h2>
<div class="claude-md-section">
<h3>Suggested CLAUDE.md Additions</h3>
<p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code to add it to your CLAUDE.md.</p>
<p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into OpenClaude to add it to your CLAUDE.md.</p>
<div class="claude-md-actions">
<button class="copy-all-btn" onclick="copyAllCheckedClaudeMd()">Copy All Checked</button>
</div>
@@ -2095,7 +1915,7 @@ function generateHtmlReport(
${
suggestions.features_to_try && suggestions.features_to_try.length > 0
? `
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll set it up for you.</p>
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into OpenClaude and it'll set it up for you.</p>
<div class="features-section">
${suggestions.features_to_try
.map(
@@ -2129,8 +1949,8 @@ function generateHtmlReport(
${
suggestions.usage_patterns && suggestions.usage_patterns.length > 0
? `
<h2 id="section-patterns">New Ways to Use Claude Code</h2>
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll walk you through it.</p>
<h2 id="section-patterns">New Ways to Use OpenClaude</h2>
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into OpenClaude and it'll walk you through it.</p>
<div class="patterns-section">
${suggestions.usage_patterns
.map(
@@ -2143,7 +1963,7 @@ function generateHtmlReport(
pat.copyable_prompt
? `
<div class="copyable-prompt-section">
<div class="prompt-label">Paste into Claude Code:</div>
<div class="prompt-label">Paste into OpenClaude:</div>
<div class="copyable-prompt-row">
<code class="copyable-prompt">${escapeHtml(pat.copyable_prompt)}</code>
<button class="copy-btn" onclick="copyText(this)">Copy</button>
@@ -2178,7 +1998,7 @@ function generateHtmlReport(
<div class="horizon-title">${escapeHtml(opp.title || '')}</div>
<div class="horizon-possible">${escapeHtml(opp.whats_possible || '')}</div>
${opp.how_to_try ? `<div class="horizon-tip"><strong>Getting started:</strong> ${escapeHtml(opp.how_to_try)}</div>` : ''}
${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into Claude Code:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''}
${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into OpenClaude:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''}
</div>
`,
)
@@ -2485,13 +2305,13 @@ function generateHtmlReport(
<html>
<head>
<meta charset="utf-8">
<title>Claude Code Insights</title>
<title>OpenClaude Insights</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>${css}</style>
</head>
<body>
<div class="container">
<h1>Claude Code Insights</h1>
<h1>OpenClaude Insights</h1>
<p class="subtitle">${data.total_messages.toLocaleString()} messages across ${data.total_sessions} sessions${data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? ` (${data.total_sessions_scanned.toLocaleString()} total)` : ''} | ${data.date_range.start} to ${data.date_range.end}</p>
${atAGlanceHtml}
@@ -2557,7 +2377,7 @@ function generateHtmlReport(
data.multi_clauding.overlap_events === 0
? `
<p style="font-size: 14px; color: #64748b; padding: 8px 0;">
No parallel session usage detected. You typically work with one Claude Code session at a time.
No parallel session usage detected. You typically work with one OpenClaude session at a time.
</p>
`
: `
@@ -2576,7 +2396,7 @@ function generateHtmlReport(
</div>
</div>
<p style="font-size: 13px; color: #475569; margin-top: 12px;">
You run multiple Claude Code sessions simultaneously. Multi-clauding is detected when sessions
You run multiple OpenClaude sessions simultaneously. Multi-clauding is detected when sessions
overlap in time, suggesting parallel workflows.
</p>
`
@@ -2659,7 +2479,6 @@ export type InsightsExport = {
claude_code_version: string
date_range: { start: string; end: string }
session_count: number
remote_hosts_collected?: string[]
}
aggregated_data: AggregatedData
insights: InsightResults
@@ -2680,14 +2499,9 @@ export function buildExportData(
data: AggregatedData,
insights: InsightResults,
facets: Map<string, SessionFacets>,
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
): InsightsExport {
const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown'
const remote_hosts_collected = remoteStats?.hosts
.filter(h => h.sessionCount > 0)
.map(h => h.name)
const facets_summary = {
total: facets.size,
goal_categories: {} as Record<string, number>,
@@ -2725,10 +2539,6 @@ export function buildExportData(
claude_code_version: version,
date_range: data.date_range,
session_count: data.total_sessions,
...(remote_hosts_collected &&
remote_hosts_collected.length > 0 && {
remote_hosts_collected,
}),
},
aggregated_data: data,
insights,
@@ -2793,24 +2603,12 @@ async function scanAllSessions(): Promise<LiteSessionInfo[]> {
// Main Function
// ============================================================================
export async function generateUsageReport(options?: {
collectRemote?: boolean
}): Promise<{
export async function generateUsageReport(): Promise<{
insights: InsightResults
htmlPath: string
data: AggregatedData
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
facets: Map<string, SessionFacets>
}> {
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
// Optionally collect data from remote hosts first (internal-only)
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
const destDir = join(getClaudeConfigHomeDir(), 'projects')
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
remoteStats = { hosts, totalCopied }
}
// Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
const allScannedSessions = await scanAllSessions()
const totalSessionsScanned = allScannedSessions.length
@@ -3017,7 +2815,6 @@ export async function generateUsageReport(options?: {
insights,
htmlPath,
data: aggregated,
remoteStats,
facets: substantiveFacets,
}
}
@@ -3039,35 +2836,12 @@ function safeKeys(obj: Record<string, unknown> | undefined | null): string[] {
const usageReport: Command = {
type: 'prompt',
name: 'insights',
description: 'Generate a report analyzing your Claude Code sessions',
description: 'Generate a report analyzing your OpenClaude sessions',
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your sessions',
source: 'builtin',
async getPromptForCommand(args) {
let collectRemote = false
let remoteHosts: string[] = []
let hasRemoteHosts = false
if (process.env.USER_TYPE === 'ant') {
// Parse --homespaces flag
collectRemote = args?.includes('--homespaces') ?? false
// Check for available remote hosts
remoteHosts = await getRunningRemoteHosts()
hasRemoteHosts = remoteHosts.length > 0
// Show collection message if collecting
if (collectRemote && hasRemoteHosts) {
// biome-ignore lint/suspicious/noConsole: intentional
console.error(
`Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
)
}
}
const { insights, htmlPath, data, remoteStats } = await generateUsageReport(
{ collectRemote },
)
async getPromptForCommand(_args) {
const { insights, htmlPath, data } = await generateUsageReport()
let reportUrl = `file://${htmlPath}`
let uploadHint = ''
@@ -3085,20 +2859,6 @@ const usageReport: Command = {
`${data.git_commits} commits`,
].join(' · ')
// Build remote host info (internal-only)
let remoteInfo = ''
if (process.env.USER_TYPE === 'ant') {
if (remoteStats && remoteStats.totalCopied > 0) {
const hsNames = remoteStats.hosts
.filter(h => h.sessionCount > 0)
.map(h => h.name)
.join(', ')
remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n`
} else if (!collectRemote && hasRemoteHosts) {
// Suggest using --homespaces if they have remote hosts but didn't use the flag
remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n`
}
}
// Build markdown summary from insights
const atAGlance = insights.at_a_glance
@@ -3114,11 +2874,10 @@ ${atAGlance.quick_wins ? `**Quick wins to try:** ${atAGlance.quick_wins} See _Fe
${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitious_workflows} See _On the Horizon_.` : ''}`
: '_No insights generated_'
const header = `# Claude Code Insights
const header = `# OpenClaude Insights
${stats}
${data.date_range.start} to ${data.date_range.end}
${remoteInfo}
`
const userSummary = `${header}${summaryText}
@@ -3129,7 +2888,7 @@ Your full shareable insights report is ready: ${reportUrl}${uploadHint}`
return [
{
type: 'text',
text: `The user just ran /insights to generate a usage report analyzing their Claude Code sessions.
text: `The user just ran /insights to generate a usage report analyzing their OpenClaude sessions.
Here is the full insights data:
${jsonStringify(insights, null, 2)}

View File

@@ -39,16 +39,16 @@ type InstallState = {
message: string;
warnings?: string[];
};
function getInstallationPath(): string {
export function getInstallationPath(): string {
const isWindows = env.platform === 'win32';
const homeDir = homedir();
if (isWindows) {
// Convert to Windows-style path
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe');
const windowsPath = join(homeDir, '.local', 'bin', 'openclaude.exe');
// Replace forward slashes with backslashes for Windows display
return windowsPath.replace(/\//g, '\\');
}
return '~/.local/bin/claude';
return '~/.local/bin/openclaude';
}
function SetupNotes(t0) {
const $ = _c(5);
@@ -210,12 +210,12 @@ function Install({
useEffect(() => {
if (state.type === 'success') {
// Give success message time to render before exiting
setTimeout(onDone, 2000, 'Claude Code installation completed successfully', {
setTimeout(onDone, 2000, 'OpenClaude installation completed successfully', {
display: 'system' as const
});
} else if (state.type === 'error') {
// Give error message time to render before exiting
setTimeout(onDone, 3000, 'Claude Code installation failed', {
setTimeout(onDone, 3000, 'OpenClaude installation failed', {
display: 'system' as const
});
}
@@ -226,7 +226,7 @@ function Install({
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
{state.type === 'installing' && <Text color="claude">
Installing Claude Code native build {state.version}...
Installing OpenClaude native build {state.version}...
</Text>}
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
@@ -237,7 +237,7 @@ function Install({
<Box>
<StatusIcon status="success" withSpace />
<Text color="success" bold>
Claude Code successfully installed!
OpenClaude successfully installed!
</Text>
</Box>
<Box marginLeft={2} flexDirection="column" gap={1}>
@@ -254,7 +254,7 @@ function Install({
<Box marginTop={1}>
<Text dimColor>Next: Run </Text>
<Text color="claude" bold>
claude --help
openclaude --help
</Text>
<Text dimColor> to get started</Text>
</Box>
@@ -279,7 +279,7 @@ function Install({
export const install = {
type: 'local-jsx' as const,
name: 'install',
description: 'Install Claude Code native build',
description: 'Install OpenClaude native build',
argumentHint: '[options]',
async call(onDone: (result: string, options?: {
display?: CommandResultDisplay;

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const knowledge: Command = {
type: 'local',
name: 'knowledge',
description: 'Manage native Knowledge Graph',
supportsNonInteractive: true,
argumentHint: 'enable <yes|no> | clear | status | list',
load: () => import('./knowledge.js'),
}
export default knowledge

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, beforeEach } from 'bun:test'
import { call as knowledgeCall } from './knowledge.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { getArc, addEntity, resetArc } from '../../utils/conversationArc.js'
import { getGlobalGraph, resetGlobalGraph } from '../../utils/knowledgeGraph.js'
describe('knowledge command', () => {
const mockContext = {} as any
beforeEach(() => {
resetArc()
resetGlobalGraph()
})
const knowledgeCallWithCapture = async (args: string) => {
const result = await knowledgeCall(args, mockContext)
if (result.type === 'text') {
return result.value
}
return ''
}
beforeEach(() => {
// Attempt to reset config - even if mocked, we try to set our key
try {
saveGlobalConfig(current => ({
...current,
knowledgeGraphEnabled: true
}))
} catch {
// Ignore if config is heavily mocked
}
resetArc()
})
it('enables and disables knowledge graph engine', async () => {
// Test Disable
const res1 = await knowledgeCallWithCapture('enable no')
expect(res1.toLowerCase()).toContain('disabled')
// Safety check: only verify state if property is actually present (avoid CI mock interference)
const config1 = getGlobalConfig()
if (config1 && 'knowledgeGraphEnabled' in config1) {
expect(config1.knowledgeGraphEnabled).toBe(false)
}
// Test Enable
const res2 = await knowledgeCallWithCapture('enable yes')
expect(res2.toLowerCase()).toContain('enabled')
const config2 = getGlobalConfig()
if (config2 && 'knowledgeGraphEnabled' in config2) {
expect(config2.knowledgeGraphEnabled).toBe(true)
}
})
it('clears the knowledge graph', async () => {
// Add a fact first
addEntity('test', 'fact')
const graph = getGlobalGraph()
expect(Object.keys(graph.entities).length).toBe(1)
// Clear it
const res = await knowledgeCallWithCapture('clear')
const graphAfter = getGlobalGraph()
expect(Object.keys(graphAfter.entities).length).toBe(0)
expect(res.toLowerCase()).toContain('cleared')
})
it('shows error on unknown subcommand', async () => {
const res = await knowledgeCallWithCapture('invalid')
expect(res.toLowerCase()).toContain('unknown subcommand')
})
})

View File

@@ -0,0 +1,63 @@
import type { LocalCommandCall } from '../../types/command.js';
import { getArcSummary, resetArc, getArcStats } from '../../utils/conversationArc.js';
import { getGlobalGraph, resetGlobalGraph } from '../../utils/knowledgeGraph.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import chalk from 'chalk';
export const call: LocalCommandCall = async (args, _context) => {
const arg = (args ? String(args) : '').trim().toLowerCase();
const splitArgs = arg.split(/\s+/).filter(Boolean);
const subCommand = splitArgs[0];
if (!subCommand || subCommand === 'status') {
const config = getGlobalConfig();
const stats = getArcStats();
const graph = getGlobalGraph();
const entityCount = Object.keys(graph.entities).length;
const statusText = (config.knowledgeGraphEnabled !== false)
? chalk.green('ENABLED')
: chalk.red('DISABLED');
let output = `${chalk.bold('Knowledge Graph Engine')}: ${statusText}\n`;
if (stats) {
output += `• Stats: ${stats.goalCount} goals, ${stats.milestoneCount} milestones, ${entityCount} technical facts learned`;
}
return { type: 'text', value: output };
}
if (subCommand === 'enable') {
const val = splitArgs[1];
const isEnabled = val === 'yes' || val === 'true';
const isDisabled = val === 'no' || val === 'false';
if (!isEnabled && !isDisabled) {
return { type: 'text', value: 'Usage: /knowledge enable <yes|no>' };
}
saveGlobalConfig(current => ({ ...current, knowledgeGraphEnabled: isEnabled }));
return {
type: 'text',
value: `✨ Knowledge Graph engine ${isEnabled ? chalk.green('enabled') : chalk.red('disabled')}.`
};
}
if (subCommand === 'clear') {
resetArc();
resetGlobalGraph();
return {
type: 'text',
value: '🗑️ Knowledge graph memory has been cleared for this session.'
};
}
if (subCommand === 'list') {
return { type: 'text', value: getArcSummary() };
}
return {
type: 'text',
value: `Unknown subcommand: ${subCommand}. Available: enable, clear, status, list`
};
};

View File

@@ -34,16 +34,16 @@ export function registerMcpAddCommand(mcp: Command): void {
mcp
.command('add <name> <commandOrUrl> [args...]')
.description(
'Add an MCP server to Claude Code.\n\n' +
'Add an MCP server to OpenClaude.\n\n' +
'Examples:\n' +
' # Add HTTP server:\n' +
' claude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' +
' openclaude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' +
' # Add HTTP server with headers:\n' +
' claude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' +
' openclaude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' +
' # Add stdio server with environment variables:\n' +
' claude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' +
' openclaude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' +
' # Add stdio server with subprocess flags:\n' +
' claude mcp add my-server -- my-command --some-flag arg1',
' openclaude mcp add my-server -- my-command --some-flag arg1',
)
.option(
'-s, --scope <scope>',
@@ -75,7 +75,7 @@ export function registerMcpAddCommand(mcp: Command): void {
.addOption(
new Option(
'--xaa',
"Enable XAA (SEP-990) for this server. Requires 'claude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).",
"Enable XAA (SEP-990) for this server. Requires 'openclaude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).",
).hideHelp(!isXaaEnabled()),
)
.action(async (name, commandOrUrl, args, options) => {
@@ -87,12 +87,12 @@ export function registerMcpAddCommand(mcp: Command): void {
if (!name) {
cliError(
'Error: Server name is required.\n' +
'Usage: claude mcp add <name> <command> [args...]',
'Usage: openclaude mcp add <name> <command> [args...]',
)
} else if (!actualCommand) {
cliError(
'Error: Command is required when server name is provided.\n' +
'Usage: claude mcp add <name> <command> [args...]',
'Usage: openclaude mcp add <name> <command> [args...]',
)
}
@@ -113,7 +113,7 @@ export function registerMcpAddCommand(mcp: Command): void {
if (!options.clientSecret) missing.push('--client-secret')
if (!getXaaIdpSettings()) {
missing.push(
"'claude mcp xaa setup' (settings.xaaIdp not configured)",
"'openclaude mcp xaa setup' (settings.xaaIdp not configured)",
)
}
if (missing.length) {
@@ -254,10 +254,10 @@ export function registerMcpAddCommand(mcp: Command): void {
`\nWarning: The command "${actualCommand}" looks like a URL, but is being interpreted as a stdio server as --transport was not specified.\n`,
)
process.stderr.write(
`If this is an HTTP server, use: claude mcp add --transport http ${name} ${actualCommand}\n`,
`If this is an HTTP server, use: openclaude mcp add --transport http ${name} ${actualCommand}\n`,
)
process.stderr.write(
`If this is an SSE server, use: claude mcp add --transport sse ${name} ${actualCommand}\n`,
`If this is an SSE server, use: openclaude mcp add --transport sse ${name} ${actualCommand}\n`,
)
}

View File

@@ -170,7 +170,7 @@ export function registerMcpXaaIdpCommand(mcp: Command): void {
const idp = getXaaIdpSettings()
if (!idp) {
return cliError(
"Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.",
"Error: no XAA IdP connection. Run 'openclaude mcp xaa setup' first.",
)
}
@@ -235,7 +235,7 @@ export function registerMcpXaaIdpCommand(mcp: Command): void {
`Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`,
)
process.stdout.write(
`Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`,
`Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'openclaude mcp xaa login'"}\n`,
)
cliOk()
})

View File

@@ -6,7 +6,7 @@ export default {
type: 'local-jsx',
name: 'model',
get description() {
return `Set the AI model for Claude Code (currently ${renderModelName(getMainLoopModel())})`
return `Set the AI model for OpenClaude (currently ${renderModelName(getMainLoopModel())})`
},
argumentHint: '[model]',
get immediate() {

View File

@@ -1,20 +1,44 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js'
import { getAPIProvider } from '../../utils/model/providers.js'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_MODEL: process.env.OPENAI_MODEL,
}
afterEach(() => {
mock.restore()
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
process.env.CLAUDE_CODE_USE_MISTRAL = originalEnv.CLAUDE_CODE_USE_MISTRAL
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK
process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
})
test('opens the model picker without awaiting local model discovery refresh', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.OPENAI_API_BASE
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1'
process.env.OPENAI_MODEL = 'qwen2.5-coder-7b-instruct'
@@ -30,7 +54,9 @@ test('opens the model picker without awaiting local model discovery refresh', as
discoverOpenAICompatibleModelOptions,
}))
const { call } = await import(`./model.js?ts=${Date.now()}-${Math.random()}`)
expect(getAdditionalModelOptionsCacheScope()).toBe('openai:http://127.0.0.1:8080/v1')
const { call } = await import('./model.js')
const result = await Promise.race([
call(() => {}, {} as never, ''),
new Promise(resolve => setTimeout(() => resolve('timeout'), 50)),

View File

@@ -284,7 +284,7 @@ function haveSameModelOptions(left: ModelOption[], right: ModelOption[]): boolea
});
}
async function refreshOpenAIModelOptionsCache(): Promise<void> {
if (getAPIProvider() !== 'openai') {
if (!getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) {
return;
}
try {

View File

@@ -4,7 +4,7 @@ const onboardGithub: Command = {
name: 'onboard-github',
aliases: ['onboarding-github', 'onboardgithub', 'onboardinggithub'],
description:
'Interactive setup for GitHub Models: device login or PAT, saved to secure storage',
'Interactive setup for GitHub Copilot: OAuth device login stored in secure storage',
type: 'local-jsx',
load: () => import('./onboard-github.js'),
}

View File

@@ -2,9 +2,9 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { Select } from '../../components/CustomSelect/select.js'
import { Spinner } from '../../components/Spinner.js'
import TextInput from '../../components/TextInput.js'
import { Box, Text } from '../../ink.js'
import {
exchangeForCopilotToken,
openVerificationUri,
pollAccessToken,
requestDeviceCode,
@@ -15,7 +15,7 @@ import {
readGithubModelsToken,
saveGithubModelsToken,
} from '../../utils/githubModelsCredentials.js'
import { updateSettingsForSource } from '../../utils/settings/settings.js'
import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'
const DEFAULT_MODEL = 'github:copilot'
const FORCE_RELOGIN_ARGS = new Set([
@@ -27,11 +27,25 @@ const FORCE_RELOGIN_ARGS = new Set([
'--reauth',
])
type Step =
| 'menu'
| 'device-busy'
| 'pat'
| 'error'
type Step = 'menu' | 'device-busy' | 'error'
const PROVIDER_SPECIFIC_KEYS = new Set([
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
'OPENAI_API_KEY',
'OPENAI_MODEL',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_BASE_URL',
'GEMINI_MODEL',
'GEMINI_ACCESS_TOKEN',
'GEMINI_AUTH_MODE',
])
export function shouldForceGithubRelogin(args?: string): boolean {
const normalized = (args ?? '').trim().toLowerCase()
@@ -41,15 +55,29 @@ export function shouldForceGithubRelogin(args?: string): boolean {
return normalized.split(/\s+/).some(arg => FORCE_RELOGIN_ARGS.has(arg))
}
const GITHUB_PAT_PREFIXES = ['ghp_', 'gho_','ghs_', 'ghr_', 'github_pat_']
function isGithubPat(token: string): boolean {
return GITHUB_PAT_PREFIXES.some(prefix => token.startsWith(prefix))
}
export function hasExistingGithubModelsLoginToken(
env: NodeJS.ProcessEnv = process.env,
storedToken?: string,
): boolean {
const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()
if (envToken) {
// PATs are no longer supported - require OAuth re-auth
if (isGithubPat(envToken)) {
return false
}
return true
}
const persisted = (storedToken ?? readGithubModelsToken())?.trim()
// PATs are no longer supported - require OAuth re-auth
if (persisted && isGithubPat(persisted)) {
return false
}
return Boolean(persisted)
}
@@ -97,8 +125,21 @@ export function applyGithubOnboardingProcessEnv(
}
function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
const currentSettings = getSettingsForSource('userSettings')
const currentEnv = currentSettings?.env ?? {}
const newEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(currentEnv)) {
if (!PROVIDER_SPECIFIC_KEYS.has(key)) {
newEnv[key] = value
}
}
newEnv.CLAUDE_CODE_USE_GITHUB = '1'
newEnv.OPENAI_MODEL = model
const { error } = updateSettingsForSource('userSettings', {
env: buildGithubOnboardingSettingsEnv(model) as any,
env: newEnv,
})
if (error) {
return { ok: false, detail: error.message }
@@ -143,12 +184,14 @@ function OnboardGithub(props: {
user_code: string
verification_uri: string
} | null>(null)
const [patDraft, setPatDraft] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
const finalize = useCallback(
async (token: string, model: string = DEFAULT_MODEL) => {
const saved = saveGithubModelsToken(token)
async (
token: string,
model: string = DEFAULT_MODEL,
oauthToken?: string,
) => {
const saved = saveGithubModelsToken(token, oauthToken)
if (!saved.success) {
setErrorMsg(saved.warning ?? 'Could not save token to secure storage.')
setStep('error')
@@ -165,8 +208,18 @@ function OnboardGithub(props: {
setStep('error')
return
}
// Clear stale provider-specific env vars from the current session
// so resolveProviderRequest() doesn't pick up a previous provider's
// base URL or key after onboarding completes.
for (const key of PROVIDER_SPECIFIC_KEYS) {
delete process.env[key]
}
process.env.CLAUDE_CODE_USE_GITHUB = '1'
process.env.OPENAI_MODEL = model.trim() || DEFAULT_MODEL
hydrateGithubModelsTokenFromSecureStorage()
onChangeAPIKey()
onDone(
'GitHub Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.',
'GitHub Copilot onboard complete. Copilot token and OAuth token stored in secure storage (Windows/Linux: ~/.claude/.credentials.json, macOS: Keychain fallback to ~/.claude/.credentials.json); user settings updated. Restart if the model does not switch.',
{ display: 'user' },
)
},
@@ -184,11 +237,12 @@ function OnboardGithub(props: {
verification_uri: device.verification_uri,
})
await openVerificationUri(device.verification_uri)
const token = await pollAccessToken(device.device_code, {
const oauthToken = await pollAccessToken(device.device_code, {
initialInterval: device.interval,
timeoutSeconds: device.expires_in,
})
await finalize(token, DEFAULT_MODEL)
const copilotToken = await exchangeForCopilotToken(oauthToken)
await finalize(copilotToken.token, DEFAULT_MODEL, oauthToken)
} catch (e) {
setErrorMsg(e instanceof Error ? e.message : String(e))
setStep('error')
@@ -227,7 +281,7 @@ function OnboardGithub(props: {
if (step === 'device-busy') {
return (
<Box flexDirection="column" gap={1}>
<Text>GitHub device login</Text>
<Text>GitHub Copilot sign-in</Text>
{deviceHint ? (
<>
<Text>
@@ -246,43 +300,11 @@ function OnboardGithub(props: {
)
}
if (step === 'pat') {
return (
<Box flexDirection="column" gap={1}>
<Text>Paste a GitHub personal access token with access to GitHub Models.</Text>
<Text dimColor>Input is masked. Enter to submit; Esc to go back.</Text>
<TextInput
value={patDraft}
mask="*"
onChange={setPatDraft}
onSubmit={async (value: string) => {
const t = value.trim()
if (!t) {
return
}
await finalize(t, DEFAULT_MODEL)
}}
onExit={() => {
setStep('menu')
setPatDraft('')
}}
columns={80}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</Box>
)
}
const menuOptions = [
{
label: 'Sign in with browser (device code)',
label: 'Sign in with browser',
value: 'device' as const,
},
{
label: 'Paste personal access token',
value: 'pat' as const,
},
{
label: 'Cancel',
value: 'cancel' as const,
@@ -291,7 +313,7 @@ function OnboardGithub(props: {
return (
<Box flexDirection="column" gap={1}>
<Text bold>GitHub Models setup</Text>
<Text bold>GitHub Copilot setup</Text>
<Text dimColor>
Stores your token in the OS credential store (macOS Keychain when available)
and enables CLAUDE_CODE_USE_GITHUB in your user settings - no export
@@ -304,10 +326,6 @@ function OnboardGithub(props: {
onDone('GitHub onboard cancelled', { display: 'system' })
return
}
if (v === 'pat') {
setStep('pat')
return
}
void runDeviceFlow()
}}
/>

View File

@@ -713,7 +713,7 @@ function EmptyStateMessage(t0) {
{
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart Claude Code.</Text></>;
t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart OpenClaude.</Text></>;
$[0] = t1;
} else {
t1 = $[0];

View File

@@ -3,7 +3,7 @@ const plugin = {
type: 'local-jsx',
name: 'plugin',
aliases: ['plugins', 'marketplace'],
description: 'Manage Claude Code plugins',
description: 'Manage OpenClaude plugins',
immediate: true,
load: () => import('./plugin.js')
} satisfies Command;

View File

@@ -1,20 +1,29 @@
import { PassThrough } from 'node:stream'
import { expect, test } from 'bun:test'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot, render, useApp } from '../../ink.js'
import { AppStateProvider } from '../../state/AppState.js'
import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
buildCurrentProviderSummary,
buildProfileSaveMessage,
buildProviderManagerCompletion,
getProviderWizardDefaults,
ProviderWizard,
TextEntryDialog,
} from './provider.js'
import { createProfileFile } from '../../utils/providerProfile.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const ORIGINAL_SIMPLE_ENV = process.env.CLAUDE_CODE_SIMPLE
const ORIGINAL_CODEX_API_KEY = process.env.CODEX_API_KEY
const ORIGINAL_CHATGPT_ACCOUNT_ID = process.env.CHATGPT_ACCOUNT_ID
const ORIGINAL_CODEX_ACCOUNT_ID = process.env.CODEX_ACCOUNT_ID
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
@@ -60,6 +69,51 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
return stripAnsi(extractLastFrame(getOutput()))
}
async function waitForOutput(
getOutput: () => string,
predicate: (output: string) => boolean,
timeoutMs = 2500,
): Promise<string> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const output = stripAnsi(extractLastFrame(getOutput()))
if (predicate(output)) {
return output
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for ProviderWizard test output')
}
async function renderProviderWizardFrame(): Promise<string> {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<ProviderWizard onDone={() => {}} />
</AppStateProvider>,
)
try {
return await waitForOutput(
getOutput,
output => output.includes('Set up a provider profile'),
)
} finally {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
}
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
@@ -94,6 +148,34 @@ function createTestStreams(): {
}
}
afterEach(() => {
mock.restore()
if (ORIGINAL_SIMPLE_ENV === undefined) {
delete process.env.CLAUDE_CODE_SIMPLE
} else {
process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV
}
if (ORIGINAL_CODEX_API_KEY === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY
}
if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) {
delete process.env.CHATGPT_ACCOUNT_ID
} else {
process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID
}
if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) {
delete process.env.CODEX_ACCOUNT_ID
} else {
process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID
}
})
function StepChangeHarness(): React.ReactNode {
const { exit } = useApp()
const [step, setStep] = React.useState<'api' | 'model'>('api')
@@ -183,6 +265,32 @@ test('wizard step remount prevents a typed API key from leaking into the next fi
expect(output).not.toContain('sk-secret-12345678')
})
test('buildProviderManagerCompletion records provider switch event and model-visible reminder', () => {
const completion = buildProviderManagerCompletion({
action: 'activated',
activeProviderName: 'Sadaf Provider',
activeProviderModel: 'sadaf-model',
message: 'Provider switched to Sadaf Provider (sadaf-model)',
})
expect(completion.message).toBe(
'Provider switched to Sadaf Provider (sadaf-model)',
)
expect(completion.metaMessages).toEqual([
'<system-reminder>Provider switched mid-session to Sadaf Provider using model sadaf-model. Use this provider/model for subsequent requests unless the user switches again.</system-reminder>',
])
})
test('buildProviderManagerCompletion skips provider reminder when manager is cancelled', () => {
const completion = buildProviderManagerCompletion({
action: 'cancelled',
message: 'Provider manager closed',
})
expect(completion.message).toBe('Provider manager closed')
expect(completion.metaMessages).toBeUndefined()
})
test('buildProfileSaveMessage maps provider fields without echoing secrets', () => {
const message = buildProfileSaveMessage(
'openai',
@@ -233,6 +341,167 @@ test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly',
expect(message).not.toContain('AIza')
})
test('buildProfileSaveMessage reflects immediate Codex activation for existing credentials', () => {
const message = buildProfileSaveMessage(
'codex',
{
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_codex',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
{
activatedInSession: true,
},
)
expect(message).toContain('Saved Codex profile.')
expect(message).toContain('OpenClaude switched to it for this session.')
expect(message).not.toContain('Restart OpenClaude to use it.')
})
test('buildProfileSaveMessage reflects immediate Codex OAuth activation when the session switched successfully', () => {
const message = buildProfileSaveMessage(
'codex',
{
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_codex',
CODEX_CREDENTIAL_SOURCE: 'oauth',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
{
activatedInSession: true,
},
)
expect(message).toContain('Saved Codex profile.')
expect(message).toContain('OpenClaude switched to it for this session.')
expect(message).not.toContain('Restart OpenClaude to use it.')
})
test('buildCodexOAuthProfileEnv uses the fresh OAuth account id without persisting an API key', () => {
process.env.CODEX_API_KEY = 'stale-codex-key'
process.env.CHATGPT_ACCOUNT_ID = 'acct_stale'
const env = buildCodexOAuthProfileEnv({
accessToken: 'oauth-access-token',
accountId: 'acct_oauth',
})
expect(env).toEqual({
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
expect(env).not.toHaveProperty('CODEX_API_KEY')
})
test('buildCodexProfileEnv derives oauth source from secure storage when no explicit source is provided', async () => {
const actualProviderConfig = await import('../../services/api/providerConfig.js')
mock.module('../../services/api/providerConfig.js', () => ({
...actualProviderConfig,
resolveCodexApiCredentials: () => ({
apiKey: 'stored-access-token',
accountId: 'acct_secure_storage',
source: 'secure-storage' as const,
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { buildCodexProfileEnv } = await import(
'../../utils/providerProfile.js?secure-storage-codex-source'
)
const env = buildCodexProfileEnv({
model: 'codexplan',
processEnv: {},
})
expect(env).toEqual({
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: 'acct_secure_storage',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
})
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex'
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
OPENAI_API_KEY: 'sk-openai',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_codex',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'provider_old',
}
const profileFile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_codex',
})
const warning = await applySavedProfileToCurrentSession({
profileFile,
processEnv,
})
expect(warning).toBeNull()
expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeUndefined()
expect(processEnv.OPENAI_API_KEY).toBe("sk-openai")
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
CODEX_API_KEY: 'stale-codex-key',
CHATGPT_ACCOUNT_ID: 'acct_stale',
}
const profileFile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
const warning = await applySavedProfileToCurrentSession({
profileFile,
processEnv,
})
expect(warning).not.toBeUndefined()
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBe("stale-codex-key")
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_stale')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
})
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
@@ -245,8 +514,8 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', (
})
expect(summary.providerLabel).toBe('OpenAI-compatible')
expect(summary.modelLabel).toBe('sk-...5678')
expect(summary.endpointLabel).toBe('sk-...5678')
expect(summary.modelLabel).toBe('sk-...678')
expect(summary.endpointLabel).toBe('sk-...678')
})
test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => {
@@ -264,7 +533,7 @@ test('buildCurrentProviderSummary labels generic local openai-compatible provide
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
})
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex', () => {
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex when custom base URL is set', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
@@ -307,3 +576,12 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
expect(defaults.geminiModel).toBe('gemini-2.0-flash')
})
test('ProviderWizard hides Codex OAuth while running in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const output = await renderProviderWizardFrame()
expect(output).toContain('Set up a provider profile')
expect(output).not.toContain('Codex OAuth')
})

View File

@@ -2,7 +2,10 @@ import * as React from 'react'
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
import { ProviderManager } from '../../components/ProviderManager.js'
import {
ProviderManager,
type ProviderManagerResult,
} from '../../components/ProviderManager.js'
import TextInput from '../../components/TextInput.js'
import {
Select,
@@ -10,8 +13,12 @@ import {
} from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js'
import {
type CodexOAuthTokens,
} from '../../services/api/codexOAuth.js'
import {
DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL,
@@ -20,13 +27,18 @@ import {
resolveProviderRequest,
} from '../../services/api/providerConfig.js'
import {
applySavedProfileToCurrentSession as applySharedProfileToCurrentSession,
buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv,
buildCodexProfileEnv,
buildGeminiProfileEnv,
buildMistralProfileEnv,
buildOllamaProfileEnv,
buildOpenAIProfileEnv,
createProfileFile,
DEFAULT_GEMINI_BASE_URL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MISTRAL_BASE_URL,
DEFAULT_MISTRAL_MODEL,
deleteProfileFile,
loadProfileFile,
maskSecretForDisplay,
@@ -46,6 +58,7 @@ import {
readGeminiAccessToken,
saveGeminiAccessToken,
} from '../../utils/geminiCredentials.js'
import { isBareMode } from '../../utils/envUtils.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
@@ -54,12 +67,70 @@ import {
type RecommendationGoal,
} from '../../utils/providerRecommendation.js'
import {
getOllamaChatBaseUrl,
getLocalOpenAICompatibleProviderLabel,
hasLocalOllama,
listOllamaModels,
probeOllamaGenerationReadiness,
type OllamaGenerationReadiness,
} from '../../utils/providerDiscovery.js'
type ProviderChoice = 'auto' | ProviderProfile | 'clear'
export function buildProviderManagerCompletion(result?: ProviderManagerResult): {
message: string
metaMessages?: string[]
} {
const message =
result?.message ??
(result?.action === 'saved'
? 'Provider profile updated'
: 'Provider manager closed')
const metaMessages =
result?.action === 'activated' && result.activeProviderName
? [
`<system-reminder>Provider switched mid-session to ${result.activeProviderName}${
result.activeProviderModel
? ` using model ${result.activeProviderModel}`
: ''
}. Use this provider/model for subsequent requests unless the user switches again.</system-reminder>`,
]
: undefined
return { message, metaMessages }
}
function describeOllamaReadinessIssue(
readiness: OllamaGenerationReadiness,
options?: {
baseUrl?: string
allowManualFallback?: boolean
},
): string {
const endpoint = options?.baseUrl ?? 'http://localhost:11434'
if (readiness.state === 'unreachable') {
return `Could not reach Ollama at ${endpoint}. Start Ollama first, then run /provider again.`
}
if (readiness.state === 'no_models') {
const manualSuffix = options?.allowManualFallback
? ', or enter details manually'
: ''
return `Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first${manualSuffix}.`
}
if (readiness.state === 'generation_failed') {
const modelHint = readiness.probeModel ?? 'the selected model'
const detailSuffix = readiness.detail
? ` Details: ${readiness.detail}.`
: ''
const manualSuffix = options?.allowManualFallback
? ' You can also enter details manually.'
: ''
return `Ollama is reachable and models are installed, but a generation probe failed for ${modelHint}.${detailSuffix} Run "ollama run ${modelHint}" once and retry.${manualSuffix}`
}
return ''
}
type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
type Step =
| { name: 'choose' }
@@ -74,6 +145,14 @@ type Step =
baseUrl: string | null
defaultModel: string
}
| { name: 'mistral-key'; defaultModel: string }
| { name: 'mistral-base'; apiKey: string; defaultModel: string }
| {
name: 'mistral-model'
apiKey: string
baseUrl: string | null
defaultModel: string
}
| { name: 'gemini-auth-method' }
| { name: 'gemini-key' }
| { name: 'gemini-access-token' }
@@ -82,6 +161,7 @@ type Step =
apiKey?: string
authMode: 'api-key' | 'access-token' | 'adc'
}
| { name: 'codex-oauth' }
| { name: 'codex-check' }
type CurrentProviderSummary = {
@@ -116,8 +196,12 @@ type ProviderWizardDefaults = {
openAIModel: string
openAIBaseUrl: string
geminiModel: string
mistralModel: string
mistralBaseUrl: string
}
type SecretSourceEnv = NodeJS.ProcessEnv & Partial<ProfileEnv>
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
const normalized = value.trim().toLowerCase()
@@ -126,7 +210,7 @@ function isEnvTruthy(value: string | undefined): boolean {
function getSafeDisplayValue(
value: string | undefined,
processEnv: NodeJS.ProcessEnv,
processEnv: SecretSourceEnv,
profileEnv?: ProfileEnv,
fallback = '(not set)',
): string {
@@ -138,20 +222,29 @@ function getSafeDisplayValue(
export function getProviderWizardDefaults(
processEnv: NodeJS.ProcessEnv = process.env,
): ProviderWizardDefaults {
const secretSource = processEnv as SecretSourceEnv
const safeOpenAIModel =
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) ||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
'gpt-4o'
const safeOpenAIBaseUrl =
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) ||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
DEFAULT_OPENAI_BASE_URL
const safeGeminiModel =
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) ||
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
DEFAULT_GEMINI_MODEL
const safeMistralModel =
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
DEFAULT_MISTRAL_MODEL
const safeMistralBaseUrl =
sanitizeProviderConfigValue(processEnv.MISTRAL_BASE_URL, processEnv) ||
DEFAULT_MISTRAL_BASE_URL
return {
openAIModel: safeOpenAIModel,
openAIBaseUrl: safeOpenAIBaseUrl,
geminiModel: safeGeminiModel,
mistralModel: safeMistralModel,
mistralBaseUrl: safeMistralBaseUrl,
}
}
@@ -160,6 +253,7 @@ export function buildCurrentProviderSummary(options?: {
persisted?: ProfileFile | null
}): CurrentProviderSummary {
const processEnv = options?.processEnv ?? process.env
const secretSource = processEnv as SecretSourceEnv
const persisted = options?.persisted ?? loadProfileFile()
const savedProfileLabel = persisted?.profile ?? 'none'
@@ -168,11 +262,26 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'Google Gemini',
modelLabel: getSafeDisplayValue(
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
processEnv,
secretSource,
),
endpointLabel: getSafeDisplayValue(
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
processEnv,
secretSource,
),
savedProfileLabel,
}
}
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)) {
return {
providerLabel: 'Mistral',
modelLabel: getSafeDisplayValue(
processEnv.MISTRAL_MODEL ?? DEFAULT_MISTRAL_MODEL,
processEnv
),
endpointLabel: getSafeDisplayValue(
processEnv.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL,
processEnv
),
savedProfileLabel,
}
@@ -183,13 +292,13 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'GitHub Models',
modelLabel: getSafeDisplayValue(
processEnv.OPENAI_MODEL ?? 'github:copilot',
processEnv,
secretSource,
),
endpointLabel: getSafeDisplayValue(
processEnv.OPENAI_BASE_URL ??
processEnv.OPENAI_API_BASE ??
'https://models.github.ai/inference',
processEnv,
secretSource,
),
savedProfileLabel,
}
@@ -210,8 +319,8 @@ export function buildCurrentProviderSummary(options?: {
return {
providerLabel,
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv),
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv),
modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
savedProfileLabel,
}
}
@@ -222,11 +331,11 @@ export function buildCurrentProviderSummary(options?: {
processEnv.ANTHROPIC_MODEL ??
processEnv.CLAUDE_MODEL ??
'claude-sonnet-4-6',
processEnv,
secretSource,
),
endpointLabel: getSafeDisplayValue(
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
processEnv,
secretSource,
),
savedProfileLabel,
}
@@ -259,6 +368,24 @@ function buildSavedProfileSummary(
? 'configured'
: undefined,
}
case 'mistral':
return {
providerLabel: 'Mistral',
modelLabel: getSafeDisplayValue(
env.MISTRAL_MODEL ?? DEFAULT_MISTRAL_MODEL,
process.env,
env,
),
endpointLabel: getSafeDisplayValue(
env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL,
process.env,
env,
),
credentialLabel:
maskSecretForDisplay(env.MISTRAL_API_KEY) !== undefined
? 'configured'
: undefined,
}
case 'codex':
return {
providerLabel: 'Codex',
@@ -322,6 +449,10 @@ export function buildProfileSaveMessage(
profile: ProviderProfile,
env: ProfileEnv,
filePath: string,
options?: {
activatedInSession?: boolean
activationWarning?: string | null
},
): string {
const summary = buildSavedProfileSummary(profile, env)
const lines = [
@@ -335,13 +466,24 @@ export function buildProfileSaveMessage(
}
lines.push(`Profile: ${filePath}`)
lines.push('Restart OpenClaude to use it.')
if (options?.activatedInSession) {
lines.push('OpenClaude switched to it for this session.')
} else if (options?.activationWarning) {
lines.push(
`Saved for next startup. Warning: could not activate it in this session (${options.activationWarning}).`,
)
} else {
lines.push('Restart OpenClaude to use it.')
}
return lines.join('\n')
}
function buildUsageText(): string {
const summary = buildCurrentProviderSummary()
const availableProviders = isBareMode()
? 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a provider profile.'
: 'Choose Auto, Ollama, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.'
return [
'Usage: /provider',
'',
@@ -352,7 +494,7 @@ function buildUsageText(): string {
`Current endpoint: ${summary.endpointLabel}`,
`Saved profile: ${summary.savedProfileLabel}`,
'',
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.',
availableProviders,
].join('\n')
}
@@ -361,12 +503,45 @@ function finishProfileSave(
profile: ProviderProfile,
env: ProfileEnv,
): void {
void saveProfileAndNotify(onDone, profile, env)
}
export function buildCodexOAuthProfileEnv(
tokens: Pick<CodexOAuthTokens, 'accessToken' | 'idToken' | 'accountId'>,
): ProfileEnv | null {
return buildSharedCodexOAuthProfileEnv(tokens)
}
export async function applySavedProfileToCurrentSession(options: {
profileFile: ProfileFile
processEnv?: NodeJS.ProcessEnv
}): Promise<string | null> {
return applySharedProfileToCurrentSession(options)
}
async function saveProfileAndNotify(
onDone: LocalJSXCommandOnDone,
profile: ProviderProfile,
env: ProfileEnv,
): Promise<void> {
try {
const profileFile = createProfileFile(profile, env)
const filePath = saveProfileFile(profileFile)
onDone(buildProfileSaveMessage(profile, env, filePath), {
display: 'system',
})
const shouldActivateInSession = profile === 'codex'
const activationWarning = shouldActivateInSession
? await applySharedProfileToCurrentSession({ profileFile })
: null
onDone(
buildProfileSaveMessage(profile, env, filePath, {
activatedInSession:
shouldActivateInSession && activationWarning === null,
activationWarning,
}),
{
display: 'system',
},
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
onDone(`Failed to save provider profile: ${message}`, {
@@ -450,6 +625,10 @@ function ProviderChooser({
onCancel: () => void
}): React.ReactNode {
const summary = buildCurrentProviderSummary()
const canUseCodexOAuth = !isBareMode()
const helperText = canUseCodexOAuth
? 'Save a provider profile without editing environment variables first. Codex profiles backed by env, auth.json, or OpenClaude secure storage can switch this session immediately when validation succeeds.'
: 'Save a provider profile without editing environment variables first. Codex profiles backed by env or auth.json can switch this session immediately.'
const options: OptionWithDescription<ProviderChoice>[] = [
{
label: 'Auto',
@@ -473,11 +652,26 @@ function ProviderChooser({
value: 'gemini',
description: 'Use Google Gemini with API key, access token, or local ADC',
},
{
label: 'Mistral',
value: 'mistral',
description: 'Use Mistral with API key'
},
{
label: 'Codex',
value: 'codex',
description: 'Use existing ChatGPT Codex CLI auth or env credentials',
},
...(canUseCodexOAuth
? [
{
label: 'Codex OAuth',
value: 'codex-oauth' as const,
description:
'Sign in with ChatGPT in your browser and store Codex tokens securely',
},
]
: []),
]
if (summary.savedProfileLabel !== 'none') {
@@ -495,10 +689,7 @@ function ProviderChooser({
onCancel={onCancel}
>
<Box flexDirection="column" gap={1}>
<Text>
Save a provider profile for the next OpenClaude restart without
editing environment variables first.
</Text>
<Text>{helperText}</Text>
<Box flexDirection="column">
<Text dimColor>Current model: {summary.modelLabel}</Text>
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
@@ -584,6 +775,7 @@ function AutoRecommendationStep({
| {
state: 'openai'
defaultModel: string
reason: string
}
| {
state: 'error'
@@ -597,19 +789,27 @@ function AutoRecommendationStep({
void (async () => {
const defaultModel = getGoalDefaultOpenAIModel(goal)
try {
const ollamaAvailable = await hasLocalOllama()
if (!ollamaAvailable) {
const readiness = await probeOllamaGenerationReadiness()
if (readiness.state !== 'ready') {
if (!cancelled) {
setStatus({ state: 'openai', defaultModel })
setStatus({
state: 'openai',
defaultModel,
reason: describeOllamaReadinessIssue(readiness),
})
}
return
}
const models = await listOllamaModels()
const recommended = recommendOllamaModel(models, goal)
const recommended = recommendOllamaModel(readiness.models, goal)
if (!recommended) {
if (!cancelled) {
setStatus({ state: 'openai', defaultModel })
setStatus({
state: 'openai',
defaultModel,
reason:
'Ollama responded to a generation probe, but no recommended chat model matched this goal.',
})
}
return
}
@@ -650,7 +850,9 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={value => (value === 'back' ? onBack() : onCancel())}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
@@ -663,17 +865,17 @@ function AutoRecommendationStep({
<Dialog title="Auto setup fallback" onCancel={onCancel}>
<Box flexDirection="column" gap={1}>
<Text>
No viable local Ollama chat model was detected. Auto setup can
continue into OpenAI-compatible setup with a default model of{' '}
Auto setup can continue into OpenAI-compatible setup with a default model of{' '}
{status.defaultModel}.
</Text>
<Text dimColor>{status.reason}</Text>
<Select
options={[
{ label: 'Continue to OpenAI-compatible setup', value: 'continue' },
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={value => {
onChange={(value: string) => {
if (value === 'continue') {
onNeedOpenAI(status.defaultModel)
} else if (value === 'back') {
@@ -706,7 +908,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={value => {
onChange={(value: string) => {
if (value === 'save') {
onSave(
'ollama',
@@ -750,32 +952,19 @@ function OllamaModelStep({
let cancelled = false
void (async () => {
const available = await hasLocalOllama()
if (!available) {
const readiness = await probeOllamaGenerationReadiness()
if (readiness.state !== 'ready') {
if (!cancelled) {
setStatus({
state: 'unavailable',
message:
'Could not reach Ollama at http://localhost:11434. Start Ollama first, then run /provider again.',
message: describeOllamaReadinessIssue(readiness),
})
}
return
}
const models = await listOllamaModels()
if (models.length === 0) {
if (!cancelled) {
setStatus({
state: 'unavailable',
message:
'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first.',
})
}
return
}
const ranked = rankOllamaModels(models, 'balanced')
const recommended = recommendOllamaModel(models, 'balanced')
const ranked = rankOllamaModels(readiness.models, 'balanced')
const recommended = recommendOllamaModel(readiness.models, 'balanced')
if (!cancelled) {
setStatus({
state: 'ready',
@@ -808,7 +997,9 @@ function OllamaModelStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={value => (value === 'back' ? onBack() : onCancel())}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
@@ -829,7 +1020,7 @@ function OllamaModelStep({
defaultFocusValue={status.defaultValue}
inlineDescriptions
visibleOptionCount={Math.min(8, status.options.length)}
onChange={value => {
onChange={(value: string) => {
onSave(
'ollama',
buildOllamaProfileEnv(value, {
@@ -844,6 +1035,84 @@ function OllamaModelStep({
)
}
function CodexOAuthStep({
onSave,
onBack,
onCancel,
}: {
onSave: (profile: ProviderProfile, env: ProfileEnv) => void
onBack: () => void
onCancel: () => void
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (
tokens: CodexOAuthTokens,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
const env = buildCodexOAuthProfileEnv(tokens)
if (!env) {
throw new Error(
'Codex OAuth succeeded, but OpenClaude could not build a Codex profile from the stored credentials.',
)
}
persistCredentials()
onSave('codex', env)
}, [onSave])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Dialog title="Codex OAuth failed" onCancel={onCancel} color="warning">
<Box flexDirection="column" gap={1}>
<Text>{status.message}</Text>
<Select
options={[
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
</Dialog>
)
}
if (status.state === 'starting') {
return <LoadingState message="Starting Codex OAuth..." />
}
return (
<Dialog title="Codex OAuth" onCancel={onBack}>
<Box flexDirection="column" gap={1}>
<Text>
Finish signing in with ChatGPT in your browser. OpenClaude will store
the resulting Codex credentials securely for future sessions.
</Text>
{status.browserOpened === false ? (
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
) : status.browserOpened === true ? (
<Text dimColor>
Browser opened. Complete the sign-in there, then OpenClaude will
finish setup automatically.
</Text>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text>{status.authUrl}</Text>
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
</Dialog>
)
}
function CodexCredentialStep({
onSave,
onBack,
@@ -865,7 +1134,9 @@ function CodexCredentialStep({
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={value => (value === 'back' ? onBack() : onCancel())}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
@@ -899,9 +1170,10 @@ function CodexCredentialStep({
defaultFocusValue="codexplan"
inlineDescriptions
visibleOptionCount={options.length}
onChange={value => {
onChange={(value: string) => {
const env = buildCodexProfileEnv({
model: value,
credentialSource: credentials.credentialSource,
processEnv: process.env,
})
if (env) {
@@ -916,9 +1188,16 @@ function CodexCredentialStep({
}
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
| { ok: true; sourceDescription: string }
| {
ok: true
sourceDescription: string
credentialSource: 'oauth' | 'existing'
}
| { ok: false; message: string } {
const credentials = resolveCodexApiCredentials(processEnv)
const oauthHint = isBareMode()
? 'Re-login with the Codex CLI'
: 'Choose Codex OAuth in /provider, or re-login with the Codex CLI'
if (!credentials.apiKey) {
const authHint = credentials.authPath
@@ -926,7 +1205,7 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
: 'Set CODEX_API_KEY or re-login with the Codex CLI.'
return {
ok: false,
message: `Codex setup needs existing credentials. Re-login with the Codex CLI or set CODEX_API_KEY. ${authHint}`,
message: `Codex setup needs existing credentials. ${oauthHint}, or set CODEX_API_KEY. ${authHint}`,
}
}
@@ -934,15 +1213,19 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
return {
ok: false,
message:
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.',
`Codex auth is missing chatgpt_account_id. ${oauthHint}, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.`,
}
}
return {
ok: true,
credentialSource:
credentials.source === 'secure-storage' ? 'oauth' : 'existing',
sourceDescription:
credentials.source === 'env'
? 'the current shell environment'
: credentials.source === 'secure-storage'
? 'OpenClaude secure storage'
: credentials.authPath ?? DEFAULT_CODEX_BASE_URL,
}
}
@@ -971,6 +1254,13 @@ export function ProviderWizard({
})
} else if (value === 'gemini') {
setStep({ name: 'gemini-auth-method' })
} else if (value === 'mistral') {
setStep({
name: 'mistral-key',
defaultModel: defaults.mistralModel,
})
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
} else if (value === 'clear') {
const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1110,6 +1400,101 @@ export function ProviderWizard({
/>
)
case 'mistral-key':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
subtitle="Step 1 of 3"
description={
process.env.MISTRAL_API_KEY
? 'Enter an API key, or leave this blank to reuse the current MISTRAL_API_KEY from this session.'
: 'Enter the API key for your Mistral provider.'
}
initialValue=""
placeholder="..."
mask="*"
allowEmpty={Boolean(process.env.MISTRAL_API_KEY)}
validate={value => {
const candidate = value.trim() || process.env.MISTRAL_API_KEY || ''
return sanitizeApiKey(candidate)
? null
: 'Enter a real API key. Placeholder values like SUA_CHAVE are not valid.'
}}
onSubmit={value => {
const apiKey = value.trim() || process.env.MISTRAL_API_KEY || ''
setStep({
name: 'mistral-base',
apiKey,
defaultModel: step.defaultModel,
})
}}
onCancel={() => setStep({ name: 'choose' })}
/>
)
case 'mistral-base':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
subtitle="Step 2 of 3"
description={`Optionally enter a base URL. Leave blank for ${DEFAULT_MISTRAL_BASE_URL}.`}
initialValue={
defaults.mistralBaseUrl === DEFAULT_MISTRAL_BASE_URL
? ''
: defaults.mistralBaseUrl
}
placeholder={DEFAULT_MISTRAL_BASE_URL}
allowEmpty
onSubmit={value => {
setStep({
name: 'mistral-model',
apiKey: step.apiKey,
baseUrl: value.trim() || null,
defaultModel: step.defaultModel,
})
}}
onCancel={() =>
setStep({
name: 'mistral-key',
defaultModel: step.defaultModel,
})
}
/>
)
case 'mistral-model':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
subtitle="Step 3 of 3"
description={`Enter a model name. Leave blank for ${step.defaultModel}.`}
initialValue={defaults.mistralModel ?? step.defaultModel}
placeholder={step.defaultModel}
allowEmpty
onSubmit={value => {
const env = buildMistralProfileEnv({
model: value.trim() || step.defaultModel,
baseUrl: step.baseUrl,
apiKey: step.apiKey,
processEnv: process.env,
})
if (env) {
finishProfileSave(onDone, 'mistral', env)
}
}}
onCancel={() =>
setStep({
name: 'mistral-base',
apiKey: step.apiKey,
defaultModel: step.defaultModel,
})
}
/>
)
case 'gemini-auth-method': {
const hasShellGeminiKey = Boolean(
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
@@ -1155,7 +1540,7 @@ export function ProviderWizard({
options={options}
inlineDescriptions
visibleOptionCount={options.length}
onChange={value => {
onChange={(value: string) => {
if (value === 'api-key') {
setStep({ name: 'gemini-key' })
} else if (value === 'access-token') {
@@ -1311,6 +1696,15 @@ export function ProviderWizard({
onCancel={() => onDone()}
/>
)
case 'codex-oauth':
return (
<CodexOAuthStep
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
onBack={() => setStep({ name: 'choose' })}
onCancel={() => onDone()}
/>
)
}
}
@@ -1335,13 +1729,8 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
<ProviderManager
mode="manage"
onDone={result => {
const message =
result?.message ??
(result?.action === 'saved'
? 'Provider profile updated'
: 'Provider manager closed')
onDone(message, { display: 'system' })
const { message, metaMessages } = buildProviderManagerCompletion(result)
onDone(message, { display: 'system', metaMessages })
}}
/>
)

View File

@@ -6,7 +6,7 @@ const web = {
type: 'local-jsx',
name: 'web-setup',
description:
'Setup Claude Code on the web (requires connecting your GitHub account)',
'Setup OpenClaude on the web (requires connecting your GitHub account)',
availability: ['claude-ai'],
isEnabled: () =>
getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&

View File

@@ -48,7 +48,7 @@ const review: Command = {
const ultrareview: Command = {
type: 'local-jsx',
name: 'ultrareview',
description: `~1020 min · Finds and verifies bugs in your branch. Runs in Claude Code on the web. See ${CCR_TERMS_URL}`,
description: `~1020 min · Finds and verifies bugs in your branch. Runs in OpenClaude on the web. See ${CCR_TERMS_URL}`,
isEnabled: () => isUltrareviewEnabled(),
load: () => import('./review/ultrareviewCommand.js'),
}

View File

@@ -65,7 +65,7 @@ export async function call(onDone: (result?: string) => void, _context: unknown,
// Get the local settings path and make it relative to cwd
const localSettingsPath = getSettingsFilePathForSource('localSettings');
const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.claude/settings.local.json';
const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.openclaude/settings.local.json';
const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`);
onDone(message);
return null;

View File

@@ -57,7 +57,7 @@ function SessionInfo(t0) {
if (!remoteSessionUrl) {
let t4;
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
t4 = <Pane><Text color="warning">Not in remote mode. Start with `claude --remote` to use this command.</Text><Text dimColor={true}>(press esc to close)</Text></Pane>;
t4 = <Pane><Text color="warning">Not in remote mode. Start with `openclaude --remote` to use this command.</Text><Text dimColor={true}>(press esc to close)</Text></Pane>;
$[4] = t4;
} else {
t4 = $[4];

View File

@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
const stats = {
type: 'local-jsx',
name: 'stats',
description: 'Show your Claude Code usage statistics and activity',
description: 'Show your OpenClaude usage statistics and activity',
load: () => import('./stats.js'),
} satisfies Command

View File

@@ -4,7 +4,7 @@ const status = {
type: 'local-jsx',
name: 'status',
description:
'Show Claude Code status including version, model, account, API connectivity, and tool statuses',
'Show OpenClaude status including version, model, account, API connectivity, and tool statuses',
immediate: true,
load: () => import('./status.js'),
} satisfies Command

View File

@@ -3,7 +3,7 @@ import type { Command } from '../commands.js';
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js';
const statusline = {
type: 'prompt',
description: "Set up Claude Code's status line UI",
description: "Set up OpenClaude's status line UI",
contentLength: 0,
// Dynamic content
aliases: [],

View File

@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
const stickers = {
type: 'local',
name: 'stickers',
description: 'Order Claude Code stickers',
description: 'Order OpenClaude stickers',
supportsNonInteractive: false,
load: () => import('./stickers.js'),
} satisfies Command

View File

@@ -4,7 +4,7 @@ import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/anal
const thinkback = {
type: 'local-jsx',
name: 'think-back',
description: 'Your 2025 Claude Code Year in Review',
description: 'Your 2025 OpenClaude Year in Review',
isEnabled: () =>
checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_thinkback'),
load: () => import('./thinkback.js'),

View File

@@ -115,7 +115,7 @@ function startDetachedPoll(taskId: string, sessionId: string, url: string, getAp
ultraplanSessionUrl: undefined
} : prev);
enqueuePendingNotification({
value: [`Ultraplan approved — executing in Claude Code on the web. Follow along at: ${url}`, '', 'Results will land as a pull request when the remote session finishes. There is nothing to do here.'].join('\n'),
value: [`Ultraplan approved — executing in OpenClaude on the web. Follow along at: ${url}`, '', 'Results will land as a pull request when the remote session finishes. There is nothing to do here.'].join('\n'),
mode: 'task-notification'
});
} else {
@@ -184,10 +184,10 @@ function startDetachedPoll(taskId: string, sessionId: string, url: string, getAp
// multi-second teleportToRemote round-trip.
function buildLaunchMessage(disconnectedBridge?: boolean): string {
const prefix = disconnectedBridge ? `${REMOTE_CONTROL_DISCONNECTED_MSG} ` : '';
return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting Claude Code on the web…`;
return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting OpenClaude on the web…`;
}
function buildSessionReadyMessage(url: string): string {
return `${DIAMOND_OPEN} ultraplan · Monitor progress in Claude Code on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results`;
return `${DIAMOND_OPEN} ultraplan · Monitor progress in OpenClaude on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results`;
}
function buildAlreadyActiveMessage(url: string | undefined): string {
return url ? `ultraplan: already polling. Open ${url} to check status, or wait for the plan to land here.` : 'ultraplan: already launching. Please wait for the session to start.';
@@ -272,7 +272,7 @@ export async function launchUltraplan(opts: {
return [
// Rendered via <Markdown>; raw <message> is tokenized as HTML
// and dropped. Backslash-escape the brackets.
'Usage: /ultraplan \\<prompt\\>, or include "ultraplan" anywhere', 'in your prompt', '', 'Advanced multi-agent plan mode with our most powerful model', '(Opus). Runs in Claude Code on the web. When the plan is ready,', 'you can execute it in the web session or send it back here.', 'Terminal stays free while the remote plans.', 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`].join('\n');
'Usage: /ultraplan \\<prompt\\>, or include "ultraplan" anywhere', 'in your prompt', '', 'Advanced multi-agent plan mode with our most powerful model', '(Opus). Runs in OpenClaude on the web. When the plan is ready,', 'you can execute it in the web session or send it back here.', 'Terminal stays free while the remote plans.', 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`].join('\n');
}
// Set synchronously before the detached flow to prevent duplicate launches
@@ -461,7 +461,7 @@ const call: LocalJSXCommandCall = async (onDone, context, args) => {
export default {
type: 'local-jsx',
name: 'ultraplan',
description: `~1030 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
description: `~1030 min · OpenClaude on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
argumentHint: '<prompt>',
isEnabled: () => "external" === 'ant',
load: () => Promise.resolve({

View File

@@ -4,6 +4,5 @@ export default {
type: 'local-jsx',
name: 'usage',
description: 'Show plan usage limits',
availability: ['claude-ai'],
load: () => import('./usage.js'),
} satisfies Command

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const wiki = {
type: 'local-jsx',
name: 'wiki',
description: 'Initialize and inspect the OpenClaude project wiki',
argumentHint: '[init|status]',
immediate: true,
load: () => import('./wiki.js'),
} satisfies Command
export default wiki

123
src/commands/wiki/wiki.tsx Normal file
View File

@@ -0,0 +1,123 @@
import React from 'react'
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
import { ingestLocalWikiSource } from '../../services/wiki/ingest.js'
import { initializeWiki } from '../../services/wiki/init.js'
import { getWikiStatus } from '../../services/wiki/status.js'
import type {
LocalJSXCommandCall,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { getCwd } from '../../utils/cwd.js'
function renderHelp(): string {
return `Usage: /wiki [init|status|ingest <path>]
Manage the OpenClaude project wiki stored in .openclaude/wiki.
Commands:
/wiki init Initialize the wiki structure in the current project
/wiki status Show wiki status and page/source counts
/wiki ingest Ingest a local file into wiki sources
Examples:
/wiki init
/wiki status
/wiki ingest README.md`
}
function formatInitResult(result: Awaited<ReturnType<typeof initializeWiki>>): string {
const lines = [`Initialized OpenClaude wiki at ${result.root}`]
if (result.alreadyExisted) {
lines.push('', 'Wiki already existed. No new files were created.')
return lines.join('\n')
}
if (result.createdFiles.length > 0) {
lines.push('', 'Created files:')
for (const file of result.createdFiles) {
lines.push(`- ${file}`)
}
}
return lines.join('\n')
}
function formatStatus(status: Awaited<ReturnType<typeof getWikiStatus>>): string {
if (!status.initialized) {
return `OpenClaude wiki is not initialized in this project.\n\nRun /wiki init to create ${status.root}.`
}
return [
'OpenClaude wiki status',
'',
`Root: ${status.root}`,
`Pages: ${status.pageCount}`,
`Sources: ${status.sourceCount}`,
`Schema: ${status.hasSchema ? 'present' : 'missing'}`,
`Index: ${status.hasIndex ? 'present' : 'missing'}`,
`Log: ${status.hasLog ? 'present' : 'missing'}`,
`Last updated: ${status.lastUpdatedAt ?? 'unknown'}`,
].join('\n')
}
function formatIngestResult(
result: Awaited<ReturnType<typeof ingestLocalWikiSource>>,
): string {
return [
`Ingested ${result.sourceFile} into the OpenClaude wiki.`,
'',
`Title: ${result.title}`,
`Source note: ${result.sourceNote}`,
`Summary: ${result.summary}`,
].join('\n')
}
async function runWikiCommand(
onDone: LocalJSXCommandOnDone,
args: string,
): Promise<void> {
const cwd = getCwd()
const normalized = args.trim().toLowerCase()
if (COMMON_HELP_ARGS.includes(normalized) || COMMON_INFO_ARGS.includes(normalized)) {
onDone(renderHelp(), { display: 'system' })
return
}
if (!normalized || normalized === 'status') {
onDone(formatStatus(await getWikiStatus(cwd)), { display: 'system' })
return
}
if (normalized === 'init') {
onDone(formatInitResult(await initializeWiki(cwd)), { display: 'system' })
return
}
if (normalized.startsWith('ingest')) {
const pathArg = args.trim().slice('ingest'.length).trim()
if (!pathArg) {
onDone('Usage: /wiki ingest <local-file-path>', { display: 'system' })
return
}
onDone(formatIngestResult(await ingestLocalWikiSource(cwd, pathArg)), {
display: 'system',
})
return
}
onDone(`Unknown wiki subcommand: ${args.trim()}\n\n${renderHelp()}`, {
display: 'system',
})
}
export const call: LocalJSXCommandCall = async (
onDone,
_context,
args,
): Promise<React.ReactNode> => {
await runWikiCommand(onDone, args ?? '')
return null
}

View File

@@ -188,9 +188,9 @@ export function AutoUpdater({
Update installed · Restart to apply
</Text>}
{(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && <Text color="error" wrap="truncate">
Auto-update failed &middot; Try <Text bold>claude doctor</Text> or{' '}
Auto-update failed &middot; Try <Text bold>openclaude doctor</Text> or{' '}
<Text bold>
{hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`}
{hasLocalInstall ? `cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`}
</Text>
</Text>}
</Box>;

View File

@@ -31,9 +31,11 @@ export function BaseTextInput(t0) {
} = t0;
const {
onInput,
value,
renderedValue,
cursorLine,
cursorColumn
cursorColumn,
offset,
} = inputState;
const t1 = Boolean(props.focus && props.showCursor && terminalFocus);
let t2;
@@ -78,7 +80,7 @@ export function BaseTextInput(t0) {
renderedPlaceholder
} = renderPlaceholder({
placeholder: props.placeholder,
value: props.value,
value,
showCursor: props.showCursor,
focus: props.focus,
terminalFocus,
@@ -88,9 +90,9 @@ export function BaseTextInput(t0) {
useInput(wrappedOnInput, {
isActive: props.focus
});
const commandWithoutArgs = props.value && props.value.trim().indexOf(" ") === -1 || props.value && props.value.endsWith(" ");
const showArgumentHint = Boolean(props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith("/"));
const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end) : props.highlights;
const commandWithoutArgs = value && value.trim().indexOf(" ") === -1 || value && value.endsWith(" ");
const showArgumentHint = Boolean(props.argumentHint && value && commandWithoutArgs && value.startsWith("/"));
const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || offset < h.start || offset >= h.end) : props.highlights;
const {
viewportCharOffset,
viewportCharEnd
@@ -102,13 +104,13 @@ export function BaseTextInput(t0) {
})) : cursorFiltered;
const hasHighlights = filteredHighlights && filteredHighlights.length > 0;
if (hasHighlights) {
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
return <Box ref={cursorRef}><HighlightedInput text={renderedValue} highlights={filteredHighlights} />{showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>}{children}</Box>;
}
const T0 = Box;
const T1 = Text;
const t4 = "truncate-end";
const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? <Ansi>{renderedPlaceholder}</Ansi> : <Ansi>{renderedValue}</Ansi>;
const t6 = showArgumentHint && <Text dimColor={true}>{props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
const t6 = showArgumentHint && <Text dimColor={true}>{value.endsWith(" ") ? "" : " "}{props.argumentHint}</Text>;
let t7;
if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) {
t7 = <T1 wrap={t4} dimColor={props.dimColor}>{t5}{t6}{children}</T1>;

View File

@@ -56,7 +56,7 @@ export function ClaudeInChromeOnboarding(t0) {
}
let t5;
if ($[6] !== t4) {
t5 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.{t4}</Text>;
t5 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from OpenClaude. You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.{t4}</Text>;
$[6] = t4;
$[7] = t5;
} else {

View File

@@ -103,7 +103,7 @@ test('login picker shows the third-party platform option', async () => {
expect(output).toContain('3rd-party platform')
})
test('third-party provider branch opens the provider wizard', async () => {
test('third-party provider branch opens the first-run provider manager', async () => {
const output = await renderFrame(
<ConsoleOAuthFlow
initialStatus={{ state: 'platform_setup' }}
@@ -111,7 +111,11 @@ test('third-party provider branch opens the provider wizard', async () => {
/>,
)
expect(output).toContain('Set up a provider profile')
expect(output).toContain('OpenAI-compatible')
expect(output).toContain('Ollama')
expect(output).toContain('Set up provider')
// Use alphabetically-early sentinels so they remain visible in the
// 13-row test frame after the provider list was sorted A→Z.
expect(output).toContain('Anthropic')
expect(output).toContain('Azure OpenAI')
expect(output).toContain('DeepSeek')
expect(output).toContain('Google Gemini')
})

View File

@@ -12,7 +12,7 @@ import { OAuthService } from '../services/oauth/index.js';
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
import { logError } from '../utils/log.js';
import { getSettings_DEPRECATED } from '../utils/settings/settings.js';
import { ProviderWizard } from '../commands/provider/provider.js';
import { ProviderManager } from './ProviderManager.js';
import { Select } from './CustomSelect/select.js';
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
import { Spinner } from './Spinner.js';
@@ -262,7 +262,7 @@ export function ConsoleOAuthFlow({
state: 'success'
});
void sendNotification({
message: 'Claude Code login successful',
message: 'OpenClaude login successful',
notificationType: 'auth_success'
}, terminal);
}
@@ -384,7 +384,7 @@ function OAuthStatusMessage({
case 'idle': {
const promptText =
startingMessage ||
'Claude Code can be used with your Claude subscription or billed based on API usage through your Console account.'
'OpenClaude can be used with your Claude subscription or billed based on API usage through your Console account.'
const loginOptions = [
{
@@ -450,16 +450,17 @@ function OAuthStatusMessage({
case 'platform_setup':
return (
<ProviderWizard
<ProviderManager
mode="first-run"
onDone={result => {
if (!result) {
if (!result || result.action !== 'saved' || !result.message) {
setOAuthStatus({ state: 'idle' })
return
}
setOAuthStatus({
state: 'platform_setup_complete',
message: result,
message: result.message,
})
}}
/>
@@ -511,7 +512,7 @@ function OAuthStatusMessage({
<Box flexDirection="column" gap={1}>
<Box>
<Spinner />
<Text>Creating API key for Claude Code</Text>
<Text>Creating API key for OpenClaude</Text>
</Box>
</Box>
)

View File

@@ -285,7 +285,7 @@ export function Select(t0) {
onChange,
onCancel,
onFocus,
focusValue: defaultFocusValue
defaultFocusValue,
};
$[7] = defaultFocusValue;
$[8] = defaultValue;

View File

@@ -1,5 +1,4 @@
import { useCallback, useState } from 'react'
import { isDeepStrictEqual } from 'util'
import { useRegisterOverlay } from '../../context/overlayContext.js'
import type { InputEvent } from '../../ink/events/input-event.js'
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw space/arrow multiselect input
@@ -9,6 +8,7 @@ import {
normalizeFullWidthSpace,
} from '../../utils/stringUtils.js'
import type { OptionWithDescription } from './select.js'
import { optionsNavigateEqual } from './use-select-navigation.js'
import { useSelectNavigation } from './use-select-navigation.js'
export type UseMultiSelectStateProps<T> = {
@@ -174,7 +174,7 @@ export function useMultiSelectState<T>({
// and the deleted ui/useMultiSelectState.ts — without this, MCPServerDesktopImportDialog
// keeps colliding servers checked after getAllMcpConfigs() resolves.
const [lastOptions, setLastOptions] = useState(options)
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
if (options !== lastOptions && !optionsNavigateEqual(options, lastOptions)) {
setSelectedValues(defaultValue)
setLastOptions(options)
}

View File

@@ -6,10 +6,34 @@ import {
useRef,
useState,
} from 'react'
import { isDeepStrictEqual } from 'util'
import OptionMap from './option-map.js'
import type { OptionWithDescription } from './select.js'
/**
* Compare two option arrays for structural equality on properties that
* affect navigation behavior. ReactNode `label` and function `onChange`
* are intentionally excluded — they are identity-unstable (new reference
* each render) but don't change navigation semantics.
*/
export function optionsNavigateEqual<T>(
a: OptionWithDescription<T>[],
b: OptionWithDescription<T>[],
): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
const ao = a[i]!
const bo = b[i]!
if (
ao.value !== bo.value ||
ao.disabled !== bo.disabled ||
ao.type !== bo.type
) {
return false
}
}
return true
}
type State<T> = {
/**
* Map where key is option's value and value is option's index.
@@ -524,7 +548,7 @@ export function useSelectNavigation<T>({
const [lastOptions, setLastOptions] = useState(options)
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
if (options !== lastOptions && !optionsNavigateEqual(options, lastOptions)) {
dispatch({
type: 'reset',
state: createDefaultState({

Some files were not shown because too many files have changed in this diff Show More